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