new
This commit is contained in:
parent
6b23ff2697
commit
a2174937b7
73
MARKDOWN_SETUP.md
Normal file
73
MARKDOWN_SETUP.md
Normal file
@ -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
|
||||||
|
:content="message.content"
|
||||||
|
:isUser="message.role === 'user'"
|
||||||
|
:isStreaming="message.isLoading"
|
||||||
|
:previousContentLength="message.previousLength || 0"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自定义配置
|
||||||
|
|
||||||
|
如果需要自定义渲染行为,可以修改 `MarkdownRenderer.vue` 中的 `marked` 配置和自定义渲染器。
|
||||||
47
package-lock.json
generated
47
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "laitool-pro",
|
"name": "laitool-pro",
|
||||||
"version": "v4.0.2",
|
"version": "v4.0.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "laitool-pro",
|
"name": "laitool-pro",
|
||||||
"version": "v4.0.2",
|
"version": "v4.0.5",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alicloud/alimt20181012": "^1.3.0",
|
"@alicloud/alimt20181012": "^1.3.0",
|
||||||
@ -21,7 +21,10 @@
|
|||||||
"compressing": "^1.10.1",
|
"compressing": "^1.10.1",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"katex": "^0.16.25",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"marked": "^17.0.1",
|
||||||
"moment-timezone": "^0.5.48",
|
"moment-timezone": "^0.5.48",
|
||||||
"music-metadata-browser": "^2.5.11",
|
"music-metadata-browser": "^2.5.11",
|
||||||
"node-machine-id": "^1.1.12",
|
"node-machine-id": "^1.1.12",
|
||||||
@ -5546,7 +5549,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/highlight.js": {
|
"node_modules/highlight.js": {
|
||||||
"version": "11.11.1",
|
"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",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@ -6249,6 +6253,31 @@
|
|||||||
"graceful-fs": "^4.1.6"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -6544,6 +6573,18 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/matcher": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "laitool-pro",
|
"name": "laitool-pro",
|
||||||
"productName": "LaiToolPro",
|
"productName": "LaiToolPro",
|
||||||
"version": "v4.0.5",
|
"version": "v4.0.6",
|
||||||
"description": "来推 Pro - 一款集音频处理、文案生成、图片生成、视频生成等功能于一体的多合一AI工具软件。",
|
"description": "来推 Pro - 一款集音频处理、文案生成、图片生成、视频生成等功能于一体的多合一AI工具软件。",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "xiangbei",
|
"author": "xiangbei",
|
||||||
@ -39,7 +39,10 @@
|
|||||||
"compressing": "^1.10.1",
|
"compressing": "^1.10.1",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"katex": "^0.16.25",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"marked": "^17.0.1",
|
||||||
"moment-timezone": "^0.5.48",
|
"moment-timezone": "^0.5.48",
|
||||||
"music-metadata-browser": "^2.5.11",
|
"music-metadata-browser": "^2.5.11",
|
||||||
"node-machine-id": "^1.1.12",
|
"node-machine-id": "^1.1.12",
|
||||||
|
|||||||
@ -221,11 +221,17 @@ export class BookService extends RealmBaseService {
|
|||||||
} else if (book.type == BookType.ORIGINAL) {
|
} else if (book.type == BookType.ORIGINAL) {
|
||||||
let generalSetting = await getGeneralSetting()
|
let generalSetting = await getGeneralSetting()
|
||||||
imageCategory = generalSetting.defaultImgGenMethod ?? ImageCategory.Midjourney
|
imageCategory = generalSetting.defaultImgGenMethod ?? ImageCategory.Midjourney
|
||||||
|
} else if (book.type == BookType.COMIC_DRAMA) {
|
||||||
|
imageCategory = ImageCategory.Midjourney
|
||||||
} else {
|
} else {
|
||||||
throw new Error(t('未知的小说类型'))
|
throw new Error(t('未知的小说类型'))
|
||||||
}
|
}
|
||||||
|
let srtData;
|
||||||
|
// 漫剧不用处理srt
|
||||||
|
if (book.type != BookType.COMIC_DRAMA) {
|
||||||
const srtHandle = new SrtHandle()
|
const srtHandle = new SrtHandle()
|
||||||
let srtData = await srtHandle.GetSrtDataByPath(book.srtPath as string)
|
srtData = await srtHandle.GetSrtDataByPath(book.srtPath as string)
|
||||||
|
}
|
||||||
|
|
||||||
let bookTaskDetailService = await BookTaskDetailService.getInstance()
|
let bookTaskDetailService = await BookTaskDetailService.getInstance()
|
||||||
|
|
||||||
@ -258,6 +264,9 @@ export class BookService extends RealmBaseService {
|
|||||||
// 添加任务
|
// 添加任务
|
||||||
this.realm.create('BookTask', bookTask)
|
this.realm.create('BookTask', bookTask)
|
||||||
|
|
||||||
|
// 如果是小说或者是反推的话,添加详细的任务信息
|
||||||
|
if (!(book.type == BookType.COMIC_DRAMA || srtData == undefined)) {
|
||||||
|
|
||||||
// 循环添加小说详细信息
|
// 循环添加小说详细信息
|
||||||
for (let i = 0; i < srtData.length; i++) {
|
for (let i = 0; i < srtData.length; i++) {
|
||||||
const element = srtData[i]
|
const element = srtData[i]
|
||||||
@ -282,6 +291,7 @@ export class BookService extends RealmBaseService {
|
|||||||
adetailer: false // 默认false,实际更具SD设置中为主
|
adetailer: false // 默认false,实际更具SD设置中为主
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 保存成功,返回数据,但是要做处理
|
// 保存成功,返回数据,但是要做处理
|
||||||
|
|||||||
@ -380,6 +380,19 @@ export class BookTaskService extends RealmBaseService {
|
|||||||
addBookTaskDetail.push(addOneBookTaskDetail)
|
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) {
|
} catch (error) {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,9 @@ export enum BookType {
|
|||||||
// 反推
|
// 反推
|
||||||
SD_REVERSE = 'sd_reverse',
|
SD_REVERSE = 'sd_reverse',
|
||||||
// MJ 反推
|
// 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反推')
|
return t('SD反推')
|
||||||
case BookType.MJ_REVERSE:
|
case BookType.MJ_REVERSE:
|
||||||
return t('MJ反推')
|
return t('MJ反推')
|
||||||
|
case BookType.COMIC_DRAMA:
|
||||||
|
return t('漫剧')
|
||||||
default:
|
default:
|
||||||
return t('未知类型')
|
return t('未知类型')
|
||||||
}
|
}
|
||||||
@ -38,7 +42,8 @@ export function GetBookTypeOptions() {
|
|||||||
return [
|
return [
|
||||||
{ label: t('原创'), value: BookType.ORIGINAL },
|
{ label: t('原创'), value: BookType.ORIGINAL },
|
||||||
{ label: t('SD反推'), value: BookType.SD_REVERSE },
|
{ 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 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -80,7 +80,10 @@ export const OptionKeyName = {
|
|||||||
CW_SimpleSetting: 'InferenceAI_CW_SimpleSetting',
|
CW_SimpleSetting: 'InferenceAI_CW_SimpleSetting',
|
||||||
|
|
||||||
/** 文案相关的特殊字符串 */
|
/** 文案相关的特殊字符串 */
|
||||||
CW_FormatSpecialChar: 'InferenceAI_CW_FormatSpecialChar'
|
CW_FormatSpecialChar: 'InferenceAI_CW_FormatSpecialChar',
|
||||||
|
|
||||||
|
/** 文案处理 当前模式(batch/chat) */
|
||||||
|
CW_CurrentMode: 'InferenceAI_CW_CurrentMode'
|
||||||
},
|
},
|
||||||
SD: {
|
SD: {
|
||||||
/** SD基础设置 */
|
/** SD基础设置 */
|
||||||
|
|||||||
@ -114,6 +114,11 @@ function SystemIpc() {
|
|||||||
async (_, filePath: string) => await electronInterface.OpenFile(filePath)
|
async (_, filePath: string) => await electronInterface.OpenFile(filePath)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 在资源管理器中显示文件位置 */
|
||||||
|
ipcMain.on('open-file-location', (_, filePath: string) => {
|
||||||
|
electronInterface.ShowFileInFolder(filePath)
|
||||||
|
})
|
||||||
|
|
||||||
/** 复制指定的文件夹中的所有内容到另一个文件夹 */
|
/** 复制指定的文件夹中的所有内容到另一个文件夹 */
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
DEFINE_STRING.SYSTEM.COPY_FOLDER_CONTENTS,
|
DEFINE_STRING.SYSTEM.COPY_FOLDER_CONTENTS,
|
||||||
|
|||||||
69
src/define/model/copywriting.d.ts
vendored
Normal file
69
src/define/model/copywriting.d.ts
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2078,4 +2078,21 @@ export default {
|
|||||||
"云端使用推荐仙宫云": "Cloud usage recommends Xiangong Cloud",
|
"云端使用推荐仙宫云": "Cloud usage recommends Xiangong Cloud",
|
||||||
"登录/注册": "Login/Register",
|
"登录/注册": "Login/Register",
|
||||||
"开启图转视频": "Enable Image to Video",
|
"开启图转视频": "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",
|
||||||
}
|
}
|
||||||
@ -2078,4 +2078,21 @@ export default {
|
|||||||
"云端使用推荐仙宫云": "云端使用推荐仙宫云",
|
"云端使用推荐仙宫云": "云端使用推荐仙宫云",
|
||||||
"登录/注册": "登录/注册",
|
"登录/注册": "登录/注册",
|
||||||
"开启图转视频" : "开启图转视频",
|
"开启图转视频" : "开启图转视频",
|
||||||
|
"切换到{mode}模式": "切换到{mode}模式",
|
||||||
|
"批量文案处理": "批量文案处理",
|
||||||
|
"AI对话": "AI对话",
|
||||||
|
"已切换到{mode}模式": "已切换到{mode}模式",
|
||||||
|
"未选择分类": "未选择分类",
|
||||||
|
"请选择一个分类开始对话": "请选择一个分类开始对话",
|
||||||
|
"开始与AI对话,获取创作灵感": "开始与AI对话,获取创作灵感",
|
||||||
|
"你": "你",
|
||||||
|
"AI正在生成回复...": "AI正在生成回复...",
|
||||||
|
"输入你的问题...": "输入你的问题...",
|
||||||
|
"Ctrl + Enter 发送": "Ctrl + Enter 发送",
|
||||||
|
"清空对话": "清空对话",
|
||||||
|
"切换分类将清空当前对话记录,是否继续?": "切换分类将清空当前对话记录,是否继续?",
|
||||||
|
"请先选择一个分类": "请先选择一个分类",
|
||||||
|
"生成回复失败: {error}": "生成回复失败: {error}",
|
||||||
|
"确定要清空所有对话记录吗?": "确定要清空所有对话记录吗?",
|
||||||
|
"对话记录已清空": "对话记录已清空",
|
||||||
}
|
}
|
||||||
@ -374,6 +374,8 @@ export class BookPromptHandle extends BookBasicHandle {
|
|||||||
let newData: BookTask.BookTaskCharacterAndSceneObject[] = []
|
let newData: BookTask.BookTaskCharacterAndSceneObject[] = []
|
||||||
for (let i = 0; i < returnData.length; i++) {
|
for (let i = 0; i < returnData.length; i++) {
|
||||||
const element = returnData[i]
|
const element = returnData[i]
|
||||||
|
if (type == PresetCategory.Character) {
|
||||||
|
|
||||||
let splitData = element.split(':')
|
let splitData = element.split(':')
|
||||||
if (splitData.length < 2) {
|
if (splitData.length < 2) {
|
||||||
continue
|
continue
|
||||||
@ -385,6 +387,19 @@ export class BookPromptHandle extends BookBasicHandle {
|
|||||||
prompt: splitData[1]
|
prompt: splitData[1]
|
||||||
} as BookTask.BookTaskCharacterAndSceneObject
|
} as BookTask.BookTaskCharacterAndSceneObject
|
||||||
newData.push(tempData)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type == PresetCategory.Character) {
|
if (type == PresetCategory.Character) {
|
||||||
|
|||||||
@ -41,6 +41,16 @@ export default class ElectronInterface {
|
|||||||
}), 'SystemIpc_OPEN_FILE')
|
}), 'SystemIpc_OPEN_FILE')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在资源管理器中显示文件位置
|
||||||
|
* @param filePath 文件路径
|
||||||
|
*/
|
||||||
|
public ShowFileInFolder(filePath: string): void {
|
||||||
|
if (filePath) {
|
||||||
|
shell.showItemInFolder(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 深度复制文件夹内容到目标文件夹
|
* 深度复制文件夹内容到目标文件夹
|
||||||
* @param source 源文件夹路径
|
* @param source 源文件夹路径
|
||||||
|
|||||||
12
src/renderer/components.d.ts
vendored
12
src/renderer/components.d.ts
vendored
@ -9,6 +9,7 @@ export {}
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
AddOrModifyPreset: typeof import('./src/components/Preset/AddOrModifyPreset.vue')['default']
|
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']
|
AIGroup: typeof import('./src/components/Original/Copywriter/AIGroup.vue')['default']
|
||||||
AISetting: typeof import('./src/components/Setting/InferenceSetting/AISetting.vue')['default']
|
AISetting: typeof import('./src/components/Setting/InferenceSetting/AISetting.vue')['default']
|
||||||
AllImagePreview: typeof import('./src/components/Original/BookTaskDetail/AllImagePreview.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']
|
ComfyUIAddWorkflow: typeof import('./src/components/Setting/ComfyUIAddWorkflow.vue')['default']
|
||||||
ComfyUIImageToVideoInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoConfyUI/ComfyUIImageToVideoInfo.vue')['default']
|
ComfyUIImageToVideoInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoConfyUI/ComfyUIImageToVideoInfo.vue')['default']
|
||||||
ComfyUISetting: typeof import('./src/components/Setting/ComfyUISetting.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']
|
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']
|
ConfigOptionGroup: typeof import('./src/components/common/ConfigOptionGroup.vue')['default']
|
||||||
ContactDeveloper: typeof import('./src/components/SoftHome/ContactDeveloper.vue')['default']
|
ContactDeveloper: typeof import('./src/components/SoftHome/ContactDeveloper.vue')['default']
|
||||||
CopyWritingCategoryMenu: typeof import('./src/components/CopyWriting/CopyWritingCategoryMenu.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']
|
EditWord: typeof import('./src/components/Original/Copywriter/EditWord.vue')['default']
|
||||||
FindReplaceRound: typeof import('./src/components/common/Icon/FindReplaceRound.vue')['default']
|
FindReplaceRound: typeof import('./src/components/common/Icon/FindReplaceRound.vue')['default']
|
||||||
GeneralSettings: typeof import('./src/components/Setting/GeneralSettings.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']
|
HailuoFirstLastFrameInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoHaiLuo/HailuoFirstLastFrameInfo.vue')['default']
|
||||||
HailuoImageToVideoInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoHaiLuo/HailuoImageToVideoInfo.vue')['default']
|
HailuoImageToVideoInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoHaiLuo/HailuoImageToVideoInfo.vue')['default']
|
||||||
HailuoTextToVideoInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoHaiLuo/HailuoTextToVideoInfo.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']
|
LanguageSwitcher: typeof import('./src/components/common/LanguageSwitcher.vue')['default']
|
||||||
LoadingComponent: typeof import('./src/components/common/LoadingComponent.vue')['default']
|
LoadingComponent: typeof import('./src/components/common/LoadingComponent.vue')['default']
|
||||||
ManageAISetting: typeof import('./src/components/CopyWriting/ManageAISetting.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']
|
MediaToVideoInfoBasicInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoBasicInfo.vue')['default']
|
||||||
MediaToVideoInfoComfyUIInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoConfyUI/MediaToVideoInfoComfyUIInfo.vue')['default']
|
MediaToVideoInfoComfyUIInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoConfyUI/MediaToVideoInfoComfyUIInfo.vue')['default']
|
||||||
MediaToVideoInfoConfig: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoConfig.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']
|
OriginalTaskList: typeof import('./src/components/Original/MainHome/OriginalTaskList.vue')['default']
|
||||||
OriginalViewBookInfo: typeof import('./src/components/Original/MainHome/OriginalViewBookInfo.vue')['default']
|
OriginalViewBookInfo: typeof import('./src/components/Original/MainHome/OriginalViewBookInfo.vue')['default']
|
||||||
OriginalViewBookTaskInfo: typeof import('./src/components/Original/MainHome/OriginalViewBookTaskInfo.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']
|
PointRightIcon: typeof import('./src/components/common/Icon/PointRightIcon.vue')['default']
|
||||||
PresetShowCard: typeof import('./src/components/Preset/PresetShowCard.vue')['default']
|
PresetShowCard: typeof import('./src/components/Preset/PresetShowCard.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
@ -157,6 +167,7 @@ declare module 'vue' {
|
|||||||
SearchPresetArea: typeof import('./src/components/Preset/SearchPresetArea.vue')['default']
|
SearchPresetArea: typeof import('./src/components/Preset/SearchPresetArea.vue')['default']
|
||||||
SelectRegionImage: typeof import('./src/components/Original/Image/SelectRegionImage.vue')['default']
|
SelectRegionImage: typeof import('./src/components/Original/Image/SelectRegionImage.vue')['default']
|
||||||
SelectStylePreset: typeof import('./src/components/Preset/SelectStylePreset.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']
|
StylePreset: typeof import('./src/components/Preset/StylePreset.vue')['default']
|
||||||
TextEllipsis: typeof import('./src/components/common/TextEllipsis.vue')['default']
|
TextEllipsis: typeof import('./src/components/common/TextEllipsis.vue')['default']
|
||||||
ToolGrid: typeof import('./src/components/ToolBox/ToolGrid.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']
|
TopMenuButtons: typeof import('./src/components/Original/BookTaskDetail/TopMenuButtons.vue')['default']
|
||||||
UploadRound: typeof import('./src/components/common/Icon/UploadRound.vue')['default']
|
UploadRound: typeof import('./src/components/common/Icon/UploadRound.vue')['default']
|
||||||
UserAnalysis: typeof import('./src/components/Original/Analysis/UserAnalysis.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']
|
VideoDisplay: typeof import('./src/components/common/VideoDisplay.vue')['default']
|
||||||
WechatGroup: typeof import('./src/components/SoftHome/WechatGroup.vue')['default']
|
WechatGroup: typeof import('./src/components/SoftHome/WechatGroup.vue')['default']
|
||||||
WordGroup: typeof import('./src/components/Original/Copywriter/WordGroup.vue')['default']
|
WordGroup: typeof import('./src/components/Original/Copywriter/WordGroup.vue')['default']
|
||||||
|
|||||||
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
156
src/renderer/src/components/ComicDrama/ComicDramaHome.vue
Normal file
156
src/renderer/src/components/ComicDrama/ComicDramaHome.vue
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<div class="comic-drama-container">
|
||||||
|
<div v-if="!comLoading">
|
||||||
|
<!-- 项目选择视图 -->
|
||||||
|
<ComicDramaProjectSelection
|
||||||
|
v-if="currentView === 'projects'"
|
||||||
|
:project-folders="projectFolders"
|
||||||
|
:sub-projects="subProjects"
|
||||||
|
@select-project="handleSelectProject"
|
||||||
|
@switch-to-workspace="handleSwitchToWorkspace"
|
||||||
|
@refresh-projects="handleRefreshProjects"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 工作台视图 -->
|
||||||
|
<ComicDramaWorkspace
|
||||||
|
v-else
|
||||||
|
:current-project="currentProject"
|
||||||
|
:shots="shotsData"
|
||||||
|
@back-to-projects="handleBackToProjects"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<LoadingComponent v-else :description="loadingDescription" size="large" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import ComicDramaProjectSelection from './ComicDramaInfo/ComicDramaProjectSelection.vue'
|
||||||
|
import ComicDramaWorkspace from './ComicDramaInfo/ComicDramaWorkspace.vue'
|
||||||
|
import LoadingComponent from '@/renderer/src/components/common/LoadingComponent.vue'
|
||||||
|
import { useBook } from '@/renderer/src/hooks/useBook'
|
||||||
|
import { useBookStore } from '@/renderer/src/stores'
|
||||||
|
import { BookType } from '@/define/enum/bookEnum'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import { TimeDelay } from '@/define/Tools/time'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
const bookStore = useBookStore()
|
||||||
|
const message = useMessage()
|
||||||
|
const { loadBookInfo, loadProjectProgressData, loadBookTaskDetails } = useBook()
|
||||||
|
|
||||||
|
// 全局状态
|
||||||
|
const currentView = ref('projects')
|
||||||
|
const currentProject = ref(null)
|
||||||
|
const bookData = ref([])
|
||||||
|
const selectedProjectId = ref(null)
|
||||||
|
const shotsData = ref([]) // 分镜数据
|
||||||
|
const comLoading = ref(true) // 组件级loading状态
|
||||||
|
const loadingDescription = ref(t('正在初始化漫剧模块'))
|
||||||
|
|
||||||
|
// 转换为项目文件夹格式
|
||||||
|
const projectFolders = computed(() => {
|
||||||
|
return bookData.value
|
||||||
|
.filter((book) => book.type === BookType.COMIC_DRAMA)
|
||||||
|
.map((book) => ({
|
||||||
|
id: book.id,
|
||||||
|
title: book.name,
|
||||||
|
count: `${book.children?.length || 0} 个子项目`,
|
||||||
|
date: book.createTime || '2025/11/20',
|
||||||
|
active: book.id === selectedProjectId.value
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 转换为子项目格式
|
||||||
|
const subProjects = computed(() => {
|
||||||
|
const selectedBook = bookData.value.find((book) => book.id === selectedProjectId.value)
|
||||||
|
if (!selectedBook || !selectedBook.children) return []
|
||||||
|
|
||||||
|
return selectedBook.children
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载项目数据
|
||||||
|
const loadProjects = async () => {
|
||||||
|
const data = await loadBookInfo({
|
||||||
|
isInitialLoad: true,
|
||||||
|
bookType: BookType.COMIC_DRAMA,
|
||||||
|
loadingTip: '加载漫剧项目中...',
|
||||||
|
processProgressData: loadProjectProgressData
|
||||||
|
})
|
||||||
|
|
||||||
|
bookData.value = data
|
||||||
|
|
||||||
|
// 默认选中第一个项目
|
||||||
|
if (data.length > 0 && !selectedProjectId.value) {
|
||||||
|
selectedProjectId.value = data[0].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理项目选择
|
||||||
|
const handleSelectProject = (projectId) => {
|
||||||
|
selectedProjectId.value = projectId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换到工作台(带加载逻辑)
|
||||||
|
const handleSwitchToWorkspace = async (event) => {
|
||||||
|
const { subProject, description } = event
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 显示加载状态
|
||||||
|
comLoading.value = true
|
||||||
|
loadingDescription.value = description || t('正在加载漫剧工作台...')
|
||||||
|
|
||||||
|
// 延迟以显示加载动画
|
||||||
|
await TimeDelay(500)
|
||||||
|
|
||||||
|
// 获取当前项目的第一个bookTask的ID
|
||||||
|
const bookTaskId = subProject.id
|
||||||
|
if (!bookTaskId) {
|
||||||
|
message.error(t('未找到有效的任务ID'))
|
||||||
|
comLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载分镜数据
|
||||||
|
const taskDetails = await loadBookTaskDetails(bookTaskId, {
|
||||||
|
showLoading: false // 使用组件自己的loading
|
||||||
|
})
|
||||||
|
|
||||||
|
shotsData.value = taskDetails
|
||||||
|
|
||||||
|
console.log('111111111111111111', shotsData.value)
|
||||||
|
currentProject.value = subProject
|
||||||
|
currentView.value = 'workspace'
|
||||||
|
} catch (error) {
|
||||||
|
message.error(t('加载工作台失败: {error}', { error: error?.message || error }))
|
||||||
|
} finally {
|
||||||
|
comLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回项目列表
|
||||||
|
const handleBackToProjects = () => {
|
||||||
|
currentView.value = 'projects'
|
||||||
|
currentProject.value = null
|
||||||
|
shotsData.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新项目列表
|
||||||
|
const handleRefreshProjects = async () => {
|
||||||
|
await loadProjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await TimeDelay(500)
|
||||||
|
await loadProjects()
|
||||||
|
comLoading.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.comic-drama-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,586 @@
|
|||||||
|
<template>
|
||||||
|
<div class="project-selection-view">
|
||||||
|
<!-- 左侧项目列表 -->
|
||||||
|
<div class="project-sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<span class="header-title">漫剧项目</span>
|
||||||
|
<n-button class="add-icon" text @click="handleAddProject">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="16">
|
||||||
|
<AddOutline />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
添加
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<div class="project-list">
|
||||||
|
<div
|
||||||
|
v-for="folder in projectFolders"
|
||||||
|
:key="folder.id"
|
||||||
|
class="project-card"
|
||||||
|
:class="{ active: folder.active }"
|
||||||
|
@click="$emit('select-project', folder.id)"
|
||||||
|
>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="project-title">{{ folder.title }}</span>
|
||||||
|
<n-icon size="14" class="more-icon">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="1"></circle>
|
||||||
|
<circle cx="19" cy="12" r="1"></circle>
|
||||||
|
<circle cx="5" cy="12" r="1"></circle>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</div>
|
||||||
|
<div class="card-tags">
|
||||||
|
<span class="tag-original">漫剧</span>
|
||||||
|
<span class="project-count">{{ folder.count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span class="create-date">创建时间 {{ formatDateToString(folder.date) }}</span>
|
||||||
|
<div class="icon-group">
|
||||||
|
<n-icon size="12">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
<n-icon size="12">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||||
|
<polyline points="21 15 16 10 5 21"></polyline>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧子项目网格 -->
|
||||||
|
<div class="subproject-main">
|
||||||
|
<div class="main-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<span class="subtitle">子项目列表 - {{ activeProjectTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span class="project-total">共 {{ subProjects.length }} 个子项目</span>
|
||||||
|
<n-button class="add-batch-btn" text @click="handleAddBatch">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="14">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
新增分镜批次
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="subproject-grid">
|
||||||
|
<div
|
||||||
|
v-for="sub in subProjects"
|
||||||
|
:key="sub.id"
|
||||||
|
class="subproject-card"
|
||||||
|
@dblclick="handleOpenWorkspace(sub)"
|
||||||
|
>
|
||||||
|
<div class="card-title-row">
|
||||||
|
<div class="title-with-icon">
|
||||||
|
<n-icon size="14" class="file-icon">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||||
|
<polyline points="14 2 14 8 20 8"></polyline>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
<span>{{ sub.name }}</span>
|
||||||
|
</div>
|
||||||
|
<n-icon size="14" class="more-icon-hover">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="1"></circle>
|
||||||
|
<circle cx="12" cy="5" r="1"></circle>
|
||||||
|
<circle cx="12" cy="19" r="1"></circle>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 预览区 -->
|
||||||
|
<div class="preview-area">
|
||||||
|
<n-image
|
||||||
|
v-if="sub.firstImagePath"
|
||||||
|
:src="sub.firstImagePath"
|
||||||
|
object-fit="cover"
|
||||||
|
:preview-disabled="true"
|
||||||
|
class="preview-image"
|
||||||
|
/>
|
||||||
|
<div v-else class="preview-empty">
|
||||||
|
<span class="empty-text">无图片</span>
|
||||||
|
</div>
|
||||||
|
<!-- 进度显示 -->
|
||||||
|
<div class="progress-overlay">
|
||||||
|
<n-progress
|
||||||
|
type="line"
|
||||||
|
indicator-placement="inside"
|
||||||
|
:percentage="sub.imageVideoProgress?.imageRate ?? 0"
|
||||||
|
:show-indicator="true"
|
||||||
|
:height="16"
|
||||||
|
:color="toRGBA(themeStore.menuPrimaryColor,1)"
|
||||||
|
:style="{
|
||||||
|
opacity: 1
|
||||||
|
}"
|
||||||
|
:border-radius="3"
|
||||||
|
:fill-border-radius="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-bottom">
|
||||||
|
<div class="tags-row">
|
||||||
|
<span v-for="tag in sub.tags" :key="tag" class="tag">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="status-text">{{ getStatusText(sub.status) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新建卡片 -->
|
||||||
|
<div class="create-card" @click="handleCreateSubproject">
|
||||||
|
<n-icon size="32" class="plus-icon">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
<span class="create-text">新建子项目</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { NIcon, NButton, NImage, NProgress } from 'naive-ui'
|
||||||
|
import { AddOutline } from '@vicons/ionicons5'
|
||||||
|
import { useThemeStore } from '@/renderer/src/stores'
|
||||||
|
import { createHoverColor, toRGBA } from '@/renderer/src/common/color'
|
||||||
|
import { useBook } from '@/renderer/src/hooks/useBook'
|
||||||
|
import { BookType } from '@/define/enum/bookEnum'
|
||||||
|
import { formatDateToString } from '@/renderer/src/common/time'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projectFolders: any[]
|
||||||
|
subProjects: any[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
const { createProject } = useBook()
|
||||||
|
|
||||||
|
const emit = defineEmits(['select-project', 'switch-to-workspace', 'refresh-projects'])
|
||||||
|
|
||||||
|
const activeProjectTitle = computed(() => {
|
||||||
|
const active = props.projectFolders.find((p) => p.active)
|
||||||
|
return active ? active.title : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'processing':
|
||||||
|
return '生成中'
|
||||||
|
case 'done':
|
||||||
|
return '已完成'
|
||||||
|
default:
|
||||||
|
return '等待'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建项目(使用公共 hook)
|
||||||
|
const handleAddProject = () => {
|
||||||
|
createProject(() => {
|
||||||
|
emit('refresh-projects')
|
||||||
|
}, BookType.COMIC_DRAMA)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddBatch = () => {
|
||||||
|
console.log('新增分镜批次')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateSubproject = () => {
|
||||||
|
console.log('新建子项目')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双击打开工作台
|
||||||
|
const handleOpenWorkspace = (subProject) => {
|
||||||
|
const parentBook = props.projectFolders.find(p => p.active)
|
||||||
|
const description = t('正在初始化漫剧工作台,{bookName}_{taskName}', {
|
||||||
|
bookName: parentBook?.title || '漫剧项目',
|
||||||
|
taskName: subProject.name
|
||||||
|
})
|
||||||
|
|
||||||
|
emit('switch-to-workspace', {
|
||||||
|
subProject,
|
||||||
|
description
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuPrimaryColor = computed(() => {
|
||||||
|
return themeStore.menuPrimaryColor
|
||||||
|
})
|
||||||
|
const menuPrimaryShadow = computed(() => {
|
||||||
|
return themeStore.menuPrimaryShadow
|
||||||
|
})
|
||||||
|
const menuPrimaryHoverShadow = computed(() => {
|
||||||
|
return createHoverColor(themeStore.menuPrimaryShadow)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.project-selection-view {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧项目列表 */
|
||||||
|
.project-sidebar {
|
||||||
|
width: 320px;
|
||||||
|
border-right: 1px solid v-bind(menuPrimaryShadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
height: 56px;
|
||||||
|
border-bottom: 1px solid v-bind(menuPrimaryShadow);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-icon {
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-list {
|
||||||
|
padding: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid v-bind(menuPrimaryColor);
|
||||||
|
background: v-bind(menuPrimaryShadow);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card.active {
|
||||||
|
background: v-bind(menuPrimaryColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card:hover {
|
||||||
|
box-shadow: 0 0 15px v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-title {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-original {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: v-bind(menuPrimaryColor);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-count {
|
||||||
|
font-size: 11px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧子项目区域 */
|
||||||
|
.subproject-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
height: 56px;
|
||||||
|
border-bottom: 1px solid v-bind(menuPrimaryShadow);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-batch-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subproject-grid {
|
||||||
|
padding: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subproject-card {
|
||||||
|
border: 1px solid v-bind(menuPrimaryColor);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subproject-card:hover {
|
||||||
|
box-shadow: 0 0 15px v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-with-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-with-icon span {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-icon-hover {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subproject-card {
|
||||||
|
background: v-bind(menuPrimaryShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subproject-card:hover .more-icon-hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-area {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid v-bind(menuPrimaryColor);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image :deep(img) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: v-bind(menuPrimaryColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-card {
|
||||||
|
background: v-bind(menuPrimaryShadow);
|
||||||
|
border: 1px dashed v-bind(menuPrimaryShadow);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-card:hover {
|
||||||
|
border-color: v-bind(menuPrimaryShadow);
|
||||||
|
background: v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plus-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-text {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,565 @@
|
|||||||
|
<template>
|
||||||
|
<div class="workspace-container">
|
||||||
|
<!-- 顶部导航栏 -->
|
||||||
|
<header class="workspace-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<n-button class="back-btn" text @click="$emit('back-to-projects')">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="16">
|
||||||
|
<ArrowBackOutline />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h1 class="project-title">
|
||||||
|
{{ props.currentProject?.name || props.currentProject?.title || '漫剧项目' }}
|
||||||
|
</h1>
|
||||||
|
<span class="model-badge">漫剧生成 v1.0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<!-- 视图切换器 -->
|
||||||
|
<div class="view-switcher">
|
||||||
|
<n-button
|
||||||
|
class="view-btn"
|
||||||
|
:class="{ active: workspaceMode === 'list' }"
|
||||||
|
text
|
||||||
|
@click="workspaceMode = 'list'"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<ListOutline />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
class="view-btn"
|
||||||
|
:class="{ active: workspaceMode === 'grid' }"
|
||||||
|
text
|
||||||
|
@click="workspaceMode = 'grid'"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<GridOutline />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<n-button
|
||||||
|
:style="{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: pointer
|
||||||
|
}"
|
||||||
|
type="primary"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<UploadRound />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
导入
|
||||||
|
</n-button>
|
||||||
|
<n-button class="generate-all-btn" text>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="14">
|
||||||
|
<PlayForwardOutline />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
全部生成
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 全局配置条 -->
|
||||||
|
<GlobalConfigBar />
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<div class="workspace-content">
|
||||||
|
<!-- 列表模式 -->
|
||||||
|
<div v-if="workspaceMode === 'list'" class="list-view">
|
||||||
|
<ComicDramaShotCardList
|
||||||
|
v-for="(shot, index) in props.shots"
|
||||||
|
:key="shot.id"
|
||||||
|
:shot="shot"
|
||||||
|
:index="index"
|
||||||
|
:active="activeShotId === shot.id"
|
||||||
|
@click="activeShotId = shot.id"
|
||||||
|
@version-click="handleVersionClick"
|
||||||
|
@open-comparison="openComparisonModal"
|
||||||
|
@generate="handleGenerate"
|
||||||
|
@update:shot="handleShotUpdate(index, $event)"
|
||||||
|
/>
|
||||||
|
<div class="add-shot-card" @click="handleAddShot">
|
||||||
|
<n-icon size="16">
|
||||||
|
<AddOutline />
|
||||||
|
</n-icon>
|
||||||
|
新增分镜
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 网格模式 -->
|
||||||
|
<div v-else class="grid-view">
|
||||||
|
<ComicDramaShotCard
|
||||||
|
v-for="shot in props.shots"
|
||||||
|
:key="shot.id"
|
||||||
|
:shot="shot"
|
||||||
|
:active="activeShotId === shot.id"
|
||||||
|
mode="grid"
|
||||||
|
@click="activeShotId = shot.id"
|
||||||
|
@version-click="handleVersionClick"
|
||||||
|
@open-comparison="openComparisonModal"
|
||||||
|
@generate="handleGenerate"
|
||||||
|
/>
|
||||||
|
<div class="add-shot-card-grid" @click="handleAddShot">
|
||||||
|
<n-icon size="24">
|
||||||
|
<AddOutline />
|
||||||
|
</n-icon>
|
||||||
|
<span>新增</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部播放控制栏 -->
|
||||||
|
<PlaybackBar
|
||||||
|
:shots="props.shots"
|
||||||
|
:active-id="activeShotId"
|
||||||
|
:is-playing="globalPlay"
|
||||||
|
@update:active-id="activeShotId = $event"
|
||||||
|
@toggle-play="toggleGlobalPlay"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 版本对比模态框 -->
|
||||||
|
<VersionComparisonModal
|
||||||
|
v-if="activeShot"
|
||||||
|
:is-open="isComparisonModalOpen"
|
||||||
|
:shot="activeShot"
|
||||||
|
@close="isComparisonModalOpen = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch, onErrorCaptured } from 'vue'
|
||||||
|
import { NIcon, NButton, NInput } from 'naive-ui'
|
||||||
|
import {
|
||||||
|
ArrowBackOutline,
|
||||||
|
ListOutline,
|
||||||
|
GridOutline,
|
||||||
|
PlayForwardOutline,
|
||||||
|
AddOutline
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
|
import ComicDramaShotCard from './GridMode/ComicDramaShotCard.vue'
|
||||||
|
import ComicDramaShotCardList from './ListMode/ComicDramaShotCardList.vue'
|
||||||
|
import VersionComparisonModal from './VersionComparisonModal.vue'
|
||||||
|
import GlobalConfigBar from './GlobalConfigBar.vue'
|
||||||
|
import PlaybackBar from './PlaybackBar.vue'
|
||||||
|
import { useThemeStore } from '@/renderer/src/stores'
|
||||||
|
import { createHoverColor } from '@/renderer/src/common/color'
|
||||||
|
import UploadRound from '@/renderer/src/components/common/Icon/UploadRound.vue'
|
||||||
|
import { useBook } from '@/renderer/src/hooks/useBook'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
currentProject: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
shots: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['back-to-projects'])
|
||||||
|
|
||||||
|
const workspaceMode = ref('list')
|
||||||
|
const activeShotId = ref(null)
|
||||||
|
const globalPlay = ref(false)
|
||||||
|
const isComparisonModalOpen = ref(false)
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
// 监听 shots 变化,设置第一个分镜为激活状态
|
||||||
|
watch(
|
||||||
|
() => props.shots,
|
||||||
|
(newShots) => {
|
||||||
|
if (newShots && newShots.length > 0 && !activeShotId.value) {
|
||||||
|
activeShotId.value = newShots[0].id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// 如果有真实数据,在组件挂载后设置第一个分镜为激活状态
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.shots.length > 0 && !activeShotId.value) {
|
||||||
|
activeShotId.value = props.shots[0].id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 捕获 ResizeObserver 错误
|
||||||
|
onErrorCaptured((err) => {
|
||||||
|
// 忽略 ResizeObserver 错误,这是一个已知的浏览器问题
|
||||||
|
if (err.message && err.message.includes('ResizeObserver')) {
|
||||||
|
console.warn('ResizeObserver error ignored:', err.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取当前活动的分镜
|
||||||
|
const activeShot = computed(() => props.shots.find((s) => s.id === activeShotId.value))
|
||||||
|
|
||||||
|
// 获取当前激活版本的数据
|
||||||
|
const getActiveVersion = (shot) => {
|
||||||
|
return (
|
||||||
|
shot.history.find((v) => v.versionId === shot.activeVersionId) ||
|
||||||
|
shot.history.find((v) => v.status === 'generating') || {
|
||||||
|
status: 'empty',
|
||||||
|
duration: '?',
|
||||||
|
ratio: '?',
|
||||||
|
versionId: 'N/A',
|
||||||
|
progress: 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换活动版本
|
||||||
|
const handleVersionClick = (shotId, versionId) => {
|
||||||
|
const shotIndex = props.shots.findIndex((s) => s.id === shotId)
|
||||||
|
if (shotIndex !== -1) {
|
||||||
|
props.shots[shotIndex].activeVersionId = versionId
|
||||||
|
console.log(`切换分镜 ${shotId} 到版本 ${versionId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleGlobalPlay = () => {
|
||||||
|
globalPlay.value = !globalPlay.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddShot = () => {
|
||||||
|
console.log('新增分镜')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开版本对比模态框
|
||||||
|
const openComparisonModal = (shotId) => {
|
||||||
|
activeShotId.value = shotId
|
||||||
|
isComparisonModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成/重绘按钮处理
|
||||||
|
const handleGenerate = (shotId) => {
|
||||||
|
console.log(`生成/重绘分镜 ${shotId}`)
|
||||||
|
// TODO: 实际的生成逻辑
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新分镜数据
|
||||||
|
const handleShotUpdate = (index, updatedShot) => {
|
||||||
|
props.shots[index] = updatedShot
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuPrimaryColor = computed(() => {
|
||||||
|
return themeStore.menuPrimaryColor
|
||||||
|
})
|
||||||
|
const menuPrimaryShadow = computed(() => {
|
||||||
|
return themeStore.menuPrimaryShadow
|
||||||
|
})
|
||||||
|
const menuPrimaryHoverShadow = computed(() => {
|
||||||
|
return createHoverColor(themeStore.menuPrimaryShadow)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.workspace-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部导航栏 */
|
||||||
|
.workspace-header {
|
||||||
|
height: 56px;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 16px;
|
||||||
|
width: 1px;
|
||||||
|
background: v-bind(menuPrimaryColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-title {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: v-bind(menuPrimaryColor);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher {
|
||||||
|
display: flex;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn.active {
|
||||||
|
background: v-bind(menuPrimaryColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-all-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 0 10px rgba(142, 68, 173, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全局配置条 */
|
||||||
|
.global-config-bar {
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
padding: 0px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
height: 48px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-label {
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-prompt-input {
|
||||||
|
width: 256px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-prompt-input :deep(.n-input__input-el) {
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容区域 */
|
||||||
|
.workspace-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 1200px;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-view {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-shot-card {
|
||||||
|
height: 48px;
|
||||||
|
border: 1px dashed #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-shot-card:hover {
|
||||||
|
border-color: v-bind(menuPrimaryColor);
|
||||||
|
box-shadow: 0 2px 8px v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-shot-card-grid {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border: 1px dashed #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部播放控制栏 */
|
||||||
|
.playback-bar {
|
||||||
|
height: 64px;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 128px;
|
||||||
|
border-right: 1px solid #222;
|
||||||
|
padding-right: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: v-bind(menuPrimaryShadow);
|
||||||
|
box-shadow: 0 2px 8px v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-icon {
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-text {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-track {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
height: 40px;
|
||||||
|
width: 64px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid v-bind(menuPrimaryColor);
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:hover {
|
||||||
|
border-color: v-bind(menuPrimaryColor);
|
||||||
|
box-shadow: 0 2px 8px v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.active {
|
||||||
|
border-color: v-bind(menuPrimaryColor);
|
||||||
|
box-shadow: 0 2px 8px v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: v-bind(menuPrimaryShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ungenerated-text {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-video-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<div class="comparison-panel">
|
||||||
|
<!-- 标题和版本选择器 -->
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3 class="panel-title" :class="{ 'color-a': isA, 'color-b': !isA }">
|
||||||
|
{{ title }} - V{{ version?.versionId || 'N/A' }}
|
||||||
|
</h3>
|
||||||
|
<select
|
||||||
|
:value="selectedVersion"
|
||||||
|
@change="(e) => $emit('update:selectedVersion', (e.target as HTMLSelectElement).value)"
|
||||||
|
class="version-selector"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="v in availableVersions"
|
||||||
|
:key="v.versionId"
|
||||||
|
:value="v.versionId"
|
||||||
|
>
|
||||||
|
V{{ v.versionId }} ({{ v.status === 'done' ? '成功' : '失败' }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!version" class="empty-state">
|
||||||
|
<span>请选择版本</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 视频预览区 -->
|
||||||
|
<div class="video-preview">
|
||||||
|
<n-icon size="40" class="film-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect>
|
||||||
|
<line x1="7" y1="2" x2="7" y2="22"></line>
|
||||||
|
<line x1="17" y1="2" x2="17" y2="22"></line>
|
||||||
|
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
<div class="video-badge">
|
||||||
|
{{ version.duration }} | {{ version.ratio }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 提示词和元数据 -->
|
||||||
|
<div class="metadata-section">
|
||||||
|
<div class="prompt-section">
|
||||||
|
<h4 class="section-title">
|
||||||
|
<n-icon size="14" class="section-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||||
|
<polyline points="14 2 14 8 20 8"></polyline>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
核心提示词 (Prompt)
|
||||||
|
</h4>
|
||||||
|
<div class="prompt-content">
|
||||||
|
{{ getVersionPrompt(version) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata-grid">
|
||||||
|
<div class="metadata-item">
|
||||||
|
<p class="metadata-label">风格</p>
|
||||||
|
<p class="metadata-value">{{ getVersionSettings(version).style }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="metadata-item">
|
||||||
|
<p class="metadata-label">Seed</p>
|
||||||
|
<p class="metadata-value mono">{{ getVersionSettings(version).seed }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="metadata-item">
|
||||||
|
<p class="metadata-label">备注</p>
|
||||||
|
<p class="metadata-value truncate">{{ version.note || '无' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="metadata-item">
|
||||||
|
<p class="metadata-label">时间</p>
|
||||||
|
<p class="metadata-value small">{{ version.timestamp?.split(' ')[1] || 'N/A' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NIcon } from 'naive-ui'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
selectedVersion: string | null
|
||||||
|
version: any
|
||||||
|
availableVersions: any[]
|
||||||
|
title: string
|
||||||
|
isA: boolean
|
||||||
|
shot: any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits(['update:selectedVersion'])
|
||||||
|
|
||||||
|
// 模拟 Prompt 差异数据
|
||||||
|
const promptDiff: Record<string, any> = {
|
||||||
|
v1: {
|
||||||
|
prompt: '少女站在樱花树下,微笑看向镜头。',
|
||||||
|
settings: { style: 'Normal', seed: '1234' }
|
||||||
|
},
|
||||||
|
v2: {
|
||||||
|
prompt: 'Anime style, a girl in school uniform standing under cherry blossom tree, smiling at camera, soft lighting, manga aesthetic, 8k.',
|
||||||
|
settings: { style: 'Manga HD', seed: '4321' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVersionPrompt = (version: any) => {
|
||||||
|
return promptDiff[version.versionId]?.prompt || props.shot.prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVersionSettings = (version: any) => {
|
||||||
|
return promptDiff[version.versionId]?.settings || { style: 'Default', seed: 'N/A' }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.comparison-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #141414;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
background: #1a1a1a;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title.color-a {
|
||||||
|
color: #8e44ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title.color-b {
|
||||||
|
color: #f39c12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-selector {
|
||||||
|
background: #252525;
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-selector:focus {
|
||||||
|
border-color: #8e44ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-selector:hover {
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #181818;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #6b6b6b;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: black;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.film-icon {
|
||||||
|
color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #6b6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-section {
|
||||||
|
padding: 16px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #e5e5e5;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-icon {
|
||||||
|
color: #6b6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-content {
|
||||||
|
background: #111;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
color: #9b9b9b;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-item {
|
||||||
|
background: #1a1a1a;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-label {
|
||||||
|
color: #6b6b6b;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-value {
|
||||||
|
color: white;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-value.mono {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-value.truncate {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-value.small {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="global-config-bar">
|
||||||
|
<span class="config-label">全局配置:</span>
|
||||||
|
|
||||||
|
<div class="config-item">
|
||||||
|
<span
|
||||||
|
:style="{
|
||||||
|
fontSize: '14px'
|
||||||
|
}"
|
||||||
|
>视频模型:</span
|
||||||
|
>
|
||||||
|
<n-select
|
||||||
|
v-model:value="selectedVideoModel"
|
||||||
|
:options="videoModelOptions"
|
||||||
|
size="small"
|
||||||
|
style="width: 150px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-item">
|
||||||
|
<span
|
||||||
|
:style="{
|
||||||
|
fontSize: '14px'
|
||||||
|
}"
|
||||||
|
>视频比例:</span
|
||||||
|
>
|
||||||
|
<n-select
|
||||||
|
v-model:value="selectedVideoRatio"
|
||||||
|
:options="videoRatioOptions"
|
||||||
|
size="small"
|
||||||
|
style="width: 100px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<n-input class="global-prompt-input" placeholder="添加全局后缀提示词..." />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { NIcon, NInput, NSelect } from 'naive-ui'
|
||||||
|
import { ChevronDownOutline } from '@vicons/ionicons5'
|
||||||
|
import { useThemeStore } from '@/renderer/src/stores'
|
||||||
|
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
const menuPrimaryColor = computed(() => {
|
||||||
|
return themeStore.menuPrimaryColor
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedVideoModel = ref('default')
|
||||||
|
const videoModelOptions = [
|
||||||
|
{ label: '默认模型', value: 'default' },
|
||||||
|
{ label: 'Kling 1.0', value: 'kling1' },
|
||||||
|
{ label: 'Kling 1.5', value: 'kling1.5' },
|
||||||
|
{ label: 'Runway Gen-3', value: 'runway' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const selectedVideoRatio = ref('16:9')
|
||||||
|
const videoRatioOptions = [
|
||||||
|
{ label: '16:9', value: '16:9' },
|
||||||
|
{ label: '9:16', value: '9:16' },
|
||||||
|
{ label: '1:1', value: '1:1' },
|
||||||
|
{ label: '4:3', value: '4:3' }
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 全局配置条 */
|
||||||
|
.global-config-bar {
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
padding: 0px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
height: 48px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-label {
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-prompt-input {
|
||||||
|
width: 256px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-prompt-input :deep(.n-input__input-el) {
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,370 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 网格模式 -->
|
||||||
|
<div v-if="mode === 'grid'" class="shot-card-grid" :class="{ active }">
|
||||||
|
<!-- 预览区 -->
|
||||||
|
<div class="preview-area">
|
||||||
|
<div v-if="shot.status === 'done'" class="preview-done">
|
||||||
|
<n-icon size="28" class="film-icon">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect>
|
||||||
|
<line x1="7" y1="2" x2="7" y2="22"></line>
|
||||||
|
<line x1="17" y1="2" x2="17" y2="22"></line>
|
||||||
|
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
<div class="preview-hover-overlay">
|
||||||
|
<n-icon size="24" class="play-icon-overlay">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</div>
|
||||||
|
<div class="success-badge">Success</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="shot.status === 'generating'" class="preview-generating">
|
||||||
|
<n-icon size="20" class="spin-icon">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="23 4 23 10 17 10"></polyline>
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
<span class="progress-text">{{ shot.progress }}%</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="preview-empty">
|
||||||
|
<n-icon size="20" class="video-icon">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||||
|
<polygon points="14 7 7 12 14 17 14 7"></polygon>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
<span class="empty-text">待生成</span>
|
||||||
|
</div>
|
||||||
|
<n-tag class="shot-id-badge" size="small" :bordered="false" type="primary">
|
||||||
|
{{ shot.id }}
|
||||||
|
</n-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 信息区 -->
|
||||||
|
<div class="info-area">
|
||||||
|
<div class="info-header">
|
||||||
|
<div class="type-badge" :class="shot.type === 'text' ? 'text-type' : 'image-type'">
|
||||||
|
{{ shot.type === 'text' ? '文生视频' : '图生视频' }}
|
||||||
|
</div>
|
||||||
|
<span class="duration-text">{{ shot.duration }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="content-text">{{ shot.content }}</p>
|
||||||
|
<div class="prompt-text">{{ shot.prompt }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作栏 -->
|
||||||
|
<div class="action-bar">
|
||||||
|
<n-button class="action-btn" text>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path
|
||||||
|
d="M12 1v6m0 6v6m8.66-15.66l-4.24 4.24m-4.24 4.24l-4.24 4.24m15.66-8.66l-4.24-4.24m-4.24-4.24l-4.24-4.24"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
<n-button class="action-btn" text>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
||||||
|
<path d="M2 17l10 5 10-5M2 12l10 5 10-5"></path>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
<n-button class="action-btn delete" text type="error">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
|
<path
|
||||||
|
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { NIcon, NButton, NTag } from 'naive-ui'
|
||||||
|
import { createHoverColor } from '@/renderer/src/common/color'
|
||||||
|
import { useThemeStore } from '@/renderer/src/stores'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
shot: any
|
||||||
|
index?: number
|
||||||
|
active: boolean
|
||||||
|
mode: 'grid' | 'list'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
console.log('shot props:', props.shot)
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'version-click': [shotId: number, versionId: string]
|
||||||
|
'open-comparison': [shotId: number]
|
||||||
|
generate: [shotId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
// // 获取当前激活的版本
|
||||||
|
// const activeVersion = computed(() => {
|
||||||
|
// if (!props.shot.history || props.shot.history.length === 0) {
|
||||||
|
// return null
|
||||||
|
// }
|
||||||
|
// return (
|
||||||
|
// props.shot.history.find((v: any) => v.versionId === props.shot.activeVersionId) ||
|
||||||
|
// props.shot.history[0]
|
||||||
|
// )
|
||||||
|
// })
|
||||||
|
|
||||||
|
const menuPrimaryColor = computed(() => {
|
||||||
|
return themeStore.menuPrimaryColor
|
||||||
|
})
|
||||||
|
const menuPrimaryShadow = computed(() => {
|
||||||
|
return themeStore.menuPrimaryShadow
|
||||||
|
})
|
||||||
|
const menuPrimaryHoverShadow = computed(() => {
|
||||||
|
return createHoverColor(themeStore.menuPrimaryShadow)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 网格模式样式 */
|
||||||
|
.shot-card-grid {
|
||||||
|
border: 1px solid v-bind(menuPrimaryShadow);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-card-grid:hover {
|
||||||
|
box-shadow: 0 0 15px v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-card-grid.active {
|
||||||
|
border-color: v-bind(menuPrimaryColor);
|
||||||
|
box-shadow: 0 0 15px v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-area {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-done {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-hover-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-card-grid:hover .preview-hover-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
color: white;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-generating {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin-icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-id-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-area {
|
||||||
|
padding: 12px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge.text-type {
|
||||||
|
border-color: rgba(59, 130, 246, 0.3);
|
||||||
|
color: #60a5fa;
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge.image-type {
|
||||||
|
border-color: rgba(249, 115, 22, 0.3);
|
||||||
|
color: #fb923c;
|
||||||
|
background: rgba(249, 115, 22, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-text {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-text {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
height: 36px;
|
||||||
|
border-top: 1px solid v-bind(menuPrimaryShadow);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid v-bind(menuPrimaryShadow);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,578 @@
|
|||||||
|
<template>
|
||||||
|
<div class="shot-card-list" :class="{ active }">
|
||||||
|
<!-- 序号列 -->
|
||||||
|
<div class="index-column">
|
||||||
|
<span class="index-number">{{ index + 1 }}</span>
|
||||||
|
<n-button class="check-btn" text>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="14">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="9 11 12 14 22 4"></polyline>
|
||||||
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 输入源列 - 使用独立组件 -->
|
||||||
|
<SourceInputColumn
|
||||||
|
:type="shot.inputType || 'text'"
|
||||||
|
:content="shot.content"
|
||||||
|
:start-frame-url="shot.startFrame"
|
||||||
|
:end-frame-url="shot.endFrame"
|
||||||
|
@update:type="handleTypeUpdate"
|
||||||
|
@update:content="handleContentUpdate"
|
||||||
|
@update:start-frame="handleStartFrameUpdate"
|
||||||
|
@update:end-frame="handleEndFrameUpdate"
|
||||||
|
@update:video="handleVideoUpdate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Prompt列 -->
|
||||||
|
<div class="prompt-column">
|
||||||
|
<div class="prompt-header">
|
||||||
|
<span class="prompt-label">漫剧 Prompt</span>
|
||||||
|
<n-icon size="12" class="magic-icon">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
||||||
|
<path d="M2 17l10 5 10-5M2 12l10 5 10-5"></path>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</div>
|
||||||
|
<n-input
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{
|
||||||
|
minRows: 8,
|
||||||
|
maxRows: 8
|
||||||
|
}"
|
||||||
|
placeholder="输入分镜描述..."
|
||||||
|
></n-input>
|
||||||
|
<div class="prompt-footer">
|
||||||
|
<span class="param-badge">{{ activeVersion?.duration || '5s' }}</span>
|
||||||
|
<span class="param-badge">{{ activeVersion?.ratio || '16:9' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 预览列 -->
|
||||||
|
<div class="preview-column">
|
||||||
|
<div class="preview-box">
|
||||||
|
<div v-if="activeVersion && activeVersion.status === 'done'" class="preview-content">
|
||||||
|
<n-icon size="24" class="film-icon-list">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect>
|
||||||
|
<line x1="7" y1="2" x2="7" y2="22"></line>
|
||||||
|
<line x1="17" y1="2" x2="17" y2="22"></line>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
<div class="preview-hover">
|
||||||
|
<n-icon size="24" class="play-icon-list">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</div>
|
||||||
|
<div class="version-badge">{{ shot.activeVersionId }}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="activeVersion && activeVersion.status === 'generating'"
|
||||||
|
class="preview-generating-list"
|
||||||
|
>
|
||||||
|
<n-icon size="20" class="spin-icon">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="23 4 23 10 17 10"></polyline>
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
<span class="progress-text">{{ activeVersion.progress || 0 }}%</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="waiting-text">等待生成</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 版本历史横向滚动区 -->
|
||||||
|
<div v-if="shot.history && shot.history.length > 0" class="version-history">
|
||||||
|
<div ref="versionScrollContainer" class="version-scroll-container">
|
||||||
|
<div
|
||||||
|
v-for="version in shot.history"
|
||||||
|
:key="version.versionId"
|
||||||
|
class="version-thumbnail"
|
||||||
|
:class="{
|
||||||
|
active: shot.activeVersionId === version.versionId,
|
||||||
|
failed: version.status === 'failed',
|
||||||
|
generating: version.status === 'generating'
|
||||||
|
}"
|
||||||
|
@click.stop="$emit('version-click', shot.id, version.versionId)"
|
||||||
|
>
|
||||||
|
<div class="version-preview">
|
||||||
|
<n-icon v-if="version.status === 'done'" size="14" class="version-icon">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
<n-icon v-else-if="version.status === 'failed'" size="14" class="version-icon-fail">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
<n-icon v-else size="14" class="version-icon spin-icon">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="23 4 23 10 17 10"></polyline>
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</div>
|
||||||
|
<span class="version-label">{{ version.versionId }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-actions">
|
||||||
|
<n-button class="generate-btn" @click.stop="$emit('generate', shot.id)" type="primary">
|
||||||
|
{{ shot.history && shot.history.length > 0 ? '重绘' : '生成' }}
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="shot.history && shot.history.length >= 2"
|
||||||
|
class="compare-btn"
|
||||||
|
type="warning"
|
||||||
|
@click.stop="$emit('open-comparison', shot.id)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="12">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="17 11 21 7 17 3"></polyline>
|
||||||
|
<line x1="21" y1="7" x2="9" y2="7"></line>
|
||||||
|
<polyline points="7 21 3 17 7 13"></polyline>
|
||||||
|
<line x1="15" y1="17" x2="3" y2="17"></line>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
版本对比
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, nextTick } from 'vue'
|
||||||
|
import { NIcon, NButton } from 'naive-ui'
|
||||||
|
import SourceInputColumn from './SourceInputColumn.vue'
|
||||||
|
import { useThemeStore } from '@/renderer/src/stores'
|
||||||
|
import { createHoverColor } from '@/renderer/src/common/color'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
shot: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
index: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits('version-click', 'open-comparison', 'generate', 'update:shot')
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
// 获取当前激活的版本
|
||||||
|
const activeVersion = computed(() => {
|
||||||
|
if (!props.shot.history || props.shot.history.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
props.shot.history.find((v) => v.versionId === props.shot.activeVersionId) ||
|
||||||
|
props.shot.history[0]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新处理函数
|
||||||
|
const handleTypeUpdate = (type) => {
|
||||||
|
emit('update:shot', { ...props.shot, inputType: type })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContentUpdate = (content) => {
|
||||||
|
emit('update:shot', { ...props.shot, content })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartFrameUpdate = (url) => {
|
||||||
|
emit('update:shot', { ...props.shot, startFrame: url })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEndFrameUpdate = (url) => {
|
||||||
|
emit('update:shot', { ...props.shot, endFrame: url })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVideoUpdate = (file) => {
|
||||||
|
emit('update:shot', { ...props.shot, videoFile: file })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePromptUpdate = (e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
emit('update:shot', { ...props.shot, prompt: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 版本历史滚动容器引用
|
||||||
|
const versionScrollContainer = ref(null)
|
||||||
|
|
||||||
|
// 初始加载时滚动到当前选择的版本
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.shot.activeVersionId && props.shot.history && props.shot.history.length > 0) {
|
||||||
|
nextTick(() => {
|
||||||
|
const activeIndex = props.shot.history.findIndex(
|
||||||
|
(v) => v.versionId === props.shot.activeVersionId
|
||||||
|
)
|
||||||
|
if (activeIndex !== -1 && versionScrollContainer.value) {
|
||||||
|
const thumbnailWidth = 70 // 缩略图宽度
|
||||||
|
const gap = 8 // 间距
|
||||||
|
const scrollLeft = activeIndex * (thumbnailWidth + gap)
|
||||||
|
versionScrollContainer.value.scrollLeft = scrollLeft
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const menuPrimaryColor = computed(() => {
|
||||||
|
return themeStore.menuPrimaryColor
|
||||||
|
})
|
||||||
|
const menuPrimaryShadow = computed(() => {
|
||||||
|
return themeStore.menuPrimaryShadow
|
||||||
|
})
|
||||||
|
const menuPrimaryHoverShadow = computed(() => {
|
||||||
|
return createHoverColor(themeStore.menuPrimaryShadow)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shot-card-list {
|
||||||
|
display: flex;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid v-bind(menuPrimaryColor);
|
||||||
|
transition: all 0.2s;
|
||||||
|
min-height: 280px;
|
||||||
|
max-height: 500px;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-card-list:hover {
|
||||||
|
box-shadow: 0 0 15px v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-card-list.active {
|
||||||
|
border: 2px solid v-bind(menuPrimaryColor);
|
||||||
|
box-shadow: 0 0 15px v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-column {
|
||||||
|
width: 40px;
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-number {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-column {
|
||||||
|
flex: 1;
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-label {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-textarea {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
outline: none;
|
||||||
|
border: 1px solid #333;
|
||||||
|
resize: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
background-color: v-bind(menuPrimaryShadow);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-column {
|
||||||
|
width: 400px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-box {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 140px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-hover {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-card-list:hover .preview-hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waiting-text {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: v-bind(menuPrimaryColor);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-generating-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin-icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 版本历史区域 */
|
||||||
|
.version-history {
|
||||||
|
height: 70px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-scroll-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
height: 100%;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-thumbnail {
|
||||||
|
min-width: 70px;
|
||||||
|
height: 45px;
|
||||||
|
border: 2px solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-thumbnail:hover {
|
||||||
|
background: v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-thumbnail.active {
|
||||||
|
box-shadow: 0 2px 15px v-bind(menuPrimaryHoverShadow);
|
||||||
|
background-color: v-bind(menuPrimaryShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-thumbnail.failed {
|
||||||
|
border-color: #ef4444;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-thumbnail.generating {
|
||||||
|
border-color: #f39c12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-icon {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-icon-fail {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-thumbnail.active .version-label {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 预览操作按钮 */
|
||||||
|
.preview-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-btn {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-btn {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,756 @@
|
|||||||
|
<template>
|
||||||
|
<div class="source-column">
|
||||||
|
<!-- 类型切换标签 -->
|
||||||
|
<div class="type-tabs">
|
||||||
|
<n-button
|
||||||
|
class="type-tab"
|
||||||
|
:class="{ active: currentType === 'text' }"
|
||||||
|
text
|
||||||
|
@click="switchType('text')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="4 7 4 4 20 4 20 7"></polyline>
|
||||||
|
<line x1="9" y1="20" x2="15" y2="20"></line>
|
||||||
|
<line x1="12" y1="4" x2="12" y2="20"></line>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
文本
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
class="type-tab"
|
||||||
|
:class="{ active: currentType === 'image' }"
|
||||||
|
text
|
||||||
|
@click="switchType('image')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||||
|
<polyline points="21 15 16 10 5 21"></polyline>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
图片
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
class="type-tab"
|
||||||
|
:class="{ active: currentType === 'video' }"
|
||||||
|
text
|
||||||
|
@click="switchType('video')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
视频
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文本输入模式 -->
|
||||||
|
<div v-if="currentType === 'text'" class="input-area">
|
||||||
|
<n-input
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{
|
||||||
|
minRows: 8,
|
||||||
|
maxRows: 8
|
||||||
|
}"
|
||||||
|
placeholder="输入分镜描述..."
|
||||||
|
></n-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图片模式 - 首尾帧 -->
|
||||||
|
<div v-else-if="currentType === 'image'" class="input-area">
|
||||||
|
<div class="image-frames">
|
||||||
|
<!-- 首帧 -->
|
||||||
|
<div class="frame-item">
|
||||||
|
<div class="frame-label">首帧</div>
|
||||||
|
<div
|
||||||
|
class="frame-upload-box"
|
||||||
|
:class="{ 'has-image': startFrame }"
|
||||||
|
@click="!startFrame && selectStartFrame()"
|
||||||
|
>
|
||||||
|
<div v-if="startFrame" class="frame-preview-wrapper">
|
||||||
|
<n-image
|
||||||
|
:src="startFrame"
|
||||||
|
class="frame-preview"
|
||||||
|
object-fit="contain"
|
||||||
|
:preview-disabled="true"
|
||||||
|
:img-props="{ style: { width: '100%', height: '100%', objectFit: 'contain' } }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="upload-placeholder">
|
||||||
|
<n-icon size="20">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="17 8 12 3 7 8"></polyline>
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
<span>上传首帧</span>
|
||||||
|
</div>
|
||||||
|
<n-button
|
||||||
|
v-if="startFrame"
|
||||||
|
class="open-folder-btn"
|
||||||
|
text
|
||||||
|
@click.stop="openStartFrameFolder"
|
||||||
|
title="打开文件位置"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="14">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="startFrame"
|
||||||
|
class="clear-btn"
|
||||||
|
type="error"
|
||||||
|
text
|
||||||
|
@click.stop="clearStartFrame"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="20">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 尾帧 -->
|
||||||
|
<div class="frame-item">
|
||||||
|
<div class="frame-label">尾帧</div>
|
||||||
|
<div
|
||||||
|
class="frame-upload-box"
|
||||||
|
:class="{ 'has-image': endFrame }"
|
||||||
|
@click="!endFrame && selectEndFrame()"
|
||||||
|
>
|
||||||
|
<div v-if="endFrame" class="frame-preview-wrapper">
|
||||||
|
<n-image
|
||||||
|
:src="endFrame"
|
||||||
|
class="frame-preview"
|
||||||
|
object-fit="contain"
|
||||||
|
:preview-disabled="true"
|
||||||
|
:img-props="{ style: { width: '100%', height: '100%', objectFit: 'contain' } }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="upload-placeholder">
|
||||||
|
<n-icon size="20">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="17 8 12 3 7 8"></polyline>
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
<span>上传尾帧</span>
|
||||||
|
</div>
|
||||||
|
<n-button
|
||||||
|
v-if="endFrame"
|
||||||
|
class="open-folder-btn"
|
||||||
|
text
|
||||||
|
@click.stop="openEndFrameFolder"
|
||||||
|
title="打开文件位置"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="12">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="endFrame"
|
||||||
|
class="clear-btn"
|
||||||
|
type="error"
|
||||||
|
text
|
||||||
|
@click.stop="clearEndFrame"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="20">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="image-info">
|
||||||
|
<span class="info-text">{{ frameInfoText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视频模式 -->
|
||||||
|
<div v-else-if="currentType === 'video'" class="input-area">
|
||||||
|
<div v-if="videoFile" class="video-preview-container">
|
||||||
|
<div class="video-preview-wrapper">
|
||||||
|
<video :src="videoUrl" class="video-preview" controls @click.stop>
|
||||||
|
您的浏览器不支持视频播放
|
||||||
|
</video>
|
||||||
|
<n-button class="open-folder-btn" text @click.stop="openVideoFolder" title="打开文件位置">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="14">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
<n-button class="clear-btn" type="error" text @click.stop="clearVideo" title="删除视频">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="20">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="video-upload-area" @click="selectVideo">
|
||||||
|
<div class="upload-placeholder-large">
|
||||||
|
<n-icon size="32">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="17 8 12 3 7 8"></polyline>
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
<span class="upload-text">点击上传视频</span>
|
||||||
|
<span class="upload-hint">支持 MP4, MOV, AVI 格式</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 隐藏的文件输入 -->
|
||||||
|
<input
|
||||||
|
ref="startFrameInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleStartFrameChange"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="endFrameInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleEndFrameChange"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="videoInput"
|
||||||
|
type="file"
|
||||||
|
accept="video/*"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleVideoChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { NIcon, NImage, NButton } from 'naive-ui'
|
||||||
|
import { useThemeStore } from '@/renderer/src/stores'
|
||||||
|
import { createHoverColor } from '@/renderer/src/common/color'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type?: 'text' | 'image' | 'video'
|
||||||
|
content?: string
|
||||||
|
startFrameUrl?: string
|
||||||
|
endFrameUrl?: string
|
||||||
|
videoUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
type: 'text',
|
||||||
|
content: '',
|
||||||
|
startFrameUrl: '',
|
||||||
|
endFrameUrl: '',
|
||||||
|
videoUrl: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:type': [type: 'text' | 'image' | 'video']
|
||||||
|
'update:content': [content: string]
|
||||||
|
'update:startFrame': [url: string]
|
||||||
|
'update:endFrame': [url: string]
|
||||||
|
'update:video': [file: File | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const currentType = ref<'text' | 'image' | 'video'>(props.type)
|
||||||
|
// const textContent = ref(props.content)
|
||||||
|
const startFrame = ref(props.startFrameUrl)
|
||||||
|
const endFrame = ref(props.endFrameUrl)
|
||||||
|
const videoFile = ref<File | null>(null)
|
||||||
|
const videoUrl = ref('')
|
||||||
|
const startFramePath = ref('')
|
||||||
|
const endFramePath = ref('')
|
||||||
|
const videoFilePath = ref('')
|
||||||
|
|
||||||
|
const startFrameInput = ref<HTMLInputElement>()
|
||||||
|
const endFrameInput = ref<HTMLInputElement>()
|
||||||
|
const videoInput = ref<HTMLInputElement>()
|
||||||
|
|
||||||
|
// 切换输入类型
|
||||||
|
const switchType = (type: 'text' | 'image' | 'video') => {
|
||||||
|
currentType.value = type
|
||||||
|
emit('update:type', type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// // 文本输入处理
|
||||||
|
// const handleTextInput = (e: Event) => {
|
||||||
|
// const value = (e.target as HTMLTextAreaElement).value
|
||||||
|
// textContent.value = value
|
||||||
|
// emit('update:content', value)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 首帧选择
|
||||||
|
const selectStartFrame = () => {
|
||||||
|
startFrameInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartFrameChange = (e: Event) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (file) {
|
||||||
|
// 保存文件路径
|
||||||
|
startFramePath.value = (file as any).path || ''
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (event) => {
|
||||||
|
startFrame.value = event.target?.result as string
|
||||||
|
emit('update:startFrame', startFrame.value)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearStartFrame = () => {
|
||||||
|
startFrame.value = ''
|
||||||
|
startFramePath.value = ''
|
||||||
|
emit('update:startFrame', '')
|
||||||
|
if (startFrameInput.value) {
|
||||||
|
startFrameInput.value.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尾帧选择
|
||||||
|
const selectEndFrame = () => {
|
||||||
|
endFrameInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEndFrameChange = (e: Event) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (file) {
|
||||||
|
// 保存文件路径
|
||||||
|
endFramePath.value = (file as any).path || ''
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (event) => {
|
||||||
|
endFrame.value = event.target?.result as string
|
||||||
|
emit('update:endFrame', endFrame.value)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearEndFrame = () => {
|
||||||
|
endFrame.value = ''
|
||||||
|
endFramePath.value = ''
|
||||||
|
emit('update:endFrame', '')
|
||||||
|
if (endFrameInput.value) {
|
||||||
|
endFrameInput.value.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频选择
|
||||||
|
const selectVideo = () => {
|
||||||
|
videoInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVideoChange = (e: Event) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (file) {
|
||||||
|
videoFile.value = file
|
||||||
|
// 保存文件路径
|
||||||
|
videoFilePath.value = (file as any).path || ''
|
||||||
|
// 创建视频预览 URL
|
||||||
|
videoUrl.value = URL.createObjectURL(file)
|
||||||
|
emit('update:video', file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearVideo = () => {
|
||||||
|
// 释放之前的 URL 对象
|
||||||
|
if (videoUrl.value) {
|
||||||
|
URL.revokeObjectURL(videoUrl.value)
|
||||||
|
videoUrl.value = ''
|
||||||
|
}
|
||||||
|
videoFile.value = null
|
||||||
|
videoFilePath.value = ''
|
||||||
|
emit('update:video', null)
|
||||||
|
if (videoInput.value) {
|
||||||
|
videoInput.value.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// // 格式化文件大小
|
||||||
|
// const formatFileSize = (bytes: number) => {
|
||||||
|
// if (bytes < 1024) return bytes + ' B'
|
||||||
|
// if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
// return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 图片帧信息文本
|
||||||
|
const frameInfoText = computed(() => {
|
||||||
|
if (startFrame.value && endFrame.value) {
|
||||||
|
return '✓ 首尾帧已设置'
|
||||||
|
} else if (startFrame.value) {
|
||||||
|
return '首帧已设置,尾帧待上传'
|
||||||
|
} else if (endFrame.value) {
|
||||||
|
return '尾帧已设置,首帧待上传'
|
||||||
|
}
|
||||||
|
return '请上传首帧和尾帧图片'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 打开文件夹函数
|
||||||
|
const openStartFrameFolder = () => {
|
||||||
|
if (startFramePath.value) {
|
||||||
|
// window.electron.ipcRenderer.send('open-file-location', startFramePath.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEndFrameFolder = () => {
|
||||||
|
if (endFramePath.value) {
|
||||||
|
// window.electron.ipcRenderer.send('open-file-location', endFramePath.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openVideoFolder = () => {
|
||||||
|
if (videoFilePath.value) {
|
||||||
|
// window.electron.ipcRenderer.send('open-file-location', videoFilePath.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const menuPrimaryColor = computed(() => {
|
||||||
|
return themeStore.menuPrimaryColor
|
||||||
|
})
|
||||||
|
// const menuPrimaryShadow = computed(() => {
|
||||||
|
// return themeStore.menuPrimaryShadow
|
||||||
|
// })
|
||||||
|
const menuPrimaryHoverShadow = computed(() => {
|
||||||
|
return createHoverColor(themeStore.menuPrimaryShadow)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.source-column {
|
||||||
|
width: 25%;
|
||||||
|
min-width: 200px;
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tab {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid v-bind(menuPrimaryColor);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: auto !important;
|
||||||
|
min-height: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tab.active {
|
||||||
|
background: v-bind(menuPrimaryColor) !important;
|
||||||
|
border-color: v-bind(menuPrimaryColor) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
/* 图片帧样式 */
|
||||||
|
.image-frames {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-upload-box {
|
||||||
|
flex: 1;
|
||||||
|
border: 2px dashed v-bind(menuPrimaryColor);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 140px;
|
||||||
|
max-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-upload-box:hover {
|
||||||
|
border-color: v-bind(menuPrimaryHoverShadow);
|
||||||
|
background: v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-upload-box.has-image {
|
||||||
|
border-style: solid;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-preview-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-preview {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-preview :deep(img) {
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-placeholder span {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-folder-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 4px;
|
||||||
|
background: v-bind(menuPrimaryHoverShadow);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-folder-btn:hover {
|
||||||
|
background: v-bind(menuPrimaryColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-info {
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 视频上传样式 */
|
||||||
|
.video-upload-area {
|
||||||
|
height: 210px;
|
||||||
|
border: 2px dashed v-bind(menuPrimaryHoverShadow);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-placeholder-large {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: v-bind(menuPrimaryColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: v-bind(menuPrimaryColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
font-size: 10px;
|
||||||
|
color: v-bind(menuPrimaryColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 视频预览样式 */
|
||||||
|
.video-preview-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-wrapper {
|
||||||
|
height: 210px;
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid v-bind(menuPrimaryHoverShadow);
|
||||||
|
border-radius: 6px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,181 @@
|
|||||||
|
<template>
|
||||||
|
<div class="playback-bar">
|
||||||
|
<div class="playback-left">
|
||||||
|
<n-button class="play-btn" circle @click="handleTogglePlay">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="14" class="play-icon">
|
||||||
|
<PlayOutline v-if="!isPlaying" />
|
||||||
|
<PauseOutline v-else />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
<span class="preview-text">预览全片</span>
|
||||||
|
</div>
|
||||||
|
<!-- 时间轴 -->
|
||||||
|
<div class="timeline-track">
|
||||||
|
<div
|
||||||
|
v-for="(shot, idx) in shots"
|
||||||
|
:key="idx"
|
||||||
|
class="timeline-item"
|
||||||
|
:class="{ active: activeId === shot.id }"
|
||||||
|
@click="$emit('update:activeId', shot.id)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="shot.generateVideoPath == undefined || isEmpty(shot.generateVideoPath)"
|
||||||
|
class="ungenerated-text"
|
||||||
|
>
|
||||||
|
未生成
|
||||||
|
</div>
|
||||||
|
<video
|
||||||
|
v-else
|
||||||
|
:src="shot.generateVideoPath"
|
||||||
|
class="timeline-video-preview"
|
||||||
|
muted
|
||||||
|
preload="metadata"
|
||||||
|
></video>
|
||||||
|
<div v-if="activeId === shot.id" class="active-indicator"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { NIcon, NButton } from 'naive-ui'
|
||||||
|
import { PlayOutline, PauseOutline } from '@vicons/ionicons5'
|
||||||
|
import { useThemeStore } from '@/renderer/src/stores'
|
||||||
|
import { createHoverColor } from '@/renderer/src/common/color'
|
||||||
|
import { isEmpty } from 'lodash'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
shots: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
activeId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
isPlaying: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:activeId', 'toggle-play'])
|
||||||
|
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
const handleTogglePlay = () => {
|
||||||
|
emit('toggle-play')
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuPrimaryColor = computed(() => {
|
||||||
|
return themeStore.menuPrimaryColor
|
||||||
|
})
|
||||||
|
const menuPrimaryShadow = computed(() => {
|
||||||
|
return themeStore.menuPrimaryShadow
|
||||||
|
})
|
||||||
|
const menuPrimaryHoverShadow = computed(() => {
|
||||||
|
return createHoverColor(themeStore.menuPrimaryShadow)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 底部播放控制栏 */
|
||||||
|
.playback-bar {
|
||||||
|
height: 64px;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 128px;
|
||||||
|
border-right: 1px solid #222;
|
||||||
|
padding-right: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: v-bind(menuPrimaryShadow);
|
||||||
|
box-shadow: 0 2px 8px v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-icon {
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-text {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-track {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
height: 40px;
|
||||||
|
width: 64px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid v-bind(menuPrimaryColor);
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:hover {
|
||||||
|
border-color: v-bind(menuPrimaryColor);
|
||||||
|
box-shadow: 0 2px 8px v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.active {
|
||||||
|
border-color: v-bind(menuPrimaryColor);
|
||||||
|
box-shadow: 0 2px 8px v-bind(menuPrimaryHoverShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: v-bind(menuPrimaryShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ungenerated-text {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-video-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,216 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 版本对比模态框 -->
|
||||||
|
<div v-if="isOpen" class="comparison-modal-overlay" @click.self="$emit('close')">
|
||||||
|
<div class="comparison-modal-container">
|
||||||
|
<!-- 模态框头部 -->
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title">
|
||||||
|
<n-icon size="20" class="title-icon">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
版本对比 - 分镜 {{ shot.id }} ({{ shot.content.substring(0, 15) }}...)
|
||||||
|
</h2>
|
||||||
|
<button class="close-btn" @click="$emit('close')">
|
||||||
|
<n-icon size="20">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 核心对比内容区 -->
|
||||||
|
<div class="comparison-content">
|
||||||
|
<!-- 版本 A -->
|
||||||
|
<ComparisonPanel
|
||||||
|
v-model:selectedVersion="selectedVersionA"
|
||||||
|
:version="versionA"
|
||||||
|
:availableVersions="comparisonHistory"
|
||||||
|
title="版本 A"
|
||||||
|
:isA="true"
|
||||||
|
:shot="shot"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- VS 分隔线 -->
|
||||||
|
<div class="vs-divider">
|
||||||
|
<div class="vs-badge">VS</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 版本 B -->
|
||||||
|
<ComparisonPanel
|
||||||
|
v-model:selectedVersion="selectedVersionB"
|
||||||
|
:version="versionB"
|
||||||
|
:availableVersions="comparisonHistory"
|
||||||
|
title="版本 B"
|
||||||
|
:isA="false"
|
||||||
|
:shot="shot"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { NIcon } from 'naive-ui'
|
||||||
|
import ComparisonPanel from './ComparisonPanel.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isOpen: { type: Boolean, required: true },
|
||||||
|
shot: { type: Object, required: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['close'])
|
||||||
|
|
||||||
|
// 获取所有可对比的版本(排除生成中的)
|
||||||
|
const comparisonHistory = computed(() => {
|
||||||
|
// props.shot.history.filter((v) => v.status !== 'generating')
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 默认选择最新的两个版本
|
||||||
|
const selectedVersionA = ref(
|
||||||
|
comparisonHistory.value.length > 0 ? comparisonHistory.value[0].versionId : null
|
||||||
|
)
|
||||||
|
const selectedVersionB = ref(
|
||||||
|
comparisonHistory.value.length > 1
|
||||||
|
? comparisonHistory.value[1].versionId
|
||||||
|
: comparisonHistory.value.length > 0
|
||||||
|
? comparisonHistory.value[0].versionId
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
|
||||||
|
const versionA = computed(() =>
|
||||||
|
comparisonHistory.value.find((v) => v.versionId === selectedVersionA.value)
|
||||||
|
)
|
||||||
|
const versionB = computed(() =>
|
||||||
|
comparisonHistory.value.find((v) => v.versionId === selectedVersionB.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 监听 shot 变化,重置选择
|
||||||
|
watch(
|
||||||
|
() => props.shot,
|
||||||
|
() => {
|
||||||
|
selectedVersionA.value =
|
||||||
|
comparisonHistory.value.length > 0 ? comparisonHistory.value[0].versionId : null
|
||||||
|
selectedVersionB.value =
|
||||||
|
comparisonHistory.value.length > 1
|
||||||
|
? comparisonHistory.value[1].versionId
|
||||||
|
: comparisonHistory.value.length > 0
|
||||||
|
? comparisonHistory.value[0].versionId
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.comparison-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-modal-container {
|
||||||
|
background: #1a1a1a;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1800px;
|
||||||
|
height: 95%;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
background: #222;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-icon {
|
||||||
|
color: #8e44ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #9b9b9b;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: white;
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs-divider {
|
||||||
|
width: 4px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 16px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs-badge {
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #8e44ad;
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
201
src/renderer/src/components/ComicDrama/preview.html
Normal file
201
src/renderer/src/components/ComicDrama/preview.html
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>漫剧模块 - 界面预览</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #141414;
|
||||||
|
color: #e5e5e5;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
max-width: 800px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #8e44ad;
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #9b9b9b;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list {
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
background: #252525;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #8e44ad;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-content h3 {
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-content p {
|
||||||
|
color: #9b9b9b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 32px;
|
||||||
|
background: #8e44ad;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 0 15px rgba(142, 68, 173, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-btn:hover {
|
||||||
|
background: #9b59b6;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 20px rgba(142, 68, 173, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-stack {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 32px;
|
||||||
|
border-top: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: #252525;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9b9b9b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="preview-card">
|
||||||
|
<h1>✨ 漫剧模块已创建</h1>
|
||||||
|
<p class="subtitle">高度还原 Sora UI 设计风格的漫剧视频生成管理系统</p>
|
||||||
|
|
||||||
|
<div class="feature-list">
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon">1</div>
|
||||||
|
<div class="feature-content">
|
||||||
|
<h3>项目管理视图</h3>
|
||||||
|
<p>左侧项目列表 + 右侧子项目网格,支持快速导航和项目切换</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon">2</div>
|
||||||
|
<div class="feature-content">
|
||||||
|
<h3>工作台视图</h3>
|
||||||
|
<p>支持列表/网格双视图模式,全局配置,分镜管理,底部播放控制栏</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon">3</div>
|
||||||
|
<div class="feature-content">
|
||||||
|
<h3>分镜卡片组件</h3>
|
||||||
|
<p>文生视频/图生视频支持,实时生成状态,进度跟踪,快速编辑</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="access-btn" onclick="alert('请在应用中访问: /#/comic-drama\n或添加导航项指向该路由')">
|
||||||
|
查看访问方式
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="tech-stack">
|
||||||
|
<div class="tech-badges">
|
||||||
|
<span class="badge">Vue 3</span>
|
||||||
|
<span class="badge">TypeScript</span>
|
||||||
|
<span class="badge">Composition API</span>
|
||||||
|
<span class="badge">Naive UI</span>
|
||||||
|
<span class="badge">Sora UI Design</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
console.log('%c漫剧模块创建成功!', 'color: #8e44ad; font-size: 20px; font-weight: bold;');
|
||||||
|
console.log('%c路由路径: /comic-drama', 'color: #9b9b9b; font-size: 14px;');
|
||||||
|
console.log('%c组件位置: src/renderer/src/components/ComicDrama/', 'color: #9b9b9b; font-size: 14px;');
|
||||||
|
console.log('\n文件结构:');
|
||||||
|
console.log('ComicDrama/');
|
||||||
|
console.log('├── ComicDramaHome.vue');
|
||||||
|
console.log('├── ComicDramaInfo/');
|
||||||
|
console.log('│ ├── ComicDramaProjectSelection.vue');
|
||||||
|
console.log('│ ├── ComicDramaWorkspace.vue');
|
||||||
|
console.log('│ └── ComicDramaShotCard.vue');
|
||||||
|
console.log('├── README.md');
|
||||||
|
console.log('└── INTEGRATION.md');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,251 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>漫剧模块 - 版本管理系统</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #141414 0%, #1a1a1a 100%);
|
||||||
|
color: #e5e5e5;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 48px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #8e44ad;
|
||||||
|
font-size: 36px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: #9b9b9b;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
.features-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
.feature-card {
|
||||||
|
background: #252525;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.feature-card:hover {
|
||||||
|
border-color: #8e44ad;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 24px rgba(142, 68, 173, 0.2);
|
||||||
|
}
|
||||||
|
.feature-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: #8e44ad;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.feature-title {
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.feature-desc {
|
||||||
|
color: #9b9b9b;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.highlight-section {
|
||||||
|
background: linear-gradient(135deg, #8e44ad 0%, #9b59b6 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.highlight-title {
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.highlight-desc {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.code-block {
|
||||||
|
background: #111;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9b9b9b;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: #252525;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9b9b9b;
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.badge.new {
|
||||||
|
background: #8e44ad;
|
||||||
|
color: white;
|
||||||
|
border-color: #8e44ad;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>✨ 漫剧版本管理系统</h1>
|
||||||
|
<p class="subtitle">完整还原 Sora UI 设计,专业的视频生成工作流</p>
|
||||||
|
|
||||||
|
<div class="highlight-section">
|
||||||
|
<div class="highlight-title">🎯 核心升级</div>
|
||||||
|
<div class="highlight-desc">
|
||||||
|
基于 React Sora UI 设计,实现了完整的版本管理系统,支持多输入源、版本历史跟踪和智能对比功能
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="features-grid">
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📝</div>
|
||||||
|
<div class="feature-title">多输入源支持</div>
|
||||||
|
<div class="feature-desc">
|
||||||
|
支持纯文本、纯图片/视频、文本+图片混合输入,灵活应对各种创作场景
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🔄</div>
|
||||||
|
<div class="feature-title">版本历史管理</div>
|
||||||
|
<div class="feature-desc">
|
||||||
|
自动记录每次生成的版本,支持失败/成功/生成中状态,可随时切换查看不同版本
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">⚖️</div>
|
||||||
|
<div class="feature-title">智能版本对比</div>
|
||||||
|
<div class="feature-desc">
|
||||||
|
并排对比任意两个版本,查看 Prompt 差异、参数设置和生成结果
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🎨</div>
|
||||||
|
<div class="feature-title">专业 UI 设计</div>
|
||||||
|
<div class="feature-desc">
|
||||||
|
暗黑主题配色,紫色主题色,流畅动画,完美还原 Sora UI 的专业感
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📊</div>
|
||||||
|
<div class="feature-title">版本缩略图</div>
|
||||||
|
<div class="feature-desc">
|
||||||
|
横向滚动的版本缩略图,快速预览历史版本,一键切换采用版本
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">💾</div>
|
||||||
|
<div class="feature-title">完整元数据</div>
|
||||||
|
<div class="feature-desc">
|
||||||
|
记录每个版本的时间戳、备注、参数设置,便于追溯和优化
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="code-block">
|
||||||
|
// 数据结构示例
|
||||||
|
{
|
||||||
|
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: '修改镜头角度'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 32px;">
|
||||||
|
<div style="margin-bottom: 16px;">
|
||||||
|
<span class="badge new">新增</span>
|
||||||
|
<span class="badge">VersionComparisonModal.vue</span>
|
||||||
|
<span class="badge">ComparisonPanel.vue</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 16px;">
|
||||||
|
<span class="badge">已更新</span>
|
||||||
|
<span class="badge">ComicDramaWorkspace.vue</span>
|
||||||
|
<span class="badge">数据结构升级</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="badge">Vue 3</span>
|
||||||
|
<span class="badge">TypeScript</span>
|
||||||
|
<span class="badge">Composition API</span>
|
||||||
|
<span class="badge">Naive UI</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
console.log('%c🎉 漫剧版本管理系统升级完成!', 'color: #8e44ad; font-size: 20px; font-weight: bold;');
|
||||||
|
console.log('%c新增组件:', 'color: #9b9b9b; font-size: 14px;');
|
||||||
|
console.log(' - VersionComparisonModal.vue (版本对比模态框)');
|
||||||
|
console.log(' - ComparisonPanel.vue (对比面板组件)');
|
||||||
|
console.log('%c已更新:', 'color: #9b9b9b; font-size: 14px;');
|
||||||
|
console.log(' - ComicDramaWorkspace.vue (工作台)');
|
||||||
|
console.log(' - 数据结构升级为版本管理模式');
|
||||||
|
console.log('%c路由路径: /comic-drama', 'color: #8e44ad; font-size: 14px;');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
712
src/renderer/src/components/CopyWriting/AIChatInterface.vue
Normal file
712
src/renderer/src/components/CopyWriting/AIChatInterface.vue
Normal file
@ -0,0 +1,712 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-chat-interface">
|
||||||
|
<!-- 顶部分类显示 -->
|
||||||
|
<div class="chat-header">
|
||||||
|
<div class="category-info">
|
||||||
|
<n-icon size="20" :color="themeStore.menuPrimaryColor">
|
||||||
|
<ChatbubblesOutline />
|
||||||
|
</n-icon>
|
||||||
|
<span class="category-name">{{ currentCategory?.name || t('未选择分类') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-info">
|
||||||
|
<n-text depth="3" style="font-size: 12px">
|
||||||
|
{{ currentCategory?.description || currentCategory?.name }}
|
||||||
|
</n-text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 对话区域 -->
|
||||||
|
<div class="chat-messages" ref="messagesContainer">
|
||||||
|
<div v-if="messages.length === 0" class="empty-state">
|
||||||
|
<n-icon size="48" :color="themeStore.menuPrimaryColor">
|
||||||
|
<ChatbubbleEllipsesOutline />
|
||||||
|
</n-icon>
|
||||||
|
<n-text depth="3" style="margin-top: 16px">
|
||||||
|
{{ t('开始与AI对话,获取创作灵感') }}
|
||||||
|
</n-text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(message, index) in messages"
|
||||||
|
:key="index"
|
||||||
|
class="message-wrapper"
|
||||||
|
:class="message.role"
|
||||||
|
>
|
||||||
|
<!-- 头像 -->
|
||||||
|
<div class="message-avatar">
|
||||||
|
<n-avatar
|
||||||
|
:size="36"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: themeStore.menuPrimaryColor
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<n-icon :size="20">
|
||||||
|
<PersonOutline v-if="message.role === 'user'" />
|
||||||
|
<SparklesOutline v-else />
|
||||||
|
</n-icon>
|
||||||
|
</n-avatar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息内容区域 -->
|
||||||
|
<div class="message-main">
|
||||||
|
<!-- 角色标识 -->
|
||||||
|
<div class="message-role-label">
|
||||||
|
{{ message.role === 'user' ? t('你') : 'AI' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息内容 -->
|
||||||
|
<div
|
||||||
|
class="message-content"
|
||||||
|
:class="{ 'is-loading': message.isLoading && !message.content }"
|
||||||
|
>
|
||||||
|
<!-- AI思考中的提示 -->
|
||||||
|
<div
|
||||||
|
v-if="message.role === 'assistant' && message.isLoading && !message.content"
|
||||||
|
class="thinking-indicator"
|
||||||
|
>
|
||||||
|
<n-spin size="small" />
|
||||||
|
<span>{{ t('AI思考中...') }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 正常消息内容 -->
|
||||||
|
<MarkdownRenderer
|
||||||
|
v-else
|
||||||
|
:content="message.content"
|
||||||
|
:isUser="message.role === 'user'"
|
||||||
|
:isStreaming="message.isLoading"
|
||||||
|
:previousContentLength="message.previousLength || 0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 时间和操作按钮行(在消息下方) -->
|
||||||
|
<div class="message-footer" :class="message.role">
|
||||||
|
<!-- AI消息:时间在左边,按钮在右边 -->
|
||||||
|
<template v-if="message.role === 'assistant'">
|
||||||
|
<div class="message-actions">
|
||||||
|
<n-button text size="tiny" type="primary" @click="copyMessage(message.content)">
|
||||||
|
<n-icon size="16"><CopyOutline /></n-icon>
|
||||||
|
</n-button>
|
||||||
|
<n-button text size="tiny" type="error" @click="deleteMessage(index)">
|
||||||
|
<n-icon size="16"><TrashOutline /></n-icon>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<span class="message-time">{{ formatTime(message.timestamp) }}</span>
|
||||||
|
</template>
|
||||||
|
<!-- 用户消息:按钮在左边,时间在右边 -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="message-actions">
|
||||||
|
<n-button text size="tiny" type="primary" @click="copyMessage(message.content)">
|
||||||
|
<n-icon size="16"><CopyOutline /></n-icon>
|
||||||
|
</n-button>
|
||||||
|
<n-button text size="tiny" type="error" @click="deleteMessage(index)">
|
||||||
|
<n-icon size="16"><TrashOutline /></n-icon>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<span class="message-time">{{ formatTime(message.timestamp) }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户输入区域 -->
|
||||||
|
<div class="chat-input">
|
||||||
|
<n-input
|
||||||
|
v-model:value="userInput"
|
||||||
|
type="textarea"
|
||||||
|
:placeholder="t('输入你的问题...')"
|
||||||
|
:autosize="{ minRows: 2, maxRows: 4 }"
|
||||||
|
:disabled="isGenerating"
|
||||||
|
@keydown.enter.ctrl="handleSend"
|
||||||
|
/>
|
||||||
|
<div class="input-actions">
|
||||||
|
<n-text depth="3" style="font-size: 12px">
|
||||||
|
{{ t('Ctrl + Enter 发送') }}
|
||||||
|
</n-text>
|
||||||
|
<n-space>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
@click="handleClear"
|
||||||
|
:disabled="messages.length === 0 || isGenerating"
|
||||||
|
>
|
||||||
|
{{ t('清空对话') }}
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="handleSend"
|
||||||
|
:disabled="!userInput.trim() || isGenerating"
|
||||||
|
:loading="isGenerating"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<SendOutline />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
{{ t('发送') }}
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, nextTick, onMounted } from 'vue'
|
||||||
|
import {
|
||||||
|
NInput,
|
||||||
|
NButton,
|
||||||
|
NIcon,
|
||||||
|
NAvatar,
|
||||||
|
NText,
|
||||||
|
NSpin,
|
||||||
|
NSpace,
|
||||||
|
useMessage,
|
||||||
|
useDialog
|
||||||
|
} from 'naive-ui'
|
||||||
|
import {
|
||||||
|
ChatbubblesOutline,
|
||||||
|
ChatbubbleEllipsesOutline,
|
||||||
|
PersonOutline,
|
||||||
|
SparklesOutline,
|
||||||
|
SendOutline,
|
||||||
|
CopyOutline,
|
||||||
|
TrashOutline
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { useThemeStore } from '@/renderer/src/stores'
|
||||||
|
import { useAIChat } from '@/renderer/src/hooks/useAIChat.ts'
|
||||||
|
import MarkdownRenderer from './MarkdownRenderer.vue'
|
||||||
|
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
currentCategory: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
aiSetting: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:messages', 'save-history', 'load-history'])
|
||||||
|
|
||||||
|
// 使用 AI 聊天 Hook
|
||||||
|
const { callOpenAI, buildMessages, estimateTokens } = useAIChat()
|
||||||
|
|
||||||
|
const userInput = ref('')
|
||||||
|
const isGenerating = ref(false)
|
||||||
|
const messagesContainer = ref(null)
|
||||||
|
|
||||||
|
// 本地消息列表 - 用于界面显示,保持流畅
|
||||||
|
const messages = ref([])
|
||||||
|
|
||||||
|
// 同步本地消息到父组件 - 立即同步,不节流
|
||||||
|
function syncMessagesToParent() {
|
||||||
|
emit('update:messages', messages.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发保存 - 节流保存
|
||||||
|
let saveTimer = null
|
||||||
|
const SAVE_INTERVAL = 2000 // 2秒保存一次
|
||||||
|
|
||||||
|
function triggerSave(immediate = false) {
|
||||||
|
if (immediate) {
|
||||||
|
// 立即保存
|
||||||
|
if (saveTimer) {
|
||||||
|
clearTimeout(saveTimer)
|
||||||
|
saveTimer = null
|
||||||
|
}
|
||||||
|
emit('save-history')
|
||||||
|
} else {
|
||||||
|
// 节流保存
|
||||||
|
if (saveTimer) {
|
||||||
|
clearTimeout(saveTimer)
|
||||||
|
}
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
emit('save-history')
|
||||||
|
saveTimer = null
|
||||||
|
}, SAVE_INTERVAL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听props.messages变化,同步到本地(用于加载)
|
||||||
|
watch(
|
||||||
|
() => props.messages,
|
||||||
|
(newMessages) => {
|
||||||
|
if (newMessages && Array.isArray(newMessages)) {
|
||||||
|
messages.value = [...newMessages]
|
||||||
|
nextTick(() => {
|
||||||
|
scrollToBottomImmediate()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// 组件初始化时触发加载
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('组件初始化,触发加载历史记录')
|
||||||
|
emit('load-history')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听分类变化,触发加载
|
||||||
|
watch(
|
||||||
|
() => props.currentCategory?.id,
|
||||||
|
(newId, oldId) => {
|
||||||
|
if (newId !== oldId && newId) {
|
||||||
|
emit('load-history')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 不再需要 formatMessage 函数,由 MarkdownRenderer 组件处理
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
function formatTime(timestamp) {
|
||||||
|
if (!timestamp) return ''
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
const now = new Date()
|
||||||
|
const isToday = date.toDateString() === now.toDateString()
|
||||||
|
|
||||||
|
if (isToday) {
|
||||||
|
return date.toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发保存聊天记录
|
||||||
|
function triggerSaveHistory() {
|
||||||
|
emit('save-history')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制消息内容
|
||||||
|
function copyMessage(content) {
|
||||||
|
try {
|
||||||
|
navigator.clipboard.writeText(content)
|
||||||
|
message.success(t('内容已复制到剪贴板'))
|
||||||
|
} catch (error) {
|
||||||
|
message.error(t('复制失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除消息
|
||||||
|
function deleteMessage(index) {
|
||||||
|
dialog.warning({
|
||||||
|
title: t('确认删除'),
|
||||||
|
content: t('确定要删除这条消息吗?'),
|
||||||
|
positiveText: t('确定'),
|
||||||
|
negativeText: t('取消'),
|
||||||
|
onPositiveClick: () => {
|
||||||
|
// 直接修改本地数组
|
||||||
|
messages.value.splice(index, 1)
|
||||||
|
message.success(t('已删除'))
|
||||||
|
// 删除后立即同步和保存
|
||||||
|
syncMessagesToParent()
|
||||||
|
triggerSave(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新最后一条消息内容(流式输出专用)
|
||||||
|
function updateLastMessage(content, isLoading = true) {
|
||||||
|
const lastIndex = messages.value.length - 1
|
||||||
|
if (lastIndex < 0) return
|
||||||
|
|
||||||
|
// 直接修改本地对象属性,保持引用不变,Vue会自动响应
|
||||||
|
const lastMsg = messages.value[lastIndex]
|
||||||
|
lastMsg.content = content
|
||||||
|
lastMsg.isLoading = isLoading
|
||||||
|
|
||||||
|
// 触发Vue响应式更新的技巧
|
||||||
|
messages.value[lastIndex] = { ...lastMsg }
|
||||||
|
|
||||||
|
// 立即滚动
|
||||||
|
nextTick(() => {
|
||||||
|
if (messagesContainer.value) {
|
||||||
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动到底部 - 节流版本(流式输出时每次都需要滚动)
|
||||||
|
let scrollTimer = null
|
||||||
|
let lastScrollTime = 0
|
||||||
|
async function scrollToBottom() {
|
||||||
|
// 节流控制:最多每16ms执行一次(约60fps)
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastScrollTime < 16) {
|
||||||
|
// 距离上次滚动小于16ms,延迟执行
|
||||||
|
if (!scrollTimer) {
|
||||||
|
scrollTimer = setTimeout(
|
||||||
|
() => {
|
||||||
|
scrollTimer = null
|
||||||
|
scrollToBottomImmediate()
|
||||||
|
},
|
||||||
|
16 - (now - lastScrollTime)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即执行滚动
|
||||||
|
lastScrollTime = now
|
||||||
|
scrollToBottomImmediate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即滚动(不节流)
|
||||||
|
async function scrollToBottomImmediate() {
|
||||||
|
await nextTick()
|
||||||
|
if (messagesContainer.value) {
|
||||||
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
async function handleSend() {
|
||||||
|
if (!userInput.value.trim()) {
|
||||||
|
message.warning(t('请输入内容'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.currentCategory) {
|
||||||
|
message.warning(t('请先选择一个分类'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// // 验证 AI 配置
|
||||||
|
// if (!props.aiSetting?.apiUrl || !props.aiSetting?.apiKey) {
|
||||||
|
// message.error(t('AI配置不完整,请先配置API地址和密钥'))
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
const userQuestion = userInput.value.trim()
|
||||||
|
userInput.value = ''
|
||||||
|
|
||||||
|
// 添加用户消息到本地
|
||||||
|
const userMessage = {
|
||||||
|
role: 'user',
|
||||||
|
content: userQuestion,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
messages.value.push(userMessage)
|
||||||
|
|
||||||
|
// 用户发送后立即同步和保存
|
||||||
|
syncMessagesToParent()
|
||||||
|
triggerSave(true)
|
||||||
|
|
||||||
|
await scrollToBottomImmediate()
|
||||||
|
|
||||||
|
// 添加AI消息占位
|
||||||
|
const aiMessage = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
isLoading: true,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
messages.value.push(aiMessage)
|
||||||
|
isGenerating.value = true
|
||||||
|
|
||||||
|
await scrollToBottomImmediate()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 构建消息历史(不包括当前正在生成的AI消息,但包括刚添加的用户消息)
|
||||||
|
const apiMessages = buildMessages(
|
||||||
|
messages.value.slice(0, -1), // 排除正在生成的AI消息占位符
|
||||||
|
props.currentCategory
|
||||||
|
// 不传 userQuestion,因为用户消息已经在 messages.value 中了
|
||||||
|
)
|
||||||
|
|
||||||
|
// 调用 OpenAI API
|
||||||
|
await callOpenAI({
|
||||||
|
messages: apiMessages,
|
||||||
|
aiSetting: props.aiSetting,
|
||||||
|
currentCategory: props.currentCategory,
|
||||||
|
// 流式响应回调 - 直接更新消息内容
|
||||||
|
onStream: (chunk, fullContent) => {
|
||||||
|
updateLastMessage(fullContent, true)
|
||||||
|
// 立即同步到父组件(界面更新)
|
||||||
|
syncMessagesToParent()
|
||||||
|
// 节流保存
|
||||||
|
triggerSave(false)
|
||||||
|
},
|
||||||
|
// 完成回调
|
||||||
|
onComplete: (fullContent) => {
|
||||||
|
const lastIndex = messages.value.length - 1
|
||||||
|
if (lastIndex >= 0) {
|
||||||
|
const lastMsg = messages.value[lastIndex]
|
||||||
|
lastMsg.content = fullContent
|
||||||
|
lastMsg.isLoading = false
|
||||||
|
lastMsg.timestamp = Date.now()
|
||||||
|
// 触发响应式更新
|
||||||
|
messages.value[lastIndex] = { ...lastMsg }
|
||||||
|
}
|
||||||
|
scrollToBottomImmediate()
|
||||||
|
|
||||||
|
// AI完成后立即同步和保存
|
||||||
|
syncMessagesToParent()
|
||||||
|
triggerSave(true)
|
||||||
|
},
|
||||||
|
// 错误回调
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('生成回复失败:', error)
|
||||||
|
message.error(t('生成回复失败: {error}', { error: error.message }))
|
||||||
|
// 移除错误的消息
|
||||||
|
messages.value.pop()
|
||||||
|
// 立即同步和保存
|
||||||
|
syncMessagesToParent()
|
||||||
|
triggerSave(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送消息失败:', error)
|
||||||
|
} finally {
|
||||||
|
isGenerating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空对话
|
||||||
|
function handleClear() {
|
||||||
|
dialog.warning({
|
||||||
|
title: t('确认操作'),
|
||||||
|
content: t('确定要清空所有对话记录吗?'),
|
||||||
|
positiveText: t('确定'),
|
||||||
|
negativeText: t('取消'),
|
||||||
|
onPositiveClick: () => {
|
||||||
|
messages.value = []
|
||||||
|
message.success(t('对话记录已清空'))
|
||||||
|
// 立即同步和保存
|
||||||
|
syncMessagesToParent()
|
||||||
|
triggerSave(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 背景色
|
||||||
|
const backgroundColor = computed(() => {
|
||||||
|
return themeStore.menuPrimaryShadow
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-chat-interface {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
/* 优化滚动性能 */
|
||||||
|
overflow-anchor: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
/* 启用硬件加速 */
|
||||||
|
transform: translateZ(0);
|
||||||
|
will-change: scroll-position;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-track {
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-wrapper {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
/* 优化渲染性能 */
|
||||||
|
contain: layout style paint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户消息:头像在右侧 */
|
||||||
|
.message-wrapper.user {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI消息:头像在左侧 */
|
||||||
|
.message-wrapper.assistant {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头像容器 */
|
||||||
|
.message-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 消息主体区域 - 宽度自适应 */
|
||||||
|
.message-main {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 75%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 角色标签 */
|
||||||
|
.message-role-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户消息的名字靠右 */
|
||||||
|
.message-wrapper.user .message-role-label {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 时间和操作按钮行(在消息下方) */
|
||||||
|
.message-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI的时间和按钮靠左 */
|
||||||
|
.message-footer.assistant {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户的时间和按钮靠右 */
|
||||||
|
.message-footer.user {
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-wrapper:hover .message-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 纯图标按钮样式 */
|
||||||
|
.message-actions .n-button {
|
||||||
|
padding: 4px;
|
||||||
|
min-width: unset;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 消息内容 - 宽度自适应,自动换行 */
|
||||||
|
.message-content {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
/* 宽度自适应 */
|
||||||
|
width: fit-content;
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 100%;
|
||||||
|
/* 自动换行 */
|
||||||
|
word-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
/* Markdown 渲染器会处理内容样式 */
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
cursor: text;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 思考中的指示器 */
|
||||||
|
.thinking-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户消息背景 - 渐变背景 */
|
||||||
|
.message-wrapper.user .message-content {
|
||||||
|
background: v-bind(backgroundColor);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI消息背景 - 添加背景框 */
|
||||||
|
.message-wrapper.assistant .message-content {
|
||||||
|
background: v-bind(backgroundColor);
|
||||||
|
border: 1px solid rgba(24, 160, 88, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -29,6 +29,23 @@
|
|||||||
</n-icon>
|
</n-icon>
|
||||||
</template>
|
</template>
|
||||||
</TooltipButton>
|
</TooltipButton>
|
||||||
|
|
||||||
|
<!-- 切换模式按钮 -->
|
||||||
|
<TooltipButton
|
||||||
|
:tooltip="t('切换到{mode}模式', { mode: currentMode === 'batch' ? 'AI对话' : '批量文案' })"
|
||||||
|
@click="handleToggleMode"
|
||||||
|
size="medium"
|
||||||
|
text
|
||||||
|
:style="{
|
||||||
|
padding: '0 6px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<SwapHorizontalOutline />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</TooltipButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 折叠面板,小分类选项 -->
|
<!-- 折叠面板,小分类选项 -->
|
||||||
@ -53,7 +70,7 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div style="font-size: 14px; font-weight: 500">{{ subCategory.name }}</div>
|
<div style="font-size: 14px; font-weight: 500">{{ subCategory.name }}</div>
|
||||||
</template>
|
</template>
|
||||||
<div style="font-size: 12px; color: #909399; line-height: 1.4">
|
<div style="font-size: 12px; line-height: 1.4">
|
||||||
{{ subCategory.description }}
|
{{ subCategory.description }}
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
@ -71,7 +88,7 @@ import { useDialog, NSelect, NCollapse, NCollapseItem, NCard, NIcon } from 'naiv
|
|||||||
import { useThemeStore } from '@/renderer/src/stores'
|
import { useThemeStore } from '@/renderer/src/stores'
|
||||||
import { isEmpty } from 'lodash'
|
import { isEmpty } from 'lodash'
|
||||||
import TooltipButton from '@/renderer/src/components/common/TooltipButton.vue'
|
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 ManageAISetting from './ManageAISetting.vue'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
@ -98,11 +115,15 @@ const props = defineProps({
|
|||||||
selectMainCategory: {
|
selectMainCategory: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
|
},
|
||||||
|
currentMode: {
|
||||||
|
type: String,
|
||||||
|
default: 'batch' // 'batch' 或 'chat'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 定义 emits
|
// 定义 emits
|
||||||
const emit = defineEmits(['category-select', 'update-simple-settings'])
|
const emit = defineEmits(['category-select', 'update-simple-settings', 'toggle-mode'])
|
||||||
|
|
||||||
// 菜单相关数据
|
// 菜单相关数据
|
||||||
const selectedMainCategory = ref(null) // 只用于select显示,仅手动选择时有值
|
const selectedMainCategory = ref(null) // 只用于select显示,仅手动选择时有值
|
||||||
@ -209,6 +230,11 @@ function handleSettingClick() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理模式切换
|
||||||
|
function handleToggleMode() {
|
||||||
|
emit('toggle-mode')
|
||||||
|
}
|
||||||
|
|
||||||
// 暴露方法给父组件
|
// 暴露方法给父组件
|
||||||
defineExpose({
|
defineExpose({
|
||||||
selectedMainCategory,
|
selectedMainCategory,
|
||||||
@ -277,7 +303,7 @@ const selectCardBackgroundColor = computed(() => {
|
|||||||
|
|
||||||
.selected-card {
|
.selected-card {
|
||||||
border-color: v-bind(selectCardColor) !important;
|
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;
|
background-color: v-bind(selectCardBackgroundColor) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
458
src/renderer/src/components/CopyWriting/MarkdownRenderer.vue
Normal file
458
src/renderer/src/components/CopyWriting/MarkdownRenderer.vue
Normal file
@ -0,0 +1,458 @@
|
|||||||
|
<template>
|
||||||
|
<div class="markdown-renderer" :class="{ 'user-message': isUser, streaming: isStreaming }">
|
||||||
|
<div v-html="renderedContent" class="markdown-content"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, watch, ref } from 'vue'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import hljs from 'highlight.js'
|
||||||
|
import 'highlight.js/styles/github.css'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isUser: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
isStreaming: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
previousContentLength: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
// 配置 marked
|
||||||
|
marked.setOptions({
|
||||||
|
highlight: function (code, lang) {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
try {
|
||||||
|
return hljs.highlight(code, { language: lang }).value
|
||||||
|
} catch (e) {
|
||||||
|
console.error('代码高亮失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hljs.highlightAuto(code).value
|
||||||
|
},
|
||||||
|
breaks: true, // 支持 GitHub 风格换行
|
||||||
|
gfm: true // 启用 GitHub 风格的 Markdown
|
||||||
|
})
|
||||||
|
|
||||||
|
// 自定义渲染器
|
||||||
|
const renderer = new marked.Renderer()
|
||||||
|
|
||||||
|
// 图片渲染
|
||||||
|
renderer.image = function (href, title, text) {
|
||||||
|
// 检查是否为视频文件
|
||||||
|
if (/\.(mp4|webm|ogg|avi|mov)$/i.test(href)) {
|
||||||
|
return `
|
||||||
|
<div class="video-container">
|
||||||
|
<video controls style="max-width: 100%; border-radius: 8px;">
|
||||||
|
<source src="${href}" type="video/${href.split('.').pop()}">
|
||||||
|
您的浏览器不支持视频播放
|
||||||
|
</video>
|
||||||
|
${title ? `<div class="media-caption">${title}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为音频文件
|
||||||
|
if (/\.(mp3|wav|ogg|aac)$/i.test(href)) {
|
||||||
|
return `
|
||||||
|
<div class="audio-container">
|
||||||
|
<audio controls style="width: 100%;">
|
||||||
|
<source src="${href}" type="audio/${href.split('.').pop()}">
|
||||||
|
您的浏览器不支持音频播放
|
||||||
|
</audio>
|
||||||
|
${title ? `<div class="media-caption">${title}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通图片
|
||||||
|
return `
|
||||||
|
<div class="image-container">
|
||||||
|
<img src="${href}"
|
||||||
|
alt="${text || ''}"
|
||||||
|
title="${title || ''}"
|
||||||
|
loading="lazy"
|
||||||
|
onerror="this.parentElement.innerHTML='<div class=\\'image-error\\'>图片加载失败: ${href}</div>'"
|
||||||
|
style="max-width: 100%; height: auto; border-radius: 8px; cursor: pointer;"
|
||||||
|
onclick="window.open('${href}', '_blank')"
|
||||||
|
/>
|
||||||
|
${title ? `<div class="media-caption">${title}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代码块渲染
|
||||||
|
renderer.code = function (code, language) {
|
||||||
|
const validLanguage = language && hljs.getLanguage(language) ? language : 'plaintext'
|
||||||
|
const highlighted = hljs.highlight(code, { language: validLanguage }).value
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="code-block-wrapper">
|
||||||
|
<div class="code-block-header">
|
||||||
|
<span class="code-language">${validLanguage}</span>
|
||||||
|
<button class="copy-btn" onclick="copyCode(this)" data-code="${encodeURIComponent(code)}">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre><code class="hljs language-${validLanguage}">${highlighted}</code></pre>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 链接渲染
|
||||||
|
renderer.link = function (href, title, text) {
|
||||||
|
const isExternal = /^https?:\/\//i.test(href)
|
||||||
|
const target = isExternal ? '_blank' : '_self'
|
||||||
|
const rel = isExternal ? 'noopener noreferrer' : ''
|
||||||
|
|
||||||
|
return `<a href="${href}"
|
||||||
|
title="${title || ''}"
|
||||||
|
target="${target}"
|
||||||
|
${rel ? `rel="${rel}"` : ''}
|
||||||
|
class="markdown-link">
|
||||||
|
${text}
|
||||||
|
</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格渲染
|
||||||
|
renderer.table = function (header, body) {
|
||||||
|
return `
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="markdown-table">
|
||||||
|
<thead>${header}</thead>
|
||||||
|
<tbody>${body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 块引用渲染
|
||||||
|
renderer.blockquote = function (quote) {
|
||||||
|
return `<blockquote class="markdown-blockquote">${quote}</blockquote>`
|
||||||
|
}
|
||||||
|
|
||||||
|
marked.use({ renderer })
|
||||||
|
|
||||||
|
// 渲染 Markdown
|
||||||
|
const renderedContent = computed(() => {
|
||||||
|
if (!props.content) return ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
let content = props.content
|
||||||
|
|
||||||
|
// 转义特殊的 LaTeX 语法
|
||||||
|
content = content.replace(/\\\[(.+?)\\\]/g, (match, formula) => {
|
||||||
|
return `<div class="math-block">$$${formula}$$</div>`
|
||||||
|
})
|
||||||
|
content = content.replace(/\\\((.+?)\\\)/g, (match, formula) => {
|
||||||
|
return `<span class="math-inline">$${formula}$</span>`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 渲染 Markdown
|
||||||
|
let html = marked.parse(content)
|
||||||
|
|
||||||
|
// 流式输出时的打字机效果
|
||||||
|
if (props.isStreaming && props.previousContentLength > 0) {
|
||||||
|
// 给新增的内容添加动画类
|
||||||
|
const newContentStart = props.previousContentLength
|
||||||
|
// 这里可以添加更复杂的逻辑来只动画化新内容
|
||||||
|
}
|
||||||
|
|
||||||
|
return html
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Markdown 渲染失败:', error)
|
||||||
|
return `<div class="render-error">渲染失败: ${error.message}</div>`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 全局复制代码函数
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.copyCode = function (btn) {
|
||||||
|
const code = decodeURIComponent(btn.getAttribute('data-code'))
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(code)
|
||||||
|
.then(() => {
|
||||||
|
const originalText = btn.innerHTML
|
||||||
|
btn.innerHTML = `
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
已复制
|
||||||
|
`
|
||||||
|
btn.classList.add('copied')
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = originalText
|
||||||
|
btn.classList.remove('copied')
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('复制失败:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.markdown-renderer {
|
||||||
|
width: 100%;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message :deep(.markdown-link) {
|
||||||
|
color: #87ceeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 代码块样式 */
|
||||||
|
:deep(.code-block-wrapper) {
|
||||||
|
margin: 12px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.code-block-header) {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.code-language) {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.copy-btn) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(pre) {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(code) {
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(:not(pre) > code) {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片/视频/音频容器 */
|
||||||
|
:deep(.image-container),
|
||||||
|
:deep(.video-container),
|
||||||
|
:deep(.audio-container) {
|
||||||
|
margin: 12px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.media-caption) {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.image-error) {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式 */
|
||||||
|
:deep(.table-wrapper) {
|
||||||
|
margin: 12px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.markdown-table) {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.markdown-table th) {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.markdown-table td) {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 块引用样式 */
|
||||||
|
:deep(.markdown-blockquote) {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message :deep(.markdown-blockquote) {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 链接样式 */
|
||||||
|
:deep(.markdown-link) {
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.markdown-link:hover) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题样式 */
|
||||||
|
:deep(h1),
|
||||||
|
:deep(h2),
|
||||||
|
:deep(h3),
|
||||||
|
:deep(h4),
|
||||||
|
:deep(h5),
|
||||||
|
:deep(h6) {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(h1) {
|
||||||
|
font-size: 24px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(h2) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(h3) {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(h4) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表样式 */
|
||||||
|
:deep(ul),
|
||||||
|
:deep(ol) {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(li) {
|
||||||
|
margin: 4px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 段落样式 */
|
||||||
|
:deep(p) {
|
||||||
|
margin: 12px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 水平线 */
|
||||||
|
:deep(hr) {
|
||||||
|
margin: 20px 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 流式输出动画 */
|
||||||
|
.streaming :deep(.new-content) {
|
||||||
|
animation: fadeIn 0.3s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 渲染错误样式 */
|
||||||
|
:deep(.render-error) {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 数学公式样式 */
|
||||||
|
:deep(.math-block),
|
||||||
|
:deep(.math-inline) {
|
||||||
|
font-family: 'KaTeX_Math', 'Times New Roman', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.math-block) {
|
||||||
|
margin: 16px 0;
|
||||||
|
padding: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:deep(h1) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(h2) {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(h3) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.code-block-header) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.markdown-table) {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.markdown-table th),
|
||||||
|
:deep(.markdown-table td) {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -12,10 +12,32 @@
|
|||||||
</n-button>
|
</n-button>
|
||||||
|
|
||||||
<TextEllipsis
|
<TextEllipsis
|
||||||
:show-full="true"
|
:show-full="false"
|
||||||
customClass="table-header"
|
customClass="table-header"
|
||||||
:text="`${bookStore.selectBook.name} - ${bookStore.selectBookTask.name}`"
|
:text="`${bookStore.selectBook.name} - ${bookStore.selectBookTask.name}`"
|
||||||
></TextEllipsis>
|
></TextEllipsis>
|
||||||
|
|
||||||
|
<n-button-group size="small">
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button type="primary" @click="handleOneClickToVideo">
|
||||||
|
{{ t('一键提交转视频任务') }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
{{ t('将所有转视频类型为 ComfyUI 的分镜,一键提交生成视频任务。') }}
|
||||||
|
</n-tooltip>
|
||||||
|
<TooltipDropdown
|
||||||
|
trigger="click"
|
||||||
|
:options="taskActionOptions"
|
||||||
|
@select="handleActionDropdownSelect"
|
||||||
|
>
|
||||||
|
<n-button size="small" type="primary">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><ChevronDownOutline /></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</TooltipDropdown>
|
||||||
|
</n-button-group>
|
||||||
</n-space>
|
</n-space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -53,21 +75,25 @@ import {
|
|||||||
NSpace,
|
NSpace,
|
||||||
NText,
|
NText,
|
||||||
NButton,
|
NButton,
|
||||||
|
NButtonGroup,
|
||||||
NIcon,
|
NIcon,
|
||||||
NDataTable,
|
NDataTable,
|
||||||
NSwitch,
|
NSwitch,
|
||||||
NImage,
|
NImage,
|
||||||
NSelect,
|
NSelect,
|
||||||
|
NDropdown,
|
||||||
|
NTooltip,
|
||||||
useMessage,
|
useMessage,
|
||||||
useDialog
|
useDialog
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { ArrowBackOutline } from '@vicons/ionicons5'
|
import { ArrowBackOutline, ChevronDownOutline } from '@vicons/ionicons5'
|
||||||
import { define } from '@/define/define'
|
import { define } from '@/define/define'
|
||||||
import ImageTextVideoInfoVideoConfig from './MediaToVideoInfoVideoConfig.vue'
|
import ImageTextVideoInfoVideoConfig from './MediaToVideoInfoVideoConfig.vue'
|
||||||
import ImageTextVideoInfoVideoListInfo from './MediaToVideoInfoVideoListInfo.vue'
|
import ImageTextVideoInfoVideoListInfo from './MediaToVideoInfoVideoListInfo.vue'
|
||||||
import ImageTextVideoInfoTaskOptions from './MediaToVideoInfoTaskOptions.vue'
|
import ImageTextVideoInfoTaskOptions from './MediaToVideoInfoTaskOptions.vue'
|
||||||
import VideoDisplay from '@/renderer/src/components/common/VideoDisplay.vue'
|
import VideoDisplay from '@/renderer/src/components/common/VideoDisplay.vue'
|
||||||
import MediaToVideoVideoConfigHeader from './MediaToVideoVideoConfigHeader.vue'
|
import MediaToVideoVideoConfigHeader from './MediaToVideoVideoConfigHeader.vue'
|
||||||
|
import TooltipDropdown from '@/renderer/src/components/common/TooltipDropdown.vue'
|
||||||
import { OptionKeyName, OptionType } from '@/define/enum/option'
|
import { OptionKeyName, OptionType } from '@/define/enum/option'
|
||||||
import { useBookStore, useSoftwareStore } from '@/renderer/src/stores'
|
import { useBookStore, useSoftwareStore } from '@/renderer/src/stores'
|
||||||
import { GetImageToVideoModelsOptions, ImageToVideoModels } from '@/define/enum/video'
|
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 { DEFINE_STRING } from '@/define/ipcDefineString'
|
||||||
import { AddOneTask, StopToVideoTask } from '@/renderer/src/common/task'
|
import { AddOneTask, StopToVideoTask } from '@/renderer/src/common/task'
|
||||||
import { useMD } from '@/renderer/src/hooks/useMD'
|
import { useMD } from '@/renderer/src/hooks/useMD'
|
||||||
|
import { checkVideoExists } from '@/renderer/src/common/image'
|
||||||
|
|
||||||
const bookStore = useBookStore()
|
const bookStore = useBookStore()
|
||||||
const softwareStore = useSoftwareStore()
|
const softwareStore = useSoftwareStore()
|
||||||
@ -119,6 +146,24 @@ const batchVideoType = ref(null)
|
|||||||
// 视频类型选项
|
// 视频类型选项
|
||||||
const videoTypeOptions = GetImageToVideoModelsOptions()
|
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 变化,同步更新本地状态
|
// 监听 props 变化,同步更新本地状态
|
||||||
watch(
|
watch(
|
||||||
() => props.showRightPanel,
|
() => 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) {
|
async function handleStopToVideoTask(content, key) {
|
||||||
let da = dialog.warning({
|
let da = dialog.warning({
|
||||||
@ -655,6 +778,9 @@ async function handleActionDropdownSelect(key) {
|
|||||||
case 'one-click-to-video':
|
case 'one-click-to-video':
|
||||||
await handleOneClickToVideo()
|
await handleOneClickToVideo()
|
||||||
break
|
break
|
||||||
|
case 'submit-ungenerated-tasks':
|
||||||
|
await handleSubmitUngeneratedTasks()
|
||||||
|
break
|
||||||
case 'stop-current-batch-tasks':
|
case 'stop-current-batch-tasks':
|
||||||
await handleStopToVideoTask(
|
await handleStopToVideoTask(
|
||||||
t(
|
t(
|
||||||
|
|||||||
@ -79,21 +79,6 @@ const blockOptions = computed(() => {
|
|||||||
label: t('同步生图提示词'),
|
label: t('同步生图提示词'),
|
||||||
tooltip: t('同步当前分镜的生图提示词到图转视频的提示词中。'),
|
tooltip: t('同步当前分镜的生图提示词到图转视频的提示词中。'),
|
||||||
key: 'sync-image-prompts'
|
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'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
<template #input="{ submit, deactivate }">
|
<template #input="{ submit, deactivate }">
|
||||||
<n-select
|
<n-select
|
||||||
size="small"
|
size="small"
|
||||||
|
style="width: 100px"
|
||||||
v-model:value="selectedValue"
|
v-model:value="selectedValue"
|
||||||
:options="show_options"
|
:options="show_options"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-card class="add-book-form">
|
<n-card class="add-book-form">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-title">{{ type == 'edit' ? t('编辑小说') : t('添加新小说') }}</div>
|
<div class="card-title">{{ type == 'edit' ? t('编辑项目') : t('添加新项目') }}</div>
|
||||||
</template>
|
</template>
|
||||||
<n-form
|
<n-form
|
||||||
ref="formRef"
|
ref="formRef"
|
||||||
@ -33,8 +33,8 @@
|
|||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
<!-- 配音地址 -->
|
<!-- 配音地址 (仅原创类型显示) -->
|
||||||
<n-form-item :label="t('配音地址')" path="audioPath">
|
<n-form-item v-if="isOriginalType" :label="t('配音地址')" path="audioPath">
|
||||||
<n-input-group>
|
<n-input-group>
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="bookStore.selectBook.audioPath"
|
v-model:value="bookStore.selectBook.audioPath"
|
||||||
@ -48,8 +48,8 @@
|
|||||||
</n-input-group>
|
</n-input-group>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
<!-- SRT地址 -->
|
<!-- SRT地址 (仅原创类型显示) -->
|
||||||
<n-form-item :label="t('SRT文件')" path="srtPath">
|
<n-form-item v-if="isOriginalType" :label="t('SRT文件')" path="srtPath">
|
||||||
<n-input-group>
|
<n-input-group>
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="bookStore.selectBook.srtPath"
|
v-model:value="bookStore.selectBook.srtPath"
|
||||||
@ -111,11 +111,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { FolderOpen } from '@vicons/ionicons5'
|
import { FolderOpen } from '@vicons/ionicons5'
|
||||||
import { useMessage } from 'naive-ui'
|
import { useMessage } from 'naive-ui'
|
||||||
import { useBookStore } from '@/renderer/src/stores'
|
import { useBookStore } from '@/renderer/src/stores'
|
||||||
import { BookType } from '@/define/enum/bookEnum'
|
import { BookType, GetBookTypeOptions } from '@/define/enum/bookEnum'
|
||||||
import { ValidateErrorString } from '@/define/Tools/validate'
|
import { ValidateErrorString } from '@/define/Tools/validate'
|
||||||
import { TimeDelay } from '@/define/Tools/time'
|
import { TimeDelay } from '@/define/Tools/time'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
@ -137,14 +137,16 @@ const loading = ref(false)
|
|||||||
let type = ref(props.type ? props.type : 'add')
|
let type = ref(props.type ? props.type : 'add')
|
||||||
|
|
||||||
// 小说类型选项
|
// 小说类型选项
|
||||||
const typeOptions = [
|
const typeOptions = GetBookTypeOptions()
|
||||||
{ label: t('原创'), value: BookType.ORIGINAL },
|
|
||||||
{ label: t('SD反推'), value: BookType.SD_REVERSE },
|
|
||||||
{ label: t('MJ反推'), value: BookType.MJ_REVERSE }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 表单验证规则
|
// 判断是否为原创类型
|
||||||
const rules = {
|
const isOriginalType = computed(() => {
|
||||||
|
return bookStore.selectBook.type === BookType.ORIGINAL
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表单验证规则 - 根据项目类型动态生成
|
||||||
|
const rules = computed(() => {
|
||||||
|
const baseRules = {
|
||||||
title: [
|
title: [
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
@ -153,8 +155,12 @@ const rules = {
|
|||||||
}),
|
}),
|
||||||
trigger: ['blur', 'input']
|
trigger: ['blur', 'input']
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
audioPath: [
|
}
|
||||||
|
|
||||||
|
// 仅原创类型需要配音和SRT文件
|
||||||
|
if (isOriginalType.value) {
|
||||||
|
baseRules.audioPath = [
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: t('请选择 {data}', {
|
message: t('请选择 {data}', {
|
||||||
@ -162,8 +168,8 @@ const rules = {
|
|||||||
}),
|
}),
|
||||||
trigger: 'change'
|
trigger: 'change'
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
srtPath: [
|
baseRules.srtPath = [
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: t('请选择 {data}', {
|
message: t('请选择 {data}', {
|
||||||
@ -172,7 +178,10 @@ const rules = {
|
|||||||
trigger: 'change'
|
trigger: 'change'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return baseRules
|
||||||
|
})
|
||||||
|
|
||||||
// 选择配音文件
|
// 选择配音文件
|
||||||
const selectAudioFile = async () => {
|
const selectAudioFile = async () => {
|
||||||
|
|||||||
@ -217,6 +217,7 @@ import TooltipDropdown from '../../common/TooltipDropdown.vue'
|
|||||||
import OriginalModifyBookTask from './OriginalModifyBookTask.vue'
|
import OriginalModifyBookTask from './OriginalModifyBookTask.vue'
|
||||||
import OriginalViewBookTaskInfo from './OriginalViewBookTaskInfo.vue'
|
import OriginalViewBookTaskInfo from './OriginalViewBookTaskInfo.vue'
|
||||||
import JianyingGenerateInformation from '../BookTaskDetail/JianyingGenerateInformation.vue'
|
import JianyingGenerateInformation from '../BookTaskDetail/JianyingGenerateInformation.vue'
|
||||||
|
import DialogTextContent from '../../common/DialogTextContent.vue'
|
||||||
import { BookOptionType } from '@/renderer/src/components/Original/MainHome/bookType'
|
import { BookOptionType } from '@/renderer/src/components/Original/MainHome/bookType'
|
||||||
import { isEmpty } from 'lodash'
|
import { isEmpty } from 'lodash'
|
||||||
import { OptionKeyName } from '@/define/enum/option'
|
import { OptionKeyName } from '@/define/enum/option'
|
||||||
@ -302,7 +303,10 @@ async function handleTaskAction(key) {
|
|||||||
editBookTask(props.bookTask)
|
editBookTask(props.bookTask)
|
||||||
break
|
break
|
||||||
case 'open-media-to-video':
|
case 'open-media-to-video':
|
||||||
handleOpenMediaToVideo(props.bookTask)
|
handleToggleMediaToVideo(props.bookTask, true)
|
||||||
|
break
|
||||||
|
case 'close-media-to-video':
|
||||||
|
handleToggleMediaToVideo(props.bookTask, false)
|
||||||
break
|
break
|
||||||
case 'export-jianying':
|
case 'export-jianying':
|
||||||
await ExportJianyingDraft()
|
await ExportJianyingDraft()
|
||||||
@ -541,47 +545,64 @@ async function editBookTask(bookTask) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOpenMediaToVideo(bookTask) {
|
async function handleToggleMediaToVideo(bookTask, enable) {
|
||||||
let da = dialog.warning({
|
let da = dialog.warning({
|
||||||
title: t('操作确认'),
|
title: t('操作确认'),
|
||||||
content: () =>
|
content: () =>
|
||||||
h(
|
h(DialogTextContent, {
|
||||||
'div',
|
text: enable
|
||||||
{ style: 'white-space: pre-wrap;' },
|
? t(
|
||||||
t(
|
'是否将任务 {bookTaskName} 添加到图文转视频的任务列表中?\n\n添加后会自动跳转到图文转视频界面,若当前任务已经存在于图文转视频任务列表中,则不会重复添加。',
|
||||||
'是否将任务 {bookTaskName} 添加到图文转视频的任务列表中?\n\n添加后会自动跳转到图文转视频界面,若当前任务已经存在于图文转视频任务列表中,则不会重复添加。',
|
|
||||||
{
|
{
|
||||||
bookTaskName: bookTask.name
|
bookTaskName: bookTask.name
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
),
|
: t(
|
||||||
|
'是否将任务 {bookTaskName} 从图文转视频的任务列表中移除?\n\n移除后该任务将不再显示在图文转视频模块中,但已生成的视频数据不会被删除。',
|
||||||
|
{
|
||||||
|
bookTaskName: bookTask.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}),
|
||||||
positiveText: t('确定'),
|
positiveText: t('确定'),
|
||||||
negativeText: t('取消'),
|
negativeText: t('取消'),
|
||||||
onPositiveClick: async () => {
|
onPositiveClick: async () => {
|
||||||
try {
|
try {
|
||||||
da?.destroy()
|
da?.destroy()
|
||||||
debugger
|
|
||||||
softwareStore.spin.spinning = true
|
softwareStore.spin.spinning = true
|
||||||
softwareStore.spin.tip = t('正在添加任务到图文转视频模块...')
|
softwareStore.spin.tip = enable
|
||||||
|
? t('正在添加任务到图文转视频模块...')
|
||||||
|
: t('正在从图文转视频模块移除任务...')
|
||||||
|
|
||||||
// 判断当前任务是不是已经开启
|
// 修改任务的openVideoGenerate状态
|
||||||
if (!bookTask.openVideoGenerate) {
|
|
||||||
let res = await window.book.ModifyBookTaskDataById(bookTask.id, {
|
let res = await window.book.ModifyBookTaskDataById(bookTask.id, {
|
||||||
openVideoGenerate: true
|
openVideoGenerate: enable
|
||||||
})
|
})
|
||||||
if (res.code != 1) {
|
if (res.code != 1) {
|
||||||
message.error(t('开启图文转视频失败,{error}', { error: res.message }))
|
message.error(
|
||||||
|
enable
|
||||||
|
? t('开启图文转视频失败,{error}', { error: res.message })
|
||||||
|
: t('关闭图文转视频失败,{error}', { error: res.message })
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
message.success(t('任务已添加到图文转视频模块'))
|
|
||||||
}
|
// 更新本地状态
|
||||||
|
bookTask.openVideoGenerate = enable
|
||||||
|
|
||||||
|
message.success(
|
||||||
|
enable
|
||||||
|
? t('任务已添加到图文转视频模块')
|
||||||
|
: t('任务已从图文转视频模块移除')
|
||||||
|
)
|
||||||
|
|
||||||
|
// 如果是开启,询问是否跳转
|
||||||
|
if (enable) {
|
||||||
|
|
||||||
// 判断用户是不是需要要跳转过去
|
// 判断用户是不是需要要跳转过去
|
||||||
let cm = dialog.warning({
|
let cm = dialog.warning({
|
||||||
title: t('操作确认'),
|
title: t('操作确认'),
|
||||||
content: bookTask.openVideoGenerate
|
content: t('是否现在就跳转到图文转视频界面?'),
|
||||||
? t('当前批次任务已经开启图转视频,是否直接跳转到图文转视频界面?')
|
|
||||||
: t('是否现在就跳转到图文转视频界面?'),
|
|
||||||
positiveText: t('确定'),
|
positiveText: t('确定'),
|
||||||
negativeText: t('取消'),
|
negativeText: t('取消'),
|
||||||
closable: true,
|
closable: true,
|
||||||
@ -605,15 +626,22 @@ async function handleOpenMediaToVideo(bookTask) {
|
|||||||
},
|
},
|
||||||
onNegativeClick: () => {
|
onNegativeClick: () => {
|
||||||
message.info(t('已取消跳转,你可以在转视频模块中查看该任务'))
|
message.info(t('已取消跳转,你可以在转视频模块中查看该任务'))
|
||||||
|
softwareStore.spin.spinning = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
// 关闭图转视频时,刷新数据列表以移除该任务
|
||||||
|
emit('refresh-data')
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(t('开启图文转视频失败,{error}', { error: error.message }))
|
message.error(
|
||||||
|
enable
|
||||||
|
? t('开启图文转视频失败,{error}', { error: error.message })
|
||||||
|
: t('关闭图文转视频失败,{error}', { error: error.message })
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
softwareStore.spin.spinning = false
|
softwareStore.spin.spinning = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 成功的话 跳转路由到图转视频界面
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
329
src/renderer/src/hooks/useAIChat.ts
Normal file
329
src/renderer/src/hooks/useAIChat.ts
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
import { ref, Ref } from 'vue'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { OptionKeyName } from '@/define/enum/option'
|
||||||
|
import { optionSerialization } from '@/main/service/option/optionSerialization'
|
||||||
|
import { SettingModal } from '@/define/model/setting'
|
||||||
|
import { CopyWritingModel } from '@/define/model/copywriting'
|
||||||
|
import { isEmpty } from 'lodash'
|
||||||
|
import { GetApiDefineDataById } from '@/define/data/apiData'
|
||||||
|
import { useSoftwareStore } from '@renderer/stores'
|
||||||
|
import { define } from '@/define/define'
|
||||||
|
import { handleFetchError } from '@/utils/httpErrorHandler'
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
|
||||||
|
interface CallOpenAIParams {
|
||||||
|
messages: OpenAIRequest.RequestMessage[]
|
||||||
|
aiSetting: SettingModal.CopyWritingAPISettings
|
||||||
|
currentCategory: CopyWritingModel.PromptCategory
|
||||||
|
onStream?: (chunk: string, fullContent: string) => void
|
||||||
|
onComplete?: (fullContent: string) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 聊天 Hook
|
||||||
|
* 支持 OpenAI 格式的接口调用和流式响应
|
||||||
|
*/
|
||||||
|
export function useAIChat() {
|
||||||
|
const isGenerating: Ref<boolean> = ref(false)
|
||||||
|
const softwareStore = useSoftwareStore()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取推理设置
|
||||||
|
*/
|
||||||
|
async function getInferenceSetting(): Promise<SettingModal.InferenceAISettingAndProvider | null> {
|
||||||
|
try {
|
||||||
|
const res = await window.option.GetOptionByKey(OptionKeyName.InferenceAI.InferenceSetting)
|
||||||
|
|
||||||
|
if (res.code !== 1 || !res.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const setting = optionSerialization<SettingModal.InferenceAISettings>(res.data)
|
||||||
|
let apiProviderItem = GetApiDefineDataById(setting.apiProvider);
|
||||||
|
if (apiProviderItem == null) {
|
||||||
|
throw new Error(t('当前API提供商数据不存在,请检查数据是否正确'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...setting,
|
||||||
|
apiProviderItem
|
||||||
|
} as SettingModal.InferenceAISettingAndProvider
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取推理设置失败:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文案处理的AI设置
|
||||||
|
*/
|
||||||
|
async function getCopyWritingAISetting(): Promise<SettingModal.CopyWritingAPISettings | null> {
|
||||||
|
try {
|
||||||
|
const res = await window.option.GetOptionByKey(
|
||||||
|
OptionKeyName.InferenceAI.CW_AISimpleSetting
|
||||||
|
)
|
||||||
|
if (res.code === 1 && res.data) {
|
||||||
|
const setting = optionSerialization<SettingModal.CopyWritingAPISettings>(res.data)
|
||||||
|
return setting
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取文案处理AI设置失败:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 OpenAI 格式的 API
|
||||||
|
* @param {Object} params - 请求参数
|
||||||
|
* @param {Array} params.messages - 消息历史记录
|
||||||
|
* @param {Object} params.aiSetting - AI 配置
|
||||||
|
* @param {Function} params.onStream - 流式响应回调
|
||||||
|
* @param {Function} params.onComplete - 完成回调
|
||||||
|
* @param {Function} params.onError - 错误回调
|
||||||
|
*/
|
||||||
|
async function callOpenAI({
|
||||||
|
messages,
|
||||||
|
aiSetting,
|
||||||
|
currentCategory,
|
||||||
|
onStream,
|
||||||
|
onComplete,
|
||||||
|
onError
|
||||||
|
}: CallOpenAIParams): Promise<string> {
|
||||||
|
isGenerating.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
debugger
|
||||||
|
|
||||||
|
// 判断是否需要使用推理设置
|
||||||
|
let finalApiUrl = aiSetting.gptUrl
|
||||||
|
let finalApiKey = aiSetting.apiKey
|
||||||
|
let finalModel = aiSetting.model
|
||||||
|
|
||||||
|
// 如果文案处理设置中没有配置,则使用推理设置
|
||||||
|
if (isEmpty(finalApiUrl) || isEmpty(finalApiKey) || isEmpty(finalModel)) {
|
||||||
|
const inferenceSetting = await getInferenceSetting()
|
||||||
|
if (inferenceSetting) {
|
||||||
|
finalApiUrl = finalApiUrl || inferenceSetting.apiProviderItem.base_url
|
||||||
|
finalApiKey = finalApiKey || inferenceSetting.apiToken
|
||||||
|
finalModel = finalModel || inferenceSetting.inferenceModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证配置
|
||||||
|
if (isEmpty(finalApiUrl) || isEmpty(finalApiKey) || isEmpty(finalModel)) {
|
||||||
|
throw new Error(t('AI配置不完整,请先配置API地址、密钥和模型'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentCategory == null || isEmpty(currentCategory.promptTypeId) || isEmpty(currentCategory.id)) {
|
||||||
|
throw new Error(t('当前提示词分类数据不完整,请检查数据是否正确'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建请求体
|
||||||
|
const openAIBody = {
|
||||||
|
model: finalModel,
|
||||||
|
messages: messages,
|
||||||
|
stream: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
promptTypeId: currentCategory.promptTypeId,
|
||||||
|
promptId: currentCategory.id,
|
||||||
|
gptUrl: finalApiUrl,
|
||||||
|
model: finalModel,
|
||||||
|
machineId: softwareStore.authorization.machineId,
|
||||||
|
apiKey: finalApiKey,
|
||||||
|
word: '123',
|
||||||
|
openAIBodyString: JSON.stringify(openAIBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = define.lms_url + "/lms/Forward/forward-stream-struct";
|
||||||
|
// 发起请求
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// 使用通用错误处理工具
|
||||||
|
const error = await handleFetchError(response)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理流式响应
|
||||||
|
const reader = response.body!.getReader()
|
||||||
|
const decoder = new TextDecoder('utf-8')
|
||||||
|
let buffer = ''
|
||||||
|
let fullContent = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
// 确保最后的内容也被更新
|
||||||
|
if (onStream) {
|
||||||
|
onStream('', fullContent)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解码数据
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
|
||||||
|
// 按行分割
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || '' // 保留未完成的行
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim()
|
||||||
|
|
||||||
|
// 跳过空行和注释
|
||||||
|
if (!trimmedLine || trimmedLine.startsWith(':')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 SSE 数据
|
||||||
|
if (trimmedLine.startsWith('data: ')) {
|
||||||
|
const data = trimmedLine.slice(6)
|
||||||
|
|
||||||
|
// 检查是否为结束标记
|
||||||
|
if (data === '[DONE]') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
const content = parsed.choices?.[0]?.delta?.content
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
fullContent += content
|
||||||
|
|
||||||
|
// 立即触发更新回调,实现真正的实时流式输出
|
||||||
|
if (onStream) {
|
||||||
|
onStream(content, fullContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录完成原因
|
||||||
|
if (parsed.choices?.[0]?.finish_reason) {
|
||||||
|
console.log('流式响应完成:', parsed.choices[0].finish_reason)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('解析 SSE 数据失败:', e, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用完成回调
|
||||||
|
if (onComplete) {
|
||||||
|
onComplete(fullContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullContent
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('AI 请求失败:', error)
|
||||||
|
|
||||||
|
// handleFetchError 已经处理了 HTTP 错误
|
||||||
|
// 这里只需要处理网络连接等非 HTTP 错误
|
||||||
|
if (!error.type) {
|
||||||
|
// 未被 handleFetchError 处理的错误
|
||||||
|
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
||||||
|
error.message = '网络连接失败,请检查网络设置或API地址是否正确'
|
||||||
|
} else if (error.name === 'AbortError') {
|
||||||
|
error.message = '请求已取消'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用错误回调
|
||||||
|
if (onError) {
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
isGenerating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建消息历史
|
||||||
|
* @param {Array} chatMessages - 聊天消息列表
|
||||||
|
* @param {Object} currentCategory - 当前分类
|
||||||
|
* @param {string} userQuestion - 用户问题(可选,不传则只返回历史消息)
|
||||||
|
* @returns {Array} OpenAI 格式的消息数组
|
||||||
|
*/
|
||||||
|
function buildMessages(
|
||||||
|
chatMessages: ChatMessage[],
|
||||||
|
currentCategory: CopyWritingModel.PromptCategory | null,
|
||||||
|
userQuestion: string | null = null
|
||||||
|
): OpenAIRequest.RequestMessage[] {
|
||||||
|
const messages: OpenAIRequest.RequestMessage[] = []
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
role: 'system',
|
||||||
|
content: "{{SYSTEM}}"
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加历史消息(排除加载中的消息)
|
||||||
|
chatMessages
|
||||||
|
.filter((msg) => !msg.isLoading)
|
||||||
|
.forEach((msg) => {
|
||||||
|
messages.push({
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 仅当传入 userQuestion 时才添加新的用户消息
|
||||||
|
// 注意: 如果 chatMessages 中已包含当前用户问题,则不应传入 userQuestion
|
||||||
|
if (userQuestion) {
|
||||||
|
// 如果有用户提示词模板,进行替换
|
||||||
|
let finalQuestion = userQuestion
|
||||||
|
if (currentCategory?.userContent) {
|
||||||
|
finalQuestion = currentCategory.userContent.replace('{textContent}', userQuestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: finalQuestion
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 估算 token 数量(粗略估算)
|
||||||
|
* @param {string} text - 文本内容
|
||||||
|
* @returns {number} token 数量
|
||||||
|
*/
|
||||||
|
function estimateTokens(text: string): number {
|
||||||
|
if (!text) return 0
|
||||||
|
|
||||||
|
// 粗略估算:中文约 1.5 字/token,英文约 0.25 词/token
|
||||||
|
const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length
|
||||||
|
const englishWords = text.split(/\s+/).filter((word) => /[a-zA-Z]/.test(word)).length
|
||||||
|
|
||||||
|
return Math.ceil(chineseChars / 1.5 + englishWords * 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isGenerating,
|
||||||
|
callOpenAI,
|
||||||
|
buildMessages,
|
||||||
|
estimateTokens,
|
||||||
|
getInferenceSetting,
|
||||||
|
getCopyWritingAISetting
|
||||||
|
}
|
||||||
|
}
|
||||||
406
src/renderer/src/hooks/useBook.ts
Normal file
406
src/renderer/src/hooks/useBook.ts
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
import { h } from 'vue'
|
||||||
|
import { useDialog, useMessage } from 'naive-ui'
|
||||||
|
import { useBookStore, useSoftwareStore } from '@/renderer/src/stores'
|
||||||
|
import { BookType, GetBookTaskDetailStatusLabel } from '@/define/enum/bookEnum'
|
||||||
|
import { getImageCategoryOptions } from '@/define/data/imageData'
|
||||||
|
import AddBook from '@/renderer/src/components/Original/MainHome/OriginalAddBook.vue'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
export function useBook() {
|
||||||
|
const bookStore = useBookStore()
|
||||||
|
const softwareStore = useSoftwareStore()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新项目
|
||||||
|
* @param onSuccess 成功回调函数
|
||||||
|
* @param bookType 项目类型,默认为 ORIGINAL
|
||||||
|
*/
|
||||||
|
const createProject = (onSuccess?: () => void, bookType: BookType = BookType.ORIGINAL) => {
|
||||||
|
bookStore.selectBook = {
|
||||||
|
name: undefined,
|
||||||
|
id: undefined,
|
||||||
|
type: bookType
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.create({
|
||||||
|
content: () =>
|
||||||
|
h(AddBook, {
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('项目创建成功')
|
||||||
|
onSuccess?.()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
style: 'width: 600px;',
|
||||||
|
maskClosable: false,
|
||||||
|
closable: false,
|
||||||
|
showIcon: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载小说/项目数据
|
||||||
|
* @param options 配置选项
|
||||||
|
* @param options.isInitialLoad 是否为初始加载
|
||||||
|
* @param options.comLoading 组件级loading的ref(初始加载时使用)
|
||||||
|
* @param options.loadingTip 加载提示文本
|
||||||
|
* @param options.bookType 项目类型过滤(可选)
|
||||||
|
* @param options.processProgressData 处理进度数据的回调函数
|
||||||
|
* @returns 返回加载的书籍数据数组
|
||||||
|
*/
|
||||||
|
const loadBookInfo = async (options: {
|
||||||
|
isInitialLoad?: boolean
|
||||||
|
comLoading?: { value: boolean }
|
||||||
|
loadingTip?: string
|
||||||
|
bookType?: BookType
|
||||||
|
processProgressData?: (book: any) => void
|
||||||
|
} = {}) => {
|
||||||
|
const {
|
||||||
|
isInitialLoad = false,
|
||||||
|
comLoading = null,
|
||||||
|
loadingTip = t('加载小说信息中...'),
|
||||||
|
bookType = BookType.ORIGINAL,
|
||||||
|
processProgressData = null
|
||||||
|
} = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isInitialLoad && comLoading) {
|
||||||
|
// 初始加载使用组件级别的loading
|
||||||
|
comLoading.value = true
|
||||||
|
} else {
|
||||||
|
// 方法调用使用全局loading
|
||||||
|
softwareStore.spin.spinning = true
|
||||||
|
softwareStore.spin.tip = loadingTip
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageCategoryOptions = getImageCategoryOptions()
|
||||||
|
|
||||||
|
// 构建查询条件
|
||||||
|
const queryCondition = { ...bookStore.queryBookCondition }
|
||||||
|
if (bookType) {
|
||||||
|
queryCondition.type = bookType
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = await window.book.GetBookDataCondition(queryCondition)
|
||||||
|
if (res.code != 1) {
|
||||||
|
message.error(res.message)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookData = res.data.resultBooks // 加载每个的子项目
|
||||||
|
for (let i = 0; i < bookData.length; i++) {
|
||||||
|
const element = bookData[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: string[] = []
|
||||||
|
// 出图方式
|
||||||
|
let imageCategory = imageCategoryOptions.find((item) => item.value === task.imageCategory)
|
||||||
|
if (imageCategory) {
|
||||||
|
tempArray.push(imageCategory.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
let statusObj = GetBookTaskDetailStatusLabel(task.status)
|
||||||
|
tempArray.push(statusObj.label)
|
||||||
|
task.tags = tempArray
|
||||||
|
})
|
||||||
|
|
||||||
|
bookData[i].children = bookTaskRes.data.bookTasks
|
||||||
|
|
||||||
|
// 如果提供了处理进度数据的回调,则调用
|
||||||
|
if (processProgressData) {
|
||||||
|
processProgressData(bookData[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bookData
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(
|
||||||
|
t('加载小说信息失败,{error}', {
|
||||||
|
error: error?.message || error
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
} finally {
|
||||||
|
if (isInitialLoad && comLoading) {
|
||||||
|
// 初始加载结束,显示内容
|
||||||
|
comLoading.value = false
|
||||||
|
} else {
|
||||||
|
// 方法调用结束,隐藏全局loading
|
||||||
|
softwareStore.spin.spinning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取项目进度信息
|
||||||
|
* @description 调用API获取项目中所有任务的图片和视频生成进度
|
||||||
|
* @param projectId 项目ID
|
||||||
|
* @returns 进度数据对象,失败时返回null
|
||||||
|
*/
|
||||||
|
const getProjectProgress = async (projectId: string) => {
|
||||||
|
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 projectId 项目ID
|
||||||
|
* @returns 首图路径数据对象,失败时返回null
|
||||||
|
*/
|
||||||
|
const getProjectFirstImages = async (projectId: string) => {
|
||||||
|
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 project 项目对象
|
||||||
|
* @param progressData 进度数据
|
||||||
|
* @param firstImageData 首图数据
|
||||||
|
*/
|
||||||
|
const updateProjectData = (project: any, progressData: any, firstImageData: any) => {
|
||||||
|
// 遍历项目的子任务,更新进度和首图信息
|
||||||
|
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()
|
||||||
|
|
||||||
|
}
|
||||||
|
console.log('更新后的子任务数据:', project)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载项目进度和首图信息
|
||||||
|
* @description 获取指定项目的图片生成进度和首图路径信息,并更新到项目数据中
|
||||||
|
* @param project 项目对象,包含子任务列表
|
||||||
|
*/
|
||||||
|
const loadProjectProgressData = async (project: any) => {
|
||||||
|
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: any) {
|
||||||
|
message.error(
|
||||||
|
t('加载小说信息失败,{error}', {
|
||||||
|
error: error?.message || error
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载转视频项目数据(包含分镜信息)
|
||||||
|
* @param options 配置选项
|
||||||
|
* @param options.isInitialLoad 是否为初始加载
|
||||||
|
* @param options.comLoading 组件级loading的ref(初始加载时使用)
|
||||||
|
* @param options.loadingTip 加载提示文本
|
||||||
|
* @param options.processProgressData 处理进度数据的回调函数
|
||||||
|
* @returns 返回加载的视频项目数据数组
|
||||||
|
*/
|
||||||
|
const loadVideoBookInfo = async (options: {
|
||||||
|
isInitialLoad?: boolean
|
||||||
|
comLoading?: { value: boolean }
|
||||||
|
loadingTip?: string
|
||||||
|
processProgressData?: (book: any) => void
|
||||||
|
} = {}) => {
|
||||||
|
const {
|
||||||
|
isInitialLoad = false,
|
||||||
|
comLoading = null,
|
||||||
|
loadingTip = t('正在加载任务数据...'),
|
||||||
|
processProgressData = null
|
||||||
|
} = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isInitialLoad && comLoading) {
|
||||||
|
// 初始加载使用组件级别的loading
|
||||||
|
comLoading.value = true
|
||||||
|
} else {
|
||||||
|
// 方法调用使用全局loading
|
||||||
|
softwareStore.spin.spinning = true
|
||||||
|
softwareStore.spin.tip = loadingTip
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageCategoryOptions = getImageCategoryOptions()
|
||||||
|
|
||||||
|
// 加载小说数据和小说批次数据(包含分镜信息)
|
||||||
|
let res = await window.book.video.GetVideoBookInfoList({})
|
||||||
|
if (res.code != 1) {
|
||||||
|
message.error(res.message)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookData = res.data
|
||||||
|
console.log('获取转视频项目数据', res)
|
||||||
|
|
||||||
|
// 加载每个批次数据的 tag
|
||||||
|
for (let i = 0; i < bookData.length; i++) {
|
||||||
|
// 处理每一个 bookTasks,添加一个 tags 属性
|
||||||
|
bookData[i].children.forEach((task) => {
|
||||||
|
let tempArray: string[] = []
|
||||||
|
// 出图方式
|
||||||
|
let imageCategory = imageCategoryOptions.find((item) => item.value === task.imageCategory)
|
||||||
|
if (imageCategory) {
|
||||||
|
tempArray.push(imageCategory.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
let statusObj = GetBookTaskDetailStatusLabel(task.status)
|
||||||
|
tempArray.push(statusObj.label)
|
||||||
|
task.tags = tempArray
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果提供了处理进度数据的回调,则调用
|
||||||
|
if (processProgressData) {
|
||||||
|
processProgressData(bookData[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bookData
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(
|
||||||
|
t('加载数据失败,{error}', {
|
||||||
|
error: error?.message || error
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
} finally {
|
||||||
|
if (isInitialLoad && comLoading) {
|
||||||
|
// 初始加载结束,显示内容
|
||||||
|
comLoading.value = false
|
||||||
|
} else {
|
||||||
|
// 方法调用结束,隐藏全局loading
|
||||||
|
softwareStore.spin.spinning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载小说批次任务的详细分镜数据
|
||||||
|
* @param bookTaskId 小说批次任务ID
|
||||||
|
* @param options 配置选项
|
||||||
|
* @param options.showLoading 是否显示loading状态
|
||||||
|
* @param options.loadingTip 加载提示文本
|
||||||
|
* @returns 返回分镜详细数据数组
|
||||||
|
*/
|
||||||
|
const loadBookTaskDetails = async (
|
||||||
|
bookTaskId: string,
|
||||||
|
options: {
|
||||||
|
showLoading?: boolean
|
||||||
|
loadingTip?: string
|
||||||
|
} = {}
|
||||||
|
) => {
|
||||||
|
const { showLoading = true, loadingTip = t('正在加载分镜数据...') } = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (showLoading) {
|
||||||
|
softwareStore.spin.spinning = true
|
||||||
|
softwareStore.spin.tip = loadingTip
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageCategoryOptions = getImageCategoryOptions()
|
||||||
|
|
||||||
|
// 调用API获取分镜详细数据
|
||||||
|
let res = await window.book.GetBookTaskDetailDataByCondition({
|
||||||
|
bookTaskId: bookTaskId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.code != 1) {
|
||||||
|
message.error(res.message)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskDetails = res.data || []
|
||||||
|
console.log('获取分镜详细数据', taskDetails)
|
||||||
|
|
||||||
|
// 为每个分镜添加 tags 属性
|
||||||
|
taskDetails.forEach((detail) => {
|
||||||
|
let tempArray: string[] = []
|
||||||
|
|
||||||
|
// 出图方式
|
||||||
|
let imageCategory = imageCategoryOptions.find((item) => item.value === detail.imageCategory)
|
||||||
|
if (imageCategory) {
|
||||||
|
tempArray.push(imageCategory.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
let statusObj = GetBookTaskDetailStatusLabel(detail.status)
|
||||||
|
tempArray.push(statusObj.label)
|
||||||
|
|
||||||
|
detail.tags = tempArray
|
||||||
|
})
|
||||||
|
|
||||||
|
return taskDetails
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(
|
||||||
|
t('加载分镜数据失败,{error}', {
|
||||||
|
error: error?.message || error
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
} finally {
|
||||||
|
if (showLoading) {
|
||||||
|
softwareStore.spin.spinning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
createProject,
|
||||||
|
loadBookInfo,
|
||||||
|
loadVideoBookInfo,
|
||||||
|
loadProjectProgressData,
|
||||||
|
loadBookTaskDetails,
|
||||||
|
getProjectProgress,
|
||||||
|
getProjectFirstImages,
|
||||||
|
updateProjectData
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,7 +6,8 @@ import {
|
|||||||
CreateOutline,
|
CreateOutline,
|
||||||
DocumentTextOutline,
|
DocumentTextOutline,
|
||||||
RefreshOutline,
|
RefreshOutline,
|
||||||
TrashOutline
|
TrashOutline,
|
||||||
|
CloseOutline
|
||||||
} from '@vicons/ionicons5'
|
} from '@vicons/ionicons5'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
@ -91,6 +92,18 @@ const mediaToVideoOptions = [
|
|||||||
{
|
{
|
||||||
type: 'divider'
|
type: 'divider'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: () => h('span', { style: 'color: #f0a020' }, t('关闭图转视频')),
|
||||||
|
key: 'close-media-to-video',
|
||||||
|
icon: () =>
|
||||||
|
h('div', { style: 'color: #f0a020; display: flex; align-items: center;' }, [
|
||||||
|
h(NIcon, null, () => h(CloseOutline))
|
||||||
|
]),
|
||||||
|
tooltip: t('关闭图文转视频功能,会将当前任务从图文转视频的任务列表中移除')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: () => h('span', { style: 'color: #e74c3c' }, t('删除任务')),
|
label: () => h('span', { style: 'color: #e74c3c' }, t('删除任务')),
|
||||||
key: 'delete-book-task',
|
key: 'delete-book-task',
|
||||||
|
|||||||
@ -28,6 +28,16 @@ async function initializeLanguage() {
|
|||||||
await detectAndSetLanguage()
|
await detectAndSetLanguage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全局处理 ResizeObserver 错误 (这是一个已知的浏览器问题,可以安全忽略)
|
||||||
|
const resizeObserverLoopErrRe = /^[^(ResizeObserver loop limit exceeded)]/
|
||||||
|
const resizeObserverLoopErrRe2 = /^[^(ResizeObserver loop completed)]/
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
if (resizeObserverLoopErrRe.test(e.message) || resizeObserverLoopErrRe2.test(e.message)) {
|
||||||
|
e.stopImmediatePropagation()
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 初始化语言后再启动应用
|
// 初始化语言后再启动应用
|
||||||
initializeLanguage().then(() => {
|
initializeLanguage().then(() => {
|
||||||
// 创建pinia实例
|
// 创建pinia实例
|
||||||
|
|||||||
@ -35,6 +35,11 @@ const routes = [
|
|||||||
name: 'media-to-video',
|
name: 'media-to-video',
|
||||||
component: () => import('@/renderer/src/components/MediaToVideo/MediaToVideoInfoHome.vue')
|
component: () => import('@/renderer/src/components/MediaToVideo/MediaToVideoInfoHome.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'comic-drama',
|
||||||
|
name: 'comicDrama',
|
||||||
|
component: () => import('../views/ComicDramaHomeView.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'original-book-detail/:id',
|
path: 'original-book-detail/:id',
|
||||||
name: 'OriginalBookDetail',
|
name: 'OriginalBookDetail',
|
||||||
|
|||||||
@ -75,6 +75,14 @@ export const useMenuStore = defineStore('menu', () => {
|
|||||||
isFooter: false,
|
isFooter: false,
|
||||||
isDialog: false
|
isDialog: false
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// label: t('漫剧'),
|
||||||
|
// key: 'comic-drama',
|
||||||
|
// icon: renderIcon(VideoIcon),
|
||||||
|
// route: '/comic-drama',
|
||||||
|
// isFooter: false,
|
||||||
|
// isDialog: false
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
label: t('预设库'),
|
label: t('预设库'),
|
||||||
key: 'preset-library',
|
key: 'preset-library',
|
||||||
|
|||||||
17
src/renderer/src/views/ComicDramaHomeView.vue
Normal file
17
src/renderer/src/views/ComicDramaHomeView.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<div class="comic-drama-home">
|
||||||
|
<ComicDramaHome />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ComicDramaHome from '@/renderer/src/components/ComicDrama/ComicDramaHome.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.comic-drama-home {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -7,24 +7,38 @@
|
|||||||
<CopyWritingCategoryMenu
|
<CopyWritingCategoryMenu
|
||||||
@category-select="handleCategorySelect"
|
@category-select="handleCategorySelect"
|
||||||
@update-simple-settings="handleUpdateSimpleSettings"
|
@update-simple-settings="handleUpdateSimpleSettings"
|
||||||
|
@toggle-mode="handleToggleMode"
|
||||||
:prompt-category="promptCategory"
|
:prompt-category="promptCategory"
|
||||||
:ai-setting="aiSetting"
|
:ai-setting="aiSetting"
|
||||||
:simple-setting="simpleSetting"
|
:simple-setting="simpleSetting"
|
||||||
:select-sub-category="simpleSetting.gptData"
|
:select-sub-category="simpleSetting.gptData"
|
||||||
:select-main-category="simpleSetting.gptType"
|
:select-main-category="simpleSetting.gptType"
|
||||||
|
:current-mode="currentMode"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<n-divider vertical :style="{ height: '100%', margin: '0 0' }" />
|
<n-divider vertical :style="{ height: '100%', margin: '0 0' }" />
|
||||||
|
|
||||||
<!-- 右侧主内容区 - 占满剩余空间 -->
|
<!-- 右侧主内容区 - 占满剩余空间 -->
|
||||||
<div class="main-content" style="flex: 1; overflow: hidden">
|
<div class="main-content" style="flex: 1; overflow: hidden">
|
||||||
<div style="padding: 16px">
|
<!-- 批量文案处理模式 -->
|
||||||
|
<div v-if="currentMode === 'batch'" style="padding: 16px">
|
||||||
<CopyWritingContent
|
<CopyWritingContent
|
||||||
:simple-setting="simpleSetting"
|
:simple-setting="simpleSetting"
|
||||||
@split-save="handleSplitOrMergeOldText"
|
@split-save="handleSplitOrMergeOldText"
|
||||||
@save-simple-setting="handleSaveCWSimpleSetting"
|
@save-simple-setting="handleSaveCWSimpleSetting"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- AI对话模式 -->
|
||||||
|
<AIChatInterface
|
||||||
|
v-else
|
||||||
|
ref="aiChatRef"
|
||||||
|
:current-category="selectedCategory"
|
||||||
|
:ai-setting="aiSetting"
|
||||||
|
v-model:messages="chatMessages"
|
||||||
|
@save-history="handleSaveChatHistory"
|
||||||
|
@load-history="handleLoadChatHistory"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -39,7 +53,9 @@ import { TimeDelay } from '@/define/Tools/time'
|
|||||||
import { useMessage } from 'naive-ui'
|
import { useMessage } from 'naive-ui'
|
||||||
import CopyWritingContent from '@/renderer/src/components/CopyWriting/CopyWritingContent.vue'
|
import CopyWritingContent from '@/renderer/src/components/CopyWriting/CopyWritingContent.vue'
|
||||||
import CopyWritingCategoryMenu from '@/renderer/src/components/CopyWriting/CopyWritingCategoryMenu.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 LoadingComponent from '@/renderer/src/components/common/LoadingComponent.vue'
|
||||||
|
import { NDivider } from 'naive-ui'
|
||||||
import { OptionKeyName, OptionType } from '@/define/enum/option'
|
import { OptionKeyName, OptionType } from '@/define/enum/option'
|
||||||
import { define } from '@/define/define'
|
import { define } from '@/define/define'
|
||||||
import { useMD } from '../hooks/useMD'
|
import { useMD } from '../hooks/useMD'
|
||||||
@ -61,6 +77,23 @@ const simpleSetting = ref({})
|
|||||||
// 数据加载状态
|
// 数据加载状态
|
||||||
const dataLoaded = ref(false)
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
// 不再使用全局 spin,使用组件内的加载状态
|
// 不再使用全局 spin,使用组件内的加载状态
|
||||||
@ -72,6 +105,9 @@ onMounted(async () => {
|
|||||||
// 初始化界面数据
|
// 初始化界面数据
|
||||||
await InitCopyWritingData()
|
await InitCopyWritingData()
|
||||||
|
|
||||||
|
// 加载保存的模式设置
|
||||||
|
await LoadCurrentMode()
|
||||||
|
|
||||||
// 加载提示词数据
|
// 加载提示词数据
|
||||||
await InitServerGptOptions()
|
await InitServerGptOptions()
|
||||||
|
|
||||||
@ -119,6 +155,88 @@ let UpdateWord = debounce((value) => {
|
|||||||
// newWord.value += value
|
// newWord.value += value
|
||||||
}, 300)
|
}, 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) {
|
async function handleCategorySelect(selection) {
|
||||||
console.log('Received category selection:', selection)
|
console.log('Received category selection:', selection)
|
||||||
@ -128,6 +246,9 @@ async function handleCategorySelect(selection) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新选中的分类(用于AI对话模式)
|
||||||
|
selectedCategory.value = selection
|
||||||
|
|
||||||
// 检查数据结构
|
// 检查数据结构
|
||||||
let mainCategoryId, subCategoryId
|
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 = {}) {
|
// async function handleContentSaveCWSimpleSetting(selection = {}) {
|
||||||
// let res = await handleSaveCWSimpleSetting(selection)
|
// let res = await handleSaveCWSimpleSetting(selection)
|
||||||
// if(res) {
|
// if(res) {
|
||||||
@ -239,8 +391,9 @@ async function InitServerGptOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
promptCategory.value = pc
|
promptCategory.value = pc
|
||||||
console.log('Loaded prompt categories:', pc)
|
|
||||||
console.log('Current simpleSetting:', simpleSetting.value)
|
// 初始化选中的分类(用于AI对话模式)
|
||||||
|
await InitSelectedCategory()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showErrorDialog(
|
showErrorDialog(
|
||||||
t('数据加载失败'),
|
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设置
|
* 初始化文案处理AI设置
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -54,7 +54,7 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useBookStore, useSoftwareStore } from '@/renderer/src/stores'
|
import { useBookStore, useSoftwareStore } from '@/renderer/src/stores'
|
||||||
import { useMessage, useDialog } from 'naive-ui'
|
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 { TimeDelay } from '@/define/Tools/time'
|
||||||
import { h } from 'vue'
|
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 EmptyState from '@/renderer/src/components/Original/MainHome/OriginalEmptyState.vue'
|
||||||
import AddBook from '@/renderer/src/components/Original/MainHome/OriginalAddBook.vue'
|
import AddBook from '@/renderer/src/components/Original/MainHome/OriginalAddBook.vue'
|
||||||
import AddBookTask from '@/renderer/src/components/Original/MainHome/OriginalAddBookTask.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'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
const bookStore = useBookStore()
|
const bookStore = useBookStore()
|
||||||
const softwareStore = useSoftwareStore()
|
const softwareStore = useSoftwareStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const { loadVideoBookInfo, loadProjectProgressData } = useBook()
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const bookData = ref([])
|
const bookData = ref([])
|
||||||
@ -92,64 +93,15 @@ onMounted(async () => {
|
|||||||
await loadBookInfo(true) // 初始加载
|
await loadBookInfo(true) // 初始加载
|
||||||
})
|
})
|
||||||
|
|
||||||
// 加载所有的小说数据
|
// 加载所有的小说数据(包含分镜信息)
|
||||||
async function loadBookInfo(isInitialLoad = false) {
|
async function loadBookInfo(isInitialLoad = false) {
|
||||||
try {
|
const data = await loadVideoBookInfo({
|
||||||
if (isInitialLoad) {
|
isInitialLoad,
|
||||||
// 初始加载使用组件级别的loading
|
comLoading: comLoading,
|
||||||
comLoading.value = true
|
loadingTip: t('正在加载任务数据...'),
|
||||||
} else {
|
processProgressData: loadProjectProgressData
|
||||||
// 方法调用使用全局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
|
|
||||||
})
|
})
|
||||||
|
bookData.value = data
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索小说
|
// 搜索小说
|
||||||
@ -246,100 +198,6 @@ async function handleSelectProject(projectId) {
|
|||||||
await loadProjectProgressData(project)
|
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() {
|
function closeMobileSidebar() {
|
||||||
|
|||||||
@ -52,7 +52,7 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useBookStore, useSoftwareStore } from '@/renderer/src/stores'
|
import { useBookStore, useSoftwareStore } from '@/renderer/src/stores'
|
||||||
import { useMessage, useDialog } from 'naive-ui'
|
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 { TimeDelay } from '@/define/Tools/time'
|
||||||
import { h } from 'vue'
|
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 EmptyState from '@/renderer/src/components/Original/MainHome/OriginalEmptyState.vue'
|
||||||
import AddBook from '@/renderer/src/components/Original/MainHome/OriginalAddBook.vue'
|
import AddBook from '@/renderer/src/components/Original/MainHome/OriginalAddBook.vue'
|
||||||
import AddBookTask from '@/renderer/src/components/Original/MainHome/OriginalAddBookTask.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'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
const bookStore = useBookStore()
|
const bookStore = useBookStore()
|
||||||
const softwareStore = useSoftwareStore()
|
const softwareStore = useSoftwareStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const { loadBookInfo: loadBookInfoFromHook, loadProjectProgressData } = useBook()
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const bookData = ref([])
|
const bookData = ref([])
|
||||||
@ -92,67 +93,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// 加载所有的小说数据
|
// 加载所有的小说数据
|
||||||
async function loadBookInfo(isInitialLoad = false) {
|
async function loadBookInfo(isInitialLoad = false) {
|
||||||
try {
|
const data = await loadBookInfoFromHook({
|
||||||
if (isInitialLoad) {
|
isInitialLoad,
|
||||||
// 初始加载使用组件级别的loading
|
comLoading: comLoading,
|
||||||
comLoading.value = true
|
loadingTip: t('加载小说信息中...'),
|
||||||
} else {
|
processProgressData: loadProjectProgressData
|
||||||
// 方法调用使用全局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) {
|
bookData.value = data
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索小说
|
// 搜索小说
|
||||||
@ -253,100 +200,6 @@ async function handleSelectProject(projectId) {
|
|||||||
await loadProjectProgressData(project)
|
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() {
|
function closeMobileSidebar() {
|
||||||
|
|||||||
303
src/utils/httpErrorHandler.README.md
Normal file
303
src/utils/httpErrorHandler.README.md
Normal file
@ -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
|
||||||
|
- 完整的错误类型处理
|
||||||
|
- 友好的中文错误提示
|
||||||
301
src/utils/httpErrorHandler.ts
Normal file
301
src/utils/httpErrorHandler.ts
Normal file
@ -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<HttpError> {
|
||||||
|
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<HttpError> {
|
||||||
|
// 判断是 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<Response> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -11,7 +11,8 @@
|
|||||||
"src/store/**/*",
|
"src/store/**/*",
|
||||||
"src/renderer/**/*",
|
"src/renderer/**/*",
|
||||||
"src/main/**/*",
|
"src/main/**/*",
|
||||||
"src/i18n/**/*"
|
"src/i18n/**/*",
|
||||||
|
"src/utils/**/*"
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user