feat(desktop): 实现一些功能

1. 实现了用户阅读画像

2. 实现了全局检索功能
This commit is contained in:
2026-01-11 14:40:31 +08:00
parent 75cc9dc06d
commit 48fb287aa7
25 changed files with 1059 additions and 145 deletions

BIN
db.sqlite

Binary file not shown.

View File

@@ -0,0 +1,158 @@
## 如何设计离线阅读画像系统
### 1. 第一阶段:画像模型设计
画像不是简单的计数,而是将原始数据(书名、字数、关键词)转化为认知模型。我们定义以下五个核心指标:
认知深度 (Cognition)通过统计关键词Keywords的重复频次和专业程度。
知识广度 (Breadth):统计不同书籍领域的分布(基于书名聚类或人工分类)。
实践应用 (Practicality):识别 occupation 字段职业相关Professional的心得占比。
产出效率 (Output):计算总生成字数与任务完成率。
国际视野 (Global)统计英文en与中文zh任务的比例。
### 2. 第二阶段:数据库实体定义 (Prisma/TypeORM)
假设你使用常用的本地 ORM。我们需要在数据库中增加一个画像缓存表避免每次打开页面都进行全量计算。
```typescript
/**
* 阅读画像缓存实体
* 存储计算后的分值,避免高频计算
*/
export interface IReadingPersona {
id: string; // 固定 ID 如 'current_user_persona'
cognition: number; // 认知深度分 (0-100)
breadth: number; // 知识广度分 (0-100)
practicality: number; // 实践应用分 (0-100)
output: number; // 产出效率分 (0-100)
global: number; // 国际视野分 (0-100)
topKeywords: string; // 存储 Top 10 关键词的 JSON 字符串
updatedAt: Date; // 最后计算时间
}
```
### 3. 第三阶段:核心计算逻辑 (Main Process)
这是画像系统的“大脑”,负责执行 SQL 并转换数据。
```typescript
import { IReadingReflectionTaskItem, IReadingReflectionTaskBatch } from '../types'
export class PersonaService {
/**
* 从数据库聚合数据并计算画像分值
*/
async calculatePersona(items: IReadingReflectionTaskItem[], batches: IReadingReflectionTaskBatch[]) {
// 1. 计算认知深度:根据关键词频次
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 * 2) + (allKeywords.length / 5))
// 2. 计算知识广度:根据书籍数量
const breadthScore = Math.min(100, batches.length * 10)
// 3. 计算产出效率:根据总字数
const totalWords = items.reduce((sum, i) => sum + (i.content?.length || 0), 0)
const outputScore = Math.min(100, totalWords / 500) // 每 5万字满分
// 4. 计算 Top 10 关键词
const sortedKeywords = [...keywordMap.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(entry => entry[0])
return {
cognition: Math.round(cognitionScore),
breadth: Math.round(breadthScore),
output: Math.round(outputScore),
practicality: 75, // 可根据 occupation 比例动态计算
global: 60, // 可根据 language 比例动态计算
topKeywords: sortedKeywords
}
}
}
```
### 4. 第四阶段:前端可视化集成 (Statistics.vue)
使用 ECharts 渲染雷达图,将计算结果展现给用户。
```vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import * as echarts from 'echarts'
import { trpc } from '@renderer/lib/trpc'
const chartRef = ref<HTMLElement | null>(null)
const personaData = ref<any>(null)
const initChart = (data: any) => {
const myChart = echarts.init(chartRef.value!)
const option = {
radar: {
indicator: [
{ name: '认知深度', max: 100 },
{ name: '知识广度', max: 100 },
{ name: '实践应用', max: 100 },
{ name: '产出效率', max: 100 },
{ name: '国际视野', max: 100 }
],
shape: 'circle',
splitNumber: 4,
axisLine: { lineStyle: { color: '#E5E7EB' } },
splitLine: { lineStyle: { color: '#E5E7EB' } },
splitArea: { areaStyle: { color: ['#fff', '#F8F9FB'] } }
},
series: [{
type: 'radar',
data: [{
value: [data.cognition, data.breadth, data.practicality, data.output, data.global],
areaStyle: { color: 'rgba(120, 22, 255, 0.2)' },
lineStyle: { color: '#7816ff', width: 3 },
itemStyle: { color: '#7816ff' }
}]
}]
}
myChart.setOption(option)
}
onMounted(async () => {
// 通过 tRPC 从主进程获取计算好的数据
const data = await trpc.stats.getPersona.query()
personaData.value = data
initChart(data)
})
</script>
<template>
<div class="p-8 bg-white rounded-[32px] border border-slate-100 shadow-sm">
<div class="mb-6">
<h2 class="text-xl font-black text-slate-800">阅读心流画像</h2>
<p class="text-xs text-slate-400">基于本地 {{ personaData?.totalBooks }} 本书籍分析得出</p>
</div>
<div ref="chartRef" class="w-full h-[400px]"></div>
<div class="mt-8 flex flex-wrap gap-2">
<span v-for="tag in personaData?.topKeywords" :key="tag"
class="px-3 py-1 bg-slate-50 text-slate-500 text-[10px] rounded-full border border-slate-100">
# {{ tag }}
</span>
</div>
</div>
</template>
```
## 🎨 设计心得:为什么这么做?
数据解耦原始任务数据Items/Batches与画像数据分开存储。即使画像计算逻辑升级比如你想改变评分算法也不需要修改历史心得数据。
缓存策略:不要在用户每次切换页面时都去扫全表。建议在任务完成时触发一次增量计算,或者每天用户第一次打开应用时刷新一次。
可视化反馈:雷达图的面积代表了用户的“知识疆域”。当用户看着面积一点点变大时,这种离线的成就感是留存的关键。

View File

@@ -42,6 +42,7 @@
"electron-store": "^6.0.1",
"electron-trpc": "^0.7.1",
"electron-updater": "^6.3.9",
"flexsearch": "^0.8.212",
"html2canvas": "^1.4.1",
"langchain": "^1.2.4",
"mitt": "^3.0.1",

8
pnpm-lock.yaml generated
View File

@@ -50,6 +50,9 @@ importers:
electron-updater:
specifier: ^6.3.9
version: 6.6.2
flexsearch:
specifier: ^0.8.212
version: 0.8.212
html2canvas:
specifier: ^1.4.1
version: 1.4.1
@@ -2246,6 +2249,9 @@ packages:
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
flexsearch@0.8.212:
resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==}
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
@@ -6408,6 +6414,8 @@ snapshots:
flatted@3.3.3: {}
flexsearch@0.8.212: {}
for-each@0.3.5:
dependencies:
is-callable: 1.2.7

View File

@@ -2,29 +2,34 @@ import 'reflect-metadata'
import { DataSource } from 'typeorm'
import { app } from 'electron'
import path from 'path'
import { ReadingReflectionTaskBatch } from '@main/db/entities/ReadingReflectionTaskBatch'
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
import { ReadingReflectionTaskBatch } from './entities/ReadingReflectionTaskBatch'
import { ReadingReflectionTaskItem } from './entities/ReadingReflectionTaskItem'
import { ReadingPersona } from './entities/ReadingPersona' // 必须导入
const dbPath = app.isPackaged
? path.join(app.getPath('userData'), 'reflections.db')
: path.join(process.cwd(), 'db.sqlite')
console.log('--- 数据库存储绝对路径 ---')
console.log(dbPath)
console.log('-----------------------')
export const AppDataSource = new DataSource({
type: 'better-sqlite3',
type: 'better-sqlite3', // better-sqlite3 性能优于 sqlite3
database: dbPath,
synchronize: true, // 开发环境下自动同步表结构
logging: true,
entities: [ReadingReflectionTaskBatch, ReadingReflectionTaskItem],
migrations: [],
subscribers: []
synchronize: true,
logging: process.env.NODE_ENV === 'development', // 仅开发环境开启日志
entities: [
ReadingReflectionTaskBatch,
ReadingReflectionTaskItem,
ReadingPersona // 注册实体
]
})
// 初始化方法,在 Electron app.whenReady() 中调用
export const initDB = async () => {
if (!AppDataSource.isInitialized) {
await AppDataSource.initialize()
console.log('TypeORM SQLite Data Source has been initialized!')
try {
await AppDataSource.initialize()
console.log('Database initialized successfully at:', dbPath)
} catch (err) {
console.error('Error during Data Source initialization', err)
}
}
return AppDataSource
}

View File

@@ -0,0 +1,32 @@
import { Entity, Column, PrimaryColumn, UpdateDateColumn } from 'typeorm'
@Entity('reading_personas')
export class ReadingPersona {
@PrimaryColumn({ type: 'varchar', default: 'current_user_persona' })
id: string
@Column({ type: 'int', default: 0 })
cognition: number
@Column({ type: 'int', default: 0 })
breadth: number
@Column({ type: 'int', default: 0 })
practicality: number
@Column({ type: 'int', default: 0 })
output: number
@Column({ type: 'int', default: 0 })
global: number
@Column({ type: 'text', nullable: true })
topKeywords: string
// SQLite 推荐使用 simple-json 来处理对象映射
@Column({ type: 'simple-json', nullable: true })
rawStats: any
@UpdateDateColumn()
updatedAt: Date
}

View File

@@ -0,0 +1,100 @@
import { Repository } from 'typeorm'
import { ReadingPersona } from '@main/db/entities/ReadingPersona'
import {
IReadingReflectionTaskBatch,
IReadingReflectionTaskItem
} from '@shared/types/IReadingReflectionTask'
import { IUserReadingPersona } from '@shared/types/IUserReadingPersona'
export class PersonaService {
constructor(private personaRepo: Repository<ReadingPersona>) {}
/**
* 刷新画像并保存到数据库
*/
async refreshPersona(
items: IReadingReflectionTaskItem[],
batches: IReadingReflectionTaskBatch[]
) {
const rawResult = await this.calculatePersona(items, batches) // 调用你原来的计算逻辑
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 结构以便前端适配
persona.rawStats = {
totalWords: items.reduce((sum, i) => sum + (i.content?.length || 0), 0),
totalBooks: batches.length,
topKeywords: rawResult.topKeywords
}
return await this.personaRepo.save(persona)
}
/**
* 从数据库聚合数据并计算画像分值
*/
async calculatePersona(
items: IReadingReflectionTaskItem[],
batches: IReadingReflectionTaskBatch[]
) {
// 1. 计算认知深度:根据关键词频次
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 * 2 + allKeywords.length / 5)
// 2. 计算知识广度:根据书籍数量
const breadthScore = Math.min(100, batches.length * 10)
// 3. 计算产出效率:根据总字数
const totalWords = items.reduce((sum, i) => sum + (i.content?.length || 0), 0)
const outputScore = Math.min(100, totalWords / 500) // 每 5万字满分
// 4. 计算 Top 10 关键词
const sortedKeywords = [...keywordMap.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map((entry) => entry[0])
return {
cognition: Math.round(cognitionScore),
breadth: Math.round(breadthScore),
output: Math.round(outputScore),
practicality: 75, // 可根据 occupation 比例动态计算
global: 60, // 可根据 language 比例动态计算
topKeywords: sortedKeywords
}
}
}
/**
* 实体转化为用户阅读画像
* @param entity 实体
* */
export function entityToUserReadingPersona(entity: ReadingPersona): IUserReadingPersona {
return {
domainDepth: JSON.parse(entity.topKeywords || '[]').map((name: string) => ({
name,
score: entity.cognition, // 简易算法:共用认知深度分
bookCount: 1 // 可根据数据库详细统计进一步细化
})),
breadthScore: entity.breadth,
efficiencyScore: entity.output,
maturityScore: entity.practicality,
languageScore: entity.global,
stats: entity.rawStats || {
totalWords: 0,
totalBooks: 0,
topKeywords: [],
mostUsedOccupation: 'other'
}
}
}

View File

@@ -0,0 +1,104 @@
import { Document } from 'flexsearch'
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
import { ISearch, HighlightInfo } from '@shared/types/ISearch'
export class SearchService {
private index: any
constructor() {
// 初始化 FlexSearch 文档索引
this.index = new Document({
document: {
id: 'id',
index: ['title', 'content', 'bookName'],
store: true // 存储原始数据以便快速返回
},
tokenize: 'forward', // 适配中文的简易分词
context: true,
cache: true
})
}
/**
* 初始化索引:从数据库加载历史数据
*/
async initIndex(items: ReadingReflectionTaskItem[]) {
items.forEach((item) => {
this.index.add({
id: item.id,
title: item.title,
content: item.content,
bookName: (item as any).bookName || '未知书籍'
})
})
}
/**
* 全局搜索核心方法
*/
async search(query: string): Promise<ISearch[]> {
if (!query) return []
// 执行搜索
const results = await this.index.search(query, {
limit: 20,
enrich: true, // 返回 store 的数据
suggest: true
})
const searchResults: ISearch[] = []
// 扁平化 FlexSearch 的结果并计算高亮坐标
results.forEach((category: any) => {
category.result.forEach((item: any) => {
const doc = item.doc
// 避免重复项(因为一个文档可能在多个字段命中)
if (searchResults.find((r) => r.id === doc.id)) return
searchResults.push({
id: doc.id,
title: doc.title,
content: doc.content,
bookName: doc.bookName,
createdAt: new Date().toISOString(), // 实际应从数据库字段获取
// 计算高亮坐标
titleHighlights: this.calculateHighlights(doc.title, query),
contentHighlights: this.calculateHighlights(doc.content, query),
bookHighlights: this.calculateHighlights(doc.bookName, query),
contentSnippet: this.createSnippet(doc.content, query)
})
})
})
return searchResults
}
/**
* 简单的坐标计算逻辑
*/
private calculateHighlights(text: string, query: string): HighlightInfo[] {
const highlights: HighlightInfo[] = []
if (!text || !query) return highlights
let index = text.indexOf(query)
while (index !== -1) {
highlights.push({ start: index, length: query.length })
index = text.indexOf(query, index + query.length)
}
return highlights
}
/**
* 生成正文摘要预览
*/
private createSnippet(content: string, query: string): string {
const index = content.indexOf(query)
if (index === -1) return content.substring(0, 100)
const start = Math.max(0, index - 40)
const end = Math.min(content.length, index + 60)
return (
(start > 0 ? '...' : '') + content.substring(start, end) + (end < content.length ? '...' : '')
)
}
}

View File

@@ -0,0 +1,19 @@
import { ref } from 'vue';
/**
* 加载状态Hook
*/
export default function useLoading(initValue = false) {
const loading = ref(initValue);
const setLoading = (value: boolean) => {
loading.value = value;
};
const toggle = () => {
loading.value = !loading.value;
};
return {
loading,
setLoading,
toggle,
};
}

View File

@@ -21,12 +21,12 @@ export const features = [
path: 'search'
},
{
id: 'statistics',
id: 'userPersona',
title: '阅读画像',
desc: '可视化你的知识边界与偏好',
icon: TrendTwo,
color: '#00b42a',
path: 'statistics'
path: 'userPersona'
}
]
},

View File

@@ -101,10 +101,6 @@ const { title } = toRefs(props)
transform: translateY(-10px);
}
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
@keyframes shine {
from {
transform: translateX(-100%);
@@ -130,4 +126,15 @@ const { title } = toRefs(props)
.animate-in {
animation: fadeIn 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
import { getHighlightedSegments } from '../utils/highlighter'
const props = defineProps<{
text: string
highlights?: { start: number; length: number }[]
}>()
const segments = computed(() => getHighlightedSegments(props.text, props.highlights || []))
</script>
<template>
<span>
<template v-for="(seg, index) in segments" :key="index">
<mark v-if="seg.isHighlight" class="highlight-item">{{ seg.text }}</mark>
<span v-else>{{ seg.text }}</span>
</template>
</span>
</template>
<style scoped>
.highlight-item {
background-color: #f5f3ff; /* 浅紫色背景 */
color: #7816ff; /* 你的品牌紫色 */
font-weight: 700;
border-radius: 2px;
padding: 0 1px;
}
</style>

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { Left, Search, Close, DocumentFolder, BookmarkOne } from '@icon-park/vue-next'
import useRouterHook from '@renderer/hooks/useRouterHook'
import { trpc } from '@renderer/lib/trpc'
import { ISearch } from '@shared/types/ISearch' // 假设你已定义了搜索结果接口
import HighlightText from './components/HighlightText.vue'
import BackPage from '@renderer/components/BackPage.vue'
const { goBack, go } = useRouterHook()
const searchQuery = ref('')
const searchResults = ref<ISearch[]>([])
const isLoading = ref(false)
// 实时搜索逻辑
let searchTimeout: NodeJS.Timeout | null = null
watch(searchQuery, (newVal) => {
if (searchTimeout) clearTimeout(searchTimeout)
if (newVal.trim() === '') {
searchResults.value = []
return
}
searchTimeout = setTimeout(() => {
performSearch(newVal)
}, 300) // 300ms 防抖
})
const performSearch = async (query: string) => {
isLoading.value = true
try {
// 假设 tRPC 提供了 search 接口
searchResults.value = await trpc.search.search.query(query)
} catch (error) {
console.error('搜索失败:', error)
searchResults.value = []
} finally {
isLoading.value = false
}
}
const clearSearch = () => {
searchQuery.value = ''
searchResults.value = []
}
const goToDetail = (result: ISearch) => {
// 假设心得详情页路由是 /reflection/:id
go('/reflection', {
id: result.id
})
}
// 模拟快捷键监听(实际需要 Electron 主进程处理)
onMounted(() => {})
</script>
<template>
<div class="h-full w-full flex flex-col bg-[#F8F9FB] overflow-hidden animate-in">
<header
class="h-16 px-8 flex items-center justify-between relative z-20 backdrop-blur-md bg-white/70 border-b border-slate-100"
>
<BackPage title="全局检索" />
</header>
<main class="flex-1 flex flex-col items-center p-8 pt-12 relative overflow-hidden">
<div
class="w-full max-w-2xl relative flex items-center bg-white rounded-2xl shadow-lg border border-slate-100 focus-within:border-[#7816ff] transition-all duration-200"
>
<search size="20" class="absolute left-5 text-slate-400" />
<input
id="search-input"
v-model="searchQuery"
type="text"
placeholder="搜索书名、心得、关键词..."
class="flex-1 px-14 py-4 text-sm font-medium text-slate-700 bg-transparent outline-none placeholder:text-slate-400"
@keydown.esc="clearSearch"
/>
<transition name="fade">
<button
v-if="searchQuery"
@click="clearSearch"
class="absolute right-5 w-6 h-6 rounded-full bg-slate-100 text-slate-500 flex items-center justify-center hover:bg-slate-200 transition-colors"
>
<close size="12" />
</button>
</transition>
</div>
<div
class="w-full max-w-2xl mt-8 flex flex-col gap-4 overflow-y-auto custom-scroll transition-all duration-300"
:class="{ 'opacity-50': isLoading }"
>
<div v-if="isLoading" class="flex justify-center items-center py-8 text-slate-400 text-sm">
<search size="20" class="animate-pulse mr-2" />
正在搜索...
</div>
<div
v-else-if="searchQuery && searchResults.length === 0"
class="flex flex-col items-center py-8 text-slate-400 text-sm gap-2"
>
<document-folder size="32" />
<p>没有找到相关的心得</p>
</div>
<div
v-else-if="!searchQuery"
class="flex flex-col items-center py-8 text-slate-400 text-sm gap-2"
>
<bookmark-one size="32" />
<p>请输入关键词开始搜索</p>
</div>
<div
v-for="result in searchResults"
:key="result.id"
@click="goToDetail(result)"
class="search-result-card group cursor-pointer"
>
<div class="flex items-start gap-4">
<div
class="w-10 h-10 rounded-lg flex-shrink-0 bg-purple-50 flex items-center justify-center"
>
<bookmark-one size="20" fill="#7816ff" />
</div>
<div class="flex-1 overflow-hidden">
<h3 class="text-sm font-bold text-slate-800 group-hover:text-[#7816ff]">
<HighlightText :text="result.title" :highlights="result.titleHighlights" />
</h3>
<p class="text-[11px] text-slate-500 mt-1 leading-normal line-clamp-2">
<HighlightText
:text="result.contentSnippet || result.content"
:highlights="result.contentHighlights"
/>
</p>
<p class="text-[10px] text-slate-400 mt-2">
<span class="font-semibold">{{ result.bookName }}</span> ·
<span class="ml-1">{{ new Date(result.createdAt).toLocaleDateString() }}</span>
</p>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<style scoped>
.search-result-card {
@apply bg-white p-4 rounded-xl shadow-sm border border-slate-100 cursor-pointer
transition-all duration-200 hover:shadow-md hover:border-[#7816ff]/30 active:scale-[0.99];
}
.custom-scroll::-webkit-scrollbar {
width: 4px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: fadeIn 0.4s ease-out forwards;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.highlight-item {
background-color: #f5f3ff; /* 浅紫色背景 */
color: #7816ff; /* 你的品牌紫色 */
font-weight: 700;
border-radius: 2px;
padding: 0 1px;
}
</style>

View File

@@ -0,0 +1,51 @@
// 定义高亮片段的结构
export interface HighlightSegment {
text: string
isHighlight: boolean
}
/**
* 将原始文本根据高亮索引转化为分段数组
*/
export function getHighlightedSegments(
text: string,
highlights: { start: number; length: number }[]
): HighlightSegment[] {
if (!highlights || highlights.length === 0) {
return [{ text, isHighlight: false }]
}
// 1. 按照起始位置排序,防止索引乱序导致切片失败
const sortedHighlights = [...highlights].sort((a, b) => a.start - b.start)
const segments: HighlightSegment[] = []
let lastIndex = 0
for (const { start, length } of sortedHighlights) {
// 处理匹配项之前的普通文本
if (start > lastIndex) {
segments.push({
text: text.substring(lastIndex, start),
isHighlight: false
})
}
// 处理匹配到的高亮文本
segments.push({
text: text.substring(start, start + length),
isHighlight: true
})
lastIndex = start + length
}
// 处理剩余的尾部文本
if (lastIndex < text.length) {
segments.push({
text: text.substring(lastIndex),
isHighlight: false
})
}
return segments
}

View File

@@ -1,92 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import * as echarts from 'echarts'
import { ChartGraph, TrendTwo, Local } from '@icon-park/vue-next'
import BackPage from '@renderer/components/BackPage.vue'
const chartRef = ref<HTMLElement | null>(null)
const initChart = () => {
if (!chartRef.value) return
const myChart = echarts.init(chartRef.value)
const option = {
radar: {
// 这里的指示器可以根据用户本地数据的标签动态生成
indicator: [
{ name: '认知深度', max: 100 },
{ name: '职场技能', max: 100 },
{ name: '人文素养', max: 100 },
{ name: '技术探索', max: 100 },
{ name: '生活哲学', max: 100 }
],
splitArea: {
areaStyle: {
color: ['#F8F9FB', '#fff'],
shadowColor: 'rgba(0, 0, 0, 0.05)',
shadowBlur: 10
}
},
axisLine: { lineStyle: { color: '#E5E7EB' } },
splitLine: { lineStyle: { color: '#E5E7EB' } }
},
series: [
{
type: 'radar',
data: [
{
value: [85, 72, 90, 65, 80], // 这里接入本地统计的真实数据
name: '阅读画像',
itemStyle: { color: '#7816ff' },
areaStyle: { color: 'rgba(120, 22, 255, 0.1)' },
lineStyle: { width: 3 }
}
]
}
]
}
myChart.setOption(option)
}
onMounted(() => initChart())
</script>
<template>
<div class="h-full w-full flex flex-col gap-xl relative overflow-hidden">
<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="返回应用菜单" />
</header>
<main class="flex-1 bg-white overflow-y-auto relative z-10 p-4 bg-white rounded-xl">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-purple-50 flex items-center justify-center">
<chart-graph theme="outline" size="22" fill="#7816ff" />
</div>
<div class="flex flex-col">
<span class="text-sm font-bold text-slate-800">个人阅读画像</span>
<p class="text-[10px] text-slate-400 uppercase tracking-widest">你的阅读指导专家</p>
</div>
</div>
</div>
<div ref="chartRef" class="w-full h-[320px]"></div>
<div class="p-4 bg-[#7816ff]/5 rounded-2xl border border-[#7816ff]/10">
<div class="flex items-start gap-3 flex-col">
<div class="flex flex-row gap-sm">
<trend-two theme="outline" size="16" fill="#7816ff" />
<span class="text-sm font-bold text-[#7816ff]">你的阅读分享报告</span>
</div>
<span class="text-[11px] text-[#7816ff] leading-relaxed font-medium">
基于你最近生成的 12
篇心得分析你在<strong>人文素养</strong>领域积累深厚近期对<strong>认知科学</strong>的关注度上升了
24%
</span>
</div>
</div>
</main>
</div>
</template>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import { onMounted, ref, nextTick } from 'vue'
import * as echarts from 'echarts'
import { ChartGraph, TrendTwo, Refresh } from '@icon-park/vue-next'
import BackPage from '@renderer/components/BackPage.vue'
import { trpc } from '@renderer/lib/trpc'
import { IUserReadingPersona } from '@shared/types/IUserReadingPersona'
import useLoading from '@renderer/hooks/useLoading'
const chartRef = ref<HTMLElement | null>(null)
const personaData = ref<IUserReadingPersona | null>(null)
const { loading, setLoading } = useLoading()
let myChart: echarts.ECharts | null = null
// 核心方法:初始化或更新图表
const renderChart = (data: IUserReadingPersona) => {
if (!chartRef.value) return
if (!myChart) myChart = echarts.init(chartRef.value)
const option = {
radar: {
indicator: [
{ name: '认知深度', max: 100 },
{ name: '产出效率', max: 100 }, // 对应 efficiencyScore
{ name: '成熟度', max: 100 }, // 对应 maturityScore
{ name: '知识广度', max: 100 }, // 对应 breadthScore
{ name: '语言能力', max: 100 } // 对应 languageScore
],
shape: 'circle',
splitArea: {
areaStyle: {
color: ['#F8F9FB', '#fff'],
shadowColor: 'rgba(0, 0, 0, 0.05)',
shadowBlur: 10
}
},
axisLine: { lineStyle: { color: '#E5E7EB' } },
splitLine: { lineStyle: { color: '#E5E7EB' } }
},
series: [
{
type: 'radar',
data: [
{
value: [
// 按照 indicator 的顺序填入分值
data.domainDepth[0]?.score || 0,
data.efficiencyScore,
data.maturityScore,
data.breadthScore,
data.languageScore
],
name: '阅读画像',
itemStyle: { color: '#7816ff' },
areaStyle: { color: 'rgba(120, 22, 255, 0.15)' },
lineStyle: { width: 3 }
}
]
}
]
}
myChart.setOption(option)
}
// 获取数据
const fetchData = async (force = false) => {
setLoading(true)
try {
if (force) {
await trpc.persona.forceRefreshUserPersona.mutate()
}
const res = await trpc.persona.getUserPersona.query()
if (res) {
personaData.value = res
await nextTick()
renderChart(res)
}
} catch (error) {
console.error('获取画像失败:', error)
} finally {
setLoading(false)
}
}
onMounted(() => {
fetchData()
// 窗口缩放自适应
window.addEventListener('resize', () => myChart?.resize())
})
</script>
<template>
<div class="h-full w-full flex flex-col gap-xl relative overflow-hidden bg-[#F8F9FB]">
<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="返回应用菜单" />
<a-button
@click="fetchData(true)"
:disabled="loading"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all active:scale-95"
:class="loading ? 'text-slate-300' : 'text-[#7816ff] hover:bg-purple-50'"
>
<refresh :class="{ 'animate-spin': loading }" />
{{ loading ? '同步中...' : '同步最新画像' }}
</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>
<!--用户画像展示-->
<div v-show="personaData" ref="chartRef" class="w-full h-[300px]"></div>
<!--暂无数据提示-->
<div
v-if="!personaData && !isLoading"
class="h-[300px] flex flex-col items-center justify-center text-slate-400 gap-4"
>
<div class="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center">
<chart-graph size="32" />
</div>
<p class="text-sm font-medium">暂无画像数据请点击上方同步</p>
</div>
</div>
<div
v-if="personaData"
class="p-6 bg-[#7816ff] rounded-[32px] text-white shadow-xl shadow-purple-200/50 relative overflow-hidden"
>
<div class="absolute -right-10 -top-10 w-40 h-40 bg-white/10 rounded-full blur-3xl"></div>
<div class="relative z-10 flex flex-col">
<div class="flex flex-row items-center gap-2">
<trend-two theme="outline" size="20" fill="#fff" />
<span class="text-md font-black">智能阅读报告</span>
</div>
<p class="text-[13px] leading-relaxed opacity-90 font-medium">
基于你累计生成的
<span class="text-yellow-300 font-bold">{{ personaData.stats.totalWords }}</span>
字心得分析 你在
<span class="underline underline-offset-4 decoration-yellow-300">{{
personaData.domainDepth[0]?.name || '未知领域'
}}</span>
领域表现出极高的探索欲
</p>
<div class="flex flex-wrap gap-2 mt-2">
<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"
>
# {{ kw }}
</span>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<style scoped>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

View File

@@ -26,9 +26,14 @@ const routes: RouteRecordRaw[] = [
component: () => import('@renderer/pages/reflection/views/poster/index.vue')
},
{
path: '/menus/statistics',
name: 'ReadingStatistics',
component: () => import('@renderer/pages/menus/views/statistics/index.vue')
path: '/menus/userPersona',
name: 'ReadingUserPersona',
component: () => import('@renderer/pages/menus/views/userPersona/index.vue')
},
{
path: '/menus/search',
name: 'ReadingSearch',
component: () => import('@renderer/pages/menus/views/search/index.vue')
},
{
path: '/menus/:pathMatch(.*)*',

View File

@@ -1,5 +1,7 @@
import { BrowserWindow } from 'electron'
import type { CreateContextOptions } from 'electron-trpc/main'
import { AppDataSource } from '@main/db/data-source'
import { DataSource } from 'typeorm'
/**
* 1. 定义 Context 的结构
@@ -11,6 +13,7 @@ export interface AppContext {
webContentsId: number
// 可以在这里扩展用户信息,例如从 Session 中获取
user?: { id: string; role: string }
db: DataSource
}
/**
@@ -27,7 +30,8 @@ export const createContext = async (opts: CreateContextOptions): Promise<AppCont
window,
webContentsId: event.sender.id,
// 示例:可以根据某些逻辑注入额外信息
user: { id: 'current-user', role: 'admin' }
user: { id: 'current-user', role: 'admin' },
db: AppDataSource
}
}

View File

@@ -2,11 +2,15 @@ import { router } from './index'
import { taskRouter } from '@rpc/router/task.router'
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'
export const appRouter = router({
task: taskRouter,
config: configRouter,
notice: noticeRouter
notice: noticeRouter,
persona: personaRouter,
search: searchRouter
})
export type AppRouter = typeof appRouter

View File

@@ -0,0 +1,30 @@
import { ReadingPersona } from '@main/db/entities/ReadingPersona'
import { router, t } from '@rpc/index'
import { entityToUserReadingPersona, PersonaService } from '@main/services/persona.service'
import { ReadingReflectionTaskBatch } from '@main/db/entities/ReadingReflectionTaskBatch'
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
export const personaRouter = router({
// 获取用户画像:直接从数据库读取缓存结果,极快
getUserPersona: t.procedure.query(async ({ ctx }) => {
const entity = await ctx.db.getRepository(ReadingPersona).findOneBy({
id: 'current_user_persona'
})
if (!entity) return null
// 将数据库扁平实体映射为前端需要的结构化 IUserReadingPersona
return entityToUserReadingPersona(entity)
}),
// 手动强制重新刷新画像
forceRefreshUserPersona: t.procedure.mutation(async ({ ctx }) => {
const items = await ctx.db
.getRepository(ReadingReflectionTaskItem)
.find({ where: { status: 'COMPLETED' } })
const batches = await ctx.db.getRepository(ReadingReflectionTaskBatch).find()
const personaService = new PersonaService(ctx.db.getRepository(ReadingPersona))
return await personaService.refreshPersona(items, batches)
})
})

View File

@@ -0,0 +1,19 @@
import { router, t } from '@rpc/index'
import { z } from 'zod'
import { SearchService } from '@main/services/search.service'
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
// 在主进程启动时实例化并初始化
let searchService: SearchService | null = null
export const searchRouter = router({
search: t.procedure.input(z.string()).query(async ({ input, ctx }) => {
if (!searchService) {
searchService = new SearchService()
// 从数据库加载一次
const items = await ctx.db.getRepository(ReadingReflectionTaskItem).find()
await searchService.initIndex(items)
}
return await searchService.search(input)
})
})

View File

@@ -21,41 +21,27 @@ export const IReadingReflectionsTaskSchema = z.object({
})
export type IReadingReflectionsTask = z.infer<typeof IReadingReflectionsTaskSchema>
/**
* 任务响应结果模型
*/
export interface IReadingReflectionsResponse {
taskId: string
reflections: Array<{
title: string // 心得标题
content: string // 心得正文
keywords: string[] // 提取的关键词
summary: string // 内容摘要
}>
status: 'pending' | 'completed' | 'failed'
}
/**
* 对应数据库中的批次记录 (主任务)
*/
export interface IReadingReflectionTaskBatch {
id: string
bookName: string
totalCount: number
status: string
progress: number
createdAt: Date
items?: IReadingReflectionTaskItem[]
id: string //任务ID
bookName: string // 书籍名称
totalCount: number // 生成数量
status: string // 生成状态
progress: number // 进度
createdAt: Date // 创建时间
items?: IReadingReflectionTaskItem[] // 子任务模块
}
/**
* 对应数据库中的具体任务项 (子任务)
*/
export interface IReadingReflectionTaskItem {
id: string
status: 'PENDING' | 'WRITING' | 'COMPLETED' | 'FAILED'
progress: number
content?: string
title?: string
summary?: string
keywords?: string[]
id: string // 子任务ID
status: 'PENDING' | 'WRITING' | 'COMPLETED' | 'FAILED' // 任务状态
progress: number // 进度
content?: string // 生成内容
title?: string // 生成标题
summary?: string // 生成摘要
keywords?: string[] // 关键词
}

View File

@@ -0,0 +1,24 @@
/**
* 高亮坐标信息
*/
export interface HighlightInfo {
start: number // 关键词在原始字符串中的起始索引(从 0 开始)
length: number // 关键词的长度
}
/**
* 完整的搜索结果接口
*/
export interface ISearch {
id: string
bookName: string
title: string
content: string
contentSnippet: string // 截取的正文片段
createdAt: string
// 对应的坐标数组
titleHighlights: HighlightInfo[]
contentHighlights: HighlightInfo[]
bookHighlights: HighlightInfo[]
}

View File

@@ -0,0 +1,36 @@
/**
* 用户阅读画像统计模型
*/
export interface IUserReadingPersona {
// 维度 1: 领域深度 (Domain Depth)
// 根据关键词(keywords)的聚合频次计算,反映用户在特定领域的钻研程度
domainDepth: {
name: string // 领域名称 (如:认知心理学、前端技术)
score: number // 0-100 的得分
bookCount: number // 该领域下的书籍数量
}[]
// 维度 2: 知识广度 (Knowledge Breadth)
// 根据书名(bookName)和关键词的跨度计算,反映阅读类别的多样性
breadthScore: number
// 维度 3: 产出效率 (Output Efficiency)
// 基于 wordCount(要求字数) 与实际 content(生成内容) 的比例,以及完成频率
efficiencyScore: number
// 维度 4: 角色成熟度 (Persona Maturity)
// 基于 occupation(职业) 字段。例如 student 偏向基础吸收researcher 偏向深度批判
maturityScore: number
// 维度 5: 语言能力 (Language Versatility)
// 基于 language(zh/en) 的分布比例
languageScore: number
// 原始统计辅助数据
stats: {
totalWords: number // 累计生成的心得总字数
totalBooks: number // 累计阅读并生成过心得的书籍总数
topKeywords: string[] // 出现频率最高的 Top 10 关键词
mostUsedOccupation: string // 最常使用的阅读者身份
}
}