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
@@ -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()
}
})
})