feat(desktop): 优化一些逻辑

1. 优化通知配置

2. 优化命名规范

3. 优化代码逻辑
This commit is contained in:
2026-01-10 23:37:28 +08:00
parent bce411af7e
commit 36cf521851
30 changed files with 674 additions and 575 deletions

BIN
db.sqlite

Binary file not shown.

View File

@@ -1,4 +1,4 @@
appId: com.electron.app appId: com.reading_book
productName: read_books productName: read_books
directories: directories:
buildResources: build buildResources: build

View File

@@ -1,10 +1,8 @@
{ {
"name": "read_books", "name": "read_books",
"version": "1.0.0", "version": "1.0.0",
"description": "An Electron application with Vue and TypeScript", "description": "让每一篇阅读者享受阅读与思考的乐趣",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "example.com",
"homepage": "https://electron-vite.org",
"scripts": { "scripts": {
"format": "prettier --write .", "format": "prettier --write .",
"lint": "eslint --cache .", "lint": "eslint --cache .",

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, shell } from 'electron' import { app, BrowserWindow, shell } from 'electron'
import { join } from 'path' import { join } from 'path'
import { electronApp, is, optimizer } from '@electron-toolkit/utils' import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset' import icon from '../../resources/icon.png?asset'
@@ -8,11 +8,11 @@ import { createIPCHandler } from 'electron-trpc/main'
import { initDB } from '@main/db/data-source' import { initDB } from '@main/db/data-source'
function createWindow(): void { function createWindow(): void {
// Create the browser window.
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1200, width: 1200,
height: 800, height: 800,
show: false, show: false,
title: '读书心得助手',
resizable: false, resizable: false,
autoHideMenuBar: true, autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}), ...(process.platform === 'linux' ? { icon } : {}),
@@ -38,8 +38,6 @@ function createWindow(): void {
return { action: 'deny' } 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']) { if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else { } else {
@@ -47,41 +45,22 @@ function createWindow(): void {
} }
} }
// 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 () => { app.whenReady().then(async () => {
await initDB() await initDB()
// Set app user model id for windows electronApp.setAppUserModelId('com.reading_book')
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) => { app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window) optimizer.watchWindowShortcuts(window)
}) })
// IPC test
ipcMain.on('ping', () => console.log('pong'))
createWindow() createWindow()
app.on('activate', function () { 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() 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', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
app.quit() app.quit()
} }
}) })
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.

View File

@@ -4,9 +4,16 @@ import { readingReflectionGraph } from '@main/services/ai/graph/readingReflectio
import { AppDataSource } from '@main/db/data-source' import { AppDataSource } from '@main/db/data-source'
import { ReadingReflectionTaskBatch } from '@main/db/entities/ReadingReflectionTaskBatch' import { ReadingReflectionTaskBatch } from '@main/db/entities/ReadingReflectionTaskBatch'
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem' import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
import Store from 'electron-store'
import { CONFIG_STORE_KEY } from '@rpc/constants/store_key'
import { Notification } from 'electron'
export const readingReflectionTaskEvent = new EventEmitter() export const readingReflectionTaskEvent = new EventEmitter()
// 兼容性处理获取 Store 构造函数
const StoreClass = (Store as any).default || Store
const store = new StoreClass({ encryptionKey: CONFIG_STORE_KEY })
class TaskManager { class TaskManager {
private limit = pLimit(2) private limit = pLimit(2)
private batchRepo = AppDataSource.getRepository(ReadingReflectionTaskBatch) private batchRepo = AppDataSource.getRepository(ReadingReflectionTaskBatch)
@@ -113,6 +120,7 @@ class TaskManager {
result?: any result?: any
) { ) {
const displayId = total === 1 ? taskId : `${taskId}-${index}` const displayId = total === 1 ? taskId : `${taskId}-${index}`
//发送 tRPC 实时事件(驱动前端 UI 进度条)
readingReflectionTaskEvent.emit('readingReflectionTaskProgress', { readingReflectionTaskEvent.emit('readingReflectionTaskProgress', {
taskId: displayId, taskId: displayId,
progress, progress,
@@ -120,6 +128,44 @@ class TaskManager {
statusText: `[任务${index + 1}/${total}] ${status}`, // 传描述文字 statusText: `[任务${index + 1}/${total}] ${status}`, // 传描述文字
result result
}) })
// 2. 添加任务状态通知判断
this.handleNotification(status, progress, total, index)
}
/**
* 内部私有方法:处理通知逻辑
*/
private handleNotification(status: string, progress: number, total: number, index: number) {
// 从 electron-store 获取用户偏好
const config = store.get('notification') || {
masterSwitch: true,
taskCompleted: true,
taskFailed: true
}
// 如果总开关关闭,直接拦截
if (!config.masterSwitch) return
// 场景 A: 任务全部完成 (100%)
if (progress === 100 && config.taskCompleted) {
// 只有当所有子任务都完成,或者当前是单任务时才弹出
// 如果是批量任务,你可以选择在最后一个子任务完成时通知
if (index + 1 === total) {
new Notification({
title: '🎉 读书心得已生成',
body: total > 1 ? `${total} 篇心得已全部处理完成。` : '您的书籍心得已准备就绪。',
silent: config.silentMode
}).show()
}
}
// 场景 B: 任务失败 (假设你传入的 status 是 'FAILED')
if (status === 'FAILED' && config.taskFailed) {
new Notification({
title: '❌ 任务生成失败',
body: `${index + 1} 项任务执行异常,请检查网络或 API 余额。`,
silent: config.silentMode
}).show()
}
} }
} }

View File

@@ -1,5 +1,5 @@
import { Annotation } from '@langchain/langgraph' import { Annotation } from '@langchain/langgraph'
import { Occupation } from '@shared/types/reflections' import { Occupation } from '@shared/types/IReadingReflectionTask'
export const ReadingReflectionState = Annotation.Root({ export const ReadingReflectionState = Annotation.Root({
// 输入任务 // 输入任务

View File

@@ -12,6 +12,7 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AButton: typeof import('@arco-design/web-vue')['Button'] AButton: typeof import('@arco-design/web-vue')['Button']
ACheckbox: typeof import('@arco-design/web-vue')['Checkbox']
ACollapse: typeof import('@arco-design/web-vue')['Collapse'] ACollapse: typeof import('@arco-design/web-vue')['Collapse']
ACollapseItem: typeof import('@arco-design/web-vue')['CollapseItem'] ACollapseItem: typeof import('@arco-design/web-vue')['CollapseItem']
ActiveMenu: typeof import('./src/components/ActiveMenu.vue')['default'] ActiveMenu: typeof import('./src/components/ActiveMenu.vue')['default']
@@ -23,6 +24,7 @@ declare module 'vue' {
AOption: typeof import('@arco-design/web-vue')['Option'] AOption: typeof import('@arco-design/web-vue')['Option']
ASelect: typeof import('@arco-design/web-vue')['Select'] ASelect: typeof import('@arco-design/web-vue')['Select']
ASlider: typeof import('@arco-design/web-vue')['Slider'] ASlider: typeof import('@arco-design/web-vue')['Slider']
ASwitch: typeof import('@arco-design/web-vue')['Switch']
ATabPane: typeof import('@arco-design/web-vue')['TabPane'] ATabPane: typeof import('@arco-design/web-vue')['TabPane']
ATabs: typeof import('@arco-design/web-vue')['Tabs'] ATabs: typeof import('@arco-design/web-vue')['Tabs']
ATag: typeof import('@arco-design/web-vue')['Tag'] ATag: typeof import('@arco-design/web-vue')['Tag']

View File

@@ -1,17 +1,17 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Electron</title> <title>读书心得助手 | 让每一篇阅读者享受阅读与思考的乐趣</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/> />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

View File

@@ -16,7 +16,6 @@ const navButtons = [
] ]
const active = (key: string) => { const active = (key: string) => {
console.log(key)
go('/' + key) go('/' + key)
} }
</script> </script>
@@ -40,7 +39,7 @@ const active = (key: string) => {
:fill="activeBtn === btn.key ? '#7816ff' : '#64748b'" :fill="activeBtn === btn.key ? '#7816ff' : '#64748b'"
/> />
<span class="text-[10px] ml-1.5 hidden group-hover:inline-block text-slate-500 font-medium"> <span class="text-[10px] ml-1.5 group-hover:inline-block text-slate-500 font-medium">
{{ btn.title }} {{ btn.title }}
</span> </span>
</div> </div>

View File

@@ -1,22 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouterHook } from '@renderer/hooks/useRouterHook' import { useRouterHook } from '@renderer/hooks/useRouterHook'
import { import { AdProduct, Github, HoldInterface, InternalData, Mail, Twitter } from '@icon-park/vue-next'
AdProduct, import applicationImg from '@renderer/assets/application.png'
ArrowRight,
Github,
HoldInterface,
InternalData,
Mail,
Twitter
} from '@icon-park/vue-next'
const { goBack } = useRouterHook() const { go } = useRouterHook()
const coreValues = [ const coreValues = [
{ {
icon: InternalData, icon: InternalData,
title: '数据驱动', title: '数据驱动',
desc: '基于自研深度学习模型,精准提取每一本书籍的灵魂。' desc: '基于模型,精准提取每一本书籍的灵魂。'
}, },
{ {
icon: AdProduct, icon: AdProduct,
@@ -34,15 +27,9 @@ const coreValues = [
<template> <template>
<div class="flex-1 h-full overflow-y-auto bg-[#FAFAFB] custom-scroll"> <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"> <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"> <h1 class="text-4xl font-black text-slate-800 mb-4 tracking-tight flex flex-col gap-4">
<span class="w-2 h-2 bg-[#7816ff] rounded-full animate-pulse"></span> <span>让每一篇阅读者</span>
<span class="text-[10px] font-bold text-[#7816ff] uppercase tracking-widest" <span class="text-[#7816ff]">享受阅读与思考的乐趣</span>
>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> </h1>
<p class="max-w-xl mx-auto text-slate-400 text-sm leading-relaxed mb-8"> <p class="max-w-xl mx-auto text-slate-400 text-sm leading-relaxed mb-8">
我们致力于通过最前沿的 AI 技术帮助深度阅读者高效消化知识 我们致力于通过最前沿的 AI 技术帮助深度阅读者高效消化知识
@@ -53,12 +40,10 @@ const coreValues = [
type="primary" type="primary"
shape="round" shape="round"
class="bg-[#7816ff] border-none px-8 h-10 shadow-lg shadow-purple-100" class="bg-[#7816ff] border-none px-8 h-10 shadow-lg shadow-purple-100"
@click="go('/task/create')"
> >
开始体验 开始体验
</a-button> </a-button>
<a-button shape="round" class="px-8 h-10 border-slate-200" @click="goBack()">
返回探索
</a-button>
</div> </div>
</section> </section>
@@ -90,10 +75,7 @@ const coreValues = [
class="bg-white rounded-[32px] border border-slate-100 shadow-sm overflow-hidden flex flex-col md:flex-row items-center" 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="flex-1 p-12 md:p-16">
<div class="text-[11px] font-bold text-[#7816ff] uppercase tracking-[0.2em] mb-4"> <h2 class="text-2xl font-bold text-slate-800 mb-6">让那些真正热爱文字的人</h2>
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"> <p class="text-sm text-slate-500 leading-relaxed mb-8">
在一个信息碎片化的时代深度阅读正变得前所未有的奢侈我们不希望 AI 在一个信息碎片化的时代深度阅读正变得前所未有的奢侈我们不希望 AI
替代阅读而是希望它能作为你的数字笔友帮你梳理逻辑捕捉灵感让你从繁琐的摘要工作中解放回归思考本身 替代阅读而是希望它能作为你的数字笔友帮你梳理逻辑捕捉灵感让你从繁琐的摘要工作中解放回归思考本身
@@ -101,16 +83,16 @@ const coreValues = [
<div class="flex items-center space-x-6"> <div class="flex items-center space-x-6">
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-xl font-black text-slate-800">12,400+</span> <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> <span class="text-[10px] text-slate-400 font-bold uppercase">使用用户</span>
</div> </div>
<div class="w-[1px] h-8 bg-slate-100"></div> <div class="w-[1px] h-8 bg-slate-100"></div>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-xl font-black text-slate-800">8.5M</span> <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> <span class="text-[10px] text-slate-400 font-bold uppercase">阅读量</span>
</div> </div>
</div> </div>
</div> </div>
<div class="flex-1 w-full h-[400px] bg-slate-50 flex items-center justify-center relative"> <div class="flex-1 w-full h-[400px] flex items-center justify-center relative">
<div <div
class="absolute inset-0 opacity-20" class="absolute inset-0 opacity-20"
style=" style="
@@ -119,19 +101,10 @@ const coreValues = [
" "
></div> ></div>
<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" class="relative z-10 w-90 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> <img :src="applicationImg" alt="" />
<div class="h-2 w-3/4 bg-slate-100 rounded-full mb-3"></div> <div class="mt-auto flex justify-end"></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> </div>
</div> </div>

View File

@@ -0,0 +1,45 @@
export const faqList = [
{
id: 1,
category: 'general',
q: '这个 AI 阅读工具是如何工作的?',
a: '我们利用深度学习模型对书籍文本进行语义分析。它不仅能总结全文,还能根据你选择的“职业背景”提取特定的知识点。'
},
{
id: 2,
category: 'usage',
q: '生成的字数可以超过 5000 字吗?',
a: '目前单次生成上限为 5000 字,以确保内容的逻辑连贯性。如果需要更长篇幅,建议分章节创建任务。'
},
{
id: 4,
category: 'billing',
q: '当前有订阅计划吗?',
a: '当前工具为免费使用不需要任何费用但是需要自己使用大模型KEY'
},
{
id: 5,
category: 'privacy',
q: '如何查看我的数据?',
a: '你可以在设置页面查看你的数据。'
},
{
id: 6,
category: 'usage',
q: '如何设置大模型?',
a: `
您可以在<span style="color: red">『设置中心』-『大模型配置』中分别设置读书心得与摘要模型</span>。
<br/>推荐使用【阿里云-心流平台 (iflow.cn)】提供的免费 API其单次生成支持长文本处理。若生成内容接近 5000 字上限,建议分章节创建任务以获得最佳逻辑效果。
<br/><br/>可以参考以下操作指南:
<br/>
<ul>
<li>获取 API Key访问 阿里云心流平台 (iflow.cn)。</li>
注册并登录后,在后台创建一个新的 API Key。心流平台目前提供稳定的免费额度非常适合个人阅读助手使用。
<li>配置接口地址:</li>
在应用设置的 Base URL 处填入https://apis.iflow.cn/v1。
<li>模型选择:</li>
平台提供的最新模型 ID。
</ul>
`
}
]

View File

@@ -9,6 +9,7 @@ import {
Security, Security,
Wallet Wallet
} from '@icon-park/vue-next' } from '@icon-park/vue-next'
import { faqList } from './data/faqData'
// 分类配置 // 分类配置
const categories = [ const categories = [
@@ -21,34 +22,6 @@ const categories = [
const activeCategory = ref('general') const activeCategory = ref('general')
const searchQuery = ref('') 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(() => { const filteredFaqs = computed(() => {
return faqList.filter((item) => { return faqList.filter((item) => {
@@ -110,7 +83,7 @@ const filteredFaqs = computed(() => {
<span class="text-[14px] font-bold text-slate-700">{{ faq.q }}</span> <span class="text-[14px] font-bold text-slate-700">{{ faq.q }}</span>
</template> </template>
<div class="text-[13px] leading-relaxed text-slate-500 py-2"> <div class="text-[13px] leading-relaxed text-slate-500 py-2">
{{ faq.a }} <span v-html="faq.a"></span>
</div> </div>
</a-collapse-item> </a-collapse-item>
</a-collapse> </a-collapse>

View File

@@ -1,62 +1,91 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { onMounted, reactive, ref, toRaw } from 'vue'
import { SettingConfig } from '@icon-park/vue-next' import { SettingConfig } from '@icon-park/vue-next'
import { Message } from '@arco-design/web-vue'
import { trpc } from '@renderer/lib/trpc'
import { modelOptions } from '../data/ModelData'
import { ModelForm } from '@renderer/pages/setting/types/IModel'
interface ModelOption { const activeTab = ref<'reading' | 'summary'>('reading') // reading
id: string // id modelName
name: string const modelForm = reactive<ModelForm>({
desc: string reading: {
tag?: string 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 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)
}
} }
interface ModelConfig { const handleSave = async (type: 'reading' | 'summary') => {
apiKey: string try {
baseURL: string // 使 toRaw Vue
modelName: string const configToSave = toRaw(modelForm[type])
temperature: number
// 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)
}
} }
//
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 selectModel = (modelId: string) => {
const newData = { ...props.modelValue } modelForm[activeTab.value].modelName = modelId
newData[activeTab.value].modelName = modelId
emit('update:modelValue', newData)
} }
onMounted(() => {
loadConfig()
})
</script> </script>
<template> <template>
<div v-if="modelValue" class="space-y-6 animate-in"> <div class="space-y-6 animate-in pb-10">
<a-tabs v-model:active-key="activeTab" type="capsule" class="custom-tabs"> <a-tabs v-model:active-key="activeTab" type="capsule" class="custom-tabs">
<a-tab-pane key="reflection" title="读书心得模型" /> <a-tab-pane key="reading" title="读书心得模型" />
<a-tab-pane key="summary" title="摘要总结模型" /> <a-tab-pane key="summary" title="摘要总结模型" />
</a-tabs> </a-tabs>
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-8"> <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="flex items-center space-x-2 mb-8">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div> <div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<h3 class="text-sm font-bold text-slate-800">接口配置</h3> <h3 class="text-sm font-bold text-slate-800">接口配置</h3>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2"> <div class="space-y-2">
<label class="setting-label">Base URL (API 代理地址)</label> <label class="setting-label">Base URL (API 代理地址)</label>
<a-input <a-input
v-model="modelValue[activeTab].baseURL" v-model="modelForm[activeTab].baseURL"
placeholder="https://api.openai.com/v1" placeholder="https://api.openai.com/v1"
class="custom-input" class="custom-input"
/> />
@@ -64,7 +93,7 @@ const selectModel = (modelId: string) => {
<div class="space-y-2"> <div class="space-y-2">
<label class="setting-label">API Key</label> <label class="setting-label">API Key</label>
<a-input-password <a-input-password
v-model="modelValue[activeTab].apiKey" v-model="modelForm[activeTab].apiKey"
placeholder="sk-..." placeholder="sk-..."
class="custom-input" class="custom-input"
/> />
@@ -77,37 +106,34 @@ const selectModel = (modelId: string) => {
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div> <div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<h3 class="text-sm font-bold text-slate-800">模型名称 (modelName)</h3> <h3 class="text-sm font-bold text-slate-800">模型名称 (modelName)</h3>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<a-input <a-input
v-model="modelValue[activeTab].modelName" v-model="modelForm[activeTab].modelName"
placeholder="或者手动输入模型 ID如 gpt-4o" placeholder="手动输入模型 ID如 gpt-4o"
class="custom-input" class="custom-input"
/> />
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div <div
v-for="m in options" v-for="m in modelOptions"
:key="m.id" :key="m.id"
@click="selectModel(m.id)" @click="selectModel(m.id)"
:class="[ :class="[
'p-4 rounded-xl border-2 cursor-pointer transition-all relative overflow-hidden', 'p-4 rounded-xl border-2 cursor-pointer transition-all relative overflow-hidden',
modelValue[activeTab].modelName === m.id modelForm[activeTab].modelName === m.id
? 'border-[#7816ff] bg-purple-50/30' ? 'border-[#7816ff] bg-purple-50/30'
: 'border-slate-50 bg-slate-50/50 hover:border-slate-200' : 'border-slate-50 bg-slate-50/50 hover:border-slate-200'
]" ]"
> >
<div class="flex justify-between items-start mb-2"> <div class="flex justify-between items-start mb-2">
<span class="text-xs font-bold text-slate-800">{{ m.name }}</span> <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"> <span v-if="m.tag" class="text-[9px] bg-[#7816ff] text-white px-1.5 py-0.5 rounded">{{
{{ m.tag }} m.tag
</span> }}</span>
</div> </div>
<p class="text-[10px] text-slate-400 leading-relaxed">{{ m.desc }}</p> <p class="text-[10px] text-slate-400 leading-relaxed">{{ m.desc }}</p>
<div <div
v-if="modelValue[activeTab].modelName === m.id" v-if="modelForm[activeTab].modelName === m.id"
class="absolute -right-2 -bottom-2 text-[#7816ff]/10" class="absolute -right-2 -bottom-2 text-[#7816ff]/10"
> >
<SettingConfig theme="outline" size="48" /> <SettingConfig theme="outline" size="48" />
@@ -120,33 +146,31 @@ const selectModel = (modelId: string) => {
<div class="flex items-center space-x-2 mb-8"> <div class="flex items-center space-x-2 mb-8">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div> <div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<h3 class="text-sm font-bold text-slate-800">运行参数</h3> <h3 class="text-sm font-bold text-slate-800">运行参数</h3>
<p class="text-[11px] text-[#7816ff] leading-relaxed"> <p class="text-[11px] text-[#7816ff] leading-relaxed ml-4 italic">
正在配置 正在配置
<span class="font-bold underline">{{ <span class="font-bold underline">{{
activeTab === 'reflection' ? '心得生成' : '文本总结' activeTab === 'reading' ? '心得生成' : '文本总结'
}}</span> }}</span>
专用模型 建议为总结模型设置较低的 Temperature 以保证稳定性 专用模型
</p> </p>
</div> </div>
<div class="space-y-8"> <div class="space-y-8">
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<label class="setting-label" <label class="setting-label"
>随机性 (Temperature): {{ modelValue[activeTab].temperature }}</label >随机性 (Temperature): {{ modelForm[activeTab].temperature }}</label
> >
<span class="text-[10px] text-slate-400">建议心得 0.7-0.8总结 0.2-0.3</span> <span class="text-[10px] text-slate-400">建议心得 0.7-0.8总结 0.2-0.3</span>
</div> </div>
<a-slider v-model="modelValue[activeTab].temperature" :min="0" :max="1.2" :step="0.1" /> <a-slider v-model="modelForm[activeTab].temperature" :min="0" :max="1.2" :step="0.1" />
</div> </div>
<div class="flex justify-end pt-4 border-t border-slate-50"> <div class="flex justify-end pt-4 border-t border-slate-50">
<a-button <a-button
type="primary" type="primary"
class="rounded-lg bg-[#7816ff] border-none px-8 font-bold" class="rounded-lg bg-[#7816ff] border-none px-8 font-bold"
@click="emit('save', activeTab)" @click="handleSave(activeTab)"
> >
保存 {{ activeTab === 'reflection' ? '心得' : '总结' }} 配置 保存 {{ activeTab === 'reading' ? '心得' : '总结' }} 配置
</a-button> </a-button>
</div> </div>
</div> </div>
@@ -155,7 +179,7 @@ const selectModel = (modelId: string) => {
</template> </template>
<style scoped> <style scoped>
/* 继承原有样式 */ /* 样式部分保持不变... */
.setting-label { .setting-label {
display: block; display: block;
font-size: 11px; font-size: 11px;
@@ -165,17 +189,24 @@ const selectModel = (modelId: string) => {
letter-spacing: 0.05em; letter-spacing: 0.05em;
padding: 0 4px; padding: 0 4px;
} }
:deep(.custom-input) {
/* Tabs 风格微调适配你的紫色主题 */ background-color: #fcfcfd !important;
:deep(.custom-tabs .arco-tabs-nav-capsule) { border: 1px solid #f1f5f9 !important;
background-color: #f1f5f9; border-radius: 12px !important;
border-radius: 12px; font-size: 13px;
padding: 4px;
} }
:deep(.custom-tabs .arco-tabs-nav-capsule-light) {
background-color: #fff; @keyframes fadeIn {
color: #7816ff; from {
font-weight: bold; opacity: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: fadeIn 0.4s ease-out forwards;
} }
</style> </style>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { Hourglass } from '@icon-park/vue-next'
</script>
<template>
<div class="h-[600px] flex flex-col items-center justify-center animate-in">
<div
class="relative bg-white rounded-3xl border border-slate-100 shadow-xl shadow-slate-200/50 p-12 max-w-md w-full text-center overflow-hidden"
>
<div class="space-y-3 relative z-10">
<h2 class="text-xl font-bold text-slate-800 tracking-tight">
<span class="text-[#7816ff]">正在构建中</span>
</h2>
<p class="text-sm text-slate-400 leading-relaxed px-3">
我们正在为该模块编写最后一行代码以确保为您提供最极致的体验
</p>
</div>
<div class="mt-10 space-y-4">
<div class="h-2 w-full bg-slate-100 rounded-full overflow-hidden">
<div
class="h-full bg-gradient-to-r from-[#7816ff] to-[#a855f7] w-[50%] rounded-full relative"
>
<div
class="absolute top-0 right-0 h-full w-2 bg-white/30 skew-x-12 animate-shimmer"
></div>
</div>
</div>
</div>
<div class="mt-10 pt-8 border-t border-slate-50 flex items-center justify-center gap-6">
<div class="flex items-center gap-2 text-[11px] text-slate-400 font-medium">
<hourglass theme="outline" size="14" fill="#94a3b8" />
预计下个版本上线
</div>
</div>
</div>
</div>
</template>
<style scoped>
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes shimmer {
from {
transform: translateX(-100%) skewX(-12deg);
}
to {
transform: translateX(200%) skewX(-12deg);
}
}
.animate-shimmer {
animation: shimmer 2s infinite linear;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: fadeIn 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
</style>

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import { reactive, onMounted, toRaw } from 'vue'
import { Remind, Tips, SettingTwo, MessageSuccess } from '@icon-park/vue-next'
import { Message } from '@arco-design/web-vue'
import { trpc } from '@renderer/lib/trpc'
import { INoticeConfig } from '@shared/types/IConfig'
// 1. 数据结构定义
const notificationForm = reactive<INoticeConfig>({
masterSwitch: true, // 总开关
taskCompleted: true, // 任务完成时提醒
taskFailed: true, // 任务失败时提醒
silentMode: false // 静默模式(仅弹窗无声音)
})
// 2. 加载与保存逻辑
const loadSettings = async () => {
try {
const saved = await trpc.config.getNoticeConfigs.query()
if (saved) Object.assign(notificationForm, saved)
} catch (e) {
console.error('加载通知设置失败', e)
}
}
const handleToggle = async () => {
try {
await trpc.config.saveNoticeConfigs.mutate(toRaw(notificationForm))
Message.success({ content: '通知偏好已更新', showIcon: true })
} catch (e) {
Message.error('保存设置失败')
}
}
onMounted(loadSettings)
</script>
<template>
<div class="space-y-6 animate-in">
<div
class="bg-gradient-to-br from-[#7816ff] to-[#a855f7] rounded-2xl p-8 text-white shadow-lg shadow-purple-200/50"
>
<div class="flex justify-between items-start">
<div class="space-y-2">
<h2 class="text-xl font-bold flex items-center gap-2">
<Remind theme="filled" size="24" fill="#fff" /> 通知管理中心
</h2>
<p class="text-purple-100 text-xs opacity-80">
实时掌握读书心得生成进度确保每一份灵感都能及时送达
</p>
</div>
<a-switch
v-model="notificationForm.masterSwitch"
type="line"
@change="handleToggle"
class="custom-white-switch"
/>
</div>
</div>
<div
class="bg-white rounded-2xl border border-slate-100 shadow-sm p-8"
:class="{ 'opacity-60 grayscale-[0.5] pointer-events-none': !notificationForm.masterSwitch }"
>
<div class="flex items-center justify-between mb-8">
<div class="flex items-center space-x-2">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<h3 class="text-sm font-bold text-slate-800">任务状态通知</h3>
</div>
<a-tag size="small" color="arcoblue" class="rounded-md font-bold text-[10px]"
>实时推送</a-tag
>
</div>
<div class="space-y-6">
<div
class="flex items-center justify-between p-4 rounded-xl bg-slate-50/50 border border-transparent hover:border-slate-100 transition-all"
>
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-full bg-green-50 flex items-center justify-center">
<message-success theme="outline" size="20" fill="#00b42a" />
</div>
<div>
<div class="text-sm font-bold text-slate-700">生成成功提醒</div>
<div class="text-[11px] text-slate-400">
当读书心得摘要生成完毕时发送桌面通知
</div>
</div>
</div>
<a-checkbox v-model="notificationForm.taskCompleted" @change="handleToggle" />
</div>
<div
class="flex items-center justify-between p-4 rounded-xl bg-slate-50/50 border border-transparent hover:border-slate-100 transition-all"
>
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center">
<tips theme="outline" size="20" fill="#f53f3f" />
</div>
<div>
<div class="text-sm font-bold text-slate-700">异常中断提醒</div>
<div class="text-[11px] text-slate-400">
若因网络波动或 API 余额不足导致生成失败时提醒
</div>
</div>
</div>
<a-checkbox v-model="notificationForm.taskFailed" @change="handleToggle" />
</div>
<div
class="flex items-center justify-between p-4 rounded-xl bg-slate-50/50 border border-transparent hover:border-slate-100 transition-all"
>
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-full bg-purple-50 flex items-center justify-center">
<setting-two theme="outline" size="20" fill="#7816ff" />
</div>
<div>
<div class="text-sm font-bold text-slate-700">静默模式</div>
<div class="text-[11px] text-slate-400">通知弹出时不再播放系统提示音</div>
</div>
</div>
<a-switch v-model="notificationForm.silentMode" size="small" @change="handleToggle" />
</div>
</div>
</div>
<div class="flex items-start gap-3 p-4 bg-blue-50/50 rounded-xl border border-blue-100">
<remind theme="outline" size="16" fill="#0066ff" class="mt-0.5" />
<p class="text-[11px] text-blue-500 leading-relaxed">
提示通知效果受操作系统专注模式勿扰模式影响如果在设置开启后仍未收到通知请检查系统的通知管理权限
</p>
</div>
</div>
</template>
<style scoped>
.custom-white-switch :deep(.arco-switch-dot) {
background-color: #7816ff;
}
.custom-white-switch :deep(.arco-switch-checked) {
background-color: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.5);
}
/* 继承你之前的动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: fadeIn 0.4s ease-out forwards;
}
</style>

View File

@@ -0,0 +1,8 @@
import { IModelOption } from '../types/IModel'
export const modelOptions: IModelOption[] = [
{ 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: '推荐' }
]

View File

@@ -1,112 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref, toRaw } from 'vue' import { computed, onMounted, ref } from 'vue'
import { Remind, Right, SettingConfig, ShieldAdd, Wallet } from '@icon-park/vue-next' import { Remind, Right, SettingConfig, ShieldAdd, Wallet } from '@icon-park/vue-next'
import { Message } from '@arco-design/web-vue' import ModelSetting from '@renderer/pages/setting/components/ModelSetting.vue'
import { trpc } from '@renderer/lib/trpc' import NotFound from '@renderer/pages/setting/components/NotFound.vue'
import NotificationSetting from '@renderer/pages/setting/components/NotificationSetting.vue'
// --- 类型定义 ---
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 activeMenu = ref('model')
const activeTab = ref<'reading' | 'summary'>('reading') // 初始设为 reading const activeMenuItem = computed(() => {
return menuItems.find((item) => item.key === activeMenu.value)?.component
})
const menuItems = [ const menuItems = [
{ key: 'model', name: '大模型配置', icon: SettingConfig }, { key: 'model', name: '大模型配置', icon: SettingConfig, component: ModelSetting },
{ key: 'account', name: '账号安全', icon: ShieldAdd }, { key: 'account', name: '账号安全', icon: ShieldAdd },
{ key: 'billing', name: '订阅与账单', icon: Wallet }, { key: 'billing', name: '订阅与账单', icon: Wallet },
{ key: 'notification', name: '通知设置', icon: Remind } { key: 'notification', name: '通知设置', icon: Remind, component: NotificationSetting }
] ]
onMounted(() => {})
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> </script>
<template> <template>
<div class="flex-1 h-full overflow-y-auto bg-[#FAFAFB] p-8 custom-scroll"> <div class="flex-1 h-full bg-[#FAFAFB] p-8 custom-scroll">
<div class="max-w-5xl mx-auto flex gap-8"> <div class="max-w-5xl mx-auto h-full flex gap-8">
<aside class="w-64 shrink-0"> <aside class="w-64 shrink-0">
<div class="flex items-center space-x-2 mb-8 px-2"> <div class="flex items-center space-x-2 mb-8 px-2">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div> <div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
@@ -137,171 +53,16 @@ onMounted(() => {
</div> </div>
</nav> </nav>
</aside> </aside>
<main class="flex-1 overflow-y-auto">
<main class="flex-1"> <template v-if="activeMenuItem">
<div v-if="activeMenu === 'model'" class="space-y-6 animate-in"> <component :is="activeMenuItem"></component>
<a-tabs v-model:active-key="activeTab" type="capsule" class="custom-tabs"> </template>
<a-tab-pane key="reading" title="读书心得模型" /> <template v-else>
<a-tab-pane key="summary" title="摘要总结模型" /> <NotFound />
</a-tabs> </template>
<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> </main>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped></style>
/* 样式部分保持不变... */
.setting-label {
display: block;
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0 4px;
}
:deep(.custom-input) {
background-color: #fcfcfd !important;
border: 1px solid #f1f5f9 !important;
border-radius: 12px !important;
font-size: 13px;
}
:deep(.custom-tabs .arco-tabs-nav-capsule) {
background-color: #f1f5f9;
border-radius: 12px;
padding: 4px;
}
:deep(.custom-tabs .arco-tabs-nav-capsule-light) {
background-color: #fff;
color: #7816ff;
font-weight: bold;
}
.custom-scroll::-webkit-scrollbar {
width: 4px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #e5e7eb;
border-radius: 10px;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: fadeIn 0.4s ease-out forwards;
}
</style>

View File

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

View File

@@ -0,0 +1,19 @@
import { ILLMConfig } from '@shared/types/IConfig'
/**
* 模型类型定义
* */
export interface IModelOption {
id: string
name: string
desc: string
tag?: string
}
/**
* 定义模型表单类型
* */
export interface ModelForm {
reading: ILLMConfig['config']
summary: ILLMConfig['config']
}

View File

@@ -4,7 +4,7 @@ import { Message } from '@arco-design/web-vue'
import { useRouterHook } from '@renderer/hooks/useRouterHook' import { useRouterHook } from '@renderer/hooks/useRouterHook'
import { Refresh, Send } from '@icon-park/vue-next' import { Refresh, Send } from '@icon-park/vue-next'
import { trpc } from '@renderer/lib/trpc' import { trpc } from '@renderer/lib/trpc'
import { ReadingReflectionsTask } from '@shared/types/reflections' import { IReadingReflectionsTask } from '@shared/types/IReadingReflectionTask'
import { eventBus } from '@renderer/lib/eventBus' import { eventBus } from '@renderer/lib/eventBus'
const { go } = useRouterHook() const { go } = useRouterHook()
@@ -17,13 +17,14 @@ const form = reactive({
quantity: 1, quantity: 1,
prompt: '', prompt: '',
wordCount: 500 wordCount: 500
} as ReadingReflectionsTask) } as IReadingReflectionsTask)
const occupationOptions = [ const occupationOptions = [
{ label: '学生', value: 'student' }, { label: '学生', value: 'student' },
{ label: '职场白领', value: 'professional' }, { label: '职场白领', value: 'professional' },
{ label: '学者/研究员', value: 'scholar' }, { label: '学者/研究员', value: 'scholar' },
{ label: '自由职业者', value: 'freelancer' } { label: '自由职业者', value: 'freelancer' },
{ label: '教师', value: 'teacher' }
] ]
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -60,18 +61,12 @@ const handleReset = () => {
</script> </script>
<template> <template>
<div class="flex-1 overflow-y-auto bg-[#FAFAFB] p-6 custom-scroll"> <div class="flex-1 p-6">
<div class="flex items-center justify-between mb-6 mx-auto"> <div class="flex-1 bg-white rounded-xl border border-slate-100 shadow-sm p-8 custom-scroll">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="w-1 h-5 bg-[#7816ff] rounded-full"></div> <div class="w-1 h-5 bg-[#7816ff] rounded-full"></div>
<h2 class="text-base font-bold text-slate-800">创建读书心得任务</h2> <h2 class="text-base font-bold text-slate-800">创建读书心得任务</h2>
</div> </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 :model="form" layout="vertical" @submit="handleSubmit">
<a-form-item field="bookName" label="书籍名称" required> <a-form-item field="bookName" label="书籍名称" required>
<template #label><span class="text-xs font-bold text-slate-700">书籍名称</span></template> <template #label><span class="text-xs font-bold text-slate-700">书籍名称</span></template>

View File

@@ -1 +1,5 @@
export const CONFIG_STORE_KEY = 'reading-helper-secret-key' export const CONFIG_STORE_KEY = 'reading-helper-secret-key'
export const NOTIFICATION_KEY = 'notification'
export const CHAT_MODELS_KEY = 'chatModels'

View File

@@ -19,20 +19,19 @@ const loggerMiddleware = t.middleware(async ({ path, type, next, input, ctx }) =
// 成功日志:包含路径、耗时、参数、简短响应 // 成功日志:包含路径、耗时、参数、简短响应
const output = JSON.stringify(result.data) || '' const output = JSON.stringify(result.data) || ''
const safeOutput = output.length > 100 ? `${output.substring(0, 100)}...` : output const safeOutput = output.length > 100 ? `${output.substring(0, 100)}...` : output
console.log('--- 编码测试:中文内容 ---')
logger.info( logger.info(
`[前后台通讯成功:${ctx.window ? ctx.window.getTitle() : ''}] 路径:${path} | 类型:${type} | 耗时:${duration}ms | 参数:${safeInput} | 响应:${safeOutput}` `[rpc:${ctx.window ? ctx.window.getTitle() : ''}] 路径:${path} | 类型:${type} | 耗时:${duration}ms | 参数:${safeInput} | 响应:${safeOutput}`
) )
} else { } else {
// 失败日志:包含路径、耗时、参数、错误码及原因 // 失败日志:包含路径、耗时、参数、错误码及原因
// 错误堆栈通常较长,建议在单行中只记录 Message详细堆栈可另行记录或放在最后 // 错误堆栈通常较长,建议在单行中只记录 Message详细堆栈可另行记录或放在最后
logger.error( logger.error(
`[前后台通讯失败:${ctx.window ? ctx.window.getTitle() : ''}] 路径:${path} | 类型:${type} | 耗时:${duration}ms | 参数:${safeInput} | 错误:${result.error.code}(${result.error.message})` `[rpc:${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) { if (result.error.code === 'INTERNAL_SERVER_ERROR' && result.error.stack) {
logger.debug(`详细堆栈: ${result.error.stack.replace(/\n/g, ' ')}`) logger.debug(`rpc详细堆栈: ${result.error.stack.replace(/\n/g, ' ')}`)
} }
} }

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
import { z } from 'zod'
import { router, publicProcedure } from '@rpc/index'
import { Notification } from 'electron'
export const noticeRouter = router({
// 发送系统通知
send: publicProcedure
.input(
z.object({
title: z.string(),
body: z.string(),
silent: z.boolean().optional().default(false)
})
)
.mutation(({ input }) => {
// 检查系统是否支持通知
if (!Notification.isSupported()) {
throw new Error('当前系统不支持桌面通知')
}
const notification = new Notification({
title: input.title,
body: input.body,
silent: input.silent
})
notification.show()
// 监听点击事件(可选)
notification.on('click', () => {
console.log('用户点击了通知')
})
return { success: true }
})
})

View File

@@ -1,5 +1,4 @@
import { publicProcedure, router } from '@rpc/init' import { publicProcedure, router } from '@rpc/index'
import { ReadingReflectionsTaskSchema } from '@shared/types/reflections'
import { observable } from '@trpc/server/observable' import { observable } from '@trpc/server/observable'
import { import {
readingReflectionsTaskManager, readingReflectionsTaskManager,
@@ -13,7 +12,8 @@ import { ReadingReflectionTaskBatch } from '@main/db/entities/ReadingReflectionT
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem' import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
import { import {
IReadingReflectionTaskBatch, IReadingReflectionTaskBatch,
IReadingReflectionTaskItem IReadingReflectionTaskItem,
IReadingReflectionsTaskSchema
} from '@shared/types/IReadingReflectionTask' } from '@shared/types/IReadingReflectionTask'
/** /**
@@ -23,10 +23,10 @@ import {
export const taskRouter = router({ export const taskRouter = router({
/** /**
* 创建读书心得生成任务 * 创建读书心得生成任务
* @param input {ReadingReflectionsTaskSchema} * @param input {IReadingReflectionsTaskSchema}
*/ */
createReadingReflectionsTask: publicProcedure createReadingReflectionsTask: publicProcedure
.input(ReadingReflectionsTaskSchema) .input(IReadingReflectionsTaskSchema)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const taskId = uuidv4() const taskId = uuidv4()
// 启动后台异步任务 // 启动后台异步任务

View File

@@ -0,0 +1,32 @@
import { z } from 'zod'
/**
* 大模型类型枚举
* */
export type LLMType = 'reading' | 'summary'
/**
* 定义大模型配置
* */
export const ILLMConfigSchema = z.object({
type: z.enum(['reading', 'summary']), // 明确支持 reading
config: z.object({
apiKey: z.string(), // api key
baseURL: z.string(), // base url
modelName: z.string(), // 模型名称
temperature: z.number() // 模型温度
})
})
export type ILLMConfig = z.infer<typeof ILLMConfigSchema>
/**
* 定义通知配置
* */
export const INoticeConfigSchema = z.object({
masterSwitch: z.boolean().default(true), // 总开关
taskCompleted: z.boolean().default(true), // 任务完成时提醒
taskFailed: z.boolean().default(true), // 任务失败时提醒
silentMode: z.boolean().default(false) // 静默模式(仅弹窗无声音)
})
export type INoticeConfig = z.infer<typeof INoticeConfigSchema>

View File

@@ -1,3 +1,40 @@
import { z } from 'zod'
/**
* 读者职业枚举
*/
export type Occupation = 'student' | 'teacher' | 'professional' | 'researcher' | 'other'
/**
* 读书心得生成请求任务模型
*/
export const IReadingReflectionsTaskSchema = 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 IReadingReflectionsTask = z.infer<typeof IReadingReflectionsTaskSchema>
/**
* 任务响应结果模型
*/
export interface IReadingReflectionsResponse {
taskId: string
reflections: Array<{
title: string // 心得标题
content: string // 心得正文
keywords: string[] // 提取的关键词
summary: string // 内容摘要
}>
status: 'pending' | 'completed' | 'failed'
}
/** /**
* 对应数据库中的批次记录 (主任务) * 对应数据库中的批次记录 (主任务)
*/ */

View File

@@ -1,36 +0,0 @@
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'
}