diff --git a/db.sqlite b/db.sqlite index 29a130e..cd60284 100644 Binary files a/db.sqlite and b/db.sqlite differ diff --git a/electron-builder.yml b/electron-builder.yml index d389699..74cd346 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -43,3 +43,8 @@ publish: url: https://example.com/auto-updates electronDownload: mirror: https://npmmirror.com/mirrors/electron/ +extraResources: + - from: resources/templates + to: templates + filter: + - "**/*" diff --git a/resources/templates/default.docx b/resources/templates/default.docx new file mode 100644 index 0000000..0d23ab7 Binary files /dev/null and b/resources/templates/default.docx differ 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) +}