From 3f7347427e6cbb341cefbf7dd2ff39d7509ffbfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E5=AF=92?= <2596194220@qq.com> Date: Sun, 11 Jan 2026 22:54:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20=E2=9C=A8=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?MD=E6=96=87=E4=BB=B6=E6=A8=A1=E6=9D=BF=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db.sqlite | Bin 180224 -> 180224 bytes electron-builder.yml | 5 + resources/templates/default.docx | Bin 0 -> 10183 bytes resources/templates/default.md | 13 + resources/templates/templates_list.json | 1 + src/main/services/persona.service.ts | 76 +++-- src/renderer/components.d.ts | 1 + .../src/pages/menus/data/MenusData.ts | 26 +- .../components/ContributionWall.vue | 184 +++++++---- .../components/ReflectionDrawer.vue | 1 - .../pages/menus/views/userPersona/index.vue | 273 ++++++++++++---- src/renderer/src/pages/reflection/index.vue | 23 +- .../pages/reflection/views/export/index.vue | 304 ++++++++++++++++++ .../pages/reflection/views/poster/index.vue | 2 +- src/renderer/src/router/index.ts | 5 + src/rpc/router.ts | 4 +- src/rpc/router/export.router.ts | 98 ++++++ src/shared/types/IUserReadingPersona.ts | 1 + src/shared/utils/path.ts | 20 ++ 19 files changed, 857 insertions(+), 180 deletions(-) create mode 100644 resources/templates/default.docx create mode 100644 resources/templates/default.md create mode 100644 resources/templates/templates_list.json create mode 100644 src/renderer/src/pages/reflection/views/export/index.vue create mode 100644 src/rpc/router/export.router.ts create mode 100644 src/shared/utils/path.ts diff --git a/db.sqlite b/db.sqlite index 29a130e3a4df3ff87158ac5475c5d0c94c9b96cc..cd602843f415b9a2e6e1e81330c4f6c40dba462f 100644 GIT binary patch delta 114 zcmZo@;BIK(o*>PrHBrWyQL8awYXal4ewHu>zOc=L4nBMu&FYLiri>uaWZi6QoLpK| zl$uu(Us{}66km{9RGgoes2k!fWHh~R9^)RKlEfU3{L-RgB`ZU->2C8GH?bQUTNzne Q8MXhP&$#{nd?rQ(0J(!CivR!s delta 101 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 J%M?$b{{!fa;B^21 literal 0 HcmV?d00001 diff --git a/resources/templates/default.md b/resources/templates/default.md new file mode 100644 index 0000000..2b7583b --- /dev/null +++ b/resources/templates/default.md @@ -0,0 +1,13 @@ +# {{data.title}} + +> **导出日期**: {{date}} +> **作者**: {{author}} + +## 📑 核心摘要 +{{data.summary}} + +## 💡 深度心得 +{{data.content}} + +--- +*Generated by AI Reading Assistant* diff --git a/resources/templates/templates_list.json b/resources/templates/templates_list.json new file mode 100644 index 0000000..7c99855 --- /dev/null +++ b/resources/templates/templates_list.json @@ -0,0 +1 @@ +[{ "id": "tpl_001", "name": "精致黑白排版", "file": "default.md" }] diff --git a/src/main/services/persona.service.ts b/src/main/services/persona.service.ts index e044fbd..26709a9 100644 --- a/src/main/services/persona.service.ts +++ b/src/main/services/persona.service.ts @@ -9,6 +9,9 @@ import { IUserReadingPersona } from '@shared/types/IUserReadingPersona' export class PersonaService { constructor(private personaRepo: Repository) {} + /** + * 刷新画像并保存到数据库 + */ /** * 刷新画像并保存到数据库 */ @@ -16,22 +19,30 @@ export class PersonaService { items: IReadingReflectionTaskItem[], batches: IReadingReflectionTaskBatch[] ) { - const rawResult = await this.calculatePersona(items, batches) // 调用你原来的计算逻辑 + // 1. 获取计算结果 + const rawResult = await this.calculatePersona(items, batches) + // 2. 创建或更新实体 const persona = new ReadingPersona() persona.id = 'current_user_persona' + + // 核心分值映射 persona.cognition = rawResult.cognition persona.breadth = rawResult.breadth persona.practicality = rawResult.practicality persona.output = rawResult.output 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 = { - totalWords: items.reduce((sum, i) => sum + (i.content?.length || 0), 0), - totalBooks: batches.length, - topKeywords: rawResult.topKeywords + totalWords: rawResult.stats.totalWords, + totalBooks: rawResult.stats.totalBooks, + totalHours: rawResult.stats.totalHours, // 别忘了我们在 calculatePersona 补充的专注时长 + topKeywords: rawResult.stats.topKeywords } return await this.personaRepo.save(persona) @@ -43,22 +54,44 @@ export class PersonaService { items: IReadingReflectionTaskItem[], 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 keywordMap = new Map() allKeywords.forEach((k) => keywordMap.set(k, (keywordMap.get(k) || 0) + 1)) + const cognitionScore = Math.min(100, keywordMap.size * 1.5 + allKeywords.length / 8) - // 逻辑:去重后的关键词越多且重复越高,分值越高 (示例算法) - const cognitionScore = Math.min(100, keywordMap.size * 2 + allKeywords.length / 5) + // --- 2. 知识广度 (Breadth) - 修复 TS2339 --- + // 逻辑:如果 batch 没分类,就看有多少个独立的高频关键词,这代表了涉及的主题广度 + const uniqueThemes = new Set(batches.map((b) => (b as any).category).filter(Boolean)) - // 2. 计算知识广度:根据书籍数量 - const breadthScore = Math.min(100, batches.length * 10) + let breadthScore = 0 + 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. 计算产出效率:根据总字数 - const totalWords = items.reduce((sum, i) => sum + (i.content?.length || 0), 0) - const outputScore = Math.min(100, totalWords / 500) // 每 5万字满分 + // --- 3. 语言能力与全球化 (Global) --- + const langDist = items.reduce( + (acc, curr) => { + // 兼容处理:如果 curr.language 不存在则默认为 'zh' + const lang = (curr as any).language || 'zh' + acc[lang] = (acc[lang] || 0) + 1 + return acc + }, + {} as Record + ) + // 计算英文占比分:有英文记录就从 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()] .sort((a, b) => b[1] - a[1]) .slice(0, 10) @@ -67,10 +100,15 @@ export class PersonaService { return { cognition: Math.round(cognitionScore), breadth: Math.round(breadthScore), - output: Math.round(outputScore), - practicality: 75, // 可根据 occupation 比例动态计算 - global: 60, // 可根据 language 比例动态计算 - topKeywords: sortedKeywords + output: Math.min(100, Math.round(totalWords / 500)), + practicality: 75, + global: Math.round(globalScore), + stats: { + totalWords, + totalBooks, + totalHours, + topKeywords: sortedKeywords + } } } } diff --git a/src/renderer/components.d.ts b/src/renderer/components.d.ts index 9458d89..58a1ec5 100644 --- a/src/renderer/components.d.ts +++ b/src/renderer/components.d.ts @@ -31,6 +31,7 @@ declare module 'vue' { ATag: typeof import('@arco-design/web-vue')['Tag'] ATextarea: typeof import('@arco-design/web-vue')['Textarea'] ATooltip: typeof import('@arco-design/web-vue')['Tooltip'] + AWatermark: typeof import('@arco-design/web-vue')['Watermark'] BackPage: typeof import('./src/components/BackPage.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/src/renderer/src/pages/menus/data/MenusData.ts b/src/renderer/src/pages/menus/data/MenusData.ts index b826b55..5f5cca6 100644 --- a/src/renderer/src/pages/menus/data/MenusData.ts +++ b/src/renderer/src/pages/menus/data/MenusData.ts @@ -1,12 +1,4 @@ -import { - DatabaseConfig, - DocumentFolder, - FileCode, - Headset, - Refresh, - Search, - TrendTwo -} from '@icon-park/vue-next' +import { DatabaseConfig, DocumentFolder, Headset, Search, TrendTwo } from '@icon-park/vue-next' export const features = [ { @@ -33,22 +25,6 @@ export const features = [ { group: '自动化与导出', 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', title: '书库监控', diff --git a/src/renderer/src/pages/menus/views/userPersona/components/ContributionWall.vue b/src/renderer/src/pages/menus/views/userPersona/components/ContributionWall.vue index 2288b4c..76dc5c5 100644 --- a/src/renderer/src/pages/menus/views/userPersona/components/ContributionWall.vue +++ b/src/renderer/src/pages/menus/views/userPersona/components/ContributionWall.vue @@ -4,6 +4,7 @@ import dayjs from 'dayjs' import { trpc } from '@renderer/lib/trpc' import ReflectionDrawer from './ReflectionDrawer.vue' import { IContributionDay } from '@shared/types/IUserReadingPersona' +import { BabyFeet } from '@icon-park/vue-next' const props = defineProps<{ data: { date: string; count: number }[] @@ -14,27 +15,26 @@ const selectedDate = ref('') const dailyReflections = ref([]) const isDetailLoading = ref(false) -// 1. 核心计算逻辑:生成日期矩阵 +// 新增:时间范围状态 (90天 / 180天 / 365天) +const timeRange = ref(90) + +// 1. 核心计算逻辑:根据选择的范围生成日期矩阵 const calendar = computed(() => { 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 dataMap = new Map() + props.data.forEach((item) => { - if (item.date) { - dataMap.set(item.date.trim(), Number(item.count)) - } + if (item.date) dataMap.set(item.date.trim(), Number(item.count)) }) let current = start while (current.isBefore(end) || current.isSame(end, 'day')) { const dateStr = current.format('YYYY-MM-DD') const count = dataMap.get(dateStr) || 0 - - // 3. 现在 push 操作是类型安全的 days.push({ date: dateStr, count: count, @@ -45,29 +45,36 @@ const calendar = computed(() => { 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 levels = [ - 'bg-slate-100', // 0 - 'bg-purple-200', // 1 - 'bg-purple-400', // 2 - 'bg-purple-600', // 3 - 'bg-[#7816ff]' // 4 - ] + const levels = ['bg-slate-100', 'bg-purple-200', 'bg-purple-400', 'bg-purple-600', 'bg-[#7816ff]'] return levels[level] } -// 2. 点击小方块处理 const handleDayClick = async (day: { date: string; count: number }) => { if (day.count === 0) return - selectedDate.value = day.date drawerVisible.value = true isDetailLoading.value = true - try { dailyReflections.value = await trpc.persona.getReflectionsByDate.query({ date: day.date }) } catch (err) { - console.error('获取详情失败:', err) + console.error('详情失败:', err) } finally { isDetailLoading.value = false } @@ -75,49 +82,103 @@ const handleDayClick = async (day: { date: string; count: number }) => { @@ -129,4 +190,11 @@ const handleDayClick = async (day: { date: string; count: number }) => { background: #f1f5f9; border-radius: 10px; } +.custom-scroll::-webkit-scrollbar-track { + background: transparent; +} +/* 优化滚动时的呼吸感 */ +.grid { + padding: 2px; +} diff --git a/src/renderer/src/pages/menus/views/userPersona/components/ReflectionDrawer.vue b/src/renderer/src/pages/menus/views/userPersona/components/ReflectionDrawer.vue index ffd8b5f..8ffe349 100644 --- a/src/renderer/src/pages/menus/views/userPersona/components/ReflectionDrawer.vue +++ b/src/renderer/src/pages/menus/views/userPersona/components/ReflectionDrawer.vue @@ -14,7 +14,6 @@ const emit = defineEmits(['close', 'select']) diff --git a/src/renderer/src/pages/reflection/index.vue b/src/renderer/src/pages/reflection/index.vue index 256abff..1e41746 100644 --- a/src/renderer/src/pages/reflection/index.vue +++ b/src/renderer/src/pages/reflection/index.vue @@ -31,15 +31,18 @@ const handleCopyContent = async () => { Message.success('正文已成功复制') } -const handleExportMD = () => { +const handleExport = () => { if (!readingData.value) return - const md = `# ${readingData.value.title}\n\n${readingData.value.content}` - const blob = new Blob([md], { type: 'text/markdown' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `${readingData.value.title}.md` - a.click() + // const md = `# ${readingData.value.title}\n\n${readingData.value.content}` + // const blob = new Blob([md], { type: 'text/markdown' }) + // const url = URL.createObjectURL(blob) + // const a = document.createElement('a') + // a.href = url + // a.download = `${readingData.value.title}.md` + // a.click() + go('/reflection/export', { + itemId: subTaskId.value + }) } const handlePoster = () => { @@ -56,7 +59,7 @@ onMounted(() => fetchDetail())
- +
fetchDetail()) 导出文档 diff --git a/src/renderer/src/pages/reflection/views/export/index.vue b/src/renderer/src/pages/reflection/views/export/index.vue new file mode 100644 index 0000000..4d75e9e --- /dev/null +++ b/src/renderer/src/pages/reflection/views/export/index.vue @@ -0,0 +1,304 @@ + + + + diff --git a/src/renderer/src/pages/reflection/views/poster/index.vue b/src/renderer/src/pages/reflection/views/poster/index.vue index 0326e68..aa1863d 100644 --- a/src/renderer/src/pages/reflection/views/poster/index.vue +++ b/src/renderer/src/pages/reflection/views/poster/index.vue @@ -71,7 +71,7 @@ onMounted(fetchDetail)
- +
diff --git a/src/renderer/src/router/index.ts b/src/renderer/src/router/index.ts index 45c6464..58e8297 100644 --- a/src/renderer/src/router/index.ts +++ b/src/renderer/src/router/index.ts @@ -25,6 +25,11 @@ const routes: RouteRecordRaw[] = [ name: 'ReflectionPoster', 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', name: 'ReadingUserPersona', diff --git a/src/rpc/router.ts b/src/rpc/router.ts index 5746f2c..de39466 100644 --- a/src/rpc/router.ts +++ b/src/rpc/router.ts @@ -4,13 +4,15 @@ import { configRouter } from '@rpc/router/config.router' import { noticeRouter } from '@rpc/router/notice.router' import { personaRouter } from '@rpc/router/persona.router' import { searchRouter } from '@rpc/router/search.router' +import { exportRouter } from '@rpc/router/export.router' export const appRouter = router({ task: taskRouter, config: configRouter, notice: noticeRouter, persona: personaRouter, - search: searchRouter + search: searchRouter, + export: exportRouter }) export type AppRouter = typeof appRouter diff --git a/src/rpc/router/export.router.ts b/src/rpc/router/export.router.ts new file mode 100644 index 0000000..c2b8ae0 --- /dev/null +++ b/src/rpc/router/export.router.ts @@ -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() + 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}`) + } + }) +}) diff --git a/src/shared/types/IUserReadingPersona.ts b/src/shared/types/IUserReadingPersona.ts index 6a4a4d9..dc39efe 100644 --- a/src/shared/types/IUserReadingPersona.ts +++ b/src/shared/types/IUserReadingPersona.ts @@ -30,6 +30,7 @@ export interface IUserReadingPersona { stats: { totalWords: number // 累计生成的心得总字数 totalBooks: number // 累计阅读并生成过心得的书籍总数 + totalHours: number // 累计阅读并生成过心得的总时长 topKeywords: string[] // 出现频率最高的 Top 10 关键词 mostUsedOccupation: string // 最常使用的阅读者身份 } diff --git a/src/shared/utils/path.ts b/src/shared/utils/path.ts new file mode 100644 index 0000000..0e92114 --- /dev/null +++ b/src/shared/utils/path.ts @@ -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) +}