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",
|
||||
"version": "v4.0.2",
|
||||
"version": "v4.0.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "laitool-pro",
|
||||
"version": "v4.0.2",
|
||||
"version": "v4.0.5",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@alicloud/alimt20181012": "^1.3.0",
|
||||
@ -21,7 +21,10 @@
|
||||
"compressing": "^1.10.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"electron-updater": "^6.3.9",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.25",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^17.0.1",
|
||||
"moment-timezone": "^0.5.48",
|
||||
"music-metadata-browser": "^2.5.11",
|
||||
"node-machine-id": "^1.1.12",
|
||||
@ -5546,7 +5549,8 @@
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"dev": true,
|
||||
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@ -6249,6 +6253,31 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.25",
|
||||
"resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.25.tgz",
|
||||
"integrity": "sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^8.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/katex/node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"license": "MIT",
|
||||
@ -6544,6 +6573,18 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "17.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/marked/-/marked-17.0.1.tgz",
|
||||
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/matcher": {
|
||||
"version": "3.0.0",
|
||||
"license": "MIT",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "laitool-pro",
|
||||
"productName": "LaiToolPro",
|
||||
"version": "v4.0.5",
|
||||
"version": "v4.0.6",
|
||||
"description": "来推 Pro - 一款集音频处理、文案生成、图片生成、视频生成等功能于一体的多合一AI工具软件。",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "xiangbei",
|
||||
@ -39,7 +39,10 @@
|
||||
"compressing": "^1.10.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"electron-updater": "^6.3.9",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.25",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^17.0.1",
|
||||
"moment-timezone": "^0.5.48",
|
||||
"music-metadata-browser": "^2.5.11",
|
||||
"node-machine-id": "^1.1.12",
|
||||
|
||||
@ -221,11 +221,17 @@ export class BookService extends RealmBaseService {
|
||||
} else if (book.type == BookType.ORIGINAL) {
|
||||
let generalSetting = await getGeneralSetting()
|
||||
imageCategory = generalSetting.defaultImgGenMethod ?? ImageCategory.Midjourney
|
||||
} else if (book.type == BookType.COMIC_DRAMA) {
|
||||
imageCategory = ImageCategory.Midjourney
|
||||
} else {
|
||||
throw new Error(t('未知的小说类型'))
|
||||
}
|
||||
const srtHandle = new SrtHandle()
|
||||
let srtData = await srtHandle.GetSrtDataByPath(book.srtPath as string)
|
||||
let srtData;
|
||||
// 漫剧不用处理srt
|
||||
if (book.type != BookType.COMIC_DRAMA) {
|
||||
const srtHandle = new SrtHandle()
|
||||
srtData = await srtHandle.GetSrtDataByPath(book.srtPath as string)
|
||||
}
|
||||
|
||||
let bookTaskDetailService = await BookTaskDetailService.getInstance()
|
||||
|
||||
@ -258,29 +264,33 @@ export class BookService extends RealmBaseService {
|
||||
// 添加任务
|
||||
this.realm.create('BookTask', bookTask)
|
||||
|
||||
// 循环添加小说详细信息
|
||||
for (let i = 0; i < srtData.length; i++) {
|
||||
const element = srtData[i]
|
||||
bookTaskDetailService.AddBookTaskDetail({
|
||||
bookTaskId: bookTask.id,
|
||||
bookId: bookTask.bookId,
|
||||
startTime: element.start,
|
||||
endTime: element.end,
|
||||
status: BookTaskStatus.WAIT,
|
||||
word: element.text,
|
||||
afterGpt: element.text,
|
||||
subValue: JSON.stringify([
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
end_time: element.end,
|
||||
start_time: element.start,
|
||||
srt_value: element.text
|
||||
}
|
||||
] as BookTaskDetail.CopywritingSubValue[]),
|
||||
timeLimit: `${element.start} -- ${element.end}`,
|
||||
// 新增修脸跟随
|
||||
adetailer: false // 默认false,实际更具SD设置中为主
|
||||
})
|
||||
// 如果是小说或者是反推的话,添加详细的任务信息
|
||||
if (!(book.type == BookType.COMIC_DRAMA || srtData == undefined)) {
|
||||
|
||||
// 循环添加小说详细信息
|
||||
for (let i = 0; i < srtData.length; i++) {
|
||||
const element = srtData[i]
|
||||
bookTaskDetailService.AddBookTaskDetail({
|
||||
bookTaskId: bookTask.id,
|
||||
bookId: bookTask.bookId,
|
||||
startTime: element.start,
|
||||
endTime: element.end,
|
||||
status: BookTaskStatus.WAIT,
|
||||
word: element.text,
|
||||
afterGpt: element.text,
|
||||
subValue: JSON.stringify([
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
end_time: element.end,
|
||||
start_time: element.start,
|
||||
srt_value: element.text
|
||||
}
|
||||
] as BookTaskDetail.CopywritingSubValue[]),
|
||||
timeLimit: `${element.start} -- ${element.end}`,
|
||||
// 新增修脸跟随
|
||||
adetailer: false // 默认false,实际更具SD设置中为主
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -380,6 +380,19 @@ export class BookTaskService extends RealmBaseService {
|
||||
addBookTaskDetail.push(addOneBookTaskDetail)
|
||||
}
|
||||
}
|
||||
|
||||
// 数据处理完毕,开始新增数据
|
||||
// 将所有的复制才做,全部放在一个事务中
|
||||
this.transaction(() => {
|
||||
for (let i = 0; i < addBookTask.length; i++) {
|
||||
const element = addBookTask[i];
|
||||
this.realm.create('BookTask', element)
|
||||
}
|
||||
for (let i = 0; i < addBookTaskDetail.length; i++) {
|
||||
const element = addBookTaskDetail[i];
|
||||
this.realm.create('BookTaskDetail', element)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
@ -9,7 +9,9 @@ export enum BookType {
|
||||
// 反推
|
||||
SD_REVERSE = 'sd_reverse',
|
||||
// MJ 反推
|
||||
MJ_REVERSE = 'mj_reverse'
|
||||
MJ_REVERSE = 'mj_reverse',
|
||||
// 漫剧
|
||||
COMIC_DRAMA = 'comic_drama'
|
||||
}
|
||||
|
||||
/**
|
||||
@ -25,6 +27,8 @@ export function GetBookTypeLabel(type: BookType) {
|
||||
return t('SD反推')
|
||||
case BookType.MJ_REVERSE:
|
||||
return t('MJ反推')
|
||||
case BookType.COMIC_DRAMA:
|
||||
return t('漫剧')
|
||||
default:
|
||||
return t('未知类型')
|
||||
}
|
||||
@ -38,7 +42,8 @@ export function GetBookTypeOptions() {
|
||||
return [
|
||||
{ label: t('原创'), value: BookType.ORIGINAL },
|
||||
{ label: t('SD反推'), value: BookType.SD_REVERSE },
|
||||
{ label: t('MJ反推'), value: BookType.MJ_REVERSE }
|
||||
{ label: t('MJ反推'), value: BookType.MJ_REVERSE },
|
||||
{ label: t('漫剧'), value: BookType.COMIC_DRAMA }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@ -80,7 +80,10 @@ export const OptionKeyName = {
|
||||
CW_SimpleSetting: 'InferenceAI_CW_SimpleSetting',
|
||||
|
||||
/** 文案相关的特殊字符串 */
|
||||
CW_FormatSpecialChar: 'InferenceAI_CW_FormatSpecialChar'
|
||||
CW_FormatSpecialChar: 'InferenceAI_CW_FormatSpecialChar',
|
||||
|
||||
/** 文案处理 当前模式(batch/chat) */
|
||||
CW_CurrentMode: 'InferenceAI_CW_CurrentMode'
|
||||
},
|
||||
SD: {
|
||||
/** SD基础设置 */
|
||||
|
||||
@ -114,6 +114,11 @@ function SystemIpc() {
|
||||
async (_, filePath: string) => await electronInterface.OpenFile(filePath)
|
||||
)
|
||||
|
||||
/** 在资源管理器中显示文件位置 */
|
||||
ipcMain.on('open-file-location', (_, filePath: string) => {
|
||||
electronInterface.ShowFileInFolder(filePath)
|
||||
})
|
||||
|
||||
/** 复制指定的文件夹中的所有内容到另一个文件夹 */
|
||||
ipcMain.handle(
|
||||
DEFINE_STRING.SYSTEM.COPY_FOLDER_CONTENTS,
|
||||
|
||||
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",
|
||||
"登录/注册": "Login/Register",
|
||||
"开启图转视频": "Enable Image to Video",
|
||||
"切换到{mode}模式": "Switch to {mode} mode",
|
||||
"批量文案处理": "Batch Text Processing",
|
||||
"AI对话": "AI Chat",
|
||||
"已切换到{mode}模式": "Switched to {mode} mode",
|
||||
"未选择分类": "No category selected",
|
||||
"请选择一个分类开始对话": "Please select a category to start chatting",
|
||||
"开始与AI对话,获取创作灵感": "Start chatting with AI to get creative inspiration",
|
||||
"你": "You",
|
||||
"AI正在生成回复...": "AI is generating a response...",
|
||||
"输入你的问题...": "Enter your question...",
|
||||
"Ctrl + Enter 发送": "Ctrl + Enter to send",
|
||||
"清空对话": "Clear Chat",
|
||||
"切换分类将清空当前对话记录,是否继续?": "Switching categories will clear current chat history, continue?",
|
||||
"请先选择一个分类": "Please select a category first",
|
||||
"生成回复失败: {error}": "Failed to generate response: {error}",
|
||||
"确定要清空所有对话记录吗?": "Are you sure you want to clear all chat history?",
|
||||
"对话记录已清空": "Chat history cleared",
|
||||
}
|
||||
@ -2078,4 +2078,21 @@ export default {
|
||||
"云端使用推荐仙宫云": "云端使用推荐仙宫云",
|
||||
"登录/注册": "登录/注册",
|
||||
"开启图转视频" : "开启图转视频",
|
||||
"切换到{mode}模式": "切换到{mode}模式",
|
||||
"批量文案处理": "批量文案处理",
|
||||
"AI对话": "AI对话",
|
||||
"已切换到{mode}模式": "已切换到{mode}模式",
|
||||
"未选择分类": "未选择分类",
|
||||
"请选择一个分类开始对话": "请选择一个分类开始对话",
|
||||
"开始与AI对话,获取创作灵感": "开始与AI对话,获取创作灵感",
|
||||
"你": "你",
|
||||
"AI正在生成回复...": "AI正在生成回复...",
|
||||
"输入你的问题...": "输入你的问题...",
|
||||
"Ctrl + Enter 发送": "Ctrl + Enter 发送",
|
||||
"清空对话": "清空对话",
|
||||
"切换分类将清空当前对话记录,是否继续?": "切换分类将清空当前对话记录,是否继续?",
|
||||
"请先选择一个分类": "请先选择一个分类",
|
||||
"生成回复失败: {error}": "生成回复失败: {error}",
|
||||
"确定要清空所有对话记录吗?": "确定要清空所有对话记录吗?",
|
||||
"对话记录已清空": "对话记录已清空",
|
||||
}
|
||||
@ -374,17 +374,32 @@ export class BookPromptHandle extends BookBasicHandle {
|
||||
let newData: BookTask.BookTaskCharacterAndSceneObject[] = []
|
||||
for (let i = 0; i < returnData.length; i++) {
|
||||
const element = returnData[i]
|
||||
let splitData = element.split(':')
|
||||
if (splitData.length < 2) {
|
||||
continue
|
||||
if (type == PresetCategory.Character) {
|
||||
|
||||
let splitData = element.split(':')
|
||||
if (splitData.length < 2) {
|
||||
continue
|
||||
}
|
||||
let tempData = {
|
||||
no: i + 1,
|
||||
id: crypto.randomUUID(),
|
||||
name: splitData[0],
|
||||
prompt: splitData[1]
|
||||
} as BookTask.BookTaskCharacterAndSceneObject
|
||||
newData.push(tempData)
|
||||
} else if (type == PresetCategory.Scene) {
|
||||
let splitData = element.split('.')
|
||||
if (splitData.length < 3) {
|
||||
continue
|
||||
}
|
||||
let tempData = {
|
||||
no: Number(splitData[0]),
|
||||
id: crypto.randomUUID(),
|
||||
name: splitData[1],
|
||||
prompt: splitData[2]
|
||||
} as BookTask.BookTaskCharacterAndSceneObject
|
||||
newData.push(tempData)
|
||||
}
|
||||
let tempData = {
|
||||
no: i + 1,
|
||||
id: crypto.randomUUID(),
|
||||
name: splitData[0],
|
||||
prompt: splitData[1]
|
||||
} as BookTask.BookTaskCharacterAndSceneObject
|
||||
newData.push(tempData)
|
||||
}
|
||||
|
||||
if (type == PresetCategory.Character) {
|
||||
|
||||
@ -41,6 +41,16 @@ export default class ElectronInterface {
|
||||
}), 'SystemIpc_OPEN_FILE')
|
||||
}
|
||||
|
||||
/**
|
||||
* 在资源管理器中显示文件位置
|
||||
* @param filePath 文件路径
|
||||
*/
|
||||
public ShowFileInFolder(filePath: string): void {
|
||||
if (filePath) {
|
||||
shell.showItemInFolder(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度复制文件夹内容到目标文件夹
|
||||
* @param source 源文件夹路径
|
||||
|
||||
12
src/renderer/components.d.ts
vendored
12
src/renderer/components.d.ts
vendored
@ -9,6 +9,7 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AddOrModifyPreset: typeof import('./src/components/Preset/AddOrModifyPreset.vue')['default']
|
||||
AIChatInterface: typeof import('./src/components/CopyWriting/AIChatInterface.vue')['default']
|
||||
AIGroup: typeof import('./src/components/Original/Copywriter/AIGroup.vue')['default']
|
||||
AISetting: typeof import('./src/components/Setting/InferenceSetting/AISetting.vue')['default']
|
||||
AllImagePreview: typeof import('./src/components/Original/BookTaskDetail/AllImagePreview.vue')['default']
|
||||
@ -21,7 +22,13 @@ declare module 'vue' {
|
||||
ComfyUIAddWorkflow: typeof import('./src/components/Setting/ComfyUIAddWorkflow.vue')['default']
|
||||
ComfyUIImageToVideoInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoConfyUI/ComfyUIImageToVideoInfo.vue')['default']
|
||||
ComfyUISetting: typeof import('./src/components/Setting/ComfyUISetting.vue')['default']
|
||||
ComicDramaHome: typeof import('./src/components/ComicDrama/ComicDramaHome.vue')['default']
|
||||
ComicDramaProjectSelection: typeof import('./src/components/ComicDrama/ComicDramaInfo/ComicDramaProjectSelection.vue')['default']
|
||||
ComicDramaShotCard: typeof import('./src/components/ComicDrama/ComicDramaInfo/GridMode/ComicDramaShotCard.vue')['default']
|
||||
ComicDramaShotCardList: typeof import('./src/components/ComicDrama/ComicDramaInfo/ListMode/ComicDramaShotCardList.vue')['default']
|
||||
ComicDramaWorkspace: typeof import('./src/components/ComicDrama/ComicDramaInfo/ComicDramaWorkspace.vue')['default']
|
||||
CommonDialog: typeof import('./src/components/common/CommonDialog.vue')['default']
|
||||
ComparisonPanel: typeof import('./src/components/ComicDrama/ComicDramaInfo/ComparisonPanel.vue')['default']
|
||||
ConfigOptionGroup: typeof import('./src/components/common/ConfigOptionGroup.vue')['default']
|
||||
ContactDeveloper: typeof import('./src/components/SoftHome/ContactDeveloper.vue')['default']
|
||||
CopyWritingCategoryMenu: typeof import('./src/components/CopyWriting/CopyWritingCategoryMenu.vue')['default']
|
||||
@ -46,6 +53,7 @@ declare module 'vue' {
|
||||
EditWord: typeof import('./src/components/Original/Copywriter/EditWord.vue')['default']
|
||||
FindReplaceRound: typeof import('./src/components/common/Icon/FindReplaceRound.vue')['default']
|
||||
GeneralSettings: typeof import('./src/components/Setting/GeneralSettings.vue')['default']
|
||||
GlobalConfigBar: typeof import('./src/components/ComicDrama/ComicDramaInfo/GlobalConfigBar.vue')['default']
|
||||
HailuoFirstLastFrameInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoHaiLuo/HailuoFirstLastFrameInfo.vue')['default']
|
||||
HailuoImageToVideoInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoHaiLuo/HailuoImageToVideoInfo.vue')['default']
|
||||
HailuoTextToVideoInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoHaiLuo/HailuoTextToVideoInfo.vue')['default']
|
||||
@ -62,6 +70,7 @@ declare module 'vue' {
|
||||
LanguageSwitcher: typeof import('./src/components/common/LanguageSwitcher.vue')['default']
|
||||
LoadingComponent: typeof import('./src/components/common/LoadingComponent.vue')['default']
|
||||
ManageAISetting: typeof import('./src/components/CopyWriting/ManageAISetting.vue')['default']
|
||||
MarkdownRenderer: typeof import('./src/components/CopyWriting/MarkdownRenderer.vue')['default']
|
||||
MediaToVideoInfoBasicInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoBasicInfo.vue')['default']
|
||||
MediaToVideoInfoComfyUIInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoConfyUI/MediaToVideoInfoComfyUIInfo.vue')['default']
|
||||
MediaToVideoInfoConfig: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoConfig.vue')['default']
|
||||
@ -147,6 +156,7 @@ declare module 'vue' {
|
||||
OriginalTaskList: typeof import('./src/components/Original/MainHome/OriginalTaskList.vue')['default']
|
||||
OriginalViewBookInfo: typeof import('./src/components/Original/MainHome/OriginalViewBookInfo.vue')['default']
|
||||
OriginalViewBookTaskInfo: typeof import('./src/components/Original/MainHome/OriginalViewBookTaskInfo.vue')['default']
|
||||
PlaybackBar: typeof import('./src/components/ComicDrama/ComicDramaInfo/PlaybackBar.vue')['default']
|
||||
PointRightIcon: typeof import('./src/components/common/Icon/PointRightIcon.vue')['default']
|
||||
PresetShowCard: typeof import('./src/components/Preset/PresetShowCard.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
@ -157,6 +167,7 @@ declare module 'vue' {
|
||||
SearchPresetArea: typeof import('./src/components/Preset/SearchPresetArea.vue')['default']
|
||||
SelectRegionImage: typeof import('./src/components/Original/Image/SelectRegionImage.vue')['default']
|
||||
SelectStylePreset: typeof import('./src/components/Preset/SelectStylePreset.vue')['default']
|
||||
SourceInputColumn: typeof import('./src/components/ComicDrama/ComicDramaInfo/ListMode/SourceInputColumn.vue')['default']
|
||||
StylePreset: typeof import('./src/components/Preset/StylePreset.vue')['default']
|
||||
TextEllipsis: typeof import('./src/components/common/TextEllipsis.vue')['default']
|
||||
ToolGrid: typeof import('./src/components/ToolBox/ToolGrid.vue')['default']
|
||||
@ -165,6 +176,7 @@ declare module 'vue' {
|
||||
TopMenuButtons: typeof import('./src/components/Original/BookTaskDetail/TopMenuButtons.vue')['default']
|
||||
UploadRound: typeof import('./src/components/common/Icon/UploadRound.vue')['default']
|
||||
UserAnalysis: typeof import('./src/components/Original/Analysis/UserAnalysis.vue')['default']
|
||||
VersionComparisonModal: typeof import('./src/components/ComicDrama/ComicDramaInfo/VersionComparisonModal.vue')['default']
|
||||
VideoDisplay: typeof import('./src/components/common/VideoDisplay.vue')['default']
|
||||
WechatGroup: typeof import('./src/components/SoftHome/WechatGroup.vue')['default']
|
||||
WordGroup: typeof import('./src/components/Original/Copywriter/WordGroup.vue')['default']
|
||||
|
||||
@ -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>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
<!-- 折叠面板,小分类选项 -->
|
||||
@ -53,7 +70,7 @@
|
||||
<template #header>
|
||||
<div style="font-size: 14px; font-weight: 500">{{ subCategory.name }}</div>
|
||||
</template>
|
||||
<div style="font-size: 12px; color: #909399; line-height: 1.4">
|
||||
<div style="font-size: 12px; line-height: 1.4">
|
||||
{{ subCategory.description }}
|
||||
</div>
|
||||
</n-card>
|
||||
@ -71,7 +88,7 @@ import { useDialog, NSelect, NCollapse, NCollapseItem, NCard, NIcon } from 'naiv
|
||||
import { useThemeStore } from '@/renderer/src/stores'
|
||||
import { isEmpty } from 'lodash'
|
||||
import TooltipButton from '@/renderer/src/components/common/TooltipButton.vue'
|
||||
import { SettingsOutline } from '@vicons/ionicons5'
|
||||
import { SettingsOutline, SwapHorizontalOutline } from '@vicons/ionicons5'
|
||||
import ManageAISetting from './ManageAISetting.vue'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
@ -98,11 +115,15 @@ const props = defineProps({
|
||||
selectMainCategory: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
currentMode: {
|
||||
type: String,
|
||||
default: 'batch' // 'batch' 或 'chat'
|
||||
}
|
||||
})
|
||||
|
||||
// 定义 emits
|
||||
const emit = defineEmits(['category-select', 'update-simple-settings'])
|
||||
const emit = defineEmits(['category-select', 'update-simple-settings', 'toggle-mode'])
|
||||
|
||||
// 菜单相关数据
|
||||
const selectedMainCategory = ref(null) // 只用于select显示,仅手动选择时有值
|
||||
@ -209,6 +230,11 @@ function handleSettingClick() {
|
||||
})
|
||||
}
|
||||
|
||||
// 处理模式切换
|
||||
function handleToggleMode() {
|
||||
emit('toggle-mode')
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
selectedMainCategory,
|
||||
@ -277,7 +303,7 @@ const selectCardBackgroundColor = computed(() => {
|
||||
|
||||
.selected-card {
|
||||
border-color: v-bind(selectCardColor) !important;
|
||||
box-shadow: 0 2px 8px rgba(24, 160, 88, 0.2) !important;
|
||||
box-shadow: 0 2px 8px v-bind(selectCardColor) !important;
|
||||
background-color: v-bind(selectCardBackgroundColor) !important;
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
|
||||
<TextEllipsis
|
||||
:show-full="true"
|
||||
:show-full="false"
|
||||
customClass="table-header"
|
||||
:text="`${bookStore.selectBook.name} - ${bookStore.selectBookTask.name}`"
|
||||
></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>
|
||||
</div>
|
||||
|
||||
@ -53,21 +75,25 @@ import {
|
||||
NSpace,
|
||||
NText,
|
||||
NButton,
|
||||
NButtonGroup,
|
||||
NIcon,
|
||||
NDataTable,
|
||||
NSwitch,
|
||||
NImage,
|
||||
NSelect,
|
||||
NDropdown,
|
||||
NTooltip,
|
||||
useMessage,
|
||||
useDialog
|
||||
} from 'naive-ui'
|
||||
import { ArrowBackOutline } from '@vicons/ionicons5'
|
||||
import { ArrowBackOutline, ChevronDownOutline } from '@vicons/ionicons5'
|
||||
import { define } from '@/define/define'
|
||||
import ImageTextVideoInfoVideoConfig from './MediaToVideoInfoVideoConfig.vue'
|
||||
import ImageTextVideoInfoVideoListInfo from './MediaToVideoInfoVideoListInfo.vue'
|
||||
import ImageTextVideoInfoTaskOptions from './MediaToVideoInfoTaskOptions.vue'
|
||||
import VideoDisplay from '@/renderer/src/components/common/VideoDisplay.vue'
|
||||
import MediaToVideoVideoConfigHeader from './MediaToVideoVideoConfigHeader.vue'
|
||||
import TooltipDropdown from '@/renderer/src/components/common/TooltipDropdown.vue'
|
||||
import { OptionKeyName, OptionType } from '@/define/enum/option'
|
||||
import { useBookStore, useSoftwareStore } from '@/renderer/src/stores'
|
||||
import { GetImageToVideoModelsOptions, ImageToVideoModels } from '@/define/enum/video'
|
||||
@ -79,6 +105,7 @@ import { BookBackTaskType, TaskExecuteType } from '@/define/enum/bookEnum'
|
||||
import { DEFINE_STRING } from '@/define/ipcDefineString'
|
||||
import { AddOneTask, StopToVideoTask } from '@/renderer/src/common/task'
|
||||
import { useMD } from '@/renderer/src/hooks/useMD'
|
||||
import { checkVideoExists } from '@/renderer/src/common/image'
|
||||
|
||||
const bookStore = useBookStore()
|
||||
const softwareStore = useSoftwareStore()
|
||||
@ -119,6 +146,24 @@ const batchVideoType = ref(null)
|
||||
// 视频类型选项
|
||||
const videoTypeOptions = GetImageToVideoModelsOptions()
|
||||
|
||||
const taskActionOptions = [
|
||||
{
|
||||
label: t('提交未生成转视频任务'),
|
||||
tooltip: t('将所有未生成视频且类型为 ComfyUI 的分镜,一键提交生成视频任务。'),
|
||||
key: 'submit-ungenerated-tasks'
|
||||
},
|
||||
{
|
||||
label: t('停止当前批次转视频任务'),
|
||||
tooltip: t('停止当前批次中所有正在等待的转视频任务。'),
|
||||
key: 'stop-current-batch-tasks'
|
||||
},
|
||||
{
|
||||
label: t('停止所有转视频任务'),
|
||||
tooltip: t('停止所有正在等待的转视频任务。'),
|
||||
key: 'stop-all-video-tasks'
|
||||
}
|
||||
]
|
||||
|
||||
// 监听 props 变化,同步更新本地状态
|
||||
watch(
|
||||
() => props.showRightPanel,
|
||||
@ -610,6 +655,84 @@ async function handleOneClickToVideo() {
|
||||
})
|
||||
}
|
||||
|
||||
// 一键提交未生成转视频任务
|
||||
async function handleSubmitUngeneratedTasks() {
|
||||
let da = dialog.warning({
|
||||
title: t('操作确认'),
|
||||
content: () =>
|
||||
h(
|
||||
DialogTextContent,
|
||||
{
|
||||
text: t(
|
||||
`该操作会将所有未生成视频(没有可用视频)且类型为 ComfyUI 的分镜,添加到转视频任务队列中。\n\n是否继续?`
|
||||
)
|
||||
},
|
||||
{}
|
||||
),
|
||||
negativeText: t('取消'),
|
||||
positiveText: t('继续'),
|
||||
onPositiveClick: async () => {
|
||||
da?.destroy()
|
||||
await TimeDelay(200)
|
||||
try {
|
||||
softwareStore.spin.spinning = true
|
||||
softwareStore.spin.tip = t('正在提交未生成任务...')
|
||||
let taskCount = 0
|
||||
// 开始循环添加任务
|
||||
for (let i = 0; i < bookStore.selectBookTaskDetail.length; i++) {
|
||||
let element = bookStore.selectBookTaskDetail[i]
|
||||
// 只处理 ComfyUI 类型
|
||||
if (element.videoMessage?.videoType != ImageToVideoModels.COMFY_UI) {
|
||||
continue
|
||||
}
|
||||
// 检查是否已生成视频
|
||||
if (element.generateVideoPath && !isEmpty(element.generateVideoPath)) {
|
||||
// 检查视频文件是不是存在,存在就跳过
|
||||
let checkVideoExist = await checkVideoExists(element.generateVideoPath.split('?t=')[0])
|
||||
if (checkVideoExist) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
let res = await AddOneTask({
|
||||
bookId: element.bookId,
|
||||
type: BookBackTaskType.COMFYUI_VIDEO,
|
||||
executeType: TaskExecuteType.AUTO,
|
||||
bookTaskId: element.bookTaskId,
|
||||
bookTaskDetailId: element.id,
|
||||
messageName: DEFINE_STRING.BOOK.COMFYUI_TO_VIDEO_RETURN
|
||||
})
|
||||
|
||||
if (res.code != 1) {
|
||||
message.error(res.message)
|
||||
return
|
||||
}
|
||||
taskCount++
|
||||
}
|
||||
if (taskCount === 0) {
|
||||
showErrorDialog(t('提示'), t('没有符合条件的未生成任务'))
|
||||
return
|
||||
}
|
||||
await TimeDelay(500)
|
||||
showSuccessDialog(
|
||||
t('成功'),
|
||||
t('已成功添加 {taskCount} 个转视频任务到队列中', { taskCount })
|
||||
)
|
||||
} catch (error) {
|
||||
showErrorDialog(t('失败'), t('提交任务失败,{error}', { error: error.message }))
|
||||
} finally {
|
||||
da?.destroy()
|
||||
softwareStore.spin.spinning = false
|
||||
}
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
message.info(t('取消操作'))
|
||||
},
|
||||
closable: true,
|
||||
maskClosable: false
|
||||
})
|
||||
}
|
||||
|
||||
// 停止生成图片的操作方法
|
||||
async function handleStopToVideoTask(content, key) {
|
||||
let da = dialog.warning({
|
||||
@ -655,6 +778,9 @@ async function handleActionDropdownSelect(key) {
|
||||
case 'one-click-to-video':
|
||||
await handleOneClickToVideo()
|
||||
break
|
||||
case 'submit-ungenerated-tasks':
|
||||
await handleSubmitUngeneratedTasks()
|
||||
break
|
||||
case 'stop-current-batch-tasks':
|
||||
await handleStopToVideoTask(
|
||||
t(
|
||||
|
||||
@ -79,21 +79,6 @@ const blockOptions = computed(() => {
|
||||
label: t('同步生图提示词'),
|
||||
tooltip: t('同步当前分镜的生图提示词到图转视频的提示词中。'),
|
||||
key: 'sync-image-prompts'
|
||||
},
|
||||
{
|
||||
label: t('一键提交转视频任务'),
|
||||
tooltip: t('将所有转视频类型为 ComfyUI 的分镜,一键提交生成视频任务。'),
|
||||
key: 'one-click-to-video'
|
||||
},
|
||||
{
|
||||
label: t('停止当前批次转视频任务'),
|
||||
tooltip: t('停止当前批次中所有正在等待的转视频任务。'),
|
||||
key: 'stop-current-batch-tasks'
|
||||
},
|
||||
{
|
||||
label: t('停止所有转视频任务'),
|
||||
tooltip: t('停止所有正在等待的转视频任务。'),
|
||||
key: 'stop-all-video-tasks'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<template #input="{ submit, deactivate }">
|
||||
<n-select
|
||||
size="small"
|
||||
|
||||
style="width: 100px"
|
||||
v-model:value="selectedValue"
|
||||
:options="show_options"
|
||||
:placeholder="placeholder"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<n-card class="add-book-form">
|
||||
<template #header>
|
||||
<div class="card-title">{{ type == 'edit' ? t('编辑小说') : t('添加新小说') }}</div>
|
||||
<div class="card-title">{{ type == 'edit' ? t('编辑项目') : t('添加新项目') }}</div>
|
||||
</template>
|
||||
<n-form
|
||||
ref="formRef"
|
||||
@ -33,8 +33,8 @@
|
||||
/>
|
||||
</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
|
||||
v-model:value="bookStore.selectBook.audioPath"
|
||||
@ -48,8 +48,8 @@
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
|
||||
<!-- SRT地址 -->
|
||||
<n-form-item :label="t('SRT文件')" path="srtPath">
|
||||
<!-- SRT地址 (仅原创类型显示) -->
|
||||
<n-form-item v-if="isOriginalType" :label="t('SRT文件')" path="srtPath">
|
||||
<n-input-group>
|
||||
<n-input
|
||||
v-model:value="bookStore.selectBook.srtPath"
|
||||
@ -111,11 +111,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { FolderOpen } from '@vicons/ionicons5'
|
||||
import { useMessage } from 'naive-ui'
|
||||
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 { TimeDelay } from '@/define/Tools/time'
|
||||
import { t } from '@/i18n'
|
||||
@ -137,42 +137,51 @@ const loading = ref(false)
|
||||
let type = ref(props.type ? props.type : 'add')
|
||||
|
||||
// 小说类型选项
|
||||
const typeOptions = [
|
||||
{ label: t('原创'), value: BookType.ORIGINAL },
|
||||
{ label: t('SD反推'), value: BookType.SD_REVERSE },
|
||||
{ label: t('MJ反推'), value: BookType.MJ_REVERSE }
|
||||
]
|
||||
const typeOptions = GetBookTypeOptions()
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
title: [
|
||||
{
|
||||
required: true,
|
||||
message: t('请输入 {data}', {
|
||||
data: t('小说名称')
|
||||
}),
|
||||
trigger: ['blur', 'input']
|
||||
}
|
||||
],
|
||||
audioPath: [
|
||||
{
|
||||
required: true,
|
||||
message: t('请选择 {data}', {
|
||||
data: t('配音地址')
|
||||
}),
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
srtPath: [
|
||||
{
|
||||
required: true,
|
||||
message: t('请选择 {data}', {
|
||||
data: 'SRT文件'
|
||||
}),
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
// 判断是否为原创类型
|
||||
const isOriginalType = computed(() => {
|
||||
return bookStore.selectBook.type === BookType.ORIGINAL
|
||||
})
|
||||
|
||||
// 表单验证规则 - 根据项目类型动态生成
|
||||
const rules = computed(() => {
|
||||
const baseRules = {
|
||||
title: [
|
||||
{
|
||||
required: true,
|
||||
message: t('请输入 {data}', {
|
||||
data: t('小说名称')
|
||||
}),
|
||||
trigger: ['blur', 'input']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 仅原创类型需要配音和SRT文件
|
||||
if (isOriginalType.value) {
|
||||
baseRules.audioPath = [
|
||||
{
|
||||
required: true,
|
||||
message: t('请选择 {data}', {
|
||||
data: t('配音地址')
|
||||
}),
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
baseRules.srtPath = [
|
||||
{
|
||||
required: true,
|
||||
message: t('请选择 {data}', {
|
||||
data: 'SRT文件'
|
||||
}),
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return baseRules
|
||||
})
|
||||
|
||||
// 选择配音文件
|
||||
const selectAudioFile = async () => {
|
||||
|
||||
@ -217,6 +217,7 @@ import TooltipDropdown from '../../common/TooltipDropdown.vue'
|
||||
import OriginalModifyBookTask from './OriginalModifyBookTask.vue'
|
||||
import OriginalViewBookTaskInfo from './OriginalViewBookTaskInfo.vue'
|
||||
import JianyingGenerateInformation from '../BookTaskDetail/JianyingGenerateInformation.vue'
|
||||
import DialogTextContent from '../../common/DialogTextContent.vue'
|
||||
import { BookOptionType } from '@/renderer/src/components/Original/MainHome/bookType'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { OptionKeyName } from '@/define/enum/option'
|
||||
@ -302,7 +303,10 @@ async function handleTaskAction(key) {
|
||||
editBookTask(props.bookTask)
|
||||
break
|
||||
case 'open-media-to-video':
|
||||
handleOpenMediaToVideo(props.bookTask)
|
||||
handleToggleMediaToVideo(props.bookTask, true)
|
||||
break
|
||||
case 'close-media-to-video':
|
||||
handleToggleMediaToVideo(props.bookTask, false)
|
||||
break
|
||||
case 'export-jianying':
|
||||
await ExportJianyingDraft()
|
||||
@ -541,79 +545,103 @@ async function editBookTask(bookTask) {
|
||||
})
|
||||
}
|
||||
|
||||
async function handleOpenMediaToVideo(bookTask) {
|
||||
async function handleToggleMediaToVideo(bookTask, enable) {
|
||||
let da = dialog.warning({
|
||||
title: t('操作确认'),
|
||||
content: () =>
|
||||
h(
|
||||
'div',
|
||||
{ style: 'white-space: pre-wrap;' },
|
||||
t(
|
||||
'是否将任务 {bookTaskName} 添加到图文转视频的任务列表中?\n\n添加后会自动跳转到图文转视频界面,若当前任务已经存在于图文转视频任务列表中,则不会重复添加。',
|
||||
{
|
||||
bookTaskName: bookTask.name
|
||||
}
|
||||
)
|
||||
),
|
||||
h(DialogTextContent, {
|
||||
text: enable
|
||||
? t(
|
||||
'是否将任务 {bookTaskName} 添加到图文转视频的任务列表中?\n\n添加后会自动跳转到图文转视频界面,若当前任务已经存在于图文转视频任务列表中,则不会重复添加。',
|
||||
{
|
||||
bookTaskName: bookTask.name
|
||||
}
|
||||
)
|
||||
: t(
|
||||
'是否将任务 {bookTaskName} 从图文转视频的任务列表中移除?\n\n移除后该任务将不再显示在图文转视频模块中,但已生成的视频数据不会被删除。',
|
||||
{
|
||||
bookTaskName: bookTask.name
|
||||
}
|
||||
)
|
||||
}),
|
||||
positiveText: t('确定'),
|
||||
negativeText: t('取消'),
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
da?.destroy()
|
||||
debugger
|
||||
softwareStore.spin.spinning = true
|
||||
softwareStore.spin.tip = t('正在添加任务到图文转视频模块...')
|
||||
softwareStore.spin.tip = enable
|
||||
? t('正在添加任务到图文转视频模块...')
|
||||
: t('正在从图文转视频模块移除任务...')
|
||||
|
||||
// 判断当前任务是不是已经开启
|
||||
if (!bookTask.openVideoGenerate) {
|
||||
let res = await window.book.ModifyBookTaskDataById(bookTask.id, {
|
||||
openVideoGenerate: true
|
||||
})
|
||||
if (res.code != 1) {
|
||||
message.error(t('开启图文转视频失败,{error}', { error: res.message }))
|
||||
return
|
||||
}
|
||||
message.success(t('任务已添加到图文转视频模块'))
|
||||
// 修改任务的openVideoGenerate状态
|
||||
let res = await window.book.ModifyBookTaskDataById(bookTask.id, {
|
||||
openVideoGenerate: enable
|
||||
})
|
||||
if (res.code != 1) {
|
||||
message.error(
|
||||
enable
|
||||
? t('开启图文转视频失败,{error}', { error: res.message })
|
||||
: t('关闭图文转视频失败,{error}', { error: res.message })
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 判断用户是不是需要要跳转过去
|
||||
let cm = dialog.warning({
|
||||
title: t('操作确认'),
|
||||
content: bookTask.openVideoGenerate
|
||||
? t('当前批次任务已经开启图转视频,是否直接跳转到图文转视频界面?')
|
||||
: t('是否现在就跳转到图文转视频界面?'),
|
||||
positiveText: t('确定'),
|
||||
negativeText: t('取消'),
|
||||
closable: true,
|
||||
maskClosable: true,
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
cm.destroy()
|
||||
message.success(t('正在跳转到图文转视频界面...'))
|
||||
await TimeDelay(1000)
|
||||
bookStore.selectBookTask = bookTask
|
||||
bookStore.selectBook = props.book
|
||||
// 跳转到图转视频任务详情页
|
||||
router.push({
|
||||
name: 'mediaToVideo'
|
||||
})
|
||||
} catch (error) {
|
||||
message.error(t('跳转失败,{error}', { error: error.message }))
|
||||
} finally {
|
||||
// 更新本地状态
|
||||
bookTask.openVideoGenerate = enable
|
||||
|
||||
message.success(
|
||||
enable
|
||||
? t('任务已添加到图文转视频模块')
|
||||
: t('任务已从图文转视频模块移除')
|
||||
)
|
||||
|
||||
// 如果是开启,询问是否跳转
|
||||
if (enable) {
|
||||
|
||||
// 判断用户是不是需要要跳转过去
|
||||
let cm = dialog.warning({
|
||||
title: t('操作确认'),
|
||||
content: t('是否现在就跳转到图文转视频界面?'),
|
||||
positiveText: t('确定'),
|
||||
negativeText: t('取消'),
|
||||
closable: true,
|
||||
maskClosable: true,
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
cm.destroy()
|
||||
message.success(t('正在跳转到图文转视频界面...'))
|
||||
await TimeDelay(1000)
|
||||
bookStore.selectBookTask = bookTask
|
||||
bookStore.selectBook = props.book
|
||||
// 跳转到图转视频任务详情页
|
||||
router.push({
|
||||
name: 'mediaToVideo'
|
||||
})
|
||||
} catch (error) {
|
||||
message.error(t('跳转失败,{error}', { error: error.message }))
|
||||
} finally {
|
||||
softwareStore.spin.spinning = false
|
||||
}
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
message.info(t('已取消跳转,你可以在转视频模块中查看该任务'))
|
||||
softwareStore.spin.spinning = false
|
||||
}
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
message.info(t('已取消跳转,你可以在转视频模块中查看该任务'))
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 关闭图转视频时,刷新数据列表以移除该任务
|
||||
emit('refresh-data')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(t('开启图文转视频失败,{error}', { error: error.message }))
|
||||
message.error(
|
||||
enable
|
||||
? t('开启图文转视频失败,{error}', { error: error.message })
|
||||
: t('关闭图文转视频失败,{error}', { error: error.message })
|
||||
)
|
||||
} finally {
|
||||
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,
|
||||
DocumentTextOutline,
|
||||
RefreshOutline,
|
||||
TrashOutline
|
||||
TrashOutline,
|
||||
CloseOutline
|
||||
} from '@vicons/ionicons5'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
@ -91,6 +92,18 @@ const mediaToVideoOptions = [
|
||||
{
|
||||
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('删除任务')),
|
||||
key: 'delete-book-task',
|
||||
|
||||
@ -28,6 +28,16 @@ async function initializeLanguage() {
|
||||
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(() => {
|
||||
// 创建pinia实例
|
||||
|
||||
@ -35,6 +35,11 @@ const routes = [
|
||||
name: 'media-to-video',
|
||||
component: () => import('@/renderer/src/components/MediaToVideo/MediaToVideoInfoHome.vue')
|
||||
},
|
||||
{
|
||||
path: 'comic-drama',
|
||||
name: 'comicDrama',
|
||||
component: () => import('../views/ComicDramaHomeView.vue')
|
||||
},
|
||||
{
|
||||
path: 'original-book-detail/:id',
|
||||
name: 'OriginalBookDetail',
|
||||
|
||||
@ -75,6 +75,14 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
isFooter: false,
|
||||
isDialog: false
|
||||
},
|
||||
// {
|
||||
// label: t('漫剧'),
|
||||
// key: 'comic-drama',
|
||||
// icon: renderIcon(VideoIcon),
|
||||
// route: '/comic-drama',
|
||||
// isFooter: false,
|
||||
// isDialog: false
|
||||
// },
|
||||
{
|
||||
label: t('预设库'),
|
||||
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
|
||||
@category-select="handleCategorySelect"
|
||||
@update-simple-settings="handleUpdateSimpleSettings"
|
||||
@toggle-mode="handleToggleMode"
|
||||
:prompt-category="promptCategory"
|
||||
:ai-setting="aiSetting"
|
||||
:simple-setting="simpleSetting"
|
||||
:select-sub-category="simpleSetting.gptData"
|
||||
:select-main-category="simpleSetting.gptType"
|
||||
:current-mode="currentMode"
|
||||
/>
|
||||
</div>
|
||||
<n-divider vertical :style="{ height: '100%', margin: '0 0' }" />
|
||||
|
||||
<!-- 右侧主内容区 - 占满剩余空间 -->
|
||||
<div class="main-content" style="flex: 1; overflow: hidden">
|
||||
<div style="padding: 16px">
|
||||
<!-- 批量文案处理模式 -->
|
||||
<div v-if="currentMode === 'batch'" style="padding: 16px">
|
||||
<CopyWritingContent
|
||||
:simple-setting="simpleSetting"
|
||||
@split-save="handleSplitOrMergeOldText"
|
||||
@save-simple-setting="handleSaveCWSimpleSetting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- AI对话模式 -->
|
||||
<AIChatInterface
|
||||
v-else
|
||||
ref="aiChatRef"
|
||||
:current-category="selectedCategory"
|
||||
:ai-setting="aiSetting"
|
||||
v-model:messages="chatMessages"
|
||||
@save-history="handleSaveChatHistory"
|
||||
@load-history="handleLoadChatHistory"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -39,7 +53,9 @@ import { TimeDelay } from '@/define/Tools/time'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import CopyWritingContent from '@/renderer/src/components/CopyWriting/CopyWritingContent.vue'
|
||||
import CopyWritingCategoryMenu from '@/renderer/src/components/CopyWriting/CopyWritingCategoryMenu.vue'
|
||||
import AIChatInterface from '@/renderer/src/components/CopyWriting/AIChatInterface.vue'
|
||||
import LoadingComponent from '@/renderer/src/components/common/LoadingComponent.vue'
|
||||
import { NDivider } from 'naive-ui'
|
||||
import { OptionKeyName, OptionType } from '@/define/enum/option'
|
||||
import { define } from '@/define/define'
|
||||
import { useMD } from '../hooks/useMD'
|
||||
@ -61,6 +77,23 @@ const simpleSetting = ref({})
|
||||
// 数据加载状态
|
||||
const dataLoaded = ref(false)
|
||||
|
||||
// 当前模式: 'batch' 批量文案处理, 'chat' AI对话
|
||||
const currentMode = ref('batch')
|
||||
|
||||
// 当前选中的分类(用于AI对话)
|
||||
const selectedCategory = ref(null)
|
||||
|
||||
// AI聊天组件的引用
|
||||
const aiChatRef = ref(null)
|
||||
|
||||
// AI聊天消息列表
|
||||
const chatMessages = ref([])
|
||||
|
||||
// 保存节流控制
|
||||
let saveTimer = null
|
||||
let lastSaveTime = 0
|
||||
const SAVE_INTERVAL = 2000 // 2秒内最多保存一次
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 不再使用全局 spin,使用组件内的加载状态
|
||||
@ -72,6 +105,9 @@ onMounted(async () => {
|
||||
// 初始化界面数据
|
||||
await InitCopyWritingData()
|
||||
|
||||
// 加载保存的模式设置
|
||||
await LoadCurrentMode()
|
||||
|
||||
// 加载提示词数据
|
||||
await InitServerGptOptions()
|
||||
|
||||
@ -119,6 +155,88 @@ let UpdateWord = debounce((value) => {
|
||||
// newWord.value += value
|
||||
}, 300)
|
||||
|
||||
// 保存聊天记录到数据库
|
||||
async function handleSaveChatHistory() {
|
||||
try {
|
||||
if (!selectedCategory.value?.id) return
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
// 节流控制
|
||||
if (now - lastSaveTime < SAVE_INTERVAL) {
|
||||
// 清除之前的延迟保存
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
}
|
||||
// 延迟保存
|
||||
saveTimer = setTimeout(
|
||||
() => {
|
||||
handleSaveChatHistory()
|
||||
},
|
||||
SAVE_INTERVAL - (now - lastSaveTime)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
lastSaveTime = now
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
}
|
||||
|
||||
const chatData = {
|
||||
categoryId: selectedCategory.value.id,
|
||||
messages: chatMessages.value.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp
|
||||
}))
|
||||
}
|
||||
|
||||
const key = `InferenceAI_CW_ChatHistory_${selectedCategory.value.id}`
|
||||
console.log('保存聊天记录, key:', key, '消息数:', chatData.messages.length)
|
||||
|
||||
const result = await window.option.ModifyOptionByKey(
|
||||
key,
|
||||
JSON.stringify(chatData),
|
||||
OptionType.JSON
|
||||
)
|
||||
console.log('保存聊天记录结果:', result)
|
||||
} catch (error) {
|
||||
console.error('保存聊天记录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载聊天记录
|
||||
async function handleLoadChatHistory() {
|
||||
try {
|
||||
if (!selectedCategory.value?.id) {
|
||||
chatMessages.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const key = `InferenceAI_CW_ChatHistory_${selectedCategory.value.id}`
|
||||
|
||||
const res = await window.option.GetOptionByKey(key)
|
||||
|
||||
if (res.code === 1 && res.data) {
|
||||
const chatData = optionSerialization(res.data)
|
||||
|
||||
if (chatData.messages && Array.isArray(chatData.messages)) {
|
||||
chatMessages.value = chatData.messages
|
||||
console.log('成功加载消息:', chatData.messages.length, '条')
|
||||
} else {
|
||||
chatMessages.value = []
|
||||
}
|
||||
} else {
|
||||
chatMessages.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载聊天记录失败:', error)
|
||||
chatMessages.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 处理分类选择事件
|
||||
async function handleCategorySelect(selection) {
|
||||
console.log('Received category selection:', selection)
|
||||
@ -128,6 +246,9 @@ async function handleCategorySelect(selection) {
|
||||
return
|
||||
}
|
||||
|
||||
// 更新选中的分类(用于AI对话模式)
|
||||
selectedCategory.value = selection
|
||||
|
||||
// 检查数据结构
|
||||
let mainCategoryId, subCategoryId
|
||||
|
||||
@ -149,6 +270,37 @@ async function handleCategorySelect(selection) {
|
||||
})
|
||||
}
|
||||
|
||||
// 处理模式切换
|
||||
async function handleToggleMode() {
|
||||
try {
|
||||
const newMode = currentMode.value === 'batch' ? 'chat' : 'batch'
|
||||
currentMode.value = newMode
|
||||
|
||||
// 保存模式到数据库
|
||||
const saveRes = await window.option.ModifyOptionByKey(
|
||||
OptionKeyName.InferenceAI.CW_CurrentMode,
|
||||
currentMode.value,
|
||||
OptionType.STRING
|
||||
)
|
||||
|
||||
if (saveRes.code !== 1) {
|
||||
throw new Error(saveRes.message)
|
||||
}
|
||||
|
||||
message.info(
|
||||
t('已切换到{mode}模式', {
|
||||
mode: currentMode.value === 'batch' ? t('批量文案处理') : t('AI对话')
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
message.error(
|
||||
t('切换模式失败,{error}', {
|
||||
error: error.message
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// async function handleContentSaveCWSimpleSetting(selection = {}) {
|
||||
// let res = await handleSaveCWSimpleSetting(selection)
|
||||
// if(res) {
|
||||
@ -239,8 +391,9 @@ async function InitServerGptOptions() {
|
||||
}
|
||||
|
||||
promptCategory.value = pc
|
||||
console.log('Loaded prompt categories:', pc)
|
||||
console.log('Current simpleSetting:', simpleSetting.value)
|
||||
|
||||
// 初始化选中的分类(用于AI对话模式)
|
||||
await InitSelectedCategory()
|
||||
} catch (error) {
|
||||
showErrorDialog(
|
||||
t('数据加载失败'),
|
||||
@ -251,6 +404,70 @@ async function InitServerGptOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据保存的设置初始化选中的分类
|
||||
*/
|
||||
async function InitSelectedCategory() {
|
||||
try {
|
||||
const gptType = simpleSetting.value.gptType
|
||||
const gptData = simpleSetting.value.gptData
|
||||
|
||||
if (!isEmpty(gptType) && !isEmpty(gptData) && promptCategory.value.length > 0) {
|
||||
// 查找对应的主分类
|
||||
const mainCategory = promptCategory.value.find((cat) => cat.id === gptType)
|
||||
|
||||
if (mainCategory && mainCategory.children) {
|
||||
// 查找对应的子分类
|
||||
const subCategory = mainCategory.children.find((sub) => sub.id === gptData)
|
||||
|
||||
if (subCategory) {
|
||||
selectedCategory.value = subCategory
|
||||
} else {
|
||||
console.warn('未找到匹配的子分类:', gptData)
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到匹配的主分类或主分类无子项:', gptType)
|
||||
}
|
||||
} else {
|
||||
console.log('没有保存的分类数据,跳过初始化')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化选中分类失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前模式设置
|
||||
*/
|
||||
async function LoadCurrentMode() {
|
||||
try {
|
||||
const modeRes = await window.option.GetOptionByKey(OptionKeyName.InferenceAI.CW_CurrentMode)
|
||||
|
||||
if (modeRes.code === 1 && modeRes.data) {
|
||||
const savedMode = optionSerialization(modeRes.data, '', 'chat')
|
||||
// 验证模式值是否有效
|
||||
if (savedMode === 'batch' || savedMode === 'chat') {
|
||||
currentMode.value = savedMode
|
||||
console.log('已加载保存的模式:', savedMode)
|
||||
} else {
|
||||
console.warn('无效的模式值,使用默认值 batch')
|
||||
currentMode.value = 'batch'
|
||||
}
|
||||
} else {
|
||||
// 没有保存的模式,使用默认值并保存
|
||||
currentMode.value = 'batch'
|
||||
await window.option.ModifyOptionByKey(
|
||||
OptionKeyName.InferenceAI.CW_CurrentMode,
|
||||
'batch',
|
||||
OptionType.STRING
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载模式设置失败:', error)
|
||||
currentMode.value = 'batch' // 出错时使用默认值
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化文案处理AI设置
|
||||
*/
|
||||
|
||||
@ -54,7 +54,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useBookStore, useSoftwareStore } from '@/renderer/src/stores'
|
||||
import { useMessage, useDialog } from 'naive-ui'
|
||||
import { BookType, GetBookTaskDetailStatusLabel } from '@/define/enum/bookEnum'
|
||||
import { BookType } from '@/define/enum/bookEnum'
|
||||
import { TimeDelay } from '@/define/Tools/time'
|
||||
import { h } from 'vue'
|
||||
|
||||
@ -64,13 +64,14 @@ import TaskList from '@/renderer/src/components/Original/MainHome/OriginalTaskLi
|
||||
import EmptyState from '@/renderer/src/components/Original/MainHome/OriginalEmptyState.vue'
|
||||
import AddBook from '@/renderer/src/components/Original/MainHome/OriginalAddBook.vue'
|
||||
import AddBookTask from '@/renderer/src/components/Original/MainHome/OriginalAddBookTask.vue'
|
||||
import { getImageCategoryOptions } from '@/define/data/imageData'
|
||||
import { useBook } from '@/renderer/src/hooks/useBook'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const bookStore = useBookStore()
|
||||
const softwareStore = useSoftwareStore()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { loadVideoBookInfo, loadProjectProgressData } = useBook()
|
||||
|
||||
// 响应式数据
|
||||
const bookData = ref([])
|
||||
@ -92,64 +93,15 @@ onMounted(async () => {
|
||||
await loadBookInfo(true) // 初始加载
|
||||
})
|
||||
|
||||
// 加载所有的小说数据
|
||||
// 加载所有的小说数据(包含分镜信息)
|
||||
async function loadBookInfo(isInitialLoad = false) {
|
||||
try {
|
||||
if (isInitialLoad) {
|
||||
// 初始加载使用组件级别的loading
|
||||
comLoading.value = true
|
||||
} else {
|
||||
// 方法调用使用全局loading
|
||||
softwareStore.spin.spinning = true
|
||||
softwareStore.spin.tip = t('正在加载任务数据...')
|
||||
}
|
||||
|
||||
const imageCategoryOptions = getImageCategoryOptions()
|
||||
|
||||
// 加载小说数据 和 小说批次数据
|
||||
let res = await window.book.video.GetVideoBookInfoList({})
|
||||
if (res.code != 1) {
|
||||
message.error(res.message)
|
||||
return
|
||||
}
|
||||
|
||||
bookData.value = res.data
|
||||
|
||||
console.log('获取可以小说数据', res)
|
||||
|
||||
// 加载每个批次数据的 tag
|
||||
|
||||
for (let i = 0; i < bookData.value.length; i++) {
|
||||
// 处理每一个 bookTasks ,添加一个 tags 属性
|
||||
bookData.value[i].children.forEach((task) => {
|
||||
let tempArray = []
|
||||
// 出图方式
|
||||
let imageCategory = imageCategoryOptions.find((item) => item.value === task.imageCategory)
|
||||
tempArray.push(imageCategory.label)
|
||||
|
||||
// 状态
|
||||
let statusObj = GetBookTaskDetailStatusLabel(task.status)
|
||||
tempArray.push(statusObj.label)
|
||||
task.tags = tempArray
|
||||
})
|
||||
|
||||
loadProjectProgressData(bookData.value[i])
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(
|
||||
t('加载数据失败,{error}', {
|
||||
error: error.message
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
// 初始加载结束,显示内容
|
||||
comLoading.value = false
|
||||
} else {
|
||||
// 方法调用结束,隐藏全局loading
|
||||
softwareStore.spin.spinning = false
|
||||
}
|
||||
}
|
||||
const data = await loadVideoBookInfo({
|
||||
isInitialLoad,
|
||||
comLoading: comLoading,
|
||||
loadingTip: t('正在加载任务数据...'),
|
||||
processProgressData: loadProjectProgressData
|
||||
})
|
||||
bookData.value = data
|
||||
}
|
||||
|
||||
// 搜索小说
|
||||
@ -246,100 +198,6 @@ async function handleSelectProject(projectId) {
|
||||
await loadProjectProgressData(project)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载项目进度和首图信息
|
||||
* @description 获取指定项目的图片生成进度和首图路径信息,并更新到项目数据中
|
||||
* @param {Object} project 项目对象,包含子任务列表
|
||||
*/
|
||||
async function loadProjectProgressData(project) {
|
||||
try {
|
||||
// 获取进度信息
|
||||
const progressResult = await getProjectProgress(project.id)
|
||||
if (!progressResult) return
|
||||
|
||||
// 获取首图信息
|
||||
const firstImageResult = await getProjectFirstImages(project.id)
|
||||
if (!firstImageResult) return
|
||||
|
||||
// 更新项目数据
|
||||
updateProjectData(project, progressResult, firstImageResult)
|
||||
|
||||
// 更新store中的选中项目
|
||||
bookStore.selectBook = { ...project }
|
||||
} catch (error) {
|
||||
message.error(
|
||||
t('加载数据失败,{error}', {
|
||||
error: error.message
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目进度信息
|
||||
* @description 调用API获取项目中所有任务的图片和视频生成进度
|
||||
* @param {string} projectId 项目ID
|
||||
* @returns {Object|null} 进度数据对象,失败时返回null
|
||||
*/
|
||||
async function getProjectProgress(projectId) {
|
||||
const res = await window.book.GetBookTaskImageGenerateProgress(projectId)
|
||||
console.log('获取小说任务生成进度', res)
|
||||
|
||||
if (res.code != 1) {
|
||||
message.error(res.message)
|
||||
return null
|
||||
}
|
||||
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目首图信息
|
||||
* @description 调用API获取项目中所有任务的首图路径
|
||||
* @param {string} projectId 项目ID
|
||||
* @returns {Object|null} 首图路径数据对象,失败时返回null
|
||||
*/
|
||||
async function getProjectFirstImages(projectId) {
|
||||
const res = await window.book.GetBookTaskFirstImagePath(projectId)
|
||||
|
||||
if (res.code != 1) {
|
||||
message.error(res.message)
|
||||
return null
|
||||
}
|
||||
|
||||
console.log('首图路径', res.data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新项目数据
|
||||
* @description 将获取到的进度和首图信息更新到项目的子任务中
|
||||
* @param {Object} project 项目对象
|
||||
* @param {Object} progressData 进度数据
|
||||
* @param {Object} firstImageData 首图数据
|
||||
*/
|
||||
function updateProjectData(project, progressData, firstImageData) {
|
||||
// 遍历项目的子任务,更新进度和首图信息
|
||||
for (let i = 0; i < project.children.length; i++) {
|
||||
const element = project.children[i]
|
||||
|
||||
// 设置图片和视频生成进度
|
||||
element.imageVideoProgress = progressData[element.id] || {
|
||||
imageProgress: 0,
|
||||
videoProgress: 0,
|
||||
totalCount: 0,
|
||||
imageRate: 0,
|
||||
videoRate: 0
|
||||
}
|
||||
|
||||
// 设置首图路径,添加时间戳避免缓存问题
|
||||
element.firstImagePath =
|
||||
firstImageData[element.id] == undefined
|
||||
? undefined
|
||||
: firstImageData[element.id] + '?t=' + new Date().getTime()
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端侧边栏控制
|
||||
|
||||
function closeMobileSidebar() {
|
||||
|
||||
@ -52,7 +52,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useBookStore, useSoftwareStore } from '@/renderer/src/stores'
|
||||
import { useMessage, useDialog } from 'naive-ui'
|
||||
import { BookType, GetBookTaskDetailStatusLabel } from '@/define/enum/bookEnum'
|
||||
import { BookType } from '@/define/enum/bookEnum'
|
||||
import { TimeDelay } from '@/define/Tools/time'
|
||||
import { h } from 'vue'
|
||||
|
||||
@ -62,13 +62,14 @@ import TaskList from '@/renderer/src/components/Original/MainHome/OriginalTaskLi
|
||||
import EmptyState from '@/renderer/src/components/Original/MainHome/OriginalEmptyState.vue'
|
||||
import AddBook from '@/renderer/src/components/Original/MainHome/OriginalAddBook.vue'
|
||||
import AddBookTask from '@/renderer/src/components/Original/MainHome/OriginalAddBookTask.vue'
|
||||
import { getImageCategoryOptions } from '@/define/data/imageData'
|
||||
import { useBook } from '@/renderer/src/hooks/useBook'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const bookStore = useBookStore()
|
||||
const softwareStore = useSoftwareStore()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { loadBookInfo: loadBookInfoFromHook, loadProjectProgressData } = useBook()
|
||||
|
||||
// 响应式数据
|
||||
const bookData = ref([])
|
||||
@ -92,67 +93,13 @@ onMounted(async () => {
|
||||
|
||||
// 加载所有的小说数据
|
||||
async function loadBookInfo(isInitialLoad = false) {
|
||||
try {
|
||||
if (isInitialLoad) {
|
||||
// 初始加载使用组件级别的loading
|
||||
comLoading.value = true
|
||||
} else {
|
||||
// 方法调用使用全局loading
|
||||
softwareStore.spin.spinning = true
|
||||
softwareStore.spin.tip = t('加载小说信息中...')
|
||||
}
|
||||
|
||||
const imageCategoryOptions = getImageCategoryOptions()
|
||||
let res = await window.book.GetBookDataCondition({ ...bookStore.queryBookCondition })
|
||||
if (res.code != 1) {
|
||||
message.error(res.message)
|
||||
return
|
||||
}
|
||||
console.log('小说信息', res.data)
|
||||
bookData.value = res.data.resultBooks
|
||||
|
||||
// 加载每个的子项目
|
||||
for (let i = 0; i < bookData.value.length; i++) {
|
||||
const element = bookData.value[i]
|
||||
let bookTaskRes = await window.book.GetBookTaskDataByCondition({
|
||||
bookId: element.id
|
||||
})
|
||||
if (bookTaskRes.code != 1) {
|
||||
message.error(bookTaskRes.message)
|
||||
return
|
||||
}
|
||||
|
||||
// 处理每一个 bookTasks ,添加一个 tags 属性
|
||||
bookTaskRes.data.bookTasks.forEach((task) => {
|
||||
let tempArray = []
|
||||
// 出图方式
|
||||
let imageCategory = imageCategoryOptions.find((item) => item.value === task.imageCategory)
|
||||
tempArray.push(imageCategory.label)
|
||||
|
||||
// 状态
|
||||
let statusObj = GetBookTaskDetailStatusLabel(task.status)
|
||||
tempArray.push(statusObj.label)
|
||||
task.tags = tempArray
|
||||
})
|
||||
|
||||
bookData.value[i].children = bookTaskRes.data.bookTasks
|
||||
loadProjectProgressData(bookData.value[i])
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(
|
||||
t('加载小说信息失败,{error}', {
|
||||
error: error.message
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
// 初始加载结束,显示内容
|
||||
comLoading.value = false
|
||||
} else {
|
||||
// 方法调用结束,隐藏全局loading
|
||||
softwareStore.spin.spinning = false
|
||||
}
|
||||
}
|
||||
const data = await loadBookInfoFromHook({
|
||||
isInitialLoad,
|
||||
comLoading: comLoading,
|
||||
loadingTip: t('加载小说信息中...'),
|
||||
processProgressData: loadProjectProgressData
|
||||
})
|
||||
bookData.value = data
|
||||
}
|
||||
|
||||
// 搜索小说
|
||||
@ -253,100 +200,6 @@ async function handleSelectProject(projectId) {
|
||||
await loadProjectProgressData(project)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载项目进度和首图信息
|
||||
* @description 获取指定项目的图片生成进度和首图路径信息,并更新到项目数据中
|
||||
* @param {Object} project 项目对象,包含子任务列表
|
||||
*/
|
||||
async function loadProjectProgressData(project) {
|
||||
try {
|
||||
// 获取进度信息
|
||||
const progressResult = await getProjectProgress(project.id)
|
||||
if (!progressResult) return
|
||||
|
||||
// 获取首图信息
|
||||
const firstImageResult = await getProjectFirstImages(project.id)
|
||||
if (!firstImageResult) return
|
||||
|
||||
// 更新项目数据
|
||||
updateProjectData(project, progressResult, firstImageResult)
|
||||
|
||||
// 更新store中的选中项目
|
||||
bookStore.selectBook = { ...project }
|
||||
} catch (error) {
|
||||
message.error(
|
||||
t('加载小说信息失败,{error}', {
|
||||
error: error.message
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目进度信息
|
||||
* @description 调用API获取项目中所有任务的图片和视频生成进度
|
||||
* @param {string} projectId 项目ID
|
||||
* @returns {Object|null} 进度数据对象,失败时返回null
|
||||
*/
|
||||
async function getProjectProgress(projectId) {
|
||||
const res = await window.book.GetBookTaskImageGenerateProgress(projectId)
|
||||
console.log('获取小说任务生成进度', res)
|
||||
|
||||
if (res.code != 1) {
|
||||
message.error(res.message)
|
||||
return null
|
||||
}
|
||||
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目首图信息
|
||||
* @description 调用API获取项目中所有任务的首图路径
|
||||
* @param {string} projectId 项目ID
|
||||
* @returns {Object|null} 首图路径数据对象,失败时返回null
|
||||
*/
|
||||
async function getProjectFirstImages(projectId) {
|
||||
const res = await window.book.GetBookTaskFirstImagePath(projectId)
|
||||
|
||||
if (res.code != 1) {
|
||||
message.error(res.message)
|
||||
return null
|
||||
}
|
||||
|
||||
console.log('首图路径', res.data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新项目数据
|
||||
* @description 将获取到的进度和首图信息更新到项目的子任务中
|
||||
* @param {Object} project 项目对象
|
||||
* @param {Object} progressData 进度数据
|
||||
* @param {Object} firstImageData 首图数据
|
||||
*/
|
||||
function updateProjectData(project, progressData, firstImageData) {
|
||||
// 遍历项目的子任务,更新进度和首图信息
|
||||
for (let i = 0; i < project.children.length; i++) {
|
||||
const element = project.children[i]
|
||||
|
||||
// 设置图片和视频生成进度
|
||||
element.imageVideoProgress = progressData[element.id] || {
|
||||
imageProgress: 0,
|
||||
videoProgress: 0,
|
||||
totalCount: 0,
|
||||
imageRate: 0,
|
||||
videoRate: 0
|
||||
}
|
||||
|
||||
// 设置首图路径,添加时间戳避免缓存问题
|
||||
element.firstImagePath =
|
||||
firstImageData[element.id] == undefined
|
||||
? undefined
|
||||
: firstImageData[element.id] + '?t=' + new Date().getTime()
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端侧边栏控制
|
||||
|
||||
function closeMobileSidebar() {
|
||||
|
||||
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/renderer/**/*",
|
||||
"src/main/**/*",
|
||||
"src/i18n/**/*"
|
||||
"src/i18n/**/*",
|
||||
"src/utils/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user