diff --git a/.gitignore b/.gitignore index 365ffaf..05d0c81 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -node_modules +node_modules* dist coverage .env diff --git a/README.md b/README.md index 063ca09..43671ed 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ API_PROXY_TARGET=http://localhost:3412 - Vite 8、Vue 3、TypeScript、Vue Router - Tailwind CSS 4,通过 `@tailwindcss/vite` 集成 -- Reka UI:Dialog、Tabs、Checkbox、Progress 等无样式基础组件 +- Naive UI:布局、菜单、表格、选择器、输入框、按钮、弹窗、标签、折叠面板、进度和时间线 - Oxlint:脚本质量检查;ESLint:补充 Vue 模板语义 - Oxfmt(不是 `oxformat`):沿用后端四空格、单引号、无分号、无尾逗号的风格 - Vitest + Vue Test Utils + happy-dom:API 契约、组件交互和异步生命周期测试 @@ -76,13 +76,28 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S | `src/features/subject-identity` | 稳定身份 API、正式主体目录、文本编辑、生图配置、身份图库和母版切换 | | `src/features/production` | 视频提示词、镜头首帧、视频任务、就绪诊断、资产候选与批量回执 | | `src/features/workflows` | checkpoint 选择、执行记录、长请求互斥与操作确认 | -| `src/components/ui` | 跨业务使用的 Reka UI 封装与公共展示组件 | -| `src/composables` | 具备取消和竞态保护的轮询 | +| `src/components/ui` | Naive UI 公共封装、工作区滚动容器与图片展示 | +| `src/composables` | 具备取消和竞态保护的轮询、系统主题与偏好持久化 | | `src/lib` | HTTP、错误格式、日期和导出工具 | | `src/router` | 按 graph 拆分的懒加载路由 | 每个功能模块通过 `index.ts` 暴露公共入口。业务类型和行为函数均添加中文职责注释。不要把下一条 graph 的服务继续堆进 `App.vue`。 +## 后台布局与主题 + +- 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。 +- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。 +- 生成配置与批量操作放在可折叠区域,回执到达后自动展开;查询错误始终可见,不隐藏在折叠内容中。 +- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。 +- 以黑白灰为主,仅错误、警告、成功等语义状态保留辅助色。Naive UI 和业务内容共用主题;加载前应用偏好,减少主题闪烁。 +- 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。 + +布局样式集中在 `src/admin.css`,业务排版保留在 `src/styles.css`。新增页面应使用 `WorkspacePage`,避免通过 `body` 或 `window.scrollTo` 管理滚动。 + +本次 UI 重构保留前端 `dev@5551653` 的业务更新,包括过期形态图、主首帧和主视频的提示与更新入口。首帧默认只补当前剧集;存在过期主首帧时优先更新全项目过期项。非覆盖模式下的过期主视频重建完成后,会接替旧主视频;普通新增候选不自动替换主资产。 + +本地验收建议使用 1920×1080、1280×720 和窄屏视口:打开长剧本、长项目标题和多镜头项目,确认顶栏不移动、目录与正文能分别滚到底;切换暗黑模式后检查弹窗、下拉菜单、图片失败占位,刷新后主题应保持。 + ## 接口基线 对齐后端 `qianlanse/short-drama-agent` 的 `dev`: diff --git a/index.html b/index.html index e160c49..33b124d 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,20 @@
- + +{{ apiBase }}
+ {{ description }}
+{{ description }}
-{{ description }}
+正文
' } + }) + expect(wrapper.get('.workspace-heading').text()).toBe('固定操作区') + expect(wrapper.getComponent(NScrollbar).text()).toBe('正文') + expect(wrapper.getComponent(NScrollbar).find('h2').exists()).toBe(false) + }) + + it('分栏工作区不再包一层整页滚动', () => { + wrapper = mount(WorkspacePage, { + props: { split: true }, + slots: { default: '内容
' } + }) + 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() + }) +}) diff --git a/src/composables/useTheme.test.ts b/src/composables/useTheme.test.ts new file mode 100644 index 0000000..003aa61 --- /dev/null +++ b/src/composables/useTheme.test.ts @@ -0,0 +1,91 @@ +import { effectScope, nextTick, type EffectScope } from 'vue' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useTheme } from './useTheme' + +let scope: EffectScope | undefined +afterEach(() => { + scope?.stop() + scope = undefined + localStorage.clear() + delete document.documentElement.dataset.theme + document.documentElement.style.colorScheme = '' + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +/** 可控系统主题,验证跟随模式与事件清理,不依赖测试机器偏好。 */ +function setup(dark = false) { + const listeners = new Set<(event: MediaQueryListEvent) => void>() + vi.stubGlobal('matchMedia', () => ({ + matches: dark, + addEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.add(listener), + removeEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener) + })) + scope = effectScope() + const state = scope.run(useTheme)! + return { + ...state, + listeners, + change: (matches: boolean) => listeners.forEach(listener => listener({ matches } as MediaQueryListEvent)) + } +} + +describe('黑白双主题', () => { + it('默认跟随系统并实时响应,销毁时移除监听', async () => { + const state = setup() + expect(state.preference.value).toBe('system') + expect(document.documentElement.dataset.theme).toBe('light') + state.change(true) + await nextTick() + expect(state.isDark.value).toBe(true) + expect(document.documentElement.style.colorScheme).toBe('dark') + expect(state.listeners.size).toBe(1) + scope!.stop() + expect(state.listeners.size).toBe(0) + }) + + it('手动选择优先于系统,并持久化到下一次挂载', async () => { + const state = setup(true) + state.preference.value = 'light' + await nextTick() + state.change(true) + await nextTick() + expect(state.isDark.value).toBe(false) + expect(localStorage.getItem('drama-studio-theme')).toBe('light') + scope!.stop() + const restored = setup(true) + expect(restored.preference.value).toBe('light') + expect(restored.theme.value).toBeNull() + }) + + it('深浅模式同时更新 Naive 主题和业务颜色标记', async () => { + const state = setup() + expect(state.overrides.value.common?.primaryColor).toBe('#202020') + state.preference.value = 'dark' + await nextTick() + expect(state.theme.value?.name).toBe('dark') + expect(state.overrides.value.common?.primaryColor).toBe('#f5f5f5') + expect(state.overrides.value.Button?.textColorPrimary).toBe('#171717') + expect(document.documentElement.dataset.theme).toBe('dark') + }) + + it('无效存储回退系统模式', () => { + localStorage.setItem('drama-studio-theme', 'unexpected') + const state = setup(true) + expect(state.preference.value).toBe('system') + expect(state.isDark.value).toBe(true) + }) + + it('禁用存储仍能切换当前会话的主题', async () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage blocked') + }) + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('storage blocked') + }) + const state = setup() + state.preference.value = 'dark' + await nextTick() + expect(document.documentElement.dataset.theme).toBe('dark') + }) +}) diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts new file mode 100644 index 0000000..b586cb4 --- /dev/null +++ b/src/composables/useTheme.ts @@ -0,0 +1,88 @@ +import { computed, onScopeDispose, ref, watchEffect } from 'vue' +import { darkTheme, type GlobalThemeOverrides } from 'naive-ui' + +/** 用户主题偏好;system 会跟随系统的实时深浅色变化。 */ +export type ThemePreference = 'light' | 'dark' | 'system' +const storageKey = 'drama-studio-theme' + +/** 读取主题时兼容隐私模式或禁用存储的浏览器。 */ +function readPreference(): ThemePreference { + try { + const value = localStorage.getItem(storageKey) + return value === 'light' || value === 'dark' ? value : 'system' + } catch { + return 'system' + } +} + +/** 主题只影响展示,不修改项目配置;卸载时移除系统主题监听。 */ +export function useTheme() { + const preference = ref分组抽取、主体整理与分镜结果
每组集数必须是正整数,并至少选择一个抽取模块。
-{{ previewError }}
+@@ -266,29 +261,28 @@ function exportResult() { >主体 {{ subjects.length }}分镜剧集 {{ shots.length }} / {{ plans.length }}
- {{ execution.errorMessage }} -
-分镜校验未通过
+ type="line" + :show-indicator="false" + :height="4" + :percentage="Math.min(100, Math.max(0, (summary.completed / Math.max(1, summary.total)) * 100))" + class="mt-3" + > +分镜校验未通过
从中断处继续
@@ -319,42 +313,39 @@ function exportResult() {| 分组 | 模块 | @@ -390,18 +381,18 @@ function exportResult() {{{ task.errorMessage }} | |||
|---|---|---|---|---|---|
已写入 {{ episodes.length }} / {{ total }} 集 @@ -98,28 +105,26 @@ function exportScript() { />
第 {{ String(episode.episode).padStart(2, '0') }} 集
@@ -167,9 +171,9 @@ function exportScript() { title="剧本正在酝酿" description="剧集写入数据库后会自动出现在这里。角色与世界观生成期间,可以在右侧查看已保存的执行记录。" /> - -故事的发生之地
{{ JSON.stringify(project.world.rules, null, 2) }}
- {{ JSON.stringify(project.world.rules, null, 2) }}
+ {{ JSON.stringify(state.rewriteSuggestion, null, 2) }}
- {{ JSON.stringify(state.rewriteSuggestion, null, 2) }}
+ 从可生成的提示词和首帧,到异步视频任务与最终成片。
-- 每一步都按后端就绪检查执行。覆盖只会新增首帧/视频候选,不会替换当前主资产;上游参考资产变化后,下游过期结果会被标记并阻止继续使用,需要重新生成后才能恢复就绪。 -
- -- 批量操作始终面向整个项目,不受当前剧集选择影响;当前剧集只控制下方逐镜检查。Seedream 与 Seedance - 均可能产生费用。 -
-- {{ query.error.value }} 当前保留上次读取的数据,生产操作已暂停。 -
-- 当前有 {{ videoRunning }} 个视频任务等待或生成中。后端正在轮询 Provider;同一镜头不会重复提交活动任务。 -
-
- 当前有 {{ identityBlocked }} 项角色身份前置问题。Character 必须生成
- Identity、确认演员母版并锁定后,首帧才能就绪。
-
- 当前有 {{ staleKeyframes }} 个主首帧已过期。它们使用的主体参考资产已经变化,视频阶段会被阻止;可在下方逐镜重新生成,或使用“补齐 / 更新主首帧”批量处理。 -
-- 当前有 {{ staleVideos }} 个主视频已过期。它们使用的主首帧或主体参考图已经变化;非覆盖模式批量生成时会只重建这些过期主视频。 -
-STEP {{ stage.code }}
-{{ stage.description }}
-- 已过期 {{ staleKeyframes }} · 会按当前主体参考资产重新生成 -
-- 主视频已过期 {{ staleVideos }} · 会按当前主首帧与参考资产重新生成 -
-- 身份缺失 {{ query.data.value?.keyframes.missingIdentity ?? 0 }} · 母版缺失 - {{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} · 未锁定 - {{ query.data.value?.keyframes.identityUnlocked ?? 0 }} -
-状态统计取每个镜头最近一次任务,不等同于主视频数量。
+从可生成的提示词和首帧,到异步视频任务与最终成片。
+ 提示词与视频批量操作面向全项目;首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式新增全项目候选。Seedream + 与 Seedance 均可能产生费用。 +
+STEP {{ stage.code }}
+{{ stage.description }}
++ 已过期 {{ staleKeyframes }} · 会按当前主体参考资产重新生成 +
++ 主视频已过期 {{ staleVideos }} · 会按当前主首帧与参考资产重新生成 +
++ 身份缺失 {{ query.data.value?.keyframes.missingIdentity ?? 0 }} · 母版缺失 + {{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} · 未锁定 + {{ query.data.value?.keyframes.identityUnlocked ?? 0 }} +
++ 状态统计取每个镜头最近一次任务,不等同于主视频数量。 +
+{{ shot.description }}
Shot ID · {{ shot.shotId }}
-
+
+
+
正在检查全项目生产就绪状态…… -
- - \ No newline at end of file + + diff --git a/src/features/production/components/KeyframeDialog.vue b/src/features/production/components/KeyframeDialog.vue index 61f98f0..e05deed 100644 --- a/src/features/production/components/KeyframeDialog.vue +++ b/src/features/production/components/KeyframeDialog.vue @@ -1,4 +1,5 @@ - -项目工作台
-{{ context.project.value?.style || '剧本与拆解结果' }}
-- {{ context.error.value }} 当前保留上次成功读取的数据。 -
-
-
{{ operation.error }}
-{{ operation.notice }}
-工作空间 / 项目
-从故事到镜头,在这里继续你的创作。
-