feat(desktop): ✨ 实现MD文件模板导出方式
This commit is contained in:
@@ -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:
|
||||||
|
- "**/*"
|
||||||
|
|||||||
BIN
resources/templates/default.docx
Normal file
BIN
resources/templates/default.docx
Normal file
Binary file not shown.
13
resources/templates/default.md
Normal file
13
resources/templates/default.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# {{data.title}}
|
||||||
|
|
||||||
|
> **导出日期**: {{date}}
|
||||||
|
> **作者**: {{author}}
|
||||||
|
|
||||||
|
## 📑 核心摘要
|
||||||
|
{{data.summary}}
|
||||||
|
|
||||||
|
## 💡 深度心得
|
||||||
|
{{data.content}}
|
||||||
|
|
||||||
|
---
|
||||||
|
*Generated by AI Reading Assistant*
|
||||||
1
resources/templates/templates_list.json
Normal file
1
resources/templates/templates_list.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[{ "id": "tpl_001", "name": "精致黑白排版", "file": "default.md" }]
|
||||||
@@ -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,13 +100,18 @@ 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),
|
||||||
|
stats: {
|
||||||
|
totalWords,
|
||||||
|
totalBooks,
|
||||||
|
totalHours,
|
||||||
topKeywords: sortedKeywords
|
topKeywords: sortedKeywords
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 实体转化为用户阅读画像
|
* 实体转化为用户阅读画像
|
||||||
|
|||||||
1
src/renderer/components.d.ts
vendored
1
src/renderer/components.d.ts
vendored
@@ -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']
|
||||||
|
|||||||
@@ -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: '书库监控',
|
||||||
|
|||||||
@@ -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,38 +82,91 @@ 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>
|
:key="opt.v"
|
||||||
<span class="text-[10px] text-slate-400">More</span>
|
@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>
|
||||||
|
|
||||||
<div class="overflow-x-auto custom-scroll pb-2">
|
<div class="flex gap-2">
|
||||||
|
<div
|
||||||
|
class="flex flex-col justify-between py-1 text-[9px] font-bold text-slate-300 uppercase h-[115px]"
|
||||||
|
>
|
||||||
|
<span>Mon</span>
|
||||||
|
<span>Wed</span>
|
||||||
|
<span>Fri</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 class="grid grid-flow-col grid-rows-7 gap-1.5 w-max">
|
||||||
<div
|
<div
|
||||||
v-for="day in calendar"
|
v-for="day in calendar"
|
||||||
:key="day.date"
|
:key="day.date"
|
||||||
:title="`${day.date}: ${day.count} 篇心得`"
|
|
||||||
@click="handleDayClick(day)"
|
@click="handleDayClick(day)"
|
||||||
class="w-3.5 h-3.5 rounded-[2px] transition-all duration-200 hover:scale-125 hover:z-10"
|
class="w-[14px] h-[14px] rounded-[3px] transition-all duration-300 hover:scale-150 hover:z-20 hover:rounded-sm relative"
|
||||||
:class="[
|
:class="[
|
||||||
getLevelClass(day.level),
|
getLevelClass(day.level),
|
||||||
day.count > 0 ? 'cursor-pointer shadow-sm shadow-purple-200' : 'cursor-default'
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="text-[9px] font-bold text-slate-400 uppercase tracking-widest">More</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
@@ -119,6 +179,7 @@ const handleDayClick = async (day: { date: string; count: number }) => {
|
|||||||
/>
|
/>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
<p class="text-sm font-medium">暂无画像数据,请点击上方同步</p>
|
</div>
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
导出文档
|
导出文档
|
||||||
|
|||||||
304
src/renderer/src/pages/reflection/views/export/index.vue
Normal file
304
src/renderer/src/pages/reflection/views/export/index.vue
Normal 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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
98
src/rpc/router/export.router.ts
Normal file
98
src/rpc/router/export.router.ts
Normal 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}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
20
src/shared/utils/path.ts
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user