diff --git a/MARKDOWN_SETUP.md b/MARKDOWN_SETUP.md
new file mode 100644
index 0000000..53c9463
--- /dev/null
+++ b/MARKDOWN_SETUP.md
@@ -0,0 +1,73 @@
+# Markdown 渲染功能依赖安装
+
+## 需要安装的依赖
+
+```bash
+npm install marked highlight.js --save
+```
+
+或者使用 yarn:
+
+```bash
+yarn add marked highlight.js
+```
+
+## 依赖说明
+
+- **marked** (v11.x): 轻量级 Markdown 解析和渲染库
+- **highlight.js** (v11.x): 代码语法高亮库
+
+## 安装后需要做的
+
+1. 运行安装命令
+2. 重启开发服务器
+3. Markdown 渲染功能即可使用
+
+## 功能特性
+
+✅ **完整的 Markdown 支持**
+- 标题 (H1-H6)
+- 粗体、斜体、删除线
+- 代码块和行内代码
+- 引用块
+- 有序列表和无序列表
+- 表格
+- 链接
+- 图片
+
+✅ **富媒体支持**
+- 图片显示和点击放大
+- 视频播放 (.mp4, .webm, .ogg, .avi, .mov)
+- 音频播放 (.mp3, .wav, .ogg, .aac)
+
+✅ **代码高亮**
+- 支持 180+ 种编程语言
+- 自动语言检测
+- 代码块复制功能
+- GitHub 风格样式
+
+✅ **流式输出**
+- 实时渲染 AI 回复
+- 平滑的打字机效果
+- 优化的性能
+
+✅ **用户体验**
+- 响应式设计
+- 暗色/亮色主题适配
+- 移动端优化
+- 可访问性支持
+
+## 使用示例
+
+```vue
+
+```
+
+## 自定义配置
+
+如果需要自定义渲染行为,可以修改 `MarkdownRenderer.vue` 中的 `marked` 配置和自定义渲染器。
diff --git a/package-lock.json b/package-lock.json
index 64fa9af..2a136f5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "laitool-pro",
- "version": "v4.0.2",
+ "version": "v4.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "laitool-pro",
- "version": "v4.0.2",
+ "version": "v4.0.5",
"hasInstallScript": true,
"dependencies": {
"@alicloud/alimt20181012": "^1.3.0",
@@ -21,7 +21,10 @@
"compressing": "^1.10.1",
"crypto-js": "^4.2.0",
"electron-updater": "^6.3.9",
+ "highlight.js": "^11.11.1",
+ "katex": "^0.16.25",
"lodash": "^4.17.21",
+ "marked": "^17.0.1",
"moment-timezone": "^0.5.48",
"music-metadata-browser": "^2.5.11",
"node-machine-id": "^1.1.12",
@@ -5546,7 +5549,8 @@
},
"node_modules/highlight.js": {
"version": "11.11.1",
- "dev": true,
+ "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
+ "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
@@ -6249,6 +6253,31 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/katex": {
+ "version": "0.16.25",
+ "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.25.tgz",
+ "integrity": "sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==",
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^8.3.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
+ "node_modules/katex/node_modules/commander": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz",
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"license": "MIT",
@@ -6544,6 +6573,18 @@
"node": ">=12"
}
},
+ "node_modules/marked": {
+ "version": "17.0.1",
+ "resolved": "https://registry.npmmirror.com/marked/-/marked-17.0.1.tgz",
+ "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/matcher": {
"version": "3.0.0",
"license": "MIT",
diff --git a/package.json b/package.json
index 97e7340..73aa4e0 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "laitool-pro",
"productName": "LaiToolPro",
- "version": "v4.0.5",
+ "version": "v4.0.6",
"description": "来推 Pro - 一款集音频处理、文案生成、图片生成、视频生成等功能于一体的多合一AI工具软件。",
"main": "./out/main/index.js",
"author": "xiangbei",
@@ -39,7 +39,10 @@
"compressing": "^1.10.1",
"crypto-js": "^4.2.0",
"electron-updater": "^6.3.9",
+ "highlight.js": "^11.11.1",
+ "katex": "^0.16.25",
"lodash": "^4.17.21",
+ "marked": "^17.0.1",
"moment-timezone": "^0.5.48",
"music-metadata-browser": "^2.5.11",
"node-machine-id": "^1.1.12",
diff --git a/src/define/db/service/book/bookService.ts b/src/define/db/service/book/bookService.ts
index c970a45..f6b45ff 100644
--- a/src/define/db/service/book/bookService.ts
+++ b/src/define/db/service/book/bookService.ts
@@ -221,11 +221,17 @@ export class BookService extends RealmBaseService {
} else if (book.type == BookType.ORIGINAL) {
let generalSetting = await getGeneralSetting()
imageCategory = generalSetting.defaultImgGenMethod ?? ImageCategory.Midjourney
+ } else if (book.type == BookType.COMIC_DRAMA) {
+ imageCategory = ImageCategory.Midjourney
} else {
throw new Error(t('未知的小说类型'))
}
- const srtHandle = new SrtHandle()
- let srtData = await srtHandle.GetSrtDataByPath(book.srtPath as string)
+ let srtData;
+ // 漫剧不用处理srt
+ if (book.type != BookType.COMIC_DRAMA) {
+ const srtHandle = new SrtHandle()
+ srtData = await srtHandle.GetSrtDataByPath(book.srtPath as string)
+ }
let bookTaskDetailService = await BookTaskDetailService.getInstance()
@@ -258,29 +264,33 @@ export class BookService extends RealmBaseService {
// 添加任务
this.realm.create('BookTask', bookTask)
- // 循环添加小说详细信息
- for (let i = 0; i < srtData.length; i++) {
- const element = srtData[i]
- bookTaskDetailService.AddBookTaskDetail({
- bookTaskId: bookTask.id,
- bookId: bookTask.bookId,
- startTime: element.start,
- endTime: element.end,
- status: BookTaskStatus.WAIT,
- word: element.text,
- afterGpt: element.text,
- subValue: JSON.stringify([
- {
- id: crypto.randomUUID(),
- end_time: element.end,
- start_time: element.start,
- srt_value: element.text
- }
- ] as BookTaskDetail.CopywritingSubValue[]),
- timeLimit: `${element.start} -- ${element.end}`,
- // 新增修脸跟随
- adetailer: false // 默认false,实际更具SD设置中为主
- })
+ // 如果是小说或者是反推的话,添加详细的任务信息
+ if (!(book.type == BookType.COMIC_DRAMA || srtData == undefined)) {
+
+ // 循环添加小说详细信息
+ for (let i = 0; i < srtData.length; i++) {
+ const element = srtData[i]
+ bookTaskDetailService.AddBookTaskDetail({
+ bookTaskId: bookTask.id,
+ bookId: bookTask.bookId,
+ startTime: element.start,
+ endTime: element.end,
+ status: BookTaskStatus.WAIT,
+ word: element.text,
+ afterGpt: element.text,
+ subValue: JSON.stringify([
+ {
+ id: crypto.randomUUID(),
+ end_time: element.end,
+ start_time: element.start,
+ srt_value: element.text
+ }
+ ] as BookTaskDetail.CopywritingSubValue[]),
+ timeLimit: `${element.start} -- ${element.end}`,
+ // 新增修脸跟随
+ adetailer: false // 默认false,实际更具SD设置中为主
+ })
+ }
}
})
diff --git a/src/define/db/service/book/bookTaskService.ts b/src/define/db/service/book/bookTaskService.ts
index 391f3c4..a8802e9 100644
--- a/src/define/db/service/book/bookTaskService.ts
+++ b/src/define/db/service/book/bookTaskService.ts
@@ -380,6 +380,19 @@ export class BookTaskService extends RealmBaseService {
addBookTaskDetail.push(addOneBookTaskDetail)
}
}
+
+ // 数据处理完毕,开始新增数据
+ // 将所有的复制才做,全部放在一个事务中
+ this.transaction(() => {
+ for (let i = 0; i < addBookTask.length; i++) {
+ const element = addBookTask[i];
+ this.realm.create('BookTask', element)
+ }
+ for (let i = 0; i < addBookTaskDetail.length; i++) {
+ const element = addBookTaskDetail[i];
+ this.realm.create('BookTaskDetail', element)
+ }
+ })
} catch (error) {
throw error
}
diff --git a/src/define/enum/bookEnum.ts b/src/define/enum/bookEnum.ts
index 7080b7b..5aaa305 100644
--- a/src/define/enum/bookEnum.ts
+++ b/src/define/enum/bookEnum.ts
@@ -9,7 +9,9 @@ export enum BookType {
// 反推
SD_REVERSE = 'sd_reverse',
// MJ 反推
- MJ_REVERSE = 'mj_reverse'
+ MJ_REVERSE = 'mj_reverse',
+ // 漫剧
+ COMIC_DRAMA = 'comic_drama'
}
/**
@@ -25,6 +27,8 @@ export function GetBookTypeLabel(type: BookType) {
return t('SD反推')
case BookType.MJ_REVERSE:
return t('MJ反推')
+ case BookType.COMIC_DRAMA:
+ return t('漫剧')
default:
return t('未知类型')
}
@@ -38,7 +42,8 @@ export function GetBookTypeOptions() {
return [
{ label: t('原创'), value: BookType.ORIGINAL },
{ label: t('SD反推'), value: BookType.SD_REVERSE },
- { label: t('MJ反推'), value: BookType.MJ_REVERSE }
+ { label: t('MJ反推'), value: BookType.MJ_REVERSE },
+ { label: t('漫剧'), value: BookType.COMIC_DRAMA }
]
}
diff --git a/src/define/enum/option.ts b/src/define/enum/option.ts
index 8ba8aae..78ee7ee 100644
--- a/src/define/enum/option.ts
+++ b/src/define/enum/option.ts
@@ -80,7 +80,10 @@ export const OptionKeyName = {
CW_SimpleSetting: 'InferenceAI_CW_SimpleSetting',
/** 文案相关的特殊字符串 */
- CW_FormatSpecialChar: 'InferenceAI_CW_FormatSpecialChar'
+ CW_FormatSpecialChar: 'InferenceAI_CW_FormatSpecialChar',
+
+ /** 文案处理 当前模式(batch/chat) */
+ CW_CurrentMode: 'InferenceAI_CW_CurrentMode'
},
SD: {
/** SD基础设置 */
diff --git a/src/define/ipc/subIpc/systemIpc.ts b/src/define/ipc/subIpc/systemIpc.ts
index 3bc8dbc..dcad2c6 100644
--- a/src/define/ipc/subIpc/systemIpc.ts
+++ b/src/define/ipc/subIpc/systemIpc.ts
@@ -114,6 +114,11 @@ function SystemIpc() {
async (_, filePath: string) => await electronInterface.OpenFile(filePath)
)
+ /** 在资源管理器中显示文件位置 */
+ ipcMain.on('open-file-location', (_, filePath: string) => {
+ electronInterface.ShowFileInFolder(filePath)
+ })
+
/** 复制指定的文件夹中的所有内容到另一个文件夹 */
ipcMain.handle(
DEFINE_STRING.SYSTEM.COPY_FOLDER_CONTENTS,
diff --git a/src/define/model/copywriting.d.ts b/src/define/model/copywriting.d.ts
new file mode 100644
index 0000000..4b60f9d
--- /dev/null
+++ b/src/define/model/copywriting.d.ts
@@ -0,0 +1,69 @@
+/**
+ * 文案处理相关的类型定义
+ */
+export namespace CopyWritingModel {
+ /**
+ * 提示词分类
+ */
+ interface PromptCategory {
+ /**
+ * 分类ID
+ */
+ id: string
+
+ /**
+ * 分类名称
+ */
+ name: string
+
+ /**
+ * 父分类ID (提示词类型ID)
+ */
+ promptTypeId: string
+
+ /**
+ * 描述
+ */
+ description: string | null
+
+ /**
+ * 备注
+ */
+ remark: string | null
+
+ /**
+ * 用户提示词内容模板 (可选)
+ */
+ userContent?: string
+
+ /**
+ * 系统提示词内容 (可选)
+ */
+ systemContent?: string
+ }
+
+ /**
+ * 提示词类型
+ */
+ interface PromptType {
+ /**
+ * 类型ID
+ */
+ id: string
+
+ /**
+ * 类型名称
+ */
+ name: string
+
+ /**
+ * 描述
+ */
+ description: string | null
+
+ /**
+ * 排序
+ */
+ sort?: number
+ }
+}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 2c36223..ae2cf66 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -2078,4 +2078,21 @@ export default {
"云端使用推荐仙宫云": "Cloud usage recommends Xiangong Cloud",
"登录/注册": "Login/Register",
"开启图转视频": "Enable Image to Video",
+ "切换到{mode}模式": "Switch to {mode} mode",
+ "批量文案处理": "Batch Text Processing",
+ "AI对话": "AI Chat",
+ "已切换到{mode}模式": "Switched to {mode} mode",
+ "未选择分类": "No category selected",
+ "请选择一个分类开始对话": "Please select a category to start chatting",
+ "开始与AI对话,获取创作灵感": "Start chatting with AI to get creative inspiration",
+ "你": "You",
+ "AI正在生成回复...": "AI is generating a response...",
+ "输入你的问题...": "Enter your question...",
+ "Ctrl + Enter 发送": "Ctrl + Enter to send",
+ "清空对话": "Clear Chat",
+ "切换分类将清空当前对话记录,是否继续?": "Switching categories will clear current chat history, continue?",
+ "请先选择一个分类": "Please select a category first",
+ "生成回复失败: {error}": "Failed to generate response: {error}",
+ "确定要清空所有对话记录吗?": "Are you sure you want to clear all chat history?",
+ "对话记录已清空": "Chat history cleared",
}
\ No newline at end of file
diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts
index 7b1ac7b..aeb84da 100644
--- a/src/i18n/locales/zh-cn.ts
+++ b/src/i18n/locales/zh-cn.ts
@@ -2078,4 +2078,21 @@ export default {
"云端使用推荐仙宫云": "云端使用推荐仙宫云",
"登录/注册": "登录/注册",
"开启图转视频" : "开启图转视频",
+ "切换到{mode}模式": "切换到{mode}模式",
+ "批量文案处理": "批量文案处理",
+ "AI对话": "AI对话",
+ "已切换到{mode}模式": "已切换到{mode}模式",
+ "未选择分类": "未选择分类",
+ "请选择一个分类开始对话": "请选择一个分类开始对话",
+ "开始与AI对话,获取创作灵感": "开始与AI对话,获取创作灵感",
+ "你": "你",
+ "AI正在生成回复...": "AI正在生成回复...",
+ "输入你的问题...": "输入你的问题...",
+ "Ctrl + Enter 发送": "Ctrl + Enter 发送",
+ "清空对话": "清空对话",
+ "切换分类将清空当前对话记录,是否继续?": "切换分类将清空当前对话记录,是否继续?",
+ "请先选择一个分类": "请先选择一个分类",
+ "生成回复失败: {error}": "生成回复失败: {error}",
+ "确定要清空所有对话记录吗?": "确定要清空所有对话记录吗?",
+ "对话记录已清空": "对话记录已清空",
}
\ No newline at end of file
diff --git a/src/main/service/book/subBookHandle/bookPromptHandle.ts b/src/main/service/book/subBookHandle/bookPromptHandle.ts
index 8b221be..9c46a46 100644
--- a/src/main/service/book/subBookHandle/bookPromptHandle.ts
+++ b/src/main/service/book/subBookHandle/bookPromptHandle.ts
@@ -374,17 +374,32 @@ export class BookPromptHandle extends BookBasicHandle {
let newData: BookTask.BookTaskCharacterAndSceneObject[] = []
for (let i = 0; i < returnData.length; i++) {
const element = returnData[i]
- let splitData = element.split(':')
- if (splitData.length < 2) {
- continue
+ if (type == PresetCategory.Character) {
+
+ let splitData = element.split(':')
+ if (splitData.length < 2) {
+ continue
+ }
+ let tempData = {
+ no: i + 1,
+ id: crypto.randomUUID(),
+ name: splitData[0],
+ prompt: splitData[1]
+ } as BookTask.BookTaskCharacterAndSceneObject
+ newData.push(tempData)
+ } else if (type == PresetCategory.Scene) {
+ let splitData = element.split('.')
+ if (splitData.length < 3) {
+ continue
+ }
+ let tempData = {
+ no: Number(splitData[0]),
+ id: crypto.randomUUID(),
+ name: splitData[1],
+ prompt: splitData[2]
+ } as BookTask.BookTaskCharacterAndSceneObject
+ newData.push(tempData)
}
- let tempData = {
- no: i + 1,
- id: crypto.randomUUID(),
- name: splitData[0],
- prompt: splitData[1]
- } as BookTask.BookTaskCharacterAndSceneObject
- newData.push(tempData)
}
if (type == PresetCategory.Character) {
diff --git a/src/main/service/system/electronInterface.ts b/src/main/service/system/electronInterface.ts
index 02dc80e..d74cfc8 100644
--- a/src/main/service/system/electronInterface.ts
+++ b/src/main/service/system/electronInterface.ts
@@ -41,6 +41,16 @@ export default class ElectronInterface {
}), 'SystemIpc_OPEN_FILE')
}
+ /**
+ * 在资源管理器中显示文件位置
+ * @param filePath 文件路径
+ */
+ public ShowFileInFolder(filePath: string): void {
+ if (filePath) {
+ shell.showItemInFolder(filePath)
+ }
+ }
+
/**
* 深度复制文件夹内容到目标文件夹
* @param source 源文件夹路径
diff --git a/src/renderer/components.d.ts b/src/renderer/components.d.ts
index b84c106..7c28f3b 100644
--- a/src/renderer/components.d.ts
+++ b/src/renderer/components.d.ts
@@ -9,6 +9,7 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AddOrModifyPreset: typeof import('./src/components/Preset/AddOrModifyPreset.vue')['default']
+ AIChatInterface: typeof import('./src/components/CopyWriting/AIChatInterface.vue')['default']
AIGroup: typeof import('./src/components/Original/Copywriter/AIGroup.vue')['default']
AISetting: typeof import('./src/components/Setting/InferenceSetting/AISetting.vue')['default']
AllImagePreview: typeof import('./src/components/Original/BookTaskDetail/AllImagePreview.vue')['default']
@@ -21,7 +22,13 @@ declare module 'vue' {
ComfyUIAddWorkflow: typeof import('./src/components/Setting/ComfyUIAddWorkflow.vue')['default']
ComfyUIImageToVideoInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoConfyUI/ComfyUIImageToVideoInfo.vue')['default']
ComfyUISetting: typeof import('./src/components/Setting/ComfyUISetting.vue')['default']
+ ComicDramaHome: typeof import('./src/components/ComicDrama/ComicDramaHome.vue')['default']
+ ComicDramaProjectSelection: typeof import('./src/components/ComicDrama/ComicDramaInfo/ComicDramaProjectSelection.vue')['default']
+ ComicDramaShotCard: typeof import('./src/components/ComicDrama/ComicDramaInfo/GridMode/ComicDramaShotCard.vue')['default']
+ ComicDramaShotCardList: typeof import('./src/components/ComicDrama/ComicDramaInfo/ListMode/ComicDramaShotCardList.vue')['default']
+ ComicDramaWorkspace: typeof import('./src/components/ComicDrama/ComicDramaInfo/ComicDramaWorkspace.vue')['default']
CommonDialog: typeof import('./src/components/common/CommonDialog.vue')['default']
+ ComparisonPanel: typeof import('./src/components/ComicDrama/ComicDramaInfo/ComparisonPanel.vue')['default']
ConfigOptionGroup: typeof import('./src/components/common/ConfigOptionGroup.vue')['default']
ContactDeveloper: typeof import('./src/components/SoftHome/ContactDeveloper.vue')['default']
CopyWritingCategoryMenu: typeof import('./src/components/CopyWriting/CopyWritingCategoryMenu.vue')['default']
@@ -46,6 +53,7 @@ declare module 'vue' {
EditWord: typeof import('./src/components/Original/Copywriter/EditWord.vue')['default']
FindReplaceRound: typeof import('./src/components/common/Icon/FindReplaceRound.vue')['default']
GeneralSettings: typeof import('./src/components/Setting/GeneralSettings.vue')['default']
+ GlobalConfigBar: typeof import('./src/components/ComicDrama/ComicDramaInfo/GlobalConfigBar.vue')['default']
HailuoFirstLastFrameInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoHaiLuo/HailuoFirstLastFrameInfo.vue')['default']
HailuoImageToVideoInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoHaiLuo/HailuoImageToVideoInfo.vue')['default']
HailuoTextToVideoInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoHaiLuo/HailuoTextToVideoInfo.vue')['default']
@@ -62,6 +70,7 @@ declare module 'vue' {
LanguageSwitcher: typeof import('./src/components/common/LanguageSwitcher.vue')['default']
LoadingComponent: typeof import('./src/components/common/LoadingComponent.vue')['default']
ManageAISetting: typeof import('./src/components/CopyWriting/ManageAISetting.vue')['default']
+ MarkdownRenderer: typeof import('./src/components/CopyWriting/MarkdownRenderer.vue')['default']
MediaToVideoInfoBasicInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoBasicInfo.vue')['default']
MediaToVideoInfoComfyUIInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoConfyUI/MediaToVideoInfoComfyUIInfo.vue')['default']
MediaToVideoInfoConfig: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoConfig.vue')['default']
@@ -147,6 +156,7 @@ declare module 'vue' {
OriginalTaskList: typeof import('./src/components/Original/MainHome/OriginalTaskList.vue')['default']
OriginalViewBookInfo: typeof import('./src/components/Original/MainHome/OriginalViewBookInfo.vue')['default']
OriginalViewBookTaskInfo: typeof import('./src/components/Original/MainHome/OriginalViewBookTaskInfo.vue')['default']
+ PlaybackBar: typeof import('./src/components/ComicDrama/ComicDramaInfo/PlaybackBar.vue')['default']
PointRightIcon: typeof import('./src/components/common/Icon/PointRightIcon.vue')['default']
PresetShowCard: typeof import('./src/components/Preset/PresetShowCard.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
@@ -157,6 +167,7 @@ declare module 'vue' {
SearchPresetArea: typeof import('./src/components/Preset/SearchPresetArea.vue')['default']
SelectRegionImage: typeof import('./src/components/Original/Image/SelectRegionImage.vue')['default']
SelectStylePreset: typeof import('./src/components/Preset/SelectStylePreset.vue')['default']
+ SourceInputColumn: typeof import('./src/components/ComicDrama/ComicDramaInfo/ListMode/SourceInputColumn.vue')['default']
StylePreset: typeof import('./src/components/Preset/StylePreset.vue')['default']
TextEllipsis: typeof import('./src/components/common/TextEllipsis.vue')['default']
ToolGrid: typeof import('./src/components/ToolBox/ToolGrid.vue')['default']
@@ -165,6 +176,7 @@ declare module 'vue' {
TopMenuButtons: typeof import('./src/components/Original/BookTaskDetail/TopMenuButtons.vue')['default']
UploadRound: typeof import('./src/components/common/Icon/UploadRound.vue')['default']
UserAnalysis: typeof import('./src/components/Original/Analysis/UserAnalysis.vue')['default']
+ VersionComparisonModal: typeof import('./src/components/ComicDrama/ComicDramaInfo/VersionComparisonModal.vue')['default']
VideoDisplay: typeof import('./src/components/common/VideoDisplay.vue')['default']
WechatGroup: typeof import('./src/components/SoftHome/WechatGroup.vue')['default']
WordGroup: typeof import('./src/components/Original/Copywriter/WordGroup.vue')['default']
diff --git a/src/renderer/src/common/image.ts b/src/renderer/src/common/image.ts
index 581b1f6..d09b9f8 100644
--- a/src/renderer/src/common/image.ts
+++ b/src/renderer/src/common/image.ts
@@ -35,3 +35,40 @@ export function checkImageExists(imagePath: string) {
})
}
+// 通过加载视频的方式检查文件是否存在
+export function checkVideoExists(videoPath: string) {
+ return new Promise((resolve) => {
+ // 创建新的Video对象
+ const video = document.createElement('video')
+
+ // 设置成功加载元数据的处理函数
+ video.onloadedmetadata = () => {
+ resolve(true)
+ }
+
+ // 设置加载失败的处理函数
+ video.onerror = () => {
+ resolve(false)
+ }
+
+ // 确保路径格式正确,因为是本地文件需要添加file://前缀
+ let videoSrc = videoPath
+
+ // 如果路径没有协议前缀,则添加file://前缀
+ if (
+ !videoSrc.startsWith('file://') &&
+ !videoSrc.startsWith('http://') &&
+ !videoSrc.startsWith('https://')
+ ) {
+ // 确保路径格式正确,windows路径需要处理
+ videoSrc = 'file:///' + videoSrc.replace(/\\/g, '/')
+ }
+
+ // 添加时间戳避免缓存
+ videoSrc += `?t=${new Date().getTime()}`
+
+ // 设置视频源
+ video.src = videoSrc
+ })
+}
+
diff --git a/src/renderer/src/components/ComicDrama/ComicDramaHome.vue b/src/renderer/src/components/ComicDrama/ComicDramaHome.vue
new file mode 100644
index 0000000..a670125
--- /dev/null
+++ b/src/renderer/src/components/ComicDrama/ComicDramaHome.vue
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/ComicDrama/ComicDramaInfo/ComicDramaProjectSelection.vue b/src/renderer/src/components/ComicDrama/ComicDramaInfo/ComicDramaProjectSelection.vue
new file mode 100644
index 0000000..5d87329
--- /dev/null
+++ b/src/renderer/src/components/ComicDrama/ComicDramaInfo/ComicDramaProjectSelection.vue
@@ -0,0 +1,586 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ sub.name }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ tag }}
+
+
{{ getStatusText(sub.status) }}
+
+
+
+
+
+
+
+
+ 新建子项目
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/ComicDrama/ComicDramaInfo/ComicDramaWorkspace.vue b/src/renderer/src/components/ComicDrama/ComicDramaInfo/ComicDramaWorkspace.vue
new file mode 100644
index 0000000..b081511
--- /dev/null
+++ b/src/renderer/src/components/ComicDrama/ComicDramaInfo/ComicDramaWorkspace.vue
@@ -0,0 +1,565 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/ComicDrama/ComicDramaInfo/ComparisonPanel.vue b/src/renderer/src/components/ComicDrama/ComicDramaInfo/ComparisonPanel.vue
new file mode 100644
index 0000000..cf3db62
--- /dev/null
+++ b/src/renderer/src/components/ComicDrama/ComicDramaInfo/ComparisonPanel.vue
@@ -0,0 +1,284 @@
+
+
+
+
+
+
+ 请选择版本
+
+
+
+
+
+
+
+
+
+ {{ version.duration }} | {{ version.ratio }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/ComicDrama/ComicDramaInfo/GlobalConfigBar.vue b/src/renderer/src/components/ComicDrama/ComicDramaInfo/GlobalConfigBar.vue
new file mode 100644
index 0000000..af21f2f
--- /dev/null
+++ b/src/renderer/src/components/ComicDrama/ComicDramaInfo/GlobalConfigBar.vue
@@ -0,0 +1,109 @@
+
+
+
全局配置:
+
+
+ 视频模型:
+
+
+
+
+ 视频比例:
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/ComicDrama/ComicDramaInfo/GridMode/ComicDramaShotCard.vue b/src/renderer/src/components/ComicDrama/ComicDramaInfo/GridMode/ComicDramaShotCard.vue
new file mode 100644
index 0000000..90eea7a
--- /dev/null
+++ b/src/renderer/src/components/ComicDrama/ComicDramaInfo/GridMode/ComicDramaShotCard.vue
@@ -0,0 +1,370 @@
+
+
+
+
+
+
+
+
+
+
+
Success
+
+
+
+
+
+
{{ shot.progress }}%
+
+
+
+ {{ shot.id }}
+
+
+
+
+
+
+
{{ shot.content }}
+
{{ shot.prompt }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/ComicDrama/ComicDramaInfo/ListMode/ComicDramaShotCardList.vue b/src/renderer/src/components/ComicDrama/ComicDramaInfo/ListMode/ComicDramaShotCardList.vue
new file mode 100644
index 0000000..869e166
--- /dev/null
+++ b/src/renderer/src/components/ComicDrama/ComicDramaInfo/ListMode/ComicDramaShotCardList.vue
@@ -0,0 +1,578 @@
+
+
+
+
+
{{ index + 1 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ shot.activeVersionId }}
+
+
+
+
+
+
{{ activeVersion.progress || 0 }}%
+
+
等待生成
+
+
+
+
+
+
+
+ {{ shot.history && shot.history.length > 0 ? '重绘' : '生成' }}
+
+
+
+
+
+
+
+ 版本对比
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/ComicDrama/ComicDramaInfo/ListMode/SourceInputColumn.vue b/src/renderer/src/components/ComicDrama/ComicDramaInfo/ListMode/SourceInputColumn.vue
new file mode 100644
index 0000000..a8cd51b
--- /dev/null
+++ b/src/renderer/src/components/ComicDrama/ComicDramaInfo/ListMode/SourceInputColumn.vue
@@ -0,0 +1,756 @@
+
+
+
+
+
+
+
+
+
+
+ 文本
+
+
+
+
+
+
+
+ 图片
+
+
+
+
+
+
+
+ 视频
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/ComicDrama/ComicDramaInfo/PlaybackBar.vue b/src/renderer/src/components/ComicDrama/ComicDramaInfo/PlaybackBar.vue
new file mode 100644
index 0000000..daa8289
--- /dev/null
+++ b/src/renderer/src/components/ComicDrama/ComicDramaInfo/PlaybackBar.vue
@@ -0,0 +1,181 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/ComicDrama/ComicDramaInfo/VersionComparisonModal.vue b/src/renderer/src/components/ComicDrama/ComicDramaInfo/VersionComparisonModal.vue
new file mode 100644
index 0000000..e5e1946
--- /dev/null
+++ b/src/renderer/src/components/ComicDrama/ComicDramaInfo/VersionComparisonModal.vue
@@ -0,0 +1,216 @@
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/ComicDrama/preview.html b/src/renderer/src/components/ComicDrama/preview.html
new file mode 100644
index 0000000..c846bf4
--- /dev/null
+++ b/src/renderer/src/components/ComicDrama/preview.html
@@ -0,0 +1,201 @@
+
+
+
+
+
+ 漫剧模块 - 界面预览
+
+
+
+
+
+
✨ 漫剧模块已创建
+
高度还原 Sora UI 设计风格的漫剧视频生成管理系统
+
+
+
+
1
+
+
项目管理视图
+
左侧项目列表 + 右侧子项目网格,支持快速导航和项目切换
+
+
+
+
+
2
+
+
工作台视图
+
支持列表/网格双视图模式,全局配置,分镜管理,底部播放控制栏
+
+
+
+
+
3
+
+
分镜卡片组件
+
文生视频/图生视频支持,实时生成状态,进度跟踪,快速编辑
+
+
+
+
+
+
+
+
+ Vue 3
+ TypeScript
+ Composition API
+ Naive UI
+ Sora UI Design
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/ComicDrama/version-management-demo.html b/src/renderer/src/components/ComicDrama/version-management-demo.html
new file mode 100644
index 0000000..79b7abc
--- /dev/null
+++ b/src/renderer/src/components/ComicDrama/version-management-demo.html
@@ -0,0 +1,251 @@
+
+
+
+
+
+ 漫剧模块 - 版本管理系统
+
+
+
+
+
✨ 漫剧版本管理系统
+
完整还原 Sora UI 设计,专业的视频生成工作流
+
+
+
🎯 核心升级
+
+ 基于 React Sora UI 设计,实现了完整的版本管理系统,支持多输入源、版本历史跟踪和智能对比功能
+
+
+
+
+
+
📝
+
多输入源支持
+
+ 支持纯文本、纯图片/视频、文本+图片混合输入,灵活应对各种创作场景
+
+
+
+
+
🔄
+
版本历史管理
+
+ 自动记录每次生成的版本,支持失败/成功/生成中状态,可随时切换查看不同版本
+
+
+
+
+
⚖️
+
智能版本对比
+
+ 并排对比任意两个版本,查看 Prompt 差异、参数设置和生成结果
+
+
+
+
+
🎨
+
专业 UI 设计
+
+ 暗黑主题配色,紫色主题色,流畅动画,完美还原 Sora UI 的专业感
+
+
+
+
+
📊
+
版本缩略图
+
+ 横向滚动的版本缩略图,快速预览历史版本,一键切换采用版本
+
+
+
+
+
💾
+
完整元数据
+
+ 记录每个版本的时间戳、备注、参数设置,便于追溯和优化
+
+
+
+
+
+// 数据结构示例
+{
+ id: 1,
+ inputType: { text: true, image: false },
+ content: '一个穿着校服的少女站在樱花树下...',
+ prompt: 'Anime style, a girl in school uniform...',
+ activeVersionId: 'v2',
+ history: [
+ {
+ versionId: 'v1',
+ status: 'failed',
+ timestamp: '2025-11-23 19:30:00',
+ note: '首次尝试'
+ },
+ {
+ versionId: 'v2',
+ status: 'done',
+ timestamp: '2025-11-23 20:00:00',
+ note: 'Prompt 优化,增加细节'
+ },
+ {
+ versionId: 'v3',
+ status: 'generating',
+ progress: 60,
+ timestamp: '2025-11-23 20:15:00',
+ note: '修改镜头角度'
+ }
+ ]
+}
+
+
+
+
+ 新增
+ VersionComparisonModal.vue
+ ComparisonPanel.vue
+
+
+ 已更新
+ ComicDramaWorkspace.vue
+ 数据结构升级
+
+
+ Vue 3
+ TypeScript
+ Composition API
+ Naive UI
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/CopyWriting/AIChatInterface.vue b/src/renderer/src/components/CopyWriting/AIChatInterface.vue
new file mode 100644
index 0000000..e8af332
--- /dev/null
+++ b/src/renderer/src/components/CopyWriting/AIChatInterface.vue
@@ -0,0 +1,712 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('开始与AI对话,获取创作灵感') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ message.role === 'user' ? t('你') : 'AI' }}
+
+
+
+
+
+
+
+ {{ t('AI思考中...') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/CopyWriting/CopyWritingCategoryMenu.vue b/src/renderer/src/components/CopyWriting/CopyWritingCategoryMenu.vue
index 590abad..7137550 100644
--- a/src/renderer/src/components/CopyWriting/CopyWritingCategoryMenu.vue
+++ b/src/renderer/src/components/CopyWriting/CopyWritingCategoryMenu.vue
@@ -29,6 +29,23 @@
+
+
+
+
+
+
+
+
+
@@ -53,7 +70,7 @@
{{ subCategory.name }}
-
+
{{ subCategory.description }}
@@ -71,7 +88,7 @@ import { useDialog, NSelect, NCollapse, NCollapseItem, NCard, NIcon } from 'naiv
import { useThemeStore } from '@/renderer/src/stores'
import { isEmpty } from 'lodash'
import TooltipButton from '@/renderer/src/components/common/TooltipButton.vue'
-import { SettingsOutline } from '@vicons/ionicons5'
+import { SettingsOutline, SwapHorizontalOutline } from '@vicons/ionicons5'
import ManageAISetting from './ManageAISetting.vue'
import { t } from '@/i18n'
@@ -98,11 +115,15 @@ const props = defineProps({
selectMainCategory: {
type: String,
default: ''
+ },
+ currentMode: {
+ type: String,
+ default: 'batch' // 'batch' 或 'chat'
}
})
// 定义 emits
-const emit = defineEmits(['category-select', 'update-simple-settings'])
+const emit = defineEmits(['category-select', 'update-simple-settings', 'toggle-mode'])
// 菜单相关数据
const selectedMainCategory = ref(null) // 只用于select显示,仅手动选择时有值
@@ -209,6 +230,11 @@ function handleSettingClick() {
})
}
+// 处理模式切换
+function handleToggleMode() {
+ emit('toggle-mode')
+}
+
// 暴露方法给父组件
defineExpose({
selectedMainCategory,
@@ -277,7 +303,7 @@ const selectCardBackgroundColor = computed(() => {
.selected-card {
border-color: v-bind(selectCardColor) !important;
- box-shadow: 0 2px 8px rgba(24, 160, 88, 0.2) !important;
+ box-shadow: 0 2px 8px v-bind(selectCardColor) !important;
background-color: v-bind(selectCardBackgroundColor) !important;
}
diff --git a/src/renderer/src/components/CopyWriting/MarkdownRenderer.vue b/src/renderer/src/components/CopyWriting/MarkdownRenderer.vue
new file mode 100644
index 0000000..a525744
--- /dev/null
+++ b/src/renderer/src/components/CopyWriting/MarkdownRenderer.vue
@@ -0,0 +1,458 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoTaskList.vue b/src/renderer/src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoTaskList.vue
index baba85e..6f5e925 100644
--- a/src/renderer/src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoTaskList.vue
+++ b/src/renderer/src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoTaskList.vue
@@ -12,10 +12,32 @@
+
+
+
+
+
+ {{ t('一键提交转视频任务') }}
+
+
+ {{ t('将所有转视频类型为 ComfyUI 的分镜,一键提交生成视频任务。') }}
+
+
+
+
+
+
+
+
+
@@ -53,21 +75,25 @@ import {
NSpace,
NText,
NButton,
+ NButtonGroup,
NIcon,
NDataTable,
NSwitch,
NImage,
NSelect,
+ NDropdown,
+ NTooltip,
useMessage,
useDialog
} from 'naive-ui'
-import { ArrowBackOutline } from '@vicons/ionicons5'
+import { ArrowBackOutline, ChevronDownOutline } from '@vicons/ionicons5'
import { define } from '@/define/define'
import ImageTextVideoInfoVideoConfig from './MediaToVideoInfoVideoConfig.vue'
import ImageTextVideoInfoVideoListInfo from './MediaToVideoInfoVideoListInfo.vue'
import ImageTextVideoInfoTaskOptions from './MediaToVideoInfoTaskOptions.vue'
import VideoDisplay from '@/renderer/src/components/common/VideoDisplay.vue'
import MediaToVideoVideoConfigHeader from './MediaToVideoVideoConfigHeader.vue'
+import TooltipDropdown from '@/renderer/src/components/common/TooltipDropdown.vue'
import { OptionKeyName, OptionType } from '@/define/enum/option'
import { useBookStore, useSoftwareStore } from '@/renderer/src/stores'
import { GetImageToVideoModelsOptions, ImageToVideoModels } from '@/define/enum/video'
@@ -79,6 +105,7 @@ import { BookBackTaskType, TaskExecuteType } from '@/define/enum/bookEnum'
import { DEFINE_STRING } from '@/define/ipcDefineString'
import { AddOneTask, StopToVideoTask } from '@/renderer/src/common/task'
import { useMD } from '@/renderer/src/hooks/useMD'
+import { checkVideoExists } from '@/renderer/src/common/image'
const bookStore = useBookStore()
const softwareStore = useSoftwareStore()
@@ -119,6 +146,24 @@ const batchVideoType = ref(null)
// 视频类型选项
const videoTypeOptions = GetImageToVideoModelsOptions()
+const taskActionOptions = [
+ {
+ label: t('提交未生成转视频任务'),
+ tooltip: t('将所有未生成视频且类型为 ComfyUI 的分镜,一键提交生成视频任务。'),
+ key: 'submit-ungenerated-tasks'
+ },
+ {
+ label: t('停止当前批次转视频任务'),
+ tooltip: t('停止当前批次中所有正在等待的转视频任务。'),
+ key: 'stop-current-batch-tasks'
+ },
+ {
+ label: t('停止所有转视频任务'),
+ tooltip: t('停止所有正在等待的转视频任务。'),
+ key: 'stop-all-video-tasks'
+ }
+]
+
// 监听 props 变化,同步更新本地状态
watch(
() => props.showRightPanel,
@@ -610,6 +655,84 @@ async function handleOneClickToVideo() {
})
}
+// 一键提交未生成转视频任务
+async function handleSubmitUngeneratedTasks() {
+ let da = dialog.warning({
+ title: t('操作确认'),
+ content: () =>
+ h(
+ DialogTextContent,
+ {
+ text: t(
+ `该操作会将所有未生成视频(没有可用视频)且类型为 ComfyUI 的分镜,添加到转视频任务队列中。\n\n是否继续?`
+ )
+ },
+ {}
+ ),
+ negativeText: t('取消'),
+ positiveText: t('继续'),
+ onPositiveClick: async () => {
+ da?.destroy()
+ await TimeDelay(200)
+ try {
+ softwareStore.spin.spinning = true
+ softwareStore.spin.tip = t('正在提交未生成任务...')
+ let taskCount = 0
+ // 开始循环添加任务
+ for (let i = 0; i < bookStore.selectBookTaskDetail.length; i++) {
+ let element = bookStore.selectBookTaskDetail[i]
+ // 只处理 ComfyUI 类型
+ if (element.videoMessage?.videoType != ImageToVideoModels.COMFY_UI) {
+ continue
+ }
+ // 检查是否已生成视频
+ if (element.generateVideoPath && !isEmpty(element.generateVideoPath)) {
+ // 检查视频文件是不是存在,存在就跳过
+ let checkVideoExist = await checkVideoExists(element.generateVideoPath.split('?t=')[0])
+ if (checkVideoExist) {
+ continue
+ }
+ }
+
+ let res = await AddOneTask({
+ bookId: element.bookId,
+ type: BookBackTaskType.COMFYUI_VIDEO,
+ executeType: TaskExecuteType.AUTO,
+ bookTaskId: element.bookTaskId,
+ bookTaskDetailId: element.id,
+ messageName: DEFINE_STRING.BOOK.COMFYUI_TO_VIDEO_RETURN
+ })
+
+ if (res.code != 1) {
+ message.error(res.message)
+ return
+ }
+ taskCount++
+ }
+ if (taskCount === 0) {
+ showErrorDialog(t('提示'), t('没有符合条件的未生成任务'))
+ return
+ }
+ await TimeDelay(500)
+ showSuccessDialog(
+ t('成功'),
+ t('已成功添加 {taskCount} 个转视频任务到队列中', { taskCount })
+ )
+ } catch (error) {
+ showErrorDialog(t('失败'), t('提交任务失败,{error}', { error: error.message }))
+ } finally {
+ da?.destroy()
+ softwareStore.spin.spinning = false
+ }
+ },
+ onNegativeClick: () => {
+ message.info(t('取消操作'))
+ },
+ closable: true,
+ maskClosable: false
+ })
+}
+
// 停止生成图片的操作方法
async function handleStopToVideoTask(content, key) {
let da = dialog.warning({
@@ -655,6 +778,9 @@ async function handleActionDropdownSelect(key) {
case 'one-click-to-video':
await handleOneClickToVideo()
break
+ case 'submit-ungenerated-tasks':
+ await handleSubmitUngeneratedTasks()
+ break
case 'stop-current-batch-tasks':
await handleStopToVideoTask(
t(
diff --git a/src/renderer/src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoVideoConfigHeader.vue b/src/renderer/src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoVideoConfigHeader.vue
index 20e2ce9..9fd9159 100644
--- a/src/renderer/src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoVideoConfigHeader.vue
+++ b/src/renderer/src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoVideoConfigHeader.vue
@@ -79,21 +79,6 @@ const blockOptions = computed(() => {
label: t('同步生图提示词'),
tooltip: t('同步当前分镜的生图提示词到图转视频的提示词中。'),
key: 'sync-image-prompts'
- },
- {
- label: t('一键提交转视频任务'),
- tooltip: t('将所有转视频类型为 ComfyUI 的分镜,一键提交生成视频任务。'),
- key: 'one-click-to-video'
- },
- {
- label: t('停止当前批次转视频任务'),
- tooltip: t('停止当前批次中所有正在等待的转视频任务。'),
- key: 'stop-current-batch-tasks'
- },
- {
- label: t('停止所有转视频任务'),
- tooltip: t('停止所有正在等待的转视频任务。'),
- key: 'stop-all-video-tasks'
}
]
diff --git a/src/renderer/src/components/Original/BookTaskDetail/DynamicPromptSortTagsSelect.vue b/src/renderer/src/components/Original/BookTaskDetail/DynamicPromptSortTagsSelect.vue
index 08126c5..f24a545 100644
--- a/src/renderer/src/components/Original/BookTaskDetail/DynamicPromptSortTagsSelect.vue
+++ b/src/renderer/src/components/Original/BookTaskDetail/DynamicPromptSortTagsSelect.vue
@@ -10,7 +10,7 @@
- {{ type == 'edit' ? t('编辑小说') : t('添加新小说') }}
+ {{ type == 'edit' ? t('编辑项目') : t('添加新项目') }}
-
-
+
+
-
-
+
+
+
+
diff --git a/src/renderer/src/views/CopyWritingHome.vue b/src/renderer/src/views/CopyWritingHome.vue
index c5e4195..02ad002 100644
--- a/src/renderer/src/views/CopyWritingHome.vue
+++ b/src/renderer/src/views/CopyWritingHome.vue
@@ -7,24 +7,38 @@
-
@@ -39,7 +53,9 @@ import { TimeDelay } from '@/define/Tools/time'
import { useMessage } from 'naive-ui'
import CopyWritingContent from '@/renderer/src/components/CopyWriting/CopyWritingContent.vue'
import CopyWritingCategoryMenu from '@/renderer/src/components/CopyWriting/CopyWritingCategoryMenu.vue'
+import AIChatInterface from '@/renderer/src/components/CopyWriting/AIChatInterface.vue'
import LoadingComponent from '@/renderer/src/components/common/LoadingComponent.vue'
+import { NDivider } from 'naive-ui'
import { OptionKeyName, OptionType } from '@/define/enum/option'
import { define } from '@/define/define'
import { useMD } from '../hooks/useMD'
@@ -61,6 +77,23 @@ const simpleSetting = ref({})
// 数据加载状态
const dataLoaded = ref(false)
+// 当前模式: 'batch' 批量文案处理, 'chat' AI对话
+const currentMode = ref('batch')
+
+// 当前选中的分类(用于AI对话)
+const selectedCategory = ref(null)
+
+// AI聊天组件的引用
+const aiChatRef = ref(null)
+
+// AI聊天消息列表
+const chatMessages = ref([])
+
+// 保存节流控制
+let saveTimer = null
+let lastSaveTime = 0
+const SAVE_INTERVAL = 2000 // 2秒内最多保存一次
+
onMounted(async () => {
try {
// 不再使用全局 spin,使用组件内的加载状态
@@ -72,6 +105,9 @@ onMounted(async () => {
// 初始化界面数据
await InitCopyWritingData()
+ // 加载保存的模式设置
+ await LoadCurrentMode()
+
// 加载提示词数据
await InitServerGptOptions()
@@ -119,6 +155,88 @@ let UpdateWord = debounce((value) => {
// newWord.value += value
}, 300)
+// 保存聊天记录到数据库
+async function handleSaveChatHistory() {
+ try {
+ if (!selectedCategory.value?.id) return
+
+ const now = Date.now()
+
+ // 节流控制
+ if (now - lastSaveTime < SAVE_INTERVAL) {
+ // 清除之前的延迟保存
+ if (saveTimer) {
+ clearTimeout(saveTimer)
+ }
+ // 延迟保存
+ saveTimer = setTimeout(
+ () => {
+ handleSaveChatHistory()
+ },
+ SAVE_INTERVAL - (now - lastSaveTime)
+ )
+ return
+ }
+
+ lastSaveTime = now
+ if (saveTimer) {
+ clearTimeout(saveTimer)
+ saveTimer = null
+ }
+
+ const chatData = {
+ categoryId: selectedCategory.value.id,
+ messages: chatMessages.value.map((msg) => ({
+ role: msg.role,
+ content: msg.content,
+ timestamp: msg.timestamp
+ }))
+ }
+
+ const key = `InferenceAI_CW_ChatHistory_${selectedCategory.value.id}`
+ console.log('保存聊天记录, key:', key, '消息数:', chatData.messages.length)
+
+ const result = await window.option.ModifyOptionByKey(
+ key,
+ JSON.stringify(chatData),
+ OptionType.JSON
+ )
+ console.log('保存聊天记录结果:', result)
+ } catch (error) {
+ console.error('保存聊天记录失败:', error)
+ }
+}
+
+// 加载聊天记录
+async function handleLoadChatHistory() {
+ try {
+ if (!selectedCategory.value?.id) {
+ chatMessages.value = []
+ return
+ }
+
+ const key = `InferenceAI_CW_ChatHistory_${selectedCategory.value.id}`
+
+ const res = await window.option.GetOptionByKey(key)
+
+ if (res.code === 1 && res.data) {
+ const chatData = optionSerialization(res.data)
+
+ if (chatData.messages && Array.isArray(chatData.messages)) {
+ chatMessages.value = chatData.messages
+ console.log('成功加载消息:', chatData.messages.length, '条')
+ } else {
+ chatMessages.value = []
+ }
+ } else {
+ chatMessages.value = []
+ }
+ } catch (error) {
+ console.error('加载聊天记录失败:', error)
+ chatMessages.value = []
+ }
+}
+
// 处理分类选择事件
async function handleCategorySelect(selection) {
console.log('Received category selection:', selection)
@@ -128,6 +246,9 @@ async function handleCategorySelect(selection) {
return
}
+ // 更新选中的分类(用于AI对话模式)
+ selectedCategory.value = selection
+
// 检查数据结构
let mainCategoryId, subCategoryId
@@ -149,6 +270,37 @@ async function handleCategorySelect(selection) {
})
}
+// 处理模式切换
+async function handleToggleMode() {
+ try {
+ const newMode = currentMode.value === 'batch' ? 'chat' : 'batch'
+ currentMode.value = newMode
+
+ // 保存模式到数据库
+ const saveRes = await window.option.ModifyOptionByKey(
+ OptionKeyName.InferenceAI.CW_CurrentMode,
+ currentMode.value,
+ OptionType.STRING
+ )
+
+ if (saveRes.code !== 1) {
+ throw new Error(saveRes.message)
+ }
+
+ message.info(
+ t('已切换到{mode}模式', {
+ mode: currentMode.value === 'batch' ? t('批量文案处理') : t('AI对话')
+ })
+ )
+ } catch (error) {
+ message.error(
+ t('切换模式失败,{error}', {
+ error: error.message
+ })
+ )
+ }
+}
+
// async function handleContentSaveCWSimpleSetting(selection = {}) {
// let res = await handleSaveCWSimpleSetting(selection)
// if(res) {
@@ -219,7 +371,7 @@ async function InitServerGptOptions() {
throw new Error(promptRes.error || promptRes.data.message)
}
pc = [...promptRes.data.data]
-
+
// 获取提示词数据
let prompt = await axios.get(define.lms_url + '/lms/Prompt/GetPromptOptions/all')
@@ -239,8 +391,9 @@ async function InitServerGptOptions() {
}
promptCategory.value = pc
- console.log('Loaded prompt categories:', pc)
- console.log('Current simpleSetting:', simpleSetting.value)
+
+ // 初始化选中的分类(用于AI对话模式)
+ await InitSelectedCategory()
} catch (error) {
showErrorDialog(
t('数据加载失败'),
@@ -251,6 +404,70 @@ async function InitServerGptOptions() {
}
}
+/**
+ * 根据保存的设置初始化选中的分类
+ */
+async function InitSelectedCategory() {
+ try {
+ const gptType = simpleSetting.value.gptType
+ const gptData = simpleSetting.value.gptData
+
+ if (!isEmpty(gptType) && !isEmpty(gptData) && promptCategory.value.length > 0) {
+ // 查找对应的主分类
+ const mainCategory = promptCategory.value.find((cat) => cat.id === gptType)
+
+ if (mainCategory && mainCategory.children) {
+ // 查找对应的子分类
+ const subCategory = mainCategory.children.find((sub) => sub.id === gptData)
+
+ if (subCategory) {
+ selectedCategory.value = subCategory
+ } else {
+ console.warn('未找到匹配的子分类:', gptData)
+ }
+ } else {
+ console.warn('未找到匹配的主分类或主分类无子项:', gptType)
+ }
+ } else {
+ console.log('没有保存的分类数据,跳过初始化')
+ }
+ } catch (error) {
+ console.error('初始化选中分类失败:', error)
+ }
+}
+
+/**
+ * 加载当前模式设置
+ */
+async function LoadCurrentMode() {
+ try {
+ const modeRes = await window.option.GetOptionByKey(OptionKeyName.InferenceAI.CW_CurrentMode)
+
+ if (modeRes.code === 1 && modeRes.data) {
+ const savedMode = optionSerialization(modeRes.data, '', 'chat')
+ // 验证模式值是否有效
+ if (savedMode === 'batch' || savedMode === 'chat') {
+ currentMode.value = savedMode
+ console.log('已加载保存的模式:', savedMode)
+ } else {
+ console.warn('无效的模式值,使用默认值 batch')
+ currentMode.value = 'batch'
+ }
+ } else {
+ // 没有保存的模式,使用默认值并保存
+ currentMode.value = 'batch'
+ await window.option.ModifyOptionByKey(
+ OptionKeyName.InferenceAI.CW_CurrentMode,
+ 'batch',
+ OptionType.STRING
+ )
+ }
+ } catch (error) {
+ console.error('加载模式设置失败:', error)
+ currentMode.value = 'batch' // 出错时使用默认值
+ }
+}
+
/**
* 初始化文案处理AI设置
*/
diff --git a/src/renderer/src/views/MediaToVideoHome.vue b/src/renderer/src/views/MediaToVideoHome.vue
index 2b80120..df49e3a 100644
--- a/src/renderer/src/views/MediaToVideoHome.vue
+++ b/src/renderer/src/views/MediaToVideoHome.vue
@@ -54,7 +54,7 @@
import { ref, computed, onMounted } from 'vue'
import { useBookStore, useSoftwareStore } from '@/renderer/src/stores'
import { useMessage, useDialog } from 'naive-ui'
-import { BookType, GetBookTaskDetailStatusLabel } from '@/define/enum/bookEnum'
+import { BookType } from '@/define/enum/bookEnum'
import { TimeDelay } from '@/define/Tools/time'
import { h } from 'vue'
@@ -64,13 +64,14 @@ import TaskList from '@/renderer/src/components/Original/MainHome/OriginalTaskLi
import EmptyState from '@/renderer/src/components/Original/MainHome/OriginalEmptyState.vue'
import AddBook from '@/renderer/src/components/Original/MainHome/OriginalAddBook.vue'
import AddBookTask from '@/renderer/src/components/Original/MainHome/OriginalAddBookTask.vue'
-import { getImageCategoryOptions } from '@/define/data/imageData'
+import { useBook } from '@/renderer/src/hooks/useBook'
import { t } from '@/i18n'
const bookStore = useBookStore()
const softwareStore = useSoftwareStore()
const message = useMessage()
const dialog = useDialog()
+const { loadVideoBookInfo, loadProjectProgressData } = useBook()
// 响应式数据
const bookData = ref([])
@@ -92,64 +93,15 @@ onMounted(async () => {
await loadBookInfo(true) // 初始加载
})
-// 加载所有的小说数据
+// 加载所有的小说数据(包含分镜信息)
async function loadBookInfo(isInitialLoad = false) {
- try {
- if (isInitialLoad) {
- // 初始加载使用组件级别的loading
- comLoading.value = true
- } else {
- // 方法调用使用全局loading
- softwareStore.spin.spinning = true
- softwareStore.spin.tip = t('正在加载任务数据...')
- }
-
- const imageCategoryOptions = getImageCategoryOptions()
-
- // 加载小说数据 和 小说批次数据
- let res = await window.book.video.GetVideoBookInfoList({})
- if (res.code != 1) {
- message.error(res.message)
- return
- }
-
- bookData.value = res.data
-
- console.log('获取可以小说数据', res)
-
- // 加载每个批次数据的 tag
-
- for (let i = 0; i < bookData.value.length; i++) {
- // 处理每一个 bookTasks ,添加一个 tags 属性
- bookData.value[i].children.forEach((task) => {
- let tempArray = []
- // 出图方式
- let imageCategory = imageCategoryOptions.find((item) => item.value === task.imageCategory)
- tempArray.push(imageCategory.label)
-
- // 状态
- let statusObj = GetBookTaskDetailStatusLabel(task.status)
- tempArray.push(statusObj.label)
- task.tags = tempArray
- })
-
- loadProjectProgressData(bookData.value[i])
- }
- } catch (error) {
- message.error(
- t('加载数据失败,{error}', {
- error: error.message
- })
- )
- } finally {
- if (isInitialLoad) {
- // 初始加载结束,显示内容
- comLoading.value = false
- } else {
- // 方法调用结束,隐藏全局loading
- softwareStore.spin.spinning = false
- }
- }
+ const data = await loadVideoBookInfo({
+ isInitialLoad,
+ comLoading: comLoading,
+ loadingTip: t('正在加载任务数据...'),
+ processProgressData: loadProjectProgressData
+ })
+ bookData.value = data
}
// 搜索小说
@@ -246,100 +198,6 @@ async function handleSelectProject(projectId) {
await loadProjectProgressData(project)
}
-/**
- * 加载项目进度和首图信息
- * @description 获取指定项目的图片生成进度和首图路径信息,并更新到项目数据中
- * @param {Object} project 项目对象,包含子任务列表
- */
-async function loadProjectProgressData(project) {
- try {
- // 获取进度信息
- const progressResult = await getProjectProgress(project.id)
- if (!progressResult) return
-
- // 获取首图信息
- const firstImageResult = await getProjectFirstImages(project.id)
- if (!firstImageResult) return
-
- // 更新项目数据
- updateProjectData(project, progressResult, firstImageResult)
-
- // 更新store中的选中项目
- bookStore.selectBook = { ...project }
- } catch (error) {
- message.error(
- t('加载数据失败,{error}', {
- error: error.message
- })
- )
- }
-}
-
-/**
- * 获取项目进度信息
- * @description 调用API获取项目中所有任务的图片和视频生成进度
- * @param {string} projectId 项目ID
- * @returns {Object|null} 进度数据对象,失败时返回null
- */
-async function getProjectProgress(projectId) {
- const res = await window.book.GetBookTaskImageGenerateProgress(projectId)
- console.log('获取小说任务生成进度', res)
-
- if (res.code != 1) {
- message.error(res.message)
- return null
- }
-
- return res.data
-}
-
-/**
- * 获取项目首图信息
- * @description 调用API获取项目中所有任务的首图路径
- * @param {string} projectId 项目ID
- * @returns {Object|null} 首图路径数据对象,失败时返回null
- */
-async function getProjectFirstImages(projectId) {
- const res = await window.book.GetBookTaskFirstImagePath(projectId)
-
- if (res.code != 1) {
- message.error(res.message)
- return null
- }
-
- console.log('首图路径', res.data)
- return res.data
-}
-
-/**
- * 更新项目数据
- * @description 将获取到的进度和首图信息更新到项目的子任务中
- * @param {Object} project 项目对象
- * @param {Object} progressData 进度数据
- * @param {Object} firstImageData 首图数据
- */
-function updateProjectData(project, progressData, firstImageData) {
- // 遍历项目的子任务,更新进度和首图信息
- for (let i = 0; i < project.children.length; i++) {
- const element = project.children[i]
-
- // 设置图片和视频生成进度
- element.imageVideoProgress = progressData[element.id] || {
- imageProgress: 0,
- videoProgress: 0,
- totalCount: 0,
- imageRate: 0,
- videoRate: 0
- }
-
- // 设置首图路径,添加时间戳避免缓存问题
- element.firstImagePath =
- firstImageData[element.id] == undefined
- ? undefined
- : firstImageData[element.id] + '?t=' + new Date().getTime()
- }
-}
-
// 移动端侧边栏控制
function closeMobileSidebar() {
diff --git a/src/renderer/src/views/OriginalHome.vue b/src/renderer/src/views/OriginalHome.vue
index 2f68baf..09a1ab1 100644
--- a/src/renderer/src/views/OriginalHome.vue
+++ b/src/renderer/src/views/OriginalHome.vue
@@ -52,7 +52,7 @@
import { ref, computed, onMounted } from 'vue'
import { useBookStore, useSoftwareStore } from '@/renderer/src/stores'
import { useMessage, useDialog } from 'naive-ui'
-import { BookType, GetBookTaskDetailStatusLabel } from '@/define/enum/bookEnum'
+import { BookType } from '@/define/enum/bookEnum'
import { TimeDelay } from '@/define/Tools/time'
import { h } from 'vue'
@@ -62,13 +62,14 @@ import TaskList from '@/renderer/src/components/Original/MainHome/OriginalTaskLi
import EmptyState from '@/renderer/src/components/Original/MainHome/OriginalEmptyState.vue'
import AddBook from '@/renderer/src/components/Original/MainHome/OriginalAddBook.vue'
import AddBookTask from '@/renderer/src/components/Original/MainHome/OriginalAddBookTask.vue'
-import { getImageCategoryOptions } from '@/define/data/imageData'
+import { useBook } from '@/renderer/src/hooks/useBook'
import { t } from '@/i18n'
const bookStore = useBookStore()
const softwareStore = useSoftwareStore()
const message = useMessage()
const dialog = useDialog()
+const { loadBookInfo: loadBookInfoFromHook, loadProjectProgressData } = useBook()
// 响应式数据
const bookData = ref([])
@@ -92,67 +93,13 @@ onMounted(async () => {
// 加载所有的小说数据
async function loadBookInfo(isInitialLoad = false) {
- try {
- if (isInitialLoad) {
- // 初始加载使用组件级别的loading
- comLoading.value = true
- } else {
- // 方法调用使用全局loading
- softwareStore.spin.spinning = true
- softwareStore.spin.tip = t('加载小说信息中...')
- }
-
- const imageCategoryOptions = getImageCategoryOptions()
- let res = await window.book.GetBookDataCondition({ ...bookStore.queryBookCondition })
- if (res.code != 1) {
- message.error(res.message)
- return
- }
- console.log('小说信息', res.data)
- bookData.value = res.data.resultBooks
-
- // 加载每个的子项目
- for (let i = 0; i < bookData.value.length; i++) {
- const element = bookData.value[i]
- let bookTaskRes = await window.book.GetBookTaskDataByCondition({
- bookId: element.id
- })
- if (bookTaskRes.code != 1) {
- message.error(bookTaskRes.message)
- return
- }
-
- // 处理每一个 bookTasks ,添加一个 tags 属性
- bookTaskRes.data.bookTasks.forEach((task) => {
- let tempArray = []
- // 出图方式
- let imageCategory = imageCategoryOptions.find((item) => item.value === task.imageCategory)
- tempArray.push(imageCategory.label)
-
- // 状态
- let statusObj = GetBookTaskDetailStatusLabel(task.status)
- tempArray.push(statusObj.label)
- task.tags = tempArray
- })
-
- bookData.value[i].children = bookTaskRes.data.bookTasks
- loadProjectProgressData(bookData.value[i])
- }
- } catch (error) {
- message.error(
- t('加载小说信息失败,{error}', {
- error: error.message
- })
- )
- } finally {
- if (isInitialLoad) {
- // 初始加载结束,显示内容
- comLoading.value = false
- } else {
- // 方法调用结束,隐藏全局loading
- softwareStore.spin.spinning = false
- }
- }
+ const data = await loadBookInfoFromHook({
+ isInitialLoad,
+ comLoading: comLoading,
+ loadingTip: t('加载小说信息中...'),
+ processProgressData: loadProjectProgressData
+ })
+ bookData.value = data
}
// 搜索小说
@@ -253,100 +200,6 @@ async function handleSelectProject(projectId) {
await loadProjectProgressData(project)
}
-/**
- * 加载项目进度和首图信息
- * @description 获取指定项目的图片生成进度和首图路径信息,并更新到项目数据中
- * @param {Object} project 项目对象,包含子任务列表
- */
-async function loadProjectProgressData(project) {
- try {
- // 获取进度信息
- const progressResult = await getProjectProgress(project.id)
- if (!progressResult) return
-
- // 获取首图信息
- const firstImageResult = await getProjectFirstImages(project.id)
- if (!firstImageResult) return
-
- // 更新项目数据
- updateProjectData(project, progressResult, firstImageResult)
-
- // 更新store中的选中项目
- bookStore.selectBook = { ...project }
- } catch (error) {
- message.error(
- t('加载小说信息失败,{error}', {
- error: error.message
- })
- )
- }
-}
-
-/**
- * 获取项目进度信息
- * @description 调用API获取项目中所有任务的图片和视频生成进度
- * @param {string} projectId 项目ID
- * @returns {Object|null} 进度数据对象,失败时返回null
- */
-async function getProjectProgress(projectId) {
- const res = await window.book.GetBookTaskImageGenerateProgress(projectId)
- console.log('获取小说任务生成进度', res)
-
- if (res.code != 1) {
- message.error(res.message)
- return null
- }
-
- return res.data
-}
-
-/**
- * 获取项目首图信息
- * @description 调用API获取项目中所有任务的首图路径
- * @param {string} projectId 项目ID
- * @returns {Object|null} 首图路径数据对象,失败时返回null
- */
-async function getProjectFirstImages(projectId) {
- const res = await window.book.GetBookTaskFirstImagePath(projectId)
-
- if (res.code != 1) {
- message.error(res.message)
- return null
- }
-
- console.log('首图路径', res.data)
- return res.data
-}
-
-/**
- * 更新项目数据
- * @description 将获取到的进度和首图信息更新到项目的子任务中
- * @param {Object} project 项目对象
- * @param {Object} progressData 进度数据
- * @param {Object} firstImageData 首图数据
- */
-function updateProjectData(project, progressData, firstImageData) {
- // 遍历项目的子任务,更新进度和首图信息
- for (let i = 0; i < project.children.length; i++) {
- const element = project.children[i]
-
- // 设置图片和视频生成进度
- element.imageVideoProgress = progressData[element.id] || {
- imageProgress: 0,
- videoProgress: 0,
- totalCount: 0,
- imageRate: 0,
- videoRate: 0
- }
-
- // 设置首图路径,添加时间戳避免缓存问题
- element.firstImagePath =
- firstImageData[element.id] == undefined
- ? undefined
- : firstImageData[element.id] + '?t=' + new Date().getTime()
- }
-}
-
// 移动端侧边栏控制
function closeMobileSidebar() {
diff --git a/src/utils/httpErrorHandler.README.md b/src/utils/httpErrorHandler.README.md
new file mode 100644
index 0000000..18dfac9
--- /dev/null
+++ b/src/utils/httpErrorHandler.README.md
@@ -0,0 +1,303 @@
+# HTTP 错误处理工具使用指南
+
+## 概述
+
+`httpErrorHandler.js` 提供了统一的 HTTP 错误处理功能,支持 fetch 和 axios,可在主线程和渲染线程使用。
+
+## 功能特性
+
+- ✅ 支持 Fetch API
+- ✅ 支持 Axios
+- ✅ 自动识别错误类型
+- ✅ 提取服务器错误消息
+- ✅ 友好的中文错误提示
+- ✅ 支持主线程和渲染线程
+- ✅ 完整的错误信息日志
+
+## 使用方法
+
+### 1. Fetch API 使用
+
+#### 方式一:使用包装器(推荐)
+
+```javascript
+import { fetchWithErrorHandling } from '@/utils/httpErrorHandler'
+
+try {
+ const response = await fetchWithErrorHandling('https://api.example.com/data', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key: 'value' })
+ })
+
+ const data = await response.json()
+ console.log(data)
+} catch (error) {
+ console.error(error.message) // 友好的错误提示
+ console.log(error.status) // HTTP 状态码
+ console.log(error.data) // 服务器返回的原始数据
+}
+```
+
+#### 方式二:手动处理
+
+```javascript
+import { handleFetchError } from '@/utils/httpErrorHandler'
+
+const response = await fetch('https://api.example.com/data')
+
+if (!response.ok) {
+ const error = await handleFetchError(response)
+ throw error
+}
+
+const data = await response.json()
+```
+
+### 2. Axios 使用
+
+#### 方式一:错误拦截器(推荐)
+
+```javascript
+import axios from 'axios'
+import { createAxiosErrorInterceptor } from '@/utils/httpErrorHandler'
+
+// 创建 axios 实例
+const api = axios.create({
+ baseURL: 'https://api.example.com'
+})
+
+// 添加错误拦截器
+api.interceptors.response.use(
+ response => response,
+ createAxiosErrorInterceptor()
+)
+
+// 使用
+try {
+ const response = await api.post('/data', { key: 'value' })
+ console.log(response.data)
+} catch (error) {
+ console.error(error.message) // 友好的错误提示
+ console.log(error.status) // HTTP 状态码
+ console.log(error.data) // 服务器返回的原始数据
+}
+```
+
+#### 方式二:手动处理
+
+```javascript
+import axios from 'axios'
+import { handleAxiosError } from '@/utils/httpErrorHandler'
+
+try {
+ const response = await axios.post('https://api.example.com/data', {
+ key: 'value'
+ })
+ console.log(response.data)
+} catch (axiosError) {
+ const error = handleAxiosError(axiosError)
+ console.error(error.message)
+ throw error
+}
+```
+
+### 3. 通用处理(自动识别)
+
+```javascript
+import { handleRequestError } from '@/utils/httpErrorHandler'
+
+try {
+ // fetch 或 axios 请求
+ const response = await fetch(url)
+
+ if (!response.ok) {
+ const error = await handleRequestError(response)
+ throw error
+ }
+} catch (error) {
+ // 如果是 axios 错误
+ const friendlyError = await handleRequestError(error)
+ console.error(friendlyError.message)
+}
+```
+
+## 错误类型
+
+```javascript
+import { ErrorType, isErrorType } from '@/utils/httpErrorHandler'
+
+try {
+ // 请求代码
+} catch (error) {
+ if (isErrorType(error, ErrorType.NETWORK_ERROR)) {
+ console.log('网络错误')
+ } else if (isErrorType(error, ErrorType.TIMEOUT)) {
+ console.log('请求超时')
+ } else if (isErrorType(error, ErrorType.HTTP_ERROR)) {
+ console.log('HTTP错误:', error.status)
+ }
+}
+```
+
+### 支持的错误类型
+
+- `HTTP_ERROR` - HTTP 状态码错误(400-599)
+- `NETWORK_ERROR` - 网络连接错误
+- `TIMEOUT` - 请求超时
+- `ABORTED` - 请求被取消
+- `UNKNOWN_ERROR` - 未知错误
+
+## HTTP 状态码处理
+
+| 状态码 | 错误提示 |
+|--------|----------|
+| 400 | 请求参数错误,请检查配置是否正确 |
+| 401 | API密钥无效或已过期,请检查配置 |
+| 403 | 没有访问权限,请检查API密钥是否有效 |
+| 404 | API地址不存在,请检查配置的URL是否正确 |
+| 429 | 请求过于频繁,请稍后再试 |
+| 500 | 服务器内部错误,请稍后重试 |
+| 502 | 网关错误,服务暂时不可用,请稍后重试 |
+| 503 | 服务暂时不可用,请稍后重试 |
+| 504 | 请求超时,请检查网络连接或稍后重试 |
+
+## 错误对象属性
+
+```javascript
+error = {
+ message: '友好的错误提示', // 用户可读的错误消息
+ type: 'HTTP_ERROR', // 错误类型
+ status: 400, // HTTP 状态码(HTTP错误时)
+ statusText: 'Bad Request', // HTTP 状态文本
+ data: {...}, // 服务器返回的原始数据
+ originalError: Error // 原始错误对象(如果有)
+}
+```
+
+## 在主线程使用
+
+```javascript
+// main/index.js 或其他主线程文件
+import { handleFetchError, fetchWithErrorHandling } from '../utils/httpErrorHandler'
+
+async function fetchData() {
+ try {
+ const response = await fetchWithErrorHandling('https://api.example.com/data')
+ const data = await response.json()
+ return data
+ } catch (error) {
+ console.error('主线程请求失败:', error.message)
+ throw error
+ }
+}
+```
+
+## 完整示例
+
+### 示例 1:AI 接口调用
+
+```javascript
+import { handleFetchError } from '@/utils/httpErrorHandler'
+
+async function callAI(prompt) {
+ try {
+ const response = await fetch('https://api.openai.com/v1/chat/completions', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${apiKey}`
+ },
+ body: JSON.stringify({
+ model: 'gpt-3.5-turbo',
+ messages: [{ role: 'user', content: prompt }]
+ })
+ })
+
+ if (!response.ok) {
+ const error = await handleFetchError(response)
+ throw error
+ }
+
+ return await response.json()
+ } catch (error) {
+ if (error.status === 401) {
+ console.log('需要重新登录')
+ } else if (error.status === 429) {
+ console.log('请求过于频繁,等待后重试')
+ }
+
+ throw error
+ }
+}
+```
+
+### 示例 2:Axios 全局配置
+
+```javascript
+// src/api/request.js
+import axios from 'axios'
+import { createAxiosErrorInterceptor } from '@/utils/httpErrorHandler'
+import { useMessage } from 'naive-ui'
+
+const message = useMessage()
+
+const api = axios.create({
+ baseURL: import.meta.env.VITE_API_BASE_URL,
+ timeout: 30000
+})
+
+// 响应拦截器
+api.interceptors.response.use(
+ response => response,
+ createAxiosErrorInterceptor()
+)
+
+// 在组件中使用
+export async function fetchUserData() {
+ try {
+ const response = await api.get('/user/profile')
+ return response.data
+ } catch (error) {
+ message.error(error.message) // 显示友好的错误提示
+ throw error
+ }
+}
+```
+
+## 服务器错误消息提取
+
+工具会自动尝试从以下字段提取服务器错误消息:
+
+- `error.message`
+- `message`
+- `msg`
+- `error`
+- `rawError`(当响应不是JSON格式时)
+
+服务器返回示例:
+
+```json
+{
+ "error": {
+ "message": "Invalid API key"
+ }
+}
+```
+
+最终错误提示:`API密钥无效或已过期,请检查配置:Invalid API key`
+
+## 注意事项
+
+1. **日志输出**:工具会在控制台输出详细的错误信息,方便调试
+2. **原始数据保留**:所有原始错误数据都保存在 `error.data` 中
+3. **类型判断**:使用 `isErrorType()` 判断错误类型,而不是直接比较字符串
+4. **网络错误**:网络连接失败等非 HTTP 错误也会被统一处理
+
+## 更新日志
+
+- v1.0.0 (2025-11-23)
+ - 初始版本
+ - 支持 Fetch 和 Axios
+ - 完整的错误类型处理
+ - 友好的中文错误提示
diff --git a/src/utils/httpErrorHandler.ts b/src/utils/httpErrorHandler.ts
new file mode 100644
index 0000000..fe0fc91
--- /dev/null
+++ b/src/utils/httpErrorHandler.ts
@@ -0,0 +1,301 @@
+/**
+ * HTTP 错误处理工具
+ * 支持 fetch 和 axios,可在主线程和渲染线程使用
+ */
+
+import type { AxiosError } from 'axios'
+
+// 错误类型常量
+export const ErrorType = {
+ HTTP_ERROR: 'HTTP_ERROR',
+ NETWORK_ERROR: 'NETWORK_ERROR',
+ TIMEOUT: 'TIMEOUT',
+ ABORTED: 'ABORTED',
+ UNKNOWN_ERROR: 'UNKNOWN_ERROR'
+} as const
+
+export type ErrorTypeValue = (typeof ErrorType)[keyof typeof ErrorType]
+
+// 扩展的 Error 接口
+export interface HttpError extends Error {
+ type?: ErrorTypeValue
+ status?: number
+ statusText?: string
+ data?: any
+ originalError?: any
+}
+
+// 错误响应数据接口
+// interface ErrorData {
+// error?: {
+// message?: string
+// }
+// message?: string
+// msg?: string
+// rawError?: string
+// [key: string]: any
+// }
+
+/**
+ * 从错误响应数据中提取服务器消息
+ * @param errorData - 错误响应数据
+ * @returns 服务器错误消息
+ */
+function extractServerMessage(errorData: any): string | null {
+ if (!errorData) return null
+
+ return (
+ errorData?.error?.message ||
+ errorData?.message ||
+ errorData?.msg ||
+ errorData?.error ||
+ errorData?.rawError ||
+ null
+ )
+}
+
+/**
+ * 根据 HTTP 状态码获取友好的错误提示
+ * @param status - HTTP 状态码
+ * @param serverMessage - 服务器返回的错误消息
+ * @param statusText - HTTP 状态文本
+ * @returns 友好的错误消息
+ */
+function getHttpErrorMessage(
+ status: number,
+ serverMessage: string | null = null,
+ statusText: string | null = null
+): string {
+ let errorMessage = ''
+
+ switch (status) {
+ case 400:
+ errorMessage = '请求参数错误,请检查配置是否正确'
+ break
+
+ case 401:
+ errorMessage = 'API密钥无效或已过期,请检查配置'
+ break
+
+ case 403:
+ errorMessage = '没有访问权限,请检查API密钥是否有效'
+ break
+
+ case 404:
+ errorMessage = 'API地址不存在,请检查配置的URL是否正确'
+ break
+
+ case 429:
+ errorMessage = '请求过于频繁,请稍后再试'
+ break
+
+ case 500:
+ errorMessage = '服务器内部错误,请稍后重试'
+ break
+
+ case 502:
+ errorMessage = '网关错误,服务暂时不可用,请稍后重试'
+ break
+
+ case 503:
+ errorMessage = '服务暂时不可用,请稍后重试'
+ break
+
+ case 504:
+ errorMessage = '请求超时,请检查网络连接或稍后重试'
+ break
+
+ default:
+ errorMessage = `请求失败 (${status})`
+ }
+
+ // 附加服务器消息
+ if (serverMessage) {
+ // 避免重复信息
+ if (!errorMessage.includes(serverMessage)) {
+ errorMessage += `:${serverMessage}`
+ }
+ } else if (statusText && status >= 500) {
+ // 5xx 错误且没有服务器消息时,附加状态文本
+ errorMessage += `:${statusText}`
+ }
+
+ return errorMessage
+}
+
+/**
+ * 处理 Fetch API 的错误响应
+ * @param response - Fetch Response 对象
+ * @returns 包含友好错误信息的 Error 对象
+ */
+export async function handleFetchError(response: Response): Promise
{
+ let errorData: any = null
+
+ try {
+ const text = await response.text()
+ console.log('HTTP错误响应原始内容:', text)
+
+ if (text) {
+ try {
+ errorData = JSON.parse(text)
+ console.log('解析后的错误数据:', errorData)
+ } catch (e) {
+ console.warn('错误响应不是JSON格式:', text)
+ errorData = { rawError: text }
+ }
+ }
+ } catch (e) {
+ console.error('读取错误响应失败:', e)
+ }
+
+ const serverMessage = extractServerMessage(errorData)
+ const errorMessage = getHttpErrorMessage(response.status, serverMessage, response.statusText)
+
+ // 创建错误对象并附加原始数据
+ const error = new Error(errorMessage) as HttpError
+ error.status = response.status
+ error.statusText = response.statusText
+ error.data = errorData
+ error.type = ErrorType.HTTP_ERROR
+
+ return error
+}
+
+/**
+ * 处理 Axios 的错误响应
+ * @param axiosError - Axios 错误对象
+ * @returns 包含友好错误信息的 Error 对象
+ */
+export function handleAxiosError(axiosError: AxiosError): HttpError {
+ console.log('Axios错误对象:', axiosError)
+
+ // 如果是网络错误(没有响应)
+ if (!axiosError.response) {
+ if (axiosError.code === 'ECONNABORTED') {
+ const error = new Error('请求超时,请检查网络连接或稍后重试') as HttpError
+ error.type = ErrorType.TIMEOUT
+ return error
+ }
+
+ if (axiosError.code === 'ERR_NETWORK') {
+ const error = new Error('网络连接失败,请检查网络设置或API地址是否正确') as HttpError
+ error.type = ErrorType.NETWORK_ERROR
+ return error
+ }
+
+ const error = new Error(`网络错误:${axiosError.message}`) as HttpError
+ error.type = ErrorType.NETWORK_ERROR
+ error.originalError = axiosError
+ return error
+ }
+
+ // 有响应,处理 HTTP 错误
+ const response = axiosError.response
+ const errorData = response.data
+
+ console.log('Axios错误响应数据:', errorData)
+
+ const serverMessage = extractServerMessage(errorData)
+ const errorMessage = getHttpErrorMessage(response.status, serverMessage, response.statusText)
+
+ // 创建错误对象
+ const error = new Error(errorMessage) as HttpError
+ error.status = response.status
+ error.statusText = response.statusText
+ error.data = errorData
+ error.type = ErrorType.HTTP_ERROR
+ error.originalError = axiosError
+
+ return error
+}
+
+/**
+ * 通用的请求错误处理函数
+ * 自动识别 fetch 或 axios 错误
+ * @param errorOrResponse - fetch Response 或 axios error
+ * @returns 包含友好错误信息的 Error 对象
+ */
+export async function handleRequestError(errorOrResponse: Response | AxiosError | any): Promise {
+ // 判断是 fetch Response 还是 axios error
+ if (errorOrResponse instanceof Response) {
+ // fetch Response
+ return await handleFetchError(errorOrResponse)
+ } else if ((errorOrResponse as AxiosError).isAxiosError) {
+ // axios error
+ return handleAxiosError(errorOrResponse as AxiosError)
+ } else if (errorOrResponse.response) {
+ // 可能是 axios error 但没有 isAxiosError 标记
+ return handleAxiosError(errorOrResponse as AxiosError)
+ } else {
+ // 未知错误类型
+ console.error('未知错误类型:', errorOrResponse)
+ const error = new Error(errorOrResponse.message || '请求失败') as HttpError
+ error.originalError = errorOrResponse
+ error.type = ErrorType.UNKNOWN_ERROR
+ return error
+ }
+}
+
+/**
+ * 创建统一的错误处理中间件(用于 axios 拦截器)
+ * @returns axios 错误拦截器函数
+ */
+export function createAxiosErrorInterceptor() {
+ return (error: AxiosError) => {
+ const friendlyError = handleAxiosError(error)
+ return Promise.reject(friendlyError)
+ }
+}
+
+/**
+ * Fetch 请求包装器,自动处理错误
+ * @param url - 请求URL
+ * @param options - fetch options
+ * @returns fetch Response
+ */
+export async function fetchWithErrorHandling(
+ url: string,
+ options: RequestInit = {}
+): Promise {
+ try {
+ const response = await fetch(url, options)
+
+ if (!response.ok) {
+ const error = await handleFetchError(response)
+ throw error
+ }
+
+ return response
+ } catch (error: any) {
+ // 处理网络错误等非 HTTP 错误
+ if (error.type) {
+ // 已经是我们处理过的错误
+ throw error
+ }
+
+ // 处理其他类型的错误
+ if (error.name === 'TypeError' && error.message?.includes('fetch')) {
+ const networkError = new Error('网络连接失败,请检查网络设置或API地址是否正确') as HttpError
+ networkError.type = ErrorType.NETWORK_ERROR
+ networkError.originalError = error
+ throw networkError
+ } else if (error.name === 'AbortError') {
+ const abortError = new Error('请求已取消') as HttpError
+ abortError.type = ErrorType.ABORTED
+ abortError.originalError = error
+ throw abortError
+ }
+
+ throw error
+ }
+}
+
+/**
+ * 判断是否为特定类型的错误
+ * @param error - 错误对象
+ * @param type - 错误类型
+ * @returns 是否匹配
+ */
+export function isErrorType(error: HttpError, type: ErrorTypeValue): boolean {
+ return error.type === type
+}
diff --git a/tsconfig.web.json b/tsconfig.web.json
index 8924848..3cd6299 100644
--- a/tsconfig.web.json
+++ b/tsconfig.web.json
@@ -11,7 +11,8 @@
"src/store/**/*",
"src/renderer/**/*",
"src/main/**/*",
- "src/i18n/**/*"
+ "src/i18n/**/*",
+ "src/utils/**/*"
],
"compilerOptions": {
"composite": true,