feat(desktop): 实现MD文件模板导出方式

This commit is contained in:
2026-01-11 22:54:46 +08:00
parent cb7a1ba6d8
commit 3f7347427e
19 changed files with 857 additions and 180 deletions

BIN
db.sqlite

Binary file not shown.

View File

@@ -43,3 +43,8 @@ publish:
url: https://example.com/auto-updates url: https://example.com/auto-updates
electronDownload: electronDownload:
mirror: https://npmmirror.com/mirrors/electron/ mirror: https://npmmirror.com/mirrors/electron/
extraResources:
- from: resources/templates
to: templates
filter:
- "**/*"

Binary file not shown.

View File

@@ -0,0 +1,13 @@
# {{data.title}}
> **导出日期**: {{date}}
> **作者**: {{author}}
## 📑 核心摘要
{{data.summary}}
## 💡 深度心得
{{data.content}}
---
*Generated by AI Reading Assistant*

View File

@@ -0,0 +1 @@
[{ "id": "tpl_001", "name": "精致黑白排版", "file": "default.md" }]

View File

@@ -9,6 +9,9 @@ import { IUserReadingPersona } from '@shared/types/IUserReadingPersona'
export class PersonaService { export class PersonaService {
constructor(private personaRepo: Repository<ReadingPersona>) {} constructor(private personaRepo: Repository<ReadingPersona>) {}
/**
* 刷新画像并保存到数据库
*/
/** /**
* 刷新画像并保存到数据库 * 刷新画像并保存到数据库
*/ */
@@ -16,22 +19,30 @@ export class PersonaService {
items: IReadingReflectionTaskItem[], items: IReadingReflectionTaskItem[],
batches: IReadingReflectionTaskBatch[] batches: IReadingReflectionTaskBatch[]
) { ) {
const rawResult = await this.calculatePersona(items, batches) // 调用你原来的计算逻辑 // 1. 获取计算结果
const rawResult = await this.calculatePersona(items, batches)
// 2. 创建或更新实体
const persona = new ReadingPersona() const persona = new ReadingPersona()
persona.id = 'current_user_persona' persona.id = 'current_user_persona'
// 核心分值映射
persona.cognition = rawResult.cognition persona.cognition = rawResult.cognition
persona.breadth = rawResult.breadth persona.breadth = rawResult.breadth
persona.practicality = rawResult.practicality persona.practicality = rawResult.practicality
persona.output = rawResult.output persona.output = rawResult.output
persona.global = rawResult.global persona.global = rawResult.global
persona.topKeywords = JSON.stringify(rawResult.topKeywords)
// 存储完整的 stats 结构以便前端适配 // ✨ 修复关键点:从 rawResult.stats 中获取 topKeywords
// 因为 calculatePersona 返回的是 { stats: { topKeywords: [...] } }
persona.topKeywords = JSON.stringify(rawResult.stats.topKeywords)
// 3. 存储完整的 rawStats 结构,确保与前端接口定义对齐
persona.rawStats = { persona.rawStats = {
totalWords: items.reduce((sum, i) => sum + (i.content?.length || 0), 0), totalWords: rawResult.stats.totalWords,
totalBooks: batches.length, totalBooks: rawResult.stats.totalBooks,
topKeywords: rawResult.topKeywords totalHours: rawResult.stats.totalHours, // 别忘了我们在 calculatePersona 补充的专注时长
topKeywords: rawResult.stats.topKeywords
} }
return await this.personaRepo.save(persona) return await this.personaRepo.save(persona)
@@ -43,22 +54,44 @@ export class PersonaService {
items: IReadingReflectionTaskItem[], items: IReadingReflectionTaskItem[],
batches: IReadingReflectionTaskBatch[] batches: IReadingReflectionTaskBatch[]
) { ) {
// 1. 计算认知深度:根据关键词频次 const totalBooks = batches.length
const totalWords = items.reduce((sum, i) => sum + (i.content?.length || 0), 0)
// --- 1. 认知深度 (Cognition) ---
const allKeywords = items.flatMap((i) => i.keywords || []) const allKeywords = items.flatMap((i) => i.keywords || [])
const keywordMap = new Map<string, number>() const keywordMap = new Map<string, number>()
allKeywords.forEach((k) => keywordMap.set(k, (keywordMap.get(k) || 0) + 1)) allKeywords.forEach((k) => keywordMap.set(k, (keywordMap.get(k) || 0) + 1))
const cognitionScore = Math.min(100, keywordMap.size * 1.5 + allKeywords.length / 8)
// 逻辑:去重后的关键词越多且重复越高,分值越高 (示例算法) // --- 2. 知识广度 (Breadth) - 修复 TS2339 ---
const cognitionScore = Math.min(100, keywordMap.size * 2 + allKeywords.length / 5) // 逻辑:如果 batch 没分类,就看有多少个独立的高频关键词,这代表了涉及的主题广度
const uniqueThemes = new Set(batches.map((b) => (b as any).category).filter(Boolean))
// 2. 计算知识广度:根据书籍数量 let breadthScore = 0
const breadthScore = Math.min(100, batches.length * 10) if (uniqueThemes.size > 0) {
// 如果有分类数据,按分类算
breadthScore = Math.min(100, uniqueThemes.size * 20 + totalBooks * 2)
} else {
// 如果没分类数据,按关键词覆盖面算(每 5 个独立关键词视为一个知识领域)
breadthScore = Math.min(100, (keywordMap.size / 5) * 15 + totalBooks * 2)
}
// 3. 计算产出效率:根据总字数 // --- 3. 语言能力与全球化 (Global) ---
const totalWords = items.reduce((sum, i) => sum + (i.content?.length || 0), 0) const langDist = items.reduce(
const outputScore = Math.min(100, totalWords / 500) // 每 5万字满分 (acc, curr) => {
// 兼容处理:如果 curr.language 不存在则默认为 'zh'
const lang = (curr as any).language || 'zh'
acc[lang] = (acc[lang] || 0) + 1
return acc
},
{} as Record<string, number>
)
// 计算英文占比分:有英文记录就从 60 分起跳,最高 100
const globalScore = langDist['en'] ? Math.min(100, 60 + langDist['en'] * 5) : 50
// --- 4. 专注时长 (Total Hours) ---
const totalHours = Math.round((totalWords / 1000) * 1.5 + totalBooks)
// 4. 计算 Top 10 关键词
const sortedKeywords = [...keywordMap.entries()] const sortedKeywords = [...keywordMap.entries()]
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.slice(0, 10) .slice(0, 10)
@@ -67,10 +100,15 @@ export class PersonaService {
return { return {
cognition: Math.round(cognitionScore), cognition: Math.round(cognitionScore),
breadth: Math.round(breadthScore), breadth: Math.round(breadthScore),
output: Math.round(outputScore), output: Math.min(100, Math.round(totalWords / 500)),
practicality: 75, // 可根据 occupation 比例动态计算 practicality: 75,
global: 60, // 可根据 language 比例动态计算 global: Math.round(globalScore),
topKeywords: sortedKeywords stats: {
totalWords,
totalBooks,
totalHours,
topKeywords: sortedKeywords
}
} }
} }
} }

View File

@@ -31,6 +31,7 @@ declare module 'vue' {
ATag: typeof import('@arco-design/web-vue')['Tag'] ATag: typeof import('@arco-design/web-vue')['Tag']
ATextarea: typeof import('@arco-design/web-vue')['Textarea'] ATextarea: typeof import('@arco-design/web-vue')['Textarea']
ATooltip: typeof import('@arco-design/web-vue')['Tooltip'] ATooltip: typeof import('@arco-design/web-vue')['Tooltip']
AWatermark: typeof import('@arco-design/web-vue')['Watermark']
BackPage: typeof import('./src/components/BackPage.vue')['default'] BackPage: typeof import('./src/components/BackPage.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']

View File

@@ -1,12 +1,4 @@
import { import { DatabaseConfig, DocumentFolder, Headset, Search, TrendTwo } from '@icon-park/vue-next'
DatabaseConfig,
DocumentFolder,
FileCode,
Headset,
Refresh,
Search,
TrendTwo
} from '@icon-park/vue-next'
export const features = [ export const features = [
{ {
@@ -33,22 +25,6 @@ export const features = [
{ {
group: '自动化与导出', group: '自动化与导出',
items: [ items: [
{
id: 'obsidian',
title: 'Obsidian 同步',
desc: '自动同步至本地双链笔记库',
icon: Refresh,
color: '#165dff',
path: 'sync'
},
{
id: 'export',
title: '批量导出',
icon: FileCode,
desc: '导出 PDF / Markdown 格式',
color: '#ff7d00',
path: 'export'
},
{ {
id: 'monitor', id: 'monitor',
title: '书库监控', title: '书库监控',

View File

@@ -4,6 +4,7 @@ import dayjs from 'dayjs'
import { trpc } from '@renderer/lib/trpc' import { trpc } from '@renderer/lib/trpc'
import ReflectionDrawer from './ReflectionDrawer.vue' import ReflectionDrawer from './ReflectionDrawer.vue'
import { IContributionDay } from '@shared/types/IUserReadingPersona' import { IContributionDay } from '@shared/types/IUserReadingPersona'
import { BabyFeet } from '@icon-park/vue-next'
const props = defineProps<{ const props = defineProps<{
data: { date: string; count: number }[] data: { date: string; count: number }[]
@@ -14,27 +15,26 @@ const selectedDate = ref('')
const dailyReflections = ref<any[]>([]) const dailyReflections = ref<any[]>([])
const isDetailLoading = ref(false) const isDetailLoading = ref(false)
// 1. 核心计算逻辑:生成日期矩阵 // 新增:时间范围状态 (90天 / 180天 / 365天)
const timeRange = ref(90)
// 1. 核心计算逻辑:根据选择的范围生成日期矩阵
const calendar = computed(() => { const calendar = computed(() => {
const end = dayjs() const end = dayjs()
const start = end.subtract(1, 'year').startOf('week') // 根据 timeRange 动态计算起始时间
const start = end.subtract(timeRange.value, 'day').startOf('week')
// 2. 关键修复:显式指定数组类型为 ICalendarDay[]
const days: IContributionDay[] = [] const days: IContributionDay[] = []
const dataMap = new Map<string, number>() const dataMap = new Map<string, number>()
props.data.forEach((item) => { props.data.forEach((item) => {
if (item.date) { if (item.date) dataMap.set(item.date.trim(), Number(item.count))
dataMap.set(item.date.trim(), Number(item.count))
}
}) })
let current = start let current = start
while (current.isBefore(end) || current.isSame(end, 'day')) { while (current.isBefore(end) || current.isSame(end, 'day')) {
const dateStr = current.format('YYYY-MM-DD') const dateStr = current.format('YYYY-MM-DD')
const count = dataMap.get(dateStr) || 0 const count = dataMap.get(dateStr) || 0
// 3. 现在 push 操作是类型安全的
days.push({ days.push({
date: dateStr, date: dateStr,
count: count, count: count,
@@ -45,29 +45,36 @@ const calendar = computed(() => {
return days return days
}) })
// 计算月份标记位(只在每月的第一个周展示)
const monthLabels = computed(() => {
const labels: { text: string; index: number }[] = []
let lastMonth = -1
// 每 7 个方块为一列
for (let i = 0; i < calendar.value.length; i += 7) {
const m = dayjs(calendar.value[i].date).month()
if (m !== lastMonth) {
labels.push({ text: dayjs(calendar.value[i].date).format('MMM'), index: i / 7 })
lastMonth = m
}
}
return labels
})
const getLevelClass = (level: number) => { const getLevelClass = (level: number) => {
const levels = [ const levels = ['bg-slate-100', 'bg-purple-200', 'bg-purple-400', 'bg-purple-600', 'bg-[#7816ff]']
'bg-slate-100', // 0
'bg-purple-200', // 1
'bg-purple-400', // 2
'bg-purple-600', // 3
'bg-[#7816ff]' // 4
]
return levels[level] return levels[level]
} }
// 2. 点击小方块处理
const handleDayClick = async (day: { date: string; count: number }) => { const handleDayClick = async (day: { date: string; count: number }) => {
if (day.count === 0) return if (day.count === 0) return
selectedDate.value = day.date selectedDate.value = day.date
drawerVisible.value = true drawerVisible.value = true
isDetailLoading.value = true isDetailLoading.value = true
try { try {
dailyReflections.value = await trpc.persona.getReflectionsByDate.query({ date: day.date }) dailyReflections.value = await trpc.persona.getReflectionsByDate.query({ date: day.date })
} catch (err) { } catch (err) {
console.error('获取详情失败:', err) console.error('详情失败:', err)
} finally { } finally {
isDetailLoading.value = false isDetailLoading.value = false
} }
@@ -75,49 +82,103 @@ const handleDayClick = async (day: { date: string; count: number }) => {
</script> </script>
<template> <template>
<div class="bg-white p-6 rounded-[24px] border border-slate-100 mt-6 shadow-sm"> <div class="bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
<div class="flex items-center justify-between mb-4"> <div class="flex flex-row justify-between">
<div class="flex flex-col"> <div class="flex flex-row items-center gap-2 mb-2">
<h3 class="text-xs font-black text-slate-400 uppercase tracking-widest">阅读贡献墙</h3> <BabyFeet theme="filled" size="18" fill="#7816ff" />
<p class="text-[10px] text-slate-400 font-medium">展示过去一年生成读书心得的频率</p> <span class="text-sm font-black text-slate-800">阅读贡献足迹</span>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex bg-slate-50 p-1 rounded-xl border gap-2 border-slate-100">
<span class="text-[10px] text-slate-400">Less</span> <a-button
<div v-for="opt in [
v-for="i in 5" { l: '近三月', v: 90 },
:key="i" { l: '半年', v: 180 },
:class="getLevelClass(i - 1)" { l: '全年', v: 365 }
class="w-2.5 h-2.5 rounded-[2px]"
></div>
<span class="text-[10px] text-slate-400">More</span>
</div>
</div>
<div class="overflow-x-auto custom-scroll pb-2">
<div class="grid grid-flow-col grid-rows-7 gap-1.5 w-max">
<div
v-for="day in calendar"
:key="day.date"
:title="`${day.date}: ${day.count} 篇心得`"
@click="handleDayClick(day)"
class="w-3.5 h-3.5 rounded-[2px] transition-all duration-200 hover:scale-125 hover:z-10"
:class="[
getLevelClass(day.level),
day.count > 0 ? 'cursor-pointer shadow-sm shadow-purple-200' : 'cursor-default'
]" ]"
></div> :key="opt.v"
@click="timeRange = opt.v"
:class="[
'px-3 py-1 text-[10px] font-black transition-all rounded-lg',
timeRange === opt.v
? 'bg-white text-[#7816ff] shadow-sm'
: 'text-slate-400 hover:text-slate-600'
]"
>
{{ opt.l }}
</a-button>
</div> </div>
</div> </div>
<div class="bg-white p-6 rounded-[24px] border border-slate-100 mt-6 shadow-sm group">
<div class="relative mb-2 ml-8 h-4">
<span
v-for="label in monthLabels"
:key="label.index"
class="absolute text-[9px] font-bold text-slate-300 uppercase"
:style="{ left: `${label.index * 20}px` }"
>
{{ label.text }}
</span>
</div>
<Teleport to="body"> <div class="flex gap-2">
<ReflectionDrawer <div
:visible="drawerVisible" class="flex flex-col justify-between py-1 text-[9px] font-bold text-slate-300 uppercase h-[115px]"
:date="selectedDate" >
:list="dailyReflections" <span>Mon</span>
:loading="isDetailLoading" <span>Wed</span>
@close="drawerVisible = false" <span>Fri</span>
/> </div>
</Teleport>
<div class="flex-1 overflow-x-auto custom-scroll pb-4">
<div class="grid grid-flow-col grid-rows-7 gap-1.5 w-max">
<div
v-for="day in calendar"
:key="day.date"
@click="handleDayClick(day)"
class="w-[14px] h-[14px] rounded-[3px] transition-all duration-300 hover:scale-150 hover:z-20 hover:rounded-sm relative"
:class="[
getLevelClass(day.level),
day.count > 0
? 'cursor-pointer shadow-[0_0_8px_rgba(120,22,255,0.1)]'
: 'cursor-default'
]"
>
<div class="sr-only">{{ day.date }}: {{ day.count }}</div>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-2 mt-2 pt-4 border-t border-slate-50">
<div class="mt-2 flex items-center gap-2 px-1">
<div class="w-1.5 h-1.5 rounded-full bg-[#7816ff] animate-pulse"></div>
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">
数据更新时间 {{ dayjs().format('YYYY-MM-DD HH:mm') }}
</span>
</div>
<div class="flex flex-row gap-2 items-center">
<div class="flex gap-1">
<div
v-for="i in 5"
:key="i"
:class="getLevelClass(i - 1)"
class="w-2 h-2 rounded-[2px]"
></div>
</div>
<span class="text-[9px] font-bold text-slate-400 uppercase tracking-widest">More</span>
</div>
</div>
<Teleport to="body">
<ReflectionDrawer
:visible="drawerVisible"
:date="selectedDate"
:list="dailyReflections"
:loading="isDetailLoading"
@close="drawerVisible = false"
/>
</Teleport>
</div>
</div> </div>
</template> </template>
@@ -129,4 +190,11 @@ const handleDayClick = async (day: { date: string; count: number }) => {
background: #f1f5f9; background: #f1f5f9;
border-radius: 10px; border-radius: 10px;
} }
.custom-scroll::-webkit-scrollbar-track {
background: transparent;
}
/* 优化滚动时的呼吸感 */
.grid {
padding: 2px;
}
</style> </style>

View File

@@ -14,7 +14,6 @@ const emit = defineEmits(['close', 'select'])
<template> <template>
<div v-if="visible" class="fixed inset-0 z-[100] flex justify-end"> <div v-if="visible" class="fixed inset-0 z-[100] flex justify-end">
<div class="absolute inset-0 bg-slate-900/20 backdrop-blur-sm" @click="emit('close')"></div> <div class="absolute inset-0 bg-slate-900/20 backdrop-blur-sm" @click="emit('close')"></div>
<div class="relative w-80 h-full bg-white shadow-2xl flex flex-col animate-slide-in"> <div class="relative w-80 h-full bg-white shadow-2xl flex flex-col animate-slide-in">
<header class="p-6 border-b border-slate-50 flex items-center justify-between"> <header class="p-6 border-b border-slate-50 flex items-center justify-between">
<div> <div>

View File

@@ -1,7 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, nextTick } from 'vue' import { onMounted, ref, nextTick, computed } from 'vue'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { ChartGraph, TrendTwo, Refresh } from '@icon-park/vue-next' import {
ChartGraph,
TrendTwo,
Refresh,
BookOpen,
Write,
Time,
ChartHistogram
} from '@icon-park/vue-next'
import BackPage from '@renderer/components/BackPage.vue' import BackPage from '@renderer/components/BackPage.vue'
import { trpc } from '@renderer/lib/trpc' import { trpc } from '@renderer/lib/trpc'
import { IUserReadingPersona } from '@shared/types/IUserReadingPersona' import { IUserReadingPersona } from '@shared/types/IUserReadingPersona'
@@ -9,13 +17,53 @@ import useLoading from '@renderer/hooks/useLoading'
import ContributionWall from './components/ContributionWall.vue' import ContributionWall from './components/ContributionWall.vue'
const chartRef = ref<HTMLElement | null>(null) const chartRef = ref<HTMLElement | null>(null)
const trendChartRef = ref<HTMLElement | null>(null)
const personaData = ref<IUserReadingPersona | null>(null) const personaData = ref<IUserReadingPersona | null>(null)
const { loading, setLoading } = useLoading() const { loading, setLoading } = useLoading()
const contributionData = ref<{ date: string; count: number }[]>([]) const contributionData = ref<{ date: string; count: number }[]>([])
let myChart: echarts.ECharts | null = null
// 核心方法:初始化或更新图表 let myChart: echarts.ECharts | null = null
const renderChart = (data: IUserReadingPersona) => { let trendChart: echarts.ECharts | null = null
// 计算统计数据
const statsCards = computed(() => {
const s = personaData.value?.stats
return [
{
label: '累计产出心得',
// 增加万字单位转换,让数据在大数额下更精致
value: s
? s.totalWords > 10000
? (s.totalWords / 10000).toFixed(1)
: s.totalWords.toLocaleString()
: 0,
unit: s ? (s.totalWords > 10000 ? '万字' : '字') : '字',
icon: Write,
color: 'bg-blue-50 text-blue-600',
trend: 'Keep growing' // 增加一个装饰性文字
},
{
label: '已读深度书籍',
value: s?.totalBooks || '0',
unit: '本',
icon: BookOpen,
color: 'bg-purple-50 text-[#7816ff]',
trend: 'Deep reading'
},
{
label: '专注成长时长',
value: s?.totalHours || '0',
unit: '小时',
icon: Time,
color: 'bg-orange-50 text-orange-600',
trend: 'Focus time'
}
]
})
// 渲染雷达图
const renderRadarChart = (data: IUserReadingPersona) => {
if (!chartRef.value) return if (!chartRef.value) return
if (!myChart) myChart = echarts.init(chartRef.value) if (!myChart) myChart = echarts.init(chartRef.value)
@@ -23,19 +71,14 @@ const renderChart = (data: IUserReadingPersona) => {
radar: { radar: {
indicator: [ indicator: [
{ name: '认知深度', max: 100 }, { name: '认知深度', max: 100 },
{ name: '产出效率', max: 100 }, // 对应 efficiencyScore { name: '产出效率', max: 100 },
{ name: '成熟度', max: 100 }, // 对应 maturityScore { name: '成熟度', max: 100 },
{ name: '知识广度', max: 100 }, // 对应 breadthScore { name: '知识广度', max: 100 },
{ name: '语言能力', max: 100 } // 对应 languageScore { name: '语言能力', max: 100 }
], ],
shape: 'circle', shape: 'circle',
splitArea: { radius: '65%',
areaStyle: { splitArea: { areaStyle: { color: ['#F8F9FB', '#fff'] } },
color: ['#F8F9FB', '#fff'],
shadowColor: 'rgba(0, 0, 0, 0.05)',
shadowBlur: 10
}
},
axisLine: { lineStyle: { color: '#E5E7EB' } }, axisLine: { lineStyle: { color: '#E5E7EB' } },
splitLine: { lineStyle: { color: '#E5E7EB' } } splitLine: { lineStyle: { color: '#E5E7EB' } }
}, },
@@ -45,7 +88,6 @@ const renderChart = (data: IUserReadingPersona) => {
data: [ data: [
{ {
value: [ value: [
// 按照 indicator 的顺序填入分值
data.domainDepth[0]?.score || 0, data.domainDepth[0]?.score || 0,
data.efficiencyScore, data.efficiencyScore,
data.maturityScore, data.maturityScore,
@@ -64,13 +106,95 @@ const renderChart = (data: IUserReadingPersona) => {
myChart.setOption(option) myChart.setOption(option)
} }
// 获取数据 // 渲染趋势柱状图
const renderTrendChart = (contributions: any[]) => {
if (!trendChartRef.value) return
if (!trendChart) trendChart = echarts.init(trendChartRef.value)
const recentData = contributions.slice(-7)
const option = {
// 1. 新增 Tooltip 配置
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.9)', // 半透明白
backdropFilter: 'blur(4px)', // 毛玻璃效果 (部分浏览器支持)
borderColor: '#f1f5f9', // slate-100
borderWidth: 1,
padding: [8, 12],
textStyle: {
color: '#1e293b', // slate-800
fontSize: 12,
fontWeight: 'bold'
},
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
formatter: (params: any) => {
const item = params[0]
return `
<div style="display: flex; flex-direction: column; gap: 4px;">
<div style="font-size: 10px; color: #94a3b8; text-transform: uppercase; font-weight: 800;">阅读数量</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #7816ff;"></span>
<span>${item.name}: <span style="color: #7816ff;">${item.value} 篇心得</span></span>
</div>
</div>
`
},
axisPointer: {
type: 'shadow', // 悬浮时背景阴影
shadowStyle: {
color: 'rgba(120, 22, 255, 0.03)' // 极淡的紫色背景,呼应主题
}
}
},
grid: { left: '3%', right: '3%', bottom: '3%', top: '15%', containLabel: true },
xAxis: {
type: 'category',
data: recentData.map((d) => d.date.split('-').slice(1).join('/')),
axisLine: { lineStyle: { color: '#E5E7EB' } },
axisTick: { show: false },
axisLabel: {
color: '#94a3b8',
fontSize: 10,
fontWeight: 'bold'
}
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { type: 'dashed', color: '#F1F5F9' } },
axisLabel: { color: '#94a3b8', fontSize: 10 }
},
series: [
{
data: recentData.map((d) => d.count),
type: 'bar',
barWidth: '40%',
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#9d50ff' },
{ offset: 1, color: '#7816ff' }
]),
borderRadius: [4, 4, 0, 0]
},
// 悬浮时高亮效果
emphasis: {
itemStyle: {
color: '#7816ff',
shadowBlur: 10,
shadowColor: 'rgba(120, 22, 255, 0.3)'
}
}
}
]
}
trendChart.setOption(option)
}
const fetchData = async (force = false) => { const fetchData = async (force = false) => {
setLoading(true) setLoading(true)
try { try {
if (force) await trpc.persona.forceRefreshUserPersona.mutate() if (force) await trpc.persona.forceRefreshUserPersona.mutate()
// 并行请求:提高响应速度
const [persona, contributions] = await Promise.all([ const [persona, contributions] = await Promise.all([
trpc.persona.getUserPersona.query(), trpc.persona.getUserPersona.query(),
trpc.persona.getContributionData.query() trpc.persona.getContributionData.query()
@@ -79,11 +203,13 @@ const fetchData = async (force = false) => {
if (persona) { if (persona) {
personaData.value = persona personaData.value = persona
await nextTick() await nextTick()
renderChart(persona) renderRadarChart(persona)
} }
if (contributions) { if (contributions) {
contributionData.value = contributions contributionData.value = contributions
await nextTick()
renderTrendChart(contributions)
} }
} catch (error) { } catch (error) {
console.error('数据同步失败:', error) console.error('数据同步失败:', error)
@@ -94,8 +220,10 @@ const fetchData = async (force = false) => {
onMounted(() => { onMounted(() => {
fetchData() fetchData()
// 窗口缩放自适应 window.addEventListener('resize', () => {
window.addEventListener('resize', () => myChart?.resize()) myChart?.resize()
trendChart?.resize()
})
}) })
</script> </script>
@@ -105,7 +233,6 @@ onMounted(() => {
class="h-16 border-b border-slate-100/50 px-6 flex items-center justify-between relative z-20 backdrop-blur-md bg-white/70" class="h-16 border-b border-slate-100/50 px-6 flex items-center justify-between relative z-20 backdrop-blur-md bg-white/70"
> >
<BackPage title="返回应用菜单" /> <BackPage title="返回应用菜单" />
<a-button <a-button
@click="fetchData(true)" @click="fetchData(true)"
:disabled="loading" :disabled="loading"
@@ -117,67 +244,77 @@ onMounted(() => {
</a-button> </a-button>
</header> </header>
<main class="flex-1 overflow-y-auto relative z-10"> <main class="flex-1 overflow-y-auto relative z-10 pb-10">
<div class="max-w-4xl mx-auto space-y-6"> <div class="max-w-4xl mx-auto space-y-6 px-4">
<div class="bg-white p-8 rounded-xl shadow-sm border border-slate-100"> <div class="grid grid-cols-3 gap-4">
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-2xl bg-purple-50 flex items-center justify-center">
<chart-graph theme="outline" size="26" fill="#7816ff" />
</div>
<div class="flex flex-col">
<span class="text-lg font-black text-slate-800 tracking-tight">阅读画像</span>
<p class="text-[10px] text-slate-400 uppercase tracking-widest font-bold">
让每一篇阅读者享受阅读与思考的乐趣
</p>
</div>
</div>
</div>
<!--用户画像展示-->
<h3 class="text-xs font-black text-slate-400 uppercase tracking-widest">阅读用户画像</h3>
<div v-show="personaData" ref="chartRef" class="w-full h-[300px]"></div>
<ContributionWall v-if="contributionData.length > 0" :data="contributionData" />
<!--暂无数据提示-->
<div <div
v-if="!personaData && !loading" v-for="card in statsCards"
class="h-[300px] flex flex-col items-center justify-center text-slate-400 gap-4" :key="card.label"
class="bg-white p-5 rounded-2xl border border-slate-100 shadow-sm hover:shadow-md transition-all group"
> >
<div class="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center"> <div class="flex items-center justify-between mb-3">
<chart-graph size="32" /> <div
:class="[
'w-10 h-10 rounded-xl flex items-center justify-center transition-transform group-hover:scale-110',
card.color
]"
>
<component :is="card.icon" size="20" />
</div>
</div> </div>
<p class="text-sm font-medium">暂无画像数据请点击上方同步</p> <div class="flex items-baseline gap-1">
<span class="text-2xl font-black text-slate-800">{{ card.value }}</span>
<span class="text-xs font-bold text-slate-400">{{ card.unit }}</span>
</div>
<p class="text-[11px] text-slate-500 mt-1 font-medium">{{ card.label }}</p>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
<div class="flex items-center gap-2 mb-4">
<chart-graph theme="filled" size="18" fill="#7816ff" />
<span class="text-sm font-black text-slate-800">多维能力画像</span>
</div>
<div v-show="personaData" ref="chartRef" class="w-full h-[280px]"></div>
</div>
<div class="bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
<div class="flex items-center gap-2 mb-4">
<chart-histogram theme="filled" size="18" fill="#7816ff" />
<span class="text-sm font-black text-slate-800">产出密度 (近7日)</span>
</div>
<div ref="trendChartRef" class="w-full h-[280px]"></div>
</div>
</div>
<!--阅读贡献足迹-->
<ContributionWall v-if="contributionData.length > 0" :data="contributionData" />
<div <div
v-if="personaData" v-if="personaData"
class="p-6 bg-[#7816ff] rounded-[32px] text-white shadow-xl shadow-purple-200/50 relative overflow-hidden" class="p-6 bg-[#7816ff] rounded-[32px] text-white shadow-xl shadow-purple-200/50 relative overflow-hidden group"
> >
<div class="absolute -right-10 -top-10 w-40 h-40 bg-white/10 rounded-full blur-3xl"></div> <div
class="absolute -right-10 -top-10 w-40 h-40 bg-white/10 rounded-full blur-3xl group-hover:scale-125 transition-transform duration-700"
></div>
<div class="relative z-10 flex flex-col"> <div class="relative z-10 flex flex-col">
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2 mb-2">
<trend-two theme="outline" size="20" fill="#fff" /> <trend-two theme="outline" size="20" fill="#fff" />
<span class="text-md font-black">智能阅读报告</span> <span class="text-md font-black">阅读维度报告</span>
</div> </div>
<p class="text-[14px] leading-relaxed opacity-95 font-medium">
<p class="text-[13px] leading-relaxed opacity-90 font-medium">
基于你累计生成的 基于你累计生成的
<span class="text-yellow-300 font-bold">{{ personaData.stats.totalWords }}</span> <span class="text-yellow-300 font-black">{{ personaData.stats.totalWords }}</span>
字心得分析 你在 字心得分析 你在
<span class="underline underline-offset-4 decoration-yellow-300">{{ <span class="px-2 py-0.5 bg-white/20 rounded-md mx-1 font-bold">{{
personaData.domainDepth[0]?.name || '未知领域' personaData.domainDepth[0]?.name || '探索'
}}</span> }}</span>
领域表现出极高的探索欲 领域已初步建立知识框架
</p> </p>
<div class="flex flex-wrap gap-2 mt-4">
<div class="flex flex-wrap gap-2 mt-2">
<span <span
v-for="kw in personaData.stats.topKeywords" v-for="kw in personaData.stats.topKeywords"
:key="kw" :key="kw"
class="text-[10px] bg-white/20 px-3 py-1 rounded-full backdrop-blur-md" class="text-[10px] bg-white/10 border border-white/20 px-3 py-1 rounded-full backdrop-blur-md font-bold"
> >
# {{ kw }} # {{ kw }}
</span> </span>
@@ -190,6 +327,7 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
/* 保持原有的动画 */
@keyframes spin { @keyframes spin {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
@@ -201,4 +339,9 @@ onMounted(() => {
.animate-spin { .animate-spin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
/* 隐藏滚动条但保留功能 */
main::-webkit-scrollbar {
width: 0px;
}
</style> </style>

View File

@@ -31,15 +31,18 @@ const handleCopyContent = async () => {
Message.success('正文已成功复制') Message.success('正文已成功复制')
} }
const handleExportMD = () => { const handleExport = () => {
if (!readingData.value) return if (!readingData.value) return
const md = `# ${readingData.value.title}\n\n${readingData.value.content}` // const md = `# ${readingData.value.title}\n\n${readingData.value.content}`
const blob = new Blob([md], { type: 'text/markdown' }) // const blob = new Blob([md], { type: 'text/markdown' })
const url = URL.createObjectURL(blob) // const url = URL.createObjectURL(blob)
const a = document.createElement('a') // const a = document.createElement('a')
a.href = url // a.href = url
a.download = `${readingData.value.title}.md` // a.download = `${readingData.value.title}.md`
a.click() // a.click()
go('/reflection/export', {
itemId: subTaskId.value
})
} }
const handlePoster = () => { const handlePoster = () => {
@@ -56,7 +59,7 @@ onMounted(() => fetchDetail())
<header <header
class="h-16 border-b border-slate-100/50 px-6 flex items-center justify-between relative z-20 backdrop-blur-md bg-white/70" class="h-16 border-b border-slate-100/50 px-6 flex items-center justify-between relative z-20 backdrop-blur-md bg-white/70"
> >
<BackPage title="返回列表" /> <BackPage :title="`阅读 | ${readingData?.title || ''}`" />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a-button <a-button
size="mini" size="mini"
@@ -77,7 +80,7 @@ onMounted(() => fetchDetail())
<a-button <a-button
size="mini" size="mini"
class="rounded-lg bg-[#7816ff] text-white border-none" class="rounded-lg bg-[#7816ff] text-white border-none"
@click="handleExportMD" @click="handleExport"
> >
<template #icon><FileCode theme="outline" size="14" /></template> <template #icon><FileCode theme="outline" size="14" /></template>
导出文档 导出文档

View File

@@ -0,0 +1,304 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { trpc } from '@renderer/lib/trpc'
import useRouterHook from '@renderer/hooks/useRouterHook'
import { FileCode, Send, Loading, Info } from '@icon-park/vue-next'
import { Message } from '@arco-design/web-vue'
import BackPage from '@renderer/components/BackPage.vue'
const { getQuery } = useRouterHook()
const subTaskId = computed(() => getQuery('itemId') as string)
const reflectionData = ref<any>(null)
const templates = ref<any[]>([])
const selectedTpl = ref<any>(null)
const tags = ref<string[]>([])
const formData = ref<Record<string, string>>({})
const exporting = ref(false)
const isLoading = ref(true)
const fieldConfig = computed(() => {
const autoTags = tags.value.filter((t) => t.startsWith('data.'))
const manualTags = tags.value.filter((t) => !t.startsWith('data.'))
return { autoTags, manualTags }
})
const initPage = async () => {
if (!subTaskId.value) {
Message.error('缺少导出参数')
return
}
try {
isLoading.value = true
const [data, tpls] = await Promise.all([
trpc.task.getItemDetail.query({ itemId: subTaskId.value }),
trpc.export.listTemplates.query()
])
reflectionData.value = data
templates.value = tpls
} catch (err: any) {
Message.error('初始化数据失败')
} finally {
isLoading.value = false
}
}
const selectTemplate = async (tpl: any) => {
selectedTpl.value = tpl
formData.value = {}
try {
const res = await trpc.export.getFields.query({ templateName: tpl.file })
tags.value = res
if (res.includes('date')) formData.value['date'] = new Date().toLocaleDateString()
} catch (err) {
Message.error('解析模板标签失败')
}
}
const handleExport = async () => {
if (!selectedTpl.value || !reflectionData.value) return
exporting.value = true
try {
const rawFormData = JSON.parse(JSON.stringify(formData.value || {}))
const rawReflectionData = JSON.parse(JSON.stringify(reflectionData.value))
const result = await trpc.export.generateMdFile.mutate({
templateName: selectedTpl.value.file,
formData: rawFormData,
reflectionData: rawReflectionData
})
if (result && result.success) {
Message.success('Markdown 导出成功!已打开所在目录')
}
} catch (error: any) {
Message.error(`导出失败: ${error.message}`)
} finally {
exporting.value = false
}
}
const getObjectValue = (obj: any, path: string) => {
if (!obj) return '...'
const keys = path.replace('data.', '').split('.')
let val = obj
try {
for (const key of keys) {
if (val && typeof val === 'object' && key in val) val = val[key]
else return 'Empty'
}
return val
} catch (e) {
return 'Error'
}
}
onMounted(initPage)
</script>
<template>
<div class="flex flex-col h-screen bg-[#FAFAFB] overflow-hidden">
<header
class="h-16 px-8 bg-white/80 backdrop-blur-md border-b border-slate-100 flex items-center justify-between shrink-0 z-50"
>
<BackPage title="导出 Markdown" />
<div
v-if="reflectionData"
class="flex items-center gap-3 bg-purple-50/50 px-4 py-2 rounded-xl border border-purple-100/50"
>
<span class="text-xs font-black text-[#7816ff]/40 uppercase tracking-widest"
>当前文档:</span
>
<span class="text-xs font-bold text-[#7816ff] truncate max-w-[200px] font-mono">{{
reflectionData.title
}}</span>
</div>
</header>
<main class="flex-1 overflow-hidden p-8 flex flex-col gap-8">
<section class="shrink-0">
<div class="flex items-center gap-2 mb-5">
<div
class="w-1.5 h-4 bg-[#7816ff] rounded-full shadow-[0_0_8px_rgba(120,22,255,0.3)]"
></div>
<h3 class="text-sm font-black text-slate-800 tracking-tight">第一步选择导出样式模板</h3>
</div>
<div class="grid grid-cols-4 gap-5">
<div
v-for="tpl in templates"
:key="tpl.id"
@click="selectTemplate(tpl)"
:class="[
'relative p-5 rounded-2xl border-2 transition-all duration-300 cursor-pointer group',
selectedTpl?.id === tpl.id
? 'border-[#7816ff] bg-white shadow-xl shadow-purple-100/50'
: 'border-transparent bg-white hover:border-purple-200 shadow-sm'
]"
>
<div class="flex items-center gap-4 relative z-10">
<div
:class="[
'w-12 h-12 rounded-xl flex items-center justify-center transition-all',
selectedTpl?.id === tpl.id
? 'bg-[#7816ff] text-white'
: 'bg-slate-50 text-slate-400'
]"
>
<FileCode theme="outline" size="24" />
</div>
<div class="flex flex-col">
<span class="text-xs font-black text-slate-700">{{ tpl.name }}</span>
<span
class="text-[9px] text-slate-400 font-bold uppercase mt-0.5 tracking-tighter"
>{{ tpl.file }}</span
>
</div>
</div>
</div>
</div>
</section>
<section v-if="!selectedTpl" class="flex-1 min-h-0">
<div
class="h-full w-full border-2 border-dashed border-slate-200 rounded-[32px] flex flex-col items-center justify-center bg-white/60"
>
<FileCode size="40" class="text-slate-200 mb-4" />
<p class="text-sm font-black text-slate-300">请选择一个模板以开启预览</p>
</div>
</section>
<section
v-else
class="flex-1 min-h-0 grid grid-cols-2 gap-8 animate-in fade-in slide-in-from-bottom-6 duration-500"
>
<div class="flex flex-col min-h-0">
<div class="flex items-center gap-2 mb-4 shrink-0">
<div class="w-1.5 h-4 bg-[#7816ff] rounded-full"></div>
<h3 class="text-sm font-black text-slate-800 tracking-tight">第二步完善补充信息</h3>
</div>
<div
class="flex-1 bg-white rounded-[28px] border border-slate-100 p-8 flex flex-col overflow-hidden shadow-sm"
>
<div class="flex-1 overflow-y-auto pr-2 space-y-6 custom-scroll">
<div
v-for="tag in fieldConfig.manualTags"
:key="tag"
class="flex flex-col gap-2 group"
>
<label
class="text-[10px] font-black text-slate-400 uppercase tracking-widest font-mono group-focus-within:text-[#7816ff] transition-colors"
>
{{ tag }}
</label>
<a-input
v-model="formData[tag]"
class="!rounded-xl !border-none !bg-slate-50 !h-12 !font-mono"
/>
</div>
<div
v-if="fieldConfig.manualTags.length === 0"
class="h-full flex flex-col items-center justify-center text-slate-300 opacity-60"
>
<Info size="32" class="mb-2" />
<span class="text-xs font-bold uppercase tracking-widest">无须额外信息</span>
</div>
</div>
</div>
</div>
<div class="flex flex-col min-h-0">
<div class="flex items-center gap-2 mb-4 shrink-0">
<div class="w-1.5 h-4 bg-[#7816ff] rounded-full"></div>
<h3 class="text-sm font-black text-slate-800 tracking-tight">第三步MD 源码预览</h3>
</div>
<div
class="flex-1 bg-purple-50/30 rounded-xl p-4 flex flex-col overflow-hidden border-2 border-dashed border-purple-100 relative"
>
<div class="flex-1 overflow-y-auto pr-2 space-y-4 custom-scroll relative z-10">
<div
v-for="tag in fieldConfig.autoTags"
:key="tag"
class="p-5 rounded-2xl bg-white border border-purple-100/50 group transition-all hover:shadow-md hover:border-[#7816ff]/30"
>
<div class="flex justify-between items-center mb-3">
<span
class="text-[9px] font-black text-[#7816ff] uppercase bg-purple-50 px-2.5 py-1 rounded tracking-tighter"
>
{{ tag }}
</span>
<div class="w-1.5 h-1.5 rounded-full bg-[#7816ff] animate-pulse"></div>
</div>
<div
class="text-[12px] text-slate-600 font-mono leading-relaxed break-all whitespace-pre-wrap selection:bg-purple-100"
>
{{ getObjectValue(reflectionData, tag) }}
</div>
</div>
</div>
<div class="pt-4 border-t border-purple-100/50 shrink-0">
<a-button
type="primary"
:disabled="exporting"
@click="handleExport"
class="w-full h-10 !rounded-2xl !bg-[#7816ff] !border-none shadow-xl shadow-purple-200 group active:scale-[0.98] transition-all"
>
<template #icon>
<Loading v-if="exporting" class="animate-spin" />
<Send
v-else
class="group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform"
/>
</template>
<span class="text-xs font-black uppercase tracking-[0.2em] ml-1">
{{ exporting ? '构建中...' : '确认导出报告' }}
</span>
</a-button>
</div>
</div>
</div>
</section>
</main>
</div>
</template>
<style scoped>
.custom-scroll::-webkit-scrollbar {
width: 4px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.font-mono {
font-family: 'Fira Code', 'Consolas', 'Courier New', monospace;
}
/* 简单的入场动画 */
.animate-in {
animation: slideUp 0.5s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -71,7 +71,7 @@ onMounted(fetchDetail)
<header <header
class="h-16 px-8 flex items-center gap-4 bg-white/80 backdrop-blur-md border-b border-slate-100 relative z-10" class="h-16 px-8 flex items-center gap-4 bg-white/80 backdrop-blur-md border-b border-slate-100 relative z-10"
> >
<BackPage title="功能中心" /> <BackPage title="分享海报" />
</header> </header>
<div class="flex-1 overflow-y-auto p-8 space-y-10 custom-scroll"> <div class="flex-1 overflow-y-auto p-8 space-y-10 custom-scroll">
<section class="space-y-4"> <section class="space-y-4">

View File

@@ -25,6 +25,11 @@ const routes: RouteRecordRaw[] = [
name: 'ReflectionPoster', name: 'ReflectionPoster',
component: () => import('@renderer/pages/reflection/views/poster/index.vue') component: () => import('@renderer/pages/reflection/views/poster/index.vue')
}, },
{
path: '/reflection/export',
name: 'ReflectionExport',
component: () => import('@renderer/pages/reflection/views/export/index.vue')
},
{ {
path: '/menus/userPersona', path: '/menus/userPersona',
name: 'ReadingUserPersona', name: 'ReadingUserPersona',

View File

@@ -4,13 +4,15 @@ import { configRouter } from '@rpc/router/config.router'
import { noticeRouter } from '@rpc/router/notice.router' import { noticeRouter } from '@rpc/router/notice.router'
import { personaRouter } from '@rpc/router/persona.router' import { personaRouter } from '@rpc/router/persona.router'
import { searchRouter } from '@rpc/router/search.router' import { searchRouter } from '@rpc/router/search.router'
import { exportRouter } from '@rpc/router/export.router'
export const appRouter = router({ export const appRouter = router({
task: taskRouter, task: taskRouter,
config: configRouter, config: configRouter,
notice: noticeRouter, notice: noticeRouter,
persona: personaRouter, persona: personaRouter,
search: searchRouter search: searchRouter,
export: exportRouter
}) })
export type AppRouter = typeof appRouter export type AppRouter = typeof appRouter

View File

@@ -0,0 +1,98 @@
import { router, publicProcedure } from '@rpc/index'
import { z } from 'zod'
import fs from 'fs'
import { getResourcesPath, getTemplatePath } from '@shared/utils/path'
import path from 'path'
import { shell, dialog } from 'electron'
export const exportRouter = router({
/**
* 获取模板列表
* */
listTemplates: publicProcedure.query(async () => {
try {
const listPath = path.join(getResourcesPath(), 'templates', 'templates_list.json')
if (!fs.existsSync(listPath)) {
console.warn('模板列表文件不存在:', listPath)
return []
}
const content = fs.readFileSync(listPath, 'utf-8')
return JSON.parse(content) as { id: string; name: string; file: string }[]
} catch (error) {
console.error('读取模板列表失败:', error)
return []
}
}),
/**
* 获取选定模板的字段
* */
getFields: publicProcedure
.input(z.object({ templateName: z.string() }))
.query(async ({ input }) => {
const filePath = getTemplatePath(input.templateName)
const content = fs.readFileSync(filePath, 'utf-8')
const regex = /\{\{(.+?)\}\}/g
const tags = new Set<string>()
let match
while ((match = regex.exec(content)) !== null) {
tags.add(match[1].trim())
}
return Array.from(tags)
}),
/**
* 导出Markdown文件
* */
generateMdFile: publicProcedure
.input(
z.object({
templateName: z.string(),
formData: z.record(z.string(), z.any()),
reflectionData: z.any()
})
)
.mutation(async ({ input }) => {
try {
const { templateName, formData, reflectionData } = input
const templatePath = getTemplatePath(templateName)
let content = fs.readFileSync(templatePath, 'utf-8')
// 1. 准备数据池
const renderData = { ...formData, data: reflectionData }
// 2. 递归替换变量 (支持 {{data.title}} 这种路径)
const getVal = (path: string): string => {
const value = path.split('.').reduce((obj, key) => {
return obj && typeof obj === 'object' ? obj[key] : undefined
}, renderData)
return value !== undefined && value !== null ? String(value) : ''
}
content = content.replace(/\{\{([\s\S]+?)\}\}/g, (_match: string, path: string): string => {
const result = getVal(path.trim())
// 如果该字段没找到,保留原标签 {{path}} 还是返回空?通常返回空更干净
return result || ''
})
// 3. 弹出保存对话框
const { filePath, canceled } = await dialog.showSaveDialog({
title: '导出为 Markdown',
defaultPath: `Reading_Reflection_${reflectionData.title || 'Untitled'}.md`,
filters: [{ name: 'Markdown', extensions: ['md'] }]
})
if (canceled || !filePath) return { success: false }
// 4. 写入并打开
fs.writeFileSync(filePath, content, 'utf-8')
shell.showItemInFolder(filePath)
return { success: true, path: filePath }
} catch (error: any) {
throw new Error(`导出 MD 失败: ${error.message}`)
}
})
})

View File

@@ -30,6 +30,7 @@ export interface IUserReadingPersona {
stats: { stats: {
totalWords: number // 累计生成的心得总字数 totalWords: number // 累计生成的心得总字数
totalBooks: number // 累计阅读并生成过心得的书籍总数 totalBooks: number // 累计阅读并生成过心得的书籍总数
totalHours: number // 累计阅读并生成过心得的总时长
topKeywords: string[] // 出现频率最高的 Top 10 关键词 topKeywords: string[] // 出现频率最高的 Top 10 关键词
mostUsedOccupation: string // 最常使用的阅读者身份 mostUsedOccupation: string // 最常使用的阅读者身份
} }

20
src/shared/utils/path.ts Normal file
View File

@@ -0,0 +1,20 @@
import { app } from 'electron'
import path from 'path'
export const getResourcesPath = () => {
const isDev = !app.isPackaged
// 开发环境指向根目录 resources生产环境指向 process.resourcesPath
return isDev ? path.join(app.getAppPath(), 'resources') : process.resourcesPath
}
export const getTemplatePath = (fileName: string) => {
// 开发环境下,指向项目根目录下的 resources/templates
// 生产环境下,指向 process.resourcesPath (即安装目录下的 resources 文件夹)
const isDev = !app.isPackaged
const baseDir = isDev
? path.join(app.getAppPath(), 'resources', 'templates')
: path.join(process.resourcesPath, 'templates')
return path.join(baseDir, fileName)
}