This commit is contained in:
2025-07-19 12:46:27 +08:00
parent 12e1da5681
commit c1d6fe181d
47 changed files with 3570 additions and 24 deletions
+11
View File
@@ -8,6 +8,8 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NCheckbox: typeof import('naive-ui')['NCheckbox']
@@ -15,12 +17,16 @@ declare module 'vue' {
NColorPicker: typeof import('naive-ui')['NColorPicker']
NDataTable: typeof import('naive-ui')['NDataTable']
NDivider: typeof import('naive-ui')['NDivider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDropdown: typeof import('naive-ui')['NDropdown']
NDynamicInput: typeof import('naive-ui')['NDynamicInput']
NDynamicTags: typeof import('naive-ui')['NDynamicTags']
NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NGrid: typeof import('naive-ui')['NGrid']
NGridItem: typeof import('naive-ui')['NGridItem']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NImageGroup: typeof import('naive-ui')['NImageGroup']
@@ -28,10 +34,14 @@ declare module 'vue' {
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NList: typeof import('naive-ui')['NList']
NListItem: typeof import('naive-ui')['NListItem']
NLog: typeof import('naive-ui')['NLog']
NMenu: typeof import('naive-ui')['NMenu']
NModal: typeof import('naive-ui')['NModal']
NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSlider: typeof import('naive-ui')['NSlider']
NSpace: typeof import('naive-ui')['NSpace']
@@ -42,6 +52,7 @@ declare module 'vue' {
NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NThing: typeof import('naive-ui')['NThing']
NTooltip: typeof import('naive-ui')['NTooltip']
NTree: typeof import('naive-ui')['NTree']
NUpload: typeof import('naive-ui')['NUpload']
+9
View File
@@ -8,6 +8,7 @@ import {
SettingsOutline,
DuplicateOutline,
GridOutline,
VideocamOutline,
RadioOutline,
BookOutline
} from '@vicons/ionicons5'
@@ -86,6 +87,13 @@ export const menuDataSource = [
type: 'route',
icon: 'grid'
},
{
label: '图/文生视频',
key: 'image_text_video',
routeName: 'image_text_video',
type: 'route',
icon: 'videocam'
},
{
label: 'API服务',
key: 'lai_api',
@@ -163,6 +171,7 @@ const iconMap = {
plane: PaperPlaneOutline,
duplicate: DuplicateOutline,
grid: GridOutline,
videocam: VideocamOutline,
api: APIIcon,
radio: RadioOutline,
settings: SettingsOutline,
+41
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));
}
@@ -80,7 +80,7 @@ async function GetBookTaskDetailOption() {
videoMessage.value = res.data
} else {
// 这边初始化
let initRes = await window.book.InitVideoMessage(props.bookTaskDetailId)
let initRes = await window.book.video.InitVideoMessage(props.bookTaskDetailId)
console.log('InitVideoMessage', initRes)
if (initRes.code != 1) {
message.error(initRes.message)
@@ -96,7 +96,7 @@ async function GetBookTaskDetailOption() {
if (ValidateJson(videoMessage.value.lumaOptions)) {
lumaOptions.value = JSON.parse(videoMessage.value.lumaOptions)
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
}
if (ValidateJson(videoMessage.value.klingOptions)) {
@@ -158,7 +158,7 @@ async function SaveSimpleOptions() {
}
console.log('saveVideoMessageObject', saveVideoMessageObject)
let res = await window.book.UpdateBookTaskDetailVideoMessage(
let res = await window.book.video.UpdateBookTaskDetailVideoMessage(
props.bookTaskDetailId,
saveVideoMessageObject
)
@@ -67,7 +67,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { NProgress, useMessage } from 'naive-ui'
import { useReverseManageStore } from '../../../../../stores/reverseManage'
import { useSoftwareStore } from '../../../../../stores/software'
@@ -79,6 +79,9 @@ let promptPercentage = ref(0)
let imagePercentage = ref(0)
let reversePromptPercentage = ref(0)
// 定时器引用
let intervalId = null
let reverseManageStore = useReverseManageStore()
let softwareStore = useSoftwareStore()
let message = useMessage()
@@ -134,11 +137,19 @@ onMounted(() => {
ComputePercentage()
// 这边开始定时执行计算
setInterval(async () => {
intervalId = setInterval(async () => {
await ComputePercentage()
}, 10000)
})
onUnmounted(() => {
// 组件卸载时清除定时器
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
})
function ErrorPosition(type) {
let index = -1
softwareStore.skipRowIndex = 0
@@ -26,7 +26,7 @@
</template>
<script setup>
import { ref, onMounted, defineComponent, onUnmounted, toRaw, watch } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { useMessage, NDivider } from 'naive-ui'
import ManageBookDetailButton from './MJReverse/ManageBookDetailButton.vue'
import ManageBookReverseTable from './MJReverse/ManageBookReverseTable.vue'
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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
})
// 处理bookTask的tags的方法
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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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 // 分页高度:开启时60px,关闭时10px边距
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(() => {
// 分页开启时最小高度300px,关闭时最小高度400px(因为要显示更多数据)
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>
@@ -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>
@@ -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>
@@ -25,7 +25,7 @@ import MJImagePackage from './MJImagePackage.vue'
import { MJImageType } from '@/define/enum/mjEnum'
import { isEmpty } from 'lodash'
import { GetMJUrlOptions } from '@/define/api/apiUrlDefine'
import { GetImageProxyUrlOptions, ImagePackageProxyOptions } from '@/define/data/settingData'
import { GetImageProxyUrlOptions } from '@/define/data/settingData'
let softwareStore = useSoftwareStore()
let optionStore = useOptionStore()
+10
View File
@@ -103,6 +103,16 @@ const routes = [
name: 'manage_book',
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',
name: 'test_options',