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
+90
View File
@@ -0,0 +1,90 @@
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 '@/components/ui/ActionMenu.vue'
import DetailDisclosure from '@/components/ui/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)
})
})
+79
View File
@@ -0,0 +1,79 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { NImage } from 'naive-ui'
import AssetImage from '@/components/ui/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)
})
})
+98
View File
@@ -0,0 +1,98 @@
import { mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NButton } from 'naive-ui'
import DirectoryItem from '@/components/ui/DirectoryItem.vue'
import { readAllStyles } from '@/testing/styles'
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 = readAllStyles()
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 = readAllStyles()
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%`))
expect(css).toMatch(new RegExp(`\\.n-button\\.${selector}\\s*\\{[^}]*padding:\\s*12px 14px`))
expect(css).toMatch(new RegExp(`\\.n-button\\.${selector}\\s*\\{[^}]*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 = readAllStyles()
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/
)
})
})
+268
View File
@@ -0,0 +1,268 @@
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'
import { readAllStyles } from '@/testing/styles'
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 = readAllStyles()
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 = readAllStyles()
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 = readAllStyles()
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,\s*4\s*\/\s*3\);/
)
})
it('图库顶部统一镜头选择和筛选的背景与垂直对齐,窄屏整组换行', () => {
// DOM 环境不计算几何;保护容器宽度断点和居中契约,真实坐标仍需浏览器验收。
const css = readAllStyles()
expect(css).toMatch(
/\.form-image-filter-region\s*\{[^}]*padding(?:-block)?:\s*14px;[^}]*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/)
expect(css).toMatch(/\.asset-impact-context\s*\{[^}]*padding(?:-block)?:\s*5px/)
expect(css).toMatch(/\.asset-impact-context\s*\{[^}]*margin:\s*0/)
})
it('单图标按钮以当前控件高度为边长,尺寸与内容按钮互不影响', () => {
// happy-dom 不计算几何尺寸,此项约束共用样式和调用方,实际布局仍需浏览器验收。
const css = readAllStyles()
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 css = readAllStyles()
expect(css).not.toMatch(/border-radius:\s*[1-9]/)
expect(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(css).toMatch(/\.directory-group-heading\s*\{[^}]*background:\s*var\(--app-control\)/)
expect(css).toMatch(/\.directory-status\s*\{[^}]*background:\s*var\(--app-control\)/)
expect(css).toMatch(/\.n-button\.directory-item:focus-visible\s*\{[^}]*outline:\s*2px/)
expect(css).toMatch(
/\.n-button\.directory-item\.selected\s*\{[^}]*var\(--app-selected\)[^}]*var\(--app-accent\)/
)
})
it('斑马纹首项保留统一顶部留白,背景色平滑过渡并遵循减少动态效果设置', () => {
// DOM 环境不计算实际内边距,检查共享规则及之前覆盖首项留白的工具类。
const css = readFileSync('src/styles/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|$)/
)
}
}
})
})
+368
View File
@@ -0,0 +1,368 @@
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, NMenu, NModal, NScrollbar, NTooltip } from 'naive-ui'
import App from '@/App.vue'
import WorkspacePage from '@/components/ui/WorkspacePage.vue'
import AppDialog from '@/components/ui/AppDialog.vue'
import WorkspaceTools from '@/components/ui/WorkspaceTools.vue'
import ProjectLayout from '@/features/projects/ProjectLayout.vue'
import ConfirmAction from '@/features/workflows/ConfirmAction.vue'
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
import { projectsApi } from '@/features/projects/api'
import { readFileSync } from 'node:fs'
import { drawerPanel } from '@/testing/naive'
import { readAllStyles } from '@/testing/styles'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
localStorage.clear()
document.body.innerHTML = ''
vi.restoreAllMocks()
})
describe('管理后台组件边界', () => {
it('Tabs suffix 与标签共用导航行,窄屏换行但不产生外层滚动', () => {
// DOM 测试不计算坐标;保护对齐尺寸与容器断点,视觉验收仍需浏览器。
const css = readAllStyles()
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 = readAllStyles()
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 = readAllStyles()
expect(css).not.toMatch(/overflow(?:-[xy])?\s*:\s*(?:auto|scroll)\b/)
})
it('历史缩略图尺寸在非分层样式中覆盖 Naive 按钮,原图不能撑大预览或横向条目', () => {
// happy-dom 不计算几何;专门保护此前失效的层叠和固定尺寸约束。
const css = readFileSync('src/styles/admin.css', 'utf8')
expect(css).not.toContain('@layer')
const all = readAllStyles()
expect(all).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(all).toMatch(/\.image-history-item \.asset-image\s*\{[^}]*height:\s*88px;[^}]*min-height:\s*0/)
expect(all).toMatch(
/\.asset-image\.asset-image-preview\s*\{[^}]*height:\s*min\(42dvh,\s*420px\);[^}]*aspect-ratio:\s*auto/
)
expect(all).toMatch(/\.image-history\.n-scrollbar\s*\{[^}]*max-width:\s*100%;[^}]*min-width:\s*0/)
expect(all).toMatch(/\.image-history-content\s*\{[^}]*display:\s*flex;[^}]*width:\s*max-content/)
})
it('选角面板保留说明间距,卡片按可用宽度排列并覆盖按钮默认高度', () => {
// 保护间距与换行契约,真实宽高和暗色效果仍需浏览器验收。
const css = readAllStyles()
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 = readAllStyles()
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('后端连接')
expect(wrapper.getComponent(NMenu).props()).toMatchObject({
collapsedWidth: 64,
iconSize: 20,
collapsedIconSize: 20,
indent: 22,
rootIndent: 22
})
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 = readAllStyles()
expect(css).toMatch(/\.admin-sider\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*height:\s*100%/)
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).toContain('@media (max-width: 800px)')
expect(css).toMatch(/transition:\s*width\s+0\.32s/)
expect(css).toContain('calc(100% - var(--admin-sider-collapsed))')
expect(css).toContain('@media (prefers-reduced-motion: reduce)')
expect(css).not.toContain('.admin-sider-mask')
expect(css).not.toContain('.admin-sider.is-overlay')
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('窄屏展开导航复用同一侧栏并右移主栏,不压缩主栏宽度', async () => {
const realMatchMedia = window.matchMedia.bind(window)
vi.stubGlobal('matchMedia', (query: string) => {
if (query.includes('max-width: 800px')) {
return {
matches: true,
media: query,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent() {
return false
}
}
}
return realMatchMedia(query)
})
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('.admin-sider')).toHaveLength(1)
expect(wrapper.get('.admin-sider').classes()).toContain('is-collapsed')
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 64px')
expect(wrapper.find('.admin-sider-mask').exists()).toBe(false)
expect(wrapper.find('.admin-nav-drawer').exists()).toBe(false)
await wrapper.get('[aria-label="展开侧栏"]').trigger('click')
await flushPromises()
expect(wrapper.findAll('.admin-sider')).toHaveLength(1)
expect(wrapper.get('.admin-sider').classes()).not.toContain('is-collapsed')
expect(wrapper.get('.admin-sider').classes()).not.toContain('is-overlay')
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 208px')
expect(wrapper.find('.admin-sider-mask').exists()).toBe(false)
expect(wrapper.get('.admin-sider').findAll('.n-menu .n-menu-item')).toHaveLength(8)
expect(wrapper.get('[aria-label="折叠侧栏"]').attributes('aria-expanded')).toBe('true')
await wrapper.get('[aria-label="折叠侧栏"]').trigger('click')
await flushPromises()
expect(wrapper.get('.admin-sider').classes()).toContain('is-collapsed')
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 64px')
})
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(readAllStyles()).toMatch(
/\.production-pipeline-card > \.confirm-action\s*\{[^}]*margin-block:\s*16px 4px/
)
})
})