diff --git a/README.md b/README.md index aa0f8fb..36d7709 100644 --- a/README.md +++ b/README.md @@ -94,13 +94,14 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S - 拆解页固定精简状态栏与结果标签,设置、预览及恢复操作收进“拆解设置与恢复”面板;人物、场景、道具、分镜和任务明细共用独立结果滚动区,执行记录单独滚动。结果区占满剩余高度,切换标签回到顶部,轮询刷新或开关设置不重置阅读位置;失败与校验提示在面板关闭时仍然可见。 - 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。 - 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。 -- 侧栏导航与主体/剧集/镜头目录采用直角满行背景,移除列表两侧的内缩,悬停和选中态都铺满所在列。条目内部保留文字间距;窄屏横向目录仍保持独立滚动,不影响表单与弹窗的圆角。 -- 主体目录以母版缩略图搭配名称、编号与状态,默认保留行背景和细分隔线。人物优先复用选角接口的母版;缺少地址的条目进入可视区域后才读取正式身份图库,不为整份目录轮询。无母版或加载失败显示人物/场景/道具图标,候选与形态图不会替代母版;点击缩略图只切换主体,刷新目录可重试读取。 +- 侧栏导航与主体/剧集/镜头目录采用直角满行背景,移除列表两侧的内缩,悬停和选中态都铺满所在列。条目内部保留文字间距;窄屏横向目录仍保持独立滚动。 +- 主体目录以母版缩略图搭配名称、编号与状态,默认用行背景和轻微间距区分条目。人物优先复用选角接口的母版;缺少地址的条目进入可视区域后才读取正式身份图库,不为整份目录轮询。无母版或加载失败显示人物/场景/道具图标,候选与形态图不会替代母版;点击缩略图只切换主体,刷新目录可重试读取。 - 分镜设计与镜头生产共用两级镜头目录:分组头展示 `BEAT 01` 和镜头数,组内仅显示“镜头 01”、标题及状态。分组头在本组内吸顶;窄屏横向模式才在条目上补回 Beat 归属。点击仍使用正式 Shot ID,不会混淆不同 Beat 的同号镜头。 - 身份、分镜、生产与图库的批量配置、说明和诊断统一放入 `WorkspaceTools`(Naive UI Drawer),按需覆盖当前工作区,不挤压列表。面板支持 Esc 或关闭按钮;关闭不会取消任务或清除配置。历史回执可从入口查看,新回执主动打开诊断;查询错误始终在外层可见。 - 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class`。 - 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。 - 采用微信风格配色:浅色为浅灰背景与白色内容,暗色为近黑背景与深灰内容;绿色用于主操作、选中态和进度。按钮文字、目录文字与主绿色分别配置以保证对比度,错误/警告仍使用独立语义色。Naive UI 和业务内容共用主题,浏览器主题色随之切换;加载前应用偏好,减少主题闪烁。 +- 全站采用低边框、直角样式:面板、提示框、标签、图片卡片和表单用背景层次代替静态描边;表格与长记录列表使用交替底色,抽屉用灰底承托内容。保留输入框聚焦/校验反馈、复选框、选中指示和必要的浮层阴影;开关、加载等功能图形不强制改形状。 - 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。 布局样式集中在 `src/admin.css`,业务排版保留在 `src/styles.css`。新增页面应使用 `WorkspacePage`(内容优先页开启 `compact`),低频操作使用 `WorkspaceTools`;避免通过 `body` 或 `window.scrollTo` 管理滚动。 diff --git a/src/App.vue b/src/App.vue index 229f3a5..b274e21 100644 --- a/src/App.vue +++ b/src/App.vue @@ -84,7 +84,6 @@ const menuOptions = computed(() => [ (() => [ /> - +
* { min-width: 0; @@ -470,8 +486,7 @@ body { gap: 12px; flex-shrink: 0; padding: 10px 14px; - border-block: 1px solid var(--app-border); - background: var(--app-subtle); + background: var(--app-control); font: 600 11px/1.6 ui-monospace, monospace; @@ -533,13 +548,12 @@ body { flex: 1; } .identity-subject-list-content { - gap: 0; + gap: 2px; padding: 0 0 8px; } .n-button.identity-subject-item { min-height: 86px; padding: 10px 14px; - border-bottom: 1px solid var(--app-border); } .n-button.identity-subject-item:not(.selected) { background: var(--app-surface); @@ -616,8 +630,8 @@ body { align-items: center; gap: 5px; padding: 1px 5px; - border: 1px solid var(--app-border); - border-radius: 3px; + border-radius: 0; + background: var(--app-control); } .directory-status svg { flex-shrink: 0; @@ -746,7 +760,6 @@ body { min-height: 0; min-width: 0; background: var(--app-subtle); - border-right: 1px solid var(--app-border); } .reader-directory-heading { display: flex; @@ -827,7 +840,6 @@ body { } .reader-chapter + .reader-chapter { margin-top: 48px; - border-top: 1px solid var(--app-border); padding-top: 36px; } .reader-chapter:last-child { @@ -1026,6 +1038,7 @@ body { height: auto; padding: 6px; white-space: normal; + background: var(--app-subtle); } .n-button.image-history-item.selected { background: var(--app-selected); @@ -1066,7 +1079,6 @@ body { gap: 12px; margin-top: 24px; padding-top: 20px; - border-top: 1px solid var(--app-border); } .n-button.casting-item { width: 100%; @@ -1076,7 +1088,7 @@ body { padding: 12px 14px; white-space: normal; text-align: left; - background: var(--app-surface); + background: var(--app-subtle); } .n-button.casting-item.selected { background: var(--app-selected); @@ -1223,7 +1235,6 @@ body { .directory-panel { height: 100%; border-right: 0; - border-bottom: 1px solid var(--app-border); } .directory-heading { padding: 12px 14px 8px; diff --git a/src/components/ui/DirectoryItem.test.ts b/src/components/ui/DirectoryItem.test.ts index ff07683..e53bf98 100644 --- a/src/components/ui/DirectoryItem.test.ts +++ b/src/components/ui/DirectoryItem.test.ts @@ -59,8 +59,9 @@ describe('统一目录条目', () => { 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*0;[^}]*padding:\s*0 0 8px/) - expect(css).toMatch(/\.n-button\.identity-subject-item\s*\{[^}]*min-height:\s*86px;[^}]*border-bottom:/) + 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\)/) }) diff --git a/src/components/ui/flatTheme.test.ts b/src/components/ui/flatTheme.test.ts new file mode 100644 index 0000000..0b94089 --- /dev/null +++ b/src/components/ui/flatTheme.test.ts @@ -0,0 +1,89 @@ +import { h } from 'vue' +import { mount, type VueWrapper } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NAlert, NButton, NCard, NConfigProvider, NInput, NSelect, NTable, NTag } 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() {} })) + 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(NSelect, { options: [{ label: '第一集', value: 1 }] }), + h(NAlert, { type: 'error' }, () => '生成失败'), + h(NCard, {}, () => '内容'), + h(NTag, {}, () => '待设计'), + 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(selector, '--n-color')).toBe(control) + expect(value(selector, '--n-border-focus')).toContain(dark ? '#5cd693' : '#087c42') + } + 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') + }) + + 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*\{\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('所有页面不再通过工具类添加装饰边框或圆角', () => { + 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|$)/ + ) + } + } + }) +}) diff --git a/src/composables/useTheme.test.ts b/src/composables/useTheme.test.ts index f59cd6b..1eefe08 100644 --- a/src/composables/useTheme.test.ts +++ b/src/composables/useTheme.test.ts @@ -89,7 +89,7 @@ describe('微信风格明暗主题', () => { it.each([false, true])('明暗模式 %s 的组件表面、语义色与业务 CSS 一致,主要文字保持可读', dark => { const state = setup(dark) - const { common, Menu, Button, Layout } = state.overrides.value + const { common, Menu, Button, Layout, Input, Select } = state.overrides.value const css = readFileSync('src/admin.css', 'utf8') const lightTokens = css.match(/:root\s*\{([^}]+)\}/)![1]! const darkTokens = css.match(/:root\[data-theme='dark'\]\s*\{([^}]+)\}/)![1]! @@ -119,8 +119,13 @@ describe('微信风格明暗主题', () => { itemColorActiveCollapsed: tokens.selected, itemTextColorActive: tokens['accent-text'] }) - // 圆角调整只用于导航,表单、普通按钮和弹窗仍保留原有主题。 - expect(common?.borderRadius).toBe('6px') + // 全站直角;输入区域与普通按钮共享底色,保持明暗模式一致。 + expect(common?.borderRadius).toBe('0px') + expect(common?.borderRadiusSmall).toBe('0px') + expect(Input?.color).toBe(tokens.control) + expect(Button?.color).toBe(tokens.control) + expect(Button?.colorHover).toBe(tokens['control-hover']) + expect(Select?.peers?.InternalSelection?.color).toBe(tokens.control) expect(Button?.textColorPrimary).toBe(tokens['on-accent']) for (const background of [common!.primaryColor!, common!.primaryColorHover!, common!.primaryColorPressed!]) { expect(contrast(String(Button!.textColorPrimary), background)).toBeGreaterThanOrEqual(4.5) diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts index 844e993..2d86a77 100644 --- a/src/composables/useTheme.ts +++ b/src/composables/useTheme.ts @@ -39,6 +39,18 @@ export function useTheme() { const info = dark ? '#91a8d2' : '#576b95' const danger = dark ? '#fa7373' : '#a93232' const warning = dark ? '#e8b66a' : '#916019' + const control = dark ? '#2b2b2b' : '#f0f0f0' + const controlHover = dark ? '#353535' : '#e5e5e5' + // 静态边界交给底色;聚焦与错误边框仍由控件的状态主题负责。 + const focusBorder = `1px solid ${accentText}` + const selection = { + color: control, + colorActive: control, + border: 'none', + borderHover: 'none', + borderFocus: focusBorder, + borderActive: focusBorder + } return { common: { primaryColor: primary, @@ -61,7 +73,8 @@ export function useTheme() { warningColorHover: warning, warningColorPressed: warning, warningColorSuppl: warning, - borderRadius: '6px', + borderRadius: '0px', + borderRadiusSmall: '0px', bodyColor: body, cardColor: surface, modalColor: surface, @@ -80,6 +93,15 @@ export function useTheme() { "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif" }, Button: { + color: control, + colorHover: controlHover, + colorPressed: controlHover, + colorFocus: controlHover, + border: 'none', + borderHover: 'none', + borderPressed: 'none', + borderDisabled: 'none', + borderFocus: focusBorder, textColorPrimary: onPrimary, textColorHoverPrimary: onPrimary, textColorPressedPrimary: onPrimary, @@ -88,6 +110,55 @@ export function useTheme() { textColorTextPrimary: accentText, textColorGhostPrimary: accentText }, + Input: { + color: control, + colorHover: controlHover, + colorFocus: control, + border: 'none', + borderHover: 'none', + borderDisabled: 'none', + borderFocus: focusBorder, + groupLabelBorder: 'none' + }, + Select: { peers: { InternalSelection: selection } }, + Card: { borderColor: 'transparent' }, + Drawer: { headerBorderBottom: 'none', footerBorderTop: 'none' }, + Alert: { + border: 'none', + borderInfo: 'none', + borderSuccess: 'none', + borderWarning: 'none', + borderError: 'none' + }, + Collapse: { dividerColor: 'transparent' }, + Table: { + borderColor: 'transparent', + borderColorModal: 'transparent', + borderColorPopover: 'transparent', + thColor: control, + thColorModal: control, + thColorPopover: control, + tdColorStriped: subtle, + tdColorStripedModal: subtle, + tdColorStripedPopover: subtle + }, + DataTable: { + borderColor: 'transparent', + borderColorModal: 'transparent', + borderColorPopover: 'transparent', + thColor: control, + tdColorStriped: subtle, + tdColorHover: selected + }, + Radio: { + buttonBorderColor: 'transparent', + buttonBorderColorActive: 'transparent', + buttonBorderColorHover: 'transparent', + buttonColor: control, + buttonColorActive: selected, + buttonTextColorActive: accentText, + buttonBoxShadowFocus: `inset 0 0 0 1px ${accentText}` + }, Layout: { color: body, headerColor: surface, @@ -104,12 +175,25 @@ export function useTheme() { itemIconColorActive: accentText, itemIconColorActiveHover: accentText }, - Tabs: { tabTextColorActiveLine: accentText, tabTextColorHoverLine: accentText, barColor: primary }, - Tag: { textColorPrimary: accentText }, + Tabs: { + tabTextColorActiveLine: accentText, + tabTextColorHoverLine: accentText, + barColor: primary, + tabBorderColor: 'transparent' + }, + Tag: { + textColorPrimary: accentText, + border: 'none', + borderPrimary: 'none', + borderInfo: 'none', + borderSuccess: 'none', + borderWarning: 'none', + borderError: 'none' + }, Checkbox: { checkMarkColor: onPrimary }, Switch: { railColorActive: primary }, Progress: { fillColor: primary } - } + } satisfies GlobalThemeOverrides }) /** 系统切换只更新 system 模式下的实际显示。 */ function syncSystem(event: MediaQueryListEvent) { diff --git a/src/features/breakdown/BreakdownPage.vue b/src/features/breakdown/BreakdownPage.vue index 2dde3fa..a90fd24 100644 --- a/src/features/breakdown/BreakdownPage.vue +++ b/src/features/breakdown/BreakdownPage.vue @@ -393,7 +393,7 @@ function exportResult() {
- 分组 diff --git a/src/features/breakdown/components/StoryboardList.vue b/src/features/breakdown/components/StoryboardList.vue index 9401ab9..5d20752 100644 --- a/src/features/breakdown/components/StoryboardList.vue +++ b/src/features/breakdown/components/StoryboardList.vue @@ -53,7 +53,7 @@ const purposeLabels: Record = { {{ episode?.beatShots.reduce((sum, beat) => sum + beat.shots.length, 0) ?? 0 }} 个镜头
-
+

{{ plan.storyGoal }}

{{ plan.emotionalArc }}

diff --git a/src/features/breakdown/components/SubjectList.vue b/src/features/breakdown/components/SubjectList.vue index fd022cb..4b8bf90 100644 --- a/src/features/breakdown/components/SubjectList.vue +++ b/src/features/breakdown/components/SubjectList.vue @@ -30,7 +30,7 @@ const filtered = computed(() => >
-
+

{{ subject.name }}

@@ -66,7 +66,7 @@ const filtered = computed(() =>

{{ form.formName || form.name diff --git a/src/features/create-drama/CreateDramaPage.vue b/src/features/create-drama/CreateDramaPage.vue index 048c61e..f61e710 100644 --- a/src/features/create-drama/CreateDramaPage.vue +++ b/src/features/create-drama/CreateDramaPage.vue @@ -148,7 +148,7 @@ function exportScript() {

-
+
-
+
-
+

BEAT {{ shot.beatNo }} / SHOT {{ shot.shotNo }}

diff --git a/src/features/production/components/ShotProductionAssets.vue b/src/features/production/components/ShotProductionAssets.vue index 8a47943..a51bf9c 100644 --- a/src/features/production/components/ShotProductionAssets.vue +++ b/src/features/production/components/ShotProductionAssets.vue @@ -401,7 +401,7 @@ async function inspectSpecs() {
-
diff --git a/src/features/projects/ProjectsPage.vue b/src/features/projects/ProjectsPage.vue index a68e5a8..a98d2eb 100644 --- a/src/features/projects/ProjectsPage.vue +++ b/src/features/projects/ProjectsPage.vue @@ -100,6 +100,9 @@ function clearFilters() {
-

diff --git a/src/features/storyboard/components/GenerationReceipt.vue b/src/features/storyboard/components/GenerationReceipt.vue index b74fc0d..f8ce653 100644 --- a/src/features/storyboard/components/GenerationReceipt.vue +++ b/src/features/storyboard/components/GenerationReceipt.vue @@ -43,7 +43,7 @@ function exportReceipt() { {{ receipt.result.skippedEpisodes }} · 失败 {{ receipt.result.failedEpisodes }}

- 剧集 diff --git a/src/features/storyboard/components/ShotDesign.vue b/src/features/storyboard/components/ShotDesign.vue index 5c45432..13dfad6 100644 --- a/src/features/storyboard/components/ShotDesign.vue +++ b/src/features/storyboard/components/ShotDesign.vue @@ -40,7 +40,7 @@ defineProps<{ shot: DesignedShot }>() >

此镜头尚无已保存的导演设计。

-
+

即时视觉状态 VisualState

@@ -60,7 +60,7 @@ defineProps<{ shot: DesignedShot }>()
{{ subject.subjectRef }}
-
+

镜头生成规格

-
+

视频提示词

已有提示词可直接复用,没有则依据 GenerationSpec 与参考图调用模型生成。此处只处理 diff --git a/src/features/storyboard/directory.test.ts b/src/features/storyboard/directory.test.ts index f4426fa..edcaa2a 100644 --- a/src/features/storyboard/directory.test.ts +++ b/src/features/storyboard/directory.test.ts @@ -52,7 +52,7 @@ describe('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-subtle\)/ + /\.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)')) diff --git a/src/features/subject-identity/SubjectIdentityPage.vue b/src/features/subject-identity/SubjectIdentityPage.vue index e137850..4e04496 100644 --- a/src/features/subject-identity/SubjectIdentityPage.vue +++ b/src/features/subject-identity/SubjectIdentityPage.vue @@ -250,7 +250,7 @@ watch(

{{ casting.ready }}
-
+