feat(desktop): ✨ 实现一些功能
1. 实现了用户阅读画像 2. 实现了全局检索功能
This commit is contained in:
158
note/用户画像设计方案.md
Normal file
158
note/用户画像设计方案.md
Normal 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)与画像数据分开存储。即使画像计算逻辑升级(比如你想改变评分算法),也不需要修改历史心得数据。
|
||||||
|
|
||||||
|
缓存策略:不要在用户每次切换页面时都去扫全表。建议在任务完成时触发一次增量计算,或者每天用户第一次打开应用时刷新一次。
|
||||||
|
|
||||||
|
可视化反馈:雷达图的面积代表了用户的“知识疆域”。当用户看着面积一点点变大时,这种离线的成就感是留存的关键。
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"electron-store": "^6.0.1",
|
"electron-store": "^6.0.1",
|
||||||
"electron-trpc": "^0.7.1",
|
"electron-trpc": "^0.7.1",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
|
"flexsearch": "^0.8.212",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"langchain": "^1.2.4",
|
"langchain": "^1.2.4",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -50,6 +50,9 @@ importers:
|
|||||||
electron-updater:
|
electron-updater:
|
||||||
specifier: ^6.3.9
|
specifier: ^6.3.9
|
||||||
version: 6.6.2
|
version: 6.6.2
|
||||||
|
flexsearch:
|
||||||
|
specifier: ^0.8.212
|
||||||
|
version: 0.8.212
|
||||||
html2canvas:
|
html2canvas:
|
||||||
specifier: ^1.4.1
|
specifier: ^1.4.1
|
||||||
version: 1.4.1
|
version: 1.4.1
|
||||||
@@ -2246,6 +2249,9 @@ packages:
|
|||||||
flatted@3.3.3:
|
flatted@3.3.3:
|
||||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
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:
|
for-each@0.3.5:
|
||||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -6408,6 +6414,8 @@ snapshots:
|
|||||||
|
|
||||||
flatted@3.3.3: {}
|
flatted@3.3.3: {}
|
||||||
|
|
||||||
|
flexsearch@0.8.212: {}
|
||||||
|
|
||||||
for-each@0.3.5:
|
for-each@0.3.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-callable: 1.2.7
|
is-callable: 1.2.7
|
||||||
|
|||||||
@@ -2,29 +2,34 @@ import 'reflect-metadata'
|
|||||||
import { DataSource } from 'typeorm'
|
import { DataSource } from 'typeorm'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { ReadingReflectionTaskBatch } from '@main/db/entities/ReadingReflectionTaskBatch'
|
import { ReadingReflectionTaskBatch } from './entities/ReadingReflectionTaskBatch'
|
||||||
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
|
import { ReadingReflectionTaskItem } from './entities/ReadingReflectionTaskItem'
|
||||||
|
import { ReadingPersona } from './entities/ReadingPersona' // 必须导入
|
||||||
|
|
||||||
const dbPath = app.isPackaged
|
const dbPath = app.isPackaged
|
||||||
? path.join(app.getPath('userData'), 'reflections.db')
|
? path.join(app.getPath('userData'), 'reflections.db')
|
||||||
: path.join(process.cwd(), 'db.sqlite')
|
: path.join(process.cwd(), 'db.sqlite')
|
||||||
console.log('--- 数据库存储绝对路径 ---')
|
|
||||||
console.log(dbPath)
|
|
||||||
console.log('-----------------------')
|
|
||||||
export const AppDataSource = new DataSource({
|
export const AppDataSource = new DataSource({
|
||||||
type: 'better-sqlite3',
|
type: 'better-sqlite3', // better-sqlite3 性能优于 sqlite3
|
||||||
database: dbPath,
|
database: dbPath,
|
||||||
synchronize: true, // 开发环境下自动同步表结构
|
synchronize: true,
|
||||||
logging: true,
|
logging: process.env.NODE_ENV === 'development', // 仅开发环境开启日志
|
||||||
entities: [ReadingReflectionTaskBatch, ReadingReflectionTaskItem],
|
entities: [
|
||||||
migrations: [],
|
ReadingReflectionTaskBatch,
|
||||||
subscribers: []
|
ReadingReflectionTaskItem,
|
||||||
|
ReadingPersona // 注册实体
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
// 初始化方法,在 Electron app.whenReady() 中调用
|
|
||||||
export const initDB = async () => {
|
export const initDB = async () => {
|
||||||
if (!AppDataSource.isInitialized) {
|
if (!AppDataSource.isInitialized) {
|
||||||
await AppDataSource.initialize()
|
try {
|
||||||
console.log('TypeORM SQLite Data Source has been initialized!')
|
await AppDataSource.initialize()
|
||||||
|
console.log('Database initialized successfully at:', dbPath)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error during Data Source initialization', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return AppDataSource
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/main/db/entities/ReadingPersona.ts
Normal file
32
src/main/db/entities/ReadingPersona.ts
Normal 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
|
||||||
|
}
|
||||||
100
src/main/services/persona.service.ts
Normal file
100
src/main/services/persona.service.ts
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/main/services/search.service.ts
Normal file
104
src/main/services/search.service.ts
Normal 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 ? '...' : '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/renderer/src/hooks/useLoading.ts
Normal file
19
src/renderer/src/hooks/useLoading.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,12 +21,12 @@ export const features = [
|
|||||||
path: 'search'
|
path: 'search'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'statistics',
|
id: 'userPersona',
|
||||||
title: '阅读画像',
|
title: '阅读画像',
|
||||||
desc: '可视化你的知识边界与偏好',
|
desc: '可视化你的知识边界与偏好',
|
||||||
icon: TrendTwo,
|
icon: TrendTwo,
|
||||||
color: '#00b42a',
|
color: '#00b42a',
|
||||||
path: 'statistics'
|
path: 'userPersona'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,10 +101,6 @@ const { title } = toRefs(props)
|
|||||||
transform: translateY(-10px);
|
transform: translateY(-10px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animate-float {
|
|
||||||
animation: float 3s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shine {
|
@keyframes shine {
|
||||||
from {
|
from {
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
@@ -130,4 +126,15 @@ const { title } = toRefs(props)
|
|||||||
.animate-in {
|
.animate-in {
|
||||||
animation: fadeIn 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
191
src/renderer/src/pages/menus/views/search/index.vue
Normal file
191
src/renderer/src/pages/menus/views/search/index.vue
Normal 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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
192
src/renderer/src/pages/menus/views/userPersona/index.vue
Normal file
192
src/renderer/src/pages/menus/views/userPersona/index.vue
Normal 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>
|
||||||
@@ -26,9 +26,14 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@renderer/pages/reflection/views/poster/index.vue')
|
component: () => import('@renderer/pages/reflection/views/poster/index.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/menus/statistics',
|
path: '/menus/userPersona',
|
||||||
name: 'ReadingStatistics',
|
name: 'ReadingUserPersona',
|
||||||
component: () => import('@renderer/pages/menus/views/statistics/index.vue')
|
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(.*)*',
|
path: '/menus/:pathMatch(.*)*',
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { BrowserWindow } from 'electron'
|
import { BrowserWindow } from 'electron'
|
||||||
import type { CreateContextOptions } from 'electron-trpc/main'
|
import type { CreateContextOptions } from 'electron-trpc/main'
|
||||||
|
import { AppDataSource } from '@main/db/data-source'
|
||||||
|
import { DataSource } from 'typeorm'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1. 定义 Context 的结构
|
* 1. 定义 Context 的结构
|
||||||
@@ -11,6 +13,7 @@ export interface AppContext {
|
|||||||
webContentsId: number
|
webContentsId: number
|
||||||
// 可以在这里扩展用户信息,例如从 Session 中获取
|
// 可以在这里扩展用户信息,例如从 Session 中获取
|
||||||
user?: { id: string; role: string }
|
user?: { id: string; role: string }
|
||||||
|
db: DataSource
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +30,8 @@ export const createContext = async (opts: CreateContextOptions): Promise<AppCont
|
|||||||
window,
|
window,
|
||||||
webContentsId: event.sender.id,
|
webContentsId: event.sender.id,
|
||||||
// 示例:可以根据某些逻辑注入额外信息
|
// 示例:可以根据某些逻辑注入额外信息
|
||||||
user: { id: 'current-user', role: 'admin' }
|
user: { id: 'current-user', role: 'admin' },
|
||||||
|
db: AppDataSource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import { router } from './index'
|
|||||||
import { taskRouter } from '@rpc/router/task.router'
|
import { taskRouter } from '@rpc/router/task.router'
|
||||||
import { configRouter } from '@rpc/router/config.router'
|
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 { searchRouter } from '@rpc/router/search.router'
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
task: taskRouter,
|
task: taskRouter,
|
||||||
config: configRouter,
|
config: configRouter,
|
||||||
notice: noticeRouter
|
notice: noticeRouter,
|
||||||
|
persona: personaRouter,
|
||||||
|
search: searchRouter
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter
|
export type AppRouter = typeof appRouter
|
||||||
|
|||||||
30
src/rpc/router/persona.router.ts
Normal file
30
src/rpc/router/persona.router.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
19
src/rpc/router/search.router.ts
Normal file
19
src/rpc/router/search.router.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -21,41 +21,27 @@ export const IReadingReflectionsTaskSchema = z.object({
|
|||||||
})
|
})
|
||||||
export type IReadingReflectionsTask = z.infer<typeof IReadingReflectionsTaskSchema>
|
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 {
|
export interface IReadingReflectionTaskBatch {
|
||||||
id: string
|
id: string //任务ID
|
||||||
bookName: string
|
bookName: string // 书籍名称
|
||||||
totalCount: number
|
totalCount: number // 生成数量
|
||||||
status: string
|
status: string // 生成状态
|
||||||
progress: number
|
progress: number // 进度
|
||||||
createdAt: Date
|
createdAt: Date // 创建时间
|
||||||
items?: IReadingReflectionTaskItem[]
|
items?: IReadingReflectionTaskItem[] // 子任务模块
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 对应数据库中的具体任务项 (子任务)
|
* 对应数据库中的具体任务项 (子任务)
|
||||||
*/
|
*/
|
||||||
export interface IReadingReflectionTaskItem {
|
export interface IReadingReflectionTaskItem {
|
||||||
id: string
|
id: string // 子任务ID
|
||||||
status: 'PENDING' | 'WRITING' | 'COMPLETED' | 'FAILED'
|
status: 'PENDING' | 'WRITING' | 'COMPLETED' | 'FAILED' // 任务状态
|
||||||
progress: number
|
progress: number // 进度
|
||||||
content?: string
|
content?: string // 生成内容
|
||||||
title?: string
|
title?: string // 生成标题
|
||||||
summary?: string
|
summary?: string // 生成摘要
|
||||||
keywords?: string[]
|
keywords?: string[] // 关键词
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/shared/types/ISearch.ts
Normal file
24
src/shared/types/ISearch.ts
Normal 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[]
|
||||||
|
}
|
||||||
36
src/shared/types/IUserReadingPersona.ts
Normal file
36
src/shared/types/IUserReadingPersona.ts
Normal 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 // 最常使用的阅读者身份
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user