fix:基本实现相关功能
This commit is contained in:
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
.idea
|
||||
3
.npmrc
Normal file
3
.npmrc
Normal file
@@ -0,0 +1,3 @@
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
shamefully-hoist=true
|
||||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
out
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
LICENSE.md
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
||||
4
.prettierrc.yaml
Normal file
4
.prettierrc.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
singleQuote: true
|
||||
semi: false
|
||||
printWidth: 100
|
||||
trailingComma: none
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint"]
|
||||
}
|
||||
39
.vscode/launch.json
vendored
Normal file
39
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Main Process",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceRoot}",
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
|
||||
"windows": {
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
|
||||
},
|
||||
"runtimeArgs": ["--sourcemap"],
|
||||
"env": {
|
||||
"REMOTE_DEBUGGING_PORT": "9222"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Debug Renderer Process",
|
||||
"port": 9222,
|
||||
"request": "attach",
|
||||
"type": "chrome",
|
||||
"webRoot": "${workspaceFolder}/src/renderer",
|
||||
"timeout": 60000,
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Debug All",
|
||||
"configurations": ["Debug Main Process", "Debug Renderer Process"],
|
||||
"presentation": {
|
||||
"order": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
11
.vscode/settings.json
vendored
Normal file
11
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
123
README.md
Normal file
123
README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# AI阅读心得助手 (AI Reading Reflection Assistant)
|
||||
|
||||
一个基于Electron和Vue开发的桌面应用程序,利用人工智能技术帮助用户快速生成高质量的读书心得和反思。
|
||||
|
||||
## 功能特色
|
||||
|
||||
- **AI驱动的读书心得生成**:基于用户输入的书籍信息,利用大语言模型自动生成深度读书心得
|
||||
- **多职业视角**:根据用户的职业背景(学生、教师、职场人士、科研工作者等)定制化生成内容
|
||||
- **智能摘要与关键词提取**:自动生成内容摘要和关键词,便于回顾和索引
|
||||
- **任务管理**:支持批量处理多个读书反思任务
|
||||
- **现代化UI界面**:使用Vue和Arco Design构建的精致极简主义界面
|
||||
|
||||
## 技术架构
|
||||
|
||||
- **主框架**:Electron + Vue 3
|
||||
- **AI集成**:LangChain + LangGraph,支持结构化AI内容生成
|
||||
- **数据库**:TypeORM + SQLite,本地数据存储
|
||||
- **后端通信**:tRPC,类型安全的API调用
|
||||
- **构建工具**:Vite + TypeScript
|
||||
- **UI组件库**:Arco Design Vue
|
||||
- **样式框架**:UnoCSS
|
||||
|
||||
## 核心功能模块
|
||||
|
||||
1. **AI服务模块**:集成大语言模型,实现读书心得的智能生成
|
||||
2. **状态管理**:使用LangGraph管理AI生成流程的状态
|
||||
3. **数据库管理**:使用TypeORM管理任务数据和用户配置
|
||||
4. **任务管理器**:支持批量处理和任务状态追踪
|
||||
5. **用户界面**:现代化Vue界面,支持任务创建、查看和管理
|
||||
|
||||
## 安装与运行
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 18
|
||||
- pnpm (推荐)
|
||||
|
||||
### 开发环境设置
|
||||
|
||||
1. 克隆项目:
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd read_book
|
||||
```
|
||||
|
||||
2. 安装依赖:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. 启动开发模式:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 构建与打包
|
||||
|
||||
1. 构建应用:
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
2. 打包为桌面应用:
|
||||
```bash
|
||||
# Windows
|
||||
pnpm build:win
|
||||
|
||||
# macOS
|
||||
pnpm build:mac
|
||||
|
||||
# Linux
|
||||
pnpm build:linux
|
||||
```
|
||||
|
||||
## AI工作流程
|
||||
|
||||
本应用使用LangGraph构建AI工作流程:
|
||||
|
||||
1. **内容生成节点**:根据书籍信息和用户职业背景生成读书心得正文
|
||||
2. **摘要生成节点**:对生成的内容进行摘要和关键词提取
|
||||
3. **结构化输出**:使用Zod模式确保输出格式的一致性
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── main/ # Electron主进程
|
||||
│ ├── db/ # 数据库配置
|
||||
│ ├── manager/ # 任务管理器
|
||||
│ └── services/ # 核心服务
|
||||
│ └── ai/ # AI服务
|
||||
├── renderer/ # Vue渲染进程
|
||||
│ ├── components/ # UI组件
|
||||
│ ├── pages/ # 页面组件
|
||||
│ └── views/ # 视图组件
|
||||
├── preload/ # 预加载脚本
|
||||
├── rpc/ # tRPC配置
|
||||
└── shared/ # 共享类型定义
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
应用使用TypeScript进行类型安全的开发,并通过以下方式配置:
|
||||
|
||||
- **构建配置**:`electron.vite.config.ts`
|
||||
- **样式配置**:`uno.config.ts`
|
||||
- **TypeScript配置**:`tsconfig.json`
|
||||
- **数据库配置**:`src/main/db/data-source.ts`
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交Issue和Pull Request来改进项目。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 [在此添加您的许可证] 许可证。
|
||||
|
||||
## 致谢
|
||||
|
||||
- Electron - 跨平台桌面应用框架
|
||||
- Vue.js - 前端框架
|
||||
- LangChain & LangGraph - AI开发框架
|
||||
- Arco Design - UI组件库
|
||||
48
THEME.md
Normal file
48
THEME.md
Normal file
@@ -0,0 +1,48 @@
|
||||
## 🎨 风格定义:AI-Native 精致极简主义 (AI-Native Refined Minimalism)
|
||||
|
||||
### 1. 核心视觉特征 (Core Visuals)
|
||||
|
||||
* **主色调 (Brand Color):** `#7816ff` (电光紫)。用于主按钮、进度条、激活态指示和关键 Tag。
|
||||
* **背景色 (Background):** 页面底色为 `#FAFAFB` (极浅灰蓝),卡片背景为纯白 `#FFFFFF`。
|
||||
* **边框与投影 (Border & Shadow):** * 边框使用极淡的 `slate-100` (`#f1f5f9`)。
|
||||
* 投影使用微弱的柔和阴影:`shadow-sm` (用于静止) / `shadow-md` (用于悬浮)。
|
||||
|
||||
|
||||
* **圆角 (Border Radius):** 统一的大圆角设计。卡片 `12px - 16px` (rounded-xl),按钮 `8px` (rounded-lg) 或全圆角 (round)。
|
||||
|
||||
### 2. 排版与字体 (Typography)
|
||||
|
||||
* **层级感:** 标题使用 `text-slate-800` (深灰而非纯黑),副标题/正文使用 `text-slate-500`。
|
||||
* **辅助文本:** 习惯使用 `text-[10px]` 或 `text-[11px]` 的小字号配合 `font-bold` 或 `uppercase` (全大写) 来营造科技感和精致感。
|
||||
* **留白:** 强调“呼吸感”,间距多使用 Tailwind 的 `p-6` 或 `gap-4`。
|
||||
|
||||
---
|
||||
|
||||
## 📝 通用生成提示词 (Master Prompt)
|
||||
|
||||
**你可以直接复制以下这段话作为后续请求的开头:**
|
||||
|
||||
> **Role:** 你是一位精通现代 AI 产品 UI/UX 的前端专家。
|
||||
> **Style Requirements:** 请基于以下风格指南生成 [此处填入页面名称,如:用户设置页]:
|
||||
> 1. **配色方案:** 背景使用 `#FAFAFB`,组件使用纯白,强调色统一使用紫色 `#7816ff`。
|
||||
> 2. **UI 元素:** 采用轻量化卡片流风格。卡片需有 `border-slate-100` 边框和微弱阴影 `shadow-sm`。
|
||||
> 3. **交互感:** 鼠标悬浮时应有轻微位移 (translate-y) 和投影增强。
|
||||
> 4. **组件库:** 使用 **Arco Design Vue** 组件,并使用 **Tailwind CSS** 进行样式微调。
|
||||
> 5. **图标:** 使用 **IconPark** 或 **Lucide** 风格的线性图标,粗细一致。
|
||||
> 6. **排版:** 字体避开纯黑,使用 `slate-800`。关键数据或状态使用小字号、高粗细的精致排版。
|
||||
>
|
||||
>
|
||||
> **Task:** [描述你具体的页面功能需求]
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 常用 UI 组件代码片段 (Style Snippets)
|
||||
|
||||
如果你需要手动编写,请参考这些“灵魂”代码:
|
||||
|
||||
* **卡片 Hover 效果:**
|
||||
`class="bg-white border border-slate-100 shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-300 rounded-xl"`
|
||||
* **左侧激活指示条:**
|
||||
`class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-[#7816ff] rounded-r-full"`
|
||||
* **精致小标签 (Tag):**
|
||||
`class="text-[10px] px-2 py-0.5 rounded-full border border-[#7816ff] text-[#7816ff] font-bold bg-purple-50"`
|
||||
3
dev-app-update.yml
Normal file
3
dev-app-update.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
updaterCacheDirName: read_books-updater
|
||||
45
electron-builder.yml
Normal file
45
electron-builder.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
appId: com.electron.app
|
||||
productName: read_books
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
executableName: read_books
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
notarize: false
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
- deb
|
||||
maintainer: electronjs.org
|
||||
category: Utility
|
||||
appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
npmRebuild: false
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
electronDownload:
|
||||
mirror: https://npmmirror.com/mirrors/electron/
|
||||
44
electron.vite.config.ts
Normal file
44
electron.vite.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import UnoCSS from 'unocss/vite'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ArcoResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@main': resolve('src/main'),
|
||||
'@service': resolve('src/main/service'),
|
||||
'@shared': resolve('src/shared'),
|
||||
'@rpc': resolve('src/rpc')
|
||||
}
|
||||
}
|
||||
},
|
||||
preload: {},
|
||||
renderer: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src'),
|
||||
'@shared': resolve('src/shared'),
|
||||
'@rpc': resolve('src/rpc')
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
UnoCSS(),
|
||||
AutoImport({
|
||||
resolvers: [ArcoResolver()]
|
||||
}),
|
||||
Components({
|
||||
resolvers: [
|
||||
ArcoResolver({
|
||||
sideEffect: true
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
78
package.json
Normal file
78
package.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "read_books",
|
||||
"version": "1.0.0",
|
||||
"description": "An Electron application with Vue and TypeScript",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "example.com",
|
||||
"homepage": "https://electron-vite.org",
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --cache .",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "chcp 65001 && electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"build:mac": "npm run build && electron-builder --mac",
|
||||
"build:linux": "npm run build && electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-vue": "^2.57.0",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@icon-park/vue-next": "^1.4.2",
|
||||
"@langchain/core": "^1.1.11",
|
||||
"@langchain/openai": "^1.2.1",
|
||||
"@trpc/client": "^10.45.2",
|
||||
"@trpc/server": "^10.45.2",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-store": "^6.0.1",
|
||||
"electron-trpc": "^0.7.1",
|
||||
"electron-updater": "^6.3.9",
|
||||
"langchain": "^1.2.4",
|
||||
"mitt": "^3.0.1",
|
||||
"p-limit": "^2.2.0",
|
||||
"pinia": "^3.0.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"typeorm": "^0.3.28",
|
||||
"vue-router": "^4.6.4",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@types/node": "^22.19.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"electron-vite": "^5.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"less": "^4.5.1",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.9.3",
|
||||
"unocss": "^66.5.12",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.2.6",
|
||||
"vue": "^3.5.25",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.1.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3",
|
||||
"electron",
|
||||
"electron-winstaller",
|
||||
"esbuild",
|
||||
"less"
|
||||
]
|
||||
}
|
||||
}
|
||||
7184
pnpm-lock.yaml
generated
Normal file
7184
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
resources/icon.png
Normal file
BIN
resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
30
src/main/db/data-source.ts
Normal file
30
src/main/db/data-source.ts
Normal 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!')
|
||||
}
|
||||
}
|
||||
38
src/main/db/entities/ReadingReflectionTaskBatch.ts
Normal file
38
src/main/db/entities/ReadingReflectionTaskBatch.ts
Normal 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[]
|
||||
}
|
||||
31
src/main/db/entities/ReadingReflectionTaskItem.ts
Normal file
31
src/main/db/entities/ReadingReflectionTaskItem.ts
Normal 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
87
src/main/index.ts
Normal 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.
|
||||
126
src/main/manager/readingReflectionsTaskManager.ts
Normal file
126
src/main/manager/readingReflectionsTaskManager.ts
Normal 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()
|
||||
13
src/main/services/ai/graph/readingReflectionGraph.ts
Normal file
13
src/main/services/ai/graph/readingReflectionGraph.ts
Normal 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()
|
||||
24
src/main/services/ai/llmService.ts
Normal file
24
src/main/services/ai/llmService.ts
Normal 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)
|
||||
}
|
||||
55
src/main/services/ai/nodes/readingReflectionContent.ts
Normal file
55
src/main/services/ai/nodes/readingReflectionContent.ts
Normal 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 }
|
||||
}
|
||||
46
src/main/services/ai/nodes/readingReflectionSummary.ts
Normal file
46
src/main/services/ai/nodes/readingReflectionSummary.ts
Normal 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
|
||||
}
|
||||
}
|
||||
61
src/main/services/ai/prompts/readingReflactionPrompts.ts
Normal file
61
src/main/services/ai/prompts/readingReflactionPrompts.ts
Normal 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"]
|
||||
}}
|
||||
`
|
||||
36
src/main/services/ai/state/readingReflectionState.ts
Normal file
36
src/main/services/ai/state/readingReflectionState.ts
Normal 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
8
src/preload/index.d.ts
vendored
Normal 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
20
src/preload/index.ts
Normal 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
10
src/renderer/auto-imports.d.ts
vendored
Normal 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
33
src/renderer/components.d.ts
vendored
Normal 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
17
src/renderer/index.html
Normal 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
58
src/renderer/src/App.vue
Normal 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>
|
||||
129
src/renderer/src/assets/global.less
Normal file
129
src/renderer/src/assets/global.less
Normal 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;
|
||||
}
|
||||
6
src/renderer/src/common/taskStatus.ts
Normal file
6
src/renderer/src/common/taskStatus.ts
Normal 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: '任务失败,正在重试...' }
|
||||
}
|
||||
88
src/renderer/src/components/ActiveMenu.vue
Normal file
88
src/renderer/src/components/ActiveMenu.vue
Normal 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
1
src/renderer/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
84
src/renderer/src/hooks/useRouterHook.ts
Normal file
84
src/renderer/src/hooks/useRouterHook.ts
Normal 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
|
||||
7
src/renderer/src/lib/eventBus.ts
Normal file
7
src/renderer/src/lib/eventBus.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import mitt from 'mitt'
|
||||
|
||||
type Events = {
|
||||
'refresh-task-list': void // 定义事件名和参数类型
|
||||
}
|
||||
|
||||
export const eventBus = mitt<Events>()
|
||||
13
src/renderer/src/lib/trpc.ts
Normal file
13
src/renderer/src/lib/trpc.ts
Normal 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
10
src/renderer/src/main.ts
Normal 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')
|
||||
181
src/renderer/src/pages/about/index.vue
Normal file
181
src/renderer/src/pages/about/index.vue
Normal 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>
|
||||
199
src/renderer/src/pages/faq/index.vue
Normal file
199
src/renderer/src/pages/faq/index.vue
Normal 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>
|
||||
5
src/renderer/src/pages/home/index.vue
Normal file
5
src/renderer/src/pages/home/index.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template></template>
|
||||
|
||||
<style scoped></style>
|
||||
181
src/renderer/src/pages/setting/components/ModelSection.vue
Normal file
181
src/renderer/src/pages/setting/components/ModelSection.vue
Normal 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>
|
||||
307
src/renderer/src/pages/setting/index.vue
Normal file
307
src/renderer/src/pages/setting/index.vue
Normal 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>
|
||||
50
src/renderer/src/pages/setting/layout/SettingLayout.vue
Normal file
50
src/renderer/src/pages/setting/layout/SettingLayout.vue
Normal 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>
|
||||
188
src/renderer/src/pages/task/components/TaskList.vue
Normal file
188
src/renderer/src/pages/task/components/TaskList.vue
Normal 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>
|
||||
195
src/renderer/src/pages/task/create.vue
Normal file
195
src/renderer/src/pages/task/create.vue
Normal 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>
|
||||
145
src/renderer/src/pages/task/detail.vue
Normal file
145
src/renderer/src/pages/task/detail.vue
Normal 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>
|
||||
208
src/renderer/src/pages/task/index.vue
Normal file
208
src/renderer/src/pages/task/index.vue
Normal 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>
|
||||
127
src/renderer/src/pages/task/list.vue
Normal file
127
src/renderer/src/pages/task/list.vue
Normal 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>
|
||||
45
src/renderer/src/router/index.ts
Normal file
45
src/renderer/src/router/index.ts
Normal 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
|
||||
33
src/renderer/src/style.css
Normal file
33
src/renderer/src/style.css
Normal 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;
|
||||
}
|
||||
1
src/rpc/constants/store_key.ts
Normal file
1
src/rpc/constants/store_key.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const CONFIG_STORE_KEY = 'reading-helper-secret-key'
|
||||
37
src/rpc/context.ts
Normal file
37
src/rpc/context.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import type { CreateContextOptions } from 'electron-trpc/main'
|
||||
|
||||
/**
|
||||
* 1. 定义 Context 的结构
|
||||
*/
|
||||
export interface AppContext {
|
||||
// 当前发起请求的窗口实例
|
||||
window: BrowserWindow | null
|
||||
// 当前窗口的唯一 ID(Electron 内部 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
43
src/rpc/init.ts
Normal 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
10
src/rpc/router.ts
Normal 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
|
||||
44
src/rpc/router/config.router.ts
Normal file
44
src/rpc/router/config.router.ts
Normal 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 }
|
||||
})
|
||||
})
|
||||
140
src/rpc/router/task.router.ts
Normal file
140
src/rpc/router/task.router.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
24
src/shared/types/IReadingReflectionTask.ts
Normal file
24
src/shared/types/IReadingReflectionTask.ts
Normal 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[]
|
||||
}
|
||||
36
src/shared/types/reflections.ts
Normal file
36
src/shared/types/reflections.ts
Normal 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'
|
||||
}
|
||||
28
src/shared/utils/logger.ts
Normal file
28
src/shared/utils/logger.ts
Normal 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
|
||||
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||
}
|
||||
21
tsconfig.node.json
Normal file
21
tsconfig.node.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/rpc/**/*", "src/shared/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": false,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictPropertyInitialization": false, // 建议开启,防止 Entity 初始化报错
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["electron-vite/node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@main/*": ["src/main/*"],
|
||||
"@shared/*": ["src/shared/*"],
|
||||
"@service/*": ["src/main/service/*"],
|
||||
"@rpc/*":["src/rpc/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
27
tsconfig.web.json
Normal file
27
tsconfig.web.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends":"@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": [
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.vue",
|
||||
"src/preload/*.d.ts",
|
||||
"src/shared/**/*",
|
||||
"src/rpc/**/*",
|
||||
|
||||
],
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true, // 必须开启
|
||||
"emitDecoratorMetadata": true, // 必须开启
|
||||
"strictPropertyInitialization": false, // 建议开启,防止装饰器属性报错
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@main/*": ["src/main/*"],
|
||||
"@renderer/*": [
|
||||
"src/renderer/src/*"
|
||||
],
|
||||
"@shared/*": ["src/shared/*"],
|
||||
"@rpc/*": ["src/rpc/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
3
uno.config.ts
Normal file
3
uno.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from 'unocss'
|
||||
|
||||
export default defineConfig({})
|
||||
Reference in New Issue
Block a user