feat(desktop): ✨ 实现一些功能
1. 实现了用户阅读画像 2. 实现了全局检索功能
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
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 ? '...' : '')
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user