feat: 收口组件样式并调整移动端侧栏
This commit is contained in:
@@ -9,3 +9,21 @@ import { EmptyState } from './ui'
|
||||
>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../styles/styles.css";
|
||||
.page-container {
|
||||
@apply max-w-[1540px] mx-auto pt-[37px] px-10 pb-14;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.page-container {
|
||||
@apply px-[26px];
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.page-container {
|
||||
@apply pt-[25px] px-[18px] pb-10;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NInput, NDropdown } from 'naive-ui'
|
||||
import ActionMenu from './ActionMenu.vue'
|
||||
import DetailDisclosure from './DetailDisclosure.vue'
|
||||
import StyleEditor from '../../features/visual-style/components/StyleEditor.vue'
|
||||
import SubjectList from '../../features/breakdown/components/SubjectList.vue'
|
||||
import { selectMenu } from '../../testing/naive'
|
||||
import type { SubjectCandidate } from '../../features/breakdown/types'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('精简入口保留功能', () => {
|
||||
it('菜单按点击展开,禁用项不能绕过,触发器有可访问名称', async () => {
|
||||
wrapper = mount(ActionMenu, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
label: '测试更多操作',
|
||||
items: [
|
||||
{ key: 'history', label: '执行记录' },
|
||||
{ key: 'export', label: '导出', disabled: true }
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(wrapper.get('button').attributes('aria-haspopup')).toBe('menu')
|
||||
expect(wrapper.get('button').attributes('aria-expanded')).toBe('false')
|
||||
expect(document.querySelector('.n-dropdown-option-body')).toBeNull()
|
||||
await selectMenu('测试更多操作', '执行记录')
|
||||
expect(wrapper.emitted('select')).toEqual([['history']])
|
||||
wrapper.getComponent(NDropdown).vm.$emit('select', 'export')
|
||||
expect(wrapper.emitted('select')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('补充说明默认不展开,键盘可聚焦的标题可查看原文', async () => {
|
||||
wrapper = mount(DetailDisclosure, { props: { title: '规则说明' }, slots: { default: '完整规则仍然保留' } })
|
||||
expect(wrapper.text()).not.toContain('完整规则仍然保留')
|
||||
await wrapper.get('.n-collapse-item__header-main').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('完整规则仍然保留')
|
||||
})
|
||||
|
||||
it('风格高级字段收起不丢失保存值,展开后仍可修改', async () => {
|
||||
wrapper = mount(StyleEditor, { props: { visualStyle: null, disabled: false } })
|
||||
expect(wrapper.find('#style-constraints').exists()).toBe(false)
|
||||
await wrapper.get('.n-collapse-item__header-main').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('#style-constraints').setValue('["保留硬约束"]')
|
||||
const fields = wrapper.findAllComponents(NInput)
|
||||
fields.find(item => item.props('placeholder')?.includes('人物质感'))!.vm.$emit('update:value', '人物风格')
|
||||
await flushPromises()
|
||||
await wrapper.get('.n-collapse-item__header-main').trigger('click')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(wrapper.emitted('save')).toMatchObject([
|
||||
[{ characterPrompt: '人物风格', hardConstraints: ['保留硬约束'] }]
|
||||
])
|
||||
})
|
||||
|
||||
it('主体长文收起仅影响显示,完整内容与图片入口不丢失', async () => {
|
||||
const description = '主体完整经历。'.repeat(30)
|
||||
wrapper = mount(SubjectList, {
|
||||
props: {
|
||||
projectId: 'p',
|
||||
subjects: [
|
||||
{
|
||||
profileId: 'p1',
|
||||
ref: '@CH0001',
|
||||
name: '人物',
|
||||
module: 'character',
|
||||
description,
|
||||
appearance_prompt: '外观',
|
||||
aliases: []
|
||||
} as SubjectCandidate
|
||||
],
|
||||
forms: []
|
||||
},
|
||||
global: { stubs: { RouterLink: true } }
|
||||
})
|
||||
const text = wrapper.get('.line-clamp-3')
|
||||
expect(text.text()).toBe(description)
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.find('.line-clamp-3').exists()).toBe(false)
|
||||
expect(wrapper.findAll('router-link-stub')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -24,3 +24,24 @@ const open = defineModel<boolean>('open', { default: false })
|
||||
</NScrollbar>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.app-dialog {
|
||||
--app-field: var(--app-control);
|
||||
--app-field-hover: var(--app-control-hover);
|
||||
}
|
||||
.app-dialog {
|
||||
@apply max-w-[calc(100vw-32px)] max-h-[calc(100dvh-32px)];
|
||||
}
|
||||
.app-dialog .n-card-header {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.dialog-body-scroll {
|
||||
@apply max-h-[calc(100dvh-154px)];
|
||||
}
|
||||
.app-dialog .n-card__content {
|
||||
@apply min-h-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { NImage } from 'naive-ui'
|
||||
import AssetImage from './AssetImage.vue'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('资源图片展示与原图预览', () => {
|
||||
it('默认使用 cover 并禁用预览,不干扰历史缩略图选择', async () => {
|
||||
wrapper = mount(AssetImage, {
|
||||
attachTo: document.body,
|
||||
props: { src: '/storage/history.png', alt: '历史图片' }
|
||||
})
|
||||
expect(wrapper.getComponent(NImage).props()).toMatchObject({ objectFit: 'cover', previewDisabled: true })
|
||||
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('cover')
|
||||
await wrapper.get('img').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.n-image-preview-container')).toBeNull()
|
||||
})
|
||||
|
||||
it('特殊展示仍可显式选择 contain,样式交由 NImage 控制', () => {
|
||||
wrapper = mount(AssetImage, {
|
||||
props: { src: '/storage/reference.png', alt: '完整参考图', objectFit: 'contain' }
|
||||
})
|
||||
expect(wrapper.getComponent(NImage).props('objectFit')).toBe('contain')
|
||||
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('contain')
|
||||
})
|
||||
|
||||
it('cover 仅裁切卡片,点击使用 Naive 原生预览展示同一完整资源,遮罩可关闭', async () => {
|
||||
wrapper = mount(AssetImage, {
|
||||
attachTo: document.body,
|
||||
props: { src: '/storage/original.png', alt: '林默 · 基础形态', objectFit: 'cover', preview: true }
|
||||
})
|
||||
const source = wrapper.get('img').attributes('src')
|
||||
expect(wrapper.getComponent(NImage).props()).toMatchObject({
|
||||
objectFit: 'cover',
|
||||
previewDisabled: false,
|
||||
previewSrc: source
|
||||
})
|
||||
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('cover')
|
||||
await wrapper.get('img').trigger('click')
|
||||
await flushPromises()
|
||||
const image = document.querySelector<HTMLImageElement>('.n-image-preview')!
|
||||
expect(image).not.toBeNull()
|
||||
expect(image.getAttribute('src')).toBe(source)
|
||||
expect(image.alt).toBe('林默 · 基础形态')
|
||||
expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
|
||||
expect(image.style.objectFit).not.toBe('cover')
|
||||
expect(document.querySelector('.n-image-preview-toolbar')).not.toBeNull()
|
||||
expect(wrapper.element.contains(image)).toBe(false)
|
||||
document.querySelector<HTMLElement>('.n-image-preview-overlay')!.click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector<HTMLElement>('.n-image-preview-wrapper')?.style.display ?? 'none').toBe('none')
|
||||
})
|
||||
|
||||
it('资源加载失败只显示重试占位,不打开空预览', async () => {
|
||||
wrapper = mount(AssetImage, {
|
||||
attachTo: document.body,
|
||||
props: { src: '/storage/missing.png', alt: '失败图片', objectFit: 'cover', preview: true }
|
||||
})
|
||||
await wrapper.get('img').trigger('error')
|
||||
expect(wrapper.text()).toContain('图片无法加载')
|
||||
expect(wrapper.findComponent(NImage).exists()).toBe(false)
|
||||
expect(document.querySelector('.n-image-preview-container')).toBeNull()
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.getComponent(NImage).props('previewDisabled')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([null, 'javascript:alert(1)', 'data:text/html,test'])('空地址或危险地址 %s 不进入原图预览', src => {
|
||||
wrapper = mount(AssetImage, { props: { src, alt: '图片', objectFit: 'cover', preview: true } })
|
||||
expect(wrapper.findComponent(NImage).exists()).toBe(false)
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -50,3 +50,26 @@ watch(
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.asset-image {
|
||||
@apply grid place-items-center w-full aspect-[4/3] overflow-hidden bg-(--app-subtle);
|
||||
}
|
||||
.asset-image img {
|
||||
@apply w-full h-full min-h-0;
|
||||
}
|
||||
.asset-image-empty {
|
||||
@apply flex items-center justify-center flex-col gap-2.5 p-5 text-muted text-[11px] text-center;
|
||||
}
|
||||
.asset-image .n-image {
|
||||
@apply w-full h-full min-h-0 min-w-0 flex justify-center;
|
||||
}
|
||||
.asset-image .n-image img {
|
||||
@apply w-full h-full;
|
||||
}
|
||||
.asset-image.asset-image-preview {
|
||||
@apply h-[min(42dvh,420px)] min-h-0 aspect-auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,3 +21,14 @@ function toggle() {
|
||||
<div class="detail-disclosure-content"><slot /></div></NCollapseItem
|
||||
></NCollapse>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.detail-disclosure.n-collapse {
|
||||
@apply text-xs text-muted;
|
||||
}
|
||||
.detail-disclosure-content {
|
||||
@apply py-3 px-3.5 bg-(--app-subtle) text-ink leading-[1.8];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import DirectoryItem from './DirectoryItem.vue'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => wrapper?.unmount())
|
||||
|
||||
describe('统一目录条目', () => {
|
||||
it('主体缩略图使用可选前导槽位,与标题一起保留满行点击区域', () => {
|
||||
wrapper = mount(DirectoryItem, {
|
||||
props: { active: true },
|
||||
slots: { leading: '<span>母版缩略图</span>', default: '林默', eyebrow: '@CH0001' }
|
||||
})
|
||||
expect(wrapper.classes()).toContain('has-leading')
|
||||
expect(wrapper.get('.directory-item-leading').text()).toBe('母版缩略图')
|
||||
expect(wrapper.get('.directory-item-title').text()).toBe('林默')
|
||||
expect(wrapper.get('.directory-item-leading').element.closest('button')).toBe(wrapper.element)
|
||||
})
|
||||
|
||||
it('编号、长标题和多项状态各占独立层级,沿用 Naive 按钮', () => {
|
||||
const title = '雨夜霓虹街全景与记忆当铺外的人群和闪烁灯牌'
|
||||
wrapper = mount(DirectoryItem, {
|
||||
props: { active: false },
|
||||
slots: {
|
||||
eyebrow: 'BEAT 2 / SHOT 3',
|
||||
default: title,
|
||||
meta: '<span class="directory-status">设计已保存</span><span class="directory-status">待状态</span>'
|
||||
}
|
||||
})
|
||||
expect(wrapper.getComponent(NButton).props()).toMatchObject({ quaternary: true, block: true })
|
||||
expect(wrapper.get('button').attributes('type')).toBe('button')
|
||||
expect(wrapper.get('.directory-item-eyebrow').text()).toBe('BEAT 2 / SHOT 3')
|
||||
expect(wrapper.get('.directory-item-title').text()).toBe(title)
|
||||
expect(wrapper.get('.directory-item-meta').findAll('.directory-status')).toHaveLength(2)
|
||||
expect(wrapper.find('.truncate').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.directory-item-body > span')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('点击透传给业务层,选中态与无障碍属性保持同步', async () => {
|
||||
const onClick = vi.fn<() => void>()
|
||||
wrapper = mount(DirectoryItem, { props: { active: false }, attrs: { onClick }, slots: { default: '林默' } })
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
expect(wrapper.attributes('aria-current')).toBeUndefined()
|
||||
await wrapper.setProps({ active: true })
|
||||
expect(wrapper.get('button').classes()).toContain('selected')
|
||||
expect(wrapper.attributes('aria-current')).toBe('true')
|
||||
expect(wrapper.find('.directory-item-eyebrow').exists()).toBe(false)
|
||||
expect(wrapper.find('.directory-item-meta').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('显式保护最小高度、留白、换行和窄屏横向条目,避免旧按钮样式重新挤压目录', () => {
|
||||
// DOM 环境不计算视觉几何;这些约束不能替代浏览器验收。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(/\.n-button\.directory-item\s*\{[^}]*min-height:\s*88px;[^}]*padding:\s*12px 14px/)
|
||||
expect(css).toMatch(/\.directory-item-body\s*\{[^}]*flex-direction:\s*column;[^}]*gap:\s*7px/)
|
||||
expect(css).toMatch(/\.directory-item-title\s*\{[^}]*overflow-wrap:\s*anywhere/)
|
||||
expect(css).toMatch(/\.directory-list-content\s*\{[^}]*flex-direction:\s*row;[^}]*width:\s*max-content/)
|
||||
expect(css).toContain('grid-template-columns: clamp(240px, 22%, 280px) minmax(0, 1fr)')
|
||||
expect(css).toMatch(/\.identity-subject-list-content\s*\{[^}]*gap:\s*2px;[^}]*padding:\s*0 0 8px/)
|
||||
expect(css).toMatch(/\.n-button\.identity-subject-item\s*\{[^}]*min-height:\s*86px;[^}]*padding:\s*10px 14px/)
|
||||
expect(css).not.toMatch(/\.n-button\.identity-subject-item\s*\{[^}]*border-bottom:/)
|
||||
expect(css).toMatch(/\.identity-thumbnail\s*\{[^}]*width:\s*48px;[^}]*height:\s*64px/)
|
||||
expect(css).toMatch(/\.n-button\.identity-subject-item:not\(\.selected\)\s*\{[^}]*var\(--app-surface\)/)
|
||||
})
|
||||
|
||||
it('侧栏与主体、镜头、剧集目录统一直角满行,保留文字内边距和移动端滚动', () => {
|
||||
// happy-dom 无法计算伪元素几何,保护实际控制选中背景宽度的 CSS 规则。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.admin-sider \.n-menu \.n-menu-item-content::before\s*\{[^}]*left:\s*0;[^}]*right:\s*0;[^}]*border-radius:\s*0/
|
||||
)
|
||||
expect(css).toMatch(/\.directory-list-content\s*\{[^}]*padding:\s*8px 0 16px/)
|
||||
expect(css).toMatch(/\.reader-directory-content\s*\{[^}]*padding:\s*4px 0 18px/)
|
||||
expect(css).not.toMatch(/\.reader-directory-content\s*\{[^}]*padding-inline:\s*[1-9]/)
|
||||
for (const selector of ['directory-item', 'reader-episode-link']) {
|
||||
expect(css).toMatch(
|
||||
new RegExp(
|
||||
`\\.n-button\\.${selector}\\s*\\{[^}]*width:\\s*100%;[^}]*padding:\\s*12px 14px;[^}]*border-radius:\\s*0`
|
||||
)
|
||||
)
|
||||
}
|
||||
expect(css).toMatch(/\.directory-group-heading\s*\{[^}]*padding:\s*10px 14px/)
|
||||
expect(css).toMatch(/\.directory-list-content\s*\{[^}]*flex-direction:\s*row;[^}]*width:\s*max-content/)
|
||||
})
|
||||
|
||||
it('Tabs 工具栏与筛选网格限制最小宽度,不让组件默认宽度挤压内容', () => {
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(/\.workspace-tabs\.n-tabs\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*0/)
|
||||
expect(css).toMatch(/\.workspace-tabs \.n-tabs-nav-scroll-wrapper\s*\{[^}]*min-width:\s*0/)
|
||||
expect(css).toMatch(
|
||||
/\.form-image-filters\s*\{[^}]*grid-template-columns:\s*minmax\(200px, 360px\) 152px minmax\(260px, 1fr\) auto/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/@container \(max-width: 900px\)\s*\{\s*\.form-image-filters\s*\{[^}]*minmax\(0, 1fr\) 152px/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -21,3 +21,46 @@ defineProps<{ active: boolean }>()
|
||||
</span>
|
||||
</NButton>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.n-button.directory-item {
|
||||
@apply flex shrink-0 w-full h-auto min-h-[88px] py-3 px-3.5 rounded-none whitespace-normal text-left;
|
||||
}
|
||||
.n-button.directory-item .n-button__content {
|
||||
@apply block w-full min-w-0;
|
||||
}
|
||||
.directory-item-body {
|
||||
@apply flex flex-col items-start gap-[7px] min-w-0;
|
||||
}
|
||||
.n-button.directory-item.has-leading .n-button__content {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
.directory-item-leading {
|
||||
@apply flex shrink-0;
|
||||
}
|
||||
.directory-item.has-leading .directory-item-body {
|
||||
@apply flex-1;
|
||||
}
|
||||
.directory-item-eyebrow {
|
||||
@apply font-mono text-[11px] leading-normal text-muted wrap-anywhere;
|
||||
}
|
||||
.directory-item-title {
|
||||
@apply text-[13px] leading-[1.7] font-medium text-ink wrap-anywhere;
|
||||
}
|
||||
.directory-item-meta {
|
||||
@apply flex items-center flex-wrap gap-y-1.5 gap-x-2 text-muted text-[11px] leading-[1.7] wrap-anywhere;
|
||||
}
|
||||
.n-button.directory-item.selected {
|
||||
@apply bg-(--app-selected) shadow-[inset_3px_0_var(--app-accent)];
|
||||
}
|
||||
.n-button.directory-item:focus-visible {
|
||||
@apply outline-[2px_solid_var(--app-accent)] outline-offset-[-2px];
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.n-button.directory-item {
|
||||
@apply w-[228px] min-h-[104px];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,3 +13,14 @@ defineProps<{ title: string; description: string }>()
|
||||
></template>
|
||||
</NEmpty>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.empty-state {
|
||||
@apply flex flex-col items-center justify-center min-h-[300px] py-[52px] px-6 text-center;
|
||||
}
|
||||
.empty-state {
|
||||
@apply min-h-[200px] py-10 px-5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,3 +34,11 @@ function selectTheme(key: string | number) {
|
||||
</NButton>
|
||||
</NDropdown>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.n-button.theme-toggle {
|
||||
@apply shrink-0 w-[34px] h-[34px];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,3 +12,72 @@ defineProps<{ split?: boolean; compact?: boolean }>()
|
||||
<NScrollbar v-else class="workspace-scroll" content-class="workspace-scroll-content"><slot /></NScrollbar>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.workspace-page {
|
||||
@apply relative h-full flex flex-col min-h-0 overflow-hidden;
|
||||
}
|
||||
.workspace-heading-scroll.n-scrollbar {
|
||||
@apply shrink-0 h-auto max-h-[min(35dvh,260px)] overflow-hidden;
|
||||
}
|
||||
.workspace-heading {
|
||||
@apply pt-4 px-6 pb-3;
|
||||
}
|
||||
.content-first-workspace .workspace-heading {
|
||||
@apply py-2 px-5;
|
||||
}
|
||||
.content-first-workspace .workspace-split {
|
||||
@apply pt-0 px-5 pb-3 gap-2;
|
||||
}
|
||||
.content-first-workspace .workspace-scroll-content {
|
||||
@apply pt-0 px-5 pb-4;
|
||||
}
|
||||
.workspace-heading h2 {
|
||||
@apply text-base;
|
||||
}
|
||||
.workspace-heading .text-sm {
|
||||
@apply text-xs;
|
||||
}
|
||||
.workspace-heading .production-controls {
|
||||
@apply gap-3;
|
||||
}
|
||||
.workspace-heading .n-input-number {
|
||||
@apply w-full;
|
||||
}
|
||||
.workspace-scroll {
|
||||
@apply flex-1 min-h-0;
|
||||
}
|
||||
.workspace-scroll-content {
|
||||
@apply pt-1 px-6 pb-6;
|
||||
}
|
||||
.workspace-scroll > .n-scrollbar-container {
|
||||
@apply overscroll-contain;
|
||||
}
|
||||
.workspace-split {
|
||||
@apply flex flex-col flex-1 min-h-0 overflow-hidden pt-1 px-6 pb-5 gap-3;
|
||||
}
|
||||
.workspace-split > .my-6 {
|
||||
@apply m-0 shrink-0;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.workspace-heading {
|
||||
@apply p-3;
|
||||
}
|
||||
.content-first-workspace .workspace-heading {
|
||||
@apply py-2 px-3;
|
||||
}
|
||||
.content-first-workspace .workspace-split,
|
||||
.content-first-workspace .workspace-scroll-content {
|
||||
@apply pt-0 px-3 pb-3;
|
||||
}
|
||||
.workspace-scroll-content,
|
||||
.workspace-split {
|
||||
@apply pt-0 px-3 pb-3;
|
||||
}
|
||||
.workspace-heading .production-controls {
|
||||
@apply grid-cols-[minmax(100px,_1fr)_80px];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,3 +46,45 @@ const open = defineModel<boolean>('open', { default: false })
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.workspace-tools-drawer .n-drawer-header,
|
||||
.workspace-tools-drawer .n-drawer-footer {
|
||||
@apply bg-(--app-subtle);
|
||||
}
|
||||
.workspace-tools-drawer .n-drawer-body {
|
||||
@apply bg-(--app-body);
|
||||
}
|
||||
.workspace-tools-drawer {
|
||||
@apply max-w-full;
|
||||
}
|
||||
.workspace-tools-heading {
|
||||
@apply flex items-center justify-between gap-4 w-full text-[15px];
|
||||
}
|
||||
.workspace-tools-body {
|
||||
@apply p-5;
|
||||
}
|
||||
.workspace-tools-body .storyboard-controls,
|
||||
.workspace-tools-body .production-controls {
|
||||
@apply grid grid-cols-[repeat(auto-fit,_minmax(160px,_1fr))] gap-4 my-5;
|
||||
}
|
||||
.workspace-tools-body .storyboard-coverage {
|
||||
@apply my-5;
|
||||
}
|
||||
.workspace-tools-body .generation-row {
|
||||
@apply block;
|
||||
}
|
||||
.workspace-tools-body .generation-row > div + div {
|
||||
@apply mt-3.5;
|
||||
}
|
||||
.workspace-tools-body .production-pipeline {
|
||||
@apply grid-cols-[repeat(auto-fit,_minmax(min(100%,_230px),_1fr))];
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.workspace-tools-body {
|
||||
@apply p-3.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
import { h } from 'vue'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NCheckbox,
|
||||
NConfigProvider,
|
||||
NInput,
|
||||
NSelect,
|
||||
NTable,
|
||||
NTag,
|
||||
NTabPane,
|
||||
NTabs
|
||||
} from 'naive-ui'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { useTheme } from '../../composables/useTheme'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
localStorage.clear()
|
||||
delete document.documentElement.dataset.theme
|
||||
document.documentElement.style.colorScheme = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('全站平面主题', () => {
|
||||
it.each([false, true])('模式 %s 的绿色按钮使用白字,禁用后灰底灰字且不可提交', dark => {
|
||||
localStorage.setItem('drama-studio-theme', dark ? 'dark' : 'light')
|
||||
vi.stubGlobal('matchMedia', () => ({ matches: dark, addEventListener() {}, removeEventListener() {} }))
|
||||
const click = vi.fn<() => void>()
|
||||
wrapper = mount({
|
||||
setup() {
|
||||
const { theme, overrides } = useTheme()
|
||||
return () =>
|
||||
h(NConfigProvider, { theme: theme.value, themeOverrides: overrides.value }, () =>
|
||||
(['primary', 'success'] as const).flatMap(type => [
|
||||
h(NButton, { type, class: `${type}-button`, onClick: click }, () => '提交'),
|
||||
h(
|
||||
NButton,
|
||||
{ type, disabled: true, class: `${type}-disabled`, onClick: click },
|
||||
() => '提交'
|
||||
)
|
||||
])
|
||||
)
|
||||
}
|
||||
})
|
||||
for (const type of ['primary', 'success']) {
|
||||
const active = wrapper.get<HTMLButtonElement>(`.${type}-button`).element
|
||||
const disabled = wrapper.get<HTMLButtonElement>(`.${type}-disabled`).element
|
||||
for (const property of [
|
||||
'--n-text-color',
|
||||
'--n-text-color-hover',
|
||||
'--n-text-color-pressed',
|
||||
'--n-text-color-focus'
|
||||
]) {
|
||||
expect(active.style.getPropertyValue(property)).toBe('#ffffff')
|
||||
}
|
||||
expect(active.style.getPropertyValue('--n-color')).toBe('#078640')
|
||||
expect(active.style.getPropertyValue('--n-border-focus')).toBe('none')
|
||||
expect(active.style.getPropertyValue('--n-wave-opacity')).toBe('0')
|
||||
expect(disabled.style.getPropertyValue('--n-color-disabled')).toBe(dark ? '#303030' : '#e4e4e4')
|
||||
expect(disabled.style.getPropertyValue('--n-text-color-disabled')).toBe(dark ? '#808080' : '#8c8c8c')
|
||||
expect(disabled.disabled).toBe(true)
|
||||
disabled.click()
|
||||
expect(click).not.toHaveBeenCalled()
|
||||
}
|
||||
wrapper.get<HTMLButtonElement>('.primary-button').element.click()
|
||||
expect(click).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([false, true])('模式 %s 的真实控件无静态描边、使用直角,并保留聚焦与错误反馈', dark => {
|
||||
localStorage.setItem('drama-studio-theme', dark ? 'dark' : 'light')
|
||||
vi.stubGlobal('matchMedia', () => ({ matches: dark, addEventListener() {}, removeEventListener() {} }))
|
||||
wrapper = mount({
|
||||
setup() {
|
||||
const { theme, overrides } = useTheme()
|
||||
return () =>
|
||||
h(NConfigProvider, { theme: theme.value, themeOverrides: overrides.value }, () => [
|
||||
h(NButton, {}, () => '生成'),
|
||||
h(NInput, { class: 'normal-input' }),
|
||||
h(NInput, { status: 'error', class: 'error-input' }),
|
||||
h(NCheckbox, { checked: true }),
|
||||
h(NSelect, { options: [{ label: '第一集', value: 1 }] }),
|
||||
h(NAlert, { type: 'error' }, () => '生成失败'),
|
||||
h(NCard, {}, () => '内容'),
|
||||
h(NTag, {}, () => '待设计'),
|
||||
h(NTabs, { type: 'line', value: 'one' }, () => [
|
||||
h(NTabPane, { name: 'one', tab: '人物' }, () => '人物内容'),
|
||||
h(NTabPane, { name: 'two', tab: '场景' }, () => '场景内容')
|
||||
]),
|
||||
h(NTable, { striped: true }, () => h('tbody', [h('tr', [h('td', '记录')])]))
|
||||
])
|
||||
}
|
||||
})
|
||||
const value = (selector: string, name: string) =>
|
||||
(wrapper!.get(selector).element as HTMLElement).style.getPropertyValue(name)
|
||||
const control = dark ? '#2b2b2b' : '#f0f0f0'
|
||||
for (const selector of ['.n-button', '.normal-input', '.n-base-selection', '.n-alert', '.n-card', '.n-tag']) {
|
||||
expect(value(selector, '--n-border-radius')).toBe('0px')
|
||||
}
|
||||
for (const selector of ['.n-button', '.normal-input', '.n-base-selection']) {
|
||||
expect(value(selector, '--n-border')).toBe('none')
|
||||
}
|
||||
// 普通按钮点击后不残留深绿描边,输入和选择控件仍保留聚焦边界。
|
||||
expect(value('.n-button', '--n-border-focus')).toBe('none')
|
||||
expect(value('.n-button', '--n-wave-opacity')).toBe('0')
|
||||
expect(wrapper.getComponent(NButton).props('focusable')).toBe(true)
|
||||
for (const selector of ['.normal-input', '.n-base-selection']) {
|
||||
expect(value(selector, '--n-border-focus')).toContain(dark ? '#5cd693' : '#087c42')
|
||||
}
|
||||
expect(value('.n-button', '--n-color')).toBe(control)
|
||||
expect(value('.normal-input', '--n-color')).toBe('var(--app-field)')
|
||||
expect(value('.n-base-selection', '--n-color')).toBe('var(--app-field)')
|
||||
expect(value('.n-checkbox', '--n-check-mark-color')).toBe('#ffffff')
|
||||
expect(value('.n-alert', '--n-border')).toBe('none')
|
||||
expect(value('.n-tag', '--n-border')).toBe('none')
|
||||
expect(value('.n-card', '--n-border-color')).toBe('transparent')
|
||||
expect(value('.n-table', '--n-border-color')).toBe('transparent')
|
||||
expect(value('.error-input', '--n-border-error')).toContain(dark ? '#fa7373' : '#a93232')
|
||||
expect(wrapper.get('.error-input').classes()).toContain('n-input--error-status')
|
||||
// 不全局抹掉边框色:未勾选复选框和错误态继续由 Naive 的主题处理。
|
||||
const theme = wrapper.getComponent(NConfigProvider).props('themeOverrides')!
|
||||
expect(theme.common?.borderColor).not.toBe('transparent')
|
||||
expect(theme.Checkbox).not.toHaveProperty('border')
|
||||
expect(theme.Tabs?.barColor).toBe('#07c160')
|
||||
expect(value('.n-tabs', '--n-tab-border-color')).toBe(dark ? '#333333' : '#dadada')
|
||||
})
|
||||
|
||||
it('普通按钮只在键盘焦点可见时显示外框,不用失焦或禁止聚焦隐藏点击反馈', () => {
|
||||
// DOM 环境不模拟真实鼠标/键盘焦点判定,保护共享 CSS 与触发器契约。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.n-button:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--app-accent-text\);[^}]*outline-offset:\s*2px;/
|
||||
)
|
||||
const tools = readFileSync('src/components/ui/WorkspaceTools.vue', 'utf8')
|
||||
expect(tools).not.toContain('.blur(')
|
||||
expect(tools).not.toContain(':focusable="false"')
|
||||
})
|
||||
|
||||
it('图库筛选吸顶,关联内容横向单行滚动,不给头部和图片区制造双重滚动边界', () => {
|
||||
// 静态保护吸顶和横向尺寸规则;真实视口滚动与位置仍需浏览器验收。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.gallery-workspace-page \.gallery-sticky-controls\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*z-index:\s*10;[^}]*background:\s*var\(--app-body\);/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.gallery-workspace-page \.workspace-scroll > \.n-scrollbar-container\s*\{[^}]*overflow-anchor:\s*none;/
|
||||
)
|
||||
expect(css).toMatch(/\.asset-impact-links\s*\{[^}]*width:\s*max-content;[^}]*white-space:\s*nowrap;/)
|
||||
expect(css).toMatch(/\.gallery-sticky-controls > \.form-image-filter-region\s*\{[^}]*margin-block:\s*0;/)
|
||||
// 间距归列表所有,吸顶时底部不留固定色带。
|
||||
expect(css).toMatch(/\.gallery-workspace-page \.form-image-grid\s*\{[^}]*padding-top:\s*12px;/)
|
||||
const sticky = css.match(/\.gallery-workspace-page \.gallery-sticky-controls\s*\{([^}]*)\}/)?.[1]
|
||||
expect(sticky).not.toMatch(/padding|border-bottom|::after/)
|
||||
expect(css).toMatch(
|
||||
/\.asset-impact-links\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*32px;[^}]*padding-block:\s*6px;/
|
||||
)
|
||||
expect(css).toMatch(/\.asset-impact-links-scroll\.n-scrollbar\s*\{[^}]*height:\s*auto;[^}]*max-width:\s*100%;/)
|
||||
expect(readFileSync('src/features/subject-images/SubjectImagesPage.vue', 'utf8')).toMatch(
|
||||
/<WorkspacePage\b[^>]*>\s*<template #default>/
|
||||
)
|
||||
})
|
||||
|
||||
it('瀑布流自适应列宽,卡片保持完整并按封面尺寸预留高度', () => {
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.gallery-workspace-page \.form-image-masonry\s*\{[^}]*display:\s*block;[^}]*columns:\s*260px;[^}]*column-gap:\s*20px;/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.form-image-masonry > \.form-image-card\s*\{[^}]*break-inside:\s*avoid;[^}]*margin-bottom:\s*20px;/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.form-image-masonry \.asset-image\s*\{[^}]*aspect-ratio:\s*var\(--form-image-aspect, 4 \/ 3\);/
|
||||
)
|
||||
})
|
||||
|
||||
it('图库顶部统一镜头选择和筛选的背景与垂直对齐,窄屏整组换行', () => {
|
||||
// DOM 环境不计算几何;保护容器宽度断点和居中契约,真实坐标仍需浏览器验收。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.form-image-filter-region\s*\{[^}]*padding:\s*14px 16px;[^}]*background:\s*var\(--app-subtle\);/
|
||||
)
|
||||
expect(css).toMatch(/\.form-image-toolbar\s*\{[^}]*align-items:\s*center;[^}]*gap:\s*12px 16px;/)
|
||||
expect(css).toMatch(
|
||||
/\.form-image-toolbar\.has-impact-picker\s*\{[^}]*grid-template-columns:\s*minmax\(220px, 340px\) minmax\(0, 1fr\);/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/@container \(max-width: 1200px\)\s*\{\s*\.form-image-toolbar\.has-impact-picker,\s*\.form-image-toolbar\.has-impact-picker\.has-impact-actions\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);/
|
||||
)
|
||||
expect(css).toMatch(/\.asset-impact-picker\s*\{[^}]*align-items:\s*center;[^}]*gap:\s*8px;/)
|
||||
expect(css).toMatch(/\.asset-impact-context\s*\{[^}]*gap:\s*10px;[^}]*padding:\s*5px 16px;[^}]*margin:\s*0;/)
|
||||
})
|
||||
|
||||
it('单图标按钮以当前控件高度为边长,尺寸与内容按钮互不影响', () => {
|
||||
// happy-dom 不计算几何尺寸,此项约束共用样式和调用方,实际布局仍需浏览器验收。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.n-button\.icon-button\s*\{[^}]*width:\s*var\(--n-height\);[^}]*min-width:\s*var\(--n-height\);[^}]*height:\s*var\(--n-height\);[^}]*padding:\s*0/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.project-status-filters \.n-radio-button\s*\{[^}]*min-width:\s*76px;[^}]*padding-inline:\s*18px/
|
||||
)
|
||||
expect(css).toMatch(/\.project-status-filters\.n-radio-group\s*\{[^}]*flex-wrap:\s*wrap;[^}]*height:\s*auto;/)
|
||||
for (const file of [
|
||||
'src/App.vue',
|
||||
'src/components/ui/ThemeToggle.vue',
|
||||
'src/components/ui/WorkspaceTools.vue',
|
||||
'src/features/projects/ProjectsPage.vue',
|
||||
'src/features/subject-identity/SubjectIdentityPage.vue'
|
||||
]) {
|
||||
const source = readFileSync(file, 'utf8')
|
||||
expect(source).toContain('icon-button')
|
||||
expect(source).not.toMatch(/<NButton\b[^>]*\bcircle\b/)
|
||||
}
|
||||
expect(readFileSync('src/components/ui/DirectoryItem.vue', 'utf8')).not.toContain('icon-button')
|
||||
})
|
||||
|
||||
it('业务面板、列表分组和标签以背景区分,保留键盘焦点与选中指示', () => {
|
||||
// happy-dom 不计算布局;此项保护 CSS 契约,不能替代浏览器视觉验收。
|
||||
const admin = readFileSync('src/admin.css', 'utf8')
|
||||
const css = readFileSync('src/styles.css', 'utf8')
|
||||
expect(admin + css).not.toMatch(/border-radius:\s*[1-9]/)
|
||||
expect(admin + css).not.toMatch(/border(?:-[\w]+)?:\s*1px (?:solid|dashed) var\(--(?:app-border|color-line)\)/)
|
||||
expect(css).toMatch(/\.surface-inset\s*\{[^}]*background:\s*var\(--app-subtle\)/)
|
||||
expect(css).toMatch(/\.record-list > article:nth-child\(even\)\s*\{\s*background:/)
|
||||
expect(admin).toMatch(/\.directory-group-heading\s*\{[^}]*background:\s*var\(--app-control\)/)
|
||||
expect(admin).toMatch(/\.directory-status\s*\{[^}]*background:\s*var\(--app-control\)/)
|
||||
expect(admin).toMatch(/\.n-button\.directory-item:focus-visible\s*\{[^}]*outline:\s*2px/)
|
||||
expect(admin).toMatch(
|
||||
/\.n-button\.directory-item\.selected\s*\{[^}]*var\(--app-selected\)[^}]*var\(--app-accent\)/
|
||||
)
|
||||
})
|
||||
|
||||
it('斑马纹首项保留统一顶部留白,背景色平滑过渡并遵循减少动态效果设置', () => {
|
||||
// DOM 环境不计算实际内边距,检查共享规则及之前覆盖首项留白的工具类。
|
||||
const css = readFileSync('src/styles.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.record-list > article\s*\{[^}]*padding:\s*20px;[^}]*transition:\s*background-color 160ms ease;/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/@media \(prefers-reduced-motion: reduce\)[\s\S]*transition-duration:\s*0\.01ms !important;/
|
||||
)
|
||||
for (const file of [
|
||||
'src/features/breakdown/components/SubjectList.vue',
|
||||
'src/features/create-drama/CreateDramaPage.vue'
|
||||
]) {
|
||||
expect(readFileSync(file, 'utf8')).not.toContain('first:pt-0')
|
||||
}
|
||||
})
|
||||
|
||||
it('所有页面不再通过工具类添加装饰边框或圆角', () => {
|
||||
const files = readdirSync('src', { recursive: true, encoding: 'utf8' }).filter(path => path.endsWith('.vue'))
|
||||
for (const path of files) {
|
||||
const source = readFileSync(`src/${path}`, 'utf8')
|
||||
for (const match of source.matchAll(/\bclass="([^"]*)"/g)) {
|
||||
expect(match[1]).not.toMatch(
|
||||
/(?:^|\s)(?:rounded(?:-[\w-]+)?|border|border-[trbl](?:-\d+)?|divide-[xy])(?:\s|$)/
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,305 +0,0 @@
|
||||
import { h } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NConfigProvider, NDrawer, NDrawerContent, NModal, NScrollbar, NTooltip } from 'naive-ui'
|
||||
import App from '../../App.vue'
|
||||
import WorkspacePage from './WorkspacePage.vue'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
import WorkspaceTools from './WorkspaceTools.vue'
|
||||
import ProjectLayout from '../../features/projects/ProjectLayout.vue'
|
||||
import ConfirmAction from '../../features/workflows/ConfirmAction.vue'
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
import { projectsApi } from '../../features/projects/api'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { drawerPanel } from '../../testing/naive'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
localStorage.clear()
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('管理后台组件边界', () => {
|
||||
it('Tabs suffix 与标签共用导航行,窄屏换行但不产生外层滚动', () => {
|
||||
// DOM 测试不计算坐标;保护对齐尺寸与容器断点,视觉验收仍需浏览器。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(/\.workspace-tabs > \.n-tabs-nav\s*\{[^}]*align-items:\s*stretch;/)
|
||||
expect(css).toMatch(/\.workspace-tabs \.n-tabs-nav__suffix\s*\{[^}]*align-items:\s*center;/)
|
||||
expect(css).toMatch(/\.tabs-toolbar-actions\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*40px;/)
|
||||
expect(css).toContain('@container tabs-toolbar (max-width: 600px)')
|
||||
expect(css).not.toContain('.result-toolbar > .n-button')
|
||||
})
|
||||
|
||||
it('滚动边界样式包含 document 锁定和工作区收缩约束', () => {
|
||||
// happy-dom 不计算视口几何;这里只保护 CSS 契约,真实滚动仍需浏览器验收。
|
||||
const adminCss = readFileSync('src/admin.css', 'utf8')
|
||||
expect(adminCss).toMatch(/html,\s*body,\s*#app,\s*\.app-provider\s*\{[^}]*overflow:\s*hidden/)
|
||||
expect(adminCss).toMatch(/\.workspace-page\s*\{[^}]*min-height:\s*0;[^}]*overflow:\s*hidden/)
|
||||
})
|
||||
it('业务样式不再直接开启浏览器原生滚动条', () => {
|
||||
const css = readFileSync('src/admin.css', 'utf8') + readFileSync('src/styles.css', 'utf8')
|
||||
expect(css).not.toMatch(/overflow(?:-[xy])?\s*:\s*(?:auto|scroll)\b/)
|
||||
})
|
||||
it('历史缩略图尺寸在非分层样式中覆盖 Naive 按钮,原图不能撑大预览或横向条目', () => {
|
||||
// happy-dom 不计算几何;专门保护此前失效的层叠和固定尺寸约束。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).not.toContain('@layer')
|
||||
expect(css).toMatch(
|
||||
/\.n-button\.image-history-item\s*\{[^}]*width:\s*128px;[^}]*min-width:\s*128px;[^}]*max-width:\s*128px;[^}]*flex:\s*0 0 128px;[^}]*padding:\s*6px/
|
||||
)
|
||||
expect(css).toMatch(/\.image-history-item \.asset-image\s*\{[^}]*height:\s*88px;[^}]*min-height:\s*0/)
|
||||
expect(css).toMatch(
|
||||
/\.asset-image\.asset-image-preview\s*\{[^}]*height:\s*min\(42dvh, 420px\);[^}]*aspect-ratio:\s*auto/
|
||||
)
|
||||
expect(css).toMatch(/\.image-history\.n-scrollbar\s*\{[^}]*max-width:\s*100%;[^}]*min-width:\s*0/)
|
||||
expect(css).toMatch(/\.image-history-content\s*\{[^}]*display:\s*flex;[^}]*width:\s*max-content/)
|
||||
})
|
||||
it('选角面板保留说明间距,卡片按可用宽度排列并覆盖按钮默认高度', () => {
|
||||
// 保护间距与换行契约,真实宽高和暗色效果仍需浏览器验收。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(/\.identity-tools-intro\s*\{[^}]*margin-bottom:\s*24px/)
|
||||
expect(css).toMatch(
|
||||
/\.casting-list\s*\{[^}]*repeat\(auto-fit, minmax\(min\(100%, 260px\), 1fr\)\);[^}]*gap:\s*12px;[^}]*margin-top:\s*24px/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.n-button\.casting-item\s*\{[^}]*height:\s*auto;[^}]*min-height:\s*76px;[^}]*padding:\s*12px 14px/
|
||||
)
|
||||
expect(css).toMatch(/\.casting-item-name\s*\{[^}]*overflow-wrap:\s*anywhere/)
|
||||
expect(css).toMatch(/\.casting-item-status\s*\{[^}]*flex-shrink:\s*0/)
|
||||
expect(css).toMatch(/\.n-button\.casting-item\.selected\s*\{[^}]*var\(--app-selected\)[^}]*var\(--app-accent\)/)
|
||||
})
|
||||
it('拆解分栏跟随剩余高度收缩,移动端历史区不挤出结果区', () => {
|
||||
// DOM 环境不计算几何,保护完整的 flex → grid → NScrollbar 高度链。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(/\.breakdown-results-section\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0/)
|
||||
expect(css).toMatch(/\.breakdown-results-section > \.result-toolbar\s*\{[^}]*flex-shrink:\s*0/)
|
||||
expect(css).toMatch(
|
||||
/\.breakdown-results-section > \.content-with-history\s*\{[^}]*flex:\s*1;[^}]*height:\s*auto;[^}]*min-height:\s*0;[^}]*grid-template-rows:\s*minmax\(0, 1fr\)/
|
||||
)
|
||||
expect(css).toContain('grid-template-rows: minmax(0, 1fr) min(25%, 130px)')
|
||||
expect(css).toMatch(/\.panel-scroll\.n-scrollbar\s*\{[^}]*height:\s*100%;[^}]*min-height:\s*0/)
|
||||
})
|
||||
it('标题与内容使用各自滚动容器,不随正文一起滚动', () => {
|
||||
wrapper = mount(WorkspacePage, {
|
||||
slots: { header: '<h2>固定操作区</h2>', default: '<p>正文</p>' }
|
||||
})
|
||||
expect(wrapper.get('.workspace-heading').text()).toBe('固定操作区')
|
||||
const content = wrapper.get('.workspace-scroll')
|
||||
expect(content.classes()).toContain('n-scrollbar')
|
||||
expect(content.text()).toBe('正文')
|
||||
expect(content.find('h2').exists()).toBe(false)
|
||||
expect(wrapper.get('.workspace-heading-scroll .n-scrollbar-content').text()).toBe('固定操作区')
|
||||
})
|
||||
|
||||
it('分栏工作区不再包一层整页滚动', () => {
|
||||
wrapper = mount(WorkspacePage, {
|
||||
props: { split: true },
|
||||
slots: { default: '<aside>目录</aside><article>正文</article>' }
|
||||
})
|
||||
expect(wrapper.findComponent(NScrollbar).exists()).toBe(false)
|
||||
expect(wrapper.get('.workspace-split').findAll(':scope > *')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('图标主题菜单保留三种模式,设置入口位于侧栏底部并支持折叠', async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/projects', component: { render: () => h('p', '项目列表') } },
|
||||
{
|
||||
path: '/projects/:projectId/:workspace',
|
||||
component: { render: () => h(WorkspacePage, {}, () => '镜头详情') }
|
||||
}
|
||||
]
|
||||
})
|
||||
await router.push('/projects/test/production')
|
||||
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.n-menu .n-menu-item')).toHaveLength(8)
|
||||
expect(wrapper.get('#main-content .workspace-page').text()).toBe('镜头详情')
|
||||
expect(wrapper.get('.theme-toggle').text()).toBe('')
|
||||
expect(wrapper.get('.theme-toggle').attributes('aria-haspopup')).toBe('menu')
|
||||
expect(wrapper.getComponent(ThemeToggle).findComponent(NTooltip).exists()).toBe(false)
|
||||
expect(wrapper.get('.theme-toggle').attributes('title')).toBeUndefined()
|
||||
expect(wrapper.get('.theme-toggle svg').classes()).toContain('lucide-monitor')
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
for (const [key, label, icon] of [
|
||||
['dark', '暗黑模式', 'moon'],
|
||||
['light', '浅色模式', 'sun'],
|
||||
['system', '跟随系统', 'monitor']
|
||||
] as const) {
|
||||
await wrapper.get('.theme-toggle').trigger('click')
|
||||
await flushPromises()
|
||||
const option = [...document.querySelectorAll<HTMLElement>('.n-dropdown-option-body')].find(
|
||||
item => item.textContent?.trim() === label
|
||||
)
|
||||
expect(option).toBeDefined()
|
||||
option!.click()
|
||||
await flushPromises()
|
||||
expect(localStorage.getItem('drama-studio-theme')).toBe(key)
|
||||
expect(wrapper.get('.theme-toggle svg').classes()).toContain(`lucide-${icon}`)
|
||||
expect(wrapper.get('.theme-toggle').attributes('aria-label')).toBe(`主题模式:${label}`)
|
||||
const expectedTheme = key === 'system' ? systemTheme : key
|
||||
expect(document.documentElement.dataset.theme).toBe(expectedTheme)
|
||||
expect(wrapper.getComponent(NConfigProvider).props('theme')?.name ?? 'light').toBe(expectedTheme)
|
||||
}
|
||||
expect(wrapper.find('.admin-topbar [aria-label="后端连接"]').exists()).toBe(false)
|
||||
expect(wrapper.find('.admin-nav-scroll [aria-label="后端连接"]').exists()).toBe(false)
|
||||
expect(wrapper.get('.admin-sider-footer [aria-label="后端连接"]').text()).toBe('后端连接')
|
||||
await wrapper.get('[aria-label="折叠侧栏"]').trigger('click')
|
||||
expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true)
|
||||
const settings = wrapper.get('.admin-sider-footer [aria-label="后端连接"]')
|
||||
expect(settings.text()).toBe('')
|
||||
await settings.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[role="dialog"][aria-label="后端连接"]')).not.toBeNull()
|
||||
expect(wrapper.findAll('.admin-nav-scroll .n-menu .n-menu-item')).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('侧栏菜单独立滚动,页脚不参与滚动且窄屏不超出视口底部', () => {
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.admin-sider-content\s*\{[^}]*height:\s*100%;[^}]*min-height:\s*0;[^}]*overflow:\s*hidden/
|
||||
)
|
||||
expect(css).toMatch(/\.admin-nav-scroll\.n-scrollbar\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0/)
|
||||
expect(css).toMatch(/\.admin-sider-footer\s*\{[^}]*flex-shrink:\s*0/)
|
||||
expect(css).toMatch(/\.admin-sider:not\([^)]*\)\s*\{[^}]*bottom:\s*0;[^}]*height:\s*auto/)
|
||||
for (const path of ['storyboard/StoryboardPage.vue', 'production/ProductionPage.vue']) {
|
||||
const source = readFileSync(`src/features/${path}`, 'utf8')
|
||||
expect(source).not.toContain('<span>剧集</span')
|
||||
expect(source).toMatch(/aria-label="选择(?:分镜|生产)剧集"/)
|
||||
}
|
||||
})
|
||||
|
||||
it('长弹窗使用内部滚动,提交期间禁止遮罩和 Esc 关闭', async () => {
|
||||
wrapper = mount(AppDialog, {
|
||||
attachTo: document.body,
|
||||
props: { open: true, title: '确认生图', description: '费用确认', busy: true },
|
||||
slots: { default: '<p>内容</p>' }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent(NModal).props()).toMatchObject({
|
||||
closable: false,
|
||||
maskClosable: false,
|
||||
closeOnEsc: false
|
||||
})
|
||||
expect(document.querySelector('.dialog-body-scroll .n-scrollbar-container')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('抽屉挂载 body 覆盖完整视口,开关保留列表与配置草稿', async () => {
|
||||
wrapper = mount(WorkspacePage, {
|
||||
attachTo: document.body,
|
||||
props: { split: true, compact: true },
|
||||
slots: {
|
||||
header: () => h(WorkspaceTools, { title: '测试操作' }, () => h('input', { 'aria-label': '配置草稿' })),
|
||||
default: '<article class="test-list">主体列表</article>'
|
||||
}
|
||||
})
|
||||
const list = wrapper.get('.test-list').element
|
||||
expect(wrapper.find('.workspace-tools-drawer').exists()).toBe(false)
|
||||
expect(wrapper.get('.workspace-heading').find('[data-workspace-tools]').exists()).toBe(true)
|
||||
await wrapper.get('[data-workspace-tools]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent(NDrawer).props()).toMatchObject({
|
||||
to: 'body',
|
||||
blockScroll: true,
|
||||
placement: 'right',
|
||||
width: 'min(960px, 100%)',
|
||||
trapFocus: true,
|
||||
closeOnEsc: true
|
||||
})
|
||||
expect(wrapper.getComponent(NDrawerContent).props('nativeScrollbar')).toBe(false)
|
||||
expect(wrapper.find('.workspace-tools-drawer').exists()).toBe(false)
|
||||
const container = drawerPanel().element.closest('.n-drawer-container')!
|
||||
expect(container.parentElement).toBe(document.body)
|
||||
expect(container.closest('main, .admin-content, .workspace-page')).toBeNull()
|
||||
expect(container.querySelector('.n-drawer-mask')).not.toBeNull()
|
||||
expect(drawerPanel().find('.n-scrollbar-container').exists()).toBe(true)
|
||||
await drawerPanel().get('input[aria-label="配置草稿"]').setValue('保留配置')
|
||||
await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent(NDrawer).props('show')).toBe(false)
|
||||
expect(wrapper.get('.test-list').element).toBe(list)
|
||||
await wrapper.get('[data-workspace-tools]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(drawerPanel().get<HTMLInputElement>('input[aria-label="配置草稿"]').element.value).toBe('保留配置')
|
||||
container.querySelector<HTMLElement>('.n-drawer-mask')!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent(NDrawer).props('show')).toBe(false)
|
||||
expect(wrapper.get('.test-list').element).toBe(list)
|
||||
wrapper.unmount()
|
||||
wrapper = undefined
|
||||
expect(document.querySelector('.n-drawer-container')).toBeNull()
|
||||
})
|
||||
|
||||
it('完成项目解锁全部左侧链接,标题不再显示状态标签和刷新按钮', async () => {
|
||||
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue({
|
||||
id: 'navigation-test',
|
||||
title: '导航测试项目',
|
||||
topic: '测试主题',
|
||||
style: null,
|
||||
status: 'completed',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
episodes: [],
|
||||
characters: [],
|
||||
world: null,
|
||||
reviews: [],
|
||||
tasks: []
|
||||
})
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const paths = [
|
||||
'create-drama',
|
||||
'breakdown',
|
||||
'visual-style',
|
||||
'subject-identity',
|
||||
'subject-images',
|
||||
'storyboard',
|
||||
'production'
|
||||
]
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/projects/:projectId',
|
||||
component: ProjectLayout,
|
||||
children: paths.map(path => ({
|
||||
path,
|
||||
component: { render: () => h(WorkspacePage, {}, () => path) }
|
||||
}))
|
||||
}
|
||||
]
|
||||
})
|
||||
await router.push('/projects/navigation-test/create-drama')
|
||||
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[aria-label="项目工作流"]').exists()).toBe(false)
|
||||
expect(wrapper.get('.project-title').text()).toBe('导航测试项目')
|
||||
for (const path of paths) {
|
||||
await wrapper.get(`.n-menu a[href="/projects/navigation-test/${path}"]`).trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/projects/navigation-test/' + path)
|
||||
expect(wrapper.get('.project-view .workspace-page').text()).toBe(path)
|
||||
}
|
||||
expect(detail).toHaveBeenCalledOnce()
|
||||
expect(wrapper.find('.project-header .n-button').exists()).toBe(false)
|
||||
expect(wrapper.find('.project-header .n-tag').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('确认按钮有稳定根元素承接调用方间距,生产按钮显式保留上下留白', () => {
|
||||
wrapper = mount(ConfirmAction, {
|
||||
props: { label: '测试操作', description: '仅展示,不提交请求' },
|
||||
attrs: { class: 'mt-4' }
|
||||
})
|
||||
expect(wrapper.element.tagName).toBe('SPAN')
|
||||
expect(wrapper.classes()).toContain('confirm-action')
|
||||
expect(wrapper.classes()).toContain('mt-4')
|
||||
expect(wrapper.get('button').text()).toBe('测试操作')
|
||||
expect(readFileSync('src/admin.css', 'utf8')).toMatch(
|
||||
/\.production-pipeline-card > \.confirm-action\s*\{[^}]*margin-block:\s*16px 4px/
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user