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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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.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('将所有转视频类型为 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 @@ @@ -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,