feat: 收口组件样式并调整移动端侧栏

This commit is contained in:
GJ
2026-09-04 17:19:34 +08:00
parent d252d5513f
commit e6e0f08072
68 changed files with 2717 additions and 2775 deletions
+184 -23
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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>
-90
View File
@@ -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)
})
})
+21
View File
@@ -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>
-79
View File
@@ -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)
})
})
+23
View File
@@ -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>
+11
View File
@@ -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>
-100
View File
@@ -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/
)
})
})
+43
View File
@@ -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>
+11
View File
@@ -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>
+8
View File
@@ -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>
+69
View File
@@ -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>
+42
View File
@@ -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>
-266
View File
@@ -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|$)/
)
}
}
})
})
-305
View File
@@ -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/
)
})
})
-86
View File
@@ -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()
})
})
-192
View File
@@ -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)
})
})
+92
View File
@@ -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()
})
})
+103
View File
@@ -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>
-133
View File
@@ -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 }])
})
})
-515
View File
@@ -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)
})
})
+47
View File
@@ -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>
+70
View File
@@ -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>
-215
View File
@@ -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>
-62
View File
@@ -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/)
})
})
-64
View File
@@ -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)
})
})
-223
View File
@@ -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')
})
})
-187
View File
@@ -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>
+8
View File
@@ -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>
+24
View File
@@ -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>
-166
View File
@@ -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)
})
})
-20
View File
@@ -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
-71
View File
@@ -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)
})
})
-180
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+433
View File
@@ -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;
}
}
+280
View File
@@ -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;
}
}
+239
View File
@@ -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)
}