fix:基本实现相关功能

This commit is contained in:
2026-01-08 00:12:19 +08:00
commit f361a7027b
68 changed files with 10920 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
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'
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',
database: dbPath,
synchronize: true, // 开发环境下自动同步表结构
logging: true,
entities: [ReadingReflectionTaskBatch, ReadingReflectionTaskItem],
migrations: [],
subscribers: []
})
// 初始化方法,在 Electron app.whenReady() 中调用
export const initDB = async () => {
if (!AppDataSource.isInitialized) {
await AppDataSource.initialize()
console.log('TypeORM SQLite Data Source has been initialized!')
}
}

View File

@@ -0,0 +1,38 @@
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryColumn } from 'typeorm'
import { ReadingReflectionTaskItem } from './ReadingReflectionTaskItem'
import { IReadingReflectionTaskBatch } from '@shared/types/IReadingReflectionTask'
@Entity('reading_reflection_task_batches')
export class ReadingReflectionTaskBatch implements IReadingReflectionTaskBatch {
@PrimaryColumn({ type: 'varchar' })
id!: string
@Column({ type: 'varchar' })
bookName!: string
@Column({ type: 'int', default: 1 })
totalCount!: number
/**
* 批次总体状态
* PENDING: 队列中, PROCESSING: 正在生成(部分完成), COMPLETED: 全部完成, FAILED: 彻底失败
*/
@Column({ type: 'varchar', default: 'PENDING' })
status!: string
/**
* 总体进度 (0-100)
* 公式: (已完成子任务数 / 总任务数) * 100
*/
@Column({ type: 'int', default: 0 })
progress!: number
@CreateDateColumn({ type: 'datetime' })
createdAt!: Date
@OneToMany(() => ReadingReflectionTaskItem, (item) => item.batch, {
cascade: true, // 级联操作:删除 Batch 时自动删除所有 Item
onDelete: 'CASCADE'
})
items: ReadingReflectionTaskItem[]
}

View File

@@ -0,0 +1,31 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'
import { ReadingReflectionTaskBatch } from './ReadingReflectionTaskBatch'
import { IReadingReflectionTaskItem } from '@shared/types/IReadingReflectionTask'
@Entity('reading_reflection_task_items')
export class ReadingReflectionTaskItem implements IReadingReflectionTaskItem {
@PrimaryColumn({ type: 'varchar' })
id: string
@Column({ type: 'varchar' })
status: 'PENDING' | 'WRITING' | 'COMPLETED' | 'FAILED'
@Column({ type: 'int', default: 0 })
progress: number
@Column({ type: 'text', nullable: true })
content?: string
@Column({ type: 'text', nullable: true })
title?: string // 存储标题、摘要、关键词等
@Column({ type: 'text', nullable: true })
summary?: string
@Column({ type: 'simple-json', nullable: true })
keywords?: string[]
// 多对一关联
@ManyToOne(() => ReadingReflectionTaskBatch, (batch) => batch.items)
batch!: ReadingReflectionTaskBatch
resultData: any
}

87
src/main/index.ts Normal file
View File

@@ -0,0 +1,87 @@
import { app, BrowserWindow, ipcMain, shell } from 'electron'
import { join } from 'path'
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { createContext } from '@rpc/context'
import { appRouter } from '@rpc/router'
import { createIPCHandler } from 'electron-trpc/main'
import { initDB } from '@main/db/data-source'
function createWindow(): void {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: false,
resizable: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
// 核心绑定:使用 exposeElectronTRPC 适配当前窗口
createIPCHandler({
router: appRouter,
windows: [mainWindow], // 将当前新创建的窗口放入数组
createContext: createContext
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
await initDB()
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
// IPC test
ipcMain.on('ping', () => console.log('pong'))
createWindow()
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.

View File

@@ -0,0 +1,126 @@
import { EventEmitter } from 'events'
import pLimit from 'p-limit' // 建议使用 v2.2.0 以兼容 CJS
import { readingReflectionGraph } from '@main/services/ai/graph/readingReflectionGraph'
import { AppDataSource } from '@main/db/data-source'
import { ReadingReflectionTaskBatch } from '@main/db/entities/ReadingReflectionTaskBatch'
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
export const readingReflectionTaskEvent = new EventEmitter()
class TaskManager {
private limit = pLimit(2)
private batchRepo = AppDataSource.getRepository(ReadingReflectionTaskBatch)
private itemRepo = AppDataSource.getRepository(ReadingReflectionTaskItem)
/**
* 更新主任务汇总进度
*/
private async updateBatchStatus(batchId: string) {
const items = await this.itemRepo.find({ where: { batch: { id: batchId } } })
if (items.length === 0) return
const avgProgress = Math.round(items.reduce((acc, i) => acc + i.progress, 0) / items.length)
let status = 'PROCESSING'
if (avgProgress === 100) status = 'COMPLETED'
if (items.every((i) => i.status === 'FAILED')) status = 'FAILED'
await this.batchRepo.update(batchId, { progress: avgProgress, status })
// 发送给左侧列表订阅者
readingReflectionTaskEvent.emit('batchProgressUpdate', {
batchId,
progress: avgProgress,
status
})
}
async startBatchTask(taskId: string, task: any) {
const total = task.quantity || 1
// 1. 初始化主任务
const batch = this.batchRepo.create({ id: taskId, bookName: task.bookName, totalCount: total })
await this.batchRepo.save(batch)
// 发送给左侧列表订阅者
readingReflectionTaskEvent.emit('batchProgressUpdate', {
batchId: taskId,
progress: 0,
status: 'PROCESSING'
})
const promises = Array.from({ length: total }).map((_, index) => {
const subTaskId = total === 1 ? taskId : `${taskId}-${index}`
return this.limit(async () => {
try {
const item = this.itemRepo.create({ id: subTaskId, batch: batch, status: 'PENDING' })
await this.itemRepo.save(item)
const stream = await readingReflectionGraph.stream(
{ ...task },
{ configurable: { thread_id: subTaskId } }
)
let finalResult: any = {}
for await (const chunk of stream) {
// 处理生成正文节点
if (chunk.generateReadingReflectionContent) {
const contentData = chunk.generateReadingReflectionContent
await this.itemRepo.update(subTaskId, {
status: 'WRITING',
progress: 50,
content: contentData.content,
title: contentData.title
})
finalResult = { ...finalResult, ...contentData }
await this.updateBatchStatus(taskId)
this.emitProgress(taskId, index, total, 60, '正文已生成...')
}
// 处理生成摘要节点
if (chunk.generateReadingReflectionSummary) {
const summaryData = chunk.generateReadingReflectionSummary
finalResult = { ...finalResult, ...summaryData }
await this.itemRepo.update(subTaskId, {
status: 'COMPLETED',
progress: 100,
summary: summaryData.summary,
title: finalResult.title,
keywords: summaryData.keywords
})
}
}
await this.updateBatchStatus(taskId)
this.emitProgress(taskId, index, total, 100, '生成成功', finalResult)
} catch (error) {
await this.itemRepo.update(subTaskId, { status: 'FAILED', progress: 0 })
await this.updateBatchStatus(taskId)
this.emitProgress(taskId, index, total, 0, '生成失败')
}
})
})
await Promise.all(promises)
}
private emitProgress(
taskId: string,
index: number,
total: number,
progress: number,
status: string,
result?: any
) {
const displayId = total === 1 ? taskId : `${taskId}-${index}`
readingReflectionTaskEvent.emit('readingReflectionTaskProgress', {
taskId: displayId,
progress,
status: status, // 传枚举 Key
statusText: `[任务${index + 1}/${total}] ${status}`, // 传描述文字
result
})
}
}
export const readingReflectionsTaskManager = new TaskManager()

View File

@@ -0,0 +1,13 @@
import { END, START, StateGraph } from '@langchain/langgraph'
import { ReadingReflectionState } from '../state/readingReflectionState'
import { generateReadingReflectionContentNode } from '../nodes/readingReflectionContent'
import { generateReadingReflectionSummaryNode } from '../nodes/readingReflectionSummary'
const workflow = new StateGraph(ReadingReflectionState)
.addNode('generateReadingReflectionContent', generateReadingReflectionContentNode)
.addNode('generateReadingReflectionSummary', generateReadingReflectionSummaryNode)
.addEdge(START, 'generateReadingReflectionContent') // 开始 -> 生成正文
.addEdge('generateReadingReflectionContent', 'generateReadingReflectionSummary') // 正文生成后 -> 生成摘要
.addEdge('generateReadingReflectionSummary', END) // 摘要生成后 -> 结束
export const readingReflectionGraph = workflow.compile()

View File

@@ -0,0 +1,24 @@
import { ChatOpenAI } from '@langchain/openai'
import Store from 'electron-store'
import { CONFIG_STORE_KEY } from '@rpc/constants/store_key'
const StoreClass = (Store as any).default || Store
const store = new StoreClass({ encryptionKey: CONFIG_STORE_KEY })
export const createChatModel = (type: 'reading' | 'summary', schema: any) => {
const config = store.get(`chatModels.${type}`) as any
console.log('chatModels', config)
if (!config || !config.apiKey) {
throw new Error(`请先在设置中配置 ${type === 'reading' ? '心得' : '总结'} 模型的 API Key`)
}
return new ChatOpenAI({
apiKey: config.apiKey,
configuration: {
baseURL: config.baseURL || 'https://api.openai.com/v1'
},
modelName: config.modelName,
temperature: config.temperature
}).withStructuredOutput(schema)
}

View File

@@ -0,0 +1,55 @@
import { PromptTemplate } from '@langchain/core/prompts'
import { ReadingReflectionState } from '../state/readingReflectionState'
import { REFLECTION_CONTENT_PROMPT } from '@main/services/ai/prompts/readingReflactionPrompts'
import { z } from 'zod'
import { AppDataSource } from '@main/db/data-source'
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
import { createChatModel } from '@main/services/ai/llmService'
export const generateReadingReflectionContentNode = async (
state: typeof ReadingReflectionState.State,
config?: any
) => {
const taskId = config?.configurable?.thread_id
const repo = AppDataSource.getRepository(ReadingReflectionTaskItem)
const prompt = PromptTemplate.fromTemplate(REFLECTION_CONTENT_PROMPT)
const ReadingReflectionContentSchema = z.object({
title: z.string().describe('标题'),
content: z.string().describe('正文')
})
const contentModel = createChatModel('reading', ReadingReflectionContentSchema)
const chain = prompt.pipe(contentModel)
// 针对不同职业的微调逻辑(可选)
const occupationLabel =
{
student: '学生',
teacher: '教师',
professional: '职场人士',
researcher: '科研工作者',
other: '专业人士'
}[state.occupation] || state.occupation
const res = (await chain.invoke({
occupation: occupationLabel,
bookName: state.bookName,
author: state.author || '佚名',
description: state.description,
tone: state.tone || '温暖且理性',
wordCount: state.wordCount || 1000
})) as { title: string; content: string }
// 节点内部直接更新数据库状态
if (taskId) {
await repo.update(taskId, {
status: 'WRITING',
title: res.title,
content: res.content,
progress: 60
})
}
return { title: res.title, content: res.content }
}

View File

@@ -0,0 +1,46 @@
import { PromptTemplate } from '@langchain/core/prompts'
import { ReadingReflectionState } from '../state/readingReflectionState'
import { REFLECTION_SUMMARY_PROMPT } from '@main/services/ai/prompts/readingReflactionPrompts'
import { z } from 'zod'
import { createChatModel } from '@main/services/ai/llmService'
/**
* 步骤 3生成摘要和关键词
* 该节点会接收上一个节点生成的 title 和 content
*/
export const generateReadingReflectionSummaryNode = async (
state: typeof ReadingReflectionState.State
) => {
const prompt = PromptTemplate.fromTemplate(REFLECTION_SUMMARY_PROMPT)
// 定义输出的结构
const summarySchema = z.object({
summary: z.string().describe('100字以内的摘要'),
keywords: z.array(z.string()).describe('3-5个关键词')
})
// const summaryModel = new ChatOpenAI({
// apiKey: 'sk-172309b16482e6ad4264b1cd89f010d8',
// configuration: {
// baseURL: 'https://apis.iflow.cn/v1'
// },
// modelName: 'deepseek-v3.2',
// temperature: 0.3
// }).withStructuredOutput(summarySchema)
const summaryModel = createChatModel('summary', summarySchema)
const chain = prompt.pipe(summaryModel)
const res = (await chain.invoke({
title: state.title,
content: state.content
})) as {
summary: string
keywords: string[]
}
// 返回的结果会自动合并到 ReflectionState 中
return {
summary: res.summary,
keywords: res.keywords
}
}

View File

@@ -0,0 +1,61 @@
export const REFLECTION_CONTENT_PROMPT = `
# Role
你是一位在 {occupation} 领域拥有20年深厚资历的专家。你文笔{tone},擅长将理论书籍的核心观点与真实的行业场景深度结合。
# Goal
请根据提供的书籍信息,以专业视角撰写一份高质量的读书心得。
# Constraints & Rules
1. **禁止身份陈述 (No Self-Introduction)**
- **严禁**在开头或正文任何地方出现“作为一名...”、“我有...经验”、“从教/从业二十年”等自报家门的句子。
- 你的专业性应通过文字深度、对行业痛点的理解和专业术语自然流露,而不是通过宣称身份。
2. **场景化切入 (Direct Opening)**
- 开篇请直接从书籍的某个核心观点、一个具体的职场痛点、或一个生动的行业场景切入。
- 例如:不要说“作为医生我经常看到...”,而要说“当走廊的灯光亮起,面对那些复杂的病例时,我常在想...”
3. **职业化表达 (Professionalism)**
- 结合该职业特有的工作场景,将书籍内容转化为可操作的洞察。
4. **输出限制**
- 必须严格按照 JSON 格式输出,不含任何 Markdown 代码块标签或解释。
# Context Data
- 职业背景:{occupation}
- 书籍名称:{bookName}
- 作者:{author}
- 书籍描述:{description}
- 语气风格:{tone}
- 目标字数:{wordCount}
# Output JSON Format
{{
"title": "此处填写读书心得标题",
"content": "此处填写读书心得正文内容"
}}
`
export const REFLECTION_SUMMARY_PROMPT = `
# Role
你是一位金牌图书营销编辑,擅长撰写能够瞬间抓住读者眼球的“内容提要”和“搜索关键词”。你对文字有着极高的敏感度,能够从冗长的感悟中精准钩织出书籍的核心灵魂。
# Goal
基于提供的【读书心得正文】,提炼出一段极具感染力的摘要,并提取能精准覆盖内容核心的关键词。
# Constraints & Rules
1. **摘要编写要求 (Summary)**:
- **拒绝平铺直叙**:不要使用“本文介绍了...”这类陈旧开场,直接切入感悟的核心点。
- **受众共鸣**:摘要应体现出书籍对读者的实际启发或职场/生活改变。
- **精炼控制**:字数严格控制在 80-100 字之间,语言优美、有力。
2. **关键词提取要求 (Keywords)**:
- **多维覆盖**:必须包含 3-5 个词。
- **权重分配**1个书籍核心理念词1-2个职业/场景词幼儿教育、职业成长1个情感/启发词。
3. **输出限制**:
- 必须且只能输出标准 JSON 格式,严禁任何正文外的解释说明。
# Context Data
- 读书心得标题:{title}
- 读书心得正文:{content}
# Output JSON Format
{{
"summary": "此处填写精炼且感人的摘要内容",
"keywords": ["关键词1", "关键词2", "关键词3"]
}}
`

View File

@@ -0,0 +1,36 @@
import { Annotation } from '@langchain/langgraph'
import { Occupation } from '@shared/types/reflections'
export const ReadingReflectionState = Annotation.Root({
// 输入任务
bookName: Annotation<string>(),
author: Annotation<string | undefined>(),
description: Annotation<string>(),
occupation: Annotation<Occupation>(),
tone: Annotation<string | undefined>(),
wordCount: Annotation<number>(),
// 标题:使用简单的覆盖逻辑
title: Annotation<string>({
reducer: (_oldValue, newValue) => newValue,
default: () => ''
}),
// 内容
content: Annotation<string>({
reducer: (_oldValue, newValue) => newValue,
default: () => ''
}),
// 摘要
summary: Annotation<string>({
reducer: (_oldValue, newValue) => newValue,
default: () => ''
}),
// 关键词:数组通常使用追加或替换逻辑
keywords: Annotation<string[]>({
reducer: (_oldValue, newValue) => newValue,
default: () => []
})
})

8
src/preload/index.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
import { ElectronAPI } from '@electron-toolkit/preload'
declare global {
interface Window {
electron: ElectronAPI
api: unknown
}
}

20
src/preload/index.ts Normal file
View File

@@ -0,0 +1,20 @@
import { contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import { exposeElectronTRPC } from 'electron-trpc/main'
const api = {}
if (process.contextIsolated) {
try {
exposeElectronTRPC()
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI
// @ts-ignore (define in dts)
window.api = api
}

10
src/renderer/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
}

33
src/renderer/components.d.ts vendored Normal file
View File

@@ -0,0 +1,33 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AButton: typeof import('@arco-design/web-vue')['Button']
ACollapse: typeof import('@arco-design/web-vue')['Collapse']
ACollapseItem: typeof import('@arco-design/web-vue')['CollapseItem']
ActiveMenu: typeof import('./src/components/ActiveMenu.vue')['default']
AForm: typeof import('@arco-design/web-vue')['Form']
AFormItem: typeof import('@arco-design/web-vue')['FormItem']
AInput: typeof import('@arco-design/web-vue')['Input']
AInputNumber: typeof import('@arco-design/web-vue')['InputNumber']
AInputPassword: typeof import('@arco-design/web-vue')['InputPassword']
AOption: typeof import('@arco-design/web-vue')['Option']
ASelect: typeof import('@arco-design/web-vue')['Select']
ASlider: typeof import('@arco-design/web-vue')['Slider']
ATabPane: typeof import('@arco-design/web-vue')['TabPane']
ATabs: typeof import('@arco-design/web-vue')['Tabs']
ATag: typeof import('@arco-design/web-vue')['Tag']
ATextarea: typeof import('@arco-design/web-vue')['Textarea']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}

17
src/renderer/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

58
src/renderer/src/App.vue Normal file
View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import ActiveMenu from '@renderer/components/ActiveMenu.vue'
import { Plus } from '@icon-park/vue-next'
import nativeHook from '@renderer/hooks/useRouterHook'
import TaskList from '@renderer/pages/task/components/TaskList.vue'
const { go } = nativeHook()
const goTaskCreatePage = () => {
go('/task/create')
}
</script>
<template>
<div class="h-screen w-screen overflow-hidden">
<div class="flex w-full h-full overflow-hidden">
<aside class="w-60 flex flex-col mr-4">
<div class="px-4 py-8 flex items-center space-x-3 shrink-0 w-full">
<div class="flex-1 flex flex-row space-x-3 items-center">
<!--产品图标-->
<div class="w-9 h-9 bg-black rounded-xl flex items-center justify-center shadow-lg">
<span class="text-white text-lg font-bold">Z</span>
</div>
<!--产品名称-->
<span class="font-black text-base tracking-tight">读书心得助手</span>
</div>
<div class="flex justify-end p-r-20px">
<a-button size="mini" @click="goTaskCreatePage">
<plus theme="outline" size="15" fill="#333" />
</a-button>
</div>
</div>
<!--菜单-->
<nav class="flex-1 px-2 space-y-1.5 overflow-y-auto custom-scroll">
<div class="space-y-2">
<TaskList />
</div>
</nav>
<ActiveMenu />
</aside>
<main class="flex-1 flex flex-col overflow-hidden">
<router-view />
</main>
</div>
</div>
</template>
<style scoped>
/* 自定义滚动条样式 */
.custom-scroll::-webkit-scrollbar {
width: 4px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}
</style>

View File

@@ -0,0 +1,129 @@
// 1. 全局覆盖 Button 组件基础变量(水墨风格)
:root {
// 主色调(水墨黑/深灰,替代原紫色)
--arcoblue-6: #2a2a2a; // 基础墨色(主按钮默认)
--arcoblue-5: #444444; // 浅墨色hover 状态)
--arcoblue-7: #1a1a1a; // 深墨色active 状态)
// 成功色(水墨绿,低饱和度)
--arcogreen-6: #3a4d39;
--arcogreen-5: #4b604a;
--arcogreen-7: #2d3c2c;
// 危险色(水墨红,低饱和度)
--arco-red-6: #5c3a3a;
--arco-red-5: #6d4b4b;
--arco-red-7: #4a2d2d;
// 警告色(水墨黄/赭石色,低饱和度)
--arcoyellow-6: #5c4a3a;
--arcoyellow-5: #6d5b4b;
--arcoyellow-7: #4a3d2d;
// Button 基础样式变量(适配水墨风格的简约质感)
--btn-height-default: 42px; // 略高的按钮,更稳的视觉感
--btn-font-size-default: 15px; // 适中字号,提升阅读性
--btn-border-radius-default: 4px; // 小圆角,贴合水墨的简约感
--btn-border-width: 1px; // 细边框,精致感
--btn-border-color: #333333; // 基础边框墨色
}
// 2. 主按钮(水墨黑核心样式)
.arco-btn-primary {
// 核心背景/文字/边框色
--btn-bg-color: var(--arcoblue-6);
--btn-text-color: #f5f5f5; // 浅灰文字,对比水墨黑更柔和
--btn-border-color: var(--arcoblue-6);
// 交互状态(水墨渐变)
--btn-bg-color-hover: var(--arcoblue-5);
--btn-border-color-hover: var(--arcoblue-5);
--btn-bg-color-active: var(--arcoblue-7);
--btn-border-color-active: var(--arcoblue-7);
// 禁用状态(浅墨色)
--btn-bg-color-disabled: #e0e0e0;
--btn-text-color-disabled: #666666;
--btn-border-color-disabled: #e0e0e0;
// 额外样式(强化水墨质感)
font-weight: 400; // 常规字重,避免粗体破坏水墨的柔和
letter-spacing: 0.5px; // 轻微字间距,提升呼吸感
}
// 3. 次要按钮(水墨浅灰风格)
.arco-btn-secondary {
--btn-bg-color: #f8f8f8; // 极浅灰背景
--btn-text-color: var(--arcoblue-6); // 墨色文字
--btn-border-color: #e0e0e0; // 浅灰边框
// 交互状态
--btn-bg-color-hover: #f0f0f0;
--btn-border-color-hover: #d0d0d0;
--btn-bg-color-active: #e8e8e8;
--btn-border-color-active: #c0c0c0;
}
// 4. 虚线按钮(水墨简约虚线)
.arco-btn-dashed {
--btn-border-style: dashed;
--btn-border-color: var(--arcoblue-6); // 墨色虚线
--btn-bg-color: transparent;
--btn-text-color: var(--arcoblue-6);
// 交互状态
--btn-bg-color-hover: #f8f8f8;
--btn-border-color-hover: var(--arcoblue-5);
--btn-text-color-hover: var(--arcoblue-5);
}
// 5. 文本按钮(纯墨色文字)
.arco-btn-text {
--btn-text-color: var(--arcoblue-6);
--btn-bg-color: transparent;
--btn-border-color: transparent;
// 交互状态
--btn-text-color-hover: var(--arcoblue-5);
--btn-bg-color-hover: #f8f8f8;
--btn-text-color-active: var(--arcoblue-7);
--btn-bg-color-active: #f0f0f0;
}
// 6. 补充:不同状态按钮的水墨风格统一(可选,强化风格)
// 成功按钮
.arco-btn-success {
--btn-bg-color: var(--arcogreen-6);
--btn-text-color: #f5f5f5;
--btn-border-color: var(--arcogreen-6);
--btn-bg-color-hover: var(--arcogreen-5);
--btn-bg-color-active: var(--arcogreen-7);
}
// 危险按钮
.arco-btn-danger {
--btn-bg-color: var(--arco-red-6);
--btn-text-color: #f5f5f5;
--btn-border-color: var(--arco-red-6);
--btn-bg-color-hover: var(--arco-red-5);
--btn-bg-color-active: var(--arco-red-7);
}
// 警告按钮
.arco-btn-warning {
--btn-bg-color: var(--arcoyellow-6);
--btn-text-color: #f5f5f5;
--btn-border-color: var(--arcoyellow-6);
--btn-bg-color-hover: var(--arcoyellow-5);
--btn-bg-color-active: var(--arcoyellow-7);
}
// 7. 全局按钮基础样式(统一水墨风格的视觉基调)
.arco-btn {
// 移除默认阴影,贴合水墨的扁平质感
box-shadow: none !important;
// 平滑过渡,交互更自然
transition: all 0.2s ease-in-out;
// 边框样式统一
border-width: var(--btn-border-width) !important;
}

View File

@@ -0,0 +1,6 @@
export const TASK_STATUS: Record<string, { color: string; text: string; desc: string }> = {
PENDING: { color: '#7816ff', text: '任务排队中', desc: '任务排队中,等待系统调度算力...' },
WRITING: { color: '#ff5722', text: '运行中', desc: '深度学习模型正在分析文本,请稍后...' },
COMPLETED: { color: '#00b42a', text: '任务完成', desc: '任务已完成...' },
FAILED: { color: '#86909c', text: '任务失败', desc: '任务失败,正在重试...' }
}

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref } from 'vue'
// 保持图标库一致,使用 IconPark 或 Arco Icons
import { Help, Info, SettingTwo } from '@icon-park/vue-next'
import useRouterHook from '@renderer/hooks/useRouterHook'
const { go } = useRouterHook()
const activeBtn = ref('')
// 定义按钮配置,方便维护
const navButtons = [
{ key: 'setting', title: '设置', icon: SettingTwo },
{ key: 'faq', title: '帮助', icon: Help },
{ key: 'about', title: '关于', icon: Info }
]
const active = (key: string) => {
console.log(key)
go('/' + key)
}
</script>
<template>
<div class="p-4 w-full flex flex-col items-center gap-4">
<div class="flex items-center bg-white border border-slate-100 p-1.5 rounded-xl shadow-sm">
<div
v-for="btn in navButtons"
:key="btn.key"
class="nav-btn group"
:class="{ active: activeBtn === btn.key }"
:title="btn.title"
@click="active(btn.key)"
>
<component
:is="btn.icon"
theme="outline"
size="16"
class="transition-colors"
:fill="activeBtn === btn.key ? '#7816ff' : '#64748b'"
/>
<span class="text-[10px] ml-1.5 hidden group-hover:inline-block text-slate-500 font-medium">
{{ btn.title }}
</span>
</div>
</div>
</div>
</template>
<style scoped lang="less">
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 6px 10px;
border-radius: 8px;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&:hover {
background-color: #f5f3ff; // 极淡的紫色背景
.arco-icon,
.i-icon {
color: #7816ff;
}
}
&.active {
background-color: #f5f3ff;
&::after {
content: '';
position: absolute;
bottom: -4px;
width: 4px;
height: 4px;
background-color: #7816ff;
border-radius: 50%;
}
}
}
// 按钮之间的分割线(可选)
.nav-btn:not(:last-child) {
margin-right: 4px;
}
</style>

1
src/renderer/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,84 @@
import { type LocationQueryRaw, type RouteParamsRaw, useRoute, useRouter } from 'vue-router'
import { warn } from 'vue'
export function useRouterHook() {
const router = useRouter()
const route = useRoute()
const go = (to: string, query?: LocationQueryRaw, params?: RouteParamsRaw) => {
if (!to) return warn('useRouterHook: 路由路径不能为空')
// 如果有 params通常意味着你在尝试匹配动态路由
// 在 Vue Router 4 中,这种情况下必须配合 name 使用,或者手动拼接到 path 字符串中
if (params && Object.keys(params).length > 0) {
// 方案 1强制使用 name 跳转 (前提是你的 to 传入的是路由名字)
router
.push({
name: to,
query,
params
})
.catch((err) => console.error(err))
} else {
// 方案 2普通的路径跳转
router
.push({
path: to,
query
})
.catch((err) => console.error(err))
}
}
const replace = (to: string, query?: LocationQueryRaw, params?: RouteParamsRaw) => {
if (!to) return warn('useRouterHook: 路由路径不能为空')
if (route.path === to) return warn('useRouterHook: 路径不能与当前路径相同')
// 如果有 params通常意味着你在尝试匹配动态路由
// 在 Vue Router 4 中,这种情况下必须配合 name 使用,或者手动拼接到 path 字符串中
if (params && Object.keys(params).length > 0) {
// 方案 1强制使用 name 跳转 (前提是你的 to 传入的是路由名字)
router
.replace({
name: to as string,
query,
params
})
.catch((err) => console.error(err))
} else {
// 方案 2普通的路径跳转
router
.replace({
path: to as string,
query
})
.catch((err) => console.error(err))
}
}
// --- 其他方法保持不变 ---
const goBack = (n: number = 1) => {
if (window.history.length <= 1) return warn('useRouterHook: 无可返回记录')
router.go(-n)
}
const getQuery = (key?: string) => {
return key ? (route.query[key] as string | undefined) : { ...route.query }
}
const getParams = (key?: string) => {
return key ? (route.params[key] as string | undefined) : { ...route.params }
}
return {
router,
route,
go,
replace,
goBack,
getQuery,
getParams,
refresh: () => router.go(0)
}
}
export default useRouterHook

View File

@@ -0,0 +1,7 @@
import mitt from 'mitt'
type Events = {
'refresh-task-list': void // 定义事件名和参数类型
}
export const eventBus = mitt<Events>()

View File

@@ -0,0 +1,13 @@
import { createTRPCProxyClient } from '@trpc/client'
import { ipcLink } from 'electron-trpc/renderer'
import type { AppRouter } from '@rpc/router'
export const trpc = createTRPCProxyClient<AppRouter>({
transformer: {
serialize: (v) => v,
deserialize: (v) => v
} as any,
links: [
ipcLink() // 不传任何参数
]
})

10
src/renderer/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from '@renderer/router'
import '@arco-design/web-vue/dist/arco.css'
import 'virtual:uno.css'
import './style.css'
import './assets/global.less'
import '@icon-park/vue-next/styles/index.css'
createApp(App).use(router).mount('#app')

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { useRouterHook } from '@renderer/hooks/useRouterHook'
import {
AdProduct,
ArrowRight,
Github,
HoldInterface,
InternalData,
Mail,
Twitter
} from '@icon-park/vue-next'
const { goBack } = useRouterHook()
const coreValues = [
{
icon: InternalData,
title: '数据驱动',
desc: '基于自研深度学习模型,精准提取每一本书籍的灵魂。'
},
{
icon: AdProduct,
title: '极致体验',
desc: '化繁为简,让 AI 创作如同呼吸般自然、流畅。'
},
{
icon: HoldInterface,
title: '连接未来',
desc: '探索人机协作的新范式,重新定义阅读与写作。'
}
]
</script>
<template>
<div class="flex-1 h-full overflow-y-auto bg-[#FAFAFB] custom-scroll">
<section class="pt-20 pb-16 px-6 text-center bg-white border-b border-slate-50">
<div class="inline-flex items-center space-x-2 bg-purple-50 px-3 py-1 rounded-full mb-6">
<span class="w-2 h-2 bg-[#7816ff] rounded-full animate-pulse"></span>
<span class="text-[10px] font-bold text-[#7816ff] uppercase tracking-widest"
>Version 2.0.0 Now Live</span
>
</div>
<h1 class="text-4xl font-black text-slate-800 mb-4 tracking-tight">
让每一页文字<br />
<span class="text-[#7816ff]">都有迹可循</span>
</h1>
<p class="max-w-xl mx-auto text-slate-400 text-sm leading-relaxed mb-8">
我们致力于通过最前沿的 AI 技术帮助深度阅读者高效消化知识
从海量文本到结构化心得只需一键
</p>
<div class="flex justify-center gap-4">
<a-button
type="primary"
shape="round"
class="bg-[#7816ff] border-none px-8 h-10 shadow-lg shadow-purple-100"
>
开始体验
</a-button>
<a-button shape="round" class="px-8 h-10 border-slate-200" @click="goBack()">
返回探索
</a-button>
</div>
</section>
<section class="max-w-6xl mx-auto py-20 px-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div
v-for="(item, index) in coreValues"
:key="index"
class="group p-8 bg-white rounded-3xl border border-slate-100 shadow-sm hover:shadow-md transition-all duration-300"
>
<div
class="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center mb-6 group-hover:bg-purple-50 transition-colors"
>
<component
:is="item.icon"
theme="outline"
size="24"
class="text-slate-400 group-hover:text-[#7816ff]"
/>
</div>
<h3 class="text-base font-bold text-slate-800 mb-3">{{ item.title }}</h3>
<p class="text-xs text-slate-500 leading-relaxed">{{ item.desc }}</p>
</div>
</div>
</section>
<section class="max-w-6xl mx-auto pb-20 px-6">
<div
class="bg-white rounded-[32px] border border-slate-100 shadow-sm overflow-hidden flex flex-col md:flex-row items-center"
>
<div class="flex-1 p-12 md:p-16">
<div class="text-[11px] font-bold text-[#7816ff] uppercase tracking-[0.2em] mb-4">
Our Mission
</div>
<h2 class="text-2xl font-bold text-slate-800 mb-6">为了那些真正热爱文字的人</h2>
<p class="text-sm text-slate-500 leading-relaxed mb-8">
在一个信息碎片化的时代深度阅读正变得前所未有的奢侈我们不希望 AI
替代阅读而是希望它能作为你的数字笔友帮你梳理逻辑捕捉灵感让你从繁琐的摘要工作中解放回归思考本身
</p>
<div class="flex items-center space-x-6">
<div class="flex flex-col">
<span class="text-xl font-black text-slate-800">12,400+</span>
<span class="text-[10px] text-slate-400 font-bold uppercase">Active Users</span>
</div>
<div class="w-[1px] h-8 bg-slate-100"></div>
<div class="flex flex-col">
<span class="text-xl font-black text-slate-800">8.5M</span>
<span class="text-[10px] text-slate-400 font-bold uppercase">Words Generated</span>
</div>
</div>
</div>
<div class="flex-1 w-full h-[400px] bg-slate-50 flex items-center justify-center relative">
<div
class="absolute inset-0 opacity-20"
style="
background-image: radial-gradient(#7816ff 0.5px, transparent 0.5px);
background-size: 20px 20px;
"
></div>
<div
class="relative z-10 w-64 h-64 bg-white rounded-3xl shadow-xl border border-slate-100 flex flex-col p-6 animate-bounce-slow"
>
<div class="w-8 h-8 bg-purple-50 rounded-lg mb-4"></div>
<div class="h-2 w-3/4 bg-slate-100 rounded-full mb-3"></div>
<div class="h-2 w-full bg-slate-50 rounded-full mb-3"></div>
<div class="h-2 w-2/3 bg-slate-50 rounded-full"></div>
<div class="mt-auto flex justify-end">
<div
class="w-8 h-8 rounded-full bg-[#7816ff] flex items-center justify-center text-white"
>
<arrow-right size="14" />
</div>
</div>
</div>
</div>
</div>
</section>
<footer class="py-12 px-6 text-center border-t border-slate-100">
<div class="flex justify-center space-x-6 mb-8 text-slate-400">
<a href="#" class="hover:text-[#7816ff] transition-colors"><github size="20" /></a>
<a href="#" class="hover:text-[#7816ff] transition-colors"><twitter size="20" /></a>
<a href="#" class="hover:text-[#7816ff] transition-colors"><mail size="20" /></a>
</div>
<p class="text-[11px] text-slate-300 font-medium">
© 2026 AI Reader Studio. All Rights Reserved. Crafted with for readers worldwide.
</p>
</footer>
</div>
</template>
<style scoped>
.custom-scroll::-webkit-scrollbar {
width: 4px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #e5e7eb;
border-radius: 10px;
}
/* 柔和的漂浮动画 */
@keyframes bounce-slow {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-15px);
}
}
.animate-bounce-slow {
animation: bounce-slow 4s ease-in-out infinite;
}
/* 按钮微调 */
:deep(.arco-btn) {
font-weight: 600;
letter-spacing: 0.5px;
}
</style>

View File

@@ -0,0 +1,199 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
ArrowRight,
Equalizer,
HeadsetOne,
Help,
Search,
Security,
Wallet
} from '@icon-park/vue-next'
// 分类配置
const categories = [
{ key: 'general', name: '常规问题', icon: Help },
{ key: 'usage', name: '使用技巧', icon: Equalizer },
{ key: 'billing', name: '订阅支付', icon: Wallet },
{ key: 'privacy', name: '隐私安全', icon: Security }
]
const activeCategory = ref('general')
const searchQuery = ref('')
// 问题数据
const faqList = [
{
id: 1,
category: 'general',
q: '这个 AI 阅读工具是如何工作的?',
a: '我们利用深度学习模型对书籍文本进行语义分析。它不仅能总结全文,还能根据你选择的“职业背景”提取特定的知识点。'
},
{
id: 2,
category: 'usage',
q: '生成的字数可以超过 5000 字吗?',
a: '目前单次生成上限为 5000 字,以确保内容的逻辑连贯性。如果需要更长篇幅,建议分章节创建任务。'
},
{
id: 3,
category: 'billing',
q: '订阅后可以申请退款吗?',
a: '在订阅后的 24 小时内且未使用过算力资源的情况下,您可以联系客服申请全额退款。'
},
{
id: 4,
category: 'general',
q: '支持哪些格式的书籍?',
a: '目前支持 PDF, EPUB, TXT 格式。为了获得最佳效果,建议上传排版清晰的文档。'
}
]
// 搜索过滤逻辑
const filteredFaqs = computed(() => {
return faqList.filter((item) => {
const matchesCategory = item.category === activeCategory.value
const matchesSearch = item.q.toLowerCase().includes(searchQuery.value.toLowerCase())
return matchesCategory && matchesSearch
})
})
</script>
<template>
<div class="flex-1 h-full overflow-y-auto bg-[#FAFAFB] custom-scroll">
<section class="pt-16 pb-12 px-6 text-center">
<h2 class="text-3xl font-black text-slate-800 mb-4">有什么可以帮到你</h2>
<div class="max-w-2xl mx-auto relative">
<a-input
v-model="searchQuery"
placeholder="搜索您遇到的问题..."
class="faq-search-input"
size="large"
allow-clear
>
<template #prefix><search theme="outline" size="18" fill="#94a3b8" /></template>
</a-input>
</div>
</section>
<div class="max-w-5xl mx-auto px-6 flex flex-col md:flex-row gap-8 pb-20">
<aside class="w-full md:w-56 shrink-0">
<div class="flex md:flex-col gap-2 overflow-x-auto pb-4 md:pb-0">
<div
v-for="cat in categories"
:key="cat.key"
@click="activeCategory = cat.key"
:class="[
'flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer transition-all whitespace-nowrap',
activeCategory === cat.key
? 'bg-white shadow-sm text-[#7816ff] font-bold border border-slate-100'
: 'text-slate-500 hover:bg-white/60'
]"
>
<component :is="cat.icon" theme="outline" size="18" />
<span class="text-[13px]">{{ cat.name }}</span>
</div>
</div>
</aside>
<main class="flex-1 space-y-4">
<div v-if="filteredFaqs.length > 0" class="space-y-3">
<a-collapse :bordered="false" expand-icon-position="right">
<template #expand-icon="{ active }">
<div :class="['transition-transform duration-300', active ? 'rotate-90' : '']">
<arrow-right theme="outline" size="16" :fill="active ? '#7816ff' : '#94a3b8'" />
</div>
</template>
<a-collapse-item v-for="faq in filteredFaqs" :key="faq.id" class="faq-card">
<template #header>
<span class="text-[14px] font-bold text-slate-700">{{ faq.q }}</span>
</template>
<div class="text-[13px] leading-relaxed text-slate-500 py-2">
{{ faq.a }}
</div>
</a-collapse-item>
</a-collapse>
</div>
<div v-else class="bg-white rounded-2xl p-12 text-center border border-slate-100">
<div
class="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-4"
>
<search theme="outline" size="24" fill="#cbd5e1" />
</div>
<p class="text-sm text-slate-400">未找到相关问题请尝试其他关键词</p>
</div>
<div
class="mt-8 bg-white rounded-2xl p-6 border border-slate-100 flex items-center justify-between shadow-sm"
>
<div class="flex items-center gap-4">
<div
class="w-12 h-12 bg-purple-50 rounded-xl flex items-center justify-center text-[#7816ff]"
>
<headset-one theme="outline" size="24" />
</div>
<div>
<h4 class="text-sm font-bold text-slate-800">仍有疑问</h4>
<p class="text-[11px] text-slate-400">我们的团队通常在 2 小时内回复您的邮件</p>
</div>
</div>
<a-button type="primary" shape="round" class="bg-[#7816ff] border-none px-6">
联系支持
</a-button>
</div>
</main>
</div>
</div>
</template>
<style scoped>
/* 深度定制搜索框 */
:deep(.faq-search-input) {
border-radius: 16px !important;
border: 1px solid #f1f5f9 !important;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05);
background: white !important;
padding-left: 12px;
}
:deep(.faq-search-input.arco-input-focus) {
border-color: #7816ff !important;
box-shadow: 0 10px 25px -5px rgba(120, 22, 255, 0.1);
}
/* 深度定制手风琴卡片 */
:deep(.faq-card) {
background: white !important;
border: 1px solid #f1f5f9 !important;
border-radius: 16px !important;
margin-bottom: 12px !important;
overflow: hidden;
transition: all 0.3s ease;
}
:deep(.faq-card:hover) {
border-color: #7816ff;
transform: translateX(4px);
}
:deep(.arco-collapse-item-header) {
background: transparent !important;
padding: 20px 24px !important;
border-bottom: none !important;
}
:deep(.arco-collapse-item-content) {
padding: 0 24px 20px 24px !important;
background: transparent !important;
}
.custom-scroll::-webkit-scrollbar {
width: 4px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #e5e7eb;
border-radius: 10px;
}
</style>

View File

@@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template></template>
<style scoped></style>

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { ref } from 'vue'
import { SettingConfig } from '@icon-park/vue-next'
interface ModelOption {
id: string // 这里的 id 即 modelName
name: string
desc: string
tag?: string
}
interface ModelConfig {
apiKey: string
baseURL: string
modelName: string
temperature: number
}
// 包含两种类型的配置
interface ModelForm {
reflection: ModelConfig
summary: ModelConfig
}
const props = defineProps<{
modelValue: ModelForm // 必填,父组件传 reactive 对象
options?: ModelOption[]
}>()
const emit = defineEmits(['update:modelValue', 'save'])
// 当前激活的标签页
const activeTab = ref<'reflection' | 'summary'>('reflection')
// 处理模型卡片点击:更新当前 Tab 下的 modelName
const selectModel = (modelId: string) => {
const newData = { ...props.modelValue }
newData[activeTab.value].modelName = modelId
emit('update:modelValue', newData)
}
</script>
<template>
<div v-if="modelValue" class="space-y-6 animate-in">
<a-tabs v-model:active-key="activeTab" type="capsule" class="custom-tabs">
<a-tab-pane key="reflection" title="读书心得模型" />
<a-tab-pane key="summary" title="摘要总结模型" />
</a-tabs>
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-8">
<div class="flex items-center space-x-2 mb-8">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<h3 class="text-sm font-bold text-slate-800">接口配置</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="setting-label">Base URL (API 代理地址)</label>
<a-input
v-model="modelValue[activeTab].baseURL"
placeholder="https://api.openai.com/v1"
class="custom-input"
/>
</div>
<div class="space-y-2">
<label class="setting-label">API Key</label>
<a-input-password
v-model="modelValue[activeTab].apiKey"
placeholder="sk-..."
class="custom-input"
/>
</div>
</div>
</div>
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-8">
<div class="flex items-center space-x-2 mb-6">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<h3 class="text-sm font-bold text-slate-800">模型名称 (modelName)</h3>
</div>
<div class="mb-6">
<a-input
v-model="modelValue[activeTab].modelName"
placeholder="或者手动输入模型 ID如 gpt-4o"
class="custom-input"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="m in options"
:key="m.id"
@click="selectModel(m.id)"
:class="[
'p-4 rounded-xl border-2 cursor-pointer transition-all relative overflow-hidden',
modelValue[activeTab].modelName === m.id
? 'border-[#7816ff] bg-purple-50/30'
: 'border-slate-50 bg-slate-50/50 hover:border-slate-200'
]"
>
<div class="flex justify-between items-start mb-2">
<span class="text-xs font-bold text-slate-800">{{ m.name }}</span>
<span v-if="m.tag" class="text-[9px] bg-[#7816ff] text-white px-1.5 py-0.5 rounded">
{{ m.tag }}
</span>
</div>
<p class="text-[10px] text-slate-400 leading-relaxed">{{ m.desc }}</p>
<div
v-if="modelValue[activeTab].modelName === m.id"
class="absolute -right-2 -bottom-2 text-[#7816ff]/10"
>
<SettingConfig theme="outline" size="48" />
</div>
</div>
</div>
</div>
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-8">
<div class="flex items-center space-x-2 mb-8">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<h3 class="text-sm font-bold text-slate-800">运行参数</h3>
<p class="text-[11px] text-[#7816ff] leading-relaxed">
正在配置
<span class="font-bold underline">{{
activeTab === 'reflection' ? '心得生成' : '文本总结'
}}</span>
专用模型 建议为总结模型设置较低的 Temperature 以保证稳定性
</p>
</div>
<div class="space-y-8">
<div class="space-y-4">
<div class="flex justify-between items-center">
<label class="setting-label"
>随机性 (Temperature): {{ modelValue[activeTab].temperature }}</label
>
<span class="text-[10px] text-slate-400">建议心得 0.7-0.8总结 0.2-0.3</span>
</div>
<a-slider v-model="modelValue[activeTab].temperature" :min="0" :max="1.2" :step="0.1" />
</div>
<div class="flex justify-end pt-4 border-t border-slate-50">
<a-button
type="primary"
class="rounded-lg bg-[#7816ff] border-none px-8 font-bold"
@click="emit('save', activeTab)"
>
保存 {{ activeTab === 'reflection' ? '心得' : '总结' }} 配置
</a-button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 继承原有样式 */
.setting-label {
display: block;
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0 4px;
}
/* Tabs 风格微调适配你的紫色主题 */
:deep(.custom-tabs .arco-tabs-nav-capsule) {
background-color: #f1f5f9;
border-radius: 12px;
padding: 4px;
}
:deep(.custom-tabs .arco-tabs-nav-capsule-light) {
background-color: #fff;
color: #7816ff;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
</style>

View File

@@ -0,0 +1,307 @@
<script setup lang="ts">
import { onMounted, reactive, ref, toRaw } from 'vue'
import { Remind, Right, SettingConfig, ShieldAdd, Wallet } from '@icon-park/vue-next'
import { Message } from '@arco-design/web-vue'
import { trpc } from '@renderer/lib/trpc'
// --- 类型定义 ---
interface ModelOption {
id: string
name: string
desc: string
tag?: string
}
interface ModelConfig {
apiKey: string
baseURL: string
modelName: string
temperature: number
}
interface ModelForm {
reading: ModelConfig // 统一使用 reading
summary: ModelConfig
}
// --- 状态管理 ---
const activeMenu = ref('model')
const activeTab = ref<'reading' | 'summary'>('reading') // 初始设为 reading
const menuItems = [
{ key: 'model', name: '大模型配置', icon: SettingConfig },
{ key: 'account', name: '账号安全', icon: ShieldAdd },
{ key: 'billing', name: '订阅与账单', icon: Wallet },
{ key: 'notification', name: '通知设置', icon: Remind }
]
const modelForm = reactive<ModelForm>({
reading: {
apiKey: 'sk-172309b16482e6ad4264b1cd89f010d8',
baseURL: 'https://apis.iflow.cn/v1',
modelName: 'deepseek-v3.2',
temperature: 0.7
},
summary: {
apiKey: 'sk-172309b16482e6ad4264b1cd89f010d8',
baseURL: 'https://apis.iflow.cn/v1',
modelName: 'deepseek-v3.2',
temperature: 0.3
}
})
const modelOptions: ModelOption[] = [
{ id: 'gpt-4o', name: 'GPT-4o', desc: '能力最强,适合复杂文学分析', tag: '推荐' },
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5', desc: '响应速度极快', tag: '超快' },
{ id: 'claude-3-5-sonnet-20240620', name: 'Claude 3.5', desc: '文笔细腻', tag: '文笔佳' },
{ id: 'deepseek-v3.2', name: 'deepseekV3.2', desc: '适合快速生成内容', tag: '推荐' }
]
// --- 逻辑方法 ---
const loadConfig = async () => {
try {
const savedConfig = (await trpc.config.getChatConfigs.query()) as any
if (savedConfig) {
// 这里的 Key 必须与后端存储的 chatModels.reading 对应
if (savedConfig.reading) Object.assign(modelForm.reading, savedConfig.reading)
if (savedConfig.summary) Object.assign(modelForm.summary, savedConfig.summary)
}
} catch (error) {
console.error('加载配置失败:', error)
}
}
const handleSave = async (type: 'reading' | 'summary') => {
try {
// 关键点:使用 toRaw 转换,剥离 Vue 的响应式代理
const configToSave = toRaw(modelForm[type])
// 打印检查一下,确保是普通对象而非 Proxy
console.log('正在保存纯对象数据:', configToSave)
await trpc.config.saveChatConfig.mutate({
type,
config: configToSave
})
Message.success({
content: `${type === 'reading' ? '读书心得' : '摘要总结'}配置已安全保存`,
showIcon: true
})
} catch (error) {
Message.error('保存失败,无法克隆对象')
console.error('tRPC 错误详情:', error)
}
}
const selectModel = (modelId: string) => {
modelForm[activeTab.value].modelName = modelId
}
onMounted(() => {
loadConfig()
})
</script>
<template>
<div class="flex-1 h-full overflow-y-auto bg-[#FAFAFB] p-8 custom-scroll">
<div class="max-w-5xl mx-auto flex gap-8">
<aside class="w-64 shrink-0">
<div class="flex items-center space-x-2 mb-8 px-2">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<h2 class="text-base font-bold text-slate-800">设置中心</h2>
</div>
<nav class="space-y-1">
<div
v-for="item in menuItems"
:key="item.key"
@click="activeMenu = item.key"
:class="[
'flex items-center justify-between px-4 py-3 rounded-xl cursor-pointer transition-all duration-300',
activeMenu === item.key
? 'bg-white shadow-sm text-[#7816ff] font-bold border border-slate-100'
: 'text-slate-500 hover:bg-white/60 hover:text-slate-700'
]"
>
<div class="flex items-center gap-3">
<component
:is="item.icon"
theme="outline"
size="18"
:fill="activeMenu === item.key ? '#7816ff' : '#94a3b8'"
/>
<span class="text-[13px]">{{ item.name }}</span>
</div>
<right v-if="activeMenu === item.key" theme="outline" size="14" />
</div>
</nav>
</aside>
<main class="flex-1">
<div v-if="activeMenu === 'model'" class="space-y-6 animate-in">
<a-tabs v-model:active-key="activeTab" type="capsule" class="custom-tabs">
<a-tab-pane key="reading" title="读书心得模型" />
<a-tab-pane key="summary" title="摘要总结模型" />
</a-tabs>
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-8">
<div class="flex items-center space-x-2 mb-8">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<h3 class="text-sm font-bold text-slate-800">接口配置</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="setting-label">Base URL (API 代理地址)</label>
<a-input
v-model="modelForm[activeTab].baseURL"
placeholder="https://api.openai.com/v1"
class="custom-input"
/>
</div>
<div class="space-y-2">
<label class="setting-label">API Key</label>
<a-input-password
v-model="modelForm[activeTab].apiKey"
placeholder="sk-..."
class="custom-input"
/>
</div>
</div>
</div>
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-8">
<div class="flex items-center space-x-2 mb-6">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<h3 class="text-sm font-bold text-slate-800">模型名称 (modelName)</h3>
</div>
<div class="mb-6">
<a-input
v-model="modelForm[activeTab].modelName"
placeholder="手动输入模型 ID如 gpt-4o"
class="custom-input"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="m in modelOptions"
:key="m.id"
@click="selectModel(m.id)"
:class="[
'p-4 rounded-xl border-2 cursor-pointer transition-all relative overflow-hidden',
modelForm[activeTab].modelName === m.id
? 'border-[#7816ff] bg-purple-50/30'
: 'border-slate-50 bg-slate-50/50 hover:border-slate-200'
]"
>
<div class="flex justify-between items-start mb-2">
<span class="text-xs font-bold text-slate-800">{{ m.name }}</span>
<span
v-if="m.tag"
class="text-[9px] bg-[#7816ff] text-white px-1.5 py-0.5 rounded"
>{{ m.tag }}</span
>
</div>
<p class="text-[10px] text-slate-400 leading-relaxed">{{ m.desc }}</p>
<div
v-if="modelForm[activeTab].modelName === m.id"
class="absolute -right-2 -bottom-2 text-[#7816ff]/10"
>
<SettingConfig theme="outline" size="48" />
</div>
</div>
</div>
</div>
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-8">
<div class="flex items-center space-x-2 mb-8">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<h3 class="text-sm font-bold text-slate-800">运行参数</h3>
<p class="text-[11px] text-[#7816ff] leading-relaxed ml-4 italic">
正在配置
<span class="font-bold underline">{{
activeTab === 'reading' ? '心得生成' : '文本总结'
}}</span>
专用模型
</p>
</div>
<div class="space-y-8">
<div class="space-y-4">
<div class="flex justify-between items-center">
<label class="setting-label"
>随机性 (Temperature): {{ modelForm[activeTab].temperature }}</label
>
<span class="text-[10px] text-slate-400">建议心得 0.7-0.8总结 0.2-0.3</span>
</div>
<a-slider
v-model="modelForm[activeTab].temperature"
:min="0"
:max="1.2"
:step="0.1"
/>
</div>
<div class="flex justify-end pt-4 border-t border-slate-50">
<a-button
type="primary"
class="rounded-lg bg-[#7816ff] border-none px-8 font-bold"
@click="handleSave(activeTab)"
>
保存 {{ activeTab === 'reading' ? '心得' : '总结' }} 配置
</a-button>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
</template>
<style scoped>
/* 样式部分保持不变... */
.setting-label {
display: block;
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0 4px;
}
:deep(.custom-input) {
background-color: #fcfcfd !important;
border: 1px solid #f1f5f9 !important;
border-radius: 12px !important;
font-size: 13px;
}
:deep(.custom-tabs .arco-tabs-nav-capsule) {
background-color: #f1f5f9;
border-radius: 12px;
padding: 4px;
}
:deep(.custom-tabs .arco-tabs-nav-capsule-light) {
background-color: #fff;
color: #7816ff;
font-weight: bold;
}
.custom-scroll::-webkit-scrollbar {
width: 4px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #e5e7eb;
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;
}
</style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { Right } from '@icon-park/vue-next'
defineProps<{
menuItems: Array<{ key: string; name: string; icon: any }>
activeMenu: string
}>()
const emit = defineEmits(['update:activeMenu'])
</script>
<template>
<div class="flex-1 h-full overflow-y-auto bg-[#FAFAFB] p-8 custom-scroll">
<div class="max-w-5xl mx-auto flex gap-8">
<aside class="w-64 shrink-0">
<div class="flex items-center space-x-2 mb-8 px-2">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<h2 class="text-base font-bold text-slate-800">设置中心</h2>
</div>
<nav class="space-y-1">
<div
v-for="item in menuItems"
:key="item.key"
@click="emit('update:activeMenu', item.key)"
:class="[
'flex items-center justify-between px-4 py-3 rounded-xl cursor-pointer transition-all duration-300',
activeMenu === item.key
? 'bg-white shadow-sm text-[#7816ff] font-bold border border-slate-100'
: 'text-slate-500 hover:bg-white/60 hover:text-slate-700'
]"
>
<div class="flex items-center gap-3">
<component
:is="item.icon"
theme="outline"
size="18"
:fill="activeMenu === item.key ? '#7816ff' : '#94a3b8'"
/>
<span class="text-[13px]">{{ item.name }}</span>
</div>
<right v-if="activeMenu === item.key" theme="outline" size="14" />
</div>
</nav>
</aside>
<main class="flex-1">
<slot></slot>
</main>
</div>
</div>
</template>

View File

@@ -0,0 +1,188 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import nativeHook from '@renderer/hooks/useRouterHook'
import { CheckOne } from '@icon-park/vue-next'
import { trpc } from '@renderer/lib/trpc'
import { IReadingReflectionTaskBatch } from '@shared/types/IReadingReflectionTask'
import { TASK_STATUS } from '@renderer/common/taskStatus'
import { eventBus } from '@renderer/lib/eventBus'
const { go, getQuery } = nativeHook()
// 初始化时从 URL 获取当前选中的 ID否则默认为空
const activeTaskId = ref<string | number>((getQuery('id') as string) || '')
const tasks = ref<IReadingReflectionTaskBatch[]>([])
/**
* 获取初始列表数据
*/
const fetchData = async () => {
const result = await trpc.task.getBatches.query()
tasks.value = result as unknown as IReadingReflectionTaskBatch[]
return tasks.value
}
/**
* 跳转并选中
*/
const goTaskManage = (taskId: string | number) => {
activeTaskId.value = taskId
// 确保路径是你详情页的正确路径,比如 /task/manager 或 /manager
go('/task', { id: taskId })
}
/**
* 核心修复:监听刷新并自动跳转
*/
const handleRefreshAndSelectFirst = async () => {
const latestTasks = await fetchData()
// 使用 nextTick 确保数据已经渲染到响应式系统
await nextTick()
if (latestTasks.length > 0) {
// 默认选中第一项
const firstTask = latestTasks[0]
goTaskManage(firstTask.id)
} else {
// 任务全删光了,跳回创建页
go('/task/create')
}
}
// --- 订阅逻辑 ---
const statusSub = trpc.task.onReadingReflectionStatusUpdate.subscribe(undefined, {
onData(data) {
const target = tasks.value.find((t) => t.id === data.taskId)
if (target) {
target.progress = data.progress
// 可以在这里根据进度更新状态
}
}
})
const batchSub = trpc.task.onBatchProgressUpdate.subscribe(undefined, {
onData(data) {
const targetTask = tasks.value.find((t) => t.id === data.batchId)
if (targetTask) {
targetTask.progress = data.progress
targetTask.status = data.status
} else {
fetchData()
}
}
})
onMounted(async () => {
const list = await fetchData()
// 初始加载时,如果 URL 没参数且列表有数据,自动选第一个
if (!getQuery('id') && list.length > 0) {
goTaskManage(list[0].id)
}
eventBus.on('refresh-task-list', handleRefreshAndSelectFirst)
})
onUnmounted(() => {
statusSub.unsubscribe()
batchSub.unsubscribe()
eventBus.off('refresh-task-list', handleRefreshAndSelectFirst)
})
</script>
<template>
<div class="space-y-2">
<TransitionGroup name="list">
<div
v-for="task in tasks"
:key="task.id"
@click="goTaskManage(task.id)"
:class="[
'group relative p-4 rounded-xl cursor-pointer transition-all duration-300 border',
activeTaskId === task.id
? 'bg-white border-slate-200 shadow-sm'
: 'border-transparent hover:bg-white/60'
]"
>
<div
v-if="activeTaskId === task.id"
class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-[#7816ff] rounded-r-full"
></div>
<div class="flex justify-between items-center mb-3">
<div class="flex flex-col gap-1">
<span
class="text-[13px] font-bold truncate max-w-[140px] transition-colors"
:class="
activeTaskId === task.id
? 'text-slate-800'
: 'text-slate-500 group-hover:text-slate-700'
"
>
{{ task.bookName }}
</span>
<span class="text-[10px] text-slate-400 font-medium">
{{ task.totalCount }} 篇心得
</span>
</div>
<div class="flex items-center">
<div
v-if="task.status === 'PROCESSING'"
class="flex items-center justify-center w-5 h-5 rounded-full bg-purple-50"
>
<div class="w-1.5 h-1.5 bg-[#7816ff] rounded-full animate-pulse"></div>
</div>
<div
v-else
class="flex items-center justify-center w-5 h-5 rounded-full bg-green-50 text-green-500"
>
<check-one theme="filled" size="12" />
</div>
</div>
</div>
<div class="space-y-1.5">
<div class="flex justify-between items-center text-[9px] font-bold text-slate-400">
<span>{{ TASK_STATUS[task.status]?.text || '进行中' }}</span>
<span :class="{ 'text-[#7816ff]': activeTaskId === task.id }"
>{{ task.progress }}%</span
>
</div>
<div class="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div
class="h-full transition-all duration-700 ease-out rounded-full"
:class="
task.status === 'COMPLETED' || task.status === 'completed'
? 'bg-[#00b42a]'
: 'bg-[#7816ff]'
"
:style="{ width: task.progress + '%' }"
></div>
</div>
</div>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.group:active {
transform: scale(0.98);
}
/* 列表动画样式 */
.list-enter-active,
.list-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(-20px);
}
/* 确保移动时的平滑效果 */
.list-move {
transition: transform 0.4s ease;
}
</style>

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
import { h, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import { useRouterHook } from '@renderer/hooks/useRouterHook'
import { Refresh, Send } from '@icon-park/vue-next'
import { trpc } from '@renderer/lib/trpc'
import { ReadingReflectionsTask } from '@shared/types/reflections'
import { eventBus } from '@renderer/lib/eventBus'
const { go } = useRouterHook()
const form = reactive({
bookName: '打开心智',
description:
'心智是我们对外部世界的认知和一切思维方式的集合决定了人的信念、思考和行动。成长的本质就是不断用新的认知打破旧的认知重塑自己的心智模式。深度思考践行者、成长类公号“L先生说”主理人李睿秋带你探索心智的底层原理搭建一套行之有效的成长系统提供情绪、自驱、行动、学习、思考、创造六个方面的提升路径从而获得更明晰的头脑更平静的心态和更有主动权的人生',
occupation: 'teacher',
quantity: 1,
prompt: '',
wordCount: 500
} as ReadingReflectionsTask)
const occupationOptions = [
{ label: '学生', value: 'student' },
{ label: '职场白领', value: 'professional' },
{ label: '学者/研究员', value: 'scholar' },
{ label: '自由职业者', value: 'freelancer' }
]
const handleSubmit = async () => {
if (!form.bookName) {
Message.error('请输入书籍名称')
return
}
const result = await trpc.task.createReadingReflectionsTask.mutate({ ...form })
if (result.success) {
Message.success({
content: '任务已加入队列',
icon: () => h(Send, { theme: 'filled', fill: '#7816ff' })
})
// 成功后延迟一小会儿跳转,让用户看清提示
setTimeout(() => {
go('/task') // 跳转到列表页,列表页会自动 fetch 数据
eventBus.emit('refresh-task-list')
}, 500)
}
}
const handleReset = () => {
Object.assign(form, {
bookName: '',
description: '',
occupation: 'student',
quantity: 1,
prompt: '',
wordCount: 1000
})
}
</script>
<template>
<div class="flex-1 overflow-y-auto bg-[#FAFAFB] p-6 custom-scroll">
<div class="flex items-center justify-between mb-6 mx-auto">
<div class="flex items-center space-x-2">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<h2 class="text-base font-bold text-slate-800">创建读书心得任务</h2>
</div>
<a-button type="text" size="small" class="text-slate-400" @click="go('/task/list')">
返回列表
</a-button>
</div>
<div class="x-auto bg-white rounded-xl border border-slate-100 shadow-sm p-8">
<a-form :model="form" layout="vertical" @submit="handleSubmit">
<a-form-item field="bookName" label="书籍名称" required>
<template #label><span class="text-xs font-bold text-slate-700">书籍名称</span></template>
<a-input
v-model="form.bookName"
placeholder="请输入书籍完整名称..."
class="custom-input"
allow-clear
/>
</a-form-item>
<a-form-item field="description" label="书籍简介">
<template #label><span class="text-xs font-bold text-slate-700">书籍简介</span></template>
<a-textarea
v-model="form.description"
placeholder="简要描述书籍内容,帮助 AI 提取更准确的关键点..."
:auto-size="{ minRows: 3, maxRows: 5 }"
class="custom-input"
/>
</a-form-item>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<a-form-item field="occupation" label="阅读职业">
<template #label
><span class="text-xs font-bold text-slate-700">目标受众职业</span></template
>
<a-select v-model="form.occupation" class="custom-input">
<a-option v-for="item in occupationOptions" :key="item.value" :value="item.value">
{{ item.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item field="quantity" label="任务数量">
<template #label
><span class="text-xs font-bold text-slate-700">生成心得篇数</span></template
>
<a-input-number v-model="form.quantity" :min="1" :max="100" class="custom-input" />
</a-form-item>
</div>
<a-form-item field="wordCount" label="每篇字数">
<template #label
><span class="text-xs font-bold text-slate-700"
>单篇心得字数: {{ form.wordCount }} </span
></template
>
<div class="px-2 w-full">
<a-slider v-model="form.wordCount" :min="100" :max="5000" :step="100" />
</div>
</a-form-item>
<a-form-item field="prompt" label="个性化提示词">
<template #label
><span class="text-xs font-bold text-slate-700">补充要求 (Prompt)</span></template
>
<a-textarea
v-model="form.prompt"
placeholder="例如:使用鲁迅的文风、增加 3 个实战案例、针对小白用户..."
:auto-size="{ minRows: 2, maxRows: 4 }"
class="custom-input"
/>
</a-form-item>
<div class="flex justify-end items-center gap-3 pt-6 border-t border-slate-50 mt-4">
<a-button @click="handleReset" class="rounded-lg px-6">
<template #icon><Refresh theme="outline" size="14" /></template>
重置
</a-button>
<a-button
type="primary"
html-type="submit"
class="rounded-lg px-8 bg-[#7816ff] border-none hover:bg-[#620fd9]"
>
<template #icon><Send theme="outline" size="14" /></template>
立即开启任务
</a-button>
</div>
</a-form>
</div>
</div>
</template>
<style scoped>
/* 针对 C 端风格微调 Arco 控件 */
:deep(.custom-input) {
background-color: #fcfcfd !important;
border: 1px solid #f1f5f9 !important;
border-radius: 8px !important;
transition: all 0.2s;
}
:deep(.custom-input:hover) {
background-color: #fff !important;
border-color: #7816ff !important;
}
:deep(.arco-form-item-label) {
margin-bottom: 8px;
}
:deep(.arco-slider-bar) {
background-color: #7816ff;
}
:deep(.arco-slider-dot) {
border-color: #7816ff;
}
:deep(.arco-slider-btn::after) {
border-color: #7816ff;
}
.custom-scroll::-webkit-scrollbar {
width: 4px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #e5e7eb;
border-radius: 10px;
}
</style>

View File

@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import nativeHook from '@renderer/hooks/useRouterHook'
import { Copy, Left, Quote } from '@icon-park/vue-next'
import { trpc } from '@renderer/lib/trpc'
import { IReadingReflectionTaskItem } from '@shared/types/IReadingReflectionTask'
import { Message } from '@arco-design/web-vue'
const { getQuery, goBack } = nativeHook()
const subTaskId = computed(() => getQuery('id') as string)
const batchId = computed(() => getQuery('batchId') as string)
const content = ref<IReadingReflectionTaskItem | null>(null)
const isLoading = ref(true)
const fetchDetail = async () => {
if (!subTaskId.value) return
isLoading.value = true
try {
// 从批次列表中筛选出当前心得项
const data = await trpc.task.getBatchItems.query({ batchId: batchId.value })
content.value = data.find((item) => item.id === subTaskId.value) || null
} finally {
isLoading.value = false
}
}
/**
* 仅复制正文内容
* 排除标题、关键词和摘要
*/
const handleCopyContent = async () => {
if (!content.value?.content) {
Message.warning('内容尚未加载,无法复制')
return
}
try {
await navigator.clipboard.writeText(content.value.content)
Message.success({
content: '正文已成功复制到剪贴板',
duration: 2000
})
} catch (err) {
Message.error('复制失败,请手动选择复制')
console.error('Clipboard Error:', err)
}
}
onMounted(() => fetchDetail())
</script>
<template>
<div class="h-full flex flex-col bg-white">
<header class="h-14 border-b border-slate-50 px-6 flex items-center justify-between">
<div class="flex items-center">
<a-button type="text" size="small" class="text-slate-400" @click="goBack(1)">
<template #icon><left theme="outline" size="18" /></template>
</a-button>
<span class="text-sm font-bold text-slate-800 tracking-tight">返回列表</span>
</div>
<a-button
size="mini"
class="rounded-lg text-slate-500"
@click="handleCopyContent"
:disabled="!content"
>
<template #icon><copy theme="outline" size="14" /></template>
复制正文
</a-button>
</header>
<div class="flex-1 overflow-y-auto custom-scroll bg-[#FAFAFB] py-12">
<article
v-if="content"
class="w-full max-w-3xl mx-auto bg-white rounded-2xl border border-slate-100 p-12 relative shadow-sm"
>
<div class="flex flex-col justify-between items-start mb-5 border-b border-slate-50">
<h1 class="text-2xl font-black text-slate-800 leading-tight flex-1">
{{ content.title }}
</h1>
<div class="flex flex-wrap gap-1.5">
<span
v-for="tag in content.keywords"
:key="tag"
class="px-2 py-0.5 bg-purple-50 text-[#7816ff] text-[10px] font-bold rounded border border-purple-100/50"
>
# {{ tag }}
</span>
</div>
</div>
<div class="text-slate-600 text-[15px] leading-loose whitespace-pre-wrap min-h-[300px]">
{{ content.content }}
</div>
<footer class="mt-16 bg-slate-50 rounded-xl p-6 relative">
<quote class="absolute top-4 right-4 text-slate-200" size="24" theme="filled" />
<div class="text-[15px] font-black uppercase tracking-widest mb-3">文章摘要</div>
<p class="text-xs text-slate-500 leading-relaxed italic">{{ content.summary }}</p>
</footer>
</article>
</div>
</div>
</template>
<style scoped>
/* 沉浸式阅读字体微调 */
.prose p {
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
}
/* 隐藏滚动条但保留功能 */
.custom-scroll::-webkit-scrollbar {
width: 5px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #f1f5f9;
border-radius: 10px;
}
.custom-scroll::-webkit-scrollbar-track {
background: transparent;
}
/* 打印样式优化 */
@media print {
header,
.custom-scroll::-webkit-scrollbar {
display: none;
}
.bg-[#FAFAFB] {
background: white;
}
article {
border: none;
box-shadow: none;
}
}
</style>

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import nativeHook from '@renderer/hooks/useRouterHook'
import { Delete, FilePdfOne, Pause, PreviewOpen } from '@icon-park/vue-next'
import { trpc } from '@renderer/lib/trpc'
import {
IReadingReflectionTaskBatch,
IReadingReflectionTaskItem
} from '@shared/types/IReadingReflectionTask'
import { TASK_STATUS } from '@renderer/common/taskStatus'
import { Message, Modal } from '@arco-design/web-vue'
import { eventBus } from '@renderer/lib/eventBus'
const { getQuery, go } = nativeHook()
const bookSubTasks = ref<IReadingReflectionTaskItem[]>([])
const activeTaskId = computed(() => getQuery('id') as string)
const currentTask = ref<IReadingReflectionTaskBatch>({
id: '',
bookName: '加载中...',
totalCount: 0,
status: 'PENDING',
progress: 0,
createdAt: new Date()
})
/**
* 删除任务逻辑
*/
const handleDeleteTask = () => {
Modal.warning({
title: '确认删除任务',
content: '此操作将永久删除该书籍的所有心得记录及生成进度。',
hideCancel: false,
okText: '确认删除',
cancelText: '取消',
onOk: async () => {
const loading = Message.loading('正在从磁盘清理...')
try {
const result = await trpc.task.deleteBatch.mutate({
batchId: activeTaskId.value as string
})
if (result.success) {
Message.success('任务已成功删除')
eventBus.emit('refresh-task-list')
}
} catch (err) {
Message.error('删除失败,请检查数据库连接')
} finally {
loading.close()
}
}
})
}
// 预览功能
const handlePreview = (subTaskId: string) => {
go('/task/detail', {
id: subTaskId,
batchId: activeTaskId.value
})
}
const statusSub = trpc.task.onReadingReflectionStatusUpdate.subscribe(undefined, {
onData(data) {
const index = bookSubTasks.value.findIndex((item) => item.id === data.taskId)
if (index !== -1) {
const taskItem = bookSubTasks.value[index]
// 1. 处理状态映射逻辑
if (data.progress === 100) {
taskItem.status = 'COMPLETED'
} else if (TASK_STATUS[data.status]) {
taskItem.status = data.status as any
} else {
taskItem.status = 'WRITING'
}
// 2. 更新进度
taskItem.progress = data.progress
// 3. 处理生成结果内容
if (data.result) {
console.log('✅ 收到最终生成内容:', data.result.title)
taskItem.title = data.result.title || taskItem.title
taskItem.summary = data.result.summary || taskItem.summary
}
}
},
onError(err) {
console.error('订阅流异常:', err)
}
})
const batchSub = trpc.task.onBatchProgressUpdate.subscribe(undefined, {
onData(data) {
if (data.batchId === activeTaskId.value) {
currentTask.value.progress = data.progress
currentTask.value.status = data.status
}
}
})
const fetchCurrentTaskDetail = async () => {
if (!activeTaskId.value) return
const data = await trpc.task.getBatchDetail.query({ batchId: activeTaskId.value })
if (data) currentTask.value = data as unknown as IReadingReflectionTaskBatch
}
const fetchData = async () => {
if (!activeTaskId.value) return
bookSubTasks.value = await trpc.task.getBatchItems.query({ batchId: activeTaskId.value })
}
onMounted(() => {
fetchCurrentTaskDetail()
fetchData()
})
watchEffect(() => {
if (activeTaskId.value) {
fetchCurrentTaskDetail()
fetchData()
}
})
onUnmounted(() => {
statusSub.unsubscribe()
batchSub.unsubscribe()
})
</script>
<template>
<div class="h-full flex flex-col bg-[#FAFAFB]">
<header
class="h-16 bg-white border-b border-slate-100 flex items-center justify-between px-8 shrink-0 shadow-[0_1px_2px_rgba(0,0,0,0.02)] z-10"
>
<div class="flex items-center space-x-3">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<div class="flex flex-col">
<div class="text-sm font-bold text-slate-800 tracking-tight">
{{ currentTask.bookName }}
</div>
<div class="text-[10px] text-slate-400 font-medium">
总体进度{{ currentTask.progress }}%
</div>
</div>
</div>
<div class="flex items-center gap-3">
<a-button size="mini" class="hover:bg-slate-50">
<template #icon><pause theme="outline" size="12" /></template>
暂停队列
</a-button>
<a-button
size="mini"
type="primary"
class="bg-[#7816ff] border-none shadow-sm shadow-purple-200"
>
<template #icon><file-pdf-one theme="outline" size="12" /></template>
打包成果
</a-button>
<a-button size="mini" status="danger" class="hover:bg-slate-50" @click="handleDeleteTask">
<template #icon><delete theme="outline" size="12" /></template>
删除任务
</a-button>
</div>
</header>
<div class="flex-1 overflow-y-auto p-6 custom-scroll">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div
v-for="book in bookSubTasks"
:key="book.id"
class="batch-card group bg-white p-5 flex flex-col border border-slate-100 shadow-sm hover:shadow-md hover:border-slate-200 transition-all duration-300 rounded-xl relative overflow-hidden"
@click="book.status === 'COMPLETED' ? handlePreview(book.id) : null"
>
<div
class="absolute top-0 right-0 w-16 h-16 opacity-[0.03] -mr-4 -mt-4 transition-transform group-hover:scale-110"
>
<preview-open theme="outline" size="64" />
</div>
<div class="flex items-center justify-between relative z-10 flex-row">
<p class="flex-1 font-bold text-sm text-slate-800 leading-tight line-clamp-2 pr-4">
{{ book.title || '生成中...' }}
</p>
</div>
<div class="flex-1 text-[11px] leading-relaxed text-slate-500 line-clamp-3 italic mt-2">
{{ book.summary || TASK_STATUS[book.status]?.desc }}
</div>
<div class="flex items-center justify-between pt-4 border-t border-slate-50 mt-4">
<a-tag :color="TASK_STATUS[book.status]?.color" size="small" round>
{{ TASK_STATUS[book.status]?.text }}
</a-tag>
<a-button
size="mini"
type="secondary"
class="rounded-lg text-[#7816ff] bg-purple-50 border-none hover:bg-purple-100"
:disabled="book.status !== 'COMPLETED'"
@click.stop="handlePreview(book.id)"
>
预览成果
</a-button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouterHook } from '@renderer/hooks/useRouterHook'
const { go } = useRouterHook()
// 模拟书籍子任务数据
const bookSubTasks = ref([
{
id: 1,
title: '《三体》:硬核科幻中的人性博弈',
status: 'done',
description: '基于黑暗森林法则的深度解析,探讨文明生存的终极逻辑...'
},
{
id: 2,
title: '《人类简史》:从认知革命到智人兴起',
status: 'running',
description: '正在调用深度学习模型进行文本分析,生成结构化心得,请耐心等待排队...'
},
{
id: 3,
title: '《原则》:生活与工作的实战手册',
status: 'loading',
description: '任务已进入队列,等待算力调度中...'
},
{
id: 4,
title: '《认知觉醒》:开启自我改变的原动力',
status: 'success',
description: '生成完成!包含思维导图、核心金句及实践建议...'
}
])
// 状态映射颜色
const getStatusTag = (status: string) => {
const map: any = {
done: { color: '#7816ff', text: '已完成' },
success: { color: '#00b42a', text: '解析成功' },
running: { color: '#ff5722', text: '生成中' },
loading: { color: '#86909c', text: '排队中' }
}
return map[status] || map.loading
}
</script>
<template>
<div class="flex-1 overflow-y-auto bg-[#FAFAFB] p-6 custom-scroll">
<div class="flex justify-between items-center mb-6">
<div class="flex items-center space-x-2">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<h2 class="text-base font-bold text-slate-800">全部阅读任务</h2>
</div>
<a-button
type="primary"
shape="round"
size="small"
@click="go('/task')"
style="background: #7816ff; border: none"
>
+ 新建任务
</a-button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
<div
v-for="book in bookSubTasks"
:key="book.id"
class="batch-card bg-white p-5 flex flex-col border border-slate-100 shadow-sm hover:shadow-md transition-shadow duration-300 rounded-lg"
>
<div class="flex items-start justify-between mb-4">
<h3
class="font-bold text-sm leading-tight text-slate-800 line-clamp-1 pr-2"
:title="book.title"
>
{{ book.title }}
</h3>
<a-tag :color="getStatusTag(book.status).color" size="small" round border>
{{ getStatusTag(book.status).text }}
</a-tag>
</div>
<div class="flex-1 text-[11px] leading-relaxed text-slate-500 mb-6 line-clamp-3 h-12">
{{ book.description }}
</div>
<div class="flex items-center justify-end pt-4 border-t border-slate-50">
<a-button size="mini" type="text" style="color: #64748b"> 编辑 </a-button>
<a-button
size="mini"
type="primary"
:disabled="book.status === 'loading'"
:style="book.status === 'done' ? 'background: #7816ff' : ''"
>
预览结果
</a-button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 保持你的 custom-scroll 样式 */
.custom-scroll::-webkit-scrollbar {
width: 4px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #e5e7eb;
border-radius: 10px;
}
/* 卡片微动效 */
.batch-card {
transition: all 0.2s ease-in-out;
}
.batch-card:hover {
transform: translateY(-2px);
}
/* Arco Tag 微调 */
:deep(.arco-tag) {
font-size: 10px;
padding: 0 8px;
height: 20px;
}
</style>

View File

@@ -0,0 +1,45 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/task'
},
{
path: '/task',
name: 'Task',
component: () => import('@renderer/pages/task/index.vue')
},
{
path: '/task/create',
name: 'TaskCreate',
component: () => import('@renderer/pages/task/create.vue')
},
{
path: '/task/detail',
name: 'TaskDetail',
component: () => import('@renderer/pages/task/detail.vue')
},
{
path: '/setting',
name: 'Setting',
component: () => import('@renderer/pages/setting/index.vue')
},
{
path: '/about',
name: 'About',
component: () => import('@renderer/pages/about/index.vue')
},
{
path: '/faq',
name: 'FAQ',
component: () => import('@renderer/pages/faq/index.vue')
}
]
const router = createRouter({
routes,
history: createWebHistory()
})
export default router

View File

@@ -0,0 +1,33 @@
:root {
--color-primary: #000000;
--color-bg: #f6f7f9;
--radius-card: 10px;
--arcoblue-6: 0, 0, 0
}
body {
margin: 0;
background-color: var(--color-bg);
font-family: 'Inter',
-apple-system,
sans-serif;
}
body {
font-family: 'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
}
/* 隐藏滚动条但保留功能 */
::-webkit-scrollbar {
width: 0;
height: 0;
}
::-webkit-scrollbar-thumb {
border-radius: 10px;
}

View File

@@ -0,0 +1 @@
export const CONFIG_STORE_KEY = 'reading-helper-secret-key'

37
src/rpc/context.ts Normal file
View File

@@ -0,0 +1,37 @@
import { BrowserWindow } from 'electron'
import type { CreateContextOptions } from 'electron-trpc/main'
/**
* 1. 定义 Context 的结构
*/
export interface AppContext {
// 当前发起请求的窗口实例
window: BrowserWindow | null
// 当前窗口的唯一 IDElectron 内部 ID
webContentsId: number
// 可以在这里扩展用户信息,例如从 Session 中获取
user?: { id: string; role: string }
}
/**
* 2. 创建 Context 的工厂函数
*/
export const createContext = async (opts: CreateContextOptions): Promise<AppContext> => {
// 从 electron-trpc 的参数中解构出 event
const event = (opts as any).event as Electron.IpcMainInvokeEvent
// 通过 event.sender 找到发起请求的窗口
const window = BrowserWindow.fromWebContents(event.sender)
return {
window,
webContentsId: event.sender.id,
// 示例:可以根据某些逻辑注入额外信息
user: { id: 'current-user', role: 'admin' }
}
}
/**
* 3. 导出类型推导,供 init.ts 使用
*/
export type Context = Awaited<ReturnType<typeof createContext>>

43
src/rpc/init.ts Normal file
View File

@@ -0,0 +1,43 @@
import { initTRPC } from '@trpc/server'
import logger from '@shared//utils/logger'
import { type Context } from './context'
export const t = initTRPC.context<Context>().create({
isServer: true
})
// 定义日志中间件,实现调用的时候会记录日志
const loggerMiddleware = t.middleware(async ({ path, type, next, input, ctx }) => {
const startTime = Date.now()
const result = await next()
const duration = Date.now() - startTime
// 预处理参数和返回数据,防止换行破坏单行结构
const safeInput = JSON.stringify(input) || '无参数'
if (result.ok) {
// 成功日志:包含路径、耗时、参数、简短响应
const output = JSON.stringify(result.data) || ''
const safeOutput = output.length > 100 ? `${output.substring(0, 100)}...` : output
console.log('--- 编码测试:中文内容 ---')
logger.info(
`[前后台通讯成功:${ctx.window ? ctx.window.getTitle() : ''}] 路径:${path} | 类型:${type} | 耗时:${duration}ms | 参数:${safeInput} | 响应:${safeOutput}`
)
} else {
// 失败日志:包含路径、耗时、参数、错误码及原因
// 错误堆栈通常较长,建议在单行中只记录 Message详细堆栈可另行记录或放在最后
logger.error(
`[前后台通讯失败:${ctx.window ? ctx.window.getTitle() : ''}] 路径:${path} | 类型:${type} | 耗时:${duration}ms | 参数:${safeInput} | 错误:${result.error.code}(${result.error.message})`
)
// 如果需要排查极其严重的崩溃,堆栈信息依然建议保留(这部分无法缩减到一行,否则无法阅读)
if (result.error.code === 'INTERNAL_SERVER_ERROR' && result.error.stack) {
logger.debug(`详细堆栈: ${result.error.stack.replace(/\n/g, ' ')}`)
}
}
return result
})
export const router = t.router
export const publicProcedure = t.procedure.use(loggerMiddleware)

10
src/rpc/router.ts Normal file
View File

@@ -0,0 +1,10 @@
import { router } from './init'
import { taskRouter } from '@rpc/router/task.router'
import { configRouter } from '@rpc/router/config.router'
export const appRouter = router({
task: taskRouter,
config: configRouter
})
export type AppRouter = typeof appRouter

View File

@@ -0,0 +1,44 @@
import { z } from 'zod'
import { publicProcedure, router } from '@rpc/init'
import Store from 'electron-store'
import { CONFIG_STORE_KEY } from '@rpc/constants/store_key'
// 兼容性处理获取 Store 构造函数
const StoreClass = (Store as any).default || Store
const store = new StoreClass({ encryptionKey: CONFIG_STORE_KEY })
export const configRouter = router({
// 获取配置
// src/main/rpc/routers/configRouter.ts
getChatConfigs: publicProcedure.query(() => {
const data = store.get('chatModels')
// 检查是否包含必要的嵌套 Key如果没有说明是旧版本数据
if (data && !data.reading && !data.summary) {
console.log('检测到旧版本配置,正在重置...')
store.delete('chatModels') // 删除旧的根键
return null
}
return data || null
}),
// 分类保存配置
saveChatConfig: publicProcedure
.input(
z.object({
type: z.enum(['reading', 'summary']), // 明确支持 reading
config: z.object({
apiKey: z.string(),
baseURL: z.string(),
modelName: z.string(),
temperature: z.number()
})
})
)
.mutation(async ({ input }) => {
// 存储到 chatModels.reading 或 chatModels.summary
store.set(`chatModels.${input.type}`, input.config)
return { success: true }
})
})

View File

@@ -0,0 +1,140 @@
import { publicProcedure, router } from '@rpc/init'
import { ReadingReflectionsTaskSchema } from '@shared/types/reflections'
import { observable } from '@trpc/server/observable'
import {
readingReflectionsTaskManager,
readingReflectionTaskEvent
} from '@main/manager/readingReflectionsTaskManager'
import { v4 as uuidv4 } from 'uuid'
import { AppDataSource } from '@main/db/data-source'
import { z } from 'zod'
import { ReadingReflectionTaskBatch } from '@main/db/entities/ReadingReflectionTaskBatch'
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
import {
IReadingReflectionTaskBatch,
IReadingReflectionTaskItem
} from '@shared/types/IReadingReflectionTask'
/**
* 读书心得任务路由
* 处理从任务创建、实时进度订阅到历史数据查询的全流程
*/
export const taskRouter = router({
/**
* 创建读书心得生成任务
* @param input {ReadingReflectionsTaskSchema}
*/
createReadingReflectionsTask: publicProcedure
.input(ReadingReflectionsTaskSchema)
.mutation(async ({ input }) => {
const taskId = uuidv4()
// 启动后台异步任务
readingReflectionsTaskManager
.startBatchTask(taskId, input)
.catch((err) => console.error('Task execution failed:', err))
return { success: true, taskId }
}),
/**
* 订阅子任务进度更新 (用于右侧卡片实时动画)
*/
onReadingReflectionStatusUpdate: publicProcedure.subscription(() => {
return observable<{
taskId: string
progress: number
status: string
result?: any
}>((emit) => {
const onUpdate = (data: any) => emit.next(data)
readingReflectionTaskEvent.on('readingReflectionTaskProgress', onUpdate)
return () => readingReflectionTaskEvent.off('readingReflectionTaskProgress', onUpdate)
})
}),
/**
* 订阅主批次进度更新 (用于左侧侧边栏进度条)
*/
onBatchProgressUpdate: publicProcedure.subscription(() => {
return observable<{
batchId: string
progress: number
status: string
}>((emit) => {
const onBatchUpdate = (data: any) => emit.next(data)
readingReflectionTaskEvent.on('batchProgressUpdate', onBatchUpdate)
return () => readingReflectionTaskEvent.off('batchProgressUpdate', onBatchUpdate)
})
}),
/**
* 获取主任务批次列表
* 包含总体进度 progress 和状态 status
*/
getBatches: publicProcedure.query(async () => {
const repo = AppDataSource.getRepository(ReadingReflectionTaskBatch)
const batches = await repo.find({
order: { createdAt: 'DESC' }
})
return batches as IReadingReflectionTaskBatch[]
}),
/**
* 获取特定批次下的所有子任务项
* 已根据 IReadingReflectionTaskItem 接口调整,直接返回扁平化字段
*/
getBatchItems: publicProcedure
.input(z.object({ batchId: z.string() }))
.query(async ({ input }) => {
const repo = AppDataSource.getRepository(ReadingReflectionTaskItem)
const items = await repo.find({
where: { batch: { id: input.batchId } }
})
// 这里的 items 已经包含了 title, summary, keywords 字段
return items as IReadingReflectionTaskItem[]
}),
/**
* 获取批次详情及其关联的子任务
* 适用于点击左侧列表后,一次性初始化右侧所有内容
*/
getBatchDetail: publicProcedure
.input(z.object({ batchId: z.string() }))
.query(async ({ input }) => {
const batchRepo = AppDataSource.getRepository(ReadingReflectionTaskBatch)
const batch = await batchRepo.findOne({
where: { id: input.batchId },
relations: ['items']
})
if (!batch) return null
return batch as IReadingReflectionTaskBatch
}),
/**
* 删除整个任务批次
* 由于配置了级联删除,会自动清理所有关联的 ReadingReflectionTaskItem
*/
deleteBatch: publicProcedure
.input(z.object({ batchId: z.string() }))
.mutation(async ({ input }) => {
// 获取两个仓库
const batchRepo = AppDataSource.getRepository(ReadingReflectionTaskBatch)
const itemRepo = AppDataSource.getRepository(ReadingReflectionTaskItem)
// 1. 显式删除该批次下所有的子项
// 使用 QueryBuilder 或 delete 确保所有 batchId 匹配的项都被清理
await itemRepo.delete({
batch: { id: input.batchId }
})
// 2. 现在子项已经清空,可以安全删除主批次了
const result = await batchRepo.delete(input.batchId)
return {
success: result.affected ? result.affected > 0 : false
}
})
})

View File

@@ -0,0 +1,24 @@
/**
* 对应数据库中的批次记录 (主任务)
*/
export interface IReadingReflectionTaskBatch {
id: string
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[]
}

View File

@@ -0,0 +1,36 @@
import { z } from 'zod'
/**
* 读者职业枚举
*/
export type Occupation = 'student' | 'teacher' | 'professional' | 'researcher' | 'other'
/**
* 读书心得生成请求任务模型
*/
export const ReadingReflectionsTaskSchema = z.object({
bookName: z.string().min(1, '书名不能为空'),
author: z.string().optional(),
description: z.string(),
occupation: z.enum(['student', 'teacher', 'professional', 'researcher', 'other']),
prompt: z.string(),
wordCount: z.number().default(1000),
quantity: z.number().min(1).max(5).default(1),
language: z.enum(['zh', 'en']).optional(),
tone: z.string().optional()
})
export type ReadingReflectionsTask = z.infer<typeof ReadingReflectionsTaskSchema>
/**
* 任务响应结果模型
*/
export interface ReadingReflectionsResponse {
taskId: string
reflections: Array<{
title: string // 心得标题
content: string // 心得正文
keywords: string[] // 提取的关键词
summary: string // 内容摘要
}>
status: 'pending' | 'completed' | 'failed'
}

View File

@@ -0,0 +1,28 @@
import log from 'electron-log/main'
import { EventEmitter } from 'events'
export const logEvent = new EventEmitter()
// 自定义不同级别的颜色(仅对控制台有效)
log.transports.console.level = 'debug'
log.hooks.push((message, transport) => {
if (transport === log.transports.console) {
if (message.level === 'error') {
message.data[0] = `\x1b[31m${message.data[0]}\x1b[0m` // 红色
} else if (message.level === 'info') {
message.data[0] = `\x1b[32m${message.data[0]}\x1b[0m` // 绿色
}
}
return message
})
/**
* 发布日志
* @param message 日志信息
* */
log.hooks.push((message) => {
logEvent.emit('log', message)
return message
})
export default log