feat(desktop): ✨ 实现MD文件模板导出方式
This commit is contained in:
@@ -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:
|
||||
- "**/*"
|
||||
|
||||
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 {
|
||||
constructor(private personaRepo: Repository<ReadingPersona>) {}
|
||||
|
||||
/**
|
||||
* 刷新画像并保存到数据库
|
||||
*/
|
||||
/**
|
||||
* 刷新画像并保存到数据库
|
||||
*/
|
||||
@@ -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<string, number>()
|
||||
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<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()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
@@ -67,12 +100,17 @@ export class PersonaService {
|
||||
return {
|
||||
cognition: Math.round(cognitionScore),
|
||||
breadth: Math.round(breadthScore),
|
||||
output: Math.round(outputScore),
|
||||
practicality: 75, // 可根据 occupation 比例动态计算
|
||||
global: 60, // 可根据 language 比例动态计算
|
||||
output: Math.min(100, Math.round(totalWords / 500)),
|
||||
practicality: 75,
|
||||
global: Math.round(globalScore),
|
||||
stats: {
|
||||
totalWords,
|
||||
totalBooks,
|
||||
totalHours,
|
||||
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']
|
||||
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']
|
||||
|
||||
@@ -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: '书库监控',
|
||||
|
||||
@@ -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<any[]>([])
|
||||
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<string, number>()
|
||||
|
||||
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,38 +82,91 @@ const handleDayClick = async (day: { date: string; count: number }) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white p-6 rounded-[24px] border border-slate-100 mt-6 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-xs font-black text-slate-400 uppercase tracking-widest">阅读贡献墙</h3>
|
||||
<p class="text-[10px] text-slate-400 font-medium">展示过去一年生成读书心得的频率</p>
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="flex flex-row items-center gap-2 mb-2">
|
||||
<BabyFeet theme="filled" size="18" fill="#7816ff" />
|
||||
<span class="text-sm font-black text-slate-800">阅读贡献足迹</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[10px] text-slate-400">Less</span>
|
||||
<div
|
||||
v-for="i in 5"
|
||||
:key="i"
|
||||
:class="getLevelClass(i - 1)"
|
||||
class="w-2.5 h-2.5 rounded-[2px]"
|
||||
></div>
|
||||
<span class="text-[10px] text-slate-400">More</span>
|
||||
<div class="flex bg-slate-50 p-1 rounded-xl border gap-2 border-slate-100">
|
||||
<a-button
|
||||
v-for="opt in [
|
||||
{ l: '近三月', v: 90 },
|
||||
{ l: '半年', v: 180 },
|
||||
{ l: '全年', v: 365 }
|
||||
]"
|
||||
:key="opt.v"
|
||||
@click="timeRange = opt.v"
|
||||
:class="[
|
||||
'px-3 py-1 text-[10px] font-black transition-all rounded-lg',
|
||||
timeRange === opt.v
|
||||
? 'bg-white text-[#7816ff] shadow-sm'
|
||||
: 'text-slate-400 hover:text-slate-600'
|
||||
]"
|
||||
>
|
||||
{{ opt.l }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div 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
|
||||
v-for="day in calendar"
|
||||
:key="day.date"
|
||||
:title="`${day.date}: ${day.count} 篇心得`"
|
||||
@click="handleDayClick(day)"
|
||||
class="w-3.5 h-3.5 rounded-[2px] transition-all duration-200 hover:scale-125 hover:z-10"
|
||||
class="w-[14px] h-[14px] rounded-[3px] transition-all duration-300 hover:scale-150 hover:z-20 hover:rounded-sm relative"
|
||||
:class="[
|
||||
getLevelClass(day.level),
|
||||
day.count > 0 ? 'cursor-pointer shadow-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>
|
||||
<span class="text-[9px] font-bold text-slate-400 uppercase tracking-widest">More</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
@@ -119,6 +179,7 @@ const handleDayClick = async (day: { date: string; count: number }) => {
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,6 @@ const emit = defineEmits(['close', 'select'])
|
||||
<template>
|
||||
<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="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">
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, nextTick } from 'vue'
|
||||
import { onMounted, ref, nextTick, computed } from 'vue'
|
||||
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 { trpc } from '@renderer/lib/trpc'
|
||||
import { IUserReadingPersona } from '@shared/types/IUserReadingPersona'
|
||||
@@ -9,13 +17,53 @@ import useLoading from '@renderer/hooks/useLoading'
|
||||
import ContributionWall from './components/ContributionWall.vue'
|
||||
|
||||
const chartRef = ref<HTMLElement | null>(null)
|
||||
const trendChartRef = ref<HTMLElement | null>(null)
|
||||
const personaData = ref<IUserReadingPersona | null>(null)
|
||||
const { loading, setLoading } = useLoading()
|
||||
const contributionData = ref<{ date: string; count: number }[]>([])
|
||||
let myChart: echarts.ECharts | null = null
|
||||
|
||||
// 核心方法:初始化或更新图表
|
||||
const renderChart = (data: IUserReadingPersona) => {
|
||||
let myChart: echarts.ECharts | null = null
|
||||
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 (!myChart) myChart = echarts.init(chartRef.value)
|
||||
|
||||
@@ -23,19 +71,14 @@ const renderChart = (data: IUserReadingPersona) => {
|
||||
radar: {
|
||||
indicator: [
|
||||
{ name: '认知深度', max: 100 },
|
||||
{ name: '产出效率', max: 100 }, // 对应 efficiencyScore
|
||||
{ name: '成熟度', max: 100 }, // 对应 maturityScore
|
||||
{ name: '知识广度', max: 100 }, // 对应 breadthScore
|
||||
{ name: '语言能力', max: 100 } // 对应 languageScore
|
||||
{ name: '产出效率', max: 100 },
|
||||
{ name: '成熟度', max: 100 },
|
||||
{ name: '知识广度', max: 100 },
|
||||
{ name: '语言能力', max: 100 }
|
||||
],
|
||||
shape: 'circle',
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: ['#F8F9FB', '#fff'],
|
||||
shadowColor: 'rgba(0, 0, 0, 0.05)',
|
||||
shadowBlur: 10
|
||||
}
|
||||
},
|
||||
radius: '65%',
|
||||
splitArea: { areaStyle: { color: ['#F8F9FB', '#fff'] } },
|
||||
axisLine: { lineStyle: { color: '#E5E7EB' } },
|
||||
splitLine: { lineStyle: { color: '#E5E7EB' } }
|
||||
},
|
||||
@@ -45,7 +88,6 @@ const renderChart = (data: IUserReadingPersona) => {
|
||||
data: [
|
||||
{
|
||||
value: [
|
||||
// 按照 indicator 的顺序填入分值
|
||||
data.domainDepth[0]?.score || 0,
|
||||
data.efficiencyScore,
|
||||
data.maturityScore,
|
||||
@@ -64,13 +106,95 @@ const renderChart = (data: IUserReadingPersona) => {
|
||||
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) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
if (force) await trpc.persona.forceRefreshUserPersona.mutate()
|
||||
|
||||
// 并行请求:提高响应速度
|
||||
const [persona, contributions] = await Promise.all([
|
||||
trpc.persona.getUserPersona.query(),
|
||||
trpc.persona.getContributionData.query()
|
||||
@@ -79,11 +203,13 @@ const fetchData = async (force = false) => {
|
||||
if (persona) {
|
||||
personaData.value = persona
|
||||
await nextTick()
|
||||
renderChart(persona)
|
||||
renderRadarChart(persona)
|
||||
}
|
||||
|
||||
if (contributions) {
|
||||
contributionData.value = contributions
|
||||
await nextTick()
|
||||
renderTrendChart(contributions)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('数据同步失败:', error)
|
||||
@@ -94,8 +220,10 @@ const fetchData = async (force = false) => {
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
// 窗口缩放自适应
|
||||
window.addEventListener('resize', () => myChart?.resize())
|
||||
window.addEventListener('resize', () => {
|
||||
myChart?.resize()
|
||||
trendChart?.resize()
|
||||
})
|
||||
})
|
||||
</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"
|
||||
>
|
||||
<BackPage title="返回应用菜单" />
|
||||
|
||||
<a-button
|
||||
@click="fetchData(true)"
|
||||
:disabled="loading"
|
||||
@@ -117,67 +244,77 @@ onMounted(() => {
|
||||
</a-button>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-y-auto relative z-10">
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<div class="bg-white p-8 rounded-xl shadow-sm border border-slate-100">
|
||||
<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" />
|
||||
|
||||
<!--暂无数据提示-->
|
||||
<main class="flex-1 overflow-y-auto relative z-10 pb-10">
|
||||
<div class="max-w-4xl mx-auto space-y-6 px-4">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div
|
||||
v-if="!personaData && !loading"
|
||||
class="h-[300px] flex flex-col items-center justify-center text-slate-400 gap-4"
|
||||
v-for="card in statsCards"
|
||||
: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">
|
||||
<chart-graph size="32" />
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<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>
|
||||
<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 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
|
||||
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="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" />
|
||||
<span class="text-md font-black">智能阅读报告</span>
|
||||
<span class="text-md font-black">阅读维度报告</span>
|
||||
</div>
|
||||
|
||||
<p class="text-[13px] leading-relaxed opacity-90 font-medium">
|
||||
<p class="text-[14px] leading-relaxed opacity-95 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">{{
|
||||
personaData.domainDepth[0]?.name || '未知领域'
|
||||
<span class="px-2 py-0.5 bg-white/20 rounded-md mx-1 font-bold">{{
|
||||
personaData.domainDepth[0]?.name || '探索'
|
||||
}}</span>
|
||||
领域表现出极高的探索欲。
|
||||
领域已初步建立知识框架。
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<div class="flex flex-wrap gap-2 mt-4">
|
||||
<span
|
||||
v-for="kw in personaData.stats.topKeywords"
|
||||
: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 }}
|
||||
</span>
|
||||
@@ -190,6 +327,7 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 保持原有的动画 */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
@@ -201,4 +339,9 @@ onMounted(() => {
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保留功能 */
|
||||
main::-webkit-scrollbar {
|
||||
width: 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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())
|
||||
<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"
|
||||
>
|
||||
<BackPage title="返回列表" />
|
||||
<BackPage :title="`阅读 | ${readingData?.title || ''}`" />
|
||||
<div class="flex items-center gap-2">
|
||||
<a-button
|
||||
size="mini"
|
||||
@@ -77,7 +80,7 @@ onMounted(() => fetchDetail())
|
||||
<a-button
|
||||
size="mini"
|
||||
class="rounded-lg bg-[#7816ff] text-white border-none"
|
||||
@click="handleExportMD"
|
||||
@click="handleExport"
|
||||
>
|
||||
<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
|
||||
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>
|
||||
<div class="flex-1 overflow-y-auto p-8 space-y-10 custom-scroll">
|
||||
<section class="space-y-4">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
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: {
|
||||
totalWords: number // 累计生成的心得总字数
|
||||
totalBooks: number // 累计阅读并生成过心得的书籍总数
|
||||
totalHours: number // 累计阅读并生成过心得的总时长
|
||||
topKeywords: string[] // 出现频率最高的 Top 10 关键词
|
||||
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