This commit is contained in:
lq1405 2025-07-19 12:46:27 +08:00
parent 12e1da5681
commit c1d6fe181d
47 changed files with 3570 additions and 24 deletions

Binary file not shown.

View File

@ -1,5 +1,6 @@
import Realm, { ObjectSchema } from 'realm' import Realm, { ObjectSchema } from 'realm'
import { BookImageCategory, BookTaskStatus, BookType } from '../../../enum/bookEnum' import { BookImageCategory, BookTaskStatus, BookType } from '../../../enum/bookEnum'
import { ImageToVideoModels } from '@/define/enum/video'
export class ImageDefineModel extends Realm.Object<ImageDefineModel> { export class ImageDefineModel extends Realm.Object<ImageDefineModel> {
label: string label: string
@ -61,6 +62,7 @@ export class BookTaskModel extends Realm.Object<BookTaskModel> {
errorMsg: string | null errorMsg: string | null
isAuto: boolean // 是否自动 isAuto: boolean // 是否自动
openVideoGenerate: boolean | null // 是否开启视频生成 openVideoGenerate: boolean | null // 是否开启视频生成
videoCategory: ImageToVideoModels // 图转视频方式
updateTime: Date updateTime: Date
createTime: Date createTime: Date
imageCategory: BookImageCategory // 图片出图方式 imageCategory: BookImageCategory // 图片出图方式
@ -96,6 +98,7 @@ export class BookTaskModel extends Realm.Object<BookTaskModel> {
updateTime: 'date', updateTime: 'date',
createTime: 'date', createTime: 'date',
imageCategory: 'string', imageCategory: 'string',
videoCategory: "string",
}, },
// 主键为_id // 主键为_id
primaryKey: 'id' primaryKey: 'id'

View File

@ -1,9 +1,6 @@
import Realm, { ObjectSchema } from 'realm' import Realm, { ObjectSchema } from 'realm'
import { import {
BookBackTaskStatus,
BookBackTaskType,
BookTaskStatus, BookTaskStatus,
BookType,
MJAction, MJAction,
} from '../../../enum/bookEnum' } from '../../../enum/bookEnum'
import { MJImageType } from '../../../enum/mjEnum' import { MJImageType } from '../../../enum/mjEnum'
@ -65,10 +62,12 @@ export class VideoMessage extends Realm.Object<VideoMessage> {
bookTaskDetailId: string; bookTaskDetailId: string;
status: string | null; status: string | null;
videoUrl: string | null; videoUrl: string | null;
videoUrls: string[] | null; // 视频地址数组
taskId: string | null; taskId: string | null;
runwayOptions: string | null; // 生成视频的一些设置 runwayOptions: string | null; // 生成视频的一些设置
lumaOptions: string | null; // 生成视频的一些设置 lumaOptions: string | null; // 生成视频的一些设置
klingOptions: string | null; // 生成视频的一些设置 klingOptions: string | null; // 生成视频的一些设置
mjVideoOptions: string | null; // MJ生成视频的一些设置
messageData: string | null; messageData: string | null;
static schema: ObjectSchema = { static schema: ObjectSchema = {
name: 'VideoMessage', name: 'VideoMessage',
@ -87,7 +86,9 @@ export class VideoMessage extends Realm.Object<VideoMessage> {
runwayOptions: "string?", runwayOptions: "string?",
lumaOptions: "string?", lumaOptions: "string?",
klingOptions: "string?", klingOptions: "string?",
messageData: 'string?' mjVideoOptions: "string?",
messageData: 'string?',
videoUrls: 'string[]'
}, },
primaryKey: 'id' primaryKey: 'id'
} }
@ -172,6 +173,7 @@ export class BookTaskDetailModel extends Realm.Object<BookTaskDetailModel> {
bookTaskId: string bookTaskId: string
videoPath: string | null // 视频地址 videoPath: string | null // 视频地址
generateVideoPath: string | null // 生成视频地址 generateVideoPath: string | null // 生成视频地址
subVideoPath: string[] | null // 生成的批次视频的地址
audioPath: string | null // 音频地址 audioPath: string | null // 音频地址
word: string | null // 文案 word: string | null // 文案
oldImage: string | null // 旧图片用于SD的图生图 oldImage: string | null // 旧图片用于SD的图生图
@ -207,6 +209,7 @@ export class BookTaskDetailModel extends Realm.Object<BookTaskDetailModel> {
bookTaskId: { type: 'string', indexed: true }, bookTaskId: { type: 'string', indexed: true },
videoPath: 'string?', videoPath: 'string?',
generateVideoPath: 'string?', // 生成视频地址 generateVideoPath: 'string?', // 生成视频地址
subVideoPath : "string[]", // 生成的批次视频的地址
audioPath: 'string?', audioPath: 'string?',
word: 'string?', word: 'string?',
oldImage: 'string?', oldImage: 'string?',

View File

@ -249,6 +249,38 @@ const migration = (oldRealm: Realm, newRealm: Realm) => {
newBookTask[i].klingOptions = undefined; newBookTask[i].klingOptions = undefined;
} }
} }
if (oldRealm.schemaVersion < 40) {
const oldBookTask = oldRealm.objects('BookTask')
const newBookTask = newRealm.objects('BookTask')
for (let i = 0; i < oldBookTask.length; i++) {
newBookTask[i].videoCategory = "RUNWAY";
newBookTask[i].videoCategory = "RUNWAY";
}
}
if (oldRealm.schemaVersion < 41) {
const oldBookTask = oldRealm.objects('BookTaskDetail')
const newBookTask = newRealm.objects('BookTaskDetail')
for (let i = 0; i < oldBookTask.length; i++) {
newBookTask[i].mjVideoOptions = undefined;
}
}
if (oldRealm.schemaVersion < 42) {
const oldBookTask = oldRealm.objects('VideoMessage')
const newBookTask = newRealm.objects('VideoMessage')
for (let i = 0; i < oldBookTask.length; i++) {
newBookTask[i].videoUrls = [];
}
}
if (oldRealm.schemaVersion < 43) {
const oldBookTask = oldRealm.objects('VideoMessage')
const newBookTask = newRealm.objects('VideoMessage')
for (let i = 0; i < oldBookTask.length; i++) {
newBookTask[i].subVideoPath = [];
}
}
} }
export class BaseRealmService extends BaseService { export class BaseRealmService extends BaseService {
@ -291,7 +323,7 @@ export class BaseRealmService extends BaseService {
VideoMessage VideoMessage
], ],
path: this.dbpath, path: this.dbpath,
schemaVersion: 39, schemaVersion: 43,
migration: migration migration: migration
} }
this.realm = await Realm.open(config) this.realm = await Realm.open(config)

View File

@ -73,6 +73,9 @@ export class BookTaskDetailService extends BaseRealmService {
subImagePath: (item.subImagePath as string[])?.map((subImage) => { subImagePath: (item.subImagePath as string[])?.map((subImage) => {
return JoinPath(define.project_path, subImage) return JoinPath(define.project_path, subImage)
}), }),
subVideoPath: (item.subVideoPath as string[])?.map((subVideo) => {
return JoinPath(define.project_path, subVideo)
}),
characterTags: item.characterTags ? item.characterTags.map((tag) => tag) : null, characterTags: item.characterTags ? item.characterTags.map((tag) => tag) : null,
sceneTags: item.sceneTags ? item.sceneTags.map((tag) => tag) : null, sceneTags: item.sceneTags ? item.sceneTags.map((tag) => tag) : null,
subValue: isEmpty(item.subValue) ? null : JSON.parse(item.subValue), subValue: isEmpty(item.subValue) ? null : JSON.parse(item.subValue),
@ -202,7 +205,16 @@ export class BookTaskDetailService extends BaseRealmService {
} }
// 开始修改 // 开始修改
for (let key in updateData) { for (let key in updateData) {
bookTaskDetail[key] = updateData[key]
let newData = updateData[key];
if (key == "generateVideoPath") {
if (!isEmpty(updateData[key])) {
newData = path.relative(define.project_path, updateData[key])
}
}
bookTaskDetail[key] = newData;
} }
bookTaskDetail.updateTime = new Date() bookTaskDetail.updateTime = new Date()
}) })

View File

@ -13,6 +13,7 @@ import { TagDefine } from '../../../tagDefine.js'
import { ImageStyleDefine } from "../../../../define/iamgeStyleDefine" import { ImageStyleDefine } from "../../../../define/iamgeStyleDefine"
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
import { GeneralResponse } from '../../../../model/generalResponse' import { GeneralResponse } from '../../../../model/generalResponse'
import { ImageToVideoModels } from '@/define/enum/video'
let dbPath = path.resolve(define.db_path, 'book.realm') let dbPath = path.resolve(define.db_path, 'book.realm')
@ -91,6 +92,7 @@ export class BookTaskService extends BaseRealmService {
imageFolder: JoinPath(define.project_path, bookTask.imageFolder), imageFolder: JoinPath(define.project_path, bookTask.imageFolder),
cacheImageList: bookTask.cacheImageList ? Array.from(bookTask.cacheImageList).map(item => JoinPath(define.project_path, item)) : [], cacheImageList: bookTask.cacheImageList ? Array.from(bookTask.cacheImageList).map(item => JoinPath(define.project_path, item)) : [],
imageCategory: bookTask.imageCategory ? bookTask.imageCategory : BookImageCategory.MJ, // 默认使用MJ出图 imageCategory: bookTask.imageCategory ? bookTask.imageCategory : BookImageCategory.MJ, // 默认使用MJ出图
videoCategory : bookTask.videoCategory ? bookTask.videoCategory : ImageToVideoModels.MJ_VIDEO
} as Book.SelectBookTask; } as Book.SelectBookTask;
}) })

View File

@ -196,6 +196,12 @@ const BOOK = {
/** Runway图转视频返回前端数据任务 */ /** Runway图转视频返回前端数据任务 */
RUNWAY_IMAGE_TO_VIDEO_RETURN: "RUNWAY_IMAGE_TO_VIDEO_RETURN", RUNWAY_IMAGE_TO_VIDEO_RETURN: "RUNWAY_IMAGE_TO_VIDEO_RETURN",
/** 获取指定的条件的图转视频的数据,包含字批次 */
GET_VIDEO_BOOK_INFO_LIST: "GET_VIDEO_BOOK_INFO_LIST",
/** 获取小说图片和视频生成进度 */
GET_BOOK_IMAGE_AND_VIDEO_PROGRESS : "GET_BOOK_IMAGE_AND_VIDEO_PROGRESS"
//#endregion //#endregion
} }

View File

@ -302,6 +302,48 @@ export enum BookTagSelectType {
} }
/**
*
* @param value
* @returns
*/
export function GetBookTypeLabel(value: string) {
if (value == BookType.MJ_REVERSE) {
return 'MJ反推'
} else if (value == BookType.SD_REVERSE) {
return 'SD反推'
} else if (value == BookType.ORIGINAL) {
return '原创'
} else {
return '未知'
}
}
/**
*
* @param value
* @returns
*/
export function GetBookImageCategoryLabel(value: string) {
switch (value) {
case BookImageCategory.MJ:
return 'MJ';
case BookImageCategory.SD:
return 'SD';
case BookImageCategory.ComfyUI:
return 'ComfyUI';
case BookImageCategory.D3:
return 'D3';
case BookImageCategory.FLUX_API:
return 'FLUX API';
case BookImageCategory.FLUX_FORGE:
return 'FLUX FORGE';
default:
return '未知';
}
}
/** /**
* Key返回指定的后台任务类型的label * Key返回指定的后台任务类型的label
* @param key * @param key

View File

@ -1,3 +1,6 @@
//#region 图转视频类型
/** 图片转视频的方式 */ /** 图片转视频的方式 */
export enum ImageToVideoModels { export enum ImageToVideoModels {
/** runway 生成视频 */ /** runway 生成视频 */
@ -8,8 +11,54 @@ export enum ImageToVideoModels {
KLING = "KLING", KLING = "KLING",
/** Pika 生成视频 */ /** Pika 生成视频 */
PIKA = "PIKA", PIKA = "PIKA",
/** MJ 图转视频 */
MJ_VIDEO = "MJ_VIDEO"
} }
/**
*
* @param model
* @returns
*/
export const GetImageToVideoModelsLabel = (model: ImageToVideoModels | string) => {
switch (model) {
case ImageToVideoModels.RUNWAY:
return "Runway";
case ImageToVideoModels.LUMA:
return "Luma";
case ImageToVideoModels.KLING:
return "可灵";
case ImageToVideoModels.PIKA:
return "Pika";
case ImageToVideoModels.MJ_VIDEO:
return "MJ视频";
default:
return "未知";
}
}
/**
*
*
*
* labelvalue
* GetImageToVideoModelsLabel 使 ImageToVideoModels
*
* @returns label value
*/
export const GetImageToVideoModelsOptions = () => {
return [
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.RUNWAY), value: ImageToVideoModels.RUNWAY },
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.LUMA), value: ImageToVideoModels.LUMA },
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.KLING), value: ImageToVideoModels.KLING },
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.PIKA), value: ImageToVideoModels.PIKA },
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.MJ_VIDEO), value: ImageToVideoModels.MJ_VIDEO }
]
}
//#endregion
//#region 通用 //#region 通用
/** 生成视频的方式 */ /** 生成视频的方式 */

View File

@ -16,6 +16,7 @@ import { BookPrompt } from '../Service/Book/bookPrompt'
import { BookGeneral } from '../Service/Book/bookGeneral' import { BookGeneral } from '../Service/Book/bookGeneral'
import { OperateBookType } from '../../define/enum/bookEnum' import { OperateBookType } from '../../define/enum/bookEnum'
import { VideoGlobal } from '../Service/video/videoGlobal' import { VideoGlobal } from '../Service/video/videoGlobal'
import { BookImageTextToVideoIndex } from "@/main/Service/Book/BookImageTextToVideo/bookImageTextToVideoIndex";
let reverseBook = new ReverseBook() let reverseBook = new ReverseBook()
let basicReverse = new BasicReverse() let basicReverse = new BasicReverse()
let subtitle = new Subtitle() let subtitle = new Subtitle()
@ -31,6 +32,7 @@ let bookFrame = new BookFrame()
let bookPrompt = new BookPrompt(); let bookPrompt = new BookPrompt();
let bookGeneral = new BookGeneral() let bookGeneral = new BookGeneral()
let videoGlobal = new VideoGlobal() let videoGlobal = new VideoGlobal()
let bookImageTextToVideoIndex = new BookImageTextToVideoIndex();
export function BookIpc() { export function BookIpc() {
// 获取样式图片的子列表 // 获取样式图片的子列表
@ -369,6 +371,13 @@ export function BookIpc() {
/** 修改小说详细分镜的Videomessage */ /** 修改小说详细分镜的Videomessage */
ipcMain.handle(DEFINE_STRING.BOOK.UPDATE_BOOK_TASK_DETAIL_VIDEO_MESSAGE, async (event, bookTaskDetailId, videoMessage) => await videoGlobal.UpdateBookTaskDetailVideoMessage(bookTaskDetailId, videoMessage)) ipcMain.handle(DEFINE_STRING.BOOK.UPDATE_BOOK_TASK_DETAIL_VIDEO_MESSAGE, async (event, bookTaskDetailId, videoMessage) => await videoGlobal.UpdateBookTaskDetailVideoMessage(bookTaskDetailId, videoMessage))
/** 获取指定的条件的图转视频的数据,包含子批次 */
ipcMain.handle(DEFINE_STRING.BOOK.GET_VIDEO_BOOK_INFO_LIST, async (event,
condition: BookVideo.BookVideoInfoListQuertCondition) => await bookImageTextToVideoIndex.GetVideoBookInfoList(condition))
/** 获取小说图片和视频生成进度 */
ipcMain.handle(DEFINE_STRING.BOOK.GET_BOOK_IMAGE_AND_VIDEO_PROGRESS, async (event, bookId?: string, bookTaskId?: string) => await bookImageTextToVideoIndex.bookImageTextToVideoInfo.GetBookImageAndVideoProgress(bookId, bookTaskId))
//#endregion //#endregion
} }

View File

@ -1,12 +1,11 @@
import { BookType, OperateBookType, TagDefineType } from '../../../define/enum/bookEnum' import { BookType } from '../../../define/enum/bookEnum'
import { errorMessage, successMessage } from '../../Public/generalTools' import { errorMessage, successMessage } from '../../Public/generalTools'
import { BookService } from '../../../define/db/service/Book/bookService' import { BookService } from '../../../define/db/service/Book/bookService'
import path from 'path' import path from 'path'
import { CheckFileOrDirExist, CheckFolderExistsOrCreate, CopyFileOrFolder, DeleteFolderAllFile, GetSubdirectories } from '../../../define/Tools/file' import { CheckFileOrDirExist, DeleteFolderAllFile, GetSubdirectories } from '../../../define/Tools/file'
import { GeneralResponse } from '../../../model/generalResponse' import { GeneralResponse } from '../../../model/generalResponse'
import { BookServiceBasic } from '../ServiceBasic/bookServiceBasic' import { BookServiceBasic } from '../ServiceBasic/bookServiceBasic'
import { BookTask } from './bookTask' import { BookTask } from './bookTask'
import fs from 'fs'
import { Book } from '../../../model/book/book' import { Book } from '../../../model/book/book'
export class BookBasic { export class BookBasic {

View File

@ -0,0 +1,22 @@
import { BookImageTextToVideoInfo } from "./bookImageTextToVideoInfo";
export class BookImageTextToVideoIndex {
bookImageTextToVideoInfo: BookImageTextToVideoInfo;
constructor() {
this.bookImageTextToVideoInfo = new BookImageTextToVideoInfo();
}
//#region Info
/** 获取用于视频生成的小说信息列表 根据查询条件返回小说数据如果指定了bookTaskId则返回对应小说的单个任务数据 否则返回所有启用了视频生成功能的小说及其任务数据 */
GetVideoBookInfoList = async (condition: BookVideo.BookVideoInfoListQuertCondition) => await this.bookImageTextToVideoInfo.GetVideoBookInfoList(condition)
GetBookImageAndVideoProgress = async (bookId?: string, bookTaskId?: string) => await this.bookImageTextToVideoInfo.GetBookImageAndVideoProgress(bookId, bookTaskId);
//#endregion
}

View File

@ -0,0 +1,257 @@
import { errorMessage, successMessage } from "@/main/Public/generalTools";
import { BookBasicHandle } from "../bookBasicHandle";
import { isEmpty } from "lodash";
import { CheckFileOrDirExist } from "@/define/Tools/file";
import { Book } from "@/model/book/book";
// 定义进度数据的类型
export interface ProgressData {
imageProgress: number;
videoProgress: number;
totalCount: number;
imageRate: number;
videoRate: number;
}
// 定义嵌套的 Record 类型
export type BookProgressRecord = Record<string, Record<string, ProgressData>>;
export class BookImageTextToVideoInfo extends BookBasicHandle {
constructor() {
super();
}
//#region GetVideoBookInfoList
/**
*
* bookTaskId
*
* @param condition bookTaskId等参数
* @returns
*/
GetVideoBookInfoList = async (condition: BookVideo.BookVideoInfoListQuertCondition) => {
try {
await this.InitBookBasicHandle();
// 获取小说的所有的数据
let bookRes = this.bookService.GetBookData(condition);
if (bookRes.code !== 1) {
return errorMessage('获取小说数据失败,错误信息:' + bookRes.message, 'BookImageTextToVideoInfo_GetVideoBookInfoList');
}
let bookList = bookRes.data.res_book ?? [];
if (bookList.length <= 0) {
return errorMessage('没有找到对应的小说数据,请先添加小说', 'BookImageTextToVideoInfo_GetVideoBookInfoList');
}
// 有指定的小说批次任务的ID情况下 只返回当前的小说数据
if (condition.bookTaskId) {
let bookTask = this.bookTaskService.GetBookTaskDataById(condition.bookTaskId);
if (bookTask == null) {
return errorMessage('没有找到对应的小说批次数据,请先添加小说批次', 'BookImageTextToVideoInfo_GetVideoBookInfoList');
}
// 再上面的小说数据里面获取数据 然后直接返回就行
let bookInfo = bookList.find(book => book.id === bookTask.bookId);
if (!bookInfo) {
return errorMessage('没有找到对应的小说数据,请先添加小说', 'BookImageTextToVideoInfo_GetVideoBookInfoList');
}
bookInfo.bookTasks = [bookTask];
// 返回
return successMessage(bookInfo, '获取小说批次任务数据成功', 'BookImageTextToVideoInfo_GetVideoBookInfoList');
}
// 没有那个数据 将所有的数据进行处理
let res = [];
for (let i = 0; i < bookList.length; i++) {
const element = bookList[i];
// 获取小说批次任务数据
let bookTaskRes = this.bookTaskService.GetBookTaskData({ bookId: element.id });
if (bookTaskRes.code != 1) {
continue;
}
if (bookTaskRes.data.bookTasks.length > 0) {
// 检查所有的 bookTasks 里面是不是开启了图转视频功能
let videoBookTasks = bookTaskRes.data.bookTasks.filter(task => task.openVideoGenerate);
if (videoBookTasks.length <= 0) {
continue;
} else {
element.bookTasks = videoBookTasks;
res.push(element);
}
}
}
return successMessage(
res,
'获取小说批次任务数据成功',
'BookImageTextToVideoInfo_GetVideoBookInfoList'
);
} catch (error) {
return errorMessage(
'初始化BookBasicHandle失败错误信息如下' + error.toString(),
'BookImageTextToVideoInfo_GetVideoBookInfoList'
);
}
}
//#endregion
//#region GetBookImageAndVideoProgress
/**
*
*
* @param bookId ID
* @param bookTaskId ID
* @returns
*/
GetBookImageAndVideoProgress = async (bookId?: string, bookTaskId?: string) => {
try {
await this.InitBookBasicHandle();
let bookIds: Record<string, string[]> = {};
// 开始处理获取对应的小说ID和关联数据
if (!isEmpty(bookId)) {
let bookInfo = this.bookService.GetBookDataById(bookId);
if (bookInfo == null) {
return errorMessage('没有找到对应的小说数据,请先添加小说', 'BookImageTextToVideoInfo_GetBookImageAndVideoProgress');
}
bookIds[bookInfo.id] = [];
} else {
// 获取所有的小说数据
let bookRes = this.bookService.GetBookData({});
if (bookRes.code !== 1) {
return errorMessage('获取小说数据失败,错误信息:' + bookRes.message, 'BookImageTextToVideoInfo_GetBookImageAndVideoProgress');
}
let bookList = bookRes.data.res_book ?? [];
if (bookList.length <= 0) {
return errorMessage('没有找到对应的小说数据,请先添加小说', 'BookImageTextToVideoInfo_GetBookImageAndVideoProgress');
}
// 将所有的小说ID添加到bookIds里面
for (let i = 0; i < bookList.length; i++) {
const element = bookList[i];
bookIds[element.id] = [];
}
}
// 判断小说任务ID是不是存在 存在的话判断小说任务的ID是不是再bookIds里面 在的话更新bookIds中指定的ID的值数据
if (!isEmpty(bookTaskId)) {
let bookTaskInfo = this.bookTaskService.GetBookTaskDataById(bookTaskId);
if (bookTaskInfo == null) {
return errorMessage('没有找到对应的小说批次数据,请先添加小说批次', 'BookImageTextToVideoInfo_GetBookImageAndVideoProgress');
}
// 判断当前的bookid 是不是再 bookIds里面
if (bookTaskInfo.bookId && bookIds[bookTaskInfo.bookId]) {
bookIds[bookTaskInfo.bookId] = [bookTaskInfo.id];
} else {
return errorMessage('当前的小说批次任务ID不属于指定的小说ID', 'BookImageTextToVideoInfo_GetBookImageAndVideoProgress');
}
} else {
// 遍历 bookIds 获取所有的小说批次任务ID
for (const bookId of Object.keys(bookIds)) {
// 获取小说批次任务数据
let bookTaskRes = this.bookTaskService.GetBookTaskData({ bookId: bookId });
if (bookTaskRes.code !== 1) {
continue;
}
if (bookTaskRes.data.bookTasks.length > 0) {
// 检查所有的 bookTasks 里面是不是开启了图转视频功能
let ids = bookTaskRes.data.bookTasks.map(task => task.id);
bookIds[bookId] = ids;
}
}
}
// 遍历 bookIds 将值数据为空或者长度为0的都删除
Object.keys(bookIds).forEach(bookId => {
if (isEmpty(bookIds[bookId]) || bookIds[bookId].length <= 0) {
delete bookIds[bookId];
}
});
// 检查最终结果
if (Object.keys(bookIds).length === 0) {
return successMessage({}, '没有找到对应的小说数据或者小说批次任务数据', 'BookImageTextToVideoInfo_GetBookImageAndVideoProgress');
}
// 这边开始处理数据
let resData = await this.ProgressHandle(bookIds);
return successMessage(
resData,
'获取小说图片和视频生成进度成功',
'BookImageTextToVideoInfo_GetBookImageAndVideoProgress'
);
} catch (error) {
return errorMessage(
'获取小说图片和视频生成进度失败,错误信息如下:' + error.toString(),
'BookImageTextToVideoInfo_GetBookImageAndVideoProgress'
);
}
}
/**
*
* ID和批次任务ID
* @param bookIds ID和对应小说任务ID的映射关系 {bookId: [taskId1, taskId2, ...]}
* @returns
*/
private ProgressHandle = async (bookIds: Record<string, string[]>) => {
// 这边开始处理数据
let resData: BookProgressRecord = {};
// 遍历 bookIds 获取所有的小说批次任务数据
for (const bookId of Object.keys(bookIds)) {
let bookTaskIds = bookIds[bookId];
if (!bookTaskIds && bookTaskIds.length <= 0) {
continue;
}
resData[bookId] = {};
// 获取小说批次任务数据
for (let i = 0; i < bookTaskIds.length; i++) {
const element = bookTaskIds[i];
let bookTaskDetails = this.bookTaskDetailService.GetBookTaskData({ bookTaskId: element, bookId: bookId });
if (bookTaskDetails.code !== 1) {
continue;
}
// 遍历 bookTaskDetails 获取每个小说批次任务的进度数据
let imageProgress = 0;
let videoProgress = 0;
for (let j = 0; j < bookTaskDetails.data.length; j++) {
const bookTaskDetail = bookTaskDetails.data[j] as Book.SelectBookTaskDetail;
// 检查图片信息
if (!isEmpty(bookTaskDetail.outImagePath) && await CheckFileOrDirExist(bookTaskDetail.outImagePath)) {
imageProgress += 1;
}
// 检查视频信息
if (!isEmpty(bookTaskDetail.videoPath) && await CheckFileOrDirExist(bookTaskDetail.videoPath)) {
videoProgress += 1;
}
}
// 开始添加数据
resData[bookId][element] = {
imageProgress: imageProgress,
videoProgress: videoProgress,
totalCount: bookTaskDetails.data.length,
imageRate: bookTaskDetails.data.length > 0 ? (imageProgress / bookTaskDetails.data.length) * 100 : 0,
videoRate: bookTaskDetails.data.length > 0 ? (videoProgress / bookTaskDetails.data.length) * 100 : 0
}
}
}
return resData;
}
//#endregion
}

View File

@ -134,7 +134,8 @@ export class ReverseBook {
return { return {
...item, ...item,
outImagePath: isEmpty(item.outImagePath) ? item.outImagePath : item.outImagePath + '?t=' + new Date().getTime(), outImagePath: isEmpty(item.outImagePath) ? item.outImagePath : item.outImagePath + '?t=' + new Date().getTime(),
subImagePath: item.subImagePath && item.subImagePath.length > 0 ? item.subImagePath.map(it => it + '?t=' + new Date().getTime()) : item.subImagePath subImagePath: item.subImagePath && item.subImagePath.length > 0 ? item.subImagePath.map(it => it + '?t=' + new Date().getTime()) : item.subImagePath,
subVideoPath: item.subVideoPath && item.subVideoPath.length > 0 ? item.subVideoPath.map(it => it + '?t=' + new Date().getTime()) : item.subVideoPath,
} }
}) })

View File

@ -0,0 +1,39 @@
import { BookService } from "@/define/db/service/Book/bookService"
import { BookTaskDetailService } from "@/define/db/service/Book/bookTaskDetailService"
import { BookTaskService } from "@/define/db/service/Book/bookTaskService"
import { OptionRealmService } from "@/define/db/service/SoftWare/optionRealmService"
export class BookBasicHandle {
bookTaskDetailService!: BookTaskDetailService
bookTaskService!: BookTaskService
optionRealmService!: OptionRealmService
bookService!: BookService
constructor() {
// 初始化
}
async InitBookBasicHandle() {
// 如果 bookTaskDetailService 已经初始化,则直接返回
if (!this.bookTaskDetailService) {
this.bookTaskDetailService = await BookTaskDetailService.getInstance()
}
if (!this.bookTaskService) {
this.bookTaskService = await BookTaskService.getInstance()
}
if (!this.optionRealmService) {
this.optionRealmService = await OptionRealmService.getInstance()
}
if (!this.bookService) {
this.bookService = await BookService.getInstance()
}
}
async transaction(callback: (realm: any) => void) {
await this.InitBookBasicHandle()
this.bookService.transaction(() => {
callback(this.bookService.realm)
})
}
}

View File

@ -3,7 +3,6 @@ import { DownloadFile, GetBaseUrl } from "@/define/Tools/common";
import { errorMessage, successMessage } from "@/main/Public/generalTools"; import { errorMessage, successMessage } from "@/main/Public/generalTools";
import { BookTaskDetail } from "@/model/book/bookTaskDetail"; import { BookTaskDetail } from "@/model/book/bookTaskDetail";
import { GptService } from "@/main/Service/GPT/gpt"; import { GptService } from "@/main/Service/GPT/gpt";
import { v4 as uuidv4 } from "uuid";
import { BookServiceBasic } from "../ServiceBasic/bookServiceBasic"; import { BookServiceBasic } from "../ServiceBasic/bookServiceBasic";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import path from "path"; import path from "path";

View File

@ -1,3 +1,4 @@
import { ImageToVideoModels } from "@/define/enum/video"
import { BookBackTaskStatus, BookBackTaskType, BookTaskStatus, BookType, TaskExecuteType, BookRepalceDataType, BookImageCategory } from "../../define/enum/bookEnum" import { BookBackTaskStatus, BookBackTaskType, BookTaskStatus, BookType, TaskExecuteType, BookRepalceDataType, BookImageCategory } from "../../define/enum/bookEnum"
import { MJAction } from "../../define/enum/bookEnum" import { MJAction } from "../../define/enum/bookEnum"
import { MJImageType } from "../../define/enum/mjEnum" import { MJImageType } from "../../define/enum/mjEnum"
@ -78,6 +79,7 @@ declare namespace Book {
isAuto?: boolean // 是否标记全自动 isAuto?: boolean // 是否标记全自动
subImageFolder?: string[] | null // 子图片文件夹地址,多个 subImageFolder?: string[] | null // 子图片文件夹地址,多个
openVideoGenerate?: boolean // 是否开启视频生成 openVideoGenerate?: boolean // 是否开启视频生成
videoCategory?: ImageToVideoModels
} }
// 添加批次任务 // 添加批次任务
@ -151,6 +153,7 @@ declare namespace Book {
bookTaskId?: string bookTaskId?: string
videoPath?: string // 视频地址 videoPath?: string // 视频地址
generateVideoPath?: string // 生成的视频地址 generateVideoPath?: string // 生成的视频地址
subVideoPath?: string[] // 生成的批次视频的地址
audioPath?: string // 音频地址 audioPath?: string // 音频地址
draftDepend?: string // 草稿依赖 draftDepend?: string // 草稿依赖
word?: string // 文案 word?: string // 文案
@ -178,8 +181,6 @@ declare namespace Book {
updateTime?: Date updateTime?: Date
} }
type QueryBookTaskCondition = { type QueryBookTaskCondition = {
id?: string id?: string
no?: number no?: number

View File

@ -20,6 +20,8 @@ declare namespace BookTaskDetail {
runwayOptions?: string; runwayOptions?: string;
lumaOptions?: string; lumaOptions?: string;
klingOptions?: string; klingOptions?: string;
mjVideoOptions?: string;
videoUrls?: string[]; // 视频地址数组
messageData?: string; messageData?: string;
} }

15
src/model/book/bookVideo.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
declare namespace BookVideo {
/**
*
*
* @interface BookVideoInfoListQuertCondition
* @property {string?} [bookId] - ID
* @property {string?} [bookTaskId] - ID
*/
interface BookVideoInfoListQuertCondition {
bookId?: string; // 图书ID
bookTaskId?: string; // 图书任务ID
}
}

View File

@ -6,7 +6,9 @@ import { BookType, OperateBookType } from '../../define/enum/bookEnum'
import Video from './video' import Video from './video'
const book = { const book = {
...Video, video: {
...Video
},
// 获取小说操作类型(原创/SD反推/MJ反推 // 获取小说操作类型(原创/SD反推/MJ反推
GetBookType: async () => await ipcRenderer.invoke(DEFINE_STRING.BOOK.GET_BOOK_TYPE), GetBookType: async () => await ipcRenderer.invoke(DEFINE_STRING.BOOK.GET_BOOK_TYPE),

View File

@ -3,7 +3,7 @@ import { ipcRenderer } from "electron"
const Video = { const Video = {
/** 初始化图转视频消息 */ /** 初始化小说转视频消息 */
InitVideoMessage: async (bookTaskDetailId: string) => { InitVideoMessage: async (bookTaskDetailId: string) => {
return await ipcRenderer.invoke(DEFINE_STRING.BOOK.INIT_VIDEO_MESSAGE, bookTaskDetailId) return await ipcRenderer.invoke(DEFINE_STRING.BOOK.INIT_VIDEO_MESSAGE, bookTaskDetailId)
}, },
@ -11,8 +11,17 @@ const Video = {
/** 修改小说详情的VideoMessage */ /** 修改小说详情的VideoMessage */
UpdateBookTaskDetailVideoMessage: async (bookTaskDetailId: string, videoMessage: any) => { UpdateBookTaskDetailVideoMessage: async (bookTaskDetailId: string, videoMessage: any) => {
return await ipcRenderer.invoke(DEFINE_STRING.BOOK.UPDATE_BOOK_TASK_DETAIL_VIDEO_MESSAGE, bookTaskDetailId, videoMessage) return await ipcRenderer.invoke(DEFINE_STRING.BOOK.UPDATE_BOOK_TASK_DETAIL_VIDEO_MESSAGE, bookTaskDetailId, videoMessage)
} },
/** 获取指定条件的小说图转视频数据,包含子批次 */
GetVideoBookInfoList: async (condition: BookVideo.BookVideoInfoListQuertCondition) => {
return await ipcRenderer.invoke(DEFINE_STRING.BOOK.GET_VIDEO_BOOK_INFO_LIST, condition)
},
/** 获取小说图片和视频生成进度 */
GetBookImageAndVideoProgress: async (bookId?: string, bookTaskId?: string) => {
return await ipcRenderer.invoke(DEFINE_STRING.BOOK.GET_BOOK_IMAGE_AND_VIDEO_PROGRESS, bookId, bookTaskId)
}
} }
export default Video; export default Video;

View File

@ -8,6 +8,8 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton'] NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard'] NCard: typeof import('naive-ui')['NCard']
NCheckbox: typeof import('naive-ui')['NCheckbox'] NCheckbox: typeof import('naive-ui')['NCheckbox']
@ -15,12 +17,16 @@ declare module 'vue' {
NColorPicker: typeof import('naive-ui')['NColorPicker'] NColorPicker: typeof import('naive-ui')['NColorPicker']
NDataTable: typeof import('naive-ui')['NDataTable'] NDataTable: typeof import('naive-ui')['NDataTable']
NDivider: typeof import('naive-ui')['NDivider'] NDivider: typeof import('naive-ui')['NDivider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDropdown: typeof import('naive-ui')['NDropdown'] NDropdown: typeof import('naive-ui')['NDropdown']
NDynamicInput: typeof import('naive-ui')['NDynamicInput'] NDynamicInput: typeof import('naive-ui')['NDynamicInput']
NDynamicTags: typeof import('naive-ui')['NDynamicTags'] NDynamicTags: typeof import('naive-ui')['NDynamicTags']
NEmpty: typeof import('naive-ui')['NEmpty'] NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm'] NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem'] NFormItem: typeof import('naive-ui')['NFormItem']
NGrid: typeof import('naive-ui')['NGrid']
NGridItem: typeof import('naive-ui')['NGridItem']
NIcon: typeof import('naive-ui')['NIcon'] NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage'] NImage: typeof import('naive-ui')['NImage']
NImageGroup: typeof import('naive-ui')['NImageGroup'] NImageGroup: typeof import('naive-ui')['NImageGroup']
@ -28,10 +34,14 @@ declare module 'vue' {
NInputNumber: typeof import('naive-ui')['NInputNumber'] NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout'] NLayout: typeof import('naive-ui')['NLayout']
NLayoutSider: typeof import('naive-ui')['NLayoutSider'] NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NList: typeof import('naive-ui')['NList']
NListItem: typeof import('naive-ui')['NListItem']
NLog: typeof import('naive-ui')['NLog'] NLog: typeof import('naive-ui')['NLog']
NMenu: typeof import('naive-ui')['NMenu'] NMenu: typeof import('naive-ui')['NMenu']
NModal: typeof import('naive-ui')['NModal']
NPopover: typeof import('naive-ui')['NPopover'] NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress'] NProgress: typeof import('naive-ui')['NProgress']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect'] NSelect: typeof import('naive-ui')['NSelect']
NSlider: typeof import('naive-ui')['NSlider'] NSlider: typeof import('naive-ui')['NSlider']
NSpace: typeof import('naive-ui')['NSpace'] NSpace: typeof import('naive-ui')['NSpace']
@ -42,6 +52,7 @@ declare module 'vue' {
NTabs: typeof import('naive-ui')['NTabs'] NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag'] NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText'] NText: typeof import('naive-ui')['NText']
NThing: typeof import('naive-ui')['NThing']
NTooltip: typeof import('naive-ui')['NTooltip'] NTooltip: typeof import('naive-ui')['NTooltip']
NTree: typeof import('naive-ui')['NTree'] NTree: typeof import('naive-ui')['NTree']
NUpload: typeof import('naive-ui')['NUpload'] NUpload: typeof import('naive-ui')['NUpload']

View File

@ -8,6 +8,7 @@ import {
SettingsOutline, SettingsOutline,
DuplicateOutline, DuplicateOutline,
GridOutline, GridOutline,
VideocamOutline,
RadioOutline, RadioOutline,
BookOutline BookOutline
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
@ -86,6 +87,13 @@ export const menuDataSource = [
type: 'route', type: 'route',
icon: 'grid' icon: 'grid'
}, },
{
label: '图/文生视频',
key: 'image_text_video',
routeName: 'image_text_video',
type: 'route',
icon: 'videocam'
},
{ {
label: 'API服务', label: 'API服务',
key: 'lai_api', key: 'lai_api',
@ -163,6 +171,7 @@ const iconMap = {
plane: PaperPlaneOutline, plane: PaperPlaneOutline,
duplicate: DuplicateOutline, duplicate: DuplicateOutline,
grid: GridOutline, grid: GridOutline,
videocam: VideocamOutline,
api: APIIcon, api: APIIcon,
radio: RadioOutline, radio: RadioOutline,
settings: SettingsOutline, settings: SettingsOutline,

View File

@ -0,0 +1,41 @@
export function FormatDate(date: Date, onlyDay: boolean = false): string {
// 如果传入的是字符串,尝试将其转换为 Date 对象
if (typeof date === 'string') {
date = new Date(date);
}
if (!(date instanceof Date) || isNaN(date.getTime())) {
return '';
}
// 判断时间是不是 0001-01-01T08:00:00
if (date.getFullYear() === 1) {
return '';
}
if (onlyDay) {
return date.getFullYear() + '-' +
pad(date.getMonth() + 1) + '-' +
pad(date.getDate());
}
return date.getFullYear() + '-' +
pad(date.getMonth() + 1) + '-' +
pad(date.getDate()) + ' ' +
pad(date.getHours()) + ':' +
pad(date.getMinutes()) + ':' +
pad(date.getSeconds());
}
function pad(number: number) {
return (number < 10 ? '0' : '') + number;
}
/**
* Promise
* @param time
* @returns viod
*/
export async function TimeDelay(time: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, time));
}

View File

@ -80,7 +80,7 @@ async function GetBookTaskDetailOption() {
videoMessage.value = res.data videoMessage.value = res.data
} else { } else {
// //
let initRes = await window.book.InitVideoMessage(props.bookTaskDetailId) let initRes = await window.book.video.InitVideoMessage(props.bookTaskDetailId)
console.log('InitVideoMessage', initRes) console.log('InitVideoMessage', initRes)
if (initRes.code != 1) { if (initRes.code != 1) {
message.error(initRes.message) message.error(initRes.message)
@ -96,7 +96,7 @@ async function GetBookTaskDetailOption() {
if (ValidateJson(videoMessage.value.lumaOptions)) { if (ValidateJson(videoMessage.value.lumaOptions)) {
lumaOptions.value = JSON.parse(videoMessage.value.lumaOptions) lumaOptions.value = JSON.parse(videoMessage.value.lumaOptions)
lumaOptions.value.request_model = lumaOptions.value.request_model =
lumaOptions.value.request_model == null ? 'fast' : lumaOptions.value.request_model lumaOptions.value.request_model == null ? 'fast' : lumaOptions.value.request_model
lumaOptions.value.image_url = videoMessage.value.imageUrl lumaOptions.value.image_url = videoMessage.value.imageUrl
} }
if (ValidateJson(videoMessage.value.klingOptions)) { if (ValidateJson(videoMessage.value.klingOptions)) {
@ -158,7 +158,7 @@ async function SaveSimpleOptions() {
} }
console.log('saveVideoMessageObject', saveVideoMessageObject) console.log('saveVideoMessageObject', saveVideoMessageObject)
let res = await window.book.UpdateBookTaskDetailVideoMessage( let res = await window.book.video.UpdateBookTaskDetailVideoMessage(
props.bookTaskDetailId, props.bookTaskDetailId,
saveVideoMessageObject saveVideoMessageObject
) )

View File

@ -67,7 +67,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { NProgress, useMessage } from 'naive-ui' import { NProgress, useMessage } from 'naive-ui'
import { useReverseManageStore } from '../../../../../stores/reverseManage' import { useReverseManageStore } from '../../../../../stores/reverseManage'
import { useSoftwareStore } from '../../../../../stores/software' import { useSoftwareStore } from '../../../../../stores/software'
@ -79,6 +79,9 @@ let promptPercentage = ref(0)
let imagePercentage = ref(0) let imagePercentage = ref(0)
let reversePromptPercentage = ref(0) let reversePromptPercentage = ref(0)
//
let intervalId = null
let reverseManageStore = useReverseManageStore() let reverseManageStore = useReverseManageStore()
let softwareStore = useSoftwareStore() let softwareStore = useSoftwareStore()
let message = useMessage() let message = useMessage()
@ -134,11 +137,19 @@ onMounted(() => {
ComputePercentage() ComputePercentage()
// //
setInterval(async () => { intervalId = setInterval(async () => {
await ComputePercentage() await ComputePercentage()
}, 10000) }, 10000)
}) })
onUnmounted(() => {
//
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
})
function ErrorPosition(type) { function ErrorPosition(type) {
let index = -1 let index = -1
softwareStore.skipRowIndex = 0 softwareStore.skipRowIndex = 0

View File

@ -26,7 +26,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, defineComponent, onUnmounted, toRaw, watch } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { useMessage, NDivider } from 'naive-ui' import { useMessage, NDivider } from 'naive-ui'
import ManageBookDetailButton from './MJReverse/ManageBookDetailButton.vue' import ManageBookDetailButton from './MJReverse/ManageBookDetailButton.vue'
import ManageBookReverseTable from './MJReverse/ManageBookReverseTable.vue' import ManageBookReverseTable from './MJReverse/ManageBookReverseTable.vue'

View File

@ -0,0 +1,217 @@
<template>
<div class="novel-reader-home">
<!-- 移动端遮罩层 -->
<div
class="mobile-overlay"
:class="{ active: isMobileSidebarOpen }"
@click="closeMobileSidebar"
></div>
<!-- 主要内容区域 -->
<div class="content-container">
<!-- 左侧小说列表 -->
<NovelSidebar
:book-info-list="bookInfoList"
:selected-novel-id="selectedNovelId"
:is-mobile-sidebar-open="isMobileSidebarOpen"
@add-novel="handleAddNovel"
@select-novel="handleSelectNovel"
/>
<!-- 右侧章节内容 -->
<div class="chapter-content-area">
<!-- 移动端顶部导航 -->
<MobileHeader
:selected-novel-title="selectedNovel?.title"
@open-sidebar="openMobileSidebar"
/>
<div v-if="selectedNovel" class="chapter-container">
<!-- 章节列表 -->
<ChapterList
:selected-novel="selectedNovel"
@open-chapter="handleOpenChapter"
@add-tag="handleAddTag"
@remove-tag="handleRemoveTag"
/>
</div>
<!-- 空状态 -->
<div v-else class="chapter-container">
<EmptyState @add-novel="handleAddNovel" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import NovelSidebar from './components/ImageTextVideo/ImageTextVideoBookSidebar.vue'
import MobileHeader from './components/ImageTextVideo/ImageTextVideoMobileHeader.vue'
import ChapterList from './components/ImageTextVideo/ImageTextVideoTaskList.vue'
import EmptyState from './components/ImageTextVideo/ImageTextVideoEmptyState.vue'
import { useReverseManageStore } from '@/stores/reverseManage'
const reverseManageStore = useReverseManageStore()
const message = useMessage()
// ID
const selectedNovelId = ref('novel-1')
//
const isMobileSidebarOpen = ref(false)
const bookInfoList = ref([])
onMounted(async () => {
//
let res = await window.book.video.GetVideoBookInfoList({})
console.log('获取小说数据', res)
bookInfoList.value = res.data
})
//
const selectedNovel = computed(() => {
return bookInfoList.value.find((bookInfo) => bookInfo.id === selectedNovelId.value)
})
//
function handleSelectNovel(novelId) {
selectedNovelId.value = novelId
reverseManageStore.selectBook = bookInfoList.value.find((book) => book.id === novelId)
console.log('选择小说:', novelId)
//
closeMobileSidebar()
}
//
function openMobileSidebar() {
isMobileSidebarOpen.value = true
}
//
function closeMobileSidebar() {
isMobileSidebarOpen.value = false
}
//
function handleAddNovel() {
message.info('功能开发中:添加新小说')
}
//
function handleOpenChapter(chapter) {
message.info(`打开章节:${chapter.title}`)
}
//
function handleAddTag(chapterId) {
//
const tag = prompt('请输入标签名称:')
if (tag && tag.trim()) {
const chapter = findChapterById(chapterId)
if (chapter) {
chapter.tags.push(tag.trim())
message.success(`添加标签: ${tag}`)
}
}
}
//
function handleRemoveTag(chapterId, tag) {
const chapter = findChapterById(chapterId)
if (chapter) {
const index = chapter.tags.indexOf(tag)
if (index > -1) {
chapter.tags.splice(index, 1)
message.success(`移除标签: ${tag}`)
}
}
}
//
function findChapterById(chapterId) {
for (const bookInfo of bookInfoList.value) {
const bookTasks = bookInfo.bookTasks.find((ch) => ch.id === chapterId)
if (bookTasks) return bookTasks
}
return null
}
</script>
<style scoped>
.novel-reader-home {
overflow: hidden;
}
.content-container {
display: grid;
grid-template-columns: 320px 1fr;
height: calc(100vh - 10px);
gap: 16px;
/* padding: 16px; */
}
/* 右侧内容 */
.chapter-content-area {
overflow: auto;
display: flex;
flex-direction: column;
}
.chapter-container {
flex: 1;
padding: 8px 0;
}
.mobile-overlay {
display: none;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.content-container {
grid-template-columns: 280px 1fr;
}
}
@media (max-width: 768px) {
.content-container {
grid-template-columns: 1fr;
height: calc(100vh - 16px);
padding: 8px;
}
.mobile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
display: block;
}
.mobile-overlay.active {
opacity: 1;
visibility: visible;
}
.chapter-container {
padding: 0;
}
}
@media (max-width: 480px) {
.content-container {
height: calc(100vh - 8px);
padding: 4px;
}
}
</style>

View File

@ -0,0 +1,311 @@
<template>
<div class="image-text-video-info-home">
<div class="layout-wrapper">
<!-- 左右分栏容器 -->
<n-layout has-sider class="main-layout">
<!-- 左侧表格区域 -->
<n-layout-content class="left-panel">
<image-text-video-info-task-list
:table-data="tableData"
:loading="loading"
:show-right-panel="showRightPanel"
@view-detail="handleViewDetail"
@toggle-status="handleToggleStatus"
@add-task="handleAdd"
@toggle-right-panel="handleToggleRightPanel"
/>
</n-layout-content>
<!-- 右侧详细操作区域 -->
<n-layout-sider
v-if="showRightPanel"
:width="450"
:min-width="450"
:max-width="450"
:show-trigger="false"
position="right"
class="right-panel"
>
<div class="detail-container">
<!-- 空状态 -->
<image-text-video-info-empty-state v-if="!selectedTask" />
<!-- 任务详情 -->
<image-text-video-info-task-detail
v-else
:task="selectedTask"
@delete-task="handleDeleteTask"
@edit-task="handleEditTask"
@config-change="handleConfigChange"
/>
</div>
</n-layout-sider>
</n-layout>
</div>
<!-- 抽屉形式的详细信息 -->
<n-drawer
v-model:show="showDrawer"
:width="450"
placement="right"
:mask-closable="true"
:close-on-esc="true"
>
<n-drawer-content title="任务详情" closable>
<!-- 空状态 -->
<image-text-video-info-empty-state v-if="!selectedTask" />
<!-- 任务详情 -->
<image-text-video-info-task-detail
v-else
:task="selectedTask"
@delete-task="handleDeleteTask"
@edit-task="handleEditTask"
@config-change="handleConfigChange"
/>
</n-drawer-content>
</n-drawer>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import {
NLayout,
NLayoutSider,
NLayoutContent,
NDrawer,
NDrawerContent,
useMessage
} from 'naive-ui'
import ImageTextVideoInfoTaskList from './components/ImageTextVideoInfo/ImageTextVideoInfoTaskList.vue'
import ImageTextVideoInfoTaskDetail from './components/ImageTextVideoInfo/ImageTextVideoInfoTaskDetail.vue'
import ImageTextVideoInfoEmptyState from './components/ImageTextVideoInfo/ImageTextVideoInfoEmptyState.vue'
import { useReverseManageStore } from '@/stores/reverseManage'
const reverseManageStore = useReverseManageStore()
const message = useMessage()
//
const tableData = ref([])
//
const loading = ref(false)
//
const selectedTask = ref(null)
//
const showRightPanel = ref(false)
//
const showDrawer = ref(false)
async function handleInitialize() {
//
loading.value = true
try {
//
let res = await reverseManageStore.GetBookTaskDetail(reverseManageStore.selectBookTask.id)
console.log('获取任务数据', res)
if (res.code != 1) {
message.error(res.message)
return
}
tableData.value = res.data
} catch (error) {
message.error('加载数据失败')
} finally {
loading.value = false
}
}
onMounted(() => {
handleInitialize()
})
//
function handleViewDetail(row) {
selectedTask.value = { ...row }
//
if (!showRightPanel.value) {
showDrawer.value = true
}
}
//
function handleToggleRightPanel(show) {
showRightPanel.value = show
//
if (!show && selectedTask.value) {
showDrawer.value = true
}
//
if (show) {
showDrawer.value = false
}
}
//
function handleToggleStatus(row) {
const task = tableData.value.find((item) => item.id === row.id)
if (task) {
if (task.status === '进行中') {
task.status = '暂停'
message.warning(`任务 ${task.name} 已暂停`)
} else if (task.status === '暂停') {
task.status = '进行中'
message.success(`任务 ${task.name} 已启动`)
} else {
message.error('当前状态不支持切换')
}
//
if (selectedTask.value && selectedTask.value.id === task.id) {
selectedTask.value = { ...task }
}
}
}
//
function handleAdd() {
message.info('新增任务功能开发中...')
}
//
function handleEditTask(task) {
message.info(`编辑任务: ${task.name}`)
}
//
function handleDeleteTask(task) {
const index = tableData.value.findIndex((item) => item.id === task.id)
if (index > -1) {
tableData.value.splice(index, 1)
selectedTask.value = null
}
}
//
function handleConfigChange(task) {
//
const tableTask = tableData.value.find((item) => item.id === task.id)
if (tableTask) {
tableTask.config = { ...task.config }
}
}
</script>
<style scoped>
.image-text-video-info-home {
width: 100%;
height: calc(100vh - 10px);
overflow-x: auto;
overflow-y: hidden;
}
.layout-wrapper {
width: 100%;
height: 100%;
min-width: 1200px;
}
.main-layout {
width: 100%;
height: 100%;
display: flex;
min-width: 1200px;
}
.left-panel {
border-right: 1px solid var(--n-border-color);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
flex: 1;
min-width: 600px;
overflow: hidden;
transition: all 0.3s ease;
}
/* 当右侧面板隐藏时,左侧面板占满整个宽度 */
.left-panel:only-child {
border-right: none;
box-shadow: none;
}
.right-panel {
width: 450px;
min-width: 450px;
max-width: 450px;
flex-shrink: 0;
border-left: 1px solid var(--n-border-color);
transition: all 0.3s ease;
}
.detail-container {
height: calc(100vh - 20px);
padding: 5px 20px;
overflow-y: auto;
}
/* 响应式设计 */
@media (max-width: 1400px) {
.layout-wrapper {
min-width: 1000px;
}
.main-layout {
min-width: 1000px;
}
.right-panel {
width: 400px;
min-width: 400px;
max-width: 400px;
}
.left-panel {
min-width: 500px;
}
}
@media (max-width: 1200px) {
.layout-wrapper {
min-width: 900px;
}
.main-layout {
min-width: 900px;
}
.right-panel {
width: 350px;
min-width: 350px;
max-width: 350px;
}
.left-panel {
min-width: 450px;
}
}
/* 横向滚动条样式 */
.image-text-video-info-home::-webkit-scrollbar {
height: 8px;
}
.image-text-video-info-home::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.image-text-video-info-home::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.image-text-video-info-home::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>

View File

@ -0,0 +1,210 @@
<template>
<n-list-item
class="book-item"
:class="{ selected: isSelected }"
@click="$emit('select', bookInfo.id)"
>
<n-card
:bordered="false"
class="book-card"
:content-style="{
padding: '8px 0 0 0',
backgroundColor: 'transparent'
}"
:header-style="{ padding: 0 }"
>
<template #header>
<!-- 标题和按钮 -->
<div class="book-header">
<span strong class="book-title" :class="{ active: isSelected }" :title="bookInfo.name">
{{ bookInfo.name }}
</span>
<n-dropdown
:options="getBookMenuOptions()"
class="menu-button"
@select="handleBookAction"
trigger="click"
>
<n-button size="small" quaternary circle @click.stop>
<template #icon>
<n-icon><EllipsisVerticalOutline /></n-icon>
</template>
</n-button>
</n-dropdown>
</div>
</template>
<div class="book-content">
<!-- 头像部分 -->
<div class="book-avatar">
<n-avatar :size="50" :src="bookInfo.cover" color="#7fe7c4" style="border-radius: 8px">
<n-icon size="38"><BookOutline /></n-icon>
</n-avatar>
</div>
<!-- 描述部分 -->
<div class="book-meta">
<n-space vertical>
<n-space align="center" gap="8px">
<n-tag type="warning" size="small" :bordered="false">
{{ GetBookTypeLabel(bookInfo.type) }}
</n-tag>
<n-text strong>{{ bookInfo.bookTasks.length }} 个视频批次</n-text>
</n-space>
<div>
<n-text strong>{{ FormatDate(bookInfo.createTime) }}</n-text>
</div>
</n-space>
</div>
</div>
</n-card>
</n-list-item>
</template>
<script setup>
import { h } from 'vue'
import {
NListItem,
NCard,
NAvatar,
NIcon,
NTag,
NButton,
NDropdown,
NText,
useMessage
} from 'naive-ui'
import {
BookOutline,
EllipsisVerticalOutline,
CreateOutline,
TrashOutline,
EyeOutline
} from '@vicons/ionicons5'
import { GetBookTypeLabel } from '@/define/enum/bookEnum'
import { FormatDate } from '@/renderer/src/common/time'
const message = useMessage()
defineProps({
bookInfo: {
type: Object,
required: true
},
isSelected: {
type: Boolean,
default: false
}
})
defineEmits(['select'])
//
function getBookMenuOptions() {
return [
{
label: '查看详情',
key: 'view',
icon: () => h(NIcon, null, () => h(EyeOutline))
},
{
label: '编辑信息',
key: 'edit',
icon: () => h(NIcon, null, () => h(CreateOutline))
},
{
type: 'divider'
},
{
label: '删除',
key: 'delete',
icon: () => h(NIcon, null, () => h(TrashOutline))
}
]
}
//
function handleBookAction(key) {
switch (key) {
case 'view':
message.info('查看书籍详情')
break
case 'edit':
message.info('编辑书籍信息')
break
case 'delete':
message.warning('删除书籍')
break
}
}
</script>
<style scoped>
.book-item {
cursor: pointer;
transition: all 0.3s ease;
padding: 8px 12px;
border-radius: 8px;
margin-bottom: 8px;
background-color: rgba(127, 231, 196, 0.05);
border: 1px solid rgba(127, 231, 196, 0.1);
}
.book-item:hover {
background-color: var(--n-color-hover);
}
.book-item.selected {
background-color: rgba(127, 231, 196, 0.3);
border: 1px solid rgba(127, 231, 196, 0.3);
}
.book-card {
background: transparent;
padding: 0;
}
.book-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-width: 0;
}
.book-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.book-avatar {
flex-shrink: 0;
}
.book-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
max-width: 200px;
}
.book-title.active {
color: var(--n-color-primary);
font-weight: 600;
}
.menu-button {
flex-shrink: 0;
}
.book-meta {
/* display: flex; */
align-items: center;
gap: 12px;
font-size: 12px;
}
</style>

View File

@ -0,0 +1,103 @@
<template>
<n-card
class="novel-list-sidebar"
:class="{ 'mobile-open': isMobileSidebarOpen }"
:bordered="false"
:content-style="{ padding: '16px' }"
>
<template #header>
<div class="sidebar-header">
<h2>我的书库</h2>
<n-button type="primary" size="small" @click="$emit('add-novel')">
<template #icon>
<n-icon><AddOutline /></n-icon>
</template>
添加小说
</n-button>
</div>
</template>
<n-scrollbar style="max-height: calc(100vh - 100px); padding-right: 10px">
<n-list>
<NovelItem
v-for="bookInfo in bookInfoList"
:key="bookInfo.id"
:book-info="bookInfo"
:is-selected="selectedNovelId === bookInfo.id"
@select="$emit('select-novel', $event)"
/>
</n-list>
</n-scrollbar>
</n-card>
</template>
<script setup>
import { NCard, NButton, NIcon, NScrollbar, NList } from 'naive-ui'
import { AddOutline } from '@vicons/ionicons5'
import NovelItem from './ImageTextVideoBookItem.vue'
defineProps({
bookInfoList: {
type: Array,
required: true
},
selectedNovelId: {
type: String,
default: ''
},
isMobileSidebarOpen: {
type: Boolean,
default: false
}
})
defineEmits(['add-novel', 'select-novel'])
</script>
<style scoped>
.novel-list-sidebar {
height: 100%;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.sidebar-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
@media (max-width: 768px) {
.novel-list-sidebar {
position: fixed;
top: 16px;
left: -340px;
width: 320px;
height: calc(100vh - 32px);
z-index: 1000;
transition: left 0.3s ease;
box-shadow: var(--n-box-shadow-2);
}
.novel-list-sidebar.mobile-open {
left: 16px;
}
}
@media (max-width: 480px) {
.novel-list-sidebar {
top: 8px;
left: -340px;
height: calc(100vh - 16px);
}
.novel-list-sidebar.mobile-open {
left: 8px;
}
}
</style>

View File

@ -0,0 +1,30 @@
<template>
<div class="empty-state">
<n-empty size="huge" description="请选择一本小说开始操作">
<template #icon>
<n-icon><BookOutline /></n-icon>
</template>
<template #extra>
<n-button type="primary" @click="$emit('add-novel')">添加第一本小说</n-button>
</template>
</n-empty>
</div>
</template>
<script setup>
import { NEmpty, NIcon, NButton } from 'naive-ui'
import { BookOutline } from '@vicons/ionicons5'
defineEmits(['add-novel'])
</script>
<style scoped>
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
height: 100%;
}
</style>

View File

@ -0,0 +1,40 @@
<template>
<n-card class="mobile-header" :bordered="false">
<n-space justify="space-between" align="center">
<n-button type="primary" @click="$emit('open-sidebar')">
<template #icon>
<n-icon><BookOutline /></n-icon>
</template>
选择小说
</n-button>
<n-text strong>{{ selectedNovelTitle || '请选择小说' }}</n-text>
</n-space>
</n-card>
</template>
<script setup>
import { NCard, NSpace, NButton, NIcon, NText } from 'naive-ui'
import { BookOutline } from '@vicons/ionicons5'
defineProps({
selectedNovelTitle: {
type: String,
default: ''
}
})
defineEmits(['open-sidebar'])
</script>
<style scoped>
.mobile-header {
display: none;
}
@media (max-width: 768px) {
.mobile-header {
display: block;
margin-bottom: 16px;
}
}
</style>

View File

@ -0,0 +1,198 @@
<template>
<n-card
class="bookTask-card"
hoverable
@click="handleOpenBookTask"
:header-style="{
padding: '8px 16px'
}"
:content-style="{
padding: '0 16px 16px 16px',
borderRadius: '8px'
}"
>
<template #header>
<n-space justify="space-between" align="center">
<n-text strong>{{ bookTask.name }}</n-text>
<n-dropdown :options="getChapterMenuOptions(bookTask)" @select="handleChapterAction">
<n-button size="small" quaternary circle>
<template #icon>
<n-icon><EllipsisVerticalOutline /></n-icon>
</template>
</n-button>
</n-dropdown>
</n-space>
</template>
<!-- 章节信息 -->
<n-space vertical size="small">
<!-- 标签 -->
<n-space size="small" wrap>
<n-tag
v-for="tag in bookTask.tags"
:key="tag"
size="small"
type="primary"
@close="handleRemoveTag(bookTask.id, tag)"
>
{{ tag }}
</n-tag>
</n-space>
<n-space size="small">
<n-text depth="3" style="font-size: 12px">{{ FormatDate(bookTask.updateTime) }}</n-text>
</n-space>
<!-- 阅读进度 -->
<div>
<n-space justify="space-between" style="margin-bottom: 4px">
<n-text depth="3" style="font-size: 12px">转视频进度</n-text>
<n-text depth="3" style="font-size: 12px">{{ progress }}%</n-text>
</n-space>
<n-progress
type="line"
:percentage="progress"
:show-indicator="false"
color="#7fe7c4"
rail-color="#f3f4f6"
:height="6"
/>
</div>
</n-space>
</n-card>
</template>
<script setup>
import { h, toRef, computed, onMounted } from 'vue'
import {
NCard,
NSpace,
NText,
NDropdown,
NButton,
NIcon,
NTag,
NProgress,
useMessage,
useDialog
} from 'naive-ui'
import {
EllipsisVerticalOutline,
AddOutline,
CreateOutline,
TrashOutline,
EyeOutline
} from '@vicons/ionicons5'
import { GetBookImageCategoryLabel } from '@/define/enum/bookEnum'
import { GetImageToVideoModelsLabel } from '@/define/enum/video'
import { FormatDate } from '@/renderer/src/common/time'
import { useRouter } from 'vue-router'
import { useReverseManageStore } from '@/stores/reverseManage'
const reverseManageStore = useReverseManageStore()
const router = useRouter()
let props = defineProps({
bookTask: {
type: Object,
required: true
}
})
const message = useMessage()
const dialog = useDialog()
const bookTask = toRef(props, 'bookTask')
const progress = computed(() => {
debugger
return bookTask.value.imageVideoProgress ? bookTask.value.imageVideoProgress.videoRate : 0
})
// bookTasktags
function tagsHandle() {
bookTask.value.tags = [
GetBookImageCategoryLabel(props.bookTask.imageCategory),
GetImageToVideoModelsLabel(props.bookTask.videoCategory)
]
}
onMounted(() => {
tagsHandle()
})
// bookTask
function handleOpenBookTask() {
message.info(`正在打开任务: ${bookTask.value.name}`)
reverseManageStore.selectBookTask = bookTask.value
router.push({ name: 'image_text_video_info', params: { id: bookTask.value.id } })
// url
console.log('当前URL:', window.location.href)
}
//
function handleRemoveTag(bookTaskId, tag) {
console.log('移除标签:', bookTaskId, tag)
message.info(`已移除标签: ${tag}`)
//
}
//
function getChapterMenuOptions(bookTask) {
return [
{
label: '阅读',
key: 'read',
icon: () => h(NIcon, null, () => h(EyeOutline))
},
{
label: '编辑',
key: 'edit',
icon: () => h(NIcon, null, () => h(CreateOutline))
},
{
type: 'divider'
},
{
label: '删除',
key: 'delete',
icon: () => h(NIcon, null, () => h(TrashOutline))
}
]
}
//
function handleChapterAction(key, option) {
console.log('章节操作:', key)
switch (key) {
case 'read':
message.info('正在打开阅读界面...')
break
case 'edit':
message.info('正在打开编辑界面...')
break
case 'delete':
dialog.warning({
title: '确认删除',
content: '确定要删除这个章节吗?此操作不可恢复。',
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
message.success('章节已删除')
}
})
break
}
}
</script>
<style scoped>
.bookTask-card {
cursor: pointer;
transition: all 0.3s ease;
}
.bookTask-card:hover {
transform: translateY(-2px);
}
</style>

View File

@ -0,0 +1,110 @@
<template>
<n-card title="批次目录" :bordered="false" :style="{ minWidth: '380px' }">
<template #header-extra>
<n-space align="center">
<n-text depth="3"> {{ selectedNovel.bookTasks.length }} 个视频批次</n-text>
<n-divider vertical />
<n-tooltip trigger="hover">
<template #trigger>
<n-button text type="primary" size="small">
<template #icon>
<n-icon><AddOutline /></n-icon>
</template>
新增视频批次
</n-button>
</template>
点击添加选择一个视频批次从聚合推文同步过来
</n-tooltip>
</n-space>
</template>
<div class="task-grid">
<div v-for="bookTask in selectedBook.bookTasks" :key="bookTask.id" class="task-item">
<ChapterCard :bookTask="bookTask" />
</div>
</div>
</n-card>
</template>
<script setup>
import { computed } from 'vue'
import {
NCard,
NSpace,
NText,
NDivider,
NGrid,
NGridItem,
NButton,
NTooltip,
NIcon
} from 'naive-ui'
import ChapterCard from './ImageTextVideoTaskCard.vue'
import { AddOutline } from '@vicons/ionicons5'
import { TimeDelay } from '@/define/Tools/time'
const props = defineProps({
selectedNovel: {
type: Object,
required: true
}
})
const selectedBook = ref(props.selectedNovel)
async function progressHandle() {
//
let res = await window.book.video.GetBookImageAndVideoProgress(props.selectedNovel.id, null)
console.log('获取批次进度', res)
let bookProgress = res.data[props.selectedNovel.id]
for (let i = 0; i < selectedBook.value.bookTasks.length; i++) {
const element = selectedBook.value.bookTasks[i]
element.imageVideoProgress = bookProgress[element.id] || {
imageProgress: 0,
videoProgress: 0,
totalCount: 0,
imageRate: 0,
videoRate: 0
}
}
}
onMounted(async () => {
await progressHandle()
})
</script>
<style scoped>
.task-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
/* 大屏幕 (l: 1024px+) - 固定宽度,自动换行 */
@media (min-width: 1024px) {
.task-grid {
grid-template-columns: repeat(auto-fill, 320px);
justify-content: start;
}
}
/* 中等屏幕 (m: 768px-1023px) - 自适应 */
@media (min-width: 768px) and (max-width: 1023px) {
.task-grid {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
}
/* 小屏幕 (s: <768px) - 单列自适应 */
@media (max-width: 767px) {
.task-grid {
grid-template-columns: 1fr;
}
}
.task-item {
width: 100%;
}
</style>

View File

@ -0,0 +1,72 @@
<template>
<n-card title="基本信息" class="info-card">
<n-descriptions :column="2" label-placement="left" class="descriptions">
<n-descriptions-item label="任务名称">
{{ task.name }}
</n-descriptions-item>
<n-descriptions-item label="任务状态">
<n-tag :type="getStatusType(task.status)">
{{ task.status }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="创建时间">
{{ formatDate(task.createTime) }}
</n-descriptions-item>
<n-descriptions-item label="更新时间">
{{ formatDate(task.updateTime) }}
</n-descriptions-item>
<n-descriptions-item label="任务描述" :span="2">
{{ task.description || '暂无描述' }}
</n-descriptions-item>
</n-descriptions>
</n-card>
</template>
<script setup>
import {
NCard,
NDescriptions,
NDescriptionsItem,
NTag
} from 'naive-ui'
// props
const props = defineProps({
task: {
type: Object,
required: true
}
})
//
function getStatusType(status) {
const statusMap = {
进行中: 'info',
已完成: 'success',
暂停: 'warning',
失败: 'error'
}
return statusMap[status] || 'default'
}
//
function formatDate(dateString) {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
}
</script>
<style scoped>
.info-card {
margin-bottom: 16px;
transition: all 0.3s ease;
}
.info-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.descriptions :deep(.n-descriptions-item-label) {
font-weight: 500;
}
</style>

View File

@ -0,0 +1,76 @@
<template>
<n-card title="参数配置" class="info-card">
<n-form :model="task.config" label-placement="left" label-width="120px">
<n-form-item label="输出格式">
<n-select
v-model:value="task.config.outputFormat"
:options="outputFormatOptions"
@update:value="handleConfigChange"
/>
</n-form-item>
<n-form-item label="质量设置">
<n-slider
v-model:value="task.config.quality"
:min="1"
:max="10"
:step="1"
:marks="{ 1: '低', 5: '中', 10: '高' }"
@update:value="handleConfigChange"
/>
</n-form-item>
<n-form-item label="自动处理">
<n-switch
v-model:value="task.config.autoProcess"
@update:value="handleConfigChange"
/>
</n-form-item>
</n-form>
</n-card>
</template>
<script setup>
import { ref } from 'vue'
import {
NCard,
NForm,
NFormItem,
NSelect,
NSlider,
NSwitch
} from 'naive-ui'
// emits
const emit = defineEmits(['config-change'])
// props
const props = defineProps({
task: {
type: Object,
required: true
}
})
//
const outputFormatOptions = [
{ label: 'MP4', value: 'mp4' },
{ label: 'AVI', value: 'avi' },
{ label: 'MOV', value: 'mov' },
{ label: 'WMV', value: 'wmv' }
]
//
function handleConfigChange() {
emit('config-change')
}
</script>
<style scoped>
.info-card {
margin-bottom: 16px;
transition: all 0.3s ease;
}
.info-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>

View File

@ -0,0 +1,25 @@
<template>
<div class="empty-state">
<n-empty description="请选择左侧表格中的任务查看详细信息">
<template #icon>
<n-icon size="48" color="#d4d4d8">
<DocumentTextOutline />
</n-icon>
</template>
</n-empty>
</div>
</template>
<script setup>
import { NEmpty, NIcon } from 'naive-ui'
import { DocumentTextOutline } from '@vicons/ionicons5'
</script>
<style scoped>
.empty-state {
height: calc(100vh - 50px);
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -0,0 +1,45 @@
<template>
<n-card title="进度信息" class="info-card">
<n-space vertical>
<n-space justify="space-between">
<n-text>完成进度</n-text>
<n-text>{{ task.progress }}%</n-text>
</n-space>
<n-progress
type="line"
:percentage="task.progress"
color="#18a058"
rail-color="#f3f4f6"
:height="8"
/>
</n-space>
</n-card>
</template>
<script setup>
import {
NCard,
NSpace,
NText,
NProgress
} from 'naive-ui'
// props
const props = defineProps({
task: {
type: Object,
required: true
}
})
</script>
<style scoped>
.info-card {
margin-bottom: 16px;
transition: all 0.3s ease;
}
.info-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>

View File

@ -0,0 +1,118 @@
<template>
<div class="task-detail">
<div class="detail-header">
<n-space justify="space-between" align="center">
<n-text strong style="font-size: 18px">任务详情</n-text>
<n-button-group>
<n-button type="primary" @click="handleEdit">
<template #icon>
<n-icon><CreateOutline /></n-icon>
</template>
编辑
</n-button>
<n-button type="error" @click="handleDelete">
<template #icon>
<n-icon><TrashOutline /></n-icon>
</template>
删除
</n-button>
</n-button-group>
</n-space>
</div>
<n-divider />
<!-- 基本信息 -->
<image-text-video-info-basic-info :task="task" />
<!-- 进度信息 -->
<image-text-video-info-progress :task="task" />
<!-- 参数配置 -->
<image-text-video-info-config :task="task" @config-change="handleConfigChange" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import {
NSpace,
NText,
NButton,
NButtonGroup,
NIcon,
NDivider,
useMessage,
useDialog
} from 'naive-ui'
import {
CreateOutline,
TrashOutline
} from '@vicons/ionicons5'
import ImageTextVideoInfoBasicInfo from './ImageTextVideoInfoBasicInfo.vue'
import ImageTextVideoInfoProgress from './ImageTextVideoInfoProgress.vue'
import ImageTextVideoInfoConfig from './ImageTextVideoInfoConfig.vue'
const message = useMessage()
const dialog = useDialog()
// emits
const emit = defineEmits(['delete-task', 'edit-task', 'config-change'])
// props
const props = defineProps({
task: {
type: Object,
required: true
}
})
//
function handleEdit() {
emit('edit-task', props.task)
message.info(`编辑任务: ${props.task.name}`)
}
//
function handleDelete() {
dialog.warning({
title: '确认删除',
content: `确定要删除任务 "${props.task.name}" 吗?此操作不可恢复。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
emit('delete-task', props.task)
message.success('任务已删除')
}
})
}
//
function handleConfigChange() {
emit('config-change', props.task)
message.info('配置已更新')
}
</script>
<style scoped>
.task-detail {
height: 100%;
}
.detail-header {
margin-bottom: 16px;
}
/* 滚动条样式 */
.task-detail::-webkit-scrollbar {
width: 6px;
}
.task-detail::-webkit-scrollbar-track {
border-radius: 3px;
}
.task-detail::-webkit-scrollbar-thumb {
border-radius: 3px;
}
</style>

View File

@ -0,0 +1,535 @@
<template>
<div class="table-container">
<div class="table-header">
<n-space justify="space-between" align="center">
<n-text strong>任务列表</n-text>
<n-space align="center">
<n-space align="center" size="small">
<n-text>分页</n-text>
<n-switch v-model:value="paginationEnabled" @update:value="handlePaginationToggle" />
</n-space>
<n-space align="center" size="small">
<n-text>右侧面板</n-text>
<n-switch v-model:value="showRightPanel" @update:value="handleRightPanelToggle" />
</n-space>
<n-button type="primary" size="small" @click="handleAdd">
<template #icon>
<n-icon><AddOutline /></n-icon>
</template>
新增任务
</n-button>
</n-space>
</n-space>
</div>
<n-data-table
:columns="columns"
:data="tableData"
:loading="loading"
:pagination="paginationEnabled ? paginationReactive : false"
:row-key="(row) => row.id"
:scroll-x="1400"
:max-height="tableMaxHeight"
:min-height="tableMinHeight"
class="data-table"
/>
</div>
</template>
<script setup>
import { ref, reactive, h, computed, onMounted, onUnmounted, watch } from 'vue'
import {
NSpace,
NText,
NButton,
NIcon,
NDataTable,
NTag,
NSwitch,
NImage,
NInput,
useMessage
} from 'naive-ui'
import { AddOutline, EyeOutline, PlayOutline, PauseOutline } from '@vicons/ionicons5'
import { define } from '@/define/define'
import ImageTextVideoInfoVideoConfig from './ImageTextVideoInfoVideoConfig.vue'
import ImageTextVideoInfoVideoListInfo from './ImageTextVideoInfoVideoListInfo.vue'
const message = useMessage()
// emits
const emit = defineEmits([
'view-detail',
'toggle-status',
'add-task',
'update-prompt',
'update-method',
'update-note',
'toggle-right-panel'
])
// props
const props = defineProps({
tableData: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
showRightPanel: {
type: Boolean,
default: true
}
})
//
const paginationEnabled = ref(true)
//
const showRightPanel = ref(props.showRightPanel)
// props
watch(
() => props.showRightPanel,
(newVal) => {
showRightPanel.value = newVal
}
)
//
const windowHeight = ref(window.innerHeight)
//
const tableMaxHeight = computed(() => {
// - - - -
const topReserved = 60 //
const headerHeight = 48 //
const paginationHeight = paginationEnabled.value ? 40 : 0 // 60px10px
const bottomMargin = 15 //
const availableHeight =
windowHeight.value - topReserved - headerHeight - paginationHeight - bottomMargin
//
const minHeight = paginationEnabled.value ? 300 : 400
return Math.max(availableHeight, minHeight)
})
const tableMinHeight = computed(() => {
// 300px400px
return paginationEnabled.value ? 300 : 400
})
//
const handleResize = () => {
windowHeight.value = window.innerHeight
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
//
const paginationReactive = reactive({
page: 1,
pageSize: 10,
showSizePicker: true,
pageSizes: [10, 20, 50],
onChange: (page) => {
paginationReactive.page = page
},
onUpdatePageSize: (pageSize) => {
paginationReactive.pageSize = pageSize
paginationReactive.page = 1
}
})
// padding
const noPaddingColumnClass = 'no-padding-column'
//
const columns = [
{
title: '任务名称',
key: 'name',
width: 120,
fixed: 'left'
},
{
title: '图片',
key: 'image',
minWidth: 80,
width: 160,
maxWidth: 160,
className: noPaddingColumnClass,
render(row) {
if (row.outImagePath) {
return h(
'div',
{
style: {
height: '130px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}
},
[
h(NImage, {
src: row.outImagePath,
height: 130,
objectFit: 'cover',
style: {
borderRadius: '4px',
maxWidth: '160px',
height: '130px'
},
fallbackSrc: define.zhanwei_image,
showToolbar: false,
showToolbarTooltip: false
})
]
)
} else {
return h(
'div',
{
style: {
height: '130px',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5',
borderRadius: '4px',
color: '#999',
fontSize: '12px'
}
},
'图片不存在'
)
}
}
},
{
title: '图片链接',
key: 'image_url',
width: 150,
className: noPaddingColumnClass,
render(row) {
return h(
'div',
{
style: {
height: '130px',
width: '95%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '4px'
}
},
[
h(NInput, {
value: row.videoMessage?.imageUrl || '',
type: 'textarea',
placeholder: '请输入图片链接...',
resizable: false,
style: {
width: '100%',
height: '100%'
},
inputProps: {
style: {
height: '100%',
resize: 'none'
},
spellcheck: false
},
onUpdateValue: (value) => {
row.note = value
emit('update-note', row.id, value)
}
})
]
)
}
},
{
title: '视频配置',
key: 'videoConfig',
minWidth: 300,
className: noPaddingColumnClass,
render(row) {
return h(ImageTextVideoInfoVideoConfig, {
taskId: row.id,
videoMessage: row?.videoMessage,
onUpdateMethod: (taskId, method) => {
row.videoMethod = method
emit('update-method', taskId, method)
},
onUpdatePrompt: (taskId, prompt) => {
row.videoPrompt = prompt
emit('update-prompt', taskId, prompt)
}
})
}
},
{
title: '视频信息',
key: 'videoInfo',
minWidth: 120,
width: 160,
className: noPaddingColumnClass,
render(row) {
if (row.generateVideoPath) {
return h(
'div',
{
style: {
height: '130px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}
},
[
h('video', {
src: row.generateVideoPath,
controls: true,
style: {
height: '130px',
maxWidth: '160px',
objectFit: 'cover',
borderRadius: '4px'
}
})
]
)
} else {
return h(
'div',
{
style: {
height: '130px',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5',
borderRadius: '4px',
color: '#999',
fontSize: '12px'
}
},
'暂无视频'
)
}
}
},
{
title: '视频选择',
key: 'videoSelection',
minWidth: 200,
width: 200,
className: noPaddingColumnClass,
render(row) {
return h(ImageTextVideoInfoVideoListInfo, {
taskData: row,
videoList: row.subVideoPath || []
})
}
},
{
title: '操作',
key: 'actions',
width: 160,
fixed: 'right',
render(row) {
return h(
NSpace,
{ size: 'small' },
{
default: () => [
h(
NButton,
{
size: 'small',
type: 'primary',
onClick: () => handleViewDetail(row)
},
{
default: () => '查看',
icon: () => h(NIcon, null, { default: () => h(EyeOutline) })
}
),
h(
NButton,
{
size: 'small',
type: row.status === '进行中' ? 'warning' : 'success',
onClick: () => handleToggleStatus(row)
},
{
default: () => (row.status === '进行中' ? '暂停' : '启动'),
icon: () =>
h(NIcon, null, {
default: () => (row.status === '进行中' ? h(PauseOutline) : h(PlayOutline))
})
}
)
]
}
)
}
}
]
//
function getStatusType(status) {
const statusMap = {
进行中: 'info',
已完成: 'success',
暂停: 'warning',
失败: 'error'
}
return statusMap[status] || 'default'
}
//
function handleViewDetail(row) {
emit('view-detail', row)
message.info(`查看任务: ${row.name}`)
}
//
function handleToggleStatus(row) {
emit('toggle-status', row)
}
//
function handleAdd() {
emit('add-task')
message.info('新增任务功能开发中...')
}
//
function handlePaginationToggle(value) {
if (value) {
//
paginationReactive.page = 1 //
message.info('已开启分页,表格高度已调整')
} else {
//
message.info('已关闭分页,显示全部数据,表格高度已调整')
}
}
//
function handleRightPanelToggle(value) {
showRightPanel.value = value
emit('toggle-right-panel', value)
if (value) {
message.info('右侧面板已显示')
} else {
message.info('右侧面板已隐藏,点击查看按钮可在抽屉中查看详情')
}
}
</script>
<style scoped>
.table-container {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.table-header {
padding: 16px 20px;
border-bottom: 1px solid var(--n-border-color);
flex-shrink: 0;
}
/* 表格样式优化 */
.data-table {
flex: 1;
overflow: hidden;
}
.data-table :deep(.n-data-table) {
border-radius: 8px;
overflow: hidden;
}
.data-table :deep(.n-data-table-th) {
font-weight: 600;
text-align: center !important;
}
.data-table :deep(.n-data-table-td) {
border-bottom: 1px solid var(--n-border-color);
}
.data-table :deep(.n-data-table__body) {
overflow-y: auto;
}
.data-table :deep(.n-data-table__body)::-webkit-scrollbar {
width: 6px;
}
.data-table :deep(.n-data-table__body)::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.data-table :deep(.n-data-table__body)::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.data-table :deep(.n-data-table__body)::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 按钮样式 */
.data-table :deep(.n-button) {
transition: all 0.2s ease;
}
.data-table :deep(.n-button:hover) {
transform: translateY(-1px);
}
/* 进度条样式 */
.data-table :deep(.n-progress) {
margin: 4px 0;
}
/* 通用的无padding列样式 */
.data-table :deep(.no-padding-column) {
padding: 2px !important;
}
.data-table :deep(.no-padding-column .n-data-table-td__content) {
padding: 2px !important;
}
/* 解决图片下方多余间隔问题 */
.data-table :deep(.no-padding-column .n-image) {
display: block;
line-height: 0;
}
.data-table :deep(.no-padding-column .n-image img) {
display: block;
}
</style>

View File

@ -0,0 +1,138 @@
<template>
<div class="video-config-container">
<!-- 转视频方式选择和进度条 -->
<div class="method-section">
<div class="method-row">
<n-select
v-model:value="videoMessage.videoType"
:options="videoMethodOptions"
placeholder="选择转视频方式"
size="small"
@update:value="handleMethodChange"
class="method-select"
/>
<n-tag type="primary" size="small" class="method-progress">
{{ videoMessage.status ?? 'wait' }}
</n-tag>
</div>
</div>
<!-- 视频提示词输入 -->
<div class="prompt-section">
<n-input
v-model:value="videoMessage.prompt"
type="textarea"
:rows="3"
placeholder="请输入视频提示词..."
:autosize="{ minRows: 3, maxRows: 6 }"
@update:value="handlePromptChange"
/>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, toRef } from 'vue'
import { NSelect, NInput, NTag } from 'naive-ui'
import { GetImageToVideoModelsOptions } from '@/define/enum/video'
// emits
const emit = defineEmits(['update-method', 'update-prompt'])
// props
const props = defineProps({
videoMessage: {
type: Object,
default: null
},
taskId: {
type: [String, Number],
required: true
}
})
//
const videoMethodOptions = GetImageToVideoModelsOptions()
const videoMessage = ref({})
//
function handleMethodChange(value) {
emit('update-method', props.taskId, value)
}
//
function handlePromptChange(value) {
emit('update-prompt', props.taskId, value)
}
//
onMounted(() => {
if (props.videoMessage) {
videoMessage.value = props.videoMessage
} else {
videoMessage.value = {
videoType: 'RUNWAY',
prompt: ''
}
}
})
</script>
<style scoped>
.video-config-container {
display: flex;
flex-direction: column;
height: 130px; /* 与图片行高度一致 */
gap: 6px;
padding: 4px;
}
.method-section {
flex-shrink: 0;
height: 28px; /* 固定选择框高度 */
}
.method-row {
display: flex;
align-items: center;
gap: 8px;
height: 100%;
}
.method-select {
width: 120px;
flex-shrink: 0;
}
.prompt-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; /* 确保可以缩小 */
}
.prompt-section :deep(.n-input) {
height: 100%;
flex: 1;
}
.prompt-section :deep(.n-input .n-input__input-el) {
resize: none;
line-height: 1.4;
height: 100%;
}
.prompt-section :deep(.n-input .n-input-wrapper) {
height: 100%;
}
.prompt-section :deep(.n-input .n-input__textarea) {
height: 100%;
}
.prompt-section :deep(.n-input .n-input__textarea-el) {
height: 100%;
resize: none;
}
</style>

View File

@ -0,0 +1,731 @@
<template>
<div>
<!-- 视频选择缩略图 -->
<div class="video-thumbnail-container">
<div v-if="videoList.length === 0" class="no-video-thumbnail">
<div class="empty-state">
<n-text depth="3">暂无视频</n-text>
</div>
</div>
<div v-else class="thumbnail-grid">
<div
v-for="(video, index) in videoList.slice(0, 3)"
:key="index"
class="thumbnail-item"
@click="handleShowModal"
>
<video :src="video" class="thumbnail-video" muted preload="metadata" />
<!-- 如果有更多视频在最后一个缩略图上显示数量 -->
<div v-if="index === 2 && videoList.length > 3" class="more-count">
+{{ videoList.length - 3 }}
</div>
</div>
</div>
</div>
<!-- 视频弹窗 -->
<n-modal
v-model:show="showModal"
:mask-closable="false"
preset="card"
style="width: 90%; max-width: 1200px"
:title="'视频信息详情:' + taskData.name"
size="huge"
:segmented="true"
>
<n-alert type="info" closable :show-icon="false" :style="{
marginBottom : '16px'
}">
修改选择的视频之后请点击 保存视频选择 按钮将操作进行保存生效
</n-alert>
<div v-if="taskData" class="video-modal-content">
<!-- 选中的视频和任务信息 -->
<div class="selected-video-section">
<div class="selected-video-left">
<div class="current-video-container">
<video
v-if="currentSelectedVideo"
:src="currentSelectedVideo"
controls
class="current-video-player"
preload="metadata"
/>
<div v-else class="no-selected-video">
<n-text depth="3">请选择一个视频</n-text>
</div>
</div>
</div>
<div class="task-info-right">
<n-card
title="📋 任务详情"
size="small"
:bordered="true"
:style="{
height: '100%'
}"
>
<template #header-extra>
<n-button size="small" type="primary" @click="handleSaveVideoSelection">
<template #icon>
<n-icon>
<Save />
</n-icon>
</template>
保存视频选择
</n-button>
</template>
<div class="info-section">
<div class="info-item">
<div class="info-label">
<n-icon size="16" class="info-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,16.5L6.5,12L7.91,10.59L11,13.67L16.59,8.09L18,9.5L11,16.5Z"
/>
</svg>
</n-icon>
<n-text strong>分镜名称</n-text>
</div>
<div class="info-value">
<n-text>{{ taskData.name }}</n-text>
</div>
</div>
<div class="info-item" v-if="taskData.id">
<div class="info-label">
<n-icon size="16" class="info-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M2,4C2,2.89 2.9,2 4,2H7V4H4V7H2V4M22,4V7H20V4H17V2H20C21.1,2 22,2.89 22,4M20,20V17H22V20C22,21.11 21.1,22 20,22H17V20H20M2,17V20C2,21.11 2.9,22 4,22H7V20H4V17H2M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12A2,2 0 0,0 12,10Z"
/>
</svg>
</n-icon>
<n-text strong>任务ID</n-text>
</div>
<div class="info-value">
<n-text depth="2" code>{{ videoMessage.taskId }}</n-text>
<n-button
size="tiny"
type="primary"
ghost
@click="copyTaskId"
class="copy-button"
style="margin-left: 8px"
>
<template #icon>
<n-icon size="12">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"
/>
</svg>
</n-icon>
</template>
复制
</n-button>
</div>
</div>
<div class="info-item" v-if="videoMessage.status">
<div class="info-label">
<n-icon size="16" class="info-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17Z"
/>
</svg>
</n-icon>
<n-text strong>状态</n-text>
</div>
<div class="info-value">
<n-tag :type="getStatusTagType(videoMessage.status)" size="small" round>
{{ videoMessage.status }}
</n-tag>
</div>
</div>
<div class="info-item" v-if="videoMessage.videoType">
<div class="info-label">
<n-icon size="16" class="info-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M17,10.5V7A1,1 0 0,0 16,6H4A1,1 0 0,0 3,7V17A1,1 0 0,0 4,18H16A1,1 0 0,0 17,17V13.5L21,17.5V6.5L17,10.5Z"
/>
</svg>
</n-icon>
<n-text strong>转视频类型</n-text>
</div>
<div class="info-value">
<n-tag type="info" size="small" round>
{{ GetImageToVideoModelsLabel(videoMessage.videoType) }}
</n-tag>
</div>
</div>
<div class="info-item" v-if="videoList.length > 0">
<div class="info-label">
<n-icon size="16" class="info-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M4,6H2V20A2,2 0 0,0 4,22H18V20H4V6M20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16V4A2,2 0 0,0 20,2M12,4.5L17,9L12,13.5V10.5H8V7.5H12V4.5Z"
/>
</svg>
</n-icon>
<n-text strong>视频总数</n-text>
</div>
<div class="info-value">
<n-tag type="success" size="small" round :bordered="false">
{{ videoList.length }}
</n-tag>
</div>
</div>
</div>
</n-card>
</div>
</div>
<div class="video-grid">
<n-grid :cols="gridCols" :x-gap="12" :y-gap="12">
<n-grid-item v-for="(video, index) in videoList" :key="index">
<n-card
size="small"
:bordered="true"
class="video-card"
:class="{ selected: currentSelectedVideo === video }"
@click="selectVideo(video)"
>
<template #header>
<n-text>视频 {{ index + 1 }}</n-text>
</template>
<div class="video-container">
<video :src="video" class="video-player" preload="metadata" />
</div>
<template #footer>
<n-space vertical size="small">
<div v-if="video.duration">
<n-text depth="3">时长{{ video.duration }}s</n-text>
</div>
<div v-if="video.size">
<n-text depth="3">大小{{ video.size }}</n-text>
</div>
<div v-if="video.createdAt">
<n-text depth="3">创建时间{{ formatDate(video.createdAt) }}</n-text>
</div>
</n-space>
</template>
</n-card>
</n-grid-item>
</n-grid>
</div>
<div v-if="videoList.length === 0" class="no-video">
<n-text depth="3">暂无视频信息</n-text>
</div>
</div>
<n-empty description="当前分镜没有转视频相关的信息,请先进行转视频操作!" v-else> </n-empty>
</n-modal>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { NModal, NText, NCard, NGrid, NGridItem, NSpace, NTag, NIcon, NButton } from 'naive-ui'
import { GetImageToVideoModelsLabel } from '@/define/enum/video'
import { isEmpty } from 'lodash'
import { Save } from '@vicons/ionicons5'
import { TimeDelay } from '@/define/Tools/time'
// props
const props = defineProps({
taskData: {
type: Object,
default: null
},
videoList: {
type: Array,
default: () => []
}
})
//
const showModal = ref(false)
const videoMessage = ref({})
const message = useMessage()
//
const currentSelectedVideo = ref(props.taskData.generateVideoPath)
//
const windowWidth = ref(window.innerWidth)
//
const gridCols = computed(() => {
if (windowWidth.value <= 480) {
return 1 // 1
} else if (windowWidth.value <= 768) {
return 2 // 2
} else if (windowWidth.value <= 1024) {
return 3 // 3
} else {
return 4 // 4
}
})
//
const handleResize = () => {
windowWidth.value = window.innerWidth
}
onMounted(() => {
window.addEventListener('resize', handleResize)
videoMessage.value = props.taskData.videoMessage || {}
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
//
function handleShowModal() {
//
if (props.videoList.length > 0 && isEmpty(props.taskData.generateVideoPath)) {
currentSelectedVideo.value = props.videoList[0]
} else {
currentSelectedVideo.value = props.taskData.generateVideoPath
}
showModal.value = true
}
//
function selectVideo(video) {
currentSelectedVideo.value = video
}
async function handleSaveVideoSelection() {
if (isEmpty(currentSelectedVideo.value)) {
message.warning('请选择一个视频')
return
}
//
const loadingMessage = message.loading('正在保存视频选择...', {
duration: 1000
})
try {
//
const cleanVideoPath = currentSelectedVideo.value?.split('?t=')[0] || currentSelectedVideo.value
// ID
if (!props.taskData?.id) {
message.error('任务ID不存在')
return
}
//
const res = await window.db.UpdateBookTaskDetailData(props.taskData.id, {
generateVideoPath: cleanVideoPath
})
//
if (res.code == 1) {
message.success('视频选择已保存成功')
//
if (props.taskData) {
props.taskData.generateVideoPath = cleanVideoPath + '?t=' + new Date().getTime()
}
} else {
message.error('保存视频选择失败: ' + res.message)
return
}
} catch (error) {
message.error(`保存视频选择失败: ${error.message || error}`)
} finally {
//
loadingMessage.destroy()
}
}
//
function getStatusTagType(status) {
const statusMap = {
进行中: 'info',
已完成: 'success',
完成: 'success',
暂停: 'warning',
失败: 'error',
错误: 'error',
等待: 'default'
}
return statusMap[status] || 'primary'
}
//
function formatDate(dateString) {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// ID
async function copyTaskId() {
try {
const taskId = videoMessage.value.taskId
if (!taskId) {
message.warning('任务ID不存在')
return
}
await navigator.clipboard.writeText(taskId)
message.success('任务ID已复制到剪贴板')
} catch (err) {
console.error('复制失败:', err)
message.error('复制失败,请手动复制')
}
}
</script>
<style scoped>
/* 视频缩略图样式 */
.video-thumbnail-container {
height: 130px;
width: 100%;
padding: 4px;
}
.no-video-thumbnail {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
border-radius: 4px;
color: #999;
font-size: 12px;
}
.thumbnail-grid {
height: 100%;
display: flex;
gap: 4px;
align-items: center;
overflow: hidden;
}
.thumbnail-item {
position: relative;
cursor: pointer;
border-radius: 4px;
overflow: hidden;
border: 1px solid #e0e0e0;
transition: all 0.2s ease;
}
.thumbnail-item:hover {
border-color: #18a058;
transform: translateY(-1px);
}
.thumbnail-video {
width: 60px;
height: 45px;
object-fit: cover;
display: block;
}
.more-count {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
font-weight: bold;
}
/* 视频弹窗样式 */
.video-modal-content {
max-height: 75vh;
overflow-y: auto;
}
/* 选中的视频和任务信息区域 */
.selected-video-section {
display: flex;
gap: 20px;
margin-bottom: 30px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.selected-video-left {
flex: 1;
min-width: 0;
}
.current-video-container {
width: 100%;
aspect-ratio: 16/9;
background-color: #000;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.current-video-player {
width: 100%;
height: 100%;
object-fit: contain;
}
.no-selected-video {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
color: #999;
}
.task-info-right {
flex: 1;
min-width: 300px;
}
.task-detail-card :deep(.n-card-header__main) {
color: white;
}
.info-section {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #18a058;
transition: all 0.3s ease;
}
.info-item:hover {
background: #e8f5e8;
transform: translateX(4px);
}
.info-label {
display: flex;
align-items: center;
gap: 8px;
min-width: 100px;
}
.info-icon {
color: #18a058;
display: flex;
align-items: center;
}
.info-value {
display: flex;
align-items: center;
flex: 1;
justify-content: flex-end;
}
.copy-button {
transition: all 0.2s ease;
}
.copy-button:hover {
transform: scale(1.05);
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat-item {
text-align: center;
padding: 12px 8px;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
border-radius: 8px;
color: white;
display: flex;
flex-direction: column;
gap: 4px;
transition: transform 0.3s ease;
}
.stat-item:hover {
transform: translateY(-2px);
}
.stat-item:nth-child(2) {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.video-grid {
margin-top: 20px;
}
.video-card {
height: 100%;
cursor: pointer;
transition: all 0.3s ease;
}
.video-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.video-card.selected {
border-color: #18a058;
box-shadow: 0 0 0 2px rgba(24, 160, 88, 0.2);
}
.video-card.selected :deep(.n-card-header) {
background-color: #f0f9ff;
}
.video-container {
width: 100%;
aspect-ratio: 16/9;
background-color: #f5f5f5;
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.video-player {
width: 100%;
height: 100%;
object-fit: cover;
}
.no-video {
text-align: center;
padding: 40px;
color: #999;
}
/* 响应式设计 */
@media (max-width: 480px) {
.selected-video-section {
flex-direction: column;
padding: 12px;
gap: 12px;
}
.task-info-right {
min-width: unset;
}
.video-grid {
margin-top: 16px;
}
.info-item {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.info-label {
min-width: unset;
}
.info-value {
justify-content: flex-start;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.selected-video-section {
flex-direction: column;
padding: 16px;
gap: 16px;
}
.task-info-right {
min-width: unset;
}
.video-grid {
margin-top: 18px;
}
}
@media (max-width: 1024px) {
.selected-video-section {
gap: 16px;
padding: 18px;
}
.task-info-right {
min-width: 280px;
}
}
@media (min-width: 1400px) {
.selected-video-section {
padding: 24px;
gap: 24px;
}
}
</style>

View File

@ -25,7 +25,7 @@ import MJImagePackage from './MJImagePackage.vue'
import { MJImageType } from '@/define/enum/mjEnum' import { MJImageType } from '@/define/enum/mjEnum'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import { GetMJUrlOptions } from '@/define/api/apiUrlDefine' import { GetMJUrlOptions } from '@/define/api/apiUrlDefine'
import { GetImageProxyUrlOptions, ImagePackageProxyOptions } from '@/define/data/settingData' import { GetImageProxyUrlOptions } from '@/define/data/settingData'
let softwareStore = useSoftwareStore() let softwareStore = useSoftwareStore()
let optionStore = useOptionStore() let optionStore = useOptionStore()

View File

@ -103,6 +103,16 @@ const routes = [
name: 'manage_book', name: 'manage_book',
component: () => import('./components/Book/ManageBookDetail.vue') component: () => import('./components/Book/ManageBookDetail.vue')
}, },
{
path: '/image_text_video',
name: 'image_text_video',
component: () => import('./components/ImageTextVideo/ImageTextVideoHome.vue')
},
{
path: '/image_text_video_info/:id',
name: 'image_text_video_info',
component: () => import('./components/ImageTextVideo/ImageTextVideoInfoHome.vue')
},
{ {
path: '/test_options', path: '/test_options',
name: 'test_options', name: 'test_options',

View File

@ -266,7 +266,8 @@ export const useReverseManageStore = defineStore('reverseManage', {
...item, ...item,
outImagePath: item.outImagePath ? item.outImagePath + '?t=' + new Date().getTime() : undefined, outImagePath: item.outImagePath ? item.outImagePath + '?t=' + new Date().getTime() : undefined,
subImagePath: item.subImagePath ? item.subImagePath.map(item => item + '?t=' + new Date().getTime()) : [], subImagePath: item.subImagePath ? item.subImagePath.map(item => item + '?t=' + new Date().getTime()) : [],
oldImage: item.oldImage ? item.oldImage + '?t=' + new Date().getTime() : undefined oldImage: item.oldImage ? item.oldImage + '?t=' + new Date().getTime() : undefined,
generateVideoPath: item.generateVideoPath ? item.generateVideoPath + '?t=' + new Date().getTime() : undefined,
} }
}) })
// 这边开始修改数据, // 这边开始修改数据,