feat: 收口组件样式并调整移动端侧栏
This commit is contained in:
+184
-23
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, provide, ref, shallowRef } from 'vue'
|
||||
import { computed, h, onScopeDispose, provide, ref, shallowRef, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import {
|
||||
NButton,
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
NGlobalStyle,
|
||||
NLayout,
|
||||
NLayoutHeader,
|
||||
NLayoutSider,
|
||||
NMenu,
|
||||
NScrollbar,
|
||||
NTooltip,
|
||||
@@ -33,10 +32,61 @@ import { useTheme } from './composables/useTheme'
|
||||
import ThemeToggle from './components/ui/ThemeToggle.vue'
|
||||
import { projectAccessKey, type ProjectAccess } from './features/projects/access'
|
||||
|
||||
/** 窄屏与桌面共用同一侧栏;窄屏展开时主栏右移,宽度保持折叠时的尺寸。 */
|
||||
const NARROW_NAV = '(max-width: 800px)'
|
||||
/** 折叠侧栏宽度,与菜单 collapsed-width 对齐。 */
|
||||
const SIDER_COLLAPSED_WIDTH = 64
|
||||
/** 展开侧栏宽度,窄屏裁切动画以内层保持该宽度。 */
|
||||
const SIDER_EXPANDED_WIDTH = 208
|
||||
/** 菜单图标尺寸,折叠/展开共用,避免切换时缩放。 */
|
||||
const NAV_ICON_SIZE = 20
|
||||
/** 折叠与展开共用的图标左偏移,避免切换时图标跳动。 */
|
||||
const NAV_ICON_INDENT = SIDER_COLLAPSED_WIDTH / 2 - NAV_ICON_SIZE / 2
|
||||
|
||||
/** 应用外壳固定在视口内,业务数据仍由各工作区自行加载。 */
|
||||
const route = useRoute()
|
||||
const collapsed = ref(window.matchMedia('(max-width: 800px)').matches)
|
||||
const narrowMedia = window.matchMedia(NARROW_NAV)
|
||||
const narrow = ref(narrowMedia.matches)
|
||||
const collapsed = ref(narrow.value)
|
||||
const settingsOpen = ref(false)
|
||||
/** 窄屏折叠只裁切文字,不把菜单收成图标,避免推挤动画时错位。 */
|
||||
const menuCollapsed = computed(() => !narrow.value && collapsed.value)
|
||||
|
||||
/** 视口跨过断点时:进入窄屏收回侧栏。 */
|
||||
function syncNarrow(event: MediaQueryListEvent) {
|
||||
narrow.value = event.matches
|
||||
if (event.matches) collapsed.value = true
|
||||
}
|
||||
narrowMedia.addEventListener('change', syncNarrow)
|
||||
onScopeDispose(() => narrowMedia.removeEventListener('change', syncNarrow))
|
||||
|
||||
/** 宽屏与窄屏都只切换这一份侧栏的展开态。 */
|
||||
function toggleNav() {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
/** 打开后端连接说明;窄屏先收回侧栏给弹窗腾出宽度。 */
|
||||
function openSettings() {
|
||||
if (narrow.value) collapsed.value = true
|
||||
settingsOpen.value = true
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (narrow.value) collapsed.value = true
|
||||
}
|
||||
)
|
||||
|
||||
/** Esc 收回窄屏展开的侧栏。 */
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && narrow.value && !collapsed.value) collapsed.value = true
|
||||
}
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
onScopeDispose(() => window.removeEventListener('keydown', onKeydown))
|
||||
|
||||
/** 顶栏切换按钮的可访问名称。 */
|
||||
const navToggleLabel = computed(() => (collapsed.value ? '展开侧栏' : '折叠侧栏'))
|
||||
const { preference, theme, overrides } = useTheme()
|
||||
const projectId = computed(() => String(route.params.projectId || ''))
|
||||
const projectAccess = shallowRef<ProjectAccess | null>(null)
|
||||
@@ -91,18 +141,22 @@ const menuOptions = computed<MenuOption[]>(() => [
|
||||
>
|
||||
<NGlobalStyle />
|
||||
<a href="#main-content" class="skip-link">跳到主要内容</a>
|
||||
<NLayout has-sider class="admin-layout">
|
||||
<NLayoutSider
|
||||
collapse-mode="width"
|
||||
:collapsed="collapsed"
|
||||
:width="208"
|
||||
:collapsed-width="64"
|
||||
:native-scrollbar="false"
|
||||
<div
|
||||
class="admin-layout"
|
||||
:style="{
|
||||
'--admin-sider-collapsed': SIDER_COLLAPSED_WIDTH + 'px',
|
||||
'--admin-sider-expanded': SIDER_EXPANDED_WIDTH + 'px'
|
||||
}"
|
||||
>
|
||||
<!-- 以下是主导航侧栏:宽屏与窄屏共用同一份 -->
|
||||
<aside
|
||||
class="admin-sider"
|
||||
content-class="admin-sider-content"
|
||||
:class="{ 'is-collapsed': collapsed }"
|
||||
:style="{ width: (collapsed ? SIDER_COLLAPSED_WIDTH : SIDER_EXPANDED_WIDTH) + 'px' }"
|
||||
aria-label="主导航"
|
||||
>
|
||||
<RouterLink to="/projects" class="admin-brand" aria-label="短剧工作台首页">
|
||||
<Clapperboard :size="24" /><span v-if="!collapsed"
|
||||
<Clapperboard :size="24" /><span v-if="!menuCollapsed"
|
||||
>短剧工作台<small>SHORT DRAMA STUDIO</small></span
|
||||
>
|
||||
</RouterLink>
|
||||
@@ -110,9 +164,12 @@ const menuOptions = computed<MenuOption[]>(() => [
|
||||
<NMenu
|
||||
:value="route.path"
|
||||
:options="menuOptions"
|
||||
:collapsed="collapsed"
|
||||
:collapsed-width="64"
|
||||
:collapsed-icon-size="19"
|
||||
:collapsed="menuCollapsed"
|
||||
:collapsed-width="SIDER_COLLAPSED_WIDTH"
|
||||
:icon-size="NAV_ICON_SIZE"
|
||||
:collapsed-icon-size="NAV_ICON_SIZE"
|
||||
:indent="NAV_ICON_INDENT"
|
||||
:root-indent="NAV_ICON_INDENT"
|
||||
/>
|
||||
</NScrollbar>
|
||||
<footer class="admin-sider-footer">
|
||||
@@ -122,27 +179,29 @@ const menuOptions = computed<MenuOption[]>(() => [
|
||||
quaternary
|
||||
block
|
||||
class="admin-settings-button"
|
||||
:class="{ 'is-collapsed': collapsed, 'icon-button': collapsed }"
|
||||
:class="{ 'is-collapsed': menuCollapsed }"
|
||||
aria-label="后端连接"
|
||||
@click="settingsOpen = true"
|
||||
@click="openSettings"
|
||||
>
|
||||
<template #icon><Settings2 :size="18" /></template>
|
||||
<span v-if="!collapsed">后端连接</span>
|
||||
<span v-if="!menuCollapsed">后端连接</span>
|
||||
</NButton>
|
||||
</template>
|
||||
后端连接
|
||||
</NTooltip>
|
||||
</footer>
|
||||
</NLayoutSider>
|
||||
</aside>
|
||||
<!-- 以下是顶栏与主内容 -->
|
||||
<NLayout class="admin-main">
|
||||
<NLayoutHeader class="admin-topbar">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<NButton
|
||||
quaternary
|
||||
class="icon-button"
|
||||
:aria-label="collapsed ? '展开侧栏' : '折叠侧栏'"
|
||||
:title="collapsed ? '展开侧栏' : '折叠侧栏'"
|
||||
@click="collapsed = !collapsed"
|
||||
:aria-label="navToggleLabel"
|
||||
:title="navToggleLabel"
|
||||
:aria-expanded="!collapsed"
|
||||
@click="toggleNav"
|
||||
>
|
||||
<template #icon
|
||||
><PanelLeftOpen v-if="collapsed" :size="18" /><PanelLeftClose v-else :size="18"
|
||||
@@ -154,7 +213,7 @@ const menuOptions = computed<MenuOption[]>(() => [
|
||||
</NLayoutHeader>
|
||||
<main id="main-content" tabindex="-1" class="admin-content"><RouterView /></main>
|
||||
</NLayout>
|
||||
</NLayout>
|
||||
</div>
|
||||
<AppDialog
|
||||
v-model:open="settingsOpen"
|
||||
title="后端连接"
|
||||
@@ -175,3 +234,105 @@ const menuOptions = computed<MenuOption[]>(() => [
|
||||
</AppDialog>
|
||||
</NConfigProvider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "styles/styles.css";
|
||||
.skip-link {
|
||||
@apply fixed z-[100] -top-[80px] left-[15px] py-2.5 px-[15px] bg-(--app-surface) border-[1px_solid_var(--color-accent)];
|
||||
}
|
||||
.skip-link:focus {
|
||||
@apply top-2.5 z-[3000];
|
||||
}
|
||||
.admin-layout {
|
||||
--admin-sider-collapsed: 64px;
|
||||
--admin-sider-expanded: 208px;
|
||||
@apply flex h-dvh overflow-hidden;
|
||||
}
|
||||
.admin-main {
|
||||
@apply min-w-0 h-full flex-1;
|
||||
}
|
||||
.admin-main > .n-layout-scroll-container {
|
||||
@apply h-full flex flex-col overflow-hidden;
|
||||
}
|
||||
.admin-sider {
|
||||
@apply flex flex-col h-full w-[208px] shrink-0 overflow-hidden bg-(--app-subtle);
|
||||
}
|
||||
.admin-sider.is-collapsed {
|
||||
@apply w-16;
|
||||
}
|
||||
.admin-nav-scroll.n-scrollbar {
|
||||
@apply flex-1 min-h-0;
|
||||
}
|
||||
.admin-sider-footer {
|
||||
@apply shrink-0;
|
||||
padding-block: 8px max(8px, env(safe-area-inset-bottom));
|
||||
}
|
||||
.n-button.admin-settings-button {
|
||||
@apply justify-start h-11 px-5;
|
||||
}
|
||||
.admin-sider .n-menu .n-menu-item-content::before {
|
||||
@apply left-0 right-0 rounded-none;
|
||||
}
|
||||
.admin-brand {
|
||||
@apply flex shrink-0 items-center gap-3 h-[74px] py-0 px-5 text-ink whitespace-nowrap;
|
||||
}
|
||||
.admin-brand > svg {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.admin-brand span {
|
||||
@apply text-[15px] font-semibold;
|
||||
}
|
||||
.admin-brand small {
|
||||
@apply block text-[8px] tracking-[1px] font-normal text-muted mt-[3px];
|
||||
}
|
||||
.admin-topbar {
|
||||
@apply relative z-10 flex items-center justify-between shrink-0 h-11 py-0 px-5 gap-3 bg-(--app-surface);
|
||||
}
|
||||
.admin-sider .n-menu-divider {
|
||||
@apply h-3 bg-transparent;
|
||||
}
|
||||
.admin-content {
|
||||
@apply flex-1 min-h-0 min-w-0 overflow-hidden outline-none;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.admin-sider {
|
||||
overflow: hidden;
|
||||
transition: width 0.32s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
/* 主栏宽度锁在折叠态剩余空间,侧栏变宽时把主栏推到右侧而不是压窄。 */
|
||||
.admin-layout > .admin-main {
|
||||
flex: 0 0 auto;
|
||||
width: calc(100% - var(--admin-sider-collapsed));
|
||||
min-width: calc(100% - var(--admin-sider-collapsed));
|
||||
max-width: calc(100% - var(--admin-sider-collapsed));
|
||||
}
|
||||
.admin-brand,
|
||||
.admin-nav-scroll.n-scrollbar,
|
||||
.admin-sider-footer {
|
||||
min-width: var(--admin-sider-expanded);
|
||||
}
|
||||
.admin-brand span,
|
||||
.admin-sider .n-menu-item-content-header,
|
||||
.admin-settings-button span {
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.admin-sider.is-collapsed .admin-brand span,
|
||||
.admin-sider.is-collapsed .n-menu-item-content-header,
|
||||
.admin-sider.is-collapsed .admin-settings-button span {
|
||||
opacity: 0;
|
||||
}
|
||||
.admin-topbar {
|
||||
@apply py-0 px-2.5;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.admin-sider,
|
||||
.admin-brand span,
|
||||
.admin-sider .n-menu-item-content-header,
|
||||
.admin-settings-button span {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
-1763
File diff suppressed because it is too large
Load Diff
@@ -9,3 +9,21 @@ import { EmptyState } from './ui'
|
||||
>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../styles/styles.css";
|
||||
.page-container {
|
||||
@apply max-w-[1540px] mx-auto pt-[37px] px-10 pb-14;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.page-container {
|
||||
@apply px-[26px];
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.page-container {
|
||||
@apply pt-[25px] px-[18px] pb-10;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NInput, NDropdown } from 'naive-ui'
|
||||
import ActionMenu from './ActionMenu.vue'
|
||||
import DetailDisclosure from './DetailDisclosure.vue'
|
||||
import StyleEditor from '../../features/visual-style/components/StyleEditor.vue'
|
||||
import SubjectList from '../../features/breakdown/components/SubjectList.vue'
|
||||
import { selectMenu } from '../../testing/naive'
|
||||
import type { SubjectCandidate } from '../../features/breakdown/types'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('精简入口保留功能', () => {
|
||||
it('菜单按点击展开,禁用项不能绕过,触发器有可访问名称', async () => {
|
||||
wrapper = mount(ActionMenu, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
label: '测试更多操作',
|
||||
items: [
|
||||
{ key: 'history', label: '执行记录' },
|
||||
{ key: 'export', label: '导出', disabled: true }
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(wrapper.get('button').attributes('aria-haspopup')).toBe('menu')
|
||||
expect(wrapper.get('button').attributes('aria-expanded')).toBe('false')
|
||||
expect(document.querySelector('.n-dropdown-option-body')).toBeNull()
|
||||
await selectMenu('测试更多操作', '执行记录')
|
||||
expect(wrapper.emitted('select')).toEqual([['history']])
|
||||
wrapper.getComponent(NDropdown).vm.$emit('select', 'export')
|
||||
expect(wrapper.emitted('select')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('补充说明默认不展开,键盘可聚焦的标题可查看原文', async () => {
|
||||
wrapper = mount(DetailDisclosure, { props: { title: '规则说明' }, slots: { default: '完整规则仍然保留' } })
|
||||
expect(wrapper.text()).not.toContain('完整规则仍然保留')
|
||||
await wrapper.get('.n-collapse-item__header-main').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('完整规则仍然保留')
|
||||
})
|
||||
|
||||
it('风格高级字段收起不丢失保存值,展开后仍可修改', async () => {
|
||||
wrapper = mount(StyleEditor, { props: { visualStyle: null, disabled: false } })
|
||||
expect(wrapper.find('#style-constraints').exists()).toBe(false)
|
||||
await wrapper.get('.n-collapse-item__header-main').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('#style-constraints').setValue('["保留硬约束"]')
|
||||
const fields = wrapper.findAllComponents(NInput)
|
||||
fields.find(item => item.props('placeholder')?.includes('人物质感'))!.vm.$emit('update:value', '人物风格')
|
||||
await flushPromises()
|
||||
await wrapper.get('.n-collapse-item__header-main').trigger('click')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(wrapper.emitted('save')).toMatchObject([
|
||||
[{ characterPrompt: '人物风格', hardConstraints: ['保留硬约束'] }]
|
||||
])
|
||||
})
|
||||
|
||||
it('主体长文收起仅影响显示,完整内容与图片入口不丢失', async () => {
|
||||
const description = '主体完整经历。'.repeat(30)
|
||||
wrapper = mount(SubjectList, {
|
||||
props: {
|
||||
projectId: 'p',
|
||||
subjects: [
|
||||
{
|
||||
profileId: 'p1',
|
||||
ref: '@CH0001',
|
||||
name: '人物',
|
||||
module: 'character',
|
||||
description,
|
||||
appearance_prompt: '外观',
|
||||
aliases: []
|
||||
} as SubjectCandidate
|
||||
],
|
||||
forms: []
|
||||
},
|
||||
global: { stubs: { RouterLink: true } }
|
||||
})
|
||||
const text = wrapper.get('.line-clamp-3')
|
||||
expect(text.text()).toBe(description)
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.find('.line-clamp-3').exists()).toBe(false)
|
||||
expect(wrapper.findAll('router-link-stub')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -24,3 +24,24 @@ const open = defineModel<boolean>('open', { default: false })
|
||||
</NScrollbar>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.app-dialog {
|
||||
--app-field: var(--app-control);
|
||||
--app-field-hover: var(--app-control-hover);
|
||||
}
|
||||
.app-dialog {
|
||||
@apply max-w-[calc(100vw-32px)] max-h-[calc(100dvh-32px)];
|
||||
}
|
||||
.app-dialog .n-card-header {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.dialog-body-scroll {
|
||||
@apply max-h-[calc(100dvh-154px)];
|
||||
}
|
||||
.app-dialog .n-card__content {
|
||||
@apply min-h-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { NImage } from 'naive-ui'
|
||||
import AssetImage from './AssetImage.vue'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('资源图片展示与原图预览', () => {
|
||||
it('默认使用 cover 并禁用预览,不干扰历史缩略图选择', async () => {
|
||||
wrapper = mount(AssetImage, {
|
||||
attachTo: document.body,
|
||||
props: { src: '/storage/history.png', alt: '历史图片' }
|
||||
})
|
||||
expect(wrapper.getComponent(NImage).props()).toMatchObject({ objectFit: 'cover', previewDisabled: true })
|
||||
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('cover')
|
||||
await wrapper.get('img').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.n-image-preview-container')).toBeNull()
|
||||
})
|
||||
|
||||
it('特殊展示仍可显式选择 contain,样式交由 NImage 控制', () => {
|
||||
wrapper = mount(AssetImage, {
|
||||
props: { src: '/storage/reference.png', alt: '完整参考图', objectFit: 'contain' }
|
||||
})
|
||||
expect(wrapper.getComponent(NImage).props('objectFit')).toBe('contain')
|
||||
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('contain')
|
||||
})
|
||||
|
||||
it('cover 仅裁切卡片,点击使用 Naive 原生预览展示同一完整资源,遮罩可关闭', async () => {
|
||||
wrapper = mount(AssetImage, {
|
||||
attachTo: document.body,
|
||||
props: { src: '/storage/original.png', alt: '林默 · 基础形态', objectFit: 'cover', preview: true }
|
||||
})
|
||||
const source = wrapper.get('img').attributes('src')
|
||||
expect(wrapper.getComponent(NImage).props()).toMatchObject({
|
||||
objectFit: 'cover',
|
||||
previewDisabled: false,
|
||||
previewSrc: source
|
||||
})
|
||||
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('cover')
|
||||
await wrapper.get('img').trigger('click')
|
||||
await flushPromises()
|
||||
const image = document.querySelector<HTMLImageElement>('.n-image-preview')!
|
||||
expect(image).not.toBeNull()
|
||||
expect(image.getAttribute('src')).toBe(source)
|
||||
expect(image.alt).toBe('林默 · 基础形态')
|
||||
expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
|
||||
expect(image.style.objectFit).not.toBe('cover')
|
||||
expect(document.querySelector('.n-image-preview-toolbar')).not.toBeNull()
|
||||
expect(wrapper.element.contains(image)).toBe(false)
|
||||
document.querySelector<HTMLElement>('.n-image-preview-overlay')!.click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector<HTMLElement>('.n-image-preview-wrapper')?.style.display ?? 'none').toBe('none')
|
||||
})
|
||||
|
||||
it('资源加载失败只显示重试占位,不打开空预览', async () => {
|
||||
wrapper = mount(AssetImage, {
|
||||
attachTo: document.body,
|
||||
props: { src: '/storage/missing.png', alt: '失败图片', objectFit: 'cover', preview: true }
|
||||
})
|
||||
await wrapper.get('img').trigger('error')
|
||||
expect(wrapper.text()).toContain('图片无法加载')
|
||||
expect(wrapper.findComponent(NImage).exists()).toBe(false)
|
||||
expect(document.querySelector('.n-image-preview-container')).toBeNull()
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.getComponent(NImage).props('previewDisabled')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([null, 'javascript:alert(1)', 'data:text/html,test'])('空地址或危险地址 %s 不进入原图预览', src => {
|
||||
wrapper = mount(AssetImage, { props: { src, alt: '图片', objectFit: 'cover', preview: true } })
|
||||
expect(wrapper.findComponent(NImage).exists()).toBe(false)
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -50,3 +50,26 @@ watch(
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.asset-image {
|
||||
@apply grid place-items-center w-full aspect-[4/3] overflow-hidden bg-(--app-subtle);
|
||||
}
|
||||
.asset-image img {
|
||||
@apply w-full h-full min-h-0;
|
||||
}
|
||||
.asset-image-empty {
|
||||
@apply flex items-center justify-center flex-col gap-2.5 p-5 text-muted text-[11px] text-center;
|
||||
}
|
||||
.asset-image .n-image {
|
||||
@apply w-full h-full min-h-0 min-w-0 flex justify-center;
|
||||
}
|
||||
.asset-image .n-image img {
|
||||
@apply w-full h-full;
|
||||
}
|
||||
.asset-image.asset-image-preview {
|
||||
@apply h-[min(42dvh,420px)] min-h-0 aspect-auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,3 +21,14 @@ function toggle() {
|
||||
<div class="detail-disclosure-content"><slot /></div></NCollapseItem
|
||||
></NCollapse>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.detail-disclosure.n-collapse {
|
||||
@apply text-xs text-muted;
|
||||
}
|
||||
.detail-disclosure-content {
|
||||
@apply py-3 px-3.5 bg-(--app-subtle) text-ink leading-[1.8];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import DirectoryItem from './DirectoryItem.vue'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => wrapper?.unmount())
|
||||
|
||||
describe('统一目录条目', () => {
|
||||
it('主体缩略图使用可选前导槽位,与标题一起保留满行点击区域', () => {
|
||||
wrapper = mount(DirectoryItem, {
|
||||
props: { active: true },
|
||||
slots: { leading: '<span>母版缩略图</span>', default: '林默', eyebrow: '@CH0001' }
|
||||
})
|
||||
expect(wrapper.classes()).toContain('has-leading')
|
||||
expect(wrapper.get('.directory-item-leading').text()).toBe('母版缩略图')
|
||||
expect(wrapper.get('.directory-item-title').text()).toBe('林默')
|
||||
expect(wrapper.get('.directory-item-leading').element.closest('button')).toBe(wrapper.element)
|
||||
})
|
||||
|
||||
it('编号、长标题和多项状态各占独立层级,沿用 Naive 按钮', () => {
|
||||
const title = '雨夜霓虹街全景与记忆当铺外的人群和闪烁灯牌'
|
||||
wrapper = mount(DirectoryItem, {
|
||||
props: { active: false },
|
||||
slots: {
|
||||
eyebrow: 'BEAT 2 / SHOT 3',
|
||||
default: title,
|
||||
meta: '<span class="directory-status">设计已保存</span><span class="directory-status">待状态</span>'
|
||||
}
|
||||
})
|
||||
expect(wrapper.getComponent(NButton).props()).toMatchObject({ quaternary: true, block: true })
|
||||
expect(wrapper.get('button').attributes('type')).toBe('button')
|
||||
expect(wrapper.get('.directory-item-eyebrow').text()).toBe('BEAT 2 / SHOT 3')
|
||||
expect(wrapper.get('.directory-item-title').text()).toBe(title)
|
||||
expect(wrapper.get('.directory-item-meta').findAll('.directory-status')).toHaveLength(2)
|
||||
expect(wrapper.find('.truncate').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.directory-item-body > span')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('点击透传给业务层,选中态与无障碍属性保持同步', async () => {
|
||||
const onClick = vi.fn<() => void>()
|
||||
wrapper = mount(DirectoryItem, { props: { active: false }, attrs: { onClick }, slots: { default: '林默' } })
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
expect(wrapper.attributes('aria-current')).toBeUndefined()
|
||||
await wrapper.setProps({ active: true })
|
||||
expect(wrapper.get('button').classes()).toContain('selected')
|
||||
expect(wrapper.attributes('aria-current')).toBe('true')
|
||||
expect(wrapper.find('.directory-item-eyebrow').exists()).toBe(false)
|
||||
expect(wrapper.find('.directory-item-meta').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('显式保护最小高度、留白、换行和窄屏横向条目,避免旧按钮样式重新挤压目录', () => {
|
||||
// DOM 环境不计算视觉几何;这些约束不能替代浏览器验收。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(/\.n-button\.directory-item\s*\{[^}]*min-height:\s*88px;[^}]*padding:\s*12px 14px/)
|
||||
expect(css).toMatch(/\.directory-item-body\s*\{[^}]*flex-direction:\s*column;[^}]*gap:\s*7px/)
|
||||
expect(css).toMatch(/\.directory-item-title\s*\{[^}]*overflow-wrap:\s*anywhere/)
|
||||
expect(css).toMatch(/\.directory-list-content\s*\{[^}]*flex-direction:\s*row;[^}]*width:\s*max-content/)
|
||||
expect(css).toContain('grid-template-columns: clamp(240px, 22%, 280px) minmax(0, 1fr)')
|
||||
expect(css).toMatch(/\.identity-subject-list-content\s*\{[^}]*gap:\s*2px;[^}]*padding:\s*0 0 8px/)
|
||||
expect(css).toMatch(/\.n-button\.identity-subject-item\s*\{[^}]*min-height:\s*86px;[^}]*padding:\s*10px 14px/)
|
||||
expect(css).not.toMatch(/\.n-button\.identity-subject-item\s*\{[^}]*border-bottom:/)
|
||||
expect(css).toMatch(/\.identity-thumbnail\s*\{[^}]*width:\s*48px;[^}]*height:\s*64px/)
|
||||
expect(css).toMatch(/\.n-button\.identity-subject-item:not\(\.selected\)\s*\{[^}]*var\(--app-surface\)/)
|
||||
})
|
||||
|
||||
it('侧栏与主体、镜头、剧集目录统一直角满行,保留文字内边距和移动端滚动', () => {
|
||||
// happy-dom 无法计算伪元素几何,保护实际控制选中背景宽度的 CSS 规则。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.admin-sider \.n-menu \.n-menu-item-content::before\s*\{[^}]*left:\s*0;[^}]*right:\s*0;[^}]*border-radius:\s*0/
|
||||
)
|
||||
expect(css).toMatch(/\.directory-list-content\s*\{[^}]*padding:\s*8px 0 16px/)
|
||||
expect(css).toMatch(/\.reader-directory-content\s*\{[^}]*padding:\s*4px 0 18px/)
|
||||
expect(css).not.toMatch(/\.reader-directory-content\s*\{[^}]*padding-inline:\s*[1-9]/)
|
||||
for (const selector of ['directory-item', 'reader-episode-link']) {
|
||||
expect(css).toMatch(
|
||||
new RegExp(
|
||||
`\\.n-button\\.${selector}\\s*\\{[^}]*width:\\s*100%;[^}]*padding:\\s*12px 14px;[^}]*border-radius:\\s*0`
|
||||
)
|
||||
)
|
||||
}
|
||||
expect(css).toMatch(/\.directory-group-heading\s*\{[^}]*padding:\s*10px 14px/)
|
||||
expect(css).toMatch(/\.directory-list-content\s*\{[^}]*flex-direction:\s*row;[^}]*width:\s*max-content/)
|
||||
})
|
||||
|
||||
it('Tabs 工具栏与筛选网格限制最小宽度,不让组件默认宽度挤压内容', () => {
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(/\.workspace-tabs\.n-tabs\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*0/)
|
||||
expect(css).toMatch(/\.workspace-tabs \.n-tabs-nav-scroll-wrapper\s*\{[^}]*min-width:\s*0/)
|
||||
expect(css).toMatch(
|
||||
/\.form-image-filters\s*\{[^}]*grid-template-columns:\s*minmax\(200px, 360px\) 152px minmax\(260px, 1fr\) auto/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/@container \(max-width: 900px\)\s*\{\s*\.form-image-filters\s*\{[^}]*minmax\(0, 1fr\) 152px/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -21,3 +21,46 @@ defineProps<{ active: boolean }>()
|
||||
</span>
|
||||
</NButton>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.n-button.directory-item {
|
||||
@apply flex shrink-0 w-full h-auto min-h-[88px] py-3 px-3.5 rounded-none whitespace-normal text-left;
|
||||
}
|
||||
.n-button.directory-item .n-button__content {
|
||||
@apply block w-full min-w-0;
|
||||
}
|
||||
.directory-item-body {
|
||||
@apply flex flex-col items-start gap-[7px] min-w-0;
|
||||
}
|
||||
.n-button.directory-item.has-leading .n-button__content {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
.directory-item-leading {
|
||||
@apply flex shrink-0;
|
||||
}
|
||||
.directory-item.has-leading .directory-item-body {
|
||||
@apply flex-1;
|
||||
}
|
||||
.directory-item-eyebrow {
|
||||
@apply font-mono text-[11px] leading-normal text-muted wrap-anywhere;
|
||||
}
|
||||
.directory-item-title {
|
||||
@apply text-[13px] leading-[1.7] font-medium text-ink wrap-anywhere;
|
||||
}
|
||||
.directory-item-meta {
|
||||
@apply flex items-center flex-wrap gap-y-1.5 gap-x-2 text-muted text-[11px] leading-[1.7] wrap-anywhere;
|
||||
}
|
||||
.n-button.directory-item.selected {
|
||||
@apply bg-(--app-selected) shadow-[inset_3px_0_var(--app-accent)];
|
||||
}
|
||||
.n-button.directory-item:focus-visible {
|
||||
@apply outline-[2px_solid_var(--app-accent)] outline-offset-[-2px];
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.n-button.directory-item {
|
||||
@apply w-[228px] min-h-[104px];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,3 +13,14 @@ defineProps<{ title: string; description: string }>()
|
||||
></template>
|
||||
</NEmpty>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.empty-state {
|
||||
@apply flex flex-col items-center justify-center min-h-[300px] py-[52px] px-6 text-center;
|
||||
}
|
||||
.empty-state {
|
||||
@apply min-h-[200px] py-10 px-5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,3 +34,11 @@ function selectTheme(key: string | number) {
|
||||
</NButton>
|
||||
</NDropdown>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.n-button.theme-toggle {
|
||||
@apply shrink-0 w-[34px] h-[34px];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,3 +12,72 @@ defineProps<{ split?: boolean; compact?: boolean }>()
|
||||
<NScrollbar v-else class="workspace-scroll" content-class="workspace-scroll-content"><slot /></NScrollbar>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.workspace-page {
|
||||
@apply relative h-full flex flex-col min-h-0 overflow-hidden;
|
||||
}
|
||||
.workspace-heading-scroll.n-scrollbar {
|
||||
@apply shrink-0 h-auto max-h-[min(35dvh,260px)] overflow-hidden;
|
||||
}
|
||||
.workspace-heading {
|
||||
@apply pt-4 px-6 pb-3;
|
||||
}
|
||||
.content-first-workspace .workspace-heading {
|
||||
@apply py-2 px-5;
|
||||
}
|
||||
.content-first-workspace .workspace-split {
|
||||
@apply pt-0 px-5 pb-3 gap-2;
|
||||
}
|
||||
.content-first-workspace .workspace-scroll-content {
|
||||
@apply pt-0 px-5 pb-4;
|
||||
}
|
||||
.workspace-heading h2 {
|
||||
@apply text-base;
|
||||
}
|
||||
.workspace-heading .text-sm {
|
||||
@apply text-xs;
|
||||
}
|
||||
.workspace-heading .production-controls {
|
||||
@apply gap-3;
|
||||
}
|
||||
.workspace-heading .n-input-number {
|
||||
@apply w-full;
|
||||
}
|
||||
.workspace-scroll {
|
||||
@apply flex-1 min-h-0;
|
||||
}
|
||||
.workspace-scroll-content {
|
||||
@apply pt-1 px-6 pb-6;
|
||||
}
|
||||
.workspace-scroll > .n-scrollbar-container {
|
||||
@apply overscroll-contain;
|
||||
}
|
||||
.workspace-split {
|
||||
@apply flex flex-col flex-1 min-h-0 overflow-hidden pt-1 px-6 pb-5 gap-3;
|
||||
}
|
||||
.workspace-split > .my-6 {
|
||||
@apply m-0 shrink-0;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.workspace-heading {
|
||||
@apply p-3;
|
||||
}
|
||||
.content-first-workspace .workspace-heading {
|
||||
@apply py-2 px-3;
|
||||
}
|
||||
.content-first-workspace .workspace-split,
|
||||
.content-first-workspace .workspace-scroll-content {
|
||||
@apply pt-0 px-3 pb-3;
|
||||
}
|
||||
.workspace-scroll-content,
|
||||
.workspace-split {
|
||||
@apply pt-0 px-3 pb-3;
|
||||
}
|
||||
.workspace-heading .production-controls {
|
||||
@apply grid-cols-[minmax(100px,_1fr)_80px];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,3 +46,45 @@ const open = defineModel<boolean>('open', { default: false })
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.workspace-tools-drawer .n-drawer-header,
|
||||
.workspace-tools-drawer .n-drawer-footer {
|
||||
@apply bg-(--app-subtle);
|
||||
}
|
||||
.workspace-tools-drawer .n-drawer-body {
|
||||
@apply bg-(--app-body);
|
||||
}
|
||||
.workspace-tools-drawer {
|
||||
@apply max-w-full;
|
||||
}
|
||||
.workspace-tools-heading {
|
||||
@apply flex items-center justify-between gap-4 w-full text-[15px];
|
||||
}
|
||||
.workspace-tools-body {
|
||||
@apply p-5;
|
||||
}
|
||||
.workspace-tools-body .storyboard-controls,
|
||||
.workspace-tools-body .production-controls {
|
||||
@apply grid grid-cols-[repeat(auto-fit,_minmax(160px,_1fr))] gap-4 my-5;
|
||||
}
|
||||
.workspace-tools-body .storyboard-coverage {
|
||||
@apply my-5;
|
||||
}
|
||||
.workspace-tools-body .generation-row {
|
||||
@apply block;
|
||||
}
|
||||
.workspace-tools-body .generation-row > div + div {
|
||||
@apply mt-3.5;
|
||||
}
|
||||
.workspace-tools-body .production-pipeline {
|
||||
@apply grid-cols-[repeat(auto-fit,_minmax(min(100%,_230px),_1fr))];
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.workspace-tools-body {
|
||||
@apply p-3.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
import { h } from 'vue'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NCheckbox,
|
||||
NConfigProvider,
|
||||
NInput,
|
||||
NSelect,
|
||||
NTable,
|
||||
NTag,
|
||||
NTabPane,
|
||||
NTabs
|
||||
} from 'naive-ui'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { useTheme } from '../../composables/useTheme'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
localStorage.clear()
|
||||
delete document.documentElement.dataset.theme
|
||||
document.documentElement.style.colorScheme = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('全站平面主题', () => {
|
||||
it.each([false, true])('模式 %s 的绿色按钮使用白字,禁用后灰底灰字且不可提交', dark => {
|
||||
localStorage.setItem('drama-studio-theme', dark ? 'dark' : 'light')
|
||||
vi.stubGlobal('matchMedia', () => ({ matches: dark, addEventListener() {}, removeEventListener() {} }))
|
||||
const click = vi.fn<() => void>()
|
||||
wrapper = mount({
|
||||
setup() {
|
||||
const { theme, overrides } = useTheme()
|
||||
return () =>
|
||||
h(NConfigProvider, { theme: theme.value, themeOverrides: overrides.value }, () =>
|
||||
(['primary', 'success'] as const).flatMap(type => [
|
||||
h(NButton, { type, class: `${type}-button`, onClick: click }, () => '提交'),
|
||||
h(
|
||||
NButton,
|
||||
{ type, disabled: true, class: `${type}-disabled`, onClick: click },
|
||||
() => '提交'
|
||||
)
|
||||
])
|
||||
)
|
||||
}
|
||||
})
|
||||
for (const type of ['primary', 'success']) {
|
||||
const active = wrapper.get<HTMLButtonElement>(`.${type}-button`).element
|
||||
const disabled = wrapper.get<HTMLButtonElement>(`.${type}-disabled`).element
|
||||
for (const property of [
|
||||
'--n-text-color',
|
||||
'--n-text-color-hover',
|
||||
'--n-text-color-pressed',
|
||||
'--n-text-color-focus'
|
||||
]) {
|
||||
expect(active.style.getPropertyValue(property)).toBe('#ffffff')
|
||||
}
|
||||
expect(active.style.getPropertyValue('--n-color')).toBe('#078640')
|
||||
expect(active.style.getPropertyValue('--n-border-focus')).toBe('none')
|
||||
expect(active.style.getPropertyValue('--n-wave-opacity')).toBe('0')
|
||||
expect(disabled.style.getPropertyValue('--n-color-disabled')).toBe(dark ? '#303030' : '#e4e4e4')
|
||||
expect(disabled.style.getPropertyValue('--n-text-color-disabled')).toBe(dark ? '#808080' : '#8c8c8c')
|
||||
expect(disabled.disabled).toBe(true)
|
||||
disabled.click()
|
||||
expect(click).not.toHaveBeenCalled()
|
||||
}
|
||||
wrapper.get<HTMLButtonElement>('.primary-button').element.click()
|
||||
expect(click).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([false, true])('模式 %s 的真实控件无静态描边、使用直角,并保留聚焦与错误反馈', dark => {
|
||||
localStorage.setItem('drama-studio-theme', dark ? 'dark' : 'light')
|
||||
vi.stubGlobal('matchMedia', () => ({ matches: dark, addEventListener() {}, removeEventListener() {} }))
|
||||
wrapper = mount({
|
||||
setup() {
|
||||
const { theme, overrides } = useTheme()
|
||||
return () =>
|
||||
h(NConfigProvider, { theme: theme.value, themeOverrides: overrides.value }, () => [
|
||||
h(NButton, {}, () => '生成'),
|
||||
h(NInput, { class: 'normal-input' }),
|
||||
h(NInput, { status: 'error', class: 'error-input' }),
|
||||
h(NCheckbox, { checked: true }),
|
||||
h(NSelect, { options: [{ label: '第一集', value: 1 }] }),
|
||||
h(NAlert, { type: 'error' }, () => '生成失败'),
|
||||
h(NCard, {}, () => '内容'),
|
||||
h(NTag, {}, () => '待设计'),
|
||||
h(NTabs, { type: 'line', value: 'one' }, () => [
|
||||
h(NTabPane, { name: 'one', tab: '人物' }, () => '人物内容'),
|
||||
h(NTabPane, { name: 'two', tab: '场景' }, () => '场景内容')
|
||||
]),
|
||||
h(NTable, { striped: true }, () => h('tbody', [h('tr', [h('td', '记录')])]))
|
||||
])
|
||||
}
|
||||
})
|
||||
const value = (selector: string, name: string) =>
|
||||
(wrapper!.get(selector).element as HTMLElement).style.getPropertyValue(name)
|
||||
const control = dark ? '#2b2b2b' : '#f0f0f0'
|
||||
for (const selector of ['.n-button', '.normal-input', '.n-base-selection', '.n-alert', '.n-card', '.n-tag']) {
|
||||
expect(value(selector, '--n-border-radius')).toBe('0px')
|
||||
}
|
||||
for (const selector of ['.n-button', '.normal-input', '.n-base-selection']) {
|
||||
expect(value(selector, '--n-border')).toBe('none')
|
||||
}
|
||||
// 普通按钮点击后不残留深绿描边,输入和选择控件仍保留聚焦边界。
|
||||
expect(value('.n-button', '--n-border-focus')).toBe('none')
|
||||
expect(value('.n-button', '--n-wave-opacity')).toBe('0')
|
||||
expect(wrapper.getComponent(NButton).props('focusable')).toBe(true)
|
||||
for (const selector of ['.normal-input', '.n-base-selection']) {
|
||||
expect(value(selector, '--n-border-focus')).toContain(dark ? '#5cd693' : '#087c42')
|
||||
}
|
||||
expect(value('.n-button', '--n-color')).toBe(control)
|
||||
expect(value('.normal-input', '--n-color')).toBe('var(--app-field)')
|
||||
expect(value('.n-base-selection', '--n-color')).toBe('var(--app-field)')
|
||||
expect(value('.n-checkbox', '--n-check-mark-color')).toBe('#ffffff')
|
||||
expect(value('.n-alert', '--n-border')).toBe('none')
|
||||
expect(value('.n-tag', '--n-border')).toBe('none')
|
||||
expect(value('.n-card', '--n-border-color')).toBe('transparent')
|
||||
expect(value('.n-table', '--n-border-color')).toBe('transparent')
|
||||
expect(value('.error-input', '--n-border-error')).toContain(dark ? '#fa7373' : '#a93232')
|
||||
expect(wrapper.get('.error-input').classes()).toContain('n-input--error-status')
|
||||
// 不全局抹掉边框色:未勾选复选框和错误态继续由 Naive 的主题处理。
|
||||
const theme = wrapper.getComponent(NConfigProvider).props('themeOverrides')!
|
||||
expect(theme.common?.borderColor).not.toBe('transparent')
|
||||
expect(theme.Checkbox).not.toHaveProperty('border')
|
||||
expect(theme.Tabs?.barColor).toBe('#07c160')
|
||||
expect(value('.n-tabs', '--n-tab-border-color')).toBe(dark ? '#333333' : '#dadada')
|
||||
})
|
||||
|
||||
it('普通按钮只在键盘焦点可见时显示外框,不用失焦或禁止聚焦隐藏点击反馈', () => {
|
||||
// DOM 环境不模拟真实鼠标/键盘焦点判定,保护共享 CSS 与触发器契约。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.n-button:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--app-accent-text\);[^}]*outline-offset:\s*2px;/
|
||||
)
|
||||
const tools = readFileSync('src/components/ui/WorkspaceTools.vue', 'utf8')
|
||||
expect(tools).not.toContain('.blur(')
|
||||
expect(tools).not.toContain(':focusable="false"')
|
||||
})
|
||||
|
||||
it('图库筛选吸顶,关联内容横向单行滚动,不给头部和图片区制造双重滚动边界', () => {
|
||||
// 静态保护吸顶和横向尺寸规则;真实视口滚动与位置仍需浏览器验收。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.gallery-workspace-page \.gallery-sticky-controls\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*z-index:\s*10;[^}]*background:\s*var\(--app-body\);/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.gallery-workspace-page \.workspace-scroll > \.n-scrollbar-container\s*\{[^}]*overflow-anchor:\s*none;/
|
||||
)
|
||||
expect(css).toMatch(/\.asset-impact-links\s*\{[^}]*width:\s*max-content;[^}]*white-space:\s*nowrap;/)
|
||||
expect(css).toMatch(/\.gallery-sticky-controls > \.form-image-filter-region\s*\{[^}]*margin-block:\s*0;/)
|
||||
// 间距归列表所有,吸顶时底部不留固定色带。
|
||||
expect(css).toMatch(/\.gallery-workspace-page \.form-image-grid\s*\{[^}]*padding-top:\s*12px;/)
|
||||
const sticky = css.match(/\.gallery-workspace-page \.gallery-sticky-controls\s*\{([^}]*)\}/)?.[1]
|
||||
expect(sticky).not.toMatch(/padding|border-bottom|::after/)
|
||||
expect(css).toMatch(
|
||||
/\.asset-impact-links\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*32px;[^}]*padding-block:\s*6px;/
|
||||
)
|
||||
expect(css).toMatch(/\.asset-impact-links-scroll\.n-scrollbar\s*\{[^}]*height:\s*auto;[^}]*max-width:\s*100%;/)
|
||||
expect(readFileSync('src/features/subject-images/SubjectImagesPage.vue', 'utf8')).toMatch(
|
||||
/<WorkspacePage\b[^>]*>\s*<template #default>/
|
||||
)
|
||||
})
|
||||
|
||||
it('瀑布流自适应列宽,卡片保持完整并按封面尺寸预留高度', () => {
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.gallery-workspace-page \.form-image-masonry\s*\{[^}]*display:\s*block;[^}]*columns:\s*260px;[^}]*column-gap:\s*20px;/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.form-image-masonry > \.form-image-card\s*\{[^}]*break-inside:\s*avoid;[^}]*margin-bottom:\s*20px;/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.form-image-masonry \.asset-image\s*\{[^}]*aspect-ratio:\s*var\(--form-image-aspect, 4 \/ 3\);/
|
||||
)
|
||||
})
|
||||
|
||||
it('图库顶部统一镜头选择和筛选的背景与垂直对齐,窄屏整组换行', () => {
|
||||
// DOM 环境不计算几何;保护容器宽度断点和居中契约,真实坐标仍需浏览器验收。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.form-image-filter-region\s*\{[^}]*padding:\s*14px 16px;[^}]*background:\s*var\(--app-subtle\);/
|
||||
)
|
||||
expect(css).toMatch(/\.form-image-toolbar\s*\{[^}]*align-items:\s*center;[^}]*gap:\s*12px 16px;/)
|
||||
expect(css).toMatch(
|
||||
/\.form-image-toolbar\.has-impact-picker\s*\{[^}]*grid-template-columns:\s*minmax\(220px, 340px\) minmax\(0, 1fr\);/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/@container \(max-width: 1200px\)\s*\{\s*\.form-image-toolbar\.has-impact-picker,\s*\.form-image-toolbar\.has-impact-picker\.has-impact-actions\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);/
|
||||
)
|
||||
expect(css).toMatch(/\.asset-impact-picker\s*\{[^}]*align-items:\s*center;[^}]*gap:\s*8px;/)
|
||||
expect(css).toMatch(/\.asset-impact-context\s*\{[^}]*gap:\s*10px;[^}]*padding:\s*5px 16px;[^}]*margin:\s*0;/)
|
||||
})
|
||||
|
||||
it('单图标按钮以当前控件高度为边长,尺寸与内容按钮互不影响', () => {
|
||||
// happy-dom 不计算几何尺寸,此项约束共用样式和调用方,实际布局仍需浏览器验收。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.n-button\.icon-button\s*\{[^}]*width:\s*var\(--n-height\);[^}]*min-width:\s*var\(--n-height\);[^}]*height:\s*var\(--n-height\);[^}]*padding:\s*0/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.project-status-filters \.n-radio-button\s*\{[^}]*min-width:\s*76px;[^}]*padding-inline:\s*18px/
|
||||
)
|
||||
expect(css).toMatch(/\.project-status-filters\.n-radio-group\s*\{[^}]*flex-wrap:\s*wrap;[^}]*height:\s*auto;/)
|
||||
for (const file of [
|
||||
'src/App.vue',
|
||||
'src/components/ui/ThemeToggle.vue',
|
||||
'src/components/ui/WorkspaceTools.vue',
|
||||
'src/features/projects/ProjectsPage.vue',
|
||||
'src/features/subject-identity/SubjectIdentityPage.vue'
|
||||
]) {
|
||||
const source = readFileSync(file, 'utf8')
|
||||
expect(source).toContain('icon-button')
|
||||
expect(source).not.toMatch(/<NButton\b[^>]*\bcircle\b/)
|
||||
}
|
||||
expect(readFileSync('src/components/ui/DirectoryItem.vue', 'utf8')).not.toContain('icon-button')
|
||||
})
|
||||
|
||||
it('业务面板、列表分组和标签以背景区分,保留键盘焦点与选中指示', () => {
|
||||
// happy-dom 不计算布局;此项保护 CSS 契约,不能替代浏览器视觉验收。
|
||||
const admin = readFileSync('src/admin.css', 'utf8')
|
||||
const css = readFileSync('src/styles.css', 'utf8')
|
||||
expect(admin + css).not.toMatch(/border-radius:\s*[1-9]/)
|
||||
expect(admin + css).not.toMatch(/border(?:-[\w]+)?:\s*1px (?:solid|dashed) var\(--(?:app-border|color-line)\)/)
|
||||
expect(css).toMatch(/\.surface-inset\s*\{[^}]*background:\s*var\(--app-subtle\)/)
|
||||
expect(css).toMatch(/\.record-list > article:nth-child\(even\)\s*\{\s*background:/)
|
||||
expect(admin).toMatch(/\.directory-group-heading\s*\{[^}]*background:\s*var\(--app-control\)/)
|
||||
expect(admin).toMatch(/\.directory-status\s*\{[^}]*background:\s*var\(--app-control\)/)
|
||||
expect(admin).toMatch(/\.n-button\.directory-item:focus-visible\s*\{[^}]*outline:\s*2px/)
|
||||
expect(admin).toMatch(
|
||||
/\.n-button\.directory-item\.selected\s*\{[^}]*var\(--app-selected\)[^}]*var\(--app-accent\)/
|
||||
)
|
||||
})
|
||||
|
||||
it('斑马纹首项保留统一顶部留白,背景色平滑过渡并遵循减少动态效果设置', () => {
|
||||
// DOM 环境不计算实际内边距,检查共享规则及之前覆盖首项留白的工具类。
|
||||
const css = readFileSync('src/styles.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.record-list > article\s*\{[^}]*padding:\s*20px;[^}]*transition:\s*background-color 160ms ease;/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/@media \(prefers-reduced-motion: reduce\)[\s\S]*transition-duration:\s*0\.01ms !important;/
|
||||
)
|
||||
for (const file of [
|
||||
'src/features/breakdown/components/SubjectList.vue',
|
||||
'src/features/create-drama/CreateDramaPage.vue'
|
||||
]) {
|
||||
expect(readFileSync(file, 'utf8')).not.toContain('first:pt-0')
|
||||
}
|
||||
})
|
||||
|
||||
it('所有页面不再通过工具类添加装饰边框或圆角', () => {
|
||||
const files = readdirSync('src', { recursive: true, encoding: 'utf8' }).filter(path => path.endsWith('.vue'))
|
||||
for (const path of files) {
|
||||
const source = readFileSync(`src/${path}`, 'utf8')
|
||||
for (const match of source.matchAll(/\bclass="([^"]*)"/g)) {
|
||||
expect(match[1]).not.toMatch(
|
||||
/(?:^|\s)(?:rounded(?:-[\w-]+)?|border|border-[trbl](?:-\d+)?|divide-[xy])(?:\s|$)/
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,305 +0,0 @@
|
||||
import { h } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NConfigProvider, NDrawer, NDrawerContent, NModal, NScrollbar, NTooltip } from 'naive-ui'
|
||||
import App from '../../App.vue'
|
||||
import WorkspacePage from './WorkspacePage.vue'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
import WorkspaceTools from './WorkspaceTools.vue'
|
||||
import ProjectLayout from '../../features/projects/ProjectLayout.vue'
|
||||
import ConfirmAction from '../../features/workflows/ConfirmAction.vue'
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
import { projectsApi } from '../../features/projects/api'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { drawerPanel } from '../../testing/naive'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
localStorage.clear()
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('管理后台组件边界', () => {
|
||||
it('Tabs suffix 与标签共用导航行,窄屏换行但不产生外层滚动', () => {
|
||||
// DOM 测试不计算坐标;保护对齐尺寸与容器断点,视觉验收仍需浏览器。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(/\.workspace-tabs > \.n-tabs-nav\s*\{[^}]*align-items:\s*stretch;/)
|
||||
expect(css).toMatch(/\.workspace-tabs \.n-tabs-nav__suffix\s*\{[^}]*align-items:\s*center;/)
|
||||
expect(css).toMatch(/\.tabs-toolbar-actions\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*40px;/)
|
||||
expect(css).toContain('@container tabs-toolbar (max-width: 600px)')
|
||||
expect(css).not.toContain('.result-toolbar > .n-button')
|
||||
})
|
||||
|
||||
it('滚动边界样式包含 document 锁定和工作区收缩约束', () => {
|
||||
// happy-dom 不计算视口几何;这里只保护 CSS 契约,真实滚动仍需浏览器验收。
|
||||
const adminCss = readFileSync('src/admin.css', 'utf8')
|
||||
expect(adminCss).toMatch(/html,\s*body,\s*#app,\s*\.app-provider\s*\{[^}]*overflow:\s*hidden/)
|
||||
expect(adminCss).toMatch(/\.workspace-page\s*\{[^}]*min-height:\s*0;[^}]*overflow:\s*hidden/)
|
||||
})
|
||||
it('业务样式不再直接开启浏览器原生滚动条', () => {
|
||||
const css = readFileSync('src/admin.css', 'utf8') + readFileSync('src/styles.css', 'utf8')
|
||||
expect(css).not.toMatch(/overflow(?:-[xy])?\s*:\s*(?:auto|scroll)\b/)
|
||||
})
|
||||
it('历史缩略图尺寸在非分层样式中覆盖 Naive 按钮,原图不能撑大预览或横向条目', () => {
|
||||
// happy-dom 不计算几何;专门保护此前失效的层叠和固定尺寸约束。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).not.toContain('@layer')
|
||||
expect(css).toMatch(
|
||||
/\.n-button\.image-history-item\s*\{[^}]*width:\s*128px;[^}]*min-width:\s*128px;[^}]*max-width:\s*128px;[^}]*flex:\s*0 0 128px;[^}]*padding:\s*6px/
|
||||
)
|
||||
expect(css).toMatch(/\.image-history-item \.asset-image\s*\{[^}]*height:\s*88px;[^}]*min-height:\s*0/)
|
||||
expect(css).toMatch(
|
||||
/\.asset-image\.asset-image-preview\s*\{[^}]*height:\s*min\(42dvh, 420px\);[^}]*aspect-ratio:\s*auto/
|
||||
)
|
||||
expect(css).toMatch(/\.image-history\.n-scrollbar\s*\{[^}]*max-width:\s*100%;[^}]*min-width:\s*0/)
|
||||
expect(css).toMatch(/\.image-history-content\s*\{[^}]*display:\s*flex;[^}]*width:\s*max-content/)
|
||||
})
|
||||
it('选角面板保留说明间距,卡片按可用宽度排列并覆盖按钮默认高度', () => {
|
||||
// 保护间距与换行契约,真实宽高和暗色效果仍需浏览器验收。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(/\.identity-tools-intro\s*\{[^}]*margin-bottom:\s*24px/)
|
||||
expect(css).toMatch(
|
||||
/\.casting-list\s*\{[^}]*repeat\(auto-fit, minmax\(min\(100%, 260px\), 1fr\)\);[^}]*gap:\s*12px;[^}]*margin-top:\s*24px/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.n-button\.casting-item\s*\{[^}]*height:\s*auto;[^}]*min-height:\s*76px;[^}]*padding:\s*12px 14px/
|
||||
)
|
||||
expect(css).toMatch(/\.casting-item-name\s*\{[^}]*overflow-wrap:\s*anywhere/)
|
||||
expect(css).toMatch(/\.casting-item-status\s*\{[^}]*flex-shrink:\s*0/)
|
||||
expect(css).toMatch(/\.n-button\.casting-item\.selected\s*\{[^}]*var\(--app-selected\)[^}]*var\(--app-accent\)/)
|
||||
})
|
||||
it('拆解分栏跟随剩余高度收缩,移动端历史区不挤出结果区', () => {
|
||||
// DOM 环境不计算几何,保护完整的 flex → grid → NScrollbar 高度链。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(/\.breakdown-results-section\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0/)
|
||||
expect(css).toMatch(/\.breakdown-results-section > \.result-toolbar\s*\{[^}]*flex-shrink:\s*0/)
|
||||
expect(css).toMatch(
|
||||
/\.breakdown-results-section > \.content-with-history\s*\{[^}]*flex:\s*1;[^}]*height:\s*auto;[^}]*min-height:\s*0;[^}]*grid-template-rows:\s*minmax\(0, 1fr\)/
|
||||
)
|
||||
expect(css).toContain('grid-template-rows: minmax(0, 1fr) min(25%, 130px)')
|
||||
expect(css).toMatch(/\.panel-scroll\.n-scrollbar\s*\{[^}]*height:\s*100%;[^}]*min-height:\s*0/)
|
||||
})
|
||||
it('标题与内容使用各自滚动容器,不随正文一起滚动', () => {
|
||||
wrapper = mount(WorkspacePage, {
|
||||
slots: { header: '<h2>固定操作区</h2>', default: '<p>正文</p>' }
|
||||
})
|
||||
expect(wrapper.get('.workspace-heading').text()).toBe('固定操作区')
|
||||
const content = wrapper.get('.workspace-scroll')
|
||||
expect(content.classes()).toContain('n-scrollbar')
|
||||
expect(content.text()).toBe('正文')
|
||||
expect(content.find('h2').exists()).toBe(false)
|
||||
expect(wrapper.get('.workspace-heading-scroll .n-scrollbar-content').text()).toBe('固定操作区')
|
||||
})
|
||||
|
||||
it('分栏工作区不再包一层整页滚动', () => {
|
||||
wrapper = mount(WorkspacePage, {
|
||||
props: { split: true },
|
||||
slots: { default: '<aside>目录</aside><article>正文</article>' }
|
||||
})
|
||||
expect(wrapper.findComponent(NScrollbar).exists()).toBe(false)
|
||||
expect(wrapper.get('.workspace-split').findAll(':scope > *')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('图标主题菜单保留三种模式,设置入口位于侧栏底部并支持折叠', async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/projects', component: { render: () => h('p', '项目列表') } },
|
||||
{
|
||||
path: '/projects/:projectId/:workspace',
|
||||
component: { render: () => h(WorkspacePage, {}, () => '镜头详情') }
|
||||
}
|
||||
]
|
||||
})
|
||||
await router.push('/projects/test/production')
|
||||
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.n-menu .n-menu-item')).toHaveLength(8)
|
||||
expect(wrapper.get('#main-content .workspace-page').text()).toBe('镜头详情')
|
||||
expect(wrapper.get('.theme-toggle').text()).toBe('')
|
||||
expect(wrapper.get('.theme-toggle').attributes('aria-haspopup')).toBe('menu')
|
||||
expect(wrapper.getComponent(ThemeToggle).findComponent(NTooltip).exists()).toBe(false)
|
||||
expect(wrapper.get('.theme-toggle').attributes('title')).toBeUndefined()
|
||||
expect(wrapper.get('.theme-toggle svg').classes()).toContain('lucide-monitor')
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
for (const [key, label, icon] of [
|
||||
['dark', '暗黑模式', 'moon'],
|
||||
['light', '浅色模式', 'sun'],
|
||||
['system', '跟随系统', 'monitor']
|
||||
] as const) {
|
||||
await wrapper.get('.theme-toggle').trigger('click')
|
||||
await flushPromises()
|
||||
const option = [...document.querySelectorAll<HTMLElement>('.n-dropdown-option-body')].find(
|
||||
item => item.textContent?.trim() === label
|
||||
)
|
||||
expect(option).toBeDefined()
|
||||
option!.click()
|
||||
await flushPromises()
|
||||
expect(localStorage.getItem('drama-studio-theme')).toBe(key)
|
||||
expect(wrapper.get('.theme-toggle svg').classes()).toContain(`lucide-${icon}`)
|
||||
expect(wrapper.get('.theme-toggle').attributes('aria-label')).toBe(`主题模式:${label}`)
|
||||
const expectedTheme = key === 'system' ? systemTheme : key
|
||||
expect(document.documentElement.dataset.theme).toBe(expectedTheme)
|
||||
expect(wrapper.getComponent(NConfigProvider).props('theme')?.name ?? 'light').toBe(expectedTheme)
|
||||
}
|
||||
expect(wrapper.find('.admin-topbar [aria-label="后端连接"]').exists()).toBe(false)
|
||||
expect(wrapper.find('.admin-nav-scroll [aria-label="后端连接"]').exists()).toBe(false)
|
||||
expect(wrapper.get('.admin-sider-footer [aria-label="后端连接"]').text()).toBe('后端连接')
|
||||
await wrapper.get('[aria-label="折叠侧栏"]').trigger('click')
|
||||
expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true)
|
||||
const settings = wrapper.get('.admin-sider-footer [aria-label="后端连接"]')
|
||||
expect(settings.text()).toBe('')
|
||||
await settings.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[role="dialog"][aria-label="后端连接"]')).not.toBeNull()
|
||||
expect(wrapper.findAll('.admin-nav-scroll .n-menu .n-menu-item')).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('侧栏菜单独立滚动,页脚不参与滚动且窄屏不超出视口底部', () => {
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.admin-sider-content\s*\{[^}]*height:\s*100%;[^}]*min-height:\s*0;[^}]*overflow:\s*hidden/
|
||||
)
|
||||
expect(css).toMatch(/\.admin-nav-scroll\.n-scrollbar\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0/)
|
||||
expect(css).toMatch(/\.admin-sider-footer\s*\{[^}]*flex-shrink:\s*0/)
|
||||
expect(css).toMatch(/\.admin-sider:not\([^)]*\)\s*\{[^}]*bottom:\s*0;[^}]*height:\s*auto/)
|
||||
for (const path of ['storyboard/StoryboardPage.vue', 'production/ProductionPage.vue']) {
|
||||
const source = readFileSync(`src/features/${path}`, 'utf8')
|
||||
expect(source).not.toContain('<span>剧集</span')
|
||||
expect(source).toMatch(/aria-label="选择(?:分镜|生产)剧集"/)
|
||||
}
|
||||
})
|
||||
|
||||
it('长弹窗使用内部滚动,提交期间禁止遮罩和 Esc 关闭', async () => {
|
||||
wrapper = mount(AppDialog, {
|
||||
attachTo: document.body,
|
||||
props: { open: true, title: '确认生图', description: '费用确认', busy: true },
|
||||
slots: { default: '<p>内容</p>' }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent(NModal).props()).toMatchObject({
|
||||
closable: false,
|
||||
maskClosable: false,
|
||||
closeOnEsc: false
|
||||
})
|
||||
expect(document.querySelector('.dialog-body-scroll .n-scrollbar-container')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('抽屉挂载 body 覆盖完整视口,开关保留列表与配置草稿', async () => {
|
||||
wrapper = mount(WorkspacePage, {
|
||||
attachTo: document.body,
|
||||
props: { split: true, compact: true },
|
||||
slots: {
|
||||
header: () => h(WorkspaceTools, { title: '测试操作' }, () => h('input', { 'aria-label': '配置草稿' })),
|
||||
default: '<article class="test-list">主体列表</article>'
|
||||
}
|
||||
})
|
||||
const list = wrapper.get('.test-list').element
|
||||
expect(wrapper.find('.workspace-tools-drawer').exists()).toBe(false)
|
||||
expect(wrapper.get('.workspace-heading').find('[data-workspace-tools]').exists()).toBe(true)
|
||||
await wrapper.get('[data-workspace-tools]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent(NDrawer).props()).toMatchObject({
|
||||
to: 'body',
|
||||
blockScroll: true,
|
||||
placement: 'right',
|
||||
width: 'min(960px, 100%)',
|
||||
trapFocus: true,
|
||||
closeOnEsc: true
|
||||
})
|
||||
expect(wrapper.getComponent(NDrawerContent).props('nativeScrollbar')).toBe(false)
|
||||
expect(wrapper.find('.workspace-tools-drawer').exists()).toBe(false)
|
||||
const container = drawerPanel().element.closest('.n-drawer-container')!
|
||||
expect(container.parentElement).toBe(document.body)
|
||||
expect(container.closest('main, .admin-content, .workspace-page')).toBeNull()
|
||||
expect(container.querySelector('.n-drawer-mask')).not.toBeNull()
|
||||
expect(drawerPanel().find('.n-scrollbar-container').exists()).toBe(true)
|
||||
await drawerPanel().get('input[aria-label="配置草稿"]').setValue('保留配置')
|
||||
await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent(NDrawer).props('show')).toBe(false)
|
||||
expect(wrapper.get('.test-list').element).toBe(list)
|
||||
await wrapper.get('[data-workspace-tools]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(drawerPanel().get<HTMLInputElement>('input[aria-label="配置草稿"]').element.value).toBe('保留配置')
|
||||
container.querySelector<HTMLElement>('.n-drawer-mask')!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent(NDrawer).props('show')).toBe(false)
|
||||
expect(wrapper.get('.test-list').element).toBe(list)
|
||||
wrapper.unmount()
|
||||
wrapper = undefined
|
||||
expect(document.querySelector('.n-drawer-container')).toBeNull()
|
||||
})
|
||||
|
||||
it('完成项目解锁全部左侧链接,标题不再显示状态标签和刷新按钮', async () => {
|
||||
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue({
|
||||
id: 'navigation-test',
|
||||
title: '导航测试项目',
|
||||
topic: '测试主题',
|
||||
style: null,
|
||||
status: 'completed',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
episodes: [],
|
||||
characters: [],
|
||||
world: null,
|
||||
reviews: [],
|
||||
tasks: []
|
||||
})
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const paths = [
|
||||
'create-drama',
|
||||
'breakdown',
|
||||
'visual-style',
|
||||
'subject-identity',
|
||||
'subject-images',
|
||||
'storyboard',
|
||||
'production'
|
||||
]
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/projects/:projectId',
|
||||
component: ProjectLayout,
|
||||
children: paths.map(path => ({
|
||||
path,
|
||||
component: { render: () => h(WorkspacePage, {}, () => path) }
|
||||
}))
|
||||
}
|
||||
]
|
||||
})
|
||||
await router.push('/projects/navigation-test/create-drama')
|
||||
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[aria-label="项目工作流"]').exists()).toBe(false)
|
||||
expect(wrapper.get('.project-title').text()).toBe('导航测试项目')
|
||||
for (const path of paths) {
|
||||
await wrapper.get(`.n-menu a[href="/projects/navigation-test/${path}"]`).trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/projects/navigation-test/' + path)
|
||||
expect(wrapper.get('.project-view .workspace-page').text()).toBe(path)
|
||||
}
|
||||
expect(detail).toHaveBeenCalledOnce()
|
||||
expect(wrapper.find('.project-header .n-button').exists()).toBe(false)
|
||||
expect(wrapper.find('.project-header .n-tag').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('确认按钮有稳定根元素承接调用方间距,生产按钮显式保留上下留白', () => {
|
||||
wrapper = mount(ConfirmAction, {
|
||||
props: { label: '测试操作', description: '仅展示,不提交请求' },
|
||||
attrs: { class: 'mt-4' }
|
||||
})
|
||||
expect(wrapper.element.tagName).toBe('SPAN')
|
||||
expect(wrapper.classes()).toContain('confirm-action')
|
||||
expect(wrapper.classes()).toContain('mt-4')
|
||||
expect(wrapper.get('button').text()).toBe('测试操作')
|
||||
expect(readFileSync('src/admin.css', 'utf8')).toMatch(
|
||||
/\.production-pipeline-card > \.confirm-action\s*\{[^}]*margin-block:\s*16px 4px/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { usePolling } from './usePolling'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('异步轮询生命周期', () => {
|
||||
it('关闭定时刷新后仍支持首次读取、手动刷新及查询目标切换', async () => {
|
||||
vi.useFakeTimers()
|
||||
const key = ref('first')
|
||||
const loader = vi.fn<(id: string) => Promise<string>>(async id => id)
|
||||
const scope = effectScope()
|
||||
const query = scope.run(() => usePolling(key, loader, false))!
|
||||
await Promise.resolve()
|
||||
expect(query.data.value).toBe('first')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(loader).toHaveBeenCalledTimes(1)
|
||||
await query.refresh()
|
||||
expect(loader).toHaveBeenCalledTimes(2)
|
||||
key.value = 'second'
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(query.data.value).toBe('second')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(loader).toHaveBeenCalledTimes(3)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('响应式暂停清除旧定时器,恢复只创建一个轮询链', async () => {
|
||||
vi.useFakeTimers()
|
||||
const interval = ref<number | false>(1000)
|
||||
const loader = vi.fn<() => Promise<string>>(async () => '数据')
|
||||
const scope = effectScope()
|
||||
scope.run(() => usePolling(ref('p'), loader, interval))
|
||||
await Promise.resolve()
|
||||
interval.value = false
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(loader).toHaveBeenCalledTimes(1)
|
||||
interval.value = 1000
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(loader).toHaveBeenCalledTimes(3)
|
||||
scope.stop()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(loader).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('切换项目忽略旧响应,卸载时不再排队请求', async () => {
|
||||
vi.useFakeTimers()
|
||||
const key = ref('first')
|
||||
const finish = new Map<string, (value: string) => void>()
|
||||
const loader = vi.fn<(id: string) => Promise<string>>(
|
||||
(id: string) =>
|
||||
new Promise<string>(resolve => {
|
||||
finish.set(id, resolve)
|
||||
})
|
||||
)
|
||||
const scope = effectScope()
|
||||
const query = scope.run(() => usePolling(key, loader, 1000))!
|
||||
key.value = 'second'
|
||||
await nextTick()
|
||||
finish.get('second')!('新项目')
|
||||
await Promise.resolve()
|
||||
finish.get('first')!('旧项目')
|
||||
await Promise.resolve()
|
||||
expect(query.data.value).toBe('新项目')
|
||||
scope.stop()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(loader).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('失败时保留已有数据,并向页面显示错误', async () => {
|
||||
const scope = effectScope()
|
||||
const loader = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValueOnce('上次成功数据')
|
||||
.mockRejectedValueOnce(new Error('连接已断开'))
|
||||
const query = scope.run(() => usePolling(ref('p'), loader))!
|
||||
await Promise.resolve()
|
||||
await query.refresh()
|
||||
expect(query.data.value).toBe('上次成功数据')
|
||||
expect(query.error.value).toBe('连接已断开')
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
@@ -1,192 +0,0 @@
|
||||
import { effectScope, nextTick, type EffectScope } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useTheme } from './useTheme'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
let scope: EffectScope | undefined
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = undefined
|
||||
localStorage.clear()
|
||||
delete document.documentElement.dataset.theme
|
||||
document.documentElement.style.colorScheme = ''
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
/** 可控系统主题,验证跟随模式与事件清理,不依赖测试机器偏好。 */
|
||||
function setup(dark = false) {
|
||||
const listeners = new Set<(event: MediaQueryListEvent) => void>()
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
matches: dark,
|
||||
addEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.add(listener),
|
||||
removeEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener)
|
||||
}))
|
||||
scope = effectScope()
|
||||
const state = scope.run(useTheme)!
|
||||
return {
|
||||
...state,
|
||||
listeners,
|
||||
change: (matches: boolean) => listeners.forEach(listener => listener({ matches } as MediaQueryListEvent))
|
||||
}
|
||||
}
|
||||
|
||||
/** 将十六进制颜色换算为 sRGB 相对亮度。 */
|
||||
function luminance(hex: string) {
|
||||
const channels = [1, 3, 5].map(index => {
|
||||
const value = parseInt(hex.slice(index, index + 2), 16) / 255
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722
|
||||
}
|
||||
|
||||
/** 检查主题文字对比度,避免绿色按钮和浅色选中项难以辨认。 */
|
||||
function contrast(foreground: string, background: string) {
|
||||
const values = [luminance(foreground), luminance(background)]
|
||||
return (Math.max(...values) + 0.05) / (Math.min(...values) + 0.05)
|
||||
}
|
||||
|
||||
describe('微信风格明暗主题', () => {
|
||||
it('浅色 Tabs 分隔线使用约定的 #dadada,与页面和内容表面保留灰阶差异', () => {
|
||||
const { overrides } = setup()
|
||||
const color = overrides.value.Tabs!.tabBorderColor!
|
||||
if (typeof color !== 'string') throw new Error('Tabs 分隔线颜色必须为字符串')
|
||||
expect(color).toBe('#dadada')
|
||||
expect(contrast(color, '#ededed')).toBeGreaterThan(1.15)
|
||||
expect(contrast(color, '#ffffff')).toBeGreaterThan(1.15)
|
||||
})
|
||||
|
||||
it('默认跟随系统并实时响应,销毁时移除监听', async () => {
|
||||
const state = setup()
|
||||
expect(state.preference.value).toBe('system')
|
||||
expect(document.documentElement.dataset.theme).toBe('light')
|
||||
state.change(true)
|
||||
await nextTick()
|
||||
expect(state.isDark.value).toBe(true)
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark')
|
||||
expect(state.listeners.size).toBe(1)
|
||||
scope!.stop()
|
||||
expect(state.listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
it('手动选择优先于系统,并持久化到下一次挂载', async () => {
|
||||
const state = setup(true)
|
||||
state.preference.value = 'light'
|
||||
await nextTick()
|
||||
state.change(true)
|
||||
await nextTick()
|
||||
expect(state.isDark.value).toBe(false)
|
||||
expect(localStorage.getItem('drama-studio-theme')).toBe('light')
|
||||
scope!.stop()
|
||||
const restored = setup(true)
|
||||
expect(restored.preference.value).toBe('light')
|
||||
expect(restored.theme.value).toBeNull()
|
||||
})
|
||||
|
||||
it('深浅模式同时更新 Naive 主题和业务颜色标记', async () => {
|
||||
const state = setup()
|
||||
expect(state.overrides.value.common?.primaryColor).toBe('#07c160')
|
||||
expect(state.overrides.value.common?.bodyColor).toBe('#ededed')
|
||||
state.preference.value = 'dark'
|
||||
await nextTick()
|
||||
expect(state.theme.value?.name).toBe('dark')
|
||||
expect(state.overrides.value.common?.primaryColor).toBe('#07c160')
|
||||
expect(state.overrides.value.common?.bodyColor).toBe('#111111')
|
||||
expect(state.overrides.value.Button?.textColorPrimary).toBe('#ffffff')
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
})
|
||||
|
||||
it.each([false, true])('明暗模式 %s 的组件表面、语义色与业务 CSS 一致,主要文字保持可读', dark => {
|
||||
const state = setup(dark)
|
||||
const { common, Menu, Button, Layout, Input, Select } = state.overrides.value
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
const lightTokens = css.match(/:root\s*\{([^}]+)\}/)![1]!
|
||||
const darkTokens = css.match(/:root\[data-theme='dark'\]\s*\{([^}]+)\}/)![1]!
|
||||
const tokens = Object.fromEntries(
|
||||
[...(lightTokens + (dark ? darkTokens : '')).matchAll(/--app-([\w-]+):\s*(#[\da-f]+);/g)].map(match => [
|
||||
match[1],
|
||||
match[2]
|
||||
])
|
||||
)
|
||||
expect(common).toMatchObject({
|
||||
bodyColor: tokens.body,
|
||||
cardColor: tokens.surface,
|
||||
modalColor: tokens.surface,
|
||||
tableColor: tokens.surface,
|
||||
inputColor: tokens.surface,
|
||||
borderColor: tokens.border,
|
||||
textColor1: tokens.ink,
|
||||
textColor3: tokens.muted,
|
||||
primaryColor: tokens.accent,
|
||||
successColor: tokens.success,
|
||||
errorColor: tokens.danger
|
||||
})
|
||||
expect(Layout).toMatchObject({ color: tokens.body, headerColor: tokens.surface, siderColor: tokens.subtle })
|
||||
expect(Menu).toMatchObject({
|
||||
borderRadius: '0px',
|
||||
itemColorActive: tokens.selected,
|
||||
itemColorActiveCollapsed: tokens.selected,
|
||||
itemTextColorActive: tokens['accent-text']
|
||||
})
|
||||
// 全站直角;输入区域与普通按钮共享底色,保持明暗模式一致。
|
||||
expect(common?.borderRadius).toBe('0px')
|
||||
expect(common?.borderRadiusSmall).toBe('0px')
|
||||
expect(Input?.color).toBe('var(--app-field)')
|
||||
expect(Button?.color).toBe(tokens.control)
|
||||
expect(Button?.colorHover).toBe(tokens['control-hover'])
|
||||
expect(Select?.peers?.InternalSelection?.color).toBe('var(--app-field)')
|
||||
expect(contrast(tokens.field!, tokens.body!)).toBeGreaterThan(1.1)
|
||||
expect(contrast(tokens.control!, tokens.surface!)).toBeGreaterThan(1.1)
|
||||
expect(state.overrides.value.Checkbox?.checkMarkColor).toBe('#ffffff')
|
||||
expect(Button?.textColorPrimary).toBe(tokens['on-accent'])
|
||||
expect(Button?.colorPrimary).toBe(tokens['button-primary'])
|
||||
expect(Button?.colorHoverPrimary).toBe(tokens['button-hover'])
|
||||
expect(Button?.colorPressedPrimary).toBe(tokens['button-pressed'])
|
||||
expect(Button?.colorDisabledPrimary).toBe(tokens['disabled-bg'])
|
||||
expect(Button?.textColorDisabledPrimary).toBe(tokens['disabled-text'])
|
||||
for (const background of [Button!.colorPrimary!, Button!.colorHoverPrimary!, Button!.colorPressedPrimary!]) {
|
||||
expect(contrast(String(Button!.textColorPrimary), String(background))).toBeGreaterThanOrEqual(4.5)
|
||||
}
|
||||
expect(contrast(tokens.ink!, tokens.surface!)).toBeGreaterThanOrEqual(4.5)
|
||||
expect(contrast(tokens.muted!, tokens.subtle!)).toBeGreaterThanOrEqual(4.5)
|
||||
expect(contrast(tokens['accent-text']!, tokens.selected!)).toBeGreaterThanOrEqual(4.5)
|
||||
})
|
||||
|
||||
it('系统切换时浏览器主题色与页面同步', async () => {
|
||||
const meta = document.createElement('meta')
|
||||
meta.name = 'theme-color'
|
||||
document.head.appendChild(meta)
|
||||
try {
|
||||
const state = setup()
|
||||
expect(meta.content).toBe('#ededed')
|
||||
state.change(true)
|
||||
await nextTick()
|
||||
expect(meta.content).toBe('#111111')
|
||||
state.preference.value = 'light'
|
||||
await nextTick()
|
||||
expect(meta.content).toBe('#ededed')
|
||||
} finally {
|
||||
meta.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('无效存储回退系统模式', () => {
|
||||
localStorage.setItem('drama-studio-theme', 'unexpected')
|
||||
const state = setup(true)
|
||||
expect(state.preference.value).toBe('system')
|
||||
expect(state.isDark.value).toBe(true)
|
||||
})
|
||||
|
||||
it('禁用存储仍能切换当前会话的主题', async () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('storage blocked')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('storage blocked')
|
||||
})
|
||||
const state = setup()
|
||||
state.preference.value = 'dark'
|
||||
await nextTick()
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
})
|
||||
})
|
||||
@@ -1,436 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NSelect } from 'naive-ui'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import BreakdownPage from './BreakdownPage.vue'
|
||||
import { projectContextKey, type useProjectData } from '../projects/context'
|
||||
import type { ProjectDetail } from '../projects/types'
|
||||
import type { Checkpoint } from '../workflows/types'
|
||||
import type { BreakdownModule, EpisodePlan } from './types'
|
||||
import { drawerPanel, selectMenu } from '../../testing/naive'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 长主体、长分镜与长任务列表,用真实 Naive 滚动组件验证内容边界。 */
|
||||
function checkpoint(): Checkpoint {
|
||||
const plan: EpisodePlan = {
|
||||
episodeNo: 1,
|
||||
episodeTitle: '长剧集',
|
||||
storyGoal: '',
|
||||
centralConflict: '',
|
||||
emotionalArc: '',
|
||||
pacing: '',
|
||||
endingHook: '',
|
||||
beats: Array.from({ length: 25 }, (_, i) => ({
|
||||
beatNo: i + 1,
|
||||
title: `节拍 ${i + 1}`,
|
||||
purpose: 'action',
|
||||
description: '节拍内容'.repeat(100),
|
||||
visualFocus: '',
|
||||
narrativeGoal: '',
|
||||
emotionalTone: '',
|
||||
estimatedDurationSeconds: 5,
|
||||
subjectRefs: [],
|
||||
isKeyBeat: false
|
||||
}))
|
||||
}
|
||||
return {
|
||||
checkpointId: 'long-breakdown',
|
||||
workflowName: 'breakdown',
|
||||
createdAt: '2026-08-28T00:00:00Z',
|
||||
state: {
|
||||
workflowExecution: { executionId: 'run', status: 'completed', startedAt: '2026-08-28T00:00:00Z' },
|
||||
breakdownResult: {
|
||||
subjectCandidates: (['character', 'scene', 'prop'] as BreakdownModule[]).flatMap(module =>
|
||||
Array.from({ length: 30 }, (_, i) => ({
|
||||
profileId: `${module}-${i}`,
|
||||
name: `${module} 主体 ${i + 1}`,
|
||||
ref: `@${module}${i}`,
|
||||
description: '很长的主体描述。'.repeat(100),
|
||||
module,
|
||||
appearance_prompt: '外观描述'
|
||||
}))
|
||||
),
|
||||
subjectForms: [],
|
||||
storyboardPlans: [plan],
|
||||
storyboardEpisodeShots: [
|
||||
{
|
||||
episodeNo: 1,
|
||||
episodePlan: plan,
|
||||
beatShots: plan.beats.map(beat => ({
|
||||
beatNo: beat.beatNo,
|
||||
shots: [
|
||||
{
|
||||
shotNo: 1,
|
||||
title: `镜头 ${beat.beatNo}`,
|
||||
description: '镜头内容',
|
||||
visualFocus: '',
|
||||
subjectRefs: [],
|
||||
durationSeconds: 5
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
]
|
||||
},
|
||||
tasks: Array.from({ length: 50 }, (_, i) => ({
|
||||
taskId: `task-${i}`,
|
||||
module: 'character',
|
||||
group: {
|
||||
groupId: `group-${i}`,
|
||||
groupNo: i + 1,
|
||||
startEpisodeNo: i + 1,
|
||||
endEpisodeNo: i + 1,
|
||||
episodes: []
|
||||
},
|
||||
status: 'failed',
|
||||
attempt: 1,
|
||||
errorMessage: `任务错误 ${i + 1}`
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 提供响应式 checkpoint,模拟轮询更新但不连接后端或启动模型任务。 */
|
||||
function mountPage(records = [checkpoint()], episodes = 1): ReturnType<typeof useProjectData> {
|
||||
const project: ProjectDetail = {
|
||||
id: 'breakdown-scroll-test',
|
||||
title: '滚动回归',
|
||||
topic: '',
|
||||
style: '',
|
||||
status: 'completed',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
characters: [],
|
||||
world: null,
|
||||
reviews: [],
|
||||
tasks: [],
|
||||
episodes: episodes ? [{ episode: 1, title: '第一集', content: '正文' }] : []
|
||||
}
|
||||
const data = ref({ project, checkpoints: records })
|
||||
const provided: ReturnType<typeof useProjectData> = {
|
||||
data,
|
||||
project: computed(() => data.value.project),
|
||||
checkpoints: computed(() => data.value.checkpoints),
|
||||
loading: ref(false),
|
||||
error: ref(''),
|
||||
updatedAt: ref(''),
|
||||
refresh: vi.fn<() => Promise<void>>().mockResolvedValue()
|
||||
}
|
||||
wrapper = mount(BreakdownPage, {
|
||||
attachTo: document.body,
|
||||
global: { provide: { [projectContextKey as symbol]: provided }, stubs: { RouterLink: true } }
|
||||
})
|
||||
return provided
|
||||
}
|
||||
|
||||
/** 切换真实标签,不直接修改组件内部状态。 */
|
||||
async function selectTab(label: string) {
|
||||
await wrapper!
|
||||
.findAll('.result-toolbar .n-tabs-tab')
|
||||
.find(tab => tab.text().startsWith(label))!
|
||||
.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('拆解页内容滚动', () => {
|
||||
it('拆解设置保留输入标签、单行单位与模块说明,改版后仍校验模块和剧本状态', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const provided = mountPage()
|
||||
await wrapper!.get('[data-workspace-tools]').trigger('click')
|
||||
await flushPromises()
|
||||
const config = drawerPanel().get('.breakdown-config')
|
||||
expect(config.get('label[for="group-size"]').text()).toBe('每组集数')
|
||||
expect(config.get<HTMLInputElement>('#group-size').element.value).toBe('3')
|
||||
expect(config.get('.breakdown-group-unit').text()).toBe('集 / 组')
|
||||
expect(config.get('fieldset > legend').text()).toBe('抽取模块')
|
||||
const options = config.findAll('.breakdown-module-option')
|
||||
expect(options).toHaveLength(3)
|
||||
const preview = config.get<HTMLButtonElement>('.breakdown-preview-button')
|
||||
expect(preview.element.disabled).toBe(false)
|
||||
for (const option of options) {
|
||||
const checkbox = option.get('[role="checkbox"]')
|
||||
const description = option.get('.breakdown-module-description')
|
||||
expect(checkbox.attributes('aria-describedby')).toBe(description.attributes('id'))
|
||||
expect(checkbox.find('.breakdown-module-description').exists()).toBe(false)
|
||||
expect(description.text()).not.toBe('')
|
||||
await checkbox.trigger('click')
|
||||
}
|
||||
expect(preview.element.disabled).toBe(true)
|
||||
expect(drawerPanel().text()).toContain('至少选择一个抽取模块')
|
||||
await options[1]!.get('[role="checkbox"]').trigger('click')
|
||||
expect(preview.element.disabled).toBe(false)
|
||||
provided.data.value!.project.status = 'generating'
|
||||
await flushPromises()
|
||||
expect(preview.element.disabled).toBe(true)
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('拆解配置按抽屉宽度换行,单位不收缩,三类控件对齐且说明位于控件下方', () => {
|
||||
// DOM 环境不计算坐标,检查统一标题偏移、34px 控件行和容器断点契约。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(/\.breakdown-settings-panel\s*\{\s*container:\s*breakdown-settings \/ inline-size;/)
|
||||
expect(css).toMatch(/\.breakdown-config\s*\{[^}]*--breakdown-label-offset:\s*28px;/)
|
||||
expect(css).toMatch(/\.breakdown-config \.field-label\s*\{[^}]*margin:\s*0 0 8px;[^}]*line-height:\s*20px;/)
|
||||
expect(css).toMatch(/\.breakdown-group-unit\s*\{[^}]*flex-shrink:\s*0;[^}]*white-space:\s*nowrap;/)
|
||||
expect(css).toMatch(
|
||||
/\.n-checkbox\.control-row-checkbox\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*34px;/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.breakdown-preview-button\.n-button\s*\{[^}]*align-self:\s*start;[^}]*margin-top:\s*var\(--breakdown-label-offset\);/
|
||||
)
|
||||
expect(css).toContain('@container breakdown-settings (max-width: 780px)')
|
||||
expect(css).toContain('@container breakdown-settings (max-width: 560px)')
|
||||
expect(css).toContain('repeat(auto-fit, minmax(min(100%, 160px), 1fr))')
|
||||
expect(readFileSync('src/styles.css', 'utf8')).not.toContain('.breakdown-config')
|
||||
})
|
||||
|
||||
it.each(['人物', '场景', '道具'])('%s 使用共用固定斑马纹样式,搜索后保持列表条目结构', async label => {
|
||||
mountPage()
|
||||
await selectTab(label)
|
||||
expect(wrapper!.find('.history-panel').exists()).toBe(false)
|
||||
await selectMenu('拆解更多操作', '执行记录')
|
||||
const list = wrapper!.get('.subject-record-list')
|
||||
expect(list.classes()).toContain('record-list')
|
||||
expect(list.findAll(':scope > article')).toHaveLength(30)
|
||||
const toolbar = wrapper!.get('.subject-list-toolbar')
|
||||
expect(toolbar.element.firstElementChild).toBe(toolbar.get('.subject-list-search').element)
|
||||
expect(toolbar.element.lastElementChild).toBe(toolbar.get('.subject-list-count').element)
|
||||
expect(toolbar.get('.n-input__prefix svg').attributes('aria-hidden')).toBe('true')
|
||||
expect(toolbar.find(':scope > svg').exists()).toBe(false)
|
||||
expect(toolbar.get('[role="status"]').text()).toBe('共 30 个主体')
|
||||
const input = toolbar.get('input[aria-label="搜索主体"]')
|
||||
await input.setValue('主体 30')
|
||||
expect(list.findAll(':scope > article')).toHaveLength(1)
|
||||
expect(list.get(':scope > article h3').text()).toContain('主体 30')
|
||||
expect(toolbar.get('[role="status"]').text()).toBe('匹配 1 / 30 个主体')
|
||||
await input.setValue('不存在的主体')
|
||||
expect(toolbar.get('[role="status"]').text()).toBe('匹配 0 / 30 个主体')
|
||||
expect(wrapper!.text()).toContain('没有匹配的主体')
|
||||
await input.setValue('')
|
||||
expect(toolbar.get('[role="status"]').text()).toBe('共 30 个主体')
|
||||
expect(wrapper!.findAll('.subject-record-list > article')).toHaveLength(30)
|
||||
})
|
||||
|
||||
it('主体搜索与统计靠左居中,窄内容区可换行,输入框不再使用外置图标容器', () => {
|
||||
// DOM 环境不计算布局,保护输入框宽度、主题统计色与窄屏换行的样式契约。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.subject-list-toolbar\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;[^}]*align-items:\s*center;[^}]*justify-content:\s*flex-start;/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.subject-list-search\.n-input\s*\{[^}]*flex:\s*0 1 260px;[^}]*min-width:\s*0;[^}]*max-width:\s*100%;/
|
||||
)
|
||||
expect(css).toMatch(/\.subject-list-count\s*\{[^}]*color:\s*var\(--app-muted\);/)
|
||||
expect(css + readFileSync('src/styles.css', 'utf8')).not.toContain('.search-field')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['人物', 'character'],
|
||||
['场景', 'scene'],
|
||||
['道具', 'prop']
|
||||
] as const)('%s 的奇偶主体展开后均使用独立形态块,搜索重排不影响形态归属', async (label, module) => {
|
||||
const record = checkpoint()
|
||||
// 前两个主体各准备两种形态,覆盖奇偶条纹和默认标签,不调用模型接口。
|
||||
record.state.breakdownResult!.subjectForms = [0, 1].flatMap(subjectIndex =>
|
||||
[0, 1].map(formIndex => ({
|
||||
formId: `${module}-${subjectIndex}-form-${formIndex}`,
|
||||
profileId: `${module}-${subjectIndex}`,
|
||||
type: module,
|
||||
name: `主体 ${subjectIndex + 1} 形态 ${formIndex + 1}`,
|
||||
isDefault: formIndex === 0,
|
||||
description: '形态描述',
|
||||
appearancePrompt: '形态提示词'
|
||||
}))
|
||||
)
|
||||
mountPage([record])
|
||||
await selectTab(label)
|
||||
const articles = wrapper!.findAll('.subject-record-list > article')
|
||||
for (const index of [0, 1]) {
|
||||
const article = articles[index]!
|
||||
await article.get('.n-collapse-item__header-main').trigger('click')
|
||||
await flushPromises()
|
||||
const cards = article.findAll('.subject-form-card')
|
||||
expect(cards).toHaveLength(2)
|
||||
expect(cards[0]!.get('.n-tag').text()).toBe('默认')
|
||||
for (const card of cards) {
|
||||
expect(card.classes()).not.toContain('surface-inset')
|
||||
expect(card.text()).toContain(`主体 ${index + 1} 形态`)
|
||||
expect(card.text()).toContain('形态描述')
|
||||
expect(card.text()).toContain('形态提示词')
|
||||
}
|
||||
}
|
||||
// 原偶数主体过滤后成为首项,仍由当前 DOM 的奇偶选择器决定内外层底色。
|
||||
await wrapper!.get('input[aria-label="搜索主体"]').setValue(`@${module}1`)
|
||||
const first = wrapper!.get('.subject-record-list > article')
|
||||
expect(first.findAll('.subject-form-card')).toHaveLength(2)
|
||||
expect(first.get('.subject-form-card').text()).toContain('主体 2 形态 1')
|
||||
})
|
||||
|
||||
it('形态块在两种主体条纹上采用相反灰阶,以间距分组且没有整块悬停态', () => {
|
||||
// DOM 环境不提供真实主题绘制,检查奇偶行底色、内边距和状态选择器契约。
|
||||
const css = readFileSync('src/styles.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.subject-form-card\s*\{[^}]*margin-top:\s*12px;[^}]*padding:\s*16px;[^}]*background:\s*var\(--app-subtle\);/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.subject-record-list > article:nth-child\(even\) \.subject-form-card\s*\{\s*background:\s*var\(--app-control\);/
|
||||
)
|
||||
expect(css).not.toMatch(/\.subject-form-card[^{}]*(?::hover|:focus-within)/)
|
||||
})
|
||||
|
||||
it('主体奇数行常驻原悬停底色,偶数行不变,整行不再随鼠标或焦点变色', () => {
|
||||
// DOM 环境无法模拟浏览器 :hover 命中;检查静态底色与无整行交互选择器的契约。
|
||||
const css = readFileSync('src/styles.css', 'utf8')
|
||||
expect(css).toMatch(/\.record-list > article:nth-child\(even\)\s*\{\s*background:\s*var\(--app-subtle\);/)
|
||||
expect(css).toMatch(
|
||||
/\.subject-record-list > article:nth-child\(odd\)\s*\{\s*background:\s*var\(--app-control\);/
|
||||
)
|
||||
expect(css + readFileSync('src/admin.css', 'utf8')).not.toMatch(
|
||||
/\.subject-record-list[^{}]*(?::hover|:focus-within)/
|
||||
)
|
||||
// 仅移除整行变色,不影响内部链接、折叠等控件的键盘焦点提示。
|
||||
expect(css).toMatch(/:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--color-accent\);/)
|
||||
})
|
||||
|
||||
it('分镜选择与计数、入口同排,默认显示实际剧集,切换后内容和统计同步', async () => {
|
||||
const record = checkpoint()
|
||||
const result = record.state.breakdownResult!
|
||||
const first = result.storyboardPlans![0]!
|
||||
const second = { ...first, episodeNo: 2, episodeTitle: '第二集', beats: first.beats.slice(0, 2) }
|
||||
result.storyboardPlans!.push(second)
|
||||
result.storyboardEpisodeShots!.push({
|
||||
episodeNo: 2,
|
||||
episodePlan: second,
|
||||
beatShots: result.storyboardEpisodeShots![0]!.beatShots.slice(0, 2)
|
||||
})
|
||||
const provided = mountPage([record])
|
||||
await selectTab('分镜')
|
||||
const toolbar = wrapper!.get('.breakdown-storyboard-toolbar')
|
||||
expect(toolbar.get('.breakdown-episode-picker > span').text()).toBe('选择剧集')
|
||||
const select = toolbar.getComponent(NSelect)
|
||||
expect(select.props('value')).toBe(1)
|
||||
expect(toolbar.text()).toContain('长剧集')
|
||||
expect(toolbar.get('.breakdown-storyboard-summary').text()).toContain('25 个节拍 · 25 个镜头')
|
||||
expect(toolbar.get('.breakdown-storyboard-summary router-link-stub').attributes('to')).toBe(
|
||||
'/projects/breakdown-scroll-test/storyboard'
|
||||
)
|
||||
expect(wrapper!.find('.breakdown-storyboard > .surface-inset').exists()).toBe(false)
|
||||
select.vm.$emit('update:value', 2)
|
||||
await flushPromises()
|
||||
expect(select.props('value')).toBe(2)
|
||||
expect(toolbar.text()).toContain('第二集')
|
||||
expect(toolbar.get('.breakdown-storyboard-summary').text()).toContain('2 个节拍 · 2 个镜头')
|
||||
expect(wrapper!.findAll('.beat-section')).toHaveLength(2)
|
||||
// 轮询删除当前选项时,输入框与正文一起回到仍存在的第一集。
|
||||
provided.data.value!.checkpoints = [checkpoint()]
|
||||
await flushPromises()
|
||||
expect(select.props('value')).toBe(1)
|
||||
expect(wrapper!.findAll('.beat-section')).toHaveLength(25)
|
||||
})
|
||||
|
||||
it('分镜为空时保留空提示与进入设计入口,不显示空选择器', async () => {
|
||||
const record = checkpoint()
|
||||
record.state.breakdownResult!.storyboardPlans = []
|
||||
record.state.breakdownResult!.storyboardEpisodeShots = []
|
||||
mountPage([record])
|
||||
await selectTab('分镜')
|
||||
expect(wrapper!.get('.breakdown-storyboard').text()).toContain('分镜规划尚未生成')
|
||||
expect(wrapper!.find('.breakdown-episode-picker').exists()).toBe(false)
|
||||
expect(wrapper!.get('.breakdown-storyboard-summary router-link-stub').attributes('to')).toBe(
|
||||
'/projects/breakdown-scroll-test/storyboard'
|
||||
)
|
||||
})
|
||||
|
||||
it('分镜顶部无叠加操作行,选择标签保持单行且统计操作垂直居中', () => {
|
||||
// 仅保护样式契约,DOM 环境不提供真实布局坐标。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(/\.breakdown-storyboard\s*\{[^}]*padding:\s*16px 20px 20px;/)
|
||||
expect(css).toMatch(/\.breakdown-episode-picker > span\s*\{[^}]*flex-shrink:\s*0;[^}]*white-space:\s*nowrap;/)
|
||||
expect(css).toMatch(/\.breakdown-storyboard-summary\s*\{[^}]*align-items:\s*center;/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['人物', 'character 主体 30'],
|
||||
['场景', 'scene 主体 30'],
|
||||
['道具', 'prop 主体 30'],
|
||||
['分镜', '镜头 25'],
|
||||
['任务明细', '任务错误 50']
|
||||
])('%s 的末项始终放在独立结果滚动容器内', async (label, lastItem) => {
|
||||
mountPage()
|
||||
await selectTab(label)
|
||||
await selectMenu('拆解更多操作', '执行记录')
|
||||
expect(wrapper!.find('.workspace-scroll').exists()).toBe(false)
|
||||
const results = wrapper!.get('.breakdown-results-scroll .n-scrollbar-container')
|
||||
expect(results.text()).toContain(lastItem)
|
||||
expect(results.find('.result-toolbar').exists()).toBe(false)
|
||||
expect(results.find('.history-panel').exists()).toBe(false)
|
||||
expect(wrapper!.get('.history-panel .n-scrollbar-container').text()).toContain('执行记录')
|
||||
expect(wrapper!.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('false')
|
||||
expect(results.find('.table-scroll').exists()).toBe(label === '任务明细')
|
||||
})
|
||||
|
||||
it('刷新和开关配置保留阅读位置,切换标签只重置结果区,不改变执行记录位置', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const provided = mountPage()
|
||||
await selectMenu('拆解更多操作', '执行记录')
|
||||
const results = wrapper!.get<HTMLElement>('.breakdown-results-scroll .n-scrollbar-container').element
|
||||
const history = wrapper!.get<HTMLElement>('.history-panel .n-scrollbar-container').element
|
||||
results.scrollTop = 900
|
||||
history.scrollTop = 120
|
||||
await wrapper!.get('[data-workspace-tools]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(drawerPanel().element.closest('.workspace-split')).toBeNull()
|
||||
await drawerPanel().get('#group-size').setValue('2')
|
||||
await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click')
|
||||
provided.data.value!.checkpoints = [checkpoint()]
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.breakdown-results-scroll .n-scrollbar-container').element).toBe(results)
|
||||
expect(results.scrollTop).toBe(900)
|
||||
await selectTab('场景')
|
||||
const next = wrapper!.get<HTMLElement>('.breakdown-results-scroll .n-scrollbar-container').element
|
||||
expect(next).not.toBe(results)
|
||||
expect(next.scrollTop).toBe(0)
|
||||
expect(wrapper!.get('.history-panel .n-scrollbar-container').element).toBe(history)
|
||||
expect(history.scrollTop).toBe(120)
|
||||
await wrapper!.get('[data-workspace-tools]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(drawerPanel().get<HTMLInputElement>('#group-size').element.value).toBe('2')
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('关闭设置时错误仍可见,详细校验与恢复在面板内,不挤占结果高度', async () => {
|
||||
const record = checkpoint()
|
||||
record.state.workflowExecution!.status = 'failed'
|
||||
record.state.workflowExecution!.errorMessage = '工作流中断'
|
||||
record.state.breakdownResult!.storyboardShotValidation = {
|
||||
valid: false,
|
||||
issues: [{ episodeNo: 1, message: '缺少形态绑定' }]
|
||||
}
|
||||
mountPage([record])
|
||||
const feedback = wrapper!.get('.workspace-feedback')
|
||||
expect(feedback.text()).toContain('工作流中断')
|
||||
expect(feedback.text()).toContain('分镜校验未通过,共 1 项问题')
|
||||
expect(feedback.find('.breakdown-results-section').exists()).toBe(false)
|
||||
await feedback.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(drawerPanel().text()).toContain('缺少形态绑定')
|
||||
expect(drawerPanel().text()).toContain('重试失败抽取')
|
||||
})
|
||||
|
||||
it('没有正式剧集时保留可滚动的空状态,不显示拆解设置与结果', () => {
|
||||
mountPage([], 0)
|
||||
expect(wrapper!.get('.panel-scroll .n-scrollbar-container').text()).toContain('还没有可拆解的剧集')
|
||||
expect(wrapper!.find('.breakdown-results-section').exists()).toBe(false)
|
||||
expect(wrapper!.find('[data-workspace-tools]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -497,3 +497,95 @@ function exportResult() {
|
||||
</template>
|
||||
</WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.group-preview {
|
||||
@apply mt-6 pt-5;
|
||||
}
|
||||
.group-chip {
|
||||
@apply flex items-center gap-2.5 bg-(--app-subtle) rounded-none py-2 px-[11px] text-[11px];
|
||||
}
|
||||
.recovery-strip {
|
||||
@apply bg-(--app-subtle) flex justify-between flex-wrap items-center gap-[15px] p-[17px] rounded-none;
|
||||
}
|
||||
.breakdown-settings-panel {
|
||||
container: breakdown-settings / inline-size;
|
||||
}
|
||||
.breakdown-config {
|
||||
@apply grid grid-cols-[180px_minmax(0,_1fr)_auto] items-start gap-y-4 gap-x-6;
|
||||
--breakdown-label-offset: 28px;
|
||||
}
|
||||
.breakdown-config .field-label {
|
||||
@apply mt-0 mx-0 mb-2 p-0 leading-[20px];
|
||||
}
|
||||
.breakdown-group-field,
|
||||
.breakdown-modules-field,
|
||||
.breakdown-module-option {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.breakdown-group-input {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
.breakdown-group-number.n-input-number {
|
||||
@apply flex-[0_0_120px] w-[120px];
|
||||
}
|
||||
.breakdown-group-unit {
|
||||
@apply shrink-0 whitespace-nowrap text-muted text-sm;
|
||||
}
|
||||
.breakdown-modules-field {
|
||||
@apply m-0 p-0 border-0;
|
||||
}
|
||||
.breakdown-module-options {
|
||||
@apply grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-y-3 gap-x-4;
|
||||
}
|
||||
.breakdown-module-description {
|
||||
@apply mt-1 mx-0 mb-0 pl-6 text-muted text-[11px] leading-[18px] wrap-anywhere;
|
||||
}
|
||||
.breakdown-preview-button.n-button {
|
||||
@apply self-start mt-[var(--breakdown-label-offset)] whitespace-nowrap;
|
||||
}
|
||||
@container breakdown-settings (max-width: 780px) {
|
||||
.breakdown-config {
|
||||
@apply grid-cols-[180px_minmax(0,_1fr)];
|
||||
}
|
||||
.breakdown-preview-button.n-button {
|
||||
@apply col-[2] justify-self-end mt-0;
|
||||
}
|
||||
}
|
||||
@container breakdown-settings (max-width: 560px) {
|
||||
.breakdown-config {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
}
|
||||
.breakdown-module-options {
|
||||
@apply grid-cols-[repeat(auto-fit,_minmax(min(100%,_160px),_1fr))];
|
||||
}
|
||||
.breakdown-preview-button.n-button {
|
||||
@apply col-[1];
|
||||
}
|
||||
}
|
||||
.result-toolbar .n-tabs-tab__label {
|
||||
@apply inline-flex gap-1.5;
|
||||
}
|
||||
.breakdown-results-section {
|
||||
@apply flex flex-col flex-1 min-h-0 min-w-0 gap-2;
|
||||
}
|
||||
.breakdown-results-section > .result-toolbar {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.breakdown-results-section > .content-with-history {
|
||||
@apply flex-1 h-auto min-h-0 grid-rows-[minmax(0,_1fr)];
|
||||
}
|
||||
.breakdown-results-scroll .n-scrollbar-content {
|
||||
@apply wrap-anywhere;
|
||||
}
|
||||
.breakdown-results-section > .content-with-history.history-hidden {
|
||||
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[minmax(0,_1fr)];
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.breakdown-results-section > .content-with-history {
|
||||
@apply grid-rows-[minmax(0,_1fr)_min(25%,_130px)];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -106,3 +106,52 @@ const purposeLabels: Record<string, string> = {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.beat-section {
|
||||
@apply mt-[25px] pt-[23px];
|
||||
}
|
||||
.beat-heading {
|
||||
@apply flex items-center gap-2.5;
|
||||
}
|
||||
.beat-number {
|
||||
@apply text-muted font-mono text-[11px];
|
||||
}
|
||||
.shot-row {
|
||||
@apply flex gap-[17px] p-[17px] rounded-none bg-(--app-subtle);
|
||||
}
|
||||
.shot-label {
|
||||
@apply shrink-0 w-[47px] text-[10px] text-muted;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.shot-row {
|
||||
@apply gap-3 p-[13px];
|
||||
}
|
||||
}
|
||||
.breakdown-storyboard {
|
||||
@apply pt-4 px-5 pb-5;
|
||||
}
|
||||
.breakdown-storyboard-toolbar {
|
||||
@apply flex items-center justify-between flex-wrap gap-y-3 gap-x-6 mb-4;
|
||||
}
|
||||
.breakdown-episode-picker {
|
||||
@apply flex items-center gap-3 flex-[0_1_420px] min-w-0 text-sm;
|
||||
}
|
||||
.breakdown-episode-picker > span {
|
||||
@apply shrink-0 whitespace-nowrap;
|
||||
}
|
||||
.breakdown-episode-picker .n-select {
|
||||
@apply flex-1 min-w-0;
|
||||
}
|
||||
.breakdown-storyboard-summary {
|
||||
@apply flex items-center flex-wrap gap-y-2 gap-x-5 min-h-[34px] ml-auto;
|
||||
}
|
||||
.breakdown-storyboard-count {
|
||||
@apply text-muted text-xs whitespace-nowrap;
|
||||
}
|
||||
.breakdown-storyboard-summary .text-button {
|
||||
@apply min-h-[34px] whitespace-nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -117,3 +117,35 @@ const filtered = computed(() =>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.subject-record-list > article:nth-child(odd) {
|
||||
@apply bg-(--app-control);
|
||||
}
|
||||
.subject-form-card {
|
||||
@apply mt-3 p-4 min-w-0 wrap-anywhere bg-(--app-subtle);
|
||||
}
|
||||
.subject-record-list > article:nth-child(even) .subject-form-card {
|
||||
@apply bg-(--app-control);
|
||||
}
|
||||
.subject-details summary {
|
||||
@apply inline-flex items-center gap-[5px] text-ink text-[11px] list-none;
|
||||
}
|
||||
.subject-details summary::-webkit-details-marker {
|
||||
@apply hidden;
|
||||
}
|
||||
.subject-details[open] summary svg {
|
||||
@apply rotate-180;
|
||||
}
|
||||
.subject-list-toolbar {
|
||||
@apply flex flex-wrap items-center justify-start gap-y-2.5 gap-x-3 mb-5;
|
||||
}
|
||||
.subject-list-search.n-input {
|
||||
@apply flex-[0_1_260px] min-w-0 max-w-full;
|
||||
}
|
||||
.subject-list-count {
|
||||
@apply shrink-0 text-muted text-xs whitespace-nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -249,3 +249,49 @@ function exportScript() {
|
||||
</div></div
|
||||
></WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.stage-strip {
|
||||
@apply flex flex-wrap items-center gap-3.5;
|
||||
}
|
||||
.stage-item {
|
||||
@apply flex items-center gap-[7px] text-muted text-[11px];
|
||||
}
|
||||
.stage-item.done {
|
||||
@apply text-ink;
|
||||
}
|
||||
.stage-arrow {
|
||||
@apply ml-3 text-muted;
|
||||
}
|
||||
.json-view {
|
||||
@apply whitespace-pre-wrap wrap-anywhere p-4 bg-(--app-subtle) rounded-none font-mono text-[11px] leading-[1.9];
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.stage-strip {
|
||||
@apply gap-[9px];
|
||||
}
|
||||
.stage-arrow {
|
||||
@apply ml-[3px];
|
||||
}
|
||||
}
|
||||
.script-section {
|
||||
@apply flex-1 flex flex-col min-h-0;
|
||||
}
|
||||
.script-section > :first-child {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.script-section .content-with-history {
|
||||
@apply flex-1 h-auto min-h-0;
|
||||
}
|
||||
.script-workspace-page .workspace-split > :not(.script-section) {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.script-section .n-tabs-tab__label {
|
||||
@apply inline-flex items-center gap-2;
|
||||
}
|
||||
.script-section .content-with-history.history-hidden {
|
||||
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[minmax(0,_1fr)];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import EpisodeReader from './EpisodeReader.vue'
|
||||
import type { Episode } from '../projects/types'
|
||||
|
||||
const episodes: Episode[] = [
|
||||
{ episode: 1, title: '来信', summary: '第一集摘要', content: '第一场:旧书店。', conflict: '信件失踪' },
|
||||
{ episode: 2, title: '雨夜', content: '<script>不要执行模型内容</script>', hook: '门外有人' },
|
||||
{ episode: 3, title: '重逢', content: '' }
|
||||
]
|
||||
let wrapper: VueWrapper | undefined
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** happy-dom 不计算排版;注入明确的容器几何,但保持真实 NScrollbar 和原生 scroll 事件路径。 */
|
||||
async function mountReader() {
|
||||
wrapper = mount(EpisodeReader, { props: { episodes } })
|
||||
const reader = wrapper.get<HTMLElement>('.reader-scroll .n-scrollbar-container').element
|
||||
const directory = wrapper.get<HTMLElement>('.reader-directory-scroll .n-scrollbar-container').element
|
||||
const tops: Record<number, number> = { 1: 32, 2: 700, 3: 1480 }
|
||||
Object.defineProperty(reader, 'clientHeight', { configurable: true, value: 400 })
|
||||
Object.defineProperty(directory, 'clientHeight', { configurable: true, value: 164 })
|
||||
const originalRect = HTMLElement.prototype.getBoundingClientRect
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this === reader) return new DOMRect(0, 100, 600, reader.clientHeight)
|
||||
if (this === directory) return new DOMRect(0, 50, 200, directory.clientHeight)
|
||||
if (this.matches('.reader-chapter')) {
|
||||
return new DOMRect(0, 100 + tops[Number(this.dataset.episode)]! - reader.scrollTop, 600, 600)
|
||||
}
|
||||
if (this.matches('.reader-episode-link')) {
|
||||
return new DOMRect(0, 58 + (Number(this.dataset.episode) - 1) * 72 - directory.scrollTop, 180, 64)
|
||||
}
|
||||
return originalRect.call(this)
|
||||
})
|
||||
const readerScroll = vi.spyOn(reader, 'scrollTo').mockImplementation((options?: ScrollToOptions | number) => {
|
||||
if (typeof options !== 'object') return
|
||||
// 模拟浏览器边界:末集最小高度及上下留白让其标题能抵达阅读线。
|
||||
reader.scrollTop = Math.max(0, Math.min(options.top ?? 0, Math.max(...Object.values(tops)) - 32))
|
||||
reader.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
const directoryScroll = vi.spyOn(directory, 'scrollTo').mockImplementation((options?: ScrollToOptions | number) => {
|
||||
if (typeof options !== 'object') return
|
||||
directory.scrollTop = Math.max(0, options.top ?? 0)
|
||||
directory.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
await wrapper.setProps({ episodes: [...episodes] })
|
||||
return { reader, directory, tops, readerScroll, directoryScroll }
|
||||
}
|
||||
|
||||
/** 获取目录中的唯一当前集,避免只验证按钮类名而漏掉无障碍状态。 */
|
||||
function currentEpisode() {
|
||||
const current = wrapper!.findAll('.reader-episode-link[aria-current="location"]')
|
||||
expect(current).toHaveLength(1)
|
||||
return Number(current[0]!.attributes('data-episode'))
|
||||
}
|
||||
|
||||
describe('连续剧本阅读器', () => {
|
||||
it('正文容器靠左并保留目录间距,宽屏不再使用自动外边距居中', () => {
|
||||
// DOM 测试不计算布局;保护正文定位样式,目录联动仍由后续交互测试验证。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
const content = css.match(/\.reader-content\s*\{([^}]+)\}/)![1]!
|
||||
expect(content).toContain('max-width: 900px')
|
||||
expect(content).toContain('margin-inline: 0')
|
||||
expect(content).toContain('padding: 32px')
|
||||
expect(content).toContain('text-align: left')
|
||||
expect(content).not.toContain('auto')
|
||||
expect(css).toMatch(/@media \(max-width: 600px\)[\s\S]*?\.reader-content\s*\{\s*padding-inline:\s*18px/)
|
||||
})
|
||||
|
||||
it('按编号连续渲染全部剧集、元数据和空正文,标题不截断,模型内容只按文本显示', () => {
|
||||
const reversed = episodes.toReversed()
|
||||
wrapper = mount(EpisodeReader, { props: { episodes: reversed } })
|
||||
expect(wrapper.findAll('.reader-chapter').map(item => Number(item.attributes('data-episode')))).toEqual([
|
||||
1, 2, 3
|
||||
])
|
||||
expect(reversed[0]?.episode).toBe(3)
|
||||
expect(wrapper.findAll('.reader-chapter .script-body')).toHaveLength(2)
|
||||
expect(wrapper.get('.script-summary').text()).toBe('第一集摘要')
|
||||
expect(wrapper.text()).toContain('信件失踪')
|
||||
expect(wrapper.text()).toContain('门外有人')
|
||||
expect(wrapper.text()).toContain('本集正文尚未写入')
|
||||
expect(wrapper.text()).toContain('<script>不要执行模型内容</script>')
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.n-scrollbar-container')).toHaveLength(2)
|
||||
expect(wrapper.get('.reader-scroll [role="region"]').attributes('tabindex')).toBe('0')
|
||||
expect(wrapper.get('.reader-episode-link').attributes('aria-controls')).toBe(
|
||||
wrapper.get('.reader-chapter').attributes('id')
|
||||
)
|
||||
})
|
||||
|
||||
it('点击目录通过 NScrollbar 定位首集、中间集和短末集,不滚动 window', async () => {
|
||||
const { reader, readerScroll } = await mountReader()
|
||||
const pageScroll = vi.spyOn(window, 'scrollTo')
|
||||
for (const [episode, top] of [
|
||||
[2, 668],
|
||||
[3, 1448],
|
||||
[1, 0]
|
||||
] as const) {
|
||||
await wrapper!.get(`.reader-episode-link[data-episode="${episode}"]`).trigger('click')
|
||||
expect(readerScroll).toHaveBeenLastCalledWith(expect.objectContaining({ top, behavior: 'auto' }))
|
||||
expect(reader.scrollTop).toBe(top)
|
||||
expect(currentEpisode()).toBe(episode)
|
||||
}
|
||||
expect(pageScroll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('正文双向滚动同步当前集,目录项离开可见区时自动露出', async () => {
|
||||
const { reader, directory, directoryScroll } = await mountReader()
|
||||
reader.scrollTop = 1450
|
||||
reader.dispatchEvent(new Event('scroll'))
|
||||
await nextTick()
|
||||
expect(currentEpisode()).toBe(3)
|
||||
expect(directoryScroll).toHaveBeenCalled()
|
||||
expect(directory.scrollTop).toBeGreaterThan(0)
|
||||
reader.scrollTop = 0
|
||||
reader.dispatchEvent(new Event('scroll'))
|
||||
await nextTick()
|
||||
expect(currentEpisode()).toBe(1)
|
||||
expect(directory.scrollTop).toBe(0)
|
||||
})
|
||||
|
||||
it('同集内阅读不反复挪动目录,也不因目录滚动而改变正文', async () => {
|
||||
const { reader, directory, directoryScroll, readerScroll } = await mountReader()
|
||||
directory.scrollTop = 60
|
||||
directory.dispatchEvent(new Event('scroll'))
|
||||
reader.scrollTop = 120
|
||||
reader.dispatchEvent(new Event('scroll'))
|
||||
await nextTick()
|
||||
expect(currentEpisode()).toBe(1)
|
||||
expect(directory.scrollTop).toBe(60)
|
||||
expect(directoryScroll).not.toHaveBeenCalled()
|
||||
expect(readerScroll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('后台刷新与追加剧集不销毁正文或跳回首集,新增集可以定位', async () => {
|
||||
const { reader, tops } = await mountReader()
|
||||
await wrapper!.get('.reader-episode-link[data-episode="2"]').trigger('click')
|
||||
const chapter = wrapper!.get('.reader-chapter[data-episode="2"]').element
|
||||
tops[4] = 2200
|
||||
await wrapper!.setProps({
|
||||
episodes: [...episodes.map(item => ({ ...item })), { episode: 4, title: '回家', content: '结局' }]
|
||||
})
|
||||
expect(reader.scrollTop).toBe(668)
|
||||
expect(currentEpisode()).toBe(2)
|
||||
expect(wrapper!.get('.reader-chapter[data-episode="2"]').element).toBe(chapter)
|
||||
await wrapper!.get('.reader-episode-link[data-episode="4"]').trigger('click')
|
||||
expect(reader.scrollTop).toBe(2168)
|
||||
expect(currentEpisode()).toBe(4)
|
||||
})
|
||||
|
||||
it('内容尺寸和可见性变化重新测量,卸载释放尺寸监听', async () => {
|
||||
const observers: TestObserver[] = []
|
||||
/** 仅模拟尺寸通知;断言仍走真实组件挂载与卸载生命周期。 */
|
||||
class TestObserver {
|
||||
targets = new Set<Element>()
|
||||
constructor(readonly callback: ResizeObserverCallback) {
|
||||
observers.push(this)
|
||||
}
|
||||
observe(target: Element) {
|
||||
this.targets.add(target)
|
||||
}
|
||||
unobserve(target: Element) {
|
||||
this.targets.delete(target)
|
||||
}
|
||||
disconnect = vi.fn<() => void>(() => this.targets.clear())
|
||||
notify() {
|
||||
this.callback([], this as unknown as ResizeObserver)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', TestObserver)
|
||||
const { reader, tops } = await mountReader()
|
||||
const observer = observers.find(item => item.targets.has(wrapper!.get('.reader-content').element))!
|
||||
expect(observer).toBeDefined()
|
||||
await wrapper!.get('.reader-episode-link[data-episode="2"]').trigger('click')
|
||||
Object.defineProperty(reader, 'clientHeight', { configurable: true, value: 0 })
|
||||
observer.notify()
|
||||
await nextTick()
|
||||
expect(currentEpisode()).toBe(2)
|
||||
Object.defineProperty(reader, 'clientHeight', { configurable: true, value: 500 })
|
||||
tops[2] = 500
|
||||
observer.notify()
|
||||
await nextTick()
|
||||
expect(wrapper!.get('.reader-content').attributes('style')).toContain('--reader-height: 500px')
|
||||
await wrapper!.get('.reader-episode-link[data-episode="2"]').trigger('click')
|
||||
expect(reader.scrollTop).toBe(468)
|
||||
wrapper!.unmount()
|
||||
wrapper = undefined
|
||||
expect(observer.disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -166,3 +166,106 @@ onBeforeUnmount(() => observer?.disconnect())
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.script-summary {
|
||||
@apply text-xs text-muted leading-[1.9] pt-[17px] px-0 pb-[22px] mb-[23px];
|
||||
}
|
||||
.script-body {
|
||||
@apply whitespace-pre-wrap wrap-anywhere text-sm leading-[2.15] text-ink;
|
||||
}
|
||||
.script-notes {
|
||||
@apply mt-9 pt-5 text-xs;
|
||||
}
|
||||
.script-notes dt {
|
||||
@apply text-ink font-medium mb-1.5;
|
||||
}
|
||||
.script-notes dd {
|
||||
@apply text-muted leading-[1.8] mb-[15px];
|
||||
}
|
||||
.episode-reader {
|
||||
@apply grid grid-cols-[clamp(190px,_22%,_240px)_minmax(0,_1fr)] h-full min-h-0 min-w-0 overflow-hidden;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.reader-directory {
|
||||
@apply flex flex-col min-h-0 min-w-0 bg-(--app-subtle);
|
||||
}
|
||||
.reader-directory-heading {
|
||||
@apply flex justify-between items-center shrink-0 gap-2 pt-[22px] px-5 pb-3.5 text-[13px];
|
||||
}
|
||||
.reader-directory-heading h3 {
|
||||
@apply font-semibold;
|
||||
}
|
||||
.reader-directory-heading span {
|
||||
@apply text-[11px] text-muted whitespace-nowrap;
|
||||
}
|
||||
.reader-directory-scroll {
|
||||
@apply flex-1;
|
||||
}
|
||||
.reader-directory-content {
|
||||
@apply flex flex-col gap-1.5 pt-1 px-0 pb-[18px];
|
||||
}
|
||||
.n-button.reader-episode-link {
|
||||
@apply shrink-0 w-full h-auto min-h-[64px] py-3 px-3.5 rounded-none text-muted whitespace-normal text-left;
|
||||
}
|
||||
.n-button.reader-episode-link .n-button__content {
|
||||
@apply flex flex-col items-start gap-[5px] w-full min-w-0;
|
||||
}
|
||||
.reader-episode-label {
|
||||
@apply text-[13px] font-semibold leading-normal;
|
||||
}
|
||||
.reader-episode-title {
|
||||
@apply text-xs leading-[1.6] wrap-anywhere;
|
||||
}
|
||||
.n-button.reader-episode-link.active {
|
||||
@apply bg-(--app-selected) text-accent shadow-[inset_3px_0_var(--app-accent)];
|
||||
}
|
||||
.reader-page {
|
||||
@apply min-w-0 min-h-0 overflow-hidden bg-(--app-surface);
|
||||
}
|
||||
.reader-scroll .n-scrollbar-container:focus-visible {
|
||||
@apply outline-[2px_solid_var(--app-accent)] outline-offset-[-3px];
|
||||
}
|
||||
.reader-content {
|
||||
@apply mx-0 p-8 text-left;
|
||||
/* 正文紧邻目录左对齐,保留阅读行宽,不在宽屏内容区居中。 */
|
||||
max-width: 900px;
|
||||
}
|
||||
.reader-chapter + .reader-chapter {
|
||||
@apply mt-12 pt-9;
|
||||
}
|
||||
.reader-chapter:last-child {
|
||||
/* 末集较短时仍可定位到阅读线,不必把倒数第二集误认为当前集。 */
|
||||
min-height: max(160px, calc(var(--reader-height) - 64px));
|
||||
}
|
||||
.reader-chapter-heading {
|
||||
@apply mb-6;
|
||||
}
|
||||
.reader-chapter-heading p {
|
||||
@apply text-muted text-[13px] font-medium mb-2.5;
|
||||
}
|
||||
.reader-chapter-heading h2 {
|
||||
@apply text-[clamp(18px,2cqw,24px)] font-semibold leading-[1.6] wrap-anywhere;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.episode-reader {
|
||||
@apply grid-cols-[180px_minmax(0,_1fr)];
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.episode-reader {
|
||||
@apply grid-cols-[112px_minmax(0,_1fr)];
|
||||
}
|
||||
.reader-directory-heading {
|
||||
@apply pt-4 px-3 pb-3 flex-wrap;
|
||||
}
|
||||
.n-button.reader-episode-link {
|
||||
@apply py-3 px-2.5;
|
||||
}
|
||||
.reader-content {
|
||||
@apply px-[18px];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -619,3 +619,99 @@ watch(
|
||||
/>
|
||||
</WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.production-controls {
|
||||
@apply grid grid-cols-[minmax(220px,_1.6fr)_minmax(110px,_0.5fr)_minmax(230px,_1fr)_auto] items-end gap-[18px];
|
||||
}
|
||||
.production-pipeline {
|
||||
@apply grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-3.5;
|
||||
}
|
||||
.production-pipeline-card {
|
||||
@apply p-5;
|
||||
}
|
||||
.production-stats {
|
||||
@apply grid grid-cols-[repeat(3,_1fr)] gap-2;
|
||||
}
|
||||
.production-stats div,
|
||||
.production-status-grid div {
|
||||
@apply p-2.5 rounded-none bg-(--app-subtle);
|
||||
}
|
||||
.production-stats dt,
|
||||
.production-status-grid dt {
|
||||
@apply text-muted text-[10px];
|
||||
}
|
||||
.production-stats dd,
|
||||
.production-status-grid dd {
|
||||
@apply mt-[5px] text-[13px] font-mono;
|
||||
}
|
||||
.production-status-grid {
|
||||
@apply grid grid-cols-[repeat(5,_minmax(0,_1fr))] gap-2.5;
|
||||
}
|
||||
.production-workspace {
|
||||
@apply grid grid-cols-[220px_minmax(0,_1fr)] items-start overflow-hidden;
|
||||
}
|
||||
.production-shot-list {
|
||||
@apply max-h-[760px] overflow-hidden bg-(--app-surface) py-2;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.production-pipeline {
|
||||
@apply grid-cols-[1fr];
|
||||
}
|
||||
.production-controls {
|
||||
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.production-controls,
|
||||
.production-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
}
|
||||
.production-shot-list {
|
||||
@apply flex max-h-none overflow-hidden;
|
||||
border-right: none;
|
||||
}
|
||||
.production-status-grid {
|
||||
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
|
||||
}
|
||||
}
|
||||
.workspace-inline-status {
|
||||
@apply flex items-center flex-wrap justify-between gap-y-2 gap-x-4 py-[7px] px-3 shrink-0 rounded-none bg-(--app-subtle) text-xs;
|
||||
}
|
||||
.production-pipeline-card > .confirm-action {
|
||||
@apply mt-4 mb-1;
|
||||
}
|
||||
.production-workspace {
|
||||
@apply flex-1 min-h-0 items-stretch overflow-hidden;
|
||||
}
|
||||
.production-workspace {
|
||||
@apply grid-cols-[clamp(240px,_22%,_280px)_minmax(0,_1fr)];
|
||||
}
|
||||
.production-shot-list {
|
||||
@apply max-h-none overflow-hidden min-h-0;
|
||||
}
|
||||
.production-workspace > article {
|
||||
@apply overflow-hidden min-h-0 overscroll-contain;
|
||||
}
|
||||
.production-shot-list-content {
|
||||
@apply gap-3 pt-0 px-0 pb-3;
|
||||
}
|
||||
.production-shot-list {
|
||||
@apply p-0;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.production-controls {
|
||||
@apply grid-cols-[minmax(130px,_1fr)_90px];
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.production-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[184px_minmax(0,_1fr)];
|
||||
}
|
||||
.production-pipeline {
|
||||
@apply grid-cols-[1fr];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { projectContextKey } from '../projects/context'
|
||||
import { testProjectContext } from '../../testing/project-context'
|
||||
import { getOperation } from '../workflows/operations'
|
||||
import { formFixture } from '../subject-images/testing/fixtures'
|
||||
import { directionsResult } from '../storyboard/testing/fixtures'
|
||||
import { checkPipeline, useAdvancedProduction } from './useAdvancedProduction'
|
||||
import { useProduction } from './useProduction'
|
||||
import { getProductionSession } from './model'
|
||||
import AdvancedProduction from './components/AdvancedProduction.vue'
|
||||
import { expandSections } from '../../testing/naive'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
/** 从实际挂载的确认弹窗查找操作按钮。 */
|
||||
function button(label: string) {
|
||||
return [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)!
|
||||
}
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
for (const id of ['capability-test', 'new-project']) {
|
||||
Object.assign(getOperation(id), { pending: false, error: '', notice: '', label: '' })
|
||||
Object.assign(getProductionSession(id), { receipt: null, pipelineReceipt: null })
|
||||
}
|
||||
})
|
||||
|
||||
/** 所有预检均是模拟 GET;生成接口只记录契约,不调用真实 Provider。 */
|
||||
function server() {
|
||||
const context = testProjectContext()
|
||||
const form = formFixture('capability-test')
|
||||
const readiness = {
|
||||
total: 1,
|
||||
ready: 1,
|
||||
skipped: 0,
|
||||
blocked: 0,
|
||||
inProgress: 0,
|
||||
stalePrimaryKeyframe: 0,
|
||||
items: [
|
||||
{
|
||||
shotId: 'shot-1',
|
||||
shotNo: 1,
|
||||
episodeNo: 1,
|
||||
beatNo: 1,
|
||||
status: 'ready',
|
||||
issues: [] as { code: string; reason: string }[],
|
||||
primaryKeyframeId: 'keyframe-1',
|
||||
primaryKeyframeStale: false
|
||||
}
|
||||
]
|
||||
}
|
||||
const receipt = {
|
||||
projectId: 'capability-test',
|
||||
completed: true,
|
||||
needsManualReview: false,
|
||||
stopReason: '',
|
||||
errors: ['一个提示词未生成'],
|
||||
videoGenerationResult: { total: 1, created: 1, skipped: 0, failed: 0 }
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
const path = String(url)
|
||||
let data: unknown = context.project.value
|
||||
if (path.endsWith('/checkpoints')) data = context.checkpoints.value
|
||||
else if (path.endsWith('/subject-forms')) data = [form]
|
||||
else if (path.includes('/readiness')) data = readiness
|
||||
else if (path.includes('/storyboard-directions')) data = directionsResult('capability-test')
|
||||
else if (path.endsWith('/videos/status'))
|
||||
data = {
|
||||
total: 1,
|
||||
completed: 0,
|
||||
queued: 0,
|
||||
running: 0,
|
||||
pending: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
notStarted: 1,
|
||||
items: []
|
||||
}
|
||||
else if (init?.method === 'POST')
|
||||
data = path.endsWith('/production/start')
|
||||
? receipt
|
||||
: { total: 1, targetCount: 1, generated: 1, skipped: 0, blocked: 0, failed: 0, failures: [] }
|
||||
return new Response(JSON.stringify({ data }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
return {
|
||||
context,
|
||||
form,
|
||||
readiness,
|
||||
receipt,
|
||||
fetcher,
|
||||
posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||||
}
|
||||
}
|
||||
|
||||
async function advanced() {
|
||||
const data = server()
|
||||
let service!: ReturnType<typeof useAdvancedProduction>
|
||||
wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
service = useAdvancedProduction()
|
||||
return () => null
|
||||
}
|
||||
}),
|
||||
{ global: { provide: { [projectContextKey as symbol]: data.context } } }
|
||||
)
|
||||
await flushPromises()
|
||||
return { ...data, service }
|
||||
}
|
||||
|
||||
describe('高级串联生产的安全边界', () => {
|
||||
it('挂载不查询或生成,通过预检仍需显式启动,再次预检后提交固定 Provider', async () => {
|
||||
const { service, fetcher, posts } = await advanced()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
await service.start()
|
||||
expect(posts()).toHaveLength(0)
|
||||
await service.preflight()
|
||||
expect(service.check.value?.issues).toEqual([])
|
||||
expect(posts()).toHaveLength(0)
|
||||
await service.start()
|
||||
expect(posts()).toHaveLength(1)
|
||||
expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/production/start')
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({})
|
||||
expect(
|
||||
fetcher.mock.calls.filter(([url]) => String(url).includes('/keyframes/readiness?force=true'))
|
||||
).toHaveLength(2)
|
||||
expect(service.session.value.pipelineReceipt?.errors).toEqual(['一个提示词未生成'])
|
||||
expect(service.check.value).toBeNull()
|
||||
})
|
||||
it('预检通过后主首帧变旧,确认时再次预检会拦截,不提交', async () => {
|
||||
const { service, readiness, posts } = await advanced()
|
||||
await service.preflight()
|
||||
readiness.items[0]!.primaryKeyframeStale = true
|
||||
await service.start()
|
||||
expect(posts()).toHaveLength(0)
|
||||
expect(service.check.value?.issues.join()).toContain('有效主首帧')
|
||||
expect(getOperation('capability-test').error).toContain('条件变化')
|
||||
})
|
||||
it('后台活动视频、缺失主图与剧本未完成均阻止预检通过', async () => {
|
||||
const { context, form, readiness } = server()
|
||||
context.data.value!.project.status = 'need_review'
|
||||
form.images = []
|
||||
readiness.inProgress = 1
|
||||
const result = await checkPipeline('capability-test')
|
||||
expect(result.issues.join()).toContain('剧本尚未完成')
|
||||
expect(result.issues.join()).toContain('所有形态主图')
|
||||
expect(result.issues.join()).toContain('活动视频任务')
|
||||
})
|
||||
it('已有视频的 skipped 也不能掩盖缺失首帧,只有缺少提示词允许本流程补齐', async () => {
|
||||
const { readiness } = server()
|
||||
readiness.items[0]!.status = 'skipped'
|
||||
readiness.items[0]!.issues = [{ code: 'missing_keyframe', reason: '无首帧' }]
|
||||
const result = await checkPipeline('capability-test')
|
||||
expect(result.issues.join()).toContain('视频前置检查未通过')
|
||||
})
|
||||
it('项目锁和未完成剧本下不启动,切项目清空旧预检', async () => {
|
||||
const { service, context, posts } = await advanced()
|
||||
await service.preflight()
|
||||
getOperation('capability-test').pending = true
|
||||
await service.start()
|
||||
expect(posts()).toHaveLength(0)
|
||||
getOperation('capability-test').pending = false
|
||||
context.data.value!.project.status = 'failed'
|
||||
await service.start()
|
||||
expect(posts()).toHaveLength(0)
|
||||
context.data.value!.project.id = 'new-project'
|
||||
await flushPromises()
|
||||
expect(service.check.value).toBeNull()
|
||||
})
|
||||
it('已提交的长请求只写回原项目的回执,不污染新项目', async () => {
|
||||
const { service, context, fetcher, receipt } = await advanced()
|
||||
await service.preflight()
|
||||
const original = fetcher.getMockImplementation()!
|
||||
let finish!: (value: Response) => void
|
||||
fetcher.mockImplementation((url, init) =>
|
||||
String(url).endsWith('/production/start')
|
||||
? new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
: original(url, init)
|
||||
)
|
||||
const pending = service.start()
|
||||
await flushPromises()
|
||||
context.data.value!.project.id = 'new-project'
|
||||
await flushPromises()
|
||||
finish(new Response(JSON.stringify({ data: receipt })))
|
||||
await pending
|
||||
expect(service.session.value.pipelineReceipt).toBeNull()
|
||||
expect(getProductionSession('capability-test').pipelineReceipt?.projectId).toBe('capability-test')
|
||||
})
|
||||
it('回执项目不匹配时不发布成功回执', async () => {
|
||||
const { service, receipt } = await advanced()
|
||||
await service.preflight()
|
||||
receipt.projectId = 'wrong'
|
||||
await service.start()
|
||||
expect(service.session.value.pipelineReceipt).toBeNull()
|
||||
expect(getOperation('capability-test').error).toContain('回执项目不匹配')
|
||||
})
|
||||
it('界面区分流程返回与视频完成,显示部分错误,启动需要费用确认', async () => {
|
||||
const { context, receipt, posts } = server()
|
||||
wrapper = mount(AdvancedProduction, {
|
||||
attachTo: document.body,
|
||||
global: { provide: { [projectContextKey as symbol]: context } }
|
||||
})
|
||||
await flushPromises()
|
||||
await expandSections()
|
||||
expect(button('启动串联生产').disabled).toBe(true)
|
||||
button('检查串联生产条件').click()
|
||||
await flushPromises()
|
||||
button('启动串联生产').click()
|
||||
await flushPromises()
|
||||
expect(button('确认启动串联生产').disabled).toBe(true)
|
||||
expect(posts()).toHaveLength(0)
|
||||
document.querySelector<HTMLElement>('.app-dialog [role="checkbox"]')!.click()
|
||||
await flushPromises()
|
||||
button('确认启动串联生产').click()
|
||||
await flushPromises()
|
||||
expect(posts()).toHaveLength(1)
|
||||
expect(document.body.textContent).toContain('流程返回不等于成片完成')
|
||||
expect(document.body.textContent).toContain(receipt.errors[0])
|
||||
expect(document.body.textContent).toContain('已提交 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('首帧批量参数', () => {
|
||||
it('只向首帧传上限和成对尺寸,默认范围仍是当前剧集', async () => {
|
||||
const { context, posts } = server()
|
||||
let service!: ReturnType<typeof useProduction>
|
||||
wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
service = useProduction()
|
||||
return () => null
|
||||
}
|
||||
}),
|
||||
{ global: { provide: { [projectContextKey as symbol]: context } } }
|
||||
)
|
||||
await flushPromises()
|
||||
service.keyframeLimit.value = 2
|
||||
service.keyframeWidth.value = 2048
|
||||
await service.run('keyframes')
|
||||
expect(posts()).toHaveLength(0)
|
||||
service.keyframeHeight.value = 2048
|
||||
await service.run('keyframes')
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
|
||||
concurrency: 2,
|
||||
force: false,
|
||||
episodeNo: 1,
|
||||
limit: 2,
|
||||
width: 2048,
|
||||
height: 2048
|
||||
})
|
||||
service.keyframeLimit.value = -1
|
||||
await service.run('keyframes')
|
||||
expect(posts()).toHaveLength(1)
|
||||
await service.run('prompts')
|
||||
expect(JSON.parse(String(posts()[1]?.[1]?.body))).toEqual({ concurrency: 2, force: false })
|
||||
service.keyframeLimit.value = ''
|
||||
service.keyframeWidth.value = ''
|
||||
service.keyframeHeight.value = ''
|
||||
service.force.value = true
|
||||
await flushPromises()
|
||||
await service.run('keyframes')
|
||||
expect(JSON.parse(String(posts()[2]?.[1]?.body))).toEqual({ concurrency: 2, force: true })
|
||||
})
|
||||
})
|
||||
@@ -259,3 +259,11 @@ async function submit() {
|
||||
</p>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.quality-fields {
|
||||
@apply grid grid-cols-[repeat(auto-fit,_minmax(min(100%,_180px),_1fr))] items-start gap-4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -140,3 +140,17 @@ function exportResult() {
|
||||
</NCollapse>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.quality-result {
|
||||
@apply mt-6 p-4 bg-(--app-subtle);
|
||||
}
|
||||
.quality-result-row {
|
||||
@apply flex flex-wrap items-center gap-y-2 gap-x-3 mt-2.5 p-3 bg-(--app-control) text-xs wrap-anywhere;
|
||||
}
|
||||
.quality-json {
|
||||
@apply mt-4 whitespace-pre-wrap wrap-anywhere text-[11px] leading-[1.7];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -523,3 +523,46 @@ async function inspectSpecs() {
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.production-stage {
|
||||
@apply py-5 px-0;
|
||||
}
|
||||
.production-stage:first-of-type {
|
||||
@apply border-t-0;
|
||||
}
|
||||
.production-stage-heading {
|
||||
@apply flex items-start justify-between gap-4;
|
||||
}
|
||||
.keyframe-grid {
|
||||
@apply grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-3;
|
||||
}
|
||||
.keyframe-card {
|
||||
@apply overflow-hidden rounded-none bg-(--app-subtle);
|
||||
}
|
||||
.keyframe-card .asset-image {
|
||||
@apply aspect-video rounded-none;
|
||||
}
|
||||
.video-record {
|
||||
@apply flex overflow-hidden rounded-none bg-(--app-subtle);
|
||||
}
|
||||
.production-video {
|
||||
@apply w-[min(44%,390px)] min-h-[170px] bg-ink object-contain;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.keyframe-grid {
|
||||
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
|
||||
}
|
||||
.video-record {
|
||||
@apply block;
|
||||
}
|
||||
.production-video {
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
.production-video {
|
||||
@apply bg-[#080808];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mediaAssetUrl } from '../../lib/assets'
|
||||
import KeyframeDialog from './components/KeyframeDialog.vue'
|
||||
import { productionApi } from './api'
|
||||
import {
|
||||
isActiveVideo,
|
||||
issueLabel,
|
||||
primaryKeyframe,
|
||||
primaryVideo,
|
||||
productionStatusLabel,
|
||||
validOptionalSize
|
||||
} from './model'
|
||||
import { keyframeFixture, videoFixture } from './testing/fixtures'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 从 Naive 弹窗中查找精确按钮。 */
|
||||
function button(label: string): HTMLButtonElement {
|
||||
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
|
||||
if (!item) throw new Error(`缺少按钮 ${label}`)
|
||||
return item
|
||||
}
|
||||
|
||||
describe('镜头生产数据契约', () => {
|
||||
it('可选尺寸必须成对留空或填写正整数', () => {
|
||||
expect(validOptionalSize('', '')).toBe(true)
|
||||
expect(validOptionalSize(1920, 1080)).toBe(true)
|
||||
expect(validOptionalSize(1920, '')).toBe(false)
|
||||
expect(validOptionalSize('', 1080)).toBe(false)
|
||||
expect(validOptionalSize(0, 1080)).toBe(false)
|
||||
expect(validOptionalSize(10.5, 1080)).toBe(false)
|
||||
})
|
||||
|
||||
it('主资产只接受已完成且具有地址的记录,活动视频覆盖三种状态', () => {
|
||||
expect(primaryKeyframe([keyframeFixture()])?.id).toBe('keyframe-1')
|
||||
expect(primaryKeyframe([keyframeFixture({ status: 'failed' })])).toBeUndefined()
|
||||
expect(primaryVideo([videoFixture()])?.id).toBe('video-1')
|
||||
expect(primaryVideo([videoFixture({ videoUrl: null })])).toBeUndefined()
|
||||
for (const status of ['pending', 'queued', 'running'] as const)
|
||||
expect(isActiveVideo(videoFixture({ status }))).toBe(true)
|
||||
expect(isActiveVideo(videoFixture())).toBe(false)
|
||||
})
|
||||
|
||||
it('就绪问题和异步任务状态提供中文标签', () => {
|
||||
expect(issueLabel('missing_keyframe')).toBe('缺少主首帧')
|
||||
expect(issueLabel('invalid_generation_spec')).toBe('生成规格不完整')
|
||||
expect(issueLabel('missing_identity_anchor')).toBe('缺少演员母版')
|
||||
expect(issueLabel('identity_unlocked')).toBe('演员身份未锁定')
|
||||
expect(productionStatusLabel('in_progress')).toBe('任务进行中')
|
||||
expect(productionStatusLabel('unknown')).toBe('unknown')
|
||||
})
|
||||
|
||||
it('视频地址与图片使用相同的安全协议限制', () => {
|
||||
expect(mediaAssetUrl('/storage/videos/a.mp4')).toContain('/storage/videos/a.mp4')
|
||||
expect(mediaAssetUrl('https://cdn.example.com/a.mp4')).toBe('https://cdn.example.com/a.mp4')
|
||||
expect(mediaAssetUrl('javascript:alert(1)')).toBeNull()
|
||||
expect(mediaAssetUrl('/storage/../admin')).toBeNull()
|
||||
})
|
||||
|
||||
it('项目接口传递 force、Provider 与并发,视频创建不冒充同步完成', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
|
||||
const path = String(url)
|
||||
if (path.includes('/readiness'))
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
total: 1,
|
||||
ready: 1,
|
||||
skipped: 0,
|
||||
inProgress: 0,
|
||||
blocked: 0,
|
||||
missingPrompt: 0,
|
||||
missingKeyframe: 0,
|
||||
missingReference: 0,
|
||||
items: []
|
||||
}
|
||||
})
|
||||
)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
total: 1,
|
||||
targetCount: 1,
|
||||
created: 1,
|
||||
skipped: 0,
|
||||
readiness: {},
|
||||
failed: 0,
|
||||
failures: []
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await productionApi.videoReadiness('project/1', true)
|
||||
await productionApi.generateVideos('project/1', { concurrency: 3, force: true })
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/projects/project%2F1/videos/readiness?force=true')
|
||||
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/projects/project%2F1/videos/generate')
|
||||
expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({
|
||||
concurrency: 3,
|
||||
force: true
|
||||
})
|
||||
})
|
||||
|
||||
it('首个首帧默认设主图,已有主图时默认只新增候选,并要求费用确认', async () => {
|
||||
wrapper = mount(KeyframeDialog, {
|
||||
attachTo: document.body,
|
||||
props: { open: true, shotId: 'shot-1', shotTitle: '开场', keyframes: [], disabled: false }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(button('确认生成首帧').disabled).toBe(true)
|
||||
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成首帧').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')).toEqual([[{ setPrimary: true }]])
|
||||
|
||||
await wrapper.setProps({ open: false })
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ open: true, shotId: 'shot-2', keyframes: [keyframeFixture({ shotId: 'shot-2' })] })
|
||||
await flushPromises()
|
||||
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成首帧').click()
|
||||
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }])
|
||||
})
|
||||
})
|
||||
@@ -1,515 +0,0 @@
|
||||
import { defineComponent, reactive, ref } from 'vue'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NCheckbox, NInputNumber, NRadioGroup } from 'naive-ui'
|
||||
import { testProjectContext } from '../../testing/project-context'
|
||||
import { productionApi } from './api'
|
||||
import { qualityApi } from './quality-api'
|
||||
import {
|
||||
allowedTextLines,
|
||||
qualityKey,
|
||||
qualitySession,
|
||||
qualityTargets,
|
||||
savedVideoValidation,
|
||||
videoRepairInfo,
|
||||
validQualityInput
|
||||
} from './quality'
|
||||
import type { KeyframeReadiness } from './types'
|
||||
import type { QualityInput, QualityTarget } from './quality.types'
|
||||
import { useQuality } from './useQuality'
|
||||
import { keyframeFixture, videoFixture } from './testing/fixtures'
|
||||
import QualityDialog from './components/QualityDialog.vue'
|
||||
import QualityResult from './components/QualityResult.vue'
|
||||
import ModelCapabilities from './components/ModelCapabilities.vue'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
let sequence = 0
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 模拟统一配置后的真实契约,测试绝不调用真实图片或视觉模型。 */
|
||||
function server() {
|
||||
const projectId = `quality-test-${sequence++}`
|
||||
const project = testProjectContext(projectId).project.value!
|
||||
const validation = {
|
||||
passed: true,
|
||||
summary: '身份与造型一致',
|
||||
subjects: [],
|
||||
subjectCountConsistent: true,
|
||||
unauthorizedText: { detected: false, texts: [] },
|
||||
issues: []
|
||||
}
|
||||
const keyframe = keyframeFixture()
|
||||
const video = videoFixture()
|
||||
const readiness = {
|
||||
total: 1,
|
||||
ready: 1,
|
||||
skipped: 0,
|
||||
blocked: 0,
|
||||
stalePrimaryKeyframe: 0,
|
||||
missingVisualStyle: 0,
|
||||
missingIdentity: 0,
|
||||
missingIdentityAnchor: 0,
|
||||
identityUnlocked: 0,
|
||||
invalidGenerationSpec: 0,
|
||||
missingReference: 0,
|
||||
items: [{ shotId: 'shot-1', shotNo: 1, beatNo: 1, episodeNo: 1, status: 'ready', issues: [] }]
|
||||
} as KeyframeReadiness
|
||||
const capability = {
|
||||
provider: 'qwen-image',
|
||||
referenceCount: 3,
|
||||
maxReferenceImages: 3,
|
||||
valid: true,
|
||||
message: '参考图超限'
|
||||
}
|
||||
const keyframeResult = {
|
||||
...validation,
|
||||
shotId: 'shot-1',
|
||||
keyframeId: 'keyframe-1',
|
||||
validationTaskId: 'validation-1',
|
||||
isPrimary: true,
|
||||
imageUrl: keyframe.imageUrl!
|
||||
}
|
||||
const attempt = {
|
||||
attempt: 0,
|
||||
keyframeId: 'keyframe-1',
|
||||
validationTaskId: 'validation-1',
|
||||
passed: true,
|
||||
issues: [],
|
||||
validationDurationMs: 10
|
||||
}
|
||||
const repair = {
|
||||
shotId: 'shot-1',
|
||||
initialKeyframeId: 'keyframe-1',
|
||||
finalKeyframeId: 'keyframe-1',
|
||||
passed: true,
|
||||
repaired: false,
|
||||
primaryChanged: false,
|
||||
repairAttempts: 0,
|
||||
maxRepairAttempts: 1,
|
||||
attempts: [attempt]
|
||||
}
|
||||
const batch = {
|
||||
total: 1,
|
||||
ready: 1,
|
||||
selected: 1,
|
||||
targetCount: 1,
|
||||
passed: 1,
|
||||
repairFailed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
blocked: 0,
|
||||
stalePrimaryKeyframe: 0,
|
||||
missingReference: 0,
|
||||
results: [{ shotId: 'shot-1', success: true, status: 'passed' as const }]
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, options) => {
|
||||
const path = String(url)
|
||||
let data: unknown
|
||||
if (options?.method === 'POST') {
|
||||
if (path.endsWith('/generate-quality')) data = batch
|
||||
else if (path.includes('/videos/') && path.endsWith('/repair'))
|
||||
data = {
|
||||
shotId: 'shot-1',
|
||||
sourceVideoId: 'video-1',
|
||||
repairAttempt: 1,
|
||||
maxRepairAttempts: 2,
|
||||
repairInstructions: ['修复人物漂移'],
|
||||
allowedTexts: [],
|
||||
candidate: videoFixture({
|
||||
id: 'video-repair-1',
|
||||
status: 'queued',
|
||||
isPrimary: false,
|
||||
videoUrl: null,
|
||||
rawJson: JSON.stringify({ repair: { sourceVideoId: 'video-1', attempt: 1 } })
|
||||
})
|
||||
}
|
||||
else if (path.endsWith('/repair')) data = repair
|
||||
else if (path.includes('/videos/'))
|
||||
data = {
|
||||
...validation,
|
||||
shotId: 'shot-1',
|
||||
videoId: 'video-1',
|
||||
validatedAt: '2026-09-03',
|
||||
isPrimary: validation.passed && !!videoRepairInfo(video.rawJson),
|
||||
videoUrl: video.videoUrl,
|
||||
sampleFrames: [{ label: '中间', timeSeconds: 2 }],
|
||||
allowedTexts: []
|
||||
}
|
||||
else data = keyframeResult
|
||||
} else if (path.endsWith(`/projects/${projectId}`)) data = project
|
||||
else if (path.includes('/readiness')) data = readiness
|
||||
else if (path.endsWith('/keyframes')) data = [keyframe]
|
||||
else if (path.endsWith('/videos')) data = [video]
|
||||
else if (path.endsWith('/keyframe-provider-capability')) data = capability
|
||||
else if (path.endsWith('/image-providers/capabilities'))
|
||||
data = [
|
||||
{
|
||||
provider: 'qwen-image',
|
||||
active: true,
|
||||
capabilities: { references: { supported: true, maxReferenceImages: 3 } }
|
||||
}
|
||||
]
|
||||
else throw new Error('意外接口:' + path)
|
||||
return new Response(JSON.stringify({ data }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
return {
|
||||
projectId,
|
||||
project,
|
||||
validation,
|
||||
keyframe,
|
||||
video,
|
||||
readiness,
|
||||
capability,
|
||||
keyframeResult,
|
||||
repair,
|
||||
batch,
|
||||
fetcher,
|
||||
posts: () => fetcher.mock.calls.filter(([, options]) => options?.method === 'POST')
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一默认小批次,零修复和自定义允许文字均可单独覆写。 */
|
||||
function input(patch: Partial<QualityInput> = {}): QualityInput {
|
||||
return { concurrency: 1, limit: 1, episodeNo: 1, maxRepairAttempts: 1, force: false, allowedTexts: [], ...patch }
|
||||
}
|
||||
|
||||
/** 通过实际挂载按钮检验费用确认,避免绕过禁用状态。 */
|
||||
function validationButton() {
|
||||
return [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
item => item.textContent === '开始视觉校验'
|
||||
)!
|
||||
}
|
||||
|
||||
function setup(target: QualityTarget = { kind: 'keyframe', shotId: 'shot-1', assetId: 'keyframe-1', title: '镜头一' }) {
|
||||
const data = server()
|
||||
const props = reactive({ projectId: data.projectId, target: target as QualityTarget | null, disabled: false })
|
||||
const visible = ref(true)
|
||||
const changed = vi.fn<() => void>()
|
||||
let service!: ReturnType<typeof useQuality>
|
||||
wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
service = useQuality(props, () => visible.value, changed)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
return { ...data, props, visible, changed, service }
|
||||
}
|
||||
|
||||
describe('视觉质量契约与付费边界', () => {
|
||||
it('打开面板不发模型请求,确认后仅校验指定首帧', async () => {
|
||||
const data = setup()
|
||||
expect(data.fetcher).not.toHaveBeenCalled()
|
||||
await data.service.run('validate', input({ allowedTexts: ['记忆当铺'] }))
|
||||
expect(data.posts()).toHaveLength(1)
|
||||
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/keyframes/keyframe-1/validate')
|
||||
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ allowedTexts: ['记忆当铺'] })
|
||||
expect(data.service.session.value.receipt).toMatchObject({ kind: 'keyframe', result: { passed: true } })
|
||||
})
|
||||
|
||||
it.each(['generating', 'failed', 'need_review'] as const)('后端项目状态为 %s 时不发付费请求', async status => {
|
||||
const data = setup()
|
||||
data.project.status = status
|
||||
await data.service.run('validate', input())
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
expect(data.service.session.value.error).toContain('剧本未完成')
|
||||
})
|
||||
|
||||
it('目标不属于项目时不查询目标素材,不生成', async () => {
|
||||
const data = setup({ kind: 'keyframe', shotId: 'foreign-shot', assetId: 'foreign-image', title: '外部' })
|
||||
await data.service.run('validate', input())
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
expect(data.fetcher.mock.calls.some(([url]) => String(url).includes('foreign-shot'))).toBe(false)
|
||||
})
|
||||
|
||||
it('畸形视觉结果不能当作校验通过,也不会自动重试', async () => {
|
||||
const data = setup()
|
||||
vi.spyOn(qualityApi, 'validateKeyframe').mockResolvedValueOnce({
|
||||
...data.keyframeResult,
|
||||
passed: undefined
|
||||
} as never)
|
||||
await data.service.run('validate', input())
|
||||
expect(data.service.session.value.receipt).toBeNull()
|
||||
expect(data.service.session.value.error).toContain('不完整')
|
||||
expect(qualityApi.validateKeyframe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('0 次修复保留语义;不会额外调用普通生图或切换主图接口', async () => {
|
||||
const data = setup()
|
||||
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
|
||||
expect(data.posts()).toHaveLength(1)
|
||||
expect(data.posts()[0]?.[0]).toContain('/repair')
|
||||
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ maxRepairAttempts: 0, allowedTexts: [] })
|
||||
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
|
||||
expect(data.service.session.value.receipt).toMatchObject({ kind: 'repair', result: { primaryChanged: false } })
|
||||
})
|
||||
|
||||
it('模型参考图能力不足时阻止修复与质量批次', async () => {
|
||||
const data = setup()
|
||||
data.capability.valid = false
|
||||
await data.service.run('repair', input())
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
expect(data.service.session.value.error).toContain('参考图超限')
|
||||
data.props.target = { kind: 'batch', title: '第一集', episodeNo: 1 }
|
||||
await data.service.run('batch', input())
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('视频需完成并有有效时长,校验不自动切换主视频', async () => {
|
||||
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
|
||||
data.video.durationSeconds = null
|
||||
await data.service.run('validate', input())
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
data.video.durationSeconds = 5
|
||||
await data.service.run('validate', input())
|
||||
expect(data.posts()).toHaveLength(1)
|
||||
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/videos/video-1/validate')
|
||||
expect(data.service.session.value.receipt).toMatchObject({
|
||||
kind: 'video',
|
||||
result: { sampleFrames: [{ timeSeconds: 2 }] }
|
||||
})
|
||||
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
|
||||
})
|
||||
|
||||
it('批量范围、上限、尺寸原样传递,不发送 Provider;部分失败仍显示待处理', async () => {
|
||||
const data = setup({ kind: 'batch', episodeNo: 1, title: '第一集' })
|
||||
Object.assign(data.batch, {
|
||||
passed: 0,
|
||||
repairFailed: 1,
|
||||
results: [{ shotId: 'shot-1', success: false, status: 'repair_failed', error: '人物不一致' }]
|
||||
})
|
||||
await data.service.run('batch', input({ width: 1536, height: 1024 }))
|
||||
expect(data.posts()).toHaveLength(1)
|
||||
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual(input({ width: 1536, height: 1024 }))
|
||||
const receipt = data.service.session.value.receipt!
|
||||
wrapper!.unmount()
|
||||
wrapper = mount(QualityResult, { props: { receipt } })
|
||||
expect(wrapper.text()).toContain('仍需处理')
|
||||
expect(wrapper.text()).toContain('人物不一致')
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find(button => button.text() === '定位镜头')!
|
||||
.trigger('click')
|
||||
expect(wrapper.emitted('locate')).toEqual([['shot-1']])
|
||||
})
|
||||
|
||||
it('过期优先级与后端一致,当前集无过期项时不误补其他镜头', async () => {
|
||||
const data = setup({ kind: 'batch', episodeNo: 1, title: '第一集' })
|
||||
data.readiness.stalePrimaryKeyframe = 1
|
||||
expect(qualityTargets(data.readiness, input())).toHaveLength(0)
|
||||
await data.service.run('batch', input())
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
expect(data.service.session.value.error).toContain('切换剧集')
|
||||
expect(qualityTargets(data.readiness, input({ force: true }))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('错误输入与未完成素材不提交,已禁用操作也不能绕过', async () => {
|
||||
const data = setup()
|
||||
for (const values of [{ limit: 0 }, { concurrency: 1.5 }, { maxRepairAttempts: -1 }, { width: 100 }])
|
||||
await data.service.run('repair', input(values))
|
||||
expect(data.fetcher).not.toHaveBeenCalled()
|
||||
data.keyframe.status = 'generating'
|
||||
await data.service.run('repair', input())
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
data.props.disabled = true
|
||||
const count = data.fetcher.mock.calls.length
|
||||
await data.service.run('validate', input())
|
||||
expect(data.fetcher).toHaveBeenCalledTimes(count)
|
||||
})
|
||||
|
||||
it('预检中关闭或换项目不发 POST;已提交的迟到回执只写回原目标', async () => {
|
||||
const data = setup()
|
||||
let finishCheck!: (value: KeyframeReadiness) => void
|
||||
vi.spyOn(productionApi, 'keyframeReadiness').mockImplementationOnce(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finishCheck = resolve
|
||||
})
|
||||
)
|
||||
const pendingCheck = data.service.run('validate', input())
|
||||
await flushPromises()
|
||||
data.visible.value = false
|
||||
finishCheck(data.readiness)
|
||||
await pendingCheck
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
data.visible.value = true
|
||||
let finish!: (value: typeof data.keyframeResult) => void
|
||||
vi.spyOn(qualityApi, 'validateKeyframe').mockImplementationOnce(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const originalKey = data.service.key.value
|
||||
const pending = data.service.run('validate', input())
|
||||
await flushPromises()
|
||||
await data.service.run('validate', input())
|
||||
expect(qualityApi.validateKeyframe).toHaveBeenCalledTimes(1)
|
||||
data.props.projectId = 'different-project'
|
||||
finish(data.keyframeResult)
|
||||
await pending
|
||||
expect(qualitySession(originalKey).receipt).toMatchObject({ kind: 'keyframe' })
|
||||
expect(data.service.session.value.receipt).toBeNull()
|
||||
})
|
||||
|
||||
it('API 对正式 ID 编码,视觉与质量长请求只发一次', async () => {
|
||||
const data = server()
|
||||
await qualityApi.validateKeyframe('shot/a', 'asset/b', [])
|
||||
await qualityApi.repair('shot/a', 'asset/b', { maxRepairAttempts: 1 })
|
||||
await qualityApi.validateVideo('shot/a', 'video/b', [])
|
||||
expect(data.posts().map(([url]) => url)).toEqual([
|
||||
'/api/storyboard-shots/shot%2Fa/keyframes/asset%2Fb/validate',
|
||||
'/api/storyboard-shots/shot%2Fa/keyframes/asset%2Fb/repair',
|
||||
'/api/storyboard-shots/shot%2Fa/videos/video%2Fb/validate'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('质量面板渐进展示', () => {
|
||||
it('默认仅校验,未确认时不可提交,打开或更改参数不产生费用', async () => {
|
||||
const data = server()
|
||||
wrapper = mount(QualityDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
projectId: data.projectId,
|
||||
target: { kind: 'keyframe', shotId: 'shot-1', assetId: 'keyframe-1', title: '测试镜头' },
|
||||
open: true,
|
||||
disabled: false
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
expect(validationButton().disabled).toBe(true)
|
||||
expect(document.body.textContent).toContain('更多参数与模型限制')
|
||||
expect(document.querySelector('[aria-label="额外允许的画面文字"]')).toBeNull()
|
||||
expect(data.fetcher).not.toHaveBeenCalled()
|
||||
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
|
||||
await flushPromises()
|
||||
expect(validationButton().disabled).toBe(false)
|
||||
validationButton().click()
|
||||
await flushPromises()
|
||||
expect(data.posts()).toHaveLength(1)
|
||||
expect(document.body.textContent).toContain('视觉校验通过')
|
||||
})
|
||||
|
||||
it('批量默认当前集一镜,调整次数撤销费用确认', async () => {
|
||||
const data = server()
|
||||
wrapper = mount(QualityDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
projectId: data.projectId,
|
||||
target: { kind: 'batch', episodeNo: 2, title: '第二集' },
|
||||
open: true,
|
||||
disabled: false
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('仅第 2 集,最多 1 镜')
|
||||
expect(document.body.textContent).toContain('最多调用 2 次生图、2 次视觉校验')
|
||||
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
|
||||
await flushPromises()
|
||||
wrapper.findAllComponents(NInputNumber)[0]!.vm.$emit('update:value', 2)
|
||||
await flushPromises()
|
||||
const submit = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
item => item.textContent === '开始质量生成'
|
||||
)!
|
||||
expect(submit.disabled).toBe(true)
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('模型能力只读按需查询,明确当前启用模型及参考图上限', async () => {
|
||||
const data = server()
|
||||
wrapper = mount(ModelCapabilities, { props: { shotId: 'shot-1' } })
|
||||
expect(data.fetcher).not.toHaveBeenCalled()
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('qwen-image · 当前启用')
|
||||
expect(wrapper.text()).toContain('最多 3 张参考图')
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('视频历史读取不触发视觉模型,畸形历史不会伪造通过', () => {
|
||||
const data = server()
|
||||
expect(savedVideoValidation(JSON.stringify({ videoValidation: data.validation }))?.passed).toBe(true)
|
||||
expect(savedVideoValidation('{broken')).toBeNull()
|
||||
expect(savedVideoValidation('{"videoValidation":{"passed":true}}')).toBeNull()
|
||||
expect(allowedTextLines(' 招牌\n\n招牌\n编号 ')).toEqual(['招牌', '编号'])
|
||||
expect(validQualityInput(input({ maxRepairAttempts: 0 }))).toBe(true)
|
||||
expect(qualityKey('a', { kind: 'batch', episodeNo: 1, title: '' })).not.toBe(
|
||||
qualityKey('b', { kind: 'batch', episodeNo: 1, title: '' })
|
||||
)
|
||||
expect(data.fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('视频修复候选与复检晋升', () => {
|
||||
it('只允许校验未通过的视频创建一个候选,不自动再次校验', async () => {
|
||||
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
|
||||
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
data.video.rawJson = JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
|
||||
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
|
||||
expect(data.posts()).toHaveLength(1)
|
||||
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/videos/video-1/repair')
|
||||
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ maxRepairAttempts: 2 })
|
||||
expect(data.service.session.value.receipt).toMatchObject({
|
||||
kind: 'video-repair',
|
||||
result: { candidate: { status: 'queued', isPrimary: false } }
|
||||
})
|
||||
})
|
||||
|
||||
it('链路次数上限不能作为零次修复,已达上限不创建任务', async () => {
|
||||
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
|
||||
data.video.rawJson = JSON.stringify({
|
||||
videoValidation: { ...data.validation, passed: false },
|
||||
repair: { attempt: 2 }
|
||||
})
|
||||
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
|
||||
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
expect(data.service.session.value.error).toContain('次数上限')
|
||||
})
|
||||
|
||||
it('复检通过的修复候选准确显示自动晋升,前端不再另发主视频 PUT', async () => {
|
||||
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '修复候选' })
|
||||
data.video.rawJson = JSON.stringify({ repair: { attempt: 1, sourceVideoId: 'source' } })
|
||||
data.video.isPrimary = false
|
||||
await data.service.run('validate', input())
|
||||
const receipt = data.service.session.value.receipt!
|
||||
expect(receipt).toMatchObject({ kind: 'video', promoted: true, result: { isPrimary: true } })
|
||||
expect(data.posts()).toHaveLength(1)
|
||||
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
|
||||
wrapper!.unmount()
|
||||
wrapper = mount(QualityResult, { props: { receipt } })
|
||||
expect(wrapper.text()).toContain('后端已将其设为主视频')
|
||||
})
|
||||
|
||||
it('视频修复面板明确一次一任务及复检后替换,不展示生图次数', async () => {
|
||||
const data = server()
|
||||
wrapper = mount(QualityDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
projectId: data.projectId,
|
||||
target: { kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' },
|
||||
open: true,
|
||||
disabled: false,
|
||||
savedRawJson: JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('修复候选复检通过后会自动设为主视频')
|
||||
wrapper.getComponent(NRadioGroup).vm.$emit('update:value', 'repair')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('本次最多提交 1 个视频生成任务')
|
||||
expect(document.body.textContent).not.toContain('次生图')
|
||||
expect(document.body.textContent).toContain('修复链次数上限')
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -80,3 +80,50 @@ onScopeDispose(() => {
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.project-frame {
|
||||
@apply flex flex-col h-full min-h-0 overflow-hidden;
|
||||
}
|
||||
.project-header {
|
||||
@apply shrink-0 py-[9px] px-5 bg-(--app-surface);
|
||||
}
|
||||
.project-header .n-page-header__main,
|
||||
.project-header .n-page-header__title {
|
||||
@apply min-w-0 overflow-hidden;
|
||||
}
|
||||
.project-title {
|
||||
@apply max-w-full text-base font-semibold;
|
||||
}
|
||||
.project-notices.n-scrollbar {
|
||||
@apply shrink-0 h-auto max-h-[100px] overflow-hidden;
|
||||
}
|
||||
.project-notices-content {
|
||||
@apply py-2 px-6 grid gap-[5px];
|
||||
}
|
||||
.project-notices .n-alert {
|
||||
@apply py-[7px] px-3 text-xs;
|
||||
}
|
||||
.project-view {
|
||||
@apply flex-1 min-h-0 overflow-hidden;
|
||||
}
|
||||
.project-header .n-page-header-wrapper {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.project-access-gate {
|
||||
@apply h-full;
|
||||
}
|
||||
.workspace-loading {
|
||||
@apply grid place-content-center h-full;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.project-header {
|
||||
@apply py-2 px-3;
|
||||
}
|
||||
.project-title {
|
||||
@apply text-sm;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -140,3 +140,73 @@ function clearFilters() {
|
||||
</p>
|
||||
</WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.page-heading {
|
||||
@apply flex items-center justify-between gap-6 mb-7;
|
||||
}
|
||||
.page-heading h1 {
|
||||
@apply mt-[9px] mx-0 mb-0 text-[27px] font-semibold tracking-[-0.035em] leading-[1.4];
|
||||
}
|
||||
.page-description {
|
||||
@apply mt-[9px] text-muted text-xs leading-[1.7];
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.page-heading {
|
||||
@apply items-start flex-col gap-4;
|
||||
}
|
||||
.page-heading h1 {
|
||||
@apply text-[23px];
|
||||
}
|
||||
}
|
||||
.project-table-area {
|
||||
@apply flex-1 min-h-0;
|
||||
}
|
||||
.project-data-table {
|
||||
@apply h-full;
|
||||
}
|
||||
.project-data-table .n-data-table-base-table {
|
||||
@apply flex-1 min-h-0;
|
||||
}
|
||||
.project-index-heading {
|
||||
@apply m-0;
|
||||
}
|
||||
.project-index-heading h1 {
|
||||
@apply text-[22px] font-semibold;
|
||||
}
|
||||
.project-index-toolbar {
|
||||
@apply flex items-center justify-between flex-wrap gap-3 mb-1;
|
||||
}
|
||||
.project-search {
|
||||
@apply w-[240px];
|
||||
}
|
||||
.project-status-filters.n-radio-group {
|
||||
@apply flex flex-wrap h-auto min-h-(--n-height) gap-y-1 gap-x-px;
|
||||
}
|
||||
.project-status-filters .n-radio-group__splitor {
|
||||
@apply hidden;
|
||||
}
|
||||
.project-status-filters .n-radio-button {
|
||||
@apply min-w-[76px] px-[18px] text-center;
|
||||
}
|
||||
.project-name-cell {
|
||||
@apply block min-w-0;
|
||||
}
|
||||
.project-name-cell strong,
|
||||
.project-name-cell small {
|
||||
@apply block overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
.project-name-cell small {
|
||||
@apply text-muted mt-1 text-[11px];
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.project-search {
|
||||
@apply w-full;
|
||||
}
|
||||
.project-index-toolbar .n-radio-group {
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import { h } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import App from '../../App.vue'
|
||||
import ProjectLayout from './ProjectLayout.vue'
|
||||
import { isProjectComplete } from './access'
|
||||
import { projectsApi } from './api'
|
||||
import type { ProjectDetail, ProjectStatus } from './types'
|
||||
|
||||
const downstream = ['breakdown', 'visual-style', 'subject-identity', 'subject-images', 'storyboard', 'production']
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
localStorage.clear()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
/** 用已有剧集模拟中途生成的项目,完成与否必须取 status 而非数组长度。 */
|
||||
function project(id: string, status: ProjectStatus): ProjectDetail {
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
title: '访问限制测试',
|
||||
topic: '',
|
||||
style: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
episodes: [{ episode: 1, title: '部分剧集', content: '已经写入的内容' }],
|
||||
characters: [],
|
||||
world: null,
|
||||
reviews: [],
|
||||
tasks: []
|
||||
}
|
||||
}
|
||||
|
||||
/** 真实项目布局与侧栏,子工作区以挂载探针代替,防止测试发起实际生成请求。 */
|
||||
async function openProject(initialPath: string) {
|
||||
const mounted = vi.fn<(path: string) => void>()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/projects', component: { render: () => h('div', '项目列表') } },
|
||||
{
|
||||
path: '/projects/:projectId',
|
||||
component: ProjectLayout,
|
||||
children: ['create-drama', ...downstream].map(path => ({
|
||||
path,
|
||||
component: {
|
||||
setup() {
|
||||
mounted(path)
|
||||
return () => h('div', { class: 'workspace-probe' }, path)
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
]
|
||||
})
|
||||
await router.push(initialPath)
|
||||
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
return { router, mounted }
|
||||
}
|
||||
|
||||
describe('剧本完成前的下游访问限制', () => {
|
||||
it('进入图库暂停父级轮询,返回其他工作区恢复定时更新', async () => {
|
||||
vi.useFakeTimers()
|
||||
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('manual-gallery', 'completed'))
|
||||
const checkpoints = vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { router } = await openProject('/projects/manual-gallery/production')
|
||||
await router.push('/projects/manual-gallery/subject-images')
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(detail).toHaveBeenCalledTimes(1)
|
||||
expect(checkpoints).toHaveBeenCalledTimes(1)
|
||||
await router.push('/projects/manual-gallery/production')
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
expect(detail).toHaveBeenCalledTimes(2)
|
||||
expect(checkpoints).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('图库直接链接只读取一次项目,未完成时可手动刷新解锁', async () => {
|
||||
vi.useFakeTimers()
|
||||
let status: ProjectStatus = 'generating'
|
||||
const detail = vi.spyOn(projectsApi, 'detail').mockImplementation(async id => project(id, status))
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
await openProject('/projects/manual-gate/subject-images')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(detail).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper!.get('.project-access-gate').text()).not.toContain('状态会自动更新')
|
||||
status = 'completed'
|
||||
const refresh = wrapper!.findAll('button').find(button => button.text() === '刷新项目状态')!
|
||||
await refresh.trigger('click')
|
||||
await flushPromises()
|
||||
expect(detail).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper!.get('.workspace-probe').text()).toBe('subject-images')
|
||||
})
|
||||
|
||||
it.each(['draft', 'generating', 'need_review', 'failed'] as const)(
|
||||
'%s 不解锁导航,也不挂载直接链接对应的工作区',
|
||||
async status => {
|
||||
vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('unfinished', status))
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { router, mounted } = await openProject('/projects/unfinished/production')
|
||||
for (const path of downstream) {
|
||||
await router.push(`/projects/unfinished/${path}`)
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.project-access-gate').text()).toContain('请先完成剧本创作')
|
||||
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
|
||||
expect(wrapper!.find(`.n-menu a[href="/projects/unfinished/${path}"]`).exists()).toBe(false)
|
||||
}
|
||||
expect(mounted).not.toHaveBeenCalled()
|
||||
expect(wrapper!.findAll('.n-menu [aria-disabled="true"]')).toHaveLength(6)
|
||||
await wrapper!.get('.project-access-gate button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/projects/unfinished/create-drama')
|
||||
expect(wrapper!.get('.workspace-probe').text()).toBe('create-drama')
|
||||
expect(mounted).toHaveBeenCalledExactlyOnceWith('create-drama')
|
||||
}
|
||||
)
|
||||
|
||||
it('轮询完成状态自动解锁;重新变为待审核时撤下下游面板', async () => {
|
||||
vi.useFakeTimers()
|
||||
let status: ProjectStatus = 'generating'
|
||||
vi.spyOn(projectsApi, 'detail').mockImplementation(async id => project(id, status))
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { mounted } = await openProject('/projects/polling/production')
|
||||
expect(mounted).not.toHaveBeenCalled()
|
||||
status = 'completed'
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.workspace-probe').text()).toBe('production')
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(8)
|
||||
status = 'need_review'
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
await flushPromises()
|
||||
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
|
||||
expect(wrapper!.get('.project-access-gate').text()).toContain('请先完成剧本创作')
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
|
||||
expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
|
||||
})
|
||||
|
||||
it('切换项目与首次读取期间不能沿用上一个已完成项目的权限', async () => {
|
||||
let resolveSecond!: (value: ProjectDetail) => void
|
||||
const pending = new Promise<ProjectDetail>(resolve => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
vi.spyOn(projectsApi, 'detail').mockImplementation(async id =>
|
||||
id === 'first' ? project(id, 'completed') : pending
|
||||
)
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { router, mounted } = await openProject('/projects/first/production')
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(8)
|
||||
await router.push('/projects/second/production')
|
||||
await flushPromises()
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
|
||||
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
|
||||
resolveSecond(project('second', 'draft'))
|
||||
await flushPromises()
|
||||
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
|
||||
expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
|
||||
await router.push('/projects')
|
||||
await flushPromises()
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('读取失败保持锁定;错误区重试成功后解锁,页头不恢复标签与刷新按钮', async () => {
|
||||
const detail = vi
|
||||
.spyOn(projectsApi, 'detail')
|
||||
.mockRejectedValueOnce(new Error('项目读取失败'))
|
||||
.mockResolvedValue(project('retry', 'completed'))
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { mounted } = await openProject('/projects/retry/storyboard')
|
||||
expect(mounted).not.toHaveBeenCalled()
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
|
||||
expect(wrapper!.find('.project-header .n-button').exists()).toBe(false)
|
||||
expect(wrapper!.find('.project-header .n-tag').exists()).toBe(false)
|
||||
await wrapper!.get('.project-notices button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(detail).toHaveBeenCalledTimes(2)
|
||||
expect(mounted).toHaveBeenCalledExactlyOnceWith('storyboard')
|
||||
})
|
||||
|
||||
it('不存在、未知状态或项目 ID 不匹配时默认锁定', () => {
|
||||
expect(isProjectComplete(null, 'p')).toBe(false)
|
||||
expect(isProjectComplete({ id: 'other', status: 'completed' }, 'p')).toBe(false)
|
||||
expect(isProjectComplete({ id: 'p', status: 'unknown' as ProjectStatus }, 'p')).toBe(false)
|
||||
expect(isProjectComplete({ id: 'p', status: 'completed' }, 'p')).toBe(true)
|
||||
})
|
||||
|
||||
it('配置复选框居中对齐,输入表面随面板背景分层而非增加边框', () => {
|
||||
// 保护布局契约;实际像素对齐仍需浏览器视觉验收。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.n-checkbox\.control-row-checkbox\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*34px;[^}]*padding-block:\s*0/
|
||||
)
|
||||
expect(css).toMatch(/\.app-dialog\s*\{[^}]*--app-field:\s*var\(--app-control\)/)
|
||||
expect(readFileSync('src/styles.css', 'utf8')).toMatch(/\.panel\s*\{[^}]*--app-field:\s*var\(--app-control\)/)
|
||||
for (const path of [
|
||||
'production/ProductionPage.vue',
|
||||
'storyboard/StoryboardPage.vue',
|
||||
'subject-identity/SubjectIdentityPage.vue',
|
||||
'subject-images/SubjectImagesPage.vue'
|
||||
]) {
|
||||
const source = readFileSync(`src/features/${path}`, 'utf8')
|
||||
expect(source).toContain('control-row-checkbox')
|
||||
expect(source).not.toMatch(/<NCheckbox\b[^>]*class="[^"]*pb-[23]/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -418,3 +418,73 @@ watch(
|
||||
></WorkspacePage
|
||||
>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.storyboard-controls {
|
||||
@apply grid grid-cols-[minmax(180px,_1fr)_100px_145px_auto] gap-[18px] items-end;
|
||||
}
|
||||
.generation-row {
|
||||
@apply flex items-center justify-between gap-5 pt-5 mt-5;
|
||||
}
|
||||
.generation-row > div:last-child {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.storyboard-workspace {
|
||||
@apply grid grid-cols-[220px_minmax(0,_1fr)] overflow-hidden;
|
||||
}
|
||||
.shot-list {
|
||||
@apply max-h-[1000px] overflow-hidden bg-(--app-surface) pt-0 px-2 pb-5;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.generation-row {
|
||||
@apply items-start flex-col gap-3;
|
||||
}
|
||||
.storyboard-controls {
|
||||
@apply grid-cols-[minmax(0,_1fr)_minmax(0,_1fr)];
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.storyboard-controls {
|
||||
@apply grid-cols-[minmax(0,_1fr)] gap-3;
|
||||
}
|
||||
.storyboard-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
}
|
||||
.shot-list {
|
||||
@apply max-h-[240px];
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
.storyboard-workspace-page .storyboard-workspace {
|
||||
@apply flex-1 h-auto min-h-0 overflow-hidden;
|
||||
}
|
||||
.storyboard-coverage {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.storyboard-workspace {
|
||||
@apply grid-cols-[clamp(240px,_22%,_280px)_minmax(0,_1fr)];
|
||||
}
|
||||
.storyboard-workspace {
|
||||
@apply h-[clamp(360px,65dvh,850px)] min-h-0;
|
||||
}
|
||||
.storyboard-workspace > article,
|
||||
.storyboard-workspace .shot-list {
|
||||
@apply overflow-hidden min-h-0 h-full max-h-none;
|
||||
}
|
||||
.shot-list-content {
|
||||
@apply gap-3 pt-0 px-0 pb-3;
|
||||
}
|
||||
.shot-list {
|
||||
@apply p-0;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.storyboard-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[184px_minmax(0,_1fr)];
|
||||
}
|
||||
.storyboard-controls {
|
||||
@apply grid-cols-[minmax(0,_1fr)_minmax(0,_1fr)];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -55,3 +55,35 @@ const groups = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.directory-group-heading {
|
||||
@apply sticky top-0 z-[1] flex items-center justify-between gap-3 shrink-0 py-2.5 px-3.5 bg-(--app-control) font-mono font-semibold text-[11px] leading-[1.6] text-ink;
|
||||
}
|
||||
.directory-group-count {
|
||||
@apply font-normal text-muted;
|
||||
}
|
||||
.beat-directory-group {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.beat-directory-items {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
.directory-mobile-beat {
|
||||
@apply hidden;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.directory-group-heading {
|
||||
@apply hidden;
|
||||
}
|
||||
.beat-directory-group,
|
||||
.beat-directory-items {
|
||||
display: contents;
|
||||
}
|
||||
.directory-mobile-beat {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -214,3 +214,20 @@ function exportResult(kind: 'spec' | 'prompt') {
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.alert {
|
||||
@apply py-3 px-[15px] rounded-none bg-(--app-subtle) text-ink text-xs leading-[1.8] wrap-anywhere;
|
||||
}
|
||||
.reference-grid {
|
||||
@apply grid grid-cols-[repeat(auto-fill,_minmax(150px,_1fr))] gap-3.5;
|
||||
}
|
||||
.reference-card {
|
||||
@apply bg-(--app-subtle) rounded-none overflow-hidden;
|
||||
}
|
||||
.storyboard-json {
|
||||
@apply p-4 rounded-none bg-(--app-subtle) font-mono text-[11px] leading-[1.9];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import BeatShotDirectory from './components/BeatShotDirectory.vue'
|
||||
import { designedShot } from './testing/fixtures'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => wrapper?.unmount())
|
||||
|
||||
describe('Beat 两级镜头目录', () => {
|
||||
it('按 Beat 和镜号排序,分组只出现一次并显示镜数,不改变原数组', () => {
|
||||
const shots = [
|
||||
{ ...designedShot('b2-s1'), beatNo: 2 },
|
||||
{ ...designedShot('b1-s2'), shotNo: 2 },
|
||||
designedShot('b1-s1')
|
||||
]
|
||||
wrapper = mount(BeatShotDirectory, {
|
||||
props: { shots, activeId: 'b1-s1', itemClass: 'shot-link' },
|
||||
slots: { meta: '<span>设计已保存</span>' }
|
||||
})
|
||||
const groups = wrapper.findAll('.beat-directory-group')
|
||||
expect(groups).toHaveLength(2)
|
||||
expect(groups[0]!.get('h4').text()).toContain('BEAT 01')
|
||||
expect(groups[0]!.get('.directory-group-count').text()).toBe('2 镜')
|
||||
expect(groups[1]!.get('h4').text()).toContain('BEAT 02')
|
||||
expect(groups[1]!.get('.directory-group-count').text()).toBe('1 镜')
|
||||
expect(wrapper.findAll('.directory-shot-number').map(item => item.text())).toEqual([
|
||||
'镜头 01',
|
||||
'镜头 02',
|
||||
'镜头 01'
|
||||
])
|
||||
expect(wrapper.findAll('.directory-item-meta').every(item => item.text() === '设计已保存')).toBe(true)
|
||||
expect(groups[0]!.attributes('aria-labelledby')).toBe(groups[0]!.get('h4').attributes('id'))
|
||||
expect(shots.map(shot => shot.shotId)).toEqual(['b2-s1', 'b1-s2', 'b1-s1'])
|
||||
})
|
||||
|
||||
it('不同 Beat 的同号镜头按正式 ID 选择,数据刷新后选中项保持不变', async () => {
|
||||
const shots = [designedShot('b1-s1'), { ...designedShot('b2-s1'), beatNo: 2 }]
|
||||
wrapper = mount(BeatShotDirectory, { props: { shots, activeId: 'b1-s1', itemClass: 'production-shot-link' } })
|
||||
await wrapper.findAll('.production-shot-link')[1]!.trigger('click')
|
||||
expect(wrapper.emitted('select')).toEqual([['b2-s1']])
|
||||
await wrapper.setProps({
|
||||
activeId: 'b2-s1',
|
||||
shots: [...shots, { ...designedShot('b2-s2'), beatNo: 2, shotNo: 2 }]
|
||||
})
|
||||
expect(wrapper.get('.selected').attributes('aria-label')).toBe('BEAT 2 · 镜头 1 · 来信')
|
||||
expect(wrapper.findAll('.selected')).toHaveLength(1)
|
||||
expect(wrapper.findAll('.directory-group-count')[1]!.text()).toBe('2 镜')
|
||||
})
|
||||
|
||||
it('组内吸顶与移动端归属提示分别保护,不在桌面重复展示 Beat', () => {
|
||||
// happy-dom 不计算吸顶位置,保护 CSS 边界,实际滚动仍需浏览器验收。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.directory-group-heading\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*background:\s*var\(--app-control\)/
|
||||
)
|
||||
expect(css).toMatch(/\.directory-mobile-beat\s*\{\s*display:\s*none/)
|
||||
const mobile = css.slice(css.lastIndexOf('@media (max-width: 760px)'))
|
||||
expect(mobile).toMatch(/\.directory-mobile-beat\s*\{\s*display:\s*inline/)
|
||||
expect(mobile).toMatch(/\.beat-directory-group,\s*\.beat-directory-items\s*\{\s*display:\s*contents/)
|
||||
})
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { directionLabel, mergeDesignedShots, referenceImageUrl, storyboardPrerequisites } from './model'
|
||||
import { directionsResult, episodeShots, storyboardCheckpoint, visualStatesResult } from './testing/fixtures'
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
describe('分镜正式数据与生成依赖', () => {
|
||||
it('用 Shot ID 关联状态,用 Beat + Shot 编号补充描述,不串同编号镜头', () => {
|
||||
const directions = directionsResult('p')
|
||||
const states = visualStatesResult('p', 1, true)
|
||||
states.beats.reverse()
|
||||
states.beats[0]!.shots[0]!.visualState!.continuityNote = '第二个 Beat'
|
||||
const shots = mergeDesignedShots(directions, states, episodeShots())
|
||||
expect(shots.map(shot => [shot.shotId, shot.title, shot.visualState?.continuityNote])).toEqual([
|
||||
['shot-db-1-1', '第1集镜头1', '信封始终在右手'],
|
||||
['shot-db-1-2', '第1集镜头2', '第二个 Beat']
|
||||
])
|
||||
})
|
||||
|
||||
it('没有 Direction 的正式 Shot 仍可显示,不用 checkpoint 伪造数据库 ID', () => {
|
||||
const rows = mergeDesignedShots(directionsResult('p', 1, false), visualStatesResult('p'), episodeShots())
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]?.direction).toBeNull()
|
||||
expect(mergeDesignedShots(null, null, episodeShots())).toEqual([])
|
||||
})
|
||||
|
||||
it('仅最新 Breakdown 决定生成前置条件,不能回退到历史完整快照', () => {
|
||||
const ready = storyboardCheckpoint()
|
||||
expect(storyboardPrerequisites([ready], 1)).toMatchObject({ directionEpisode: true, visualEpisode: true })
|
||||
expect(storyboardPrerequisites([ready], 3)).toMatchObject({ directionEpisode: false, visualEpisode: false })
|
||||
const latest = { ...ready, checkpointId: 'failed', createdAt: '2026-08-28T01:00:00Z', state: {} }
|
||||
expect(storyboardPrerequisites([latest, ready], 1)).toMatchObject({
|
||||
directionProject: false,
|
||||
visualProject: false
|
||||
})
|
||||
expect(storyboardPrerequisites([{ ...latest, workflowName: 'create-drama' }, ready], 1)).toMatchObject({
|
||||
directionProject: true
|
||||
})
|
||||
})
|
||||
|
||||
it('保留后端嵌套 Direction 与顶层 VisualState 的不同依赖', () => {
|
||||
const checkpoint = storyboardCheckpoint()
|
||||
delete checkpoint.state.breakdownResult
|
||||
expect(storyboardPrerequisites([checkpoint], 1)).toMatchObject({ directionEpisode: false, visualEpisode: true })
|
||||
checkpoint.state.subjectForms = []
|
||||
expect(storyboardPrerequisites([checkpoint], 1).visualEpisode).toBe(false)
|
||||
expect(directionLabel('future-camera-mode')).toBe('future-camera-mode')
|
||||
})
|
||||
|
||||
it('参考图只允许 http(s) 或后端 storage 地址,拦截不可信协议和路径逃逸', () => {
|
||||
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.test/api')
|
||||
expect(referenceImageUrl('/storage/image.png')).toBe('https://api.example.test/storage/image.png')
|
||||
expect(referenceImageUrl('https://images.example.test/a.png')).toBe('https://images.example.test/a.png')
|
||||
for (const value of [
|
||||
'javascript:alert(1)',
|
||||
'data:image/svg+xml,anything',
|
||||
'//evil.test/img',
|
||||
'/storage/../api/projects',
|
||||
'/storage/\\evil.test/img'
|
||||
]) {
|
||||
expect(referenceImageUrl(value)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -621,3 +621,117 @@ watch(
|
||||
@generate="generateCastingCandidate"
|
||||
/></WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.identity-workspace {
|
||||
@apply grid grid-cols-[240px_minmax(0,_1fr)] items-start gap-5;
|
||||
}
|
||||
.identity-subject-list {
|
||||
@apply max-h-[560px] overflow-hidden;
|
||||
}
|
||||
.casting-stats {
|
||||
@apply grid grid-cols-[repeat(5,_minmax(0,_1fr))] gap-2.5;
|
||||
}
|
||||
.casting-stats div {
|
||||
@apply py-2.5 px-3 rounded-none bg-(--app-subtle);
|
||||
}
|
||||
.casting-stats dt {
|
||||
@apply text-muted text-[10px];
|
||||
}
|
||||
.casting-stats dd {
|
||||
@apply mt-[5px] font-mono text-[13px];
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.identity-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
}
|
||||
.identity-subject-list {
|
||||
@apply max-h-[220px];
|
||||
}
|
||||
.casting-stats {
|
||||
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
|
||||
}
|
||||
}
|
||||
.toolbar-type-filter {
|
||||
@apply w-[152px];
|
||||
}
|
||||
.identity-tools-intro {
|
||||
@apply mb-6;
|
||||
}
|
||||
.identity-workspace {
|
||||
@apply flex-1 min-h-0 items-stretch overflow-hidden;
|
||||
}
|
||||
.identity-workspace {
|
||||
@apply grid-cols-[clamp(240px,_22%,_280px)_minmax(0,_1fr)];
|
||||
}
|
||||
.identity-workspace > div {
|
||||
@apply min-h-0 overflow-hidden overscroll-contain;
|
||||
}
|
||||
.identity-subject-list {
|
||||
@apply max-h-none min-h-0 flex-1;
|
||||
}
|
||||
.directory-filters {
|
||||
@apply grid gap-2.5 pt-0 px-3 pb-3 shrink-0;
|
||||
}
|
||||
.directory-filters > * {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.identity-subject-list-content {
|
||||
@apply gap-0.5 pt-0 px-0 pb-2;
|
||||
}
|
||||
.n-button.identity-subject-item {
|
||||
@apply min-h-[86px] py-2.5 px-3.5;
|
||||
}
|
||||
.n-button.identity-subject-item:not(.selected) {
|
||||
@apply bg-(--app-surface);
|
||||
}
|
||||
.n-button.identity-subject-item:not(.selected):hover {
|
||||
@apply bg-(--app-subtle);
|
||||
}
|
||||
.identity-subject-item .directory-item-body {
|
||||
@apply gap-[3px];
|
||||
}
|
||||
.identity-subject-item .directory-item-title {
|
||||
@apply order-0 text-sm font-semibold;
|
||||
}
|
||||
.identity-subject-item .directory-item-eyebrow {
|
||||
@apply order-1 text-[10px];
|
||||
}
|
||||
.identity-subject-item .directory-item-meta {
|
||||
@apply order-2 gap-y-1 gap-x-1.5 text-[10px];
|
||||
}
|
||||
.casting-list {
|
||||
@apply grid grid-cols-[repeat(auto-fit,_minmax(min(100%,_260px),_1fr))] gap-3 mt-6 pt-5;
|
||||
}
|
||||
.n-button.casting-item {
|
||||
@apply w-full min-w-0 h-auto min-h-[76px] py-3 px-3.5 whitespace-normal text-left bg-(--app-subtle);
|
||||
}
|
||||
.n-button.casting-item.selected {
|
||||
@apply bg-(--app-selected) shadow-[inset_3px_0_0_var(--app-accent)];
|
||||
}
|
||||
.n-button.casting-item .n-button__content {
|
||||
@apply flex w-full min-w-0 items-center justify-between gap-4 text-left;
|
||||
}
|
||||
.casting-item-identity {
|
||||
@apply flex flex-col gap-[5px] min-w-0;
|
||||
}
|
||||
.casting-item-name {
|
||||
@apply text-sm font-medium leading-normal wrap-anywhere;
|
||||
}
|
||||
.casting-item-ref {
|
||||
@apply text-muted font-mono text-[11px] leading-normal wrap-anywhere;
|
||||
}
|
||||
.casting-item-status {
|
||||
@apply shrink-0;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.identity-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[250px_minmax(0,_1fr)] gap-2.5;
|
||||
}
|
||||
.directory-filters {
|
||||
@apply grid-cols-[minmax(0,_1fr)_120px];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,3 +105,18 @@ watch(
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.identity-thumbnail {
|
||||
@apply flex w-12 h-[64px] overflow-hidden bg-(--app-subtle);
|
||||
}
|
||||
.identity-thumbnail .n-image,
|
||||
.identity-thumbnail .n-image img {
|
||||
@apply w-full h-full;
|
||||
}
|
||||
.identity-thumbnail-placeholder {
|
||||
@apply flex flex-col items-center justify-center w-full h-full gap-[5px] text-muted text-[10px];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NImage, NScrollbar } from 'naive-ui'
|
||||
import { AssetImage } from '../../components/ui'
|
||||
import IdentityImageDialog from './components/IdentityImageDialog.vue'
|
||||
import IdentityGallery from './components/IdentityGallery.vue'
|
||||
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
|
||||
import { subjectIdentityApi } from './api'
|
||||
import {
|
||||
canBeAnchor,
|
||||
castingStatusLabel,
|
||||
currentAnchor,
|
||||
groupIdentitySubjects,
|
||||
mergeCastingSubjects,
|
||||
readImageProvenance
|
||||
} from './model'
|
||||
import { identityImageFixture } from './testing/fixtures'
|
||||
import { formFixture } from '../subject-images/testing/fixtures'
|
||||
import { expandSections, selectControl } from '../../testing/naive'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 从真实 Naive 弹窗内获取确认按钮。 */
|
||||
function button(label: string) {
|
||||
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
|
||||
if (!item) throw new Error(`缺少按钮 ${label}`)
|
||||
return item
|
||||
}
|
||||
|
||||
/** 修改 Portal 表单控件并触发 Vue 绑定。 */
|
||||
function input(selector: string, value: string) {
|
||||
if (selector === '#identity-view' || selector === '#identity-reference') {
|
||||
selectControl(wrapper!, 'id', selector.slice(1)).vm.$emit('update:value', value)
|
||||
return
|
||||
}
|
||||
const item = document.querySelector<HTMLInputElement | HTMLSelectElement>(selector)!
|
||||
item.value = value
|
||||
item.dispatchEvent(new Event(item.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }))
|
||||
}
|
||||
|
||||
describe('身份图与母版契约', () => {
|
||||
it('详情大图 contain 完整显示,历史仍为 cover,缩略图只切换记录', async () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: true,
|
||||
disabled: false,
|
||||
images: [
|
||||
identityImageFixture({ id: 'anchor', imageUrl: '/storage/anchor.png' }),
|
||||
identityImageFixture({
|
||||
id: 'front',
|
||||
viewType: 'front',
|
||||
isAnchor: false,
|
||||
imageUrl: '/storage/front.png'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
const images = wrapper.findAllComponents(NImage)
|
||||
expect(images).toHaveLength(3)
|
||||
expect(images.map(image => image.props('objectFit'))).toEqual(['contain', 'cover', 'cover'])
|
||||
expect(images.map(image => image.props('previewDisabled'))).toEqual([false, true, true])
|
||||
await wrapper.get('[aria-label="查看身份图片 front"] img').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[aria-label="查看身份图片 front"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(document.querySelector('.n-image-preview-container')).toBeNull()
|
||||
const main = wrapper.get('.asset-image-preview img')
|
||||
expect((main.element as HTMLImageElement).style.objectFit).toBe('contain')
|
||||
expect(main.attributes('src')).toContain('/storage/front.png')
|
||||
await main.trigger('click')
|
||||
await flushPromises()
|
||||
const original = document.querySelector<HTMLImageElement>('.n-image-preview')!
|
||||
expect(original.getAttribute('src')).toBe(main.attributes('src'))
|
||||
expect(original.style.objectFit).not.toBe('cover')
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('实际提示词默认展开,可手动收起,轮询更新不强行重新展开', async () => {
|
||||
const image = identityImageFixture({ prompt: '本次实际生成提示词' })
|
||||
wrapper = mount(IdentityGallery, {
|
||||
props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images: [image] }
|
||||
})
|
||||
const panel = wrapper.get('.n-collapse-item')
|
||||
expect(panel.classes()).toContain('n-collapse-item--active')
|
||||
expect(panel.text()).toContain('本次实际生成提示词')
|
||||
await panel.get('.n-collapse-item__header-main').trigger('click')
|
||||
expect(panel.classes()).not.toContain('n-collapse-item--active')
|
||||
await wrapper.setProps({ images: [{ ...image, prompt: '更新后的实际提示词' }] })
|
||||
expect(panel.classes()).not.toContain('n-collapse-item--active')
|
||||
await panel.get('.n-collapse-item__header-main').trigger('click')
|
||||
expect(panel.classes()).toContain('n-collapse-item--active')
|
||||
expect(panel.text()).toContain('更新后的实际提示词')
|
||||
})
|
||||
|
||||
it('全部历史记录显示在预览下方,候选、辅助、失败和进行中图片均可切换查看', async () => {
|
||||
const images = [
|
||||
identityImageFixture({
|
||||
id: 'candidate',
|
||||
imageUrl: '/storage/candidate.png',
|
||||
isAnchor: false,
|
||||
enabled: false
|
||||
}),
|
||||
identityImageFixture({
|
||||
id: 'failed',
|
||||
status: 'failed',
|
||||
imageUrl: null,
|
||||
isAnchor: false,
|
||||
enabled: false,
|
||||
error: '图片模型超时'
|
||||
}),
|
||||
identityImageFixture({ id: 'anchor', imageUrl: '/storage/anchor.png', width: 4096, height: 4096 }),
|
||||
identityImageFixture({ id: 'front', imageUrl: '/storage/front.png', viewType: 'front', isAnchor: false }),
|
||||
identityImageFixture({ id: 'pending', status: 'pending', imageUrl: null, isAnchor: false, enabled: false }),
|
||||
identityImageFixture({
|
||||
id: 'generating',
|
||||
status: 'generating',
|
||||
imageUrl: null,
|
||||
isAnchor: false,
|
||||
enabled: false
|
||||
})
|
||||
]
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images }
|
||||
})
|
||||
const history = wrapper.get('[aria-label="身份图片历史"]')
|
||||
expect(history.findAll('.image-history-item')).toHaveLength(6)
|
||||
expect(wrapper.getComponent(NScrollbar).props()).toMatchObject({
|
||||
xScrollable: true,
|
||||
trigger: 'none',
|
||||
contentStyle: { width: 'max-content' }
|
||||
})
|
||||
expect(wrapper.get('.asset-image-preview').element.nextElementSibling?.textContent).toContain('历史记录 · 6 条')
|
||||
expect(wrapper.get('.image-history-heading').element.nextElementSibling).toBe(history.element)
|
||||
expect(history.text()).toContain('当前母版')
|
||||
expect(history.text()).toContain('母版候选')
|
||||
expect(history.text()).toContain('正面')
|
||||
expect(history.text()).toContain('生成失败')
|
||||
expect(history.text()).toContain('排队中')
|
||||
expect(history.text()).toContain('生成中')
|
||||
expect(history.get('[aria-label="查看身份图片 anchor"]').attributes('aria-pressed')).toBe('true')
|
||||
for (const image of images) {
|
||||
await history.get(`[aria-label="查看身份图片 ${image.id}"]`).trigger('click')
|
||||
expect(history.findAll('[aria-pressed="true"]')).toHaveLength(1)
|
||||
expect(history.get(`[aria-label="查看身份图片 ${image.id}"]`).attributes('aria-pressed')).toBe('true')
|
||||
const preview = wrapper
|
||||
.findAllComponents(AssetImage)
|
||||
.find(item => item.classes().includes('asset-image-preview'))!
|
||||
expect(preview.props('src')).toBe(image.status === 'completed' ? image.imageUrl : null)
|
||||
expect(wrapper.text()).toContain(`Identity image ID · ${image.id}`)
|
||||
}
|
||||
await history.get('[aria-label="查看身份图片 failed"]').trigger('click')
|
||||
expect(wrapper.get('.asset-image-preview').text()).toContain('本次生成失败')
|
||||
expect(wrapper.findAll('[role="alert"]').map(item => item.text())).toContain('图片模型超时')
|
||||
expect(button('确认选角并锁定').disabled).toBe(true)
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('轮询新增历史不会切回母版或重置横向位置,当前记录移除后才回退', async () => {
|
||||
const anchor = identityImageFixture({ id: 'anchor' })
|
||||
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
|
||||
wrapper = mount(IdentityGallery, {
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: true,
|
||||
disabled: false,
|
||||
images: [anchor, front]
|
||||
}
|
||||
})
|
||||
await wrapper.get('[aria-label="查看身份图片 front"]').trigger('click')
|
||||
const viewport = wrapper.get<HTMLElement>('.image-history .n-scrollbar-container').element
|
||||
viewport.scrollLeft = 120
|
||||
await wrapper.setProps({ images: [identityImageFixture({ id: 'new', isAnchor: false }), anchor, front] })
|
||||
expect(wrapper.get('[aria-label="查看身份图片 front"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.get('.image-history .n-scrollbar-container').element).toBe(viewport)
|
||||
expect(viewport.scrollLeft).toBe(120)
|
||||
await wrapper.setProps({ images: [anchor] })
|
||||
expect(wrapper.get('[aria-label="查看身份图片 anchor"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.get('.image-history-heading').text()).toContain('历史记录 · 1 条')
|
||||
})
|
||||
|
||||
it('切换历史取消上一张的确认,只有再次确认才能发出当前候选 ID', async () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: false,
|
||||
disabled: false,
|
||||
images: ['candidate-a', 'candidate-b'].map(id =>
|
||||
identityImageFixture({ id, isAnchor: false, enabled: false })
|
||||
)
|
||||
}
|
||||
})
|
||||
button('确认选角并锁定').click()
|
||||
await flushPromises()
|
||||
expect(button('确认演员选择').disabled).toBe(false)
|
||||
await wrapper.get('[aria-label="查看身份图片 candidate-b"]').trigger('click')
|
||||
expect(wrapper.text()).not.toContain('确认选择这张图片作为正式演员')
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
button('确认选角并锁定').click()
|
||||
await flushPromises()
|
||||
button('确认演员选择').click()
|
||||
expect(wrapper.emitted('anchor')).toEqual([['candidate-b']])
|
||||
})
|
||||
|
||||
it('无历史图片时只展示空状态,不伪造缩略图或母版', () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
props: { subjectName: '林夏', module: 'character', identityLocked: false, disabled: false, images: [] }
|
||||
})
|
||||
expect(wrapper.text()).toContain('身份参考图 · 0')
|
||||
expect(wrapper.text()).toContain('尚无身份参考图')
|
||||
expect(wrapper.find('.image-history').exists()).toBe(false)
|
||||
expect(wrapper.find('.asset-image-preview').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('启用的辅助视角不是母版,primary 候选停用时仍可选为母版', () => {
|
||||
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
|
||||
const candidate = identityImageFixture({ id: 'candidate', enabled: false, isAnchor: false })
|
||||
expect(canBeAnchor(front)).toBe(false)
|
||||
expect(canBeAnchor(candidate)).toBe(true)
|
||||
expect(currentAnchor([front, candidate])).toBeUndefined()
|
||||
expect(canBeAnchor(identityImageFixture({ status: 'failed' }))).toBe(false)
|
||||
expect(canBeAnchor(identityImageFixture({ imageUrl: null }))).toBe(false)
|
||||
})
|
||||
|
||||
it('正式主体关联校验不接受不同主体的 form,追溯 JSON 兼容旧数据', () => {
|
||||
expect(() => groupIdentitySubjects([{ ...formFixture(), subjectId: 'wrong' }])).toThrow('不匹配')
|
||||
expect(readImageProvenance('{bad')).toEqual({})
|
||||
expect(readImageProvenance(null)).toEqual({})
|
||||
expect(readImageProvenance('{"identityAnchorImageId":"anchor","referenceImageId":7}')).toMatchObject({
|
||||
identityAnchorImageId: 'anchor',
|
||||
referenceImageId: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('选角就绪结果可以补入尚无形态的角色目录', () => {
|
||||
const rows = mergeCastingSubjects(
|
||||
groupIdentitySubjects([formFixture()]),
|
||||
[
|
||||
{
|
||||
subjectId: 'character-without-form',
|
||||
subjectRef: '@CH0002',
|
||||
subjectName: '陆川',
|
||||
status: 'missing_identity',
|
||||
isLocked: false
|
||||
}
|
||||
],
|
||||
'project-1'
|
||||
)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows.find(item => item.id === 'character-without-form')).toMatchObject({
|
||||
projectId: 'project-1',
|
||||
module: 'character',
|
||||
forms: []
|
||||
})
|
||||
expect(castingStatusLabel('candidate_pending')).toBe('等待确认演员')
|
||||
})
|
||||
|
||||
it('辅助视角传递明确参考图、成对尺寸和本次 Prompt,不传形态生图字段', async () => {
|
||||
wrapper = mount(IdentityImageDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
open: true,
|
||||
subjectId: 's1',
|
||||
subjectName: '林夏',
|
||||
images: [identityImageFixture()],
|
||||
disabled: false
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
input('#identity-view', 'three-quarter')
|
||||
input('#identity-reference', 'identity-image-1')
|
||||
input('#identity-width', '2048')
|
||||
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
|
||||
await flushPromises()
|
||||
expect(button('确认生成身份图').disabled).toBe(true)
|
||||
input('#identity-height', '2048')
|
||||
input('#identity-image-prompt', ' 自定义身份提示词 ')
|
||||
await flushPromises()
|
||||
button('确认生成身份图').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')).toEqual([
|
||||
[
|
||||
{
|
||||
viewType: 'three-quarter',
|
||||
referenceImageId: 'identity-image-1',
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
prompt: '自定义身份提示词'
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('切换主体清空生图配置和费用确认,失效参考图不能提交', async () => {
|
||||
wrapper = mount(IdentityImageDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
open: true,
|
||||
subjectId: 's1',
|
||||
subjectName: '林夏',
|
||||
images: [identityImageFixture()],
|
||||
disabled: false
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
input('#identity-reference', 'identity-image-1')
|
||||
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ images: [] })
|
||||
expect(button('确认生成身份图').disabled).toBe(true)
|
||||
await wrapper.setProps({ subjectId: 's2', subjectName: '陆川' })
|
||||
expect(selectControl(wrapper!, 'id', 'identity-reference').props('value')).toBe('')
|
||||
expect(document.querySelector('#identity-image-cost')!.getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('Character 普通身份图入口只提供辅助视角,primary 必须走选角候选接口', async () => {
|
||||
wrapper = mount(IdentityImageDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
open: true,
|
||||
subjectId: 's1',
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
images: [identityImageFixture()],
|
||||
disabled: false
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
const select = selectControl(wrapper!, 'id', 'identity-view')
|
||||
expect(select.props('options')!.map(item => item.value)).toEqual(['front', 'three-quarter', 'full-body'])
|
||||
expect(select.props('value')).toBe('front')
|
||||
})
|
||||
|
||||
it('辅助图不能切换母版,提示词按纯文本展示', async () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: false,
|
||||
disabled: false,
|
||||
images: [identityImageFixture({ viewType: 'front', isAnchor: false })]
|
||||
}
|
||||
})
|
||||
expect(button('确认选角并锁定').disabled).toBe(true)
|
||||
await expandSections()
|
||||
expect(wrapper.text()).toContain('<script>不执行</script>')
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('角色已有母版但未锁定时仍可确认选角,确认文案不冒充普通母版切换', async () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: false,
|
||||
disabled: false,
|
||||
images: [identityImageFixture({ isAnchor: true })]
|
||||
}
|
||||
})
|
||||
button('确认选角并锁定').click()
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('同一事务中切换身份母版并锁定 Identity')
|
||||
button('确认演员选择').click()
|
||||
expect(wrapper.emitted('anchor')).toEqual([['identity-image-1']])
|
||||
})
|
||||
|
||||
it('选角候选不再发送 Provider,不携带普通身份图视角字段', async () => {
|
||||
wrapper = mount(CastingCandidateDialog, {
|
||||
attachTo: document.body,
|
||||
props: { open: true, subjectId: 'subject-1', subjectName: '林夏', disabled: false }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('不会复用当前身份母版')
|
||||
document.querySelector<HTMLInputElement>('#casting-candidate-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成候选').click()
|
||||
expect(wrapper.emitted('generate')).toEqual([[{}]])
|
||||
})
|
||||
|
||||
it('角色选角接口区分批量身份、候选生图与确认事务', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
total: 0,
|
||||
targetCount: 0,
|
||||
generated: 0,
|
||||
skipped: 0,
|
||||
skippedLocked: 0,
|
||||
failed: 0,
|
||||
failures: []
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await subjectIdentityApi.generateCharacters('project/1', { force: false, concurrency: 2 })
|
||||
await subjectIdentityApi.generateCastingCandidate('subject/1', {})
|
||||
await subjectIdentityApi.confirmCasting('subject/1', 'image/1')
|
||||
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/projects/project%2F1/character-identities/generate',
|
||||
'/api/subjects/subject%2F1/identity/casting-candidates',
|
||||
'/api/subjects/subject%2F1/identity/images/image%2F1/casting'
|
||||
])
|
||||
expect(fetcher.mock.calls.map(([, init]) => init?.method)).toEqual(['POST', 'POST', 'PUT'])
|
||||
})
|
||||
})
|
||||
@@ -1,182 +0,0 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NImage } from 'naive-ui'
|
||||
import IdentityThumbnail from './components/IdentityThumbnail.vue'
|
||||
import { identityFixture, identityImageFixture } from './testing/fixtures'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
let observers: {
|
||||
callback: IntersectionObserverCallback
|
||||
observe: ReturnType<typeof vi.fn<(element: Element) => void>>
|
||||
disconnect: ReturnType<typeof vi.fn<() => void>>
|
||||
}[] = []
|
||||
const props = {
|
||||
projectId: 'project',
|
||||
subjectId: 'subject-db-1',
|
||||
name: '书店',
|
||||
module: 'scene',
|
||||
identityId: 'identity-db-1',
|
||||
refreshKey: 0
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observers = []
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
class {
|
||||
observe = vi.fn<(element: Element) => void>()
|
||||
disconnect = vi.fn<() => void>()
|
||||
unobserve = vi.fn<(element: Element) => void>()
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
observers.push({ callback, observe: this.observe, disconnect: this.disconnect })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 仅触发主体缩略图的可见事件,不触发 NImage 内部图片懒加载观察器。 */
|
||||
async function reveal() {
|
||||
const observer = observers.find(item => item.observe.mock.calls.some(([element]) => element === wrapper!.element))!
|
||||
observer.callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
|
||||
await flushPromises()
|
||||
expect(observer.disconnect).toHaveBeenCalled()
|
||||
}
|
||||
|
||||
/** 测试接口返回独立 Response,读取图片元数据不消耗生成额度。 */
|
||||
function response(data: unknown) {
|
||||
return new Response(JSON.stringify({ data }))
|
||||
}
|
||||
|
||||
describe('主体目录母版缩略图', () => {
|
||||
it.each([
|
||||
['character', '人物'],
|
||||
['scene', '场景'],
|
||||
['prop', '道具']
|
||||
])('没有母版的 %s 显示类型占位,不补查或生成图片', async (module, label) => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(IdentityThumbnail, { props: { ...props, module, source: null } })
|
||||
await reveal()
|
||||
expect(wrapper.text()).toBe(label)
|
||||
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('暂无母版')
|
||||
expect(wrapper.findComponent(NImage).exists()).toBe(false)
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('直接复用目录母版地址,图片不抢占选择点击,加载失败显示默认占位', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(IdentityThumbnail, {
|
||||
attachTo: document.body,
|
||||
props: { ...props, source: '/storage/anchor.png' }
|
||||
})
|
||||
await reveal()
|
||||
expect(wrapper.getComponent(NImage).props()).toMatchObject({ objectFit: 'cover', previewDisabled: true })
|
||||
await wrapper.get('img').trigger('click')
|
||||
expect(document.querySelector('.n-image-preview-container')).toBeNull()
|
||||
await wrapper.get('img').trigger('error')
|
||||
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('母版暂不可用')
|
||||
await wrapper.setProps({ source: '/storage/new-anchor.png' })
|
||||
expect(wrapper.get('img').attributes('src')).toContain('/storage/new-anchor.png')
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('进入可视区域后才 GET 图库,仅选择权威母版,刷新时重新读取', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () =>
|
||||
response([
|
||||
identityImageFixture({
|
||||
id: 'candidate',
|
||||
isAnchor: false,
|
||||
enabled: false,
|
||||
imageUrl: '/storage/candidate.png'
|
||||
}),
|
||||
identityImageFixture({
|
||||
id: 'front',
|
||||
isAnchor: false,
|
||||
viewType: 'front',
|
||||
imageUrl: '/storage/front.png'
|
||||
}),
|
||||
identityImageFixture({ imageUrl: '/storage/anchor.png' })
|
||||
])
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(IdentityThumbnail, { props })
|
||||
await flushPromises()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
await reveal()
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/subjects/subject-db-1/identity/images')
|
||||
expect(wrapper.get('img').attributes('src')).toContain('/storage/anchor.png')
|
||||
await wrapper.setProps({ refreshKey: 1 })
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||
})
|
||||
|
||||
it('目录未附带身份摘要时先校验正式身份,再读取母版', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(async url =>
|
||||
response(String(url).endsWith('/images') ? [identityImageFixture()] : identityFixture())
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(IdentityThumbnail, { props: { ...props, identityId: undefined } })
|
||||
await reveal()
|
||||
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/subjects/subject-db-1/identity',
|
||||
'/api/subjects/subject-db-1/identity/images'
|
||||
])
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('切项目中止旧请求,即使迟到也不能覆盖新主体占位', async () => {
|
||||
let finish!: (response: Response) => void
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(IdentityThumbnail, { props })
|
||||
await reveal()
|
||||
await wrapper.setProps({
|
||||
projectId: 'other-project',
|
||||
subjectId: 'other-subject',
|
||||
name: '另一主体',
|
||||
source: null
|
||||
})
|
||||
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
|
||||
finish(response([identityImageFixture()]))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.get('[role="img"]').attributes('aria-label')).toBe('另一主体 · 暂无母版')
|
||||
})
|
||||
|
||||
it('候选与辅助视角不能冒充母版,跨身份图片返回错误占位', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(response([identityImageFixture({ isAnchor: false })]))
|
||||
.mockResolvedValueOnce(response([identityImageFixture({ identityId: 'wrong-identity' })]))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(IdentityThumbnail, { props })
|
||||
await reveal()
|
||||
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('暂无母版')
|
||||
await wrapper.setProps({ refreshKey: 1 })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('母版暂不可用')
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('危险地址不写入图片,无效图片也不会触发生图', () => {
|
||||
wrapper = mount(IdentityThumbnail, { props: { ...props, source: 'javascript:alert(1)' } })
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('母版暂不可用')
|
||||
})
|
||||
})
|
||||
@@ -739,3 +739,118 @@ async function showStaleForms() {
|
||||
@changed="refreshAssets" /></template
|
||||
></WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.form-image-grid {
|
||||
@apply grid grid-cols-[repeat(auto-fill,_minmax(min(260px,_100%),_1fr))] gap-5;
|
||||
}
|
||||
.form-image-card {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
.gallery-workspace-page .workspace-scroll-content {
|
||||
@apply pt-2;
|
||||
}
|
||||
.gallery-workspace-page .gallery-sticky-controls {
|
||||
@apply sticky top-0 z-10 bg-(--app-body) mt-2.5 mb-0;
|
||||
}
|
||||
.gallery-workspace-page .form-image-grid {
|
||||
@apply pt-3;
|
||||
}
|
||||
.gallery-workspace-page .form-image-masonry {
|
||||
@apply block columns-[260px] gap-x-5;
|
||||
}
|
||||
.form-image-masonry > .form-image-card {
|
||||
@apply break-inside-avoid mb-5;
|
||||
}
|
||||
.form-image-masonry .asset-image {
|
||||
@apply aspect-[var(--form-image-aspect,4/3)];
|
||||
}
|
||||
.gallery-sticky-controls > .form-image-filter-region {
|
||||
@apply my-0;
|
||||
}
|
||||
.gallery-workspace-page .workspace-scroll > .n-scrollbar-container {
|
||||
@apply [overflow-anchor:none];
|
||||
}
|
||||
.form-image-filter-region {
|
||||
@apply my-4 py-3.5 px-4 bg-(--app-subtle);
|
||||
container-type: inline-size;
|
||||
}
|
||||
.form-image-toolbar {
|
||||
@apply grid grid-cols-[minmax(0,_1fr)] items-center gap-y-3 gap-x-4;
|
||||
}
|
||||
.form-image-toolbar.has-impact-picker {
|
||||
@apply grid-cols-[minmax(220px,_340px)_minmax(0,_1fr)];
|
||||
}
|
||||
.form-image-toolbar.has-impact-picker.has-impact-actions {
|
||||
@apply grid-cols-[minmax(260px,_420px)_minmax(0,_1fr)];
|
||||
}
|
||||
.asset-impact-picker {
|
||||
@apply flex items-center gap-2 min-w-0;
|
||||
}
|
||||
.asset-impact-picker .asset-impact-select {
|
||||
@apply flex-1;
|
||||
}
|
||||
.form-image-toolbar > * {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.form-image-filters {
|
||||
@apply grid grid-cols-[minmax(200px,_360px)_152px_minmax(260px,_1fr)_auto] items-center gap-y-3 gap-x-4;
|
||||
}
|
||||
.form-image-filters > * {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.filter-toggles {
|
||||
@apply flex items-center flex-wrap gap-y-2.5 gap-x-4 text-xs;
|
||||
}
|
||||
.filter-toggles .n-checkbox__label {
|
||||
@apply whitespace-nowrap;
|
||||
}
|
||||
.filter-result-count {
|
||||
@apply justify-self-end text-muted text-xs whitespace-nowrap;
|
||||
}
|
||||
.form-image-display-controls {
|
||||
@apply flex items-center justify-self-end gap-3;
|
||||
}
|
||||
.gallery-layout-switch {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
@container (max-width: 1200px) {
|
||||
.form-image-toolbar.has-impact-picker,
|
||||
.form-image-toolbar.has-impact-picker.has-impact-actions {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
}
|
||||
}
|
||||
@container (max-width: 900px) {
|
||||
.form-image-filters {
|
||||
@apply grid-cols-[minmax(0,_1fr)_152px];
|
||||
}
|
||||
.form-image-display-controls {
|
||||
@apply col-[1/-1];
|
||||
}
|
||||
}
|
||||
@container (max-width: 460px) {
|
||||
.form-image-filters {
|
||||
@apply grid-cols-[minmax(0,_1fr)_120px] gap-x-2.5;
|
||||
}
|
||||
}
|
||||
.asset-impact-context {
|
||||
@apply flex flex-col gap-2.5 py-[5px] px-4 bg-(--app-subtle) m-0 min-w-0;
|
||||
}
|
||||
.asset-impact-links-scroll.n-scrollbar {
|
||||
@apply h-auto min-w-0 max-w-full;
|
||||
}
|
||||
.asset-impact-links {
|
||||
@apply flex items-center gap-3 w-max whitespace-nowrap min-h-8 py-1.5;
|
||||
}
|
||||
.asset-impact-links > * {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.asset-impact-select {
|
||||
@apply min-w-0 w-full;
|
||||
}
|
||||
.form-image-card-focused {
|
||||
@apply shadow-[inset_3px_0_0_var(--app-accent-text)];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NSelect } from 'naive-ui'
|
||||
import { testProjectContext } from '../../testing/project-context'
|
||||
import { projectContextKey } from '../projects/context'
|
||||
import { formFixture, imageFixture } from './testing/fixtures'
|
||||
import { referenceLocation, referenceTargets } from '../production/asset-links'
|
||||
import StaleKeyframeNotice from '../production/components/StaleKeyframeNotice.vue'
|
||||
import SubjectImagesPage from './SubjectImagesPage.vue'
|
||||
import ProductionPage from '../production/ProductionPage.vue'
|
||||
import { directionsResult, storyboardCheckpoint } from '../storyboard/testing/fixtures'
|
||||
import type { ProductionIssue } from '../production/types'
|
||||
import type { ShotReferences } from '../storyboard/types'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 镜头引用非默认形态,用于防止退化成仅按主体名搜索。 */
|
||||
function fixture() {
|
||||
const first = formFixture('capability-test')
|
||||
const second = {
|
||||
...first,
|
||||
id: 'form-special',
|
||||
name: '雨夜形态',
|
||||
isDefault: false,
|
||||
images: [imageFixture({ subjectFormId: 'form-special' })]
|
||||
}
|
||||
const other = {
|
||||
...formFixture('capability-test'),
|
||||
id: 'form-other',
|
||||
subjectId: 'subject-other',
|
||||
subject: { ...first.subject, id: 'subject-other', name: '路人', ref: '@CH0005' },
|
||||
images: [imageFixture({ subjectFormId: 'form-other' })]
|
||||
}
|
||||
const references: ShotReferences = {
|
||||
shotId: 'shot-target',
|
||||
references: [
|
||||
{
|
||||
shotSubjectId: 'binding-1',
|
||||
subjectId: first.subjectId,
|
||||
subjectRef: first.subject.ref,
|
||||
subjectName: first.subject.name,
|
||||
module: 'character',
|
||||
subjectFormId: second.id,
|
||||
subjectFormName: second.name,
|
||||
imageId: 'image-db-1',
|
||||
imageUrl: '/storage/current.png'
|
||||
},
|
||||
{
|
||||
shotSubjectId: 'binding-2',
|
||||
subjectId: other.subjectId,
|
||||
subjectRef: other.subject.ref,
|
||||
subjectName: other.subject.name,
|
||||
module: 'character',
|
||||
subjectFormId: other.id,
|
||||
subjectFormName: other.name,
|
||||
imageId: 'other-image',
|
||||
imageUrl: '/storage/other.png'
|
||||
}
|
||||
],
|
||||
missing: []
|
||||
}
|
||||
const issues: ProductionIssue[] = [
|
||||
{ code: 'stale_keyframe', reason: '参考资产已变化: @CH0001, @CH0005', missingSubjects: ['@CH0001', '@CH0005'] }
|
||||
]
|
||||
const keyframes = {
|
||||
total: 1,
|
||||
ready: 1,
|
||||
skipped: 0,
|
||||
blocked: 0,
|
||||
stalePrimaryKeyframe: 1,
|
||||
items: [
|
||||
{
|
||||
shotId: 'shot-target',
|
||||
shotNo: 1,
|
||||
episodeNo: 2,
|
||||
beatNo: 2,
|
||||
status: 'ready',
|
||||
primaryKeyframeStale: true,
|
||||
primaryKeyframeId: 'keyframe-old',
|
||||
issues: []
|
||||
}
|
||||
]
|
||||
}
|
||||
const videos = {
|
||||
total: 1,
|
||||
ready: 0,
|
||||
skipped: 0,
|
||||
blocked: 1,
|
||||
items: [{ shotId: 'shot-target', shotNo: 1, status: 'blocked', issues }]
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
|
||||
const path = String(url)
|
||||
const data = path.endsWith('/subject-forms')
|
||||
? [first, second, other]
|
||||
: path.includes('/keyframes/readiness')
|
||||
? keyframes
|
||||
: path.includes('/videos/readiness')
|
||||
? videos
|
||||
: path.endsWith('/references')
|
||||
? references
|
||||
: []
|
||||
return new Response(JSON.stringify({ data }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
return { first, second, other, references, issues, keyframes, videos, fetcher }
|
||||
}
|
||||
|
||||
/** 在真实内存路由中验证跨页 URL,不替换 RouterLink 行为。 */
|
||||
async function gallery(query = '') {
|
||||
const data = fixture()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/projects/:projectId/subject-images', component: SubjectImagesPage },
|
||||
{ path: '/projects/:projectId/production', component: { template: '<div />' } }
|
||||
]
|
||||
})
|
||||
await router.push('/projects/capability-test/subject-images' + query)
|
||||
wrapper = mount(SubjectImagesPage, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router], provide: { [projectContextKey as symbol]: testProjectContext() } }
|
||||
})
|
||||
await flushPromises()
|
||||
return { ...data, router }
|
||||
}
|
||||
|
||||
describe('过期首帧到具体素材定位', () => {
|
||||
it('网格与瀑布流切换保留卡片、滚动容器和筛选,不发起额外请求', async () => {
|
||||
const { fetcher } = await gallery()
|
||||
const requestCount = fetcher.mock.calls.length
|
||||
const scroll = wrapper!.get<HTMLElement>('.workspace-scroll > .n-scrollbar-container').element
|
||||
const grid = wrapper!.get('.form-image-grid').element
|
||||
const cards = wrapper!.findAll('.form-image-card').map(card => card.element)
|
||||
scroll.scrollTop = 240
|
||||
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('true')
|
||||
await wrapper!.get('[aria-label="瀑布流布局"]').trigger('click')
|
||||
expect(wrapper!.get('.form-image-masonry').element).toBe(grid)
|
||||
expect(wrapper!.get('[aria-label="瀑布流布局"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper!.findAll('.form-image-card').map(card => card.element)).toEqual(cards)
|
||||
expect(scroll.scrollTop).toBe(240)
|
||||
expect(
|
||||
wrapper!.get<HTMLElement>('.form-image-card').element.style.getPropertyValue('--form-image-aspect')
|
||||
).toBe('2048 / 2048')
|
||||
await wrapper!.get('input[aria-label="搜索形态图片"]').setValue('雨夜')
|
||||
await wrapper!.get('[aria-label="网格布局"]').trigger('click')
|
||||
expect(wrapper!.find('.form-image-masonry').exists()).toBe(false)
|
||||
expect(wrapper!.get('.form-image-grid').element).toBe(grid)
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
|
||||
expect(wrapper!.get('.form-image-card').text()).toContain('雨夜形态')
|
||||
expect(wrapper!.get('.form-image-card').text()).toContain('查看图片与记录')
|
||||
expect(wrapper!.get('.workspace-scroll > .n-scrollbar-container').element).toBe(scroll)
|
||||
expect(fetcher).toHaveBeenCalledTimes(requestCount)
|
||||
})
|
||||
|
||||
it('图库与关联检查不定时刷新,手动刷新仍能更新数据', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { fetcher } = await gallery('?sourceShotId=shot-target')
|
||||
const count = fetcher.mock.calls.length
|
||||
const grid = wrapper!.get('.form-image-grid').element
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(count)
|
||||
expect(wrapper!.get('.form-image-grid').element).toBe(grid)
|
||||
const refresh = wrapper!.findAll('button').find(button => button.text().includes('刷新图库'))!
|
||||
await refresh.trigger('click')
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls.length).toBeGreaterThan(count)
|
||||
const refreshed = fetcher.mock.calls.length
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(fetcher).toHaveBeenCalledTimes(refreshed)
|
||||
})
|
||||
|
||||
it('切换镜头时筛选、关联说明和图片共用稳定纵向容器,长关联列表单独横向滚动', async () => {
|
||||
const { keyframes, videos, references, fetcher } = await gallery('?sourceShotId=shot-target')
|
||||
expect(wrapper!.find('.workspace-heading-scroll').exists()).toBe(false)
|
||||
const scroll = wrapper!.get<HTMLElement>('.workspace-scroll > .n-scrollbar-container').element
|
||||
const content = wrapper!.get('.workspace-scroll-content').element
|
||||
const sticky = wrapper!.get('.gallery-sticky-controls').element
|
||||
const toolbar = wrapper!.get('.form-image-filter-region').element
|
||||
expect(sticky.parentElement).toBe(content)
|
||||
expect(toolbar.parentElement).toBe(sticky)
|
||||
expect(wrapper!.get('.asset-impact-context').element.parentElement).toBe(sticky)
|
||||
expect(wrapper!.get('.form-image-grid').element.parentElement).toBe(content)
|
||||
scroll.scrollTop = 480
|
||||
keyframes.items.push({ ...keyframes.items[0]!, shotId: 'shot-next', shotNo: 2 })
|
||||
videos.items.push({ ...videos.items[0]!, shotId: 'shot-next', shotNo: 2 })
|
||||
keyframes.total = videos.total = 2
|
||||
let finish!: (response: Response) => void
|
||||
const original = fetcher.getMockImplementation()!
|
||||
fetcher.mockImplementation((url, init) =>
|
||||
String(url).endsWith('/shot-next/references')
|
||||
? new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
: original(url, init)
|
||||
)
|
||||
await wrapper!
|
||||
.findAll('button')
|
||||
.find(button => button.text() === '刷新图库')!
|
||||
.trigger('click')
|
||||
await flushPromises()
|
||||
wrapper!
|
||||
.findAllComponents(NSelect)
|
||||
.find(item => item.attributes('aria-label') === '选择关联镜头')!
|
||||
.vm.$emit('update:value', 'shot-next')
|
||||
await flushPromises()
|
||||
expect(wrapper!.text()).toContain('正在读取镜头关联素材')
|
||||
finish(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
...references,
|
||||
shotId: 'shot-next',
|
||||
references: Array.from({ length: 24 }, (_, index) => ({
|
||||
...references.references[0]!,
|
||||
subjectFormId: `long-form-${index}`,
|
||||
subjectFormName: `很长的关联素材名称${index}`
|
||||
}))
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.workspace-scroll > .n-scrollbar-container').element).toBe(scroll)
|
||||
expect(wrapper!.get('.form-image-filter-region').element).toBe(toolbar)
|
||||
expect(scroll.scrollTop).toBe(480)
|
||||
expect(wrapper!.get('.gallery-sticky-controls').element).toBe(sticky)
|
||||
expect(wrapper!.get('.asset-impact-context').element.parentElement).toBe(sticky)
|
||||
expect(wrapper!.get('.form-image-grid').element.parentElement).toBe(content)
|
||||
const links = wrapper!.get('.asset-impact-links-scroll')
|
||||
expect(links.findAll('a')).toHaveLength(24)
|
||||
expect(links.text()).toContain('很长的关联素材名称23')
|
||||
expect(links.classes()).toContain('n-scrollbar')
|
||||
expect(links.find('.n-scrollbar-container > .asset-impact-links').exists()).toBe(true)
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||
})
|
||||
|
||||
it('切换镜头取消旧参考图查询,迟到响应不会产生旧素材链接', async () => {
|
||||
const { references } = fixture()
|
||||
let finish!: (response: Response) => void
|
||||
const nextReferences = {
|
||||
...references,
|
||||
shotId: 'shot-new',
|
||||
references: [{ ...references.references[0]!, subjectFormId: 'form-new', subjectFormName: '新镜头形态' }]
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(url =>
|
||||
String(url).includes('shot-target')
|
||||
? new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
: Promise.resolve(new Response(JSON.stringify({ data: nextReferences })))
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
|
||||
})
|
||||
await router.push('/')
|
||||
wrapper = mount(StaleKeyframeNotice, {
|
||||
global: { plugins: [router] },
|
||||
props: { projectId: 'capability-test', shotId: 'shot-target', issues: [] }
|
||||
})
|
||||
await flushPromises()
|
||||
const signal = fetcher.mock.calls[0]?.[1]?.signal
|
||||
await wrapper.setProps({ shotId: 'shot-new' })
|
||||
await flushPromises()
|
||||
expect(signal?.aborted).toBe(true)
|
||||
finish(new Response(JSON.stringify({ data: references })))
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('新镜头形态')
|
||||
expect(wrapper.findAll('a').some(link => link.attributes('href')?.includes('form-special'))).toBe(false)
|
||||
})
|
||||
it('参考素材查询失败时保留引用级定位和重试,不静默选择默认形态', async () => {
|
||||
const { issues } = fixture()
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockResolvedValue(new Response('{}', { status: 500 })))
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
|
||||
})
|
||||
await router.push('/')
|
||||
wrapper = mount(StaleKeyframeNotice, {
|
||||
global: { plugins: [router] },
|
||||
props: { projectId: 'capability-test', shotId: 'shot-target', issues, inconsistent: true }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('状态待核对')
|
||||
expect(wrapper.text()).toContain('重试定位')
|
||||
expect(wrapper.findAll('a').every(link => !link.attributes('href')?.includes('subjectFormId'))).toBe(true)
|
||||
})
|
||||
it('结构化变更引用映射到实际使用的非默认形态,并保留来源镜头', () => {
|
||||
const { references, issues } = fixture()
|
||||
const targets = referenceTargets(references, issues)
|
||||
expect(targets.map(item => item.formId)).toEqual(['form-special', 'form-other'])
|
||||
expect(referenceLocation('project/1', 'shot-target', targets[0]!)).toEqual({
|
||||
path: '/projects/project%2F1/subject-images',
|
||||
query: { sourceShotId: 'shot-target', subjectRef: '@CH0001', subjectFormId: 'form-special' }
|
||||
})
|
||||
})
|
||||
it('没有当前关联时只回退到引用,不猜默认形态;没有变更列表时展示关联素材', () => {
|
||||
const { references } = fixture()
|
||||
const targets = referenceTargets(references, [
|
||||
{ code: 'stale_keyframe', reason: '历史主体被移除', missingSubjects: ['@CH0999'] }
|
||||
])
|
||||
expect(targets[0]?.formId).toBeUndefined()
|
||||
expect(targets[0]?.label).toContain('当前形态待核对')
|
||||
expect(referenceTargets(references, [])).toHaveLength(2)
|
||||
})
|
||||
it('生产警告的引用可点击定位,点击不提交生图', async () => {
|
||||
const { issues, fetcher } = fixture()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
|
||||
})
|
||||
await router.push('/projects/capability-test/production')
|
||||
wrapper = mount(StaleKeyframeNotice, {
|
||||
global: { plugins: [router] },
|
||||
props: { projectId: 'capability-test', shotId: 'shot-target', issues }
|
||||
})
|
||||
await flushPromises()
|
||||
const links = wrapper.findAll('a')
|
||||
expect(links).toHaveLength(2)
|
||||
expect(links[0]?.text()).toContain('雨夜形态')
|
||||
await links[0]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query.subjectFormId).toBe('form-special')
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||
})
|
||||
it('图库直接访问即显示下游警告,但不把有效素材误标成身份过期', async () => {
|
||||
const { fetcher } = await gallery()
|
||||
expect(wrapper!.get('[data-keyframe-impact]').text()).toContain('1 个镜头')
|
||||
expect(wrapper!.text()).toContain('不表示形态图片本身失效')
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
|
||||
expect(wrapper!.text()).not.toContain('个形态主图与当前已锁定身份母版不一致')
|
||||
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/references'))).toBe(false)
|
||||
// 镜头选择和图库筛选共享上方工具栏;未选择镜头时不留下空的关联说明面板。
|
||||
const toolbar = wrapper!.get('.form-image-toolbar')
|
||||
const picker = toolbar.get('.asset-impact-picker').element
|
||||
expect(toolbar.classes()).toContain('has-impact-picker')
|
||||
expect(picker.nextElementSibling).toBe(toolbar.get('.form-image-filters').element)
|
||||
expect(toolbar.find('input[aria-label="搜索形态图片"]').exists()).toBe(true)
|
||||
expect(toolbar.findAll('.filter-toggles [role="checkbox"]')).toHaveLength(2)
|
||||
expect(wrapper!.find('.asset-impact-context').exists()).toBe(false)
|
||||
const select = wrapper!
|
||||
.findAllComponents(NSelect)
|
||||
.find(item => item.attributes('aria-label') === '选择关联镜头')!
|
||||
select.vm.$emit('update:value', 'shot-target')
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.asset-impact-context').element.previousElementSibling).toBe(
|
||||
wrapper!.get('.form-image-filter-region').element
|
||||
)
|
||||
expect(wrapper!.text()).toContain('待处理首帧关联此素材')
|
||||
expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith('/references'))).toHaveLength(1)
|
||||
})
|
||||
it('镜头导航与筛选共行,无关联说明时不留横条,查看全部素材清除定位和筛选', async () => {
|
||||
const { references, router, fetcher } = await gallery()
|
||||
references.references = []
|
||||
await router.push({ query: { sourceShotId: 'shot-target' } })
|
||||
await flushPromises()
|
||||
const picker = wrapper!.get('.asset-impact-picker')
|
||||
const back = picker.get('a[aria-label="返回第 2 集 · 镜头 1"]')
|
||||
expect(back.classes()).toContain('icon-button')
|
||||
expect(new URL(back.attributes('href')!, 'https://local.test').searchParams.get('shotId')).toBe('shot-target')
|
||||
expect(picker.get('[aria-label="查看全部素材"]').classes()).toContain('icon-button')
|
||||
expect(wrapper!.find('.asset-impact-context').exists()).toBe(false)
|
||||
await wrapper!.get('input[aria-label="搜索形态图片"]').setValue('不存在的素材')
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(0)
|
||||
await picker.get('[aria-label="查看全部素材"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query.sourceShotId).toBeUndefined()
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
|
||||
expect(wrapper!.find('.asset-impact-picker a').exists()).toBe(false)
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||
})
|
||||
|
||||
it('精准定位卡片、清空旧类型筛选、提供可恢复原镜头的返回链接', async () => {
|
||||
const { router } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special&subjectRef=%40CH0001')
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
|
||||
expect(wrapper!.get('.form-image-card').attributes('data-form-id')).toBe('form-special')
|
||||
expect(wrapper!.get('.form-image-card').classes()).toContain('form-image-card-focused')
|
||||
expect(wrapper!.get('[data-focused-material]').text()).toContain('雨夜形态')
|
||||
const back = wrapper!.findAll('a').find(link => link.text().includes('返回第 2 集'))!
|
||||
const url = new URL(back.attributes('href')!, 'https://local.test')
|
||||
expect(url.searchParams.get('episodeNo')).toBe('2')
|
||||
expect(url.searchParams.get('shotId')).toBe('shot-target')
|
||||
const type = wrapper!.findAllComponents(NSelect).find(item => item.attributes('aria-label') === '筛选主体类型')!
|
||||
type.vm.$emit('update:value', 'scene')
|
||||
await flushPromises()
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(0)
|
||||
await router.push({ query: { sourceShotId: 'shot-target', subjectFormId: 'form-other' } })
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.form-image-card').attributes('data-form-id')).toBe('form-other')
|
||||
const clear = wrapper!.findAll('button').find(button => button.text() === '清除定位')!
|
||||
await clear.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
|
||||
})
|
||||
it('身份主图真的过期时显示独立顶部警告,并可从定位模式筛选所有过期素材', async () => {
|
||||
const { first, router } = await gallery('?subjectFormId=form-other')
|
||||
first.subject.identity = { id: 'identity-1', isLocked: true, images: [{ id: 'anchor-new' }] }
|
||||
const refresh = wrapper!.findAll('button').find(button => button.text() === '刷新图库')!
|
||||
await refresh.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper!.text()).toContain('形态主图需要更新')
|
||||
await wrapper!
|
||||
.findAll('button')
|
||||
.find(button => button.text() === '筛选身份过期形态')!
|
||||
.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query.subjectFormId).toBeUndefined()
|
||||
expect(wrapper!.findAll('.form-image-card').map(item => item.attributes('data-form-id'))).toEqual([
|
||||
'form-db-1',
|
||||
'form-special'
|
||||
])
|
||||
})
|
||||
it('仅视频接口报告过期时也显示警告,并明确两种检查不一致', async () => {
|
||||
const { keyframes } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special')
|
||||
keyframes.items[0]!.primaryKeyframeStale = false
|
||||
await wrapper!
|
||||
.findAll('button')
|
||||
.find(button => button.text() === '刷新图库')!
|
||||
.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('[data-keyframe-impact]').text()).toContain('检查结果不一致')
|
||||
expect(wrapper!.get('.form-image-card').text()).toContain('不要据此重复生图')
|
||||
})
|
||||
it('镜头修复后清除过期警告,保留明确的当前状态和返回入口', async () => {
|
||||
const { keyframes, videos } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special')
|
||||
keyframes.items[0]!.primaryKeyframeStale = false
|
||||
videos.items[0]!.issues = []
|
||||
await wrapper!
|
||||
.findAll('button')
|
||||
.find(button => button.text() === '刷新图库')!
|
||||
.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper!.find('[data-keyframe-impact]').exists()).toBe(false)
|
||||
expect(wrapper!.text()).toContain('未被标记为过期')
|
||||
})
|
||||
it('无效镜头和已移除形态不发跨项目请求,也不误定位其他素材', async () => {
|
||||
const { fetcher } = await gallery('?sourceShotId=foreign-shot&subjectFormId=deleted-form')
|
||||
expect(wrapper!.text()).toContain('来源镜头不存在或不属于当前项目')
|
||||
expect(wrapper!.get('[data-focused-material]').text()).toContain('指定形态不存在')
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(0)
|
||||
expect(fetcher.mock.calls.some(([url]) => String(url).includes('foreign-shot/references'))).toBe(false)
|
||||
})
|
||||
it('影响查询失败不能当成没有过期首帧,仍然显示已有图库', async () => {
|
||||
const { fetcher } = await gallery()
|
||||
const original = fetcher.getMockImplementation()!
|
||||
fetcher.mockImplementation((url, init) =>
|
||||
String(url).includes('/readiness')
|
||||
? Promise.resolve(new Response('{}', { status: 500 }))
|
||||
: original(url, init)
|
||||
)
|
||||
await wrapper!
|
||||
.findAll('button')
|
||||
.find(button => button.text() === '刷新图库')!
|
||||
.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper!.text()).toContain('暂时无法确认有无过期首帧')
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
|
||||
})
|
||||
it('素材返回生产页时按正式 Shot ID 选择,不误选同编号的另一个 Beat', async () => {
|
||||
const context = testProjectContext()
|
||||
context.data.value!.checkpoints = [storyboardCheckpoint()]
|
||||
const data = directionsResult('capability-test', 2)
|
||||
const target = data.beats[1]!.shots[0]!
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
|
||||
const path = String(url)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: path.includes('/storyboard-directions')
|
||||
? data
|
||||
: path.includes('/readiness') || path.endsWith('/videos/status')
|
||||
? { total: 0, items: [] }
|
||||
: []
|
||||
})
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/projects/:projectId/production', component: ProductionPage }]
|
||||
})
|
||||
await router.push({
|
||||
path: '/projects/capability-test/production',
|
||||
query: { episodeNo: '2', shotId: target.shotId }
|
||||
})
|
||||
wrapper = mount(ProductionPage, {
|
||||
global: { plugins: [router], provide: { [projectContextKey as symbol]: context } }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.production-detail').text()).toContain(target.shotId)
|
||||
expect(fetcher.mock.calls.some(([url]) => String(url).includes('episodeNo=2'))).toBe(true)
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,223 +0,0 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AssetImage } from '../../components/ui'
|
||||
import { getOperation } from '../workflows/operations'
|
||||
import GenerateImageDialog from './components/GenerateImageDialog.vue'
|
||||
import ImageGalleryDialog from './components/ImageGalleryDialog.vue'
|
||||
import { coverImage, hasRunningImages, primaryImage, validImageSize } from './model'
|
||||
import { formFixture, imageFixture } from './testing/fixtures'
|
||||
import { expandSections } from '../../testing/naive'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
/** Naive 将弹窗挂载到 body,需要从实际弹窗找到按钮。 */
|
||||
function button(label: string): HTMLButtonElement {
|
||||
const element = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)
|
||||
if (!element) throw new Error('找不到按钮:' + label)
|
||||
return element
|
||||
}
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
Object.assign(getOperation('gallery-test'), { pending: false, label: '', error: '', notice: '' })
|
||||
})
|
||||
|
||||
describe('形态图片选择与操作', () => {
|
||||
it('形态图库复用有界历史栏,多张候选与失败记录切换只更新预览', async () => {
|
||||
const rows = [
|
||||
imageFixture(),
|
||||
imageFixture({ id: 'candidate', isPrimary: false, imageUrl: '/storage/candidate.png' }),
|
||||
imageFixture({ id: 'failed', isPrimary: false, status: 'failed', imageUrl: null, error: '生成失败详情' })
|
||||
]
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({ data: rows })))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(ImageGalleryDialog, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'gallery-test', form: formFixture('gallery-test'), open: true, disabled: false }
|
||||
})
|
||||
await flushPromises()
|
||||
const history = document.querySelector<HTMLElement>('[aria-label="图片历史"]')!
|
||||
expect(history.querySelectorAll('.image-history-item')).toHaveLength(3)
|
||||
expect(document.querySelector<HTMLImageElement>('.asset-image-preview img')?.style.objectFit).toBe('contain')
|
||||
expect([...history.querySelectorAll('img')].every(image => image.style.objectFit === 'cover')).toBe(true)
|
||||
expect(history.previousElementSibling?.textContent).toContain('历史记录 · 3 条')
|
||||
expect(history.previousElementSibling?.previousElementSibling?.classList.contains('asset-image-preview')).toBe(
|
||||
true
|
||||
)
|
||||
history.querySelector<HTMLButtonElement>('[aria-label="查看图片 candidate"]')!.click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.asset-image-preview img')?.getAttribute('src')).toContain(
|
||||
'/storage/candidate.png'
|
||||
)
|
||||
expect(history.querySelector('[aria-label="查看图片 candidate"]')?.getAttribute('aria-pressed')).toBe('true')
|
||||
expect(document.querySelector<HTMLImageElement>('.asset-image-preview img')?.style.objectFit).toBe('contain')
|
||||
history.querySelector<HTMLButtonElement>('[aria-label="查看图片 failed"]')!.click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.asset-image-preview')?.textContent).toContain('本次生成失败')
|
||||
expect(button('设为主参考图').disabled).toBe(true)
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(fetcher.mock.calls[0]![1]?.method).toBe('GET')
|
||||
expect(wrapper.emitted('changed')).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['character', 'scene', 'prop'])('按后端最新 %s 模块展示母版继承范围', async module => {
|
||||
const form = formFixture()
|
||||
form.subject.module = module
|
||||
wrapper = mount(GenerateImageDialog, { attachTo: document.body, props: { open: true, form, disabled: false } })
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('母版')
|
||||
expect(document.body.textContent).not.toContain('道具形态生图暂不自动引用')
|
||||
})
|
||||
it('已有主图优先于新候选图和失败记录,无主图才回退到成功候选图', () => {
|
||||
const form = formFixture()
|
||||
form.images.unshift(
|
||||
imageFixture({ id: 'failed', isPrimary: false, status: 'failed', imageUrl: null }),
|
||||
imageFixture({ id: 'candidate', isPrimary: false })
|
||||
)
|
||||
expect(coverImage(form)?.id).toBe('image-db-1')
|
||||
form.images.pop()
|
||||
expect(primaryImage(form.images)).toBeUndefined()
|
||||
expect(coverImage(form)?.id).toBe('candidate')
|
||||
expect(hasRunningImages(form.images)).toBe(false)
|
||||
form.images.push(imageFixture({ status: 'generating', isPrimary: false }))
|
||||
expect(hasRunningImages(form.images)).toBe(true)
|
||||
})
|
||||
|
||||
it('尺寸可同时留空,但不接受单边、零、负数或非整数', () => {
|
||||
expect(validImageSize('', '')).toBe(true)
|
||||
expect(validImageSize(2048, 2048)).toBe(true)
|
||||
for (const [width, height] of [
|
||||
[1024, ''],
|
||||
['', 1024],
|
||||
[0, 1024],
|
||||
[-1, 1024],
|
||||
[10.5, 1024]
|
||||
] as const)
|
||||
expect(validImageSize(width, height)).toBe(false)
|
||||
})
|
||||
|
||||
it('生图确认发送正式形态 ID,不覆盖后端模型,默认不替换已有主图', async () => {
|
||||
wrapper = mount(GenerateImageDialog, {
|
||||
attachTo: document.body,
|
||||
props: { open: true, form: formFixture(), disabled: false }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(button('确认生成图片').disabled).toBe(true)
|
||||
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成图片').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')).toEqual([['form-db-1', { setPrimary: false }]])
|
||||
})
|
||||
|
||||
it('新形态默认设主图,填写一侧尺寸时不能提交,切换形态清空自定义提示词', async () => {
|
||||
const form = formFixture()
|
||||
form.images = []
|
||||
wrapper = mount(GenerateImageDialog, { attachTo: document.body, props: { open: true, form, disabled: false } })
|
||||
await flushPromises()
|
||||
const width = document.querySelector<HTMLInputElement>('#image-width')!
|
||||
width.value = '2048'
|
||||
width.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
|
||||
await flushPromises()
|
||||
expect(button('确认生成图片').disabled).toBe(true)
|
||||
await wrapper.setProps({ form: { ...form, id: 'form-db-2' } })
|
||||
await flushPromises()
|
||||
expect(width.value).toBe('')
|
||||
expect(document.querySelector('#confirm-image-cost')!.getAttribute('aria-checked')).toBe('false')
|
||||
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成图片').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')).toEqual([['form-db-2', { setPrimary: true }]])
|
||||
})
|
||||
|
||||
it('图库不自动生图,主图切换经确认后 PUT,失败图片不能设主图', async () => {
|
||||
vi.useFakeTimers()
|
||||
let primary = false
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
|
||||
if (init?.method === 'PUT') {
|
||||
primary = true
|
||||
return new Response(JSON.stringify({ data: imageFixture() }))
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
imageFixture({ isPrimary: primary }),
|
||||
imageFixture({
|
||||
id: 'failed',
|
||||
isPrimary: false,
|
||||
status: 'failed',
|
||||
imageUrl: null,
|
||||
error: '供应商拒绝请求'
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(ImageGalleryDialog, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'gallery-test', form: formFixture('gallery-test'), open: true, disabled: false }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls).toHaveLength(1)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(fetcher.mock.calls).toHaveLength(1)
|
||||
await expandSections()
|
||||
expect(document.body.textContent).toContain('<script>模型提示词</script>')
|
||||
expect(document.querySelector('[role="dialog"] script')).toBeNull()
|
||||
button('设为主参考图').click()
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls).toHaveLength(1)
|
||||
button('确认切换主图').click()
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')?.[0]).toBe(
|
||||
'/api/subject-forms/form-db-1/images/image-db-1/primary'
|
||||
)
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="查看图片 failed"]')!.click()
|
||||
await flushPromises()
|
||||
expect(button('设为主参考图').disabled).toBe(true)
|
||||
expect(document.body.textContent).toContain('供应商拒绝请求')
|
||||
})
|
||||
|
||||
it('关闭图片弹窗取消查询,迟到的旧形态响应不会污染再次打开的形态', async () => {
|
||||
let finish!: (response: Response) => void
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
.mockImplementation(async () => new Response('{"data":[]}'))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(ImageGalleryDialog, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'gallery-test', form: formFixture('gallery-test'), open: true, disabled: false }
|
||||
})
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ open: false })
|
||||
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
|
||||
await wrapper.setProps({ open: true, form: { ...formFixture('gallery-test'), id: 'form-db-2' } })
|
||||
await flushPromises()
|
||||
finish(new Response(JSON.stringify({ data: [imageFixture()] })))
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).not.toContain('Image ID · image-db-1')
|
||||
expect(document.body.textContent).toContain('此形态尚无图片记录')
|
||||
})
|
||||
|
||||
it('图片加载失败有占位,地址变化可恢复,危险协议不会写入 img', async () => {
|
||||
wrapper = mount(AssetImage, { props: { src: '/storage/a.png', alt: '形态主图' } })
|
||||
await wrapper.get('img').trigger('error')
|
||||
expect(wrapper.text()).toContain('图片无法加载')
|
||||
await wrapper.setProps({ src: '/storage/b.png' })
|
||||
expect(wrapper.get('img').attributes('src')).toContain('/storage/b.png')
|
||||
await wrapper.setProps({ src: 'javascript:alert(1)' })
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formCoverAspectRatio } from './layout'
|
||||
import { formFixture, imageFixture } from './testing/fixtures'
|
||||
|
||||
describe('图库瀑布流图片比例', () => {
|
||||
it.each([
|
||||
[800, 1200],
|
||||
[1600, 900],
|
||||
[1024, 1024]
|
||||
])('保留正式图片尺寸 %s × %s', (width, height) => {
|
||||
const form = formFixture()
|
||||
form.images = [imageFixture({ width, height })]
|
||||
expect(formCoverAspectRatio(form)).toBe(`${width} / ${height}`)
|
||||
})
|
||||
|
||||
it.each([null, 0, -1, NaN, Infinity])('尺寸 %s 无效时使用稳定占位比例', width => {
|
||||
const form = formFixture()
|
||||
form.images = [imageFixture({ width })]
|
||||
expect(formCoverAspectRatio(form)).toBe('4 / 3')
|
||||
form.images = [imageFixture({ height: width })]
|
||||
expect(formCoverAspectRatio(form)).toBe('4 / 3')
|
||||
})
|
||||
|
||||
it('无图片时保留占位,候选封面也使用自己的尺寸', () => {
|
||||
const form = formFixture()
|
||||
form.images = []
|
||||
expect(formCoverAspectRatio(form)).toBe('4 / 3')
|
||||
form.images = [imageFixture({ isPrimary: false, width: 800, height: 1200 })]
|
||||
expect(formCoverAspectRatio(form)).toBe('800 / 1200')
|
||||
})
|
||||
})
|
||||
@@ -1,187 +0,0 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { projectContextKey } from '../projects/context'
|
||||
import { testProjectContext } from '../../testing/project-context'
|
||||
import { getOperation } from '../workflows/operations'
|
||||
import { formFixture, imageFixture } from './testing/fixtures'
|
||||
import { currentIdentityAnchorId, getImageSession, isPrimaryIdentityStale } from './model'
|
||||
import { useSubjectImages } from './useSubjectImages'
|
||||
import FormPromptDialog from './components/FormPromptDialog.vue'
|
||||
import GenerateImageDialog from './components/GenerateImageDialog.vue'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
const projectId = 'capability-test'
|
||||
/** 从实际挂载的确认弹窗查找操作按钮。 */
|
||||
function find(label: string) {
|
||||
return [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)!
|
||||
}
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
Object.assign(getOperation(projectId), { pending: false, error: '', notice: '', label: '' })
|
||||
Object.assign(getImageSession(projectId), { receipt: null, promptReceipt: null })
|
||||
})
|
||||
|
||||
async function setup() {
|
||||
let service!: ReturnType<typeof useSubjectImages>
|
||||
const context = testProjectContext()
|
||||
const form = formFixture(projectId)
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
const path = String(url)
|
||||
let data: unknown = [form]
|
||||
if (init?.method === 'POST') {
|
||||
if (path.endsWith('/generation-prompts'))
|
||||
data = {
|
||||
total: 2,
|
||||
targetCount: 2,
|
||||
generated: 1,
|
||||
skipped: 0,
|
||||
failed: 1,
|
||||
failures: [{ subjectFormId: 'form-failed', error: '模型拒绝' }]
|
||||
}
|
||||
else if (path.endsWith('/generation-prompt')) {
|
||||
form.generationPrompt = '正式提示词'
|
||||
data = { id: form.id, subjectId: form.subjectId, generationPrompt: form.generationPrompt }
|
||||
} else if (path.includes('/subject-forms/')) data = imageFixture()
|
||||
else
|
||||
data = {
|
||||
total: 3,
|
||||
targetCount: 1,
|
||||
generated: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
failures: [],
|
||||
eligibleCount: 3,
|
||||
remaining: 2
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({ data }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
service = useSubjectImages()
|
||||
return () => null
|
||||
}
|
||||
}),
|
||||
{ global: { provide: { [projectContextKey as symbol]: context } } }
|
||||
)
|
||||
await flushPromises()
|
||||
return {
|
||||
service,
|
||||
context,
|
||||
form,
|
||||
fetcher,
|
||||
posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||||
}
|
||||
}
|
||||
|
||||
describe('形态正式提示词与批量配置', () => {
|
||||
it('单个使用正式形态 ID 和 force,生成提示词不会调用图片接口', async () => {
|
||||
const { service, posts } = await setup()
|
||||
await service.generatePrompt('unknown-form', false)
|
||||
expect(posts()).toHaveLength(0)
|
||||
await service.generatePrompt('form-db-1', false)
|
||||
expect(posts()).toHaveLength(1)
|
||||
expect(posts()[0]?.[0]).toBe('/api/subject-forms/form-db-1/generation-prompt')
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ force: false })
|
||||
expect(service.forms.value[0]?.generationPrompt).toBe('正式提示词')
|
||||
await service.generatePrompt('form-db-1', true)
|
||||
expect(JSON.parse(String(posts()[1]?.[1]?.body))).toEqual({ force: true })
|
||||
})
|
||||
it('提示词接口返回空正文时显示失败,不误报保存成功', async () => {
|
||||
const { service, fetcher } = await setup()
|
||||
fetcher.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: { id: 'form-db-1', subjectId: 'subject-db-1', generationPrompt: '' } }))
|
||||
)
|
||||
await service.generatePrompt('form-db-1', false)
|
||||
expect(getOperation(projectId).error).toContain('未确认正式提示词已保存')
|
||||
})
|
||||
it('批量提示词保留部分失败回执,不覆盖图片回执且不传图片上限', async () => {
|
||||
const { service, posts } = await setup()
|
||||
service.limit.value = 1
|
||||
service.promptConcurrency.value = 4
|
||||
service.promptForce.value = true
|
||||
await service.generatePrompts()
|
||||
expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/subject-forms/generation-prompts')
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ concurrency: 4, force: true })
|
||||
expect(service.session.value.promptReceipt?.result.failures[0]?.error).toBe('模型拒绝')
|
||||
expect(service.session.value.receipt).toBeNull()
|
||||
})
|
||||
it('图片数量上限可选,非法上限和并发阻止提交', async () => {
|
||||
const { service, posts } = await setup()
|
||||
for (const limit of [0, -1, 1.5]) {
|
||||
service.limit.value = limit
|
||||
await service.generateProject()
|
||||
}
|
||||
expect(posts()).toHaveLength(0)
|
||||
service.limit.value = 1
|
||||
await service.generateProject()
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
|
||||
concurrency: 2,
|
||||
force: false,
|
||||
limit: 1
|
||||
})
|
||||
expect(service.session.value.receipt?.result.remaining).toBe(2)
|
||||
service.limit.value = ''
|
||||
await service.generateProject()
|
||||
expect(JSON.parse(String(posts()[1]?.[1]?.body))).not.toHaveProperty('limit')
|
||||
service.promptConcurrency.value = 0
|
||||
await service.generatePrompts()
|
||||
expect(posts()).toHaveLength(2)
|
||||
})
|
||||
it.each(['draft', 'generating', 'need_review', 'failed'] as const)('%s 剧本不能生成提示词和图片', async status => {
|
||||
const { service, context, posts } = await setup()
|
||||
context.data.value!.project.status = status
|
||||
await service.generatePrompt('form-db-1', true)
|
||||
await service.generatePrompts()
|
||||
await service.generateProject()
|
||||
expect(posts()).toHaveLength(0)
|
||||
})
|
||||
it.each(['character', 'scene', 'prop'])('%s 已锁定母版才参与过期判断,刷新只新增候选', async module => {
|
||||
const { service, form, posts } = await setup()
|
||||
form.subject.module = module
|
||||
form.subject.identity = { id: 'identity', isLocked: false, images: [{ id: 'anchor-new' }] }
|
||||
expect(currentIdentityAnchorId(form)).toBeUndefined()
|
||||
expect(isPrimaryIdentityStale(form)).toBe(false)
|
||||
form.subject.identity.isLocked = true
|
||||
expect(isPrimaryIdentityStale(form)).toBe(true)
|
||||
await service.query.refresh()
|
||||
await service.generateStale()
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ setPrimary: false })
|
||||
})
|
||||
it('正式提示词确认必须勾选,未确认不发送事件', async () => {
|
||||
const form = formFixture()
|
||||
wrapper = mount(FormPromptDialog, { attachTo: document.body, props: { open: true, disabled: false, form } })
|
||||
await flushPromises()
|
||||
find('生成正式提示词').click()
|
||||
await flushPromises()
|
||||
expect(find('确认生成正式提示词').disabled).toBe(true)
|
||||
expect(wrapper.emitted('generate')).toBeUndefined()
|
||||
document.querySelector<HTMLElement>('.app-dialog [role="checkbox"]')!.click()
|
||||
await flushPromises()
|
||||
find('确认生成正式提示词').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')).toEqual([['form-db-1', false]])
|
||||
})
|
||||
it('缺少正式和原始提示词仍可确认生图,由后端补齐而非前端编造', async () => {
|
||||
const form = formFixture()
|
||||
form.appearancePrompt = null
|
||||
form.generationPrompt = null
|
||||
wrapper = mount(GenerateImageDialog, { attachTo: document.body, props: { open: true, disabled: false, form } })
|
||||
await flushPromises()
|
||||
document.querySelector<HTMLElement>('#confirm-image-cost')!.click()
|
||||
await flushPromises()
|
||||
const submit = [...document.querySelectorAll('button')].find(
|
||||
item => item.textContent?.trim() === '确认生成图片'
|
||||
)!
|
||||
expect(submit.disabled).toBe(false)
|
||||
submit.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')).toEqual([['form-db-1', { setPrimary: false }]])
|
||||
})
|
||||
})
|
||||
@@ -165,3 +165,26 @@ function removeImage(image: VisualStyleImage) {
|
||||
@remove="removeImage"
|
||||
/></WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.visual-style-toolbar {
|
||||
@apply flex items-center justify-between flex-wrap gap-y-4 gap-x-6 my-5 py-3.5 px-4 bg-(--app-subtle);
|
||||
}
|
||||
.visual-style-status {
|
||||
@apply flex items-center gap-3 min-w-0;
|
||||
}
|
||||
.visual-style-status > svg {
|
||||
@apply shrink-0 text-muted;
|
||||
}
|
||||
.visual-style-status strong {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
.visual-style-status p {
|
||||
@apply mt-1 mx-0 mb-0 text-muted text-xs leading-[1.6];
|
||||
}
|
||||
.visual-style-actions {
|
||||
@apply flex items-center flex-wrap gap-2.5 ml-auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -56,3 +56,11 @@ function confirm() {
|
||||
</AppDialog>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.confirm-action {
|
||||
@apply inline-flex items-center max-w-full;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -54,3 +54,27 @@ const history = computed(() => workflowCheckpoints(props.checkpoints, props.work
|
||||
/>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.history-panel {
|
||||
@apply py-[23px] px-[21px] bg-(--app-subtle);
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.history-panel {
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
.history-panel {
|
||||
@apply p-0;
|
||||
}
|
||||
.history-content {
|
||||
@apply py-[23px] px-[21px];
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.history-panel {
|
||||
@apply overflow-hidden min-h-0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -161,3 +161,20 @@ function exportReport() {
|
||||
/>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.diagnostics-filters {
|
||||
@apply flex flex-wrap items-center gap-3;
|
||||
}
|
||||
.diagnostics-filters > .n-input {
|
||||
@apply flex-[1_1_240px] min-w-0;
|
||||
}
|
||||
.diagnostics-filters > .n-select {
|
||||
@apply flex-[0_1_180px] min-w-0;
|
||||
}
|
||||
.diagnostics-records {
|
||||
@apply grid gap-3 min-w-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import WorkflowDiagnosticsDialog from './WorkflowDiagnosticsDialog.vue'
|
||||
import { loadWorkflowDiagnostics, type WorkflowTimelineGroup } from './diagnostics'
|
||||
import type { Checkpoint } from './types'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 后端将 checkpoint 固定标为 completed,测试确保界面不把它当执行成功。 */
|
||||
const groups: WorkflowTimelineGroup[] = [
|
||||
{
|
||||
phase: '其它',
|
||||
nodeCount: 22,
|
||||
durationMs: 22000,
|
||||
durationText: '22s',
|
||||
nodes: Array.from({ length: 22 }, (_, index) => ({
|
||||
index: index + 1,
|
||||
checkpointId: `checkpoint-${index}`,
|
||||
nodeName: `node-${index}`,
|
||||
phase: '其它',
|
||||
status: 'completed',
|
||||
durationMs: 1000,
|
||||
durationText: '1s',
|
||||
retryCount: 0,
|
||||
createdAt: '2026-09-01T00:00:00Z'
|
||||
}))
|
||||
}
|
||||
]
|
||||
const metrics = {
|
||||
projectId: 'diagnostics-test',
|
||||
nodeCount: 22,
|
||||
totalDurationText: '22s',
|
||||
retryCount: 1,
|
||||
successRate: 100,
|
||||
failedNodeCount: 0
|
||||
}
|
||||
|
||||
describe('运行观测入口', () => {
|
||||
it('关闭时不请求,打开只读两个接口,完整展示超过 18 条记录且不伪造成功率', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(
|
||||
async url => new Response(JSON.stringify({ data: String(url).endsWith('/metrics') ? metrics : groups }))
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const checkpoints: Checkpoint[] = [
|
||||
{
|
||||
checkpointId: 'checkpoint-0',
|
||||
workflowName: 'breakdown',
|
||||
createdAt: '2026-09-01T00:00:00Z',
|
||||
state: {
|
||||
workflowExecution: {
|
||||
status: 'failed',
|
||||
executionId: 'execution-1',
|
||||
startedAt: '2026-09-01T00:00:00Z'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
wrapper = mount(WorkflowDiagnosticsDialog, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'diagnostics-test', open: false, checkpoints }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||
expect(document.querySelectorAll('.diagnostics-records article')).toHaveLength(22)
|
||||
expect(document.body.textContent).toContain('包含失败记录')
|
||||
expect(document.body.textContent).not.toContain('100%')
|
||||
const input = document.querySelector<HTMLInputElement>('[aria-label="搜索运行记录"]')!
|
||||
input.value = 'checkpoint-21'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(document.querySelectorAll('.diagnostics-records article')).toHaveLength(1)
|
||||
})
|
||||
it('一个观测接口失败仍显示另一项,不启动恢复或生产', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(async url =>
|
||||
String(url).endsWith('/metrics')
|
||||
? new Response(JSON.stringify({ error: '指标不可用' }), { status: 500 })
|
||||
: new Response(JSON.stringify({ data: groups }))
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const result = await loadWorkflowDiagnostics('project/1')
|
||||
expect(result.metrics).toBeNull()
|
||||
expect(result.groups?.[0]?.nodes).toHaveLength(22)
|
||||
expect(result.errors[0]).toContain('指标')
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/projects/project%2F1/metrics')
|
||||
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/projects/project%2F1/timeline/grouped')
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||
})
|
||||
it('指标项目不匹配时拒绝展示', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(
|
||||
async url => new Response(JSON.stringify({ data: String(url).endsWith('/metrics') ? metrics : [] }))
|
||||
)
|
||||
)
|
||||
const result = await loadWorkflowDiagnostics('other-project')
|
||||
expect(result.metrics).toBeNull()
|
||||
expect(result.errors.join()).toContain('不匹配的项目')
|
||||
})
|
||||
it('切项目会取消旧查询,晚到响应不会写进新项目', async () => {
|
||||
const pending: ((response: Response) => void)[] = []
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(url =>
|
||||
String(url).includes('/old/')
|
||||
? new Promise(resolve => pending.push(resolve))
|
||||
: Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: String(url).endsWith('/metrics')
|
||||
? { ...metrics, projectId: 'new', nodeCount: 0 }
|
||||
: []
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(WorkflowDiagnosticsDialog, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'old', open: true, checkpoints: [] }
|
||||
})
|
||||
await flushPromises()
|
||||
const signal = fetcher.mock.calls[0]?.[1]?.signal
|
||||
await wrapper.setProps({ projectId: 'new' })
|
||||
await flushPromises()
|
||||
expect(signal?.aborted).toBe(true)
|
||||
pending[0]!(new Response(JSON.stringify({ data: { ...metrics, projectId: 'old' } })))
|
||||
pending[1]!(new Response(JSON.stringify({ data: groups })))
|
||||
await flushPromises()
|
||||
expect(document.querySelectorAll('.diagnostics-records article')).toHaveLength(0)
|
||||
expect(document.body.textContent).toContain('全项目记录 0')
|
||||
await wrapper.setProps({ open: false })
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
it('关闭弹窗时取消仍未返回的查询', async () => {
|
||||
const finishes: ((response: Response) => void)[] = []
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(() => new Promise(resolve => finishes.push(resolve)))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(WorkflowDiagnosticsDialog, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'diagnostics-test', open: true, checkpoints: [] }
|
||||
})
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ open: false })
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.signal?.aborted)).toBe(true)
|
||||
finishes[0]!(new Response(JSON.stringify({ data: metrics })))
|
||||
finishes[1]!(new Response(JSON.stringify({ data: groups })))
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getOperation, runOperation } from './operations'
|
||||
|
||||
describe('项目级长请求互斥', () => {
|
||||
it('同一项目不同时执行两个 graph,错误不伪装成功', async () => {
|
||||
let fail!: (reason: Error) => void
|
||||
const action = vi.fn<() => Promise<unknown>>(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
fail = reject
|
||||
})
|
||||
)
|
||||
const first = runOperation('operation-test', '拆解', action)
|
||||
expect(await runOperation('operation-test', '改写', action)).toBe(false)
|
||||
expect(action).toHaveBeenCalledTimes(1)
|
||||
fail(new Error('连接中断'))
|
||||
expect(await first).toBe(false)
|
||||
expect(getOperation('operation-test')).toMatchObject({ pending: false, error: '连接中断', notice: '' })
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { breakdownSnapshot, recoveryOptions, workflowCheckpoints } from './selectors'
|
||||
import type { Checkpoint } from './types'
|
||||
import type { BreakdownState } from '../breakdown/types'
|
||||
|
||||
/** 只构造测试所需的真实 checkpoint 字段,避免页面依赖虚构 DTO。 */
|
||||
function checkpoint(index: number, state: BreakdownState, workflowName = 'breakdown'): Checkpoint {
|
||||
return { checkpointId: String(index), workflowName, createdAt: new Date(index * 1000).toISOString(), state }
|
||||
}
|
||||
|
||||
describe('Checkpoint 选择与恢复', () => {
|
||||
it('按 graph 隔离并保持输入不可变', () => {
|
||||
const records = [checkpoint(2, {}), checkpoint(1, {}, 'create-drama')]
|
||||
expect(workflowCheckpoints(records, 'breakdown').map(item => item.checkpointId)).toEqual(['2'])
|
||||
expect(records[0]?.checkpointId).toBe('2')
|
||||
})
|
||||
|
||||
it('只有错误的最新 checkpoint 仍能展示上一份成果,同时保留最新失败状态', () => {
|
||||
const result = breakdownSnapshot([
|
||||
checkpoint(1, {
|
||||
breakdownResult: { subjectCandidates: [] },
|
||||
runConfig: { groupSize: 3, modules: ['character'], episodeGroups: [] }
|
||||
}),
|
||||
checkpoint(2, {
|
||||
workflowExecution: {
|
||||
executionId: 'e',
|
||||
status: 'failed',
|
||||
startedAt: '',
|
||||
errorMessage: '模型未返回 JSON'
|
||||
}
|
||||
})
|
||||
])
|
||||
expect(result?.runConfig?.groupSize).toBe(3)
|
||||
expect(result?.workflowExecution?.status).toBe('failed')
|
||||
})
|
||||
|
||||
it('新阶段不能继承上一轮 completed 状态', () => {
|
||||
const result = breakdownSnapshot([
|
||||
checkpoint(1, {
|
||||
workflowExecution: { executionId: 'e', status: 'completed', startedAt: '' },
|
||||
breakdownResult: {}
|
||||
}),
|
||||
checkpoint(2, { runConfig: { groupSize: 2, modules: ['scene'], episodeGroups: [] } })
|
||||
])
|
||||
expect(result?.workflowExecution).toBeUndefined()
|
||||
expect(result?.runConfig?.groupSize).toBe(2)
|
||||
})
|
||||
|
||||
it('恢复窗口和后端一致,过旧的失败任务不启用重试', () => {
|
||||
const records = Array.from({ length: 11 }, (_, index) =>
|
||||
checkpoint(
|
||||
index,
|
||||
index === 0
|
||||
? {
|
||||
tasks: [
|
||||
{
|
||||
taskId: 't',
|
||||
module: 'prop',
|
||||
status: 'failed',
|
||||
attempt: 1,
|
||||
group: { groupId: 'g', groupNo: 1, startEpisodeNo: 1, endEpisodeNo: 1, episodes: [] }
|
||||
}
|
||||
]
|
||||
}
|
||||
: {}
|
||||
)
|
||||
)
|
||||
expect(recoveryOptions(records).retry).toBe(false)
|
||||
expect(recoveryOptions(records.slice(0, 10)).retry).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,180 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiError, optionalResource, request } from './http'
|
||||
import { projectsApi } from '../features/projects/api'
|
||||
import { breakdownApi } from '../features/breakdown/api'
|
||||
import { storyboardApi } from '../features/storyboard/api'
|
||||
import { subjectImagesApi } from '../features/subject-images/api'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('后端 API 契约', () => {
|
||||
it('图库读取与单图、批量和切换主图使用各自接口,模型由后端统一配置', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":[]}'))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await subjectImagesApi.listForms('a/b')
|
||||
await subjectImagesApi.listImages('form/id')
|
||||
await subjectImagesApi.generate('form/id', {
|
||||
setPrimary: false,
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
prompt: '本次提示词'
|
||||
})
|
||||
await subjectImagesApi.generateProject('a/b', { concurrency: 2, force: false })
|
||||
await subjectImagesApi.setPrimary('form/id', 'image/id')
|
||||
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
|
||||
['/api/projects/a%2Fb/subject-forms', 'GET'],
|
||||
['/api/subject-forms/form%2Fid/images', 'GET'],
|
||||
['/api/subject-forms/form%2Fid/images', 'POST'],
|
||||
['/api/projects/a%2Fb/subject-images/generate', 'POST'],
|
||||
['/api/subject-forms/form%2Fid/images/image%2Fid/primary', 'PUT']
|
||||
])
|
||||
expect(JSON.parse(fetcher.mock.calls[2]![1]!.body as string)).toEqual({
|
||||
setPrimary: false,
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
prompt: '本次提示词'
|
||||
})
|
||||
expect(JSON.parse(fetcher.mock.calls[3]![1]!.body as string)).toEqual({
|
||||
concurrency: 2,
|
||||
force: false
|
||||
})
|
||||
expect(fetcher.mock.calls[4]![1]!.body).toBeUndefined()
|
||||
})
|
||||
it('分镜单集显式持久化,批量保持 force 和零次修复参数', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await storyboardApi.generateDirection('a/b', 2)
|
||||
await storyboardApi.generateDirections('p', { concurrency: 2, force: false })
|
||||
await storyboardApi.generateVisualState('p', 2, 0)
|
||||
await storyboardApi.generateVisualStates('p', { concurrency: 3, force: true, maxRepairAttempts: 0 })
|
||||
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method, JSON.parse(init!.body as string)])).toEqual([
|
||||
['/api/projects/a%2Fb/storyboard-directions/generate-test', 'POST', { episodeNo: 2, persist: true }],
|
||||
['/api/projects/p/storyboard-directions/generate', 'POST', { concurrency: 2, force: false }],
|
||||
[
|
||||
'/api/projects/p/storyboard-visual-states/generate-test',
|
||||
'POST',
|
||||
{ episodeNo: 2, persist: true, maxRepairAttempts: 0 }
|
||||
],
|
||||
[
|
||||
'/api/projects/p/storyboard-visual-states/generate',
|
||||
'POST',
|
||||
{ concurrency: 3, force: true, maxRepairAttempts: 0 }
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('查询按剧集,镜头工具使用数据库 ID,读取提示词仍需显式 POST', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await storyboardApi.directions('p', 4)
|
||||
await storyboardApi.visualStates('p', 4)
|
||||
await storyboardApi.references('shot/id')
|
||||
await storyboardApi.generationSpec('shot/id')
|
||||
await storyboardApi.generatePrompt('shot/id', false)
|
||||
await storyboardApi.generatePrompts('p', { concurrency: 2, force: false })
|
||||
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/projects/p/storyboard-directions?episodeNo=4',
|
||||
'/api/projects/p/storyboard-visual-states?episodeNo=4',
|
||||
'/api/storyboard-shots/shot%2Fid/references',
|
||||
'/api/storyboard-shots/shot%2Fid/generation-spec',
|
||||
'/api/storyboard-shots/shot%2Fid/video-prompt',
|
||||
'/api/projects/p/video-prompts/generate'
|
||||
])
|
||||
expect(JSON.parse(fetcher.mock.calls[4]![1]!.body as string)).toEqual({ force: false })
|
||||
})
|
||||
|
||||
it('分镜生成保留 200 中的校验失败和持久化标志,长请求不自动超时或重试', async () => {
|
||||
vi.useFakeTimers()
|
||||
let finish!: (response: Response) => void
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const pending = storyboardApi.generateVisualState('p', 1, 2)
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(false)
|
||||
finish(new Response('{"data":{"validation":{"valid":false,"issues":[]},"persisted":false}}'))
|
||||
await expect(pending).resolves.toMatchObject({ validation: { valid: false }, persisted: false })
|
||||
})
|
||||
it('解包 data,同时保留创建项目的顶层 202 结构', async () => {
|
||||
const fetcher = vi
|
||||
.fn<(url: string, options: RequestInit) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: [{ id: 'p1' }] })))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ projectId: 'p2', status: 'generating' }), { status: 202 })
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
expect(await projectsApi.list()).toEqual([{ id: 'p1' }])
|
||||
expect(await projectsApi.create({ topic: '故事', style: '悬疑', episodeCount: 3 })).toEqual({
|
||||
projectId: 'p2',
|
||||
status: 'generating'
|
||||
})
|
||||
expect(JSON.parse(fetcher.mock.calls[1]![1].body as string)).toEqual({
|
||||
topic: '故事',
|
||||
style: '悬疑',
|
||||
episodeCount: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('只将缺少 checkpoint 的 404 视为可选结果,保留服务器错误', async () => {
|
||||
expect(await optionalResource(Promise.reject(new ApiError('没有 checkpoint', 404)))).toBeNull()
|
||||
await expect(optionalResource(Promise.reject(new ApiError('数据库异常', 500)))).rejects.toMatchObject({
|
||||
status: 500
|
||||
})
|
||||
})
|
||||
|
||||
it('保留错误细节且拒绝 HTML 代理错误', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn<(url: string, options: RequestInit) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ message: '校验失败', issues: ['缺少形态'] }), { status: 400 })
|
||||
)
|
||||
.mockResolvedValueOnce(new Response('<html>Bad Gateway</html>', { status: 502 }))
|
||||
)
|
||||
await expect(request('/projects')).rejects.toMatchObject({ status: 400, details: ['缺少形态'] })
|
||||
await expect(request('/projects')).rejects.toThrow('接口未返回 JSON')
|
||||
})
|
||||
|
||||
it('预览模块使用逗号参数,三种恢复不误发到 start', async () => {
|
||||
const fetcher = vi
|
||||
.fn<(url: string, options: RequestInit) => Promise<Response>>()
|
||||
.mockImplementation(() => Promise.resolve(new Response('{"data":{}}')))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await breakdownApi.preview('a/b', { groupSize: 3, modules: ['character', 'scene'] })
|
||||
for (const action of ['retry', 'resume-shots', 'resume-storyboard'] as const)
|
||||
await breakdownApi.run('p', action)
|
||||
expect(fetcher.mock.calls.map(call => call[0])).toEqual([
|
||||
'/api/projects/a%2Fb/breakdown-preview?groupSize=3&modules=character%2Cscene',
|
||||
'/api/projects/p/breakdown/retry',
|
||||
'/api/projects/p/breakdown/resume-shots',
|
||||
'/api/projects/p/breakdown/resume-storyboard'
|
||||
])
|
||||
})
|
||||
|
||||
it('长工作流不使用普通查询的超时,不自动重试 POST', async () => {
|
||||
vi.useFakeTimers()
|
||||
let finish!: (value: Response) => void
|
||||
const fetcher = vi.fn<(url: string, options: RequestInit) => Promise<Response>>().mockImplementation(
|
||||
() =>
|
||||
new Promise<Response>(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const running = breakdownApi.run('p', 'start', { groupSize: 3, modules: ['prop'] })
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
expect(fetcher.mock.calls[0]![1].signal?.aborted).toBe(false)
|
||||
finish(new Response('{"data":{"workflowExecution":{"status":"failed"}}}'))
|
||||
expect(await running).toMatchObject({ workflowExecution: { status: 'failed' } })
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import './styles.css'
|
||||
import './admin.css'
|
||||
import './styles/styles.css'
|
||||
import './styles/admin.css'
|
||||
|
||||
/** 单页应用入口;不在浏览器保存模型密钥。 */
|
||||
createApp(App).use(router).mount('#app')
|
||||
|
||||
-781
@@ -1,781 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* 微信风格明暗主题:灰阶表面搭配绿色操作态,业务排版保持不变。 */
|
||||
@theme {
|
||||
--color-ink: var(--app-ink);
|
||||
--color-muted: var(--app-muted);
|
||||
--color-faint: var(--app-muted);
|
||||
--color-line: var(--app-border);
|
||||
--color-accent: var(--app-accent-text);
|
||||
--color-danger: var(--app-danger);
|
||||
--color-success: var(--app-success);
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--app-body);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
button,
|
||||
a,
|
||||
summary {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
button:not(:disabled),
|
||||
summary,
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
::selection {
|
||||
background: var(--app-selected);
|
||||
color: var(--app-ink);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.identity-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 20px;
|
||||
}
|
||||
.identity-subject-list {
|
||||
max-height: 560px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.casting-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.casting-stats div {
|
||||
padding: 10px 12px;
|
||||
border-radius: 0;
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
.casting-stats dt {
|
||||
color: var(--color-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.casting-stats dd {
|
||||
margin-top: 5px;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.identity-workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.identity-subject-list {
|
||||
max-height: 220px;
|
||||
}
|
||||
.casting-stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.page-container {
|
||||
max-width: 1540px;
|
||||
margin-inline: auto;
|
||||
padding: 37px 40px 56px;
|
||||
}
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.page-heading h1 {
|
||||
margin: 9px 0 0;
|
||||
font-size: 27px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.11em;
|
||||
color: var(--color-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.page-description {
|
||||
margin-top: 9px;
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
min-height: 36px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.button-primary {
|
||||
background: var(--app-button-primary);
|
||||
color: var(--app-on-accent);
|
||||
}
|
||||
.button-primary:hover:not(:disabled) {
|
||||
background: var(--app-button-hover);
|
||||
}
|
||||
.button-primary:active:not(:disabled) {
|
||||
background: var(--app-button-pressed);
|
||||
}
|
||||
.button:disabled {
|
||||
background: var(--app-disabled-bg);
|
||||
color: var(--app-disabled-text);
|
||||
opacity: 1;
|
||||
}
|
||||
.button-secondary {
|
||||
background: var(--app-control);
|
||||
color: var(--app-ink);
|
||||
}
|
||||
.button-secondary:hover:not(:disabled) {
|
||||
background: var(--app-control-hover);
|
||||
}
|
||||
|
||||
.text-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--color-muted);
|
||||
padding: 6px 0;
|
||||
}
|
||||
.text-button:hover:not(:disabled) {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.panel {
|
||||
--app-field: var(--app-control);
|
||||
--app-field-hover: var(--app-control-hover);
|
||||
background: var(--app-surface);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* 次级信息用低对比底色成组,避免用边框把工作台切成碎片。 */
|
||||
.surface-inset {
|
||||
--app-field: var(--app-surface);
|
||||
--app-field-hover: var(--app-surface);
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
/* 首项与其它条目统一内边距;背景过渡仅用于主题切换,不随鼠标或焦点变化。 */
|
||||
.record-list > article {
|
||||
padding: 20px;
|
||||
transition: background-color 160ms ease;
|
||||
}
|
||||
.record-list > article:nth-child(even) {
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
/* 主体奇数行常驻原悬停底色,与偶数行形成固定斑马纹;内部控件仍保留交互反馈。 */
|
||||
.subject-record-list > article:nth-child(odd) {
|
||||
background: var(--app-control);
|
||||
}
|
||||
/* 形态块与所在主体条纹使用相反灰阶,避免偶数行内外同色;不增加边框或悬停态。 */
|
||||
.subject-form-card {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
.subject-record-list > article:nth-child(even) .subject-form-card {
|
||||
background: var(--app-control);
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
.project-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
.project-table th {
|
||||
padding: 13px 20px;
|
||||
background: var(--app-control);
|
||||
color: var(--color-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.project-table td {
|
||||
padding: 21px 20px;
|
||||
}
|
||||
.project-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.project-table tbody tr:nth-child(even) td {
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
.project-table tbody tr:hover td {
|
||||
background: var(--app-selected);
|
||||
}
|
||||
|
||||
.production-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.6fr) minmax(110px, 0.5fr) minmax(230px, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 18px;
|
||||
}
|
||||
.production-pipeline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.production-pipeline-card {
|
||||
padding: 20px;
|
||||
}
|
||||
.production-step {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0;
|
||||
background: var(--app-subtle);
|
||||
color: var(--app-ink);
|
||||
}
|
||||
.production-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.production-stats div,
|
||||
.production-status-grid div {
|
||||
padding: 10px;
|
||||
border-radius: 0;
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
.production-stats dt,
|
||||
.production-status-grid dt {
|
||||
color: var(--color-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.production-stats dd,
|
||||
.production-status-grid dd {
|
||||
margin-top: 5px;
|
||||
font-size: 13px;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.production-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.production-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
overflow: hidden;
|
||||
}
|
||||
.production-shot-list {
|
||||
max-height: 760px;
|
||||
overflow: hidden;
|
||||
background: var(--app-surface);
|
||||
padding-block: 8px;
|
||||
}
|
||||
.production-stage {
|
||||
padding: 20px 0;
|
||||
}
|
||||
.production-stage:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
.production-stage-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.keyframe-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.keyframe-card {
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
.keyframe-card .asset-image {
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 0;
|
||||
}
|
||||
.video-record {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
.production-video {
|
||||
width: min(44%, 390px);
|
||||
min-height: 170px;
|
||||
background: var(--app-ink);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.production-pipeline {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.production-controls {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.production-controls,
|
||||
.production-workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.production-shot-list {
|
||||
display: flex;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
border-right: none;
|
||||
}
|
||||
.production-status-grid,
|
||||
.keyframe-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.video-record {
|
||||
display: block;
|
||||
}
|
||||
.production-video {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
padding: 52px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 9px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--app-ink);
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--app-field);
|
||||
color: var(--color-ink);
|
||||
border-radius: 0;
|
||||
padding: 9px 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.input::placeholder {
|
||||
color: var(--app-muted);
|
||||
}
|
||||
.input:disabled {
|
||||
background: var(--app-subtle);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.alert {
|
||||
padding: 12px 15px;
|
||||
border-radius: 0;
|
||||
background: var(--app-subtle);
|
||||
color: var(--app-ink);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.alert-error {
|
||||
background: var(--app-subtle);
|
||||
color: var(--app-danger);
|
||||
}
|
||||
|
||||
.stage-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.stage-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--app-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.stage-item.done {
|
||||
color: var(--app-ink);
|
||||
}
|
||||
.stage-arrow {
|
||||
margin-left: 12px;
|
||||
color: var(--app-muted);
|
||||
}
|
||||
|
||||
.content-with-history {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 207px;
|
||||
min-height: 480px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.history-panel {
|
||||
padding: 23px 21px;
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
|
||||
.form-image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.form-image-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
.asset-image {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
.asset-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.asset-image-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
color: var(--color-muted);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
.image-history {
|
||||
overflow: hidden;
|
||||
}
|
||||
.image-history-item .asset-image-empty {
|
||||
font-size: 9px;
|
||||
padding: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
.image-history-item .asset-image-empty svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.storyboard-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) 100px 145px auto;
|
||||
gap: 18px;
|
||||
align-items: end;
|
||||
}
|
||||
.generation-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding-top: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.generation-row > div:last-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.storyboard-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
.shot-list {
|
||||
max-height: 1000px;
|
||||
overflow: hidden;
|
||||
background: var(--app-surface);
|
||||
padding: 0 8px 20px;
|
||||
}
|
||||
.reference-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.reference-card {
|
||||
background: var(--app-subtle);
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.reference-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: 4 / 3;
|
||||
font-size: 11px;
|
||||
color: var(--color-muted);
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
.storyboard-json {
|
||||
padding: 16px;
|
||||
border-radius: 0;
|
||||
background: var(--app-subtle);
|
||||
font:
|
||||
11px/1.9 ui-monospace,
|
||||
monospace;
|
||||
}
|
||||
.script-summary {
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
line-height: 1.9;
|
||||
padding: 17px 0 22px;
|
||||
margin-bottom: 23px;
|
||||
}
|
||||
.script-body {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 14px;
|
||||
line-height: 2.15;
|
||||
color: var(--app-ink);
|
||||
}
|
||||
.script-notes {
|
||||
margin-top: 36px;
|
||||
padding-top: 20px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.script-notes dt {
|
||||
color: var(--app-ink);
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.script-notes dd {
|
||||
color: var(--color-muted);
|
||||
line-height: 1.8;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 70px minmax(0, 1fr);
|
||||
gap: 15px;
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
.detail-grid dt {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.detail-grid dd {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.json-view {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 16px;
|
||||
background: var(--app-subtle);
|
||||
border-radius: 0;
|
||||
font:
|
||||
11px/1.9 ui-monospace,
|
||||
monospace;
|
||||
}
|
||||
|
||||
.group-preview {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.group-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--app-subtle);
|
||||
border-radius: 0;
|
||||
padding: 8px 11px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.recovery-strip {
|
||||
background: var(--app-subtle);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 17px;
|
||||
border-radius: 0;
|
||||
}
|
||||
.subject-ref {
|
||||
display: inline-flex;
|
||||
background: var(--app-subtle);
|
||||
padding: 3px 6px;
|
||||
border-radius: 0;
|
||||
color: var(--app-ink);
|
||||
font-size: 10px;
|
||||
}
|
||||
.subject-details summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--app-ink);
|
||||
font-size: 11px;
|
||||
list-style: none;
|
||||
}
|
||||
.subject-details summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.subject-details[open] summary svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.beat-section {
|
||||
margin-top: 25px;
|
||||
padding-top: 23px;
|
||||
}
|
||||
.beat-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.beat-number {
|
||||
color: var(--app-muted);
|
||||
font:
|
||||
11px ui-monospace,
|
||||
monospace;
|
||||
}
|
||||
.shot-row {
|
||||
display: flex;
|
||||
gap: 17px;
|
||||
padding: 17px;
|
||||
border-radius: 0;
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
.shot-label {
|
||||
flex-shrink: 0;
|
||||
width: 47px;
|
||||
font-size: 10px;
|
||||
color: var(--app-muted);
|
||||
}
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
top: -80px;
|
||||
left: 15px;
|
||||
padding: 10px 15px;
|
||||
background: var(--app-surface);
|
||||
border: 1px solid var(--color-accent);
|
||||
}
|
||||
.skip-link:focus {
|
||||
top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 中等宽度保留正文空间,执行记录下移,移动端导航收为图标。 */
|
||||
@media (max-width: 1200px) {
|
||||
.generation-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.storyboard-controls {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
.page-container {
|
||||
padding-inline: 26px;
|
||||
}
|
||||
|
||||
.content-with-history {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.history-panel {
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.storyboard-controls {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.storyboard-workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.shot-list {
|
||||
max-height: 240px;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 25px 18px 40px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.page-heading h1 {
|
||||
font-size: 23px;
|
||||
}
|
||||
|
||||
.stage-strip {
|
||||
gap: 9px;
|
||||
}
|
||||
.stage-arrow {
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.project-table td,
|
||||
.project-table th {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
.shot-row {
|
||||
gap: 12px;
|
||||
padding: 13px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/* 管理后台视口边界:document 不滚动,各个工作区和面板独立滚动。 */
|
||||
:root {
|
||||
--app-ink: #191919;
|
||||
--app-muted: #707070;
|
||||
--app-body: #ededed;
|
||||
--app-surface: #ffffff;
|
||||
--app-subtle: #f7f7f7;
|
||||
--app-control: #f0f0f0;
|
||||
--app-control-hover: #e5e5e5;
|
||||
--app-field: #ffffff;
|
||||
--app-field-hover: #ffffff;
|
||||
--app-border: #e5e5e5;
|
||||
--app-inverse: #ffffff;
|
||||
--app-accent: #07c160;
|
||||
--app-accent-hover: #2dcb79;
|
||||
--app-accent-text: #087c42;
|
||||
--app-on-accent: #ffffff;
|
||||
--app-button-primary: #078640;
|
||||
--app-button-hover: #087c42;
|
||||
--app-button-pressed: #066b36;
|
||||
--app-disabled-bg: #e4e4e4;
|
||||
--app-disabled-text: #8c8c8c;
|
||||
--app-selected: #e4f4e9;
|
||||
--app-shadow: #00000018;
|
||||
--app-danger: #a93232;
|
||||
--app-success: #087c42;
|
||||
}
|
||||
:root[data-theme='dark'] {
|
||||
--app-ink: #d9d9d9;
|
||||
--app-muted: #a3a3a3;
|
||||
--app-body: #111111;
|
||||
--app-surface: #191919;
|
||||
--app-subtle: #232323;
|
||||
--app-control: #2b2b2b;
|
||||
--app-control-hover: #353535;
|
||||
--app-field: #2b2b2b;
|
||||
--app-field-hover: #353535;
|
||||
--app-disabled-bg: #303030;
|
||||
--app-disabled-text: #808080;
|
||||
--app-border: #333333;
|
||||
--app-inverse: #111111;
|
||||
--app-accent-text: #5cd693;
|
||||
--app-selected: #173527;
|
||||
--app-shadow: #00000055;
|
||||
--app-danger: #fa7373;
|
||||
--app-success: #5cd693;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#app,
|
||||
.app-provider {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
html {
|
||||
background: var(--app-body);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
/* 外壳固定品牌与底部设置;只有菜单自身使用 NScrollbar 滚动。 */
|
||||
/* Naive 菜单背景默认左右内缩 8px;只铺满导航行,不改图标与文字缩进。 */
|
||||
/* 按钮保持直角,单图标按钮以 Naive 当前尺寸为边长,加载时也不改变宽度。 */
|
||||
.n-button {
|
||||
border-radius: 0;
|
||||
}
|
||||
.n-button.icon-button {
|
||||
width: var(--n-height);
|
||||
min-width: var(--n-height);
|
||||
height: var(--n-height);
|
||||
padding: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.n-button.icon-button .n-button__icon {
|
||||
margin: 0;
|
||||
}
|
||||
.n-button:focus-visible {
|
||||
outline: 2px solid var(--app-accent-text);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* 标题移除额外按钮后让名称使用剩余空间;未完成项目的引导仍留在内容区。 */
|
||||
/* 常用工具固定在滚动区外;低频操作使用局部抽屉,不叠加占高面板。 */
|
||||
.workspace-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
min-height: 34px;
|
||||
}
|
||||
.toolbar-episode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 1 340px;
|
||||
min-width: 180px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.toolbar-episode .n-select {
|
||||
min-width: 0;
|
||||
}
|
||||
.toolbar-metrics,
|
||||
.toolbar-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
color: var(--app-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
/* 身份说明与折叠入口分开,避免正文紧贴下一组操作。 */
|
||||
/* 与 34px 中号输入框共用一行;不再通过 pb-2 / pb-3 手动垫高复选框。 */
|
||||
.n-checkbox.control-row-checkbox {
|
||||
align-self: end;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding-block: 0;
|
||||
}
|
||||
.control-row-checkbox .n-checkbox-box-wrapper {
|
||||
align-self: center;
|
||||
}
|
||||
/* 拆解配置跟随抽屉实际宽度;标题 20px + 间距 8px,下方统一使用 34px 控件行。 */
|
||||
/* 筛选与关联行整体吸顶,底部不留固定分隔带;列表自己的顶部间距随内容滚走。 */
|
||||
/* 使用成熟的多列排版实现瀑布流;完整卡片不跨列,窄容器自动减少列数。 */
|
||||
.workspace-feedback.n-scrollbar {
|
||||
flex-shrink: 0;
|
||||
height: auto;
|
||||
max-height: min(24dvh, 160px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.workspace-feedback .n-alert + .n-alert {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.overview-content {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
/* 目录统一为固定标题/筛选区和独立滚动列表;不再依赖按钮的默认行高与内边距。 */
|
||||
.directory-panel {
|
||||
--app-field: var(--app-surface);
|
||||
--app-field-hover: var(--app-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
.directory-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
padding: 18px 16px 14px;
|
||||
font-size: 11px;
|
||||
color: var(--app-muted);
|
||||
}
|
||||
.directory-heading h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--app-ink);
|
||||
}
|
||||
.directory-heading h3 span {
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--app-muted);
|
||||
}
|
||||
.directory-scroll {
|
||||
flex: 1;
|
||||
}
|
||||
.directory-list-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
/* 可选前导内容用于主体母版,不改变剧集与镜头目录的原有排版。 */
|
||||
.directory-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 0;
|
||||
background: var(--app-control);
|
||||
}
|
||||
.directory-status svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* 标签与操作由同一 NTabs 导航行管理,suffix 共享底线及垂直中心。 */
|
||||
.tabs-toolbar {
|
||||
container: tabs-toolbar / inline-size;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.workspace-tabs.n-tabs {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.workspace-tabs > .n-tabs-nav {
|
||||
align-items: stretch;
|
||||
}
|
||||
.workspace-tabs .n-tabs-tab {
|
||||
min-height: 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.workspace-tabs .n-tabs-nav-scroll-wrapper {
|
||||
min-width: 0;
|
||||
}
|
||||
.workspace-tabs .n-tabs-nav__suffix {
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.tabs-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
min-height: 40px;
|
||||
}
|
||||
.tabs-toolbar-actions .n-button,
|
||||
.tabs-toolbar-actions .text-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 窄内容区允许 suffix 独占一行;Tabs 本身继续使用组件内置横向滚动。 */
|
||||
@container tabs-toolbar (max-width: 600px) {
|
||||
.workspace-tabs > .n-tabs-nav {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.workspace-tabs .n-tabs-nav-scroll-wrapper {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.workspace-tabs .n-tabs-nav__suffix {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
/* 筛选按内容区的实际宽度换行,避免侧栏展开后下拉框把其他控件挤到下一行。 */
|
||||
/* 镜头选择和图库筛选共用一行,以控件中心对齐;窄屏保留完整筛选组,不挤压复选框。 */
|
||||
.content-with-history {
|
||||
height: clamp(360px, 66dvh, 850px);
|
||||
min-height: 0;
|
||||
grid-template-columns: minmax(0, 1fr) 207px;
|
||||
}
|
||||
.content-with-history > main,
|
||||
.content-with-history > aside {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
/* 拆解结果使用剩余高度,不能再用视口比例定高后隐藏长列表。 */
|
||||
/* 剧集选择、统计和下一步入口共用一行,避免空操作行与双层顶部留白。 */
|
||||
/* 连续阅读区保留双栏,执行记录按需展开,不挤占默认阅读宽度。 */
|
||||
/* 面板只负责尺寸与裁切;滚动和深浅主题滚动条统一交给 Naive UI。 */
|
||||
.panel-scroll.n-scrollbar {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.n-scrollbar > .n-scrollbar-container {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.production-detail,
|
||||
.storyboard-detail,
|
||||
.content-with-history > main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.image-history.n-scrollbar,
|
||||
.table-scroll.n-scrollbar {
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
.image-history.n-scrollbar {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.image-history-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px 16px;
|
||||
margin-block: 16px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.image-history-content {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 4px 4px 12px;
|
||||
width: max-content;
|
||||
}
|
||||
.code-scroll.n-scrollbar {
|
||||
height: auto;
|
||||
max-height: 420px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.code-scroll .json-view,
|
||||
.code-scroll .storyboard-json {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
/* 项目筛选与搜索框同高,保留更宽的点击区;窄屏可自然换行。 */
|
||||
/* 状态说明与操作各自成组,避免小标签夹在不同尺寸的按钮之间。 */
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
/* 预览尺寸不随原图分辨率增长,历史缩略图也不能被按钮默认宽度覆盖。 */
|
||||
.n-button__content {
|
||||
gap: 6px;
|
||||
}
|
||||
.n-button:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
.n-button.image-history-item {
|
||||
width: 128px;
|
||||
min-width: 128px;
|
||||
max-width: 128px;
|
||||
flex: 0 0 128px;
|
||||
height: auto;
|
||||
padding: 6px;
|
||||
white-space: normal;
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
.n-button.image-history-item.selected {
|
||||
background: var(--app-selected);
|
||||
box-shadow: inset 0 0 0 2px var(--app-accent);
|
||||
}
|
||||
.n-button.image-history-item .n-button__content {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
.image-history-item .asset-image {
|
||||
height: 88px;
|
||||
min-height: 0;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
.image-history-label {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.image-history-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
color: var(--app-muted);
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* 选角进度按抽屉实际宽度排列;按钮尺寸显式覆盖组件默认值。 */
|
||||
/* 主体搜索靠左,统计紧随输入框;窄内容区自然换行,不占满整行或挤压控件。 */
|
||||
/* 诊断筛选按实际空间换行,长 checkpoint ID 不撑开弹窗。 */
|
||||
/* 镜头影响和素材定位使用固定底色;高亮只表示当前定位,不代表素材失效。 */
|
||||
/* 关联素材始终单行横向浏览,数量和长名称不会撑高定位区或压缩图片区域。 */
|
||||
/* 低频记录收起后归还正文宽度,不留下历史列的占位。 */
|
||||
/* 补充说明只按需展开,继续使用平直背景层级,不增加边框或圆角。 */
|
||||
.select-control {
|
||||
min-width: 130px;
|
||||
}
|
||||
.n-checkbox {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.n-checkbox .n-checkbox__label {
|
||||
white-space: normal;
|
||||
}
|
||||
.n-alert {
|
||||
line-height: 1.8;
|
||||
}
|
||||
.n-collapse.panel {
|
||||
padding: 16px;
|
||||
}
|
||||
.n-table.project-table th {
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.content-with-history {
|
||||
grid-template-columns: minmax(0, 1fr) 180px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.toolbar-episode {
|
||||
flex: 1;
|
||||
}
|
||||
.toolbar-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
.directory-panel {
|
||||
height: 100%;
|
||||
border-right: 0;
|
||||
}
|
||||
.directory-heading {
|
||||
padding: 12px 14px 8px;
|
||||
}
|
||||
.directory-list-content {
|
||||
flex-direction: row;
|
||||
width: max-content;
|
||||
align-items: stretch;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.content-with-history {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) 130px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
@import 'tailwindcss';
|
||||
/* 微信风格明暗主题:灰阶表面搭配绿色操作态,业务排版保持不变。 */
|
||||
@theme {
|
||||
--color-ink: var(--app-ink);
|
||||
--color-muted: var(--app-muted);
|
||||
--color-faint: var(--app-muted);
|
||||
--color-line: var(--app-border);
|
||||
--color-accent: var(--app-accent-text);
|
||||
--color-danger: var(--app-danger);
|
||||
--color-success: var(--app-success);
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--app-body);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
button,
|
||||
a,
|
||||
summary {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
button:not(:disabled),
|
||||
summary,
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
::selection {
|
||||
background: var(--app-selected);
|
||||
color: var(--app-ink);
|
||||
}
|
||||
}
|
||||
@layer components {
|
||||
.eyebrow {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.11em;
|
||||
color: var(--color-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
min-height: 36px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.button-primary {
|
||||
background: var(--app-button-primary);
|
||||
color: var(--app-on-accent);
|
||||
}
|
||||
.button-primary:hover:not(:disabled) {
|
||||
background: var(--app-button-hover);
|
||||
}
|
||||
.button-primary:active:not(:disabled) {
|
||||
background: var(--app-button-pressed);
|
||||
}
|
||||
.button:disabled {
|
||||
background: var(--app-disabled-bg);
|
||||
color: var(--app-disabled-text);
|
||||
opacity: 1;
|
||||
}
|
||||
.button-secondary {
|
||||
background: var(--app-control);
|
||||
color: var(--app-ink);
|
||||
}
|
||||
.button-secondary:hover:not(:disabled) {
|
||||
background: var(--app-control-hover);
|
||||
}
|
||||
.text-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--color-muted);
|
||||
padding: 6px 0;
|
||||
}
|
||||
.text-button:hover:not(:disabled) {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.panel {
|
||||
--app-field: var(--app-control);
|
||||
--app-field-hover: var(--app-control-hover);
|
||||
background: var(--app-surface);
|
||||
border-radius: 0;
|
||||
}
|
||||
/* 次级信息用低对比底色成组,避免用边框把工作台切成碎片。 */
|
||||
.surface-inset {
|
||||
--app-field: var(--app-surface);
|
||||
--app-field-hover: var(--app-surface);
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
/* 首项与其它条目统一内边距;背景过渡仅用于主题切换,不随鼠标或焦点变化。 */
|
||||
.record-list > article {
|
||||
padding: 20px;
|
||||
transition: background-color 160ms ease;
|
||||
}
|
||||
.record-list > article:nth-child(even) {
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
/* 主体奇数行常驻原悬停底色,与偶数行形成固定斑马纹;内部控件仍保留交互反馈。 */
|
||||
/* 形态块与所在主体条纹使用相反灰阶,避免偶数行内外同色;不增加边框或悬停态。 */
|
||||
.table-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
.project-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
.project-table th {
|
||||
padding: 13px 20px;
|
||||
background: var(--app-control);
|
||||
color: var(--color-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.project-table td {
|
||||
padding: 21px 20px;
|
||||
}
|
||||
.project-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.project-table tbody tr:nth-child(even) td {
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
.project-table tbody tr:hover td {
|
||||
background: var(--app-selected);
|
||||
}
|
||||
.production-step {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0;
|
||||
background: var(--app-subtle);
|
||||
color: var(--app-ink);
|
||||
}
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 9px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--app-ink);
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--app-field);
|
||||
color: var(--color-ink);
|
||||
border-radius: 0;
|
||||
padding: 9px 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.input::placeholder {
|
||||
color: var(--app-muted);
|
||||
}
|
||||
.input:disabled {
|
||||
background: var(--app-subtle);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.alert-error {
|
||||
background: var(--app-subtle);
|
||||
color: var(--app-danger);
|
||||
}
|
||||
.content-with-history {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 207px;
|
||||
min-height: 480px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.image-history {
|
||||
overflow: hidden;
|
||||
}
|
||||
.image-history-item .asset-image-empty {
|
||||
font-size: 9px;
|
||||
padding: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
.image-history-item .asset-image-empty svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.reference-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: 4 / 3;
|
||||
font-size: 11px;
|
||||
color: var(--color-muted);
|
||||
background: var(--app-subtle);
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 70px minmax(0, 1fr);
|
||||
gap: 15px;
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
.detail-grid dt {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.detail-grid dd {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.subject-ref {
|
||||
display: inline-flex;
|
||||
background: var(--app-subtle);
|
||||
padding: 3px 6px;
|
||||
border-radius: 0;
|
||||
color: var(--app-ink);
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
/* 中等宽度保留正文空间,执行记录下移,移动端导航收为图标。 */
|
||||
@media (max-width: 1200px) {
|
||||
.content-with-history {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.project-table td,
|
||||
.project-table th {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
|
||||
const SPACE: Record<string, string> = {
|
||||
'0': '0',
|
||||
px: '1px',
|
||||
'0.5': '2px',
|
||||
'1': '4px',
|
||||
'1.5': '6px',
|
||||
'2': '8px',
|
||||
'2.5': '10px',
|
||||
'3': '12px',
|
||||
'3.5': '14px',
|
||||
'4': '16px',
|
||||
'5': '20px',
|
||||
'6': '24px',
|
||||
'7': '28px',
|
||||
'8': '32px',
|
||||
'9': '36px',
|
||||
'10': '40px',
|
||||
'11': '44px',
|
||||
'12': '48px',
|
||||
'14': '56px'
|
||||
}
|
||||
|
||||
const STATIC: Record<string, string> = {
|
||||
flex: 'display: flex;',
|
||||
grid: 'display: grid;',
|
||||
block: 'display: block;',
|
||||
hidden: 'display: none;',
|
||||
contents: 'display: contents;',
|
||||
'inline-flex': 'display: inline-flex;',
|
||||
'flex-col': 'flex-direction: column;',
|
||||
'flex-wrap': 'flex-wrap: wrap;',
|
||||
'items-center': 'align-items: center;',
|
||||
'items-end': 'align-items: end;',
|
||||
'items-start': 'align-items: flex-start;',
|
||||
'items-stretch': 'align-items: stretch;',
|
||||
'self-start': 'align-self: start;',
|
||||
'justify-center': 'justify-content: center;',
|
||||
'justify-start': 'justify-content: flex-start;',
|
||||
'justify-between': 'justify-content: space-between;',
|
||||
'justify-self-end': 'justify-self: end;',
|
||||
'shrink-0': 'flex-shrink: 0;',
|
||||
'flex-1': 'flex: 1;',
|
||||
'overflow-hidden': 'overflow: hidden;',
|
||||
'min-h-0': 'min-height: 0;',
|
||||
'min-w-0': 'min-width: 0;',
|
||||
'w-full': 'width: 100%;',
|
||||
'w-max': 'width: max-content;',
|
||||
'h-full': 'height: 100%;',
|
||||
'h-auto': 'height: auto;',
|
||||
'h-dvh': 'height: 100dvh;',
|
||||
'max-w-full': 'max-width: 100%;',
|
||||
'max-h-none': 'max-height: none;',
|
||||
'whitespace-nowrap': 'white-space: nowrap;',
|
||||
'whitespace-normal': 'white-space: normal;',
|
||||
'whitespace-pre-wrap': 'white-space: pre-wrap;',
|
||||
'text-center': 'text-align: center;',
|
||||
'text-left': 'text-align: left;',
|
||||
'wrap-anywhere': 'overflow-wrap: anywhere;',
|
||||
'rounded-none': 'border-radius: 0;',
|
||||
relative: 'position: relative;',
|
||||
sticky: 'position: sticky;',
|
||||
fixed: 'position: fixed;',
|
||||
'top-0': 'top: 0;',
|
||||
'left-0': 'left: 0;',
|
||||
'right-0': 'right: 0;',
|
||||
'bottom-0': 'bottom: 0;',
|
||||
'inset-0': 'inset: 0;',
|
||||
'list-none': 'list-style: none;',
|
||||
'place-items-center': 'place-items: center;',
|
||||
'place-content-center': 'place-content: center;',
|
||||
'object-contain': 'object-fit: contain;',
|
||||
'overscroll-contain': 'overscroll-behavior: contain;',
|
||||
'break-inside-avoid': 'break-inside: avoid;',
|
||||
'outline-none': 'outline: none;',
|
||||
'text-ellipsis': 'text-overflow: ellipsis;',
|
||||
'font-normal': 'font-weight: 400;',
|
||||
'font-medium': 'font-weight: 500;',
|
||||
'font-semibold': 'font-weight: 600;',
|
||||
'font-mono': 'font-family: ui-monospace, monospace;',
|
||||
'text-xs': 'font-size: 12px;',
|
||||
'text-sm': 'font-size: 14px;',
|
||||
'text-base': 'font-size: 16px;',
|
||||
'leading-normal': 'line-height: 1.5;',
|
||||
'm-0': 'margin: 0;',
|
||||
'p-0': 'padding: 0;',
|
||||
'border-0': 'border: 0;',
|
||||
'border-t-0': 'border-top: none;',
|
||||
'mx-auto': 'margin-inline: auto;',
|
||||
'mx-0': 'margin-inline: 0;',
|
||||
'my-0': 'margin-block: 0;',
|
||||
'ml-auto': 'margin-left: auto;',
|
||||
'px-0': 'padding-inline: 0;',
|
||||
'bg-transparent': 'background: transparent;',
|
||||
'order-0': 'order: 0;',
|
||||
'order-1': 'order: 1;',
|
||||
'order-2': 'order: 2;',
|
||||
'aspect-auto': 'aspect-ratio: auto;',
|
||||
'aspect-video': 'aspect-ratio: 16 / 9;',
|
||||
'rotate-180': 'transform: rotate(180deg);',
|
||||
'text-ink': 'color: var(--app-ink);',
|
||||
'text-muted': 'color: var(--app-muted);',
|
||||
'text-accent': 'color: var(--app-accent-text);',
|
||||
'bg-ink': 'background: var(--app-ink);',
|
||||
'z-10': 'z-index: 10;'
|
||||
}
|
||||
|
||||
const PREFIX_PROP: Record<string, string> = {
|
||||
'min-h': 'min-height',
|
||||
'min-w': 'min-width',
|
||||
'max-h': 'max-height',
|
||||
'max-w': 'max-width',
|
||||
'grid-cols': 'grid-template-columns',
|
||||
'grid-rows': 'grid-template-rows',
|
||||
col: 'grid-column',
|
||||
flex: 'flex',
|
||||
w: 'width',
|
||||
h: 'height',
|
||||
p: 'padding',
|
||||
px: 'padding-inline',
|
||||
py: 'padding-block',
|
||||
pt: 'padding-top',
|
||||
pr: 'padding-right',
|
||||
pb: 'padding-bottom',
|
||||
pl: 'padding-left',
|
||||
m: 'margin',
|
||||
mx: 'margin-inline',
|
||||
my: 'margin-block',
|
||||
mt: 'margin-top',
|
||||
mr: 'margin-right',
|
||||
mb: 'margin-bottom',
|
||||
ml: 'margin-left',
|
||||
gap: 'gap',
|
||||
'gap-x': 'column-gap',
|
||||
'gap-y': 'row-gap',
|
||||
text: 'font-size',
|
||||
leading: 'line-height',
|
||||
tracking: 'letter-spacing',
|
||||
top: 'top',
|
||||
right: 'right',
|
||||
bottom: 'bottom',
|
||||
left: 'left',
|
||||
inset: 'inset',
|
||||
z: 'z-index',
|
||||
shadow: 'box-shadow',
|
||||
outline: 'outline',
|
||||
'outline-offset': 'outline-offset',
|
||||
bg: 'background',
|
||||
columns: 'columns',
|
||||
aspect: 'aspect-ratio'
|
||||
}
|
||||
|
||||
const PREFIXES = Object.keys(PREFIX_PROP).toSorted((a, b) => b.length - a.length)
|
||||
|
||||
/**
|
||||
* 把单个 Tailwind 工具类还原成 CSS 声明,供样式契约测试对照。
|
||||
* @param util 工具类名
|
||||
* @returns CSS 声明,无法还原时返回空字符串
|
||||
*/
|
||||
function expandUtil(util: string): string {
|
||||
if (util.startsWith('[') && util.endsWith(']')) {
|
||||
const inner = util.slice(1, -1)
|
||||
const colon = inner.indexOf(':')
|
||||
if (colon > 0) return `${inner.slice(0, colon)}: ${inner.slice(colon + 1)};`
|
||||
}
|
||||
if (STATIC[util]) return STATIC[util]!
|
||||
const paren = util.match(/^([a-z-]+)-\((--[\w-]+)\)$/)
|
||||
if (paren) {
|
||||
const prop = PREFIX_PROP[paren[1]!] ?? paren[1]
|
||||
const cssVar = paren[2]!
|
||||
if (prop === 'background') return `background: var(${cssVar});`
|
||||
if (paren[1] === 'text') return `color: var(${cssVar});`
|
||||
return `${prop}: var(${cssVar});`
|
||||
}
|
||||
const arb = util.match(/^(-?[a-z-]+)-\[(.+)\]$/)
|
||||
if (arb) {
|
||||
const name = arb[1]!
|
||||
const raw = arb[2]!.replace(/_/g, ' ').replace(/,(?=\S)/g, ', ')
|
||||
const prop = PREFIX_PROP[name.replace(/^-/, '')]
|
||||
if (!prop) return `${name}: ${raw};`
|
||||
if (name.startsWith('-')) return `${prop}: -${raw};`
|
||||
return `${prop}: ${raw};`
|
||||
}
|
||||
for (const prefix of PREFIXES) {
|
||||
if (!util.startsWith(`${prefix}-`)) continue
|
||||
const key = util.slice(prefix.length + 1)
|
||||
const px = SPACE[key]
|
||||
const prop = PREFIX_PROP[prefix]
|
||||
if (px !== undefined && prop) return `${prop}: ${px};`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 @apply 行展开成 CSS 声明,并补回测试仍在匹配的 padding/margin 简写。
|
||||
* @param css 含 @apply 的样式源码
|
||||
* @returns 展开后的样式文本
|
||||
*/
|
||||
function expandApply(css: string): string {
|
||||
return css.replace(/@apply\s+([^;]+);/g, (_all, utils: string) => {
|
||||
const decls = utils.trim().split(/\s+/).map(expandUtil).filter(Boolean)
|
||||
const valueOf = (prefix: string) =>
|
||||
decls
|
||||
.find(item => item.startsWith(prefix))
|
||||
?.slice(prefix.length)
|
||||
.replace(';', '')
|
||||
.trim()
|
||||
const paddingX = valueOf('padding-inline:')
|
||||
const paddingY = valueOf('padding-block:')
|
||||
const paddingT = valueOf('padding-top:')
|
||||
const paddingB = valueOf('padding-bottom:')
|
||||
if (paddingY && paddingX) decls.push(`padding: ${paddingY} ${paddingX};`)
|
||||
else if (paddingT && paddingX && paddingB) decls.push(`padding: ${paddingT} ${paddingX} ${paddingB};`)
|
||||
const marginX = valueOf('margin-inline:')
|
||||
const marginT = valueOf('margin-top:')
|
||||
const marginB = valueOf('margin-bottom:')
|
||||
if (marginT && marginX && marginB) decls.push(`margin: ${marginT} ${marginX} ${marginB};`)
|
||||
if (marginT && marginB) decls.push(`margin-block: ${marginT} ${marginB};`)
|
||||
const gapY = valueOf('row-gap:')
|
||||
const gapX = valueOf('column-gap:')
|
||||
if (gapY && gapX) decls.push(`gap: ${gapY} ${gapX};`)
|
||||
return decls.join(' ')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取全局 CSS 与全部组件内 style,供样式契约测试对照搬迁后的规则。
|
||||
* @returns 拼接后的样式源码
|
||||
*/
|
||||
export function readAllStyles(): string {
|
||||
const vueFiles = readdirSync('src', { recursive: true, encoding: 'utf8' }).filter(path =>
|
||||
String(path).endsWith('.vue')
|
||||
)
|
||||
const raw = ['src/styles/styles.css', 'src/styles/admin.css', ...vueFiles.map(path => `src/${path}`)]
|
||||
.map(path => readFileSync(path, 'utf8'))
|
||||
.join('\n')
|
||||
return expandApply(raw)
|
||||
}
|
||||
Reference in New Issue
Block a user