This commit is contained in:
lq1405 2026-04-01 17:33:48 +08:00
parent 6b23ff2697
commit a2174937b7
48 changed files with 7726 additions and 478 deletions

73
MARKDOWN_SETUP.md Normal file
View 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
View File

@ -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",

View File

@ -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",

View File

@ -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设置中为主
})
}
}
})

View File

@ -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
}

View File

@ -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 }
]
}

View File

@ -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基础设置 */

View File

@ -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
View 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
}
}

View File

@ -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",
}

View File

@ -2078,4 +2078,21 @@ export default {
"云端使用推荐仙宫云": "云端使用推荐仙宫云",
"登录/注册": "登录/注册",
"开启图转视频" : "开启图转视频",
"切换到{mode}模式": "切换到{mode}模式",
"批量文案处理": "批量文案处理",
"AI对话": "AI对话",
"已切换到{mode}模式": "已切换到{mode}模式",
"未选择分类": "未选择分类",
"请选择一个分类开始对话": "请选择一个分类开始对话",
"开始与AI对话获取创作灵感": "开始与AI对话获取创作灵感",
"你": "你",
"AI正在生成回复...": "AI正在生成回复...",
"输入你的问题...": "输入你的问题...",
"Ctrl + Enter 发送": "Ctrl + Enter 发送",
"清空对话": "清空对话",
"切换分类将清空当前对话记录,是否继续?": "切换分类将清空当前对话记录,是否继续?",
"请先选择一个分类": "请先选择一个分类",
"生成回复失败: {error}": "生成回复失败: {error}",
"确定要清空所有对话记录吗?": "确定要清空所有对话记录吗?",
"对话记录已清空": "对话记录已清空",
}

View File

@ -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) {

View File

@ -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

View File

@ -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']

View File

@ -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
})
}

View 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)
// bookTaskID
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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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>

View 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() {
// 16ms60fps
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('AIAPI'))
// 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>

View File

@ -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;
}

View 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>

View File

@ -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(

View File

@ -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'
}
]

View File

@ -10,7 +10,7 @@
<template #input="{ submit, deactivate }">
<n-select
size="small"
style="width: 100px"
v-model:value="selectedValue"
:options="show_options"
:placeholder="placeholder"

View File

@ -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 () => {

View File

@ -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
}
//
}
})
}

View 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
}
}

View 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
}
}

View File

@ -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',

View File

@ -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实例

View File

@ -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',

View File

@ -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',

View 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>

View File

@ -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设置
*/

View File

@ -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() {

View File

@ -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() {

View 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
}
}
```
## 完整示例
### 示例 1AI 接口调用
```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
}
}
```
### 示例 2Axios 全局配置
```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
- 完整的错误类型处理
- 友好的中文错误提示

View 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
}

View File

@ -11,7 +11,8 @@
"src/store/**/*",
"src/renderer/**/*",
"src/main/**/*",
"src/i18n/**/*"
"src/i18n/**/*",
"src/utils/**/*"
],
"compilerOptions": {
"composite": true,