From e6e0f08072bb36b57de5416138b0540fdb63cdcb Mon Sep 17 00:00:00 2001
From: GJ
Date: Fri, 4 Sep 2026 17:19:34 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B6=E5=8F=A3=E7=BB=84=E4=BB=B6?=
=?UTF-8?q?=E6=A0=B7=E5=BC=8F=E5=B9=B6=E8=B0=83=E6=95=B4=E7=A7=BB=E5=8A=A8?=
=?UTF-8?q?=E7=AB=AF=E4=BE=A7=E6=A0=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 1 +
README.md | 8 +-
src/App.vue | 207 +-
src/admin.css | 1763 -----------------
src/components/NotFoundPage.vue | 18 +
src/components/ui/AppDialog.vue | 21 +
src/components/ui/AssetImage.vue | 23 +
src/components/ui/DetailDisclosure.vue | 11 +
src/components/ui/DirectoryItem.vue | 43 +
src/components/ui/EmptyState.vue | 11 +
src/components/ui/ThemeToggle.vue | 8 +
src/components/ui/WorkspacePage.vue | 69 +
src/components/ui/WorkspaceTools.vue | 42 +
src/features/breakdown/BreakdownPage.vue | 92 +
.../breakdown/components/StoryboardList.vue | 49 +
.../breakdown/components/SubjectList.vue | 32 +
src/features/create-drama/CreateDramaPage.vue | 46 +
src/features/create-drama/EpisodeReader.vue | 103 +
src/features/production/ProductionPage.vue | 96 +
.../production/components/QualityDialog.vue | 8 +
.../production/components/QualityResult.vue | 14 +
.../components/ShotProductionAssets.vue | 43 +
src/features/projects/ProjectLayout.vue | 47 +
src/features/projects/ProjectsPage.vue | 70 +
src/features/storyboard/StoryboardPage.vue | 70 +
.../components/BeatShotDirectory.vue | 32 +
.../storyboard/components/ShotTools.vue | 17 +
.../subject-identity/SubjectIdentityPage.vue | 114 ++
.../components/IdentityThumbnail.vue | 15 +
.../subject-images/SubjectImagesPage.vue | 115 ++
src/features/visual-style/VisualStylePage.vue | 23 +
src/features/workflows/ConfirmAction.vue | 8 +
src/features/workflows/HistoryPanel.vue | 24 +
.../workflows/WorkflowDiagnosticsDialog.vue | 17 +
src/main.ts | 4 +-
src/styles.css | 781 --------
src/styles/admin.css | 433 ++++
src/styles/styles.css | 280 +++
src/testing/styles.ts | 239 +++
.../components/ui/ActionMenu.test.ts | 12 +-
.../components/ui/AssetImage.test.ts | 2 +-
.../components/ui/DirectoryItem.test.ts | 18 +-
.../components/ui/flatTheme.test.ts | 38 +-
{src => tests}/components/ui/layout.test.ts | 121 +-
{src => tests}/composables/usePolling.test.ts | 2 +-
{src => tests}/composables/useTheme.test.ts | 4 +-
.../features/breakdown/BreakdownPage.test.ts | 32 +-
.../create-drama/EpisodeReader.test.ts | 8 +-
.../features/production/capabilities.test.ts | 20 +-
.../features/production/production.test.ts | 10 +-
.../features/production/quality.test.ts | 22 +-
.../features/projects/access.test.ts | 17 +-
.../features/storyboard/directory.test.ts | 15 +-
.../features/storyboard/model.test.ts | 14 +-
.../subject-identity/identity.test.ts | 18 +-
.../subject-identity/thumbnail.test.ts | 4 +-
.../subject-images/asset-impact.test.ts | 20 +-
.../features/subject-images/images.test.ts | 14 +-
.../features/subject-images/layout.test.ts | 4 +-
.../features/subject-images/prompts.test.ts | 16 +-
.../features/workflows/diagnostics.test.ts | 6 +-
.../features/workflows/operations.test.ts | 2 +-
.../features/workflows/pages.test.ts | 46 +-
.../features/workflows/selectors.test.ts | 6 +-
{src => tests}/lib/http.test.ts | 10 +-
tsconfig.json | 5 +-
vite.config.ts | 2 +
vitest.config.ts | 7 +-
68 files changed, 2717 insertions(+), 2775 deletions(-)
delete mode 100644 src/admin.css
delete mode 100644 src/styles.css
create mode 100644 src/styles/admin.css
create mode 100644 src/styles/styles.css
create mode 100644 src/testing/styles.ts
rename {src => tests}/components/ui/ActionMenu.test.ts (90%)
rename {src => tests}/components/ui/AssetImage.test.ts (98%)
rename {src => tests}/components/ui/DirectoryItem.test.ts (91%)
rename {src => tests}/components/ui/flatTheme.test.ts (91%)
rename {src => tests}/components/ui/layout.test.ts (76%)
rename {src => tests}/composables/usePolling.test.ts (98%)
rename {src => tests}/composables/useTheme.test.ts (98%)
rename {src => tests}/features/breakdown/BreakdownPage.test.ts (95%)
rename {src => tests}/features/create-drama/EpisodeReader.test.ts (97%)
rename {src => tests}/features/production/capabilities.test.ts (93%)
rename {src => tests}/features/production/production.test.ts (94%)
rename {src => tests}/features/production/quality.test.ts (96%)
rename {src => tests}/features/projects/access.test.ts (95%)
rename {src => tests}/features/storyboard/directory.test.ts (84%)
rename {src => tests}/features/storyboard/model.test.ts (91%)
rename {src => tests}/features/subject-identity/identity.test.ts (96%)
rename {src => tests}/features/subject-identity/thumbnail.test.ts (97%)
rename {src => tests}/features/subject-images/asset-impact.test.ts (97%)
rename {src => tests}/features/subject-images/images.test.ts (95%)
rename {src => tests}/features/subject-images/layout.test.ts (87%)
rename {src => tests}/features/subject-images/prompts.test.ts (93%)
rename {src => tests}/features/workflows/diagnostics.test.ts (97%)
rename {src => tests}/features/workflows/operations.test.ts (91%)
rename {src => tests}/features/workflows/pages.test.ts (97%)
rename {src => tests}/features/workflows/selectors.test.ts (94%)
rename {src => tests}/lib/http.test.ts (96%)
diff --git a/.gitignore b/.gitignore
index 05d0c81..9f11a7d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ coverage
.eslintcache
*.tsbuildinfo
.DS_Store
+.worktree/
diff --git a/README.md b/README.md
index c3ef552..aa266cd 100644
--- a/README.md
+++ b/README.md
@@ -55,11 +55,11 @@ API_PROXY_TARGET=http://localhost:3412
## 技术与规范
- Vite 8、Vue 3、TypeScript、Vue Router
-- Tailwind CSS 4,通过 `@tailwindcss/vite` 集成
+- Tailwind CSS 4,通过 `@tailwindcss/vite` 集成;组件专属样式写在 SFC `
diff --git a/src/admin.css b/src/admin.css
deleted file mode 100644
index a1269f8..0000000
--- a/src/admin.css
+++ /dev/null
@@ -1,1763 +0,0 @@
-/* 管理后台视口边界:document 不滚动,各个工作区和面板独立滚动。 */
-:root {
- --app-ink: #191919;
- --app-muted: #707070;
- --app-body: #ededed;
- --app-surface: #ffffff;
- --app-subtle: #f7f7f7;
- --app-control: #f0f0f0;
- --app-control-hover: #e5e5e5;
- --app-field: #ffffff;
- --app-field-hover: #ffffff;
- --app-border: #e5e5e5;
- --app-inverse: #ffffff;
- --app-accent: #07c160;
- --app-accent-hover: #2dcb79;
- --app-accent-text: #087c42;
- --app-on-accent: #ffffff;
- --app-button-primary: #078640;
- --app-button-hover: #087c42;
- --app-button-pressed: #066b36;
- --app-disabled-bg: #e4e4e4;
- --app-disabled-text: #8c8c8c;
- --app-selected: #e4f4e9;
- --app-shadow: #00000018;
- --app-danger: #a93232;
- --app-success: #087c42;
-}
-:root[data-theme='dark'] {
- --app-ink: #d9d9d9;
- --app-muted: #a3a3a3;
- --app-body: #111111;
- --app-surface: #191919;
- --app-subtle: #232323;
- --app-control: #2b2b2b;
- --app-control-hover: #353535;
- --app-field: #2b2b2b;
- --app-field-hover: #353535;
- --app-disabled-bg: #303030;
- --app-disabled-text: #808080;
- --app-border: #333333;
- --app-inverse: #111111;
- --app-accent-text: #5cd693;
- --app-selected: #173527;
- --app-shadow: #00000055;
- --app-danger: #fa7373;
- --app-success: #5cd693;
-}
-html,
-body,
-#app,
-.app-provider {
- height: 100%;
- min-height: 0;
- overflow: hidden;
-}
-html {
- background: var(--app-body);
-}
-body {
- margin: 0;
-}
-.admin-layout {
- height: 100dvh;
- overflow: hidden;
-}
-.admin-main {
- min-width: 0;
- height: 100%;
-}
-.admin-main > .n-layout-scroll-container {
- height: 100%;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-.admin-sider {
- height: 100%;
-}
-/* 外壳固定品牌与底部设置;只有菜单自身使用 NScrollbar 滚动。 */
-.admin-sider-content {
- display: flex;
- flex-direction: column;
- height: 100%;
- min-height: 0;
- overflow: hidden;
-}
-.admin-nav-scroll.n-scrollbar {
- flex: 1;
- min-height: 0;
-}
-.admin-sider-footer {
- flex-shrink: 0;
- padding-block: 8px max(8px, env(safe-area-inset-bottom));
-}
-.n-button.admin-settings-button {
- justify-content: flex-start;
- height: 44px;
- padding-inline: 20px;
-}
-.n-button.admin-settings-button.is-collapsed {
- justify-content: center;
- padding-inline: 0;
- width: 44px;
- min-width: 44px;
- height: 44px;
- margin-inline: auto;
-}
-.admin-settings-button.is-collapsed .n-button__icon {
- margin: 0;
-}
-/* Naive 菜单背景默认左右内缩 8px;只铺满导航行,不改图标与文字缩进。 */
-.admin-sider .n-menu .n-menu-item-content::before {
- left: 0;
- right: 0;
- border-radius: 0;
-}
-.admin-brand {
- display: flex;
- flex-shrink: 0;
- align-items: center;
- gap: 12px;
- height: 74px;
- padding: 0 20px;
- color: var(--app-ink);
- white-space: nowrap;
-}
-.admin-brand > svg {
- flex-shrink: 0;
-}
-.admin-brand span {
- font-size: 15px;
- font-weight: 600;
-}
-.admin-brand small {
- display: block;
- font-size: 8px;
- letter-spacing: 1px;
- font-weight: 400;
- color: var(--app-muted);
- margin-top: 3px;
-}
-.admin-topbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- flex-shrink: 0;
- height: 44px;
- padding: 0 20px;
- gap: 12px;
-}
-/* 按钮保持直角,单图标按钮以 Naive 当前尺寸为边长,加载时也不改变宽度。 */
-.n-button {
- border-radius: 0;
-}
-.n-button.icon-button {
- width: var(--n-height);
- min-width: var(--n-height);
- height: var(--n-height);
- padding: 0;
- flex: 0 0 auto;
-}
-.n-button.icon-button .n-button__icon {
- margin: 0;
-}
-.n-button:focus-visible {
- outline: 2px solid var(--app-accent-text);
- outline-offset: 2px;
-}
-.admin-sider .n-menu-divider {
- height: 12px;
- background: transparent;
-}
-.workspace-tools-drawer .n-drawer-header,
-.workspace-tools-drawer .n-drawer-footer {
- background: var(--app-subtle);
-}
-.workspace-tools-drawer .n-drawer-body {
- background: var(--app-body);
-}
-.n-button.theme-toggle {
- flex-shrink: 0;
- width: 34px;
- height: 34px;
-}
-.admin-content {
- flex: 1;
- min-height: 0;
- min-width: 0;
- overflow: hidden;
- outline: none;
-}
-.project-frame {
- display: flex;
- flex-direction: column;
- height: 100%;
- min-height: 0;
- overflow: hidden;
-}
-.project-header {
- flex-shrink: 0;
- padding: 9px 20px;
- background: var(--app-surface);
-}
-.project-header .n-page-header__main,
-.project-header .n-page-header__title {
- min-width: 0;
- overflow: hidden;
-}
-.project-title {
- max-width: 100%;
- font-size: 16px;
- font-weight: 600;
-}
-.project-notices.n-scrollbar {
- flex-shrink: 0;
- height: auto;
- max-height: 100px;
- overflow: hidden;
-}
-.project-notices-content {
- padding: 8px 24px;
- display: grid;
- gap: 5px;
-}
-.project-notices .n-alert {
- padding: 7px 12px;
- font-size: 12px;
-}
-.project-view {
- flex: 1;
- min-height: 0;
- overflow: hidden;
-}
-/* 标题移除额外按钮后让名称使用剩余空间;未完成项目的引导仍留在内容区。 */
-.project-header .n-page-header-wrapper {
- min-width: 0;
-}
-.project-access-gate {
- height: 100%;
-}
-.app-dialog {
- --app-field: var(--app-control);
- --app-field-hover: var(--app-control-hover);
-}
-.workspace-page {
- position: relative;
- height: 100%;
- display: flex;
- flex-direction: column;
- min-height: 0;
- overflow: hidden;
-}
-.workspace-heading-scroll.n-scrollbar {
- flex-shrink: 0;
- height: auto;
- max-height: min(35dvh, 260px);
- overflow: hidden;
-}
-.workspace-heading {
- padding: 16px 24px 12px;
-}
-/* 常用工具固定在滚动区外;低频操作使用局部抽屉,不叠加占高面板。 */
-.content-first-workspace .workspace-heading {
- padding: 8px 20px;
-}
-.content-first-workspace .workspace-split {
- padding: 0 20px 12px;
- gap: 8px;
-}
-.content-first-workspace .workspace-scroll-content {
- padding: 0 20px 16px;
-}
-.workspace-toolbar {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 8px 16px;
- min-height: 34px;
-}
-.toolbar-episode {
- display: flex;
- align-items: center;
- gap: 8px;
- flex: 0 1 340px;
- min-width: 180px;
- font-size: 12px;
-}
-.toolbar-episode .n-select {
- min-width: 0;
-}
-.toolbar-metrics,
-.toolbar-summary {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 8px 16px;
- color: var(--app-muted);
- font-size: 12px;
- line-height: 1.6;
-}
-.toolbar-actions {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 10px;
- margin-left: auto;
-}
-.toolbar-type-filter {
- width: 152px;
-}
-.workspace-tools-drawer {
- max-width: 100%;
-}
-.workspace-tools-heading {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- width: 100%;
- font-size: 15px;
-}
-.workspace-tools-body {
- padding: 20px;
-}
-/* 身份说明与折叠入口分开,避免正文紧贴下一组操作。 */
-.identity-tools-intro {
- margin-bottom: 24px;
-}
-.workspace-tools-body .storyboard-controls,
-.workspace-tools-body .production-controls {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
- gap: 16px;
- margin-block: 20px;
-}
-/* 与 34px 中号输入框共用一行;不再通过 pb-2 / pb-3 手动垫高复选框。 */
-.n-checkbox.control-row-checkbox {
- align-self: end;
- align-items: center;
- min-height: 34px;
- padding-block: 0;
-}
-.control-row-checkbox .n-checkbox-box-wrapper {
- align-self: center;
-}
-/* 拆解配置跟随抽屉实际宽度;标题 20px + 间距 8px,下方统一使用 34px 控件行。 */
-.breakdown-settings-panel {
- container: breakdown-settings / inline-size;
-}
-.breakdown-config {
- --breakdown-label-offset: 28px;
- display: grid;
- grid-template-columns: 180px minmax(0, 1fr) auto;
- align-items: start;
- gap: 16px 24px;
-}
-.breakdown-config .field-label {
- margin: 0 0 8px;
- padding: 0;
- line-height: 20px;
-}
-.breakdown-group-field,
-.breakdown-modules-field,
-.breakdown-module-option {
- min-width: 0;
-}
-.breakdown-group-input {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-.breakdown-group-number.n-input-number {
- flex: 0 0 120px;
- width: 120px;
-}
-.breakdown-group-unit {
- flex-shrink: 0;
- white-space: nowrap;
- color: var(--app-muted);
- font-size: 14px;
-}
-.breakdown-modules-field {
- margin: 0;
- padding: 0;
- border: 0;
-}
-.breakdown-module-options {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 12px 16px;
-}
-.breakdown-module-description {
- margin: 4px 0 0;
- padding-left: 24px;
- color: var(--app-muted);
- font-size: 11px;
- line-height: 18px;
- overflow-wrap: anywhere;
-}
-.breakdown-preview-button.n-button {
- align-self: start;
- margin-top: var(--breakdown-label-offset);
- white-space: nowrap;
-}
-@container breakdown-settings (max-width: 780px) {
- .breakdown-config {
- grid-template-columns: 180px minmax(0, 1fr);
- }
- .breakdown-preview-button.n-button {
- grid-column: 2;
- justify-self: end;
- margin-top: 0;
- }
-}
-@container breakdown-settings (max-width: 560px) {
- .breakdown-config {
- grid-template-columns: minmax(0, 1fr);
- }
- .breakdown-module-options {
- grid-template-columns: repeat(auto-fit, minmax(min(100%, 160px), 1fr));
- }
- .breakdown-preview-button.n-button {
- grid-column: 1;
- }
-}
-.workspace-tools-body .storyboard-coverage {
- margin-block: 20px;
-}
-.workspace-tools-body .generation-row {
- display: block;
-}
-.workspace-tools-body .generation-row > div + div {
- margin-top: 14px;
-}
-.workspace-tools-body .production-pipeline {
- grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));
-}
-.workspace-inline-status {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- justify-content: space-between;
- gap: 8px 16px;
- padding: 7px 12px;
- flex-shrink: 0;
- border-radius: 0;
- background: var(--app-subtle);
- font-size: 12px;
-}
-.confirm-action {
- display: inline-flex;
- align-items: center;
- max-width: 100%;
-}
-.production-pipeline-card > .confirm-action {
- margin-block: 16px 4px;
-}
-.gallery-workspace-page .workspace-scroll-content {
- padding-top: 8px;
-}
-/* 筛选与关联行整体吸顶,底部不留固定分隔带;列表自己的顶部间距随内容滚走。 */
-.gallery-workspace-page .gallery-sticky-controls {
- position: sticky;
- top: 0;
- z-index: 10;
- background: var(--app-body);
- margin-block: 10px 0;
-}
-.gallery-workspace-page .form-image-grid {
- padding-top: 12px;
-}
-/* 使用成熟的多列排版实现瀑布流;完整卡片不跨列,窄容器自动减少列数。 */
-.gallery-workspace-page .form-image-masonry {
- display: block;
- columns: 260px;
- column-gap: 20px;
-}
-.form-image-masonry > .form-image-card {
- break-inside: avoid;
- margin-bottom: 20px;
-}
-.form-image-masonry .asset-image {
- aspect-ratio: var(--form-image-aspect, 4 / 3);
-}
-.gallery-sticky-controls > .form-image-filter-region {
- margin-block: 0;
-}
-.gallery-workspace-page .workspace-scroll > .n-scrollbar-container {
- overflow-anchor: none;
-}
-.workspace-heading h2 {
- font-size: 16px;
-}
-.workspace-heading .text-sm {
- font-size: 12px;
-}
-.workspace-heading .production-controls {
- gap: 12px;
-}
-.workspace-heading .n-input-number {
- width: 100%;
-}
-.workspace-scroll {
- flex: 1;
- min-height: 0;
-}
-.workspace-scroll-content {
- padding: 4px 24px 24px;
-}
-.workspace-scroll > .n-scrollbar-container {
- overscroll-behavior: contain;
-}
-.workspace-split {
- display: flex;
- flex-direction: column;
- flex: 1;
- min-height: 0;
- overflow: hidden;
- padding: 4px 24px 20px;
- gap: 12px;
-}
-.workspace-split > .my-6 {
- margin: 0;
- flex-shrink: 0;
-}
-.workspace-feedback.n-scrollbar {
- flex-shrink: 0;
- height: auto;
- max-height: min(24dvh, 160px);
- overflow: hidden;
-}
-.workspace-feedback .n-alert + .n-alert {
- margin-top: 8px;
-}
-.script-section {
- flex: 1;
- display: flex;
- flex-direction: column;
- min-height: 0;
-}
-.script-section > :first-child {
- flex-shrink: 0;
-}
-.script-section .content-with-history {
- flex: 1;
- height: auto;
- min-height: 0;
-}
-.script-workspace-page .workspace-split > :not(.script-section) {
- flex-shrink: 0;
-}
-.storyboard-workspace-page .storyboard-workspace {
- flex: 1;
- height: auto;
- min-height: 0;
- overflow: hidden;
-}
-.storyboard-coverage {
- flex-shrink: 0;
-}
-.overview-content {
- padding-bottom: 8px;
-}
-.workspace-loading {
- display: grid;
- place-content: center;
- height: 100%;
-}
-.production-workspace,
-.identity-workspace {
- flex: 1;
- min-height: 0;
- align-items: stretch;
- overflow: hidden;
-}
-.production-workspace,
-.identity-workspace,
-.storyboard-workspace {
- grid-template-columns: clamp(240px, 22%, 280px) minmax(0, 1fr);
-}
-.production-shot-list {
- max-height: none;
- overflow: hidden;
- min-height: 0;
-}
-.production-workspace > article {
- overflow: hidden;
- min-height: 0;
- overscroll-behavior: contain;
-}
-.identity-workspace > div {
- min-height: 0;
- overflow: hidden;
- overscroll-behavior: contain;
-}
-.identity-subject-list {
- max-height: none;
- min-height: 0;
- flex: 1;
-}
-.storyboard-workspace {
- height: clamp(360px, 65dvh, 850px);
- min-height: 0;
-}
-.storyboard-workspace > article,
-.storyboard-workspace .shot-list {
- overflow: hidden;
- min-height: 0;
- height: 100%;
- max-height: none;
-}
-/* 目录统一为固定标题/筛选区和独立滚动列表;不再依赖按钮的默认行高与内边距。 */
-.directory-panel {
- --app-field: var(--app-surface);
- --app-field-hover: var(--app-surface);
- display: flex;
- flex-direction: column;
- min-width: 0;
- min-height: 0;
- padding: 0;
- overflow: hidden;
- background: var(--app-subtle);
-}
-.directory-heading {
- display: flex;
- align-items: center;
- justify-content: space-between;
- flex-wrap: wrap;
- flex-shrink: 0;
- gap: 8px;
- padding: 18px 16px 14px;
- font-size: 11px;
- color: var(--app-muted);
-}
-.directory-heading h3 {
- font-size: 13px;
- font-weight: 600;
- color: var(--app-ink);
-}
-.directory-heading h3 span {
- margin-left: 8px;
- font-size: 11px;
- font-weight: 400;
- color: var(--app-muted);
-}
-.directory-filters {
- display: grid;
- gap: 10px;
- padding: 0 12px 12px;
- flex-shrink: 0;
-}
-.directory-filters > * {
- min-width: 0;
-}
-.directory-scroll {
- flex: 1;
-}
-.directory-list-content {
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 8px 0 16px;
-}
-.directory-group-heading {
- position: sticky;
- top: 0;
- z-index: 1;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- flex-shrink: 0;
- padding: 10px 14px;
- background: var(--app-control);
- font:
- 600 11px/1.6 ui-monospace,
- monospace;
- color: var(--app-ink);
-}
-.directory-group-count {
- font-weight: 400;
- color: var(--app-muted);
-}
-.beat-directory-group {
- min-width: 0;
-}
-.beat-directory-items {
- display: flex;
- flex-direction: column;
-}
-.directory-mobile-beat {
- display: none;
-}
-.shot-list-content,
-.production-shot-list-content {
- gap: 12px;
- padding: 0 0 12px;
-}
-.n-button.directory-item {
- display: flex;
- flex-shrink: 0;
- width: 100%;
- height: auto;
- min-height: 88px;
- padding: 12px 14px;
- border-radius: 0;
- white-space: normal;
- text-align: left;
-}
-.n-button.directory-item .n-button__content {
- display: block;
- width: 100%;
- min-width: 0;
-}
-.directory-item-body {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 7px;
- min-width: 0;
-}
-/* 可选前导内容用于主体母版,不改变剧集与镜头目录的原有排版。 */
-.n-button.directory-item.has-leading .n-button__content {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-.directory-item-leading {
- display: flex;
- flex-shrink: 0;
-}
-.directory-item.has-leading .directory-item-body {
- flex: 1;
-}
-.identity-subject-list-content {
- gap: 2px;
- padding: 0 0 8px;
-}
-.n-button.identity-subject-item {
- min-height: 86px;
- padding: 10px 14px;
-}
-.n-button.identity-subject-item:not(.selected) {
- background: var(--app-surface);
-}
-.n-button.identity-subject-item:not(.selected):hover {
- background: var(--app-subtle);
-}
-.identity-subject-item .directory-item-body {
- gap: 3px;
-}
-.identity-subject-item .directory-item-title {
- order: 0;
- font-size: 14px;
- font-weight: 600;
-}
-.identity-subject-item .directory-item-eyebrow {
- order: 1;
- font-size: 10px;
-}
-.identity-subject-item .directory-item-meta {
- order: 2;
- gap: 4px 6px;
- font-size: 10px;
-}
-.identity-thumbnail {
- display: flex;
- width: 48px;
- height: 64px;
- overflow: hidden;
- background: var(--app-subtle);
-}
-.identity-thumbnail .n-image,
-.identity-thumbnail .n-image img {
- width: 100%;
- height: 100%;
-}
-.identity-thumbnail-placeholder {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- width: 100%;
- height: 100%;
- gap: 5px;
- color: var(--app-muted);
- font-size: 10px;
-}
-.directory-item-eyebrow {
- font:
- 11px/1.5 ui-monospace,
- monospace;
- color: var(--app-muted);
- overflow-wrap: anywhere;
-}
-.directory-item-title {
- font-size: 13px;
- line-height: 1.7;
- font-weight: 500;
- color: var(--app-ink);
- overflow-wrap: anywhere;
-}
-.directory-item-meta {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 6px 8px;
- color: var(--app-muted);
- font-size: 11px;
- line-height: 1.7;
- overflow-wrap: anywhere;
-}
-.directory-status {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 1px 5px;
- border-radius: 0;
- background: var(--app-control);
-}
-.directory-status svg {
- flex-shrink: 0;
-}
-.n-button.directory-item.selected {
- background: var(--app-selected);
- box-shadow: inset 3px 0 var(--app-accent);
-}
-.n-button.directory-item:focus-visible {
- outline: 2px solid var(--app-accent);
- outline-offset: -2px;
-}
-/* 标签与操作由同一 NTabs 导航行管理,suffix 共享底线及垂直中心。 */
-.tabs-toolbar {
- container: tabs-toolbar / inline-size;
- width: 100%;
- min-width: 0;
-}
-.workspace-tabs.n-tabs {
- width: 100%;
- min-width: 0;
-}
-.workspace-tabs > .n-tabs-nav {
- align-items: stretch;
-}
-.workspace-tabs .n-tabs-tab {
- min-height: 40px;
- box-sizing: border-box;
-}
-.workspace-tabs .n-tabs-nav-scroll-wrapper {
- min-width: 0;
-}
-.workspace-tabs .n-tabs-nav__suffix {
- align-items: center;
- flex-shrink: 0;
- padding-left: 20px;
-}
-.tabs-toolbar-actions {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- flex-wrap: wrap;
- gap: 4px 14px;
- min-height: 40px;
-}
-.tabs-toolbar-actions .n-button,
-.tabs-toolbar-actions .text-button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- height: 32px;
- white-space: nowrap;
-}
-/* 窄内容区允许 suffix 独占一行;Tabs 本身继续使用组件内置横向滚动。 */
-@container tabs-toolbar (max-width: 600px) {
- .workspace-tabs > .n-tabs-nav {
- flex-wrap: wrap;
- }
- .workspace-tabs .n-tabs-nav-scroll-wrapper {
- flex-basis: 100%;
- }
- .workspace-tabs .n-tabs-nav__suffix {
- width: 100%;
- justify-content: flex-end;
- padding-left: 0;
- }
-}
-.result-toolbar .n-tabs-tab__label {
- display: inline-flex;
- gap: 6px;
-}
-/* 筛选按内容区的实际宽度换行,避免侧栏展开后下拉框把其他控件挤到下一行。 */
-.form-image-filter-region {
- container-type: inline-size;
- margin-block: 16px;
- padding: 14px 16px;
- background: var(--app-subtle);
-}
-/* 镜头选择和图库筛选共用一行,以控件中心对齐;窄屏保留完整筛选组,不挤压复选框。 */
-.form-image-toolbar {
- display: grid;
- grid-template-columns: minmax(0, 1fr);
- align-items: center;
- gap: 12px 16px;
-}
-.form-image-toolbar.has-impact-picker {
- grid-template-columns: minmax(220px, 340px) minmax(0, 1fr);
-}
-.form-image-toolbar.has-impact-picker.has-impact-actions {
- grid-template-columns: minmax(260px, 420px) minmax(0, 1fr);
-}
-.asset-impact-picker {
- display: flex;
- align-items: center;
- gap: 8px;
- min-width: 0;
-}
-.asset-impact-picker .asset-impact-select {
- flex: 1 1 0;
-}
-.form-image-toolbar > * {
- min-width: 0;
-}
-.form-image-filters {
- display: grid;
- grid-template-columns: minmax(200px, 360px) 152px minmax(260px, 1fr) auto;
- align-items: center;
- gap: 12px 16px;
-}
-.form-image-filters > * {
- min-width: 0;
-}
-.filter-toggles {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 10px 16px;
- font-size: 12px;
-}
-.filter-toggles .n-checkbox__label {
- white-space: nowrap;
-}
-.filter-result-count {
- justify-self: end;
- color: var(--app-muted);
- font-size: 12px;
- white-space: nowrap;
-}
-.form-image-display-controls {
- display: flex;
- align-items: center;
- justify-self: end;
- gap: 12px;
-}
-.gallery-layout-switch {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-@container (max-width: 1200px) {
- .form-image-toolbar.has-impact-picker,
- .form-image-toolbar.has-impact-picker.has-impact-actions {
- grid-template-columns: minmax(0, 1fr);
- }
-}
-@container (max-width: 900px) {
- .form-image-filters {
- grid-template-columns: minmax(0, 1fr) 152px;
- }
- .form-image-display-controls {
- grid-column: 1 / -1;
- }
-}
-@container (max-width: 460px) {
- .form-image-filters {
- grid-template-columns: minmax(0, 1fr) 120px;
- column-gap: 10px;
- }
-}
-.content-with-history {
- height: clamp(360px, 66dvh, 850px);
- min-height: 0;
- grid-template-columns: minmax(0, 1fr) 207px;
-}
-.content-with-history > main,
-.content-with-history > aside {
- min-height: 0;
- overflow: hidden;
- overscroll-behavior: contain;
-}
-/* 拆解结果使用剩余高度,不能再用视口比例定高后隐藏长列表。 */
-.breakdown-results-section {
- display: flex;
- flex-direction: column;
- flex: 1;
- min-height: 0;
- min-width: 0;
- gap: 8px;
-}
-.breakdown-results-section > .result-toolbar {
- flex-shrink: 0;
-}
-.breakdown-results-section > .content-with-history {
- flex: 1;
- height: auto;
- min-height: 0;
- grid-template-rows: minmax(0, 1fr);
-}
-.breakdown-results-scroll .n-scrollbar-content {
- overflow-wrap: anywhere;
-}
-/* 剧集选择、统计和下一步入口共用一行,避免空操作行与双层顶部留白。 */
-.breakdown-storyboard {
- padding: 16px 20px 20px;
-}
-.breakdown-storyboard-toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- flex-wrap: wrap;
- gap: 12px 24px;
- margin-bottom: 16px;
-}
-.breakdown-episode-picker {
- display: flex;
- align-items: center;
- gap: 12px;
- flex: 0 1 420px;
- min-width: 0;
- font-size: 14px;
-}
-.breakdown-episode-picker > span {
- flex-shrink: 0;
- white-space: nowrap;
-}
-.breakdown-episode-picker .n-select {
- flex: 1;
- min-width: 0;
-}
-.breakdown-storyboard-summary {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 8px 20px;
- min-height: 34px;
- margin-left: auto;
-}
-.breakdown-storyboard-count {
- color: var(--app-muted);
- font-size: 12px;
- white-space: nowrap;
-}
-.breakdown-storyboard-summary .text-button {
- min-height: 34px;
- white-space: nowrap;
-}
-/* 连续阅读区保留双栏,执行记录按需展开,不挤占默认阅读宽度。 */
-.script-section .n-tabs-tab__label {
- display: inline-flex;
- align-items: center;
- gap: 8px;
-}
-.script-section .content-with-history.history-hidden {
- grid-template-columns: minmax(0, 1fr);
- grid-template-rows: minmax(0, 1fr);
-}
-.episode-reader {
- display: grid;
- grid-template-columns: clamp(190px, 22%, 240px) minmax(0, 1fr);
- height: 100%;
- min-height: 0;
- min-width: 0;
- overflow: hidden;
- container-type: inline-size;
-}
-.reader-directory {
- display: flex;
- flex-direction: column;
- min-height: 0;
- min-width: 0;
- background: var(--app-subtle);
-}
-.reader-directory-heading {
- display: flex;
- justify-content: space-between;
- align-items: center;
- flex-shrink: 0;
- gap: 8px;
- padding: 22px 20px 14px;
- font-size: 13px;
-}
-.reader-directory-heading h3 {
- font-weight: 600;
-}
-.reader-directory-heading span {
- font-size: 11px;
- color: var(--app-muted);
- white-space: nowrap;
-}
-.reader-directory-scroll {
- flex: 1;
-}
-.reader-directory-content {
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding: 4px 0 18px;
-}
-.n-button.reader-episode-link {
- flex-shrink: 0;
- width: 100%;
- height: auto;
- min-height: 64px;
- padding: 12px 14px;
- border-radius: 0;
- color: var(--app-muted);
- white-space: normal;
- text-align: left;
-}
-.n-button.reader-episode-link .n-button__content {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 5px;
- width: 100%;
- min-width: 0;
-}
-.reader-episode-label {
- font-size: 13px;
- font-weight: 600;
- line-height: 1.5;
-}
-.reader-episode-title {
- font-size: 12px;
- line-height: 1.6;
- overflow-wrap: anywhere;
-}
-.n-button.reader-episode-link.active {
- background: var(--app-selected);
- color: var(--app-accent-text);
- box-shadow: inset 3px 0 var(--app-accent);
-}
-.reader-page {
- min-width: 0;
- min-height: 0;
- overflow: hidden;
- background: var(--app-surface);
-}
-.reader-scroll .n-scrollbar-container:focus-visible {
- outline: 2px solid var(--app-accent);
- outline-offset: -3px;
-}
-.reader-content {
- /* 正文紧邻目录左对齐,保留阅读行宽,不在宽屏内容区居中。 */
- max-width: 900px;
- margin-inline: 0;
- padding: 32px;
- text-align: left;
-}
-.reader-chapter + .reader-chapter {
- margin-top: 48px;
- padding-top: 36px;
-}
-.reader-chapter:last-child {
- /* 末集较短时仍可定位到阅读线,不必把倒数第二集误认为当前集。 */
- min-height: max(160px, calc(var(--reader-height) - 64px));
-}
-.reader-chapter-heading {
- margin-bottom: 24px;
-}
-.reader-chapter-heading p {
- color: var(--app-muted);
- font-size: 13px;
- font-weight: 500;
- margin-bottom: 10px;
-}
-.reader-chapter-heading h2 {
- font-size: clamp(18px, 2cqw, 24px);
- font-weight: 600;
- line-height: 1.6;
- overflow-wrap: anywhere;
-}
-@media (max-width: 1000px) {
- .episode-reader {
- grid-template-columns: 180px minmax(0, 1fr);
- }
-}
-@media (max-width: 600px) {
- .episode-reader {
- grid-template-columns: 112px minmax(0, 1fr);
- }
- .reader-directory-heading {
- padding: 16px 12px 12px;
- flex-wrap: wrap;
- }
- .n-button.reader-episode-link {
- padding: 12px 10px;
- }
- .reader-content {
- padding-inline: 18px;
- }
-}
-/* 面板只负责尺寸与裁切;滚动和深浅主题滚动条统一交给 Naive UI。 */
-.panel-scroll.n-scrollbar {
- height: 100%;
- min-height: 0;
- min-width: 0;
- overflow: hidden;
-}
-.n-scrollbar > .n-scrollbar-container {
- overscroll-behavior: contain;
-}
-.production-shot-list,
-.production-detail,
-.storyboard-detail,
-.shot-list,
-.content-with-history > main {
- min-width: 0;
- min-height: 0;
-}
-.history-panel,
-.production-shot-list,
-.shot-list {
- padding: 0;
-}
-.history-content {
- padding: 23px 21px;
-}
-.image-history.n-scrollbar,
-.table-scroll.n-scrollbar {
- height: auto;
- overflow: hidden;
-}
-.image-history.n-scrollbar {
- width: 100%;
- max-width: 100%;
- min-width: 0;
-}
-.image-history-heading {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: space-between;
- gap: 6px 16px;
- margin-block: 16px 8px;
- font-size: 12px;
-}
-.image-history-content {
- display: flex;
- gap: 12px;
- padding: 4px 4px 12px;
- width: max-content;
-}
-.code-scroll.n-scrollbar {
- height: auto;
- max-height: 420px;
- overflow: hidden;
-}
-.code-scroll .json-view,
-.code-scroll .storyboard-json {
- max-height: none;
- overflow: visible;
-}
-.project-table-area {
- flex: 1;
- min-height: 0;
-}
-.project-data-table {
- height: 100%;
-}
-.project-data-table .n-data-table-base-table {
- flex: 1;
- min-height: 0;
-}
-.project-index-heading {
- margin: 0;
-}
-.project-index-heading h1 {
- font-size: 22px;
- font-weight: 600;
-}
-.project-index-toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- flex-wrap: wrap;
- gap: 12px;
- margin-bottom: 4px;
-}
-.project-search {
- width: 240px;
-}
-/* 项目筛选与搜索框同高,保留更宽的点击区;窄屏可自然换行。 */
-.project-status-filters.n-radio-group {
- display: flex;
- flex-wrap: wrap;
- height: auto;
- min-height: var(--n-height);
- gap: 4px 1px;
-}
-.project-status-filters .n-radio-group__splitor {
- display: none;
-}
-.project-status-filters .n-radio-button {
- min-width: 76px;
- padding-inline: 18px;
- text-align: center;
-}
-/* 状态说明与操作各自成组,避免小标签夹在不同尺寸的按钮之间。 */
-.visual-style-toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- flex-wrap: wrap;
- gap: 16px 24px;
- margin-block: 20px;
- padding: 14px 16px;
- background: var(--app-subtle);
-}
-.visual-style-status {
- display: flex;
- align-items: center;
- gap: 12px;
- min-width: 0;
-}
-.visual-style-status > svg {
- flex-shrink: 0;
- color: var(--app-muted);
-}
-.visual-style-status strong {
- font-size: 14px;
- font-weight: 500;
-}
-.visual-style-status p {
- margin: 4px 0 0;
- color: var(--app-muted);
- font-size: 12px;
- line-height: 1.6;
-}
-.visual-style-actions {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 10px;
- margin-left: auto;
-}
-.project-name-cell {
- display: block;
- min-width: 0;
-}
-.project-name-cell strong,
-.project-name-cell small {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.project-name-cell small {
- color: var(--app-muted);
- margin-top: 4px;
- font-size: 11px;
-}
-.app-dialog {
- max-width: calc(100vw - 32px);
- max-height: calc(100dvh - 32px);
-}
-.app-dialog .n-card-header {
- flex-shrink: 0;
-}
-.dialog-body-scroll {
- max-height: calc(100dvh - 154px);
-}
-.app-dialog .n-card__content {
- min-height: 0;
-}
-.dialog-footer {
- display: flex;
- justify-content: flex-end;
- gap: 8px;
- padding-top: 16px;
-}
-.asset-image .n-image {
- width: 100%;
- height: 100%;
- min-height: 0;
- min-width: 0;
- display: flex;
- justify-content: center;
-}
-.asset-image .n-image img {
- width: 100%;
- height: 100%;
-}
-/* 预览尺寸不随原图分辨率增长,历史缩略图也不能被按钮默认宽度覆盖。 */
-.asset-image.asset-image-preview {
- height: min(42dvh, 420px);
- min-height: 0;
- aspect-ratio: auto;
-}
-.n-button__content {
- gap: 6px;
-}
-.n-button:disabled {
- opacity: 1;
-}
-.n-button.image-history-item {
- width: 128px;
- min-width: 128px;
- max-width: 128px;
- flex: 0 0 128px;
- height: auto;
- padding: 6px;
- white-space: normal;
- background: var(--app-subtle);
-}
-.n-button.image-history-item.selected {
- background: var(--app-selected);
- box-shadow: inset 0 0 0 2px var(--app-accent);
-}
-.n-button.image-history-item .n-button__content {
- display: block;
- width: 100%;
- text-align: left;
- overflow: hidden;
-}
-.image-history-item .asset-image {
- height: 88px;
- min-height: 0;
- aspect-ratio: auto;
-}
-.image-history-label {
- display: block;
- margin-top: 6px;
- font-size: 11px;
- line-height: 1.5;
- overflow-wrap: anywhere;
-}
-.image-history-meta {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 4px;
- margin-top: 6px;
- color: var(--app-muted);
- font-size: 10px;
- line-height: 1.5;
-}
-/* 选角进度按抽屉实际宽度排列;按钮尺寸显式覆盖组件默认值。 */
-.casting-list {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
- gap: 12px;
- margin-top: 24px;
- padding-top: 20px;
-}
-.n-button.casting-item {
- width: 100%;
- min-width: 0;
- height: auto;
- min-height: 76px;
- padding: 12px 14px;
- white-space: normal;
- text-align: left;
- background: var(--app-subtle);
-}
-.n-button.casting-item.selected {
- background: var(--app-selected);
- box-shadow: inset 3px 0 0 var(--app-accent);
-}
-.n-button.casting-item .n-button__content {
- display: flex;
- width: 100%;
- min-width: 0;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- text-align: left;
-}
-.casting-item-identity {
- display: flex;
- flex-direction: column;
- gap: 5px;
- min-width: 0;
-}
-.casting-item-name {
- font-size: 14px;
- font-weight: 500;
- line-height: 1.5;
- overflow-wrap: anywhere;
-}
-.casting-item-ref {
- color: var(--app-muted);
- font-family: ui-monospace, monospace;
- font-size: 11px;
- line-height: 1.5;
- overflow-wrap: anywhere;
-}
-.casting-item-status {
- flex-shrink: 0;
-}
-/* 主体搜索靠左,统计紧随输入框;窄内容区自然换行,不占满整行或挤压控件。 */
-.subject-list-toolbar {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: flex-start;
- gap: 10px 12px;
- margin-bottom: 20px;
-}
-.subject-list-search.n-input {
- flex: 0 1 260px;
- min-width: 0;
- max-width: 100%;
-}
-.subject-list-count {
- flex-shrink: 0;
- color: var(--app-muted);
- font-size: 12px;
- white-space: nowrap;
-}
-/* 诊断筛选按实际空间换行,长 checkpoint ID 不撑开弹窗。 */
-.diagnostics-filters {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 12px;
-}
-.diagnostics-filters > .n-input {
- flex: 1 1 240px;
- min-width: 0;
-}
-.diagnostics-filters > .n-select {
- flex: 0 1 180px;
- min-width: 0;
-}
-.diagnostics-records {
- display: grid;
- gap: 12px;
- min-width: 0;
-}
-/* 镜头影响和素材定位使用固定底色;高亮只表示当前定位,不代表素材失效。 */
-.asset-impact-context {
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 5px 16px;
- background: var(--app-subtle);
- margin: 0;
- min-width: 0;
-}
-/* 关联素材始终单行横向浏览,数量和长名称不会撑高定位区或压缩图片区域。 */
-.asset-impact-links-scroll.n-scrollbar {
- height: auto;
- min-width: 0;
- max-width: 100%;
-}
-.asset-impact-links {
- display: flex;
- align-items: center;
- gap: 12px;
- width: max-content;
- white-space: nowrap;
- min-height: 32px;
- padding-block: 6px;
-}
-.asset-impact-links > * {
- flex-shrink: 0;
-}
-.asset-impact-select {
- min-width: 0;
- width: 100%;
-}
-.form-image-card-focused {
- box-shadow: inset 3px 0 0 var(--app-accent-text);
-}
-/* 低频记录收起后归还正文宽度,不留下历史列的占位。 */
-.breakdown-results-section > .content-with-history.history-hidden {
- grid-template-columns: minmax(0, 1fr);
- grid-template-rows: minmax(0, 1fr);
-}
-/* 补充说明只按需展开,继续使用平直背景层级,不增加边框或圆角。 */
-.detail-disclosure.n-collapse {
- font-size: 12px;
- color: var(--app-muted);
-}
-.detail-disclosure-content {
- padding: 12px 14px;
- background: var(--app-subtle);
- color: var(--app-ink);
- line-height: 1.8;
-}
-.quality-fields {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
- align-items: start;
- gap: 16px;
-}
-.quality-result {
- margin-top: 24px;
- padding: 16px;
- background: var(--app-subtle);
-}
-.quality-result-row {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 8px 12px;
- margin-top: 10px;
- padding: 12px;
- background: var(--app-control);
- font-size: 12px;
- overflow-wrap: anywhere;
-}
-.quality-json {
- margin-top: 16px;
- white-space: pre-wrap;
- overflow-wrap: anywhere;
- font-size: 11px;
- line-height: 1.7;
-}
-.select-control {
- min-width: 130px;
-}
-.n-checkbox {
- align-items: flex-start;
-}
-.n-checkbox .n-checkbox__label {
- white-space: normal;
-}
-.n-alert {
- line-height: 1.8;
-}
-.n-collapse.panel {
- padding: 16px;
-}
-.n-table.project-table th {
- background: var(--app-subtle);
-}
-.empty-state {
- min-height: 200px;
- padding: 40px 20px;
-}
-.production-video {
- background: #080808;
-}
-.skip-link:focus {
- z-index: 3000;
-}
-@media (max-width: 1000px) {
- .production-controls {
- grid-template-columns: minmax(130px, 1fr) 90px;
- }
- .content-with-history {
- grid-template-columns: minmax(0, 1fr) 180px;
- }
-}
-@media (max-width: 760px) {
- .admin-topbar {
- padding: 0 10px;
- }
- .admin-sider:not(.n-layout-sider--collapsed) {
- position: absolute;
- top: 44px;
- bottom: 0;
- height: auto;
- z-index: 20;
- box-shadow: 6px 0 24px var(--app-shadow);
- }
- .project-header {
- padding: 8px 12px;
- }
- .project-title {
- font-size: 14px;
- }
- .workspace-heading {
- padding: 12px;
- }
- .content-first-workspace .workspace-heading {
- padding: 8px 12px;
- }
- .content-first-workspace .workspace-split,
- .content-first-workspace .workspace-scroll-content {
- padding: 0 12px 12px;
- }
- .toolbar-episode {
- flex: 1;
- }
- .toolbar-actions {
- gap: 8px;
- }
- .workspace-tools-body {
- padding: 14px;
- }
- .workspace-scroll-content,
- .workspace-split {
- padding: 0 12px 12px;
- }
- .workspace-heading .production-controls {
- grid-template-columns: minmax(100px, 1fr) 80px;
- }
- .production-workspace,
- .storyboard-workspace {
- grid-template-columns: minmax(0, 1fr);
- grid-template-rows: 184px minmax(0, 1fr);
- }
- .directory-panel {
- height: 100%;
- border-right: 0;
- }
- .directory-heading {
- padding: 12px 14px 8px;
- }
- .directory-list-content {
- flex-direction: row;
- width: max-content;
- align-items: stretch;
- padding-bottom: 12px;
- }
- .n-button.directory-item {
- width: 228px;
- min-height: 104px;
- }
- .directory-group-heading {
- display: none;
- }
- .beat-directory-group,
- .beat-directory-items {
- display: contents;
- }
- .directory-mobile-beat {
- display: inline;
- }
- .identity-workspace {
- grid-template-columns: minmax(0, 1fr);
- grid-template-rows: 250px minmax(0, 1fr);
- gap: 10px;
- }
- .directory-filters {
- grid-template-columns: minmax(0, 1fr) 120px;
- }
- .storyboard-controls {
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
- }
- .content-with-history {
- grid-template-columns: minmax(0, 1fr);
- grid-template-rows: minmax(0, 1fr) 130px;
- }
- .breakdown-results-section > .content-with-history {
- grid-template-rows: minmax(0, 1fr) min(25%, 130px);
- }
- .history-panel {
- overflow: hidden;
- min-height: 0;
- }
- .project-search {
- width: 100%;
- }
- .project-index-toolbar .n-radio-group {
- width: 100%;
- }
- .production-pipeline {
- grid-template-columns: 1fr;
- }
-}
diff --git a/src/components/NotFoundPage.vue b/src/components/NotFoundPage.vue
index 56f3ae8..67d3166 100644
--- a/src/components/NotFoundPage.vue
+++ b/src/components/NotFoundPage.vue
@@ -9,3 +9,21 @@ import { EmptyState } from './ui'
>
+
+
diff --git a/src/components/ui/AppDialog.vue b/src/components/ui/AppDialog.vue
index ae7eee7..e039086 100644
--- a/src/components/ui/AppDialog.vue
+++ b/src/components/ui/AppDialog.vue
@@ -24,3 +24,24 @@ const open = defineModel('open', { default: false })
+
+
diff --git a/src/components/ui/AssetImage.vue b/src/components/ui/AssetImage.vue
index 8f9b761..046ebc4 100644
--- a/src/components/ui/AssetImage.vue
+++ b/src/components/ui/AssetImage.vue
@@ -50,3 +50,26 @@ watch(
>
+
+
diff --git a/src/components/ui/DetailDisclosure.vue b/src/components/ui/DetailDisclosure.vue
index b75340c..7395495 100644
--- a/src/components/ui/DetailDisclosure.vue
+++ b/src/components/ui/DetailDisclosure.vue
@@ -21,3 +21,14 @@ function toggle() {
+
+
diff --git a/src/components/ui/DirectoryItem.vue b/src/components/ui/DirectoryItem.vue
index 1690a0b..3437388 100644
--- a/src/components/ui/DirectoryItem.vue
+++ b/src/components/ui/DirectoryItem.vue
@@ -21,3 +21,46 @@ defineProps<{ active: boolean }>()
+
+
diff --git a/src/components/ui/EmptyState.vue b/src/components/ui/EmptyState.vue
index 07c1ce5..3d64bab 100644
--- a/src/components/ui/EmptyState.vue
+++ b/src/components/ui/EmptyState.vue
@@ -13,3 +13,14 @@ defineProps<{ title: string; description: string }>()
>
+
+
diff --git a/src/components/ui/ThemeToggle.vue b/src/components/ui/ThemeToggle.vue
index 04580b5..41207f0 100644
--- a/src/components/ui/ThemeToggle.vue
+++ b/src/components/ui/ThemeToggle.vue
@@ -34,3 +34,11 @@ function selectTheme(key: string | number) {
+
+
diff --git a/src/components/ui/WorkspacePage.vue b/src/components/ui/WorkspacePage.vue
index 7f2be9f..ba9c4a0 100644
--- a/src/components/ui/WorkspacePage.vue
+++ b/src/components/ui/WorkspacePage.vue
@@ -12,3 +12,72 @@ defineProps<{ split?: boolean; compact?: boolean }>()
+
+
diff --git a/src/components/ui/WorkspaceTools.vue b/src/components/ui/WorkspaceTools.vue
index 8456664..4a002e9 100644
--- a/src/components/ui/WorkspaceTools.vue
+++ b/src/components/ui/WorkspaceTools.vue
@@ -46,3 +46,45 @@ const open = defineModel('open', { default: false })
+
+
diff --git a/src/features/breakdown/BreakdownPage.vue b/src/features/breakdown/BreakdownPage.vue
index 0708fb1..63cdad4 100644
--- a/src/features/breakdown/BreakdownPage.vue
+++ b/src/features/breakdown/BreakdownPage.vue
@@ -497,3 +497,95 @@ function exportResult() {
+
+
diff --git a/src/features/breakdown/components/StoryboardList.vue b/src/features/breakdown/components/StoryboardList.vue
index eeebee9..d0d5547 100644
--- a/src/features/breakdown/components/StoryboardList.vue
+++ b/src/features/breakdown/components/StoryboardList.vue
@@ -106,3 +106,52 @@ const purposeLabels: Record = {
/>
+
+
diff --git a/src/features/breakdown/components/SubjectList.vue b/src/features/breakdown/components/SubjectList.vue
index 789926a..a60b52d 100644
--- a/src/features/breakdown/components/SubjectList.vue
+++ b/src/features/breakdown/components/SubjectList.vue
@@ -117,3 +117,35 @@ const filtered = computed(() =>
/>
+
+
diff --git a/src/features/create-drama/CreateDramaPage.vue b/src/features/create-drama/CreateDramaPage.vue
index fc90515..06fa8b1 100644
--- a/src/features/create-drama/CreateDramaPage.vue
+++ b/src/features/create-drama/CreateDramaPage.vue
@@ -249,3 +249,49 @@ function exportScript() {
+
+
diff --git a/src/features/create-drama/EpisodeReader.vue b/src/features/create-drama/EpisodeReader.vue
index d3163bb..f70624e 100644
--- a/src/features/create-drama/EpisodeReader.vue
+++ b/src/features/create-drama/EpisodeReader.vue
@@ -166,3 +166,106 @@ onBeforeUnmount(() => observer?.disconnect())
+
+
diff --git a/src/features/production/ProductionPage.vue b/src/features/production/ProductionPage.vue
index a8f8415..1219246 100644
--- a/src/features/production/ProductionPage.vue
+++ b/src/features/production/ProductionPage.vue
@@ -619,3 +619,99 @@ watch(
/>
+
+
diff --git a/src/features/production/components/QualityDialog.vue b/src/features/production/components/QualityDialog.vue
index 0042126..8fa5604 100644
--- a/src/features/production/components/QualityDialog.vue
+++ b/src/features/production/components/QualityDialog.vue
@@ -259,3 +259,11 @@ async function submit() {
+
+
diff --git a/src/features/production/components/QualityResult.vue b/src/features/production/components/QualityResult.vue
index af9ea48..bd7baf6 100644
--- a/src/features/production/components/QualityResult.vue
+++ b/src/features/production/components/QualityResult.vue
@@ -140,3 +140,17 @@ function exportResult() {
+
+
diff --git a/src/features/production/components/ShotProductionAssets.vue b/src/features/production/components/ShotProductionAssets.vue
index 0d39099..e66362f 100644
--- a/src/features/production/components/ShotProductionAssets.vue
+++ b/src/features/production/components/ShotProductionAssets.vue
@@ -523,3 +523,46 @@ async function inspectSpecs() {
/>
+
+
diff --git a/src/features/projects/ProjectLayout.vue b/src/features/projects/ProjectLayout.vue
index 099d418..7e0f2ca 100644
--- a/src/features/projects/ProjectLayout.vue
+++ b/src/features/projects/ProjectLayout.vue
@@ -80,3 +80,50 @@ onScopeDispose(() => {
+
+
diff --git a/src/features/projects/ProjectsPage.vue b/src/features/projects/ProjectsPage.vue
index c3c634c..bd0888f 100644
--- a/src/features/projects/ProjectsPage.vue
+++ b/src/features/projects/ProjectsPage.vue
@@ -140,3 +140,73 @@ function clearFilters() {
+
+
diff --git a/src/features/storyboard/StoryboardPage.vue b/src/features/storyboard/StoryboardPage.vue
index 6169ee2..322b114 100644
--- a/src/features/storyboard/StoryboardPage.vue
+++ b/src/features/storyboard/StoryboardPage.vue
@@ -418,3 +418,73 @@ watch(
>
+
+
diff --git a/src/features/storyboard/components/BeatShotDirectory.vue b/src/features/storyboard/components/BeatShotDirectory.vue
index 3af228b..06d0297 100644
--- a/src/features/storyboard/components/BeatShotDirectory.vue
+++ b/src/features/storyboard/components/BeatShotDirectory.vue
@@ -55,3 +55,35 @@ const groups = computed(() => {
+
+
diff --git a/src/features/storyboard/components/ShotTools.vue b/src/features/storyboard/components/ShotTools.vue
index c13ceda..39602c7 100644
--- a/src/features/storyboard/components/ShotTools.vue
+++ b/src/features/storyboard/components/ShotTools.vue
@@ -214,3 +214,20 @@ function exportResult(kind: 'spec' | 'prompt') {
+
+
diff --git a/src/features/subject-identity/SubjectIdentityPage.vue b/src/features/subject-identity/SubjectIdentityPage.vue
index 93f9a6c..ccb0e53 100644
--- a/src/features/subject-identity/SubjectIdentityPage.vue
+++ b/src/features/subject-identity/SubjectIdentityPage.vue
@@ -621,3 +621,117 @@ watch(
@generate="generateCastingCandidate"
/>
+
+
diff --git a/src/features/subject-identity/components/IdentityThumbnail.vue b/src/features/subject-identity/components/IdentityThumbnail.vue
index 024b929..3742c51 100644
--- a/src/features/subject-identity/components/IdentityThumbnail.vue
+++ b/src/features/subject-identity/components/IdentityThumbnail.vue
@@ -105,3 +105,18 @@ watch(
+
+
diff --git a/src/features/subject-images/SubjectImagesPage.vue b/src/features/subject-images/SubjectImagesPage.vue
index 8b57228..a3baf4a 100644
--- a/src/features/subject-images/SubjectImagesPage.vue
+++ b/src/features/subject-images/SubjectImagesPage.vue
@@ -739,3 +739,118 @@ async function showStaleForms() {
@changed="refreshAssets" />
+
+
diff --git a/src/features/visual-style/VisualStylePage.vue b/src/features/visual-style/VisualStylePage.vue
index 115c97b..e76a4a1 100644
--- a/src/features/visual-style/VisualStylePage.vue
+++ b/src/features/visual-style/VisualStylePage.vue
@@ -165,3 +165,26 @@ function removeImage(image: VisualStyleImage) {
@remove="removeImage"
/>
+
+
diff --git a/src/features/workflows/ConfirmAction.vue b/src/features/workflows/ConfirmAction.vue
index 7981f31..566f118 100644
--- a/src/features/workflows/ConfirmAction.vue
+++ b/src/features/workflows/ConfirmAction.vue
@@ -56,3 +56,11 @@ function confirm() {
+
+
diff --git a/src/features/workflows/HistoryPanel.vue b/src/features/workflows/HistoryPanel.vue
index 6160a2b..d1babf4 100644
--- a/src/features/workflows/HistoryPanel.vue
+++ b/src/features/workflows/HistoryPanel.vue
@@ -54,3 +54,27 @@ const history = computed(() => workflowCheckpoints(props.checkpoints, props.work
/>
+
+
diff --git a/src/features/workflows/WorkflowDiagnosticsDialog.vue b/src/features/workflows/WorkflowDiagnosticsDialog.vue
index 169d4b1..ebf5ecb 100644
--- a/src/features/workflows/WorkflowDiagnosticsDialog.vue
+++ b/src/features/workflows/WorkflowDiagnosticsDialog.vue
@@ -161,3 +161,20 @@ function exportReport() {
/>
+
+
diff --git a/src/main.ts b/src/main.ts
index 6b036a9..8b1d344 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,8 +1,8 @@
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'
-import './styles.css'
-import './admin.css'
+import './styles/styles.css'
+import './styles/admin.css'
/** 单页应用入口;不在浏览器保存模型密钥。 */
createApp(App).use(router).mount('#app')
diff --git a/src/styles.css b/src/styles.css
deleted file mode 100644
index 664edde..0000000
--- a/src/styles.css
+++ /dev/null
@@ -1,781 +0,0 @@
-@import 'tailwindcss';
-
-/* 微信风格明暗主题:灰阶表面搭配绿色操作态,业务排版保持不变。 */
-@theme {
- --color-ink: var(--app-ink);
- --color-muted: var(--app-muted);
- --color-faint: var(--app-muted);
- --color-line: var(--app-border);
- --color-accent: var(--app-accent-text);
- --color-danger: var(--app-danger);
- --color-success: var(--app-success);
- --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
-}
-
-@layer base {
- * {
- box-sizing: border-box;
- }
- body {
- margin: 0;
- background: var(--app-body);
- color: var(--color-ink);
- font-family: var(--font-sans);
- -webkit-font-smoothing: antialiased;
- }
- button,
- input,
- textarea,
- select {
- font: inherit;
- }
- button,
- a,
- summary {
- -webkit-tap-highlight-color: transparent;
- }
- button:not(:disabled),
- summary,
- select {
- cursor: pointer;
- }
- button:disabled {
- opacity: 0.45;
- cursor: not-allowed;
- }
- a {
- text-decoration: none;
- color: inherit;
- }
- :focus-visible {
- outline: 2px solid var(--color-accent);
- outline-offset: 3px;
- }
- input:focus-visible,
- textarea:focus-visible,
- select:focus-visible {
- outline-offset: 1px;
- }
- ::selection {
- background: var(--app-selected);
- color: var(--app-ink);
- }
-}
-
-@layer components {
- .identity-workspace {
- display: grid;
- grid-template-columns: 240px minmax(0, 1fr);
- align-items: start;
- gap: 20px;
- }
- .identity-subject-list {
- max-height: 560px;
- overflow: hidden;
- }
- .casting-stats {
- display: grid;
- grid-template-columns: repeat(5, minmax(0, 1fr));
- gap: 10px;
- }
- .casting-stats div {
- padding: 10px 12px;
- border-radius: 0;
- background: var(--app-subtle);
- }
- .casting-stats dt {
- color: var(--color-muted);
- font-size: 10px;
- }
- .casting-stats dd {
- margin-top: 5px;
- font-family: ui-monospace, monospace;
- font-size: 13px;
- }
- @media (max-width: 900px) {
- .identity-workspace {
- grid-template-columns: minmax(0, 1fr);
- }
- .identity-subject-list {
- max-height: 220px;
- }
- .casting-stats {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
- }
-
- .page-container {
- max-width: 1540px;
- margin-inline: auto;
- padding: 37px 40px 56px;
- }
- .page-heading {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 24px;
- margin-bottom: 28px;
- }
- .page-heading h1 {
- margin: 9px 0 0;
- font-size: 27px;
- font-weight: 600;
- letter-spacing: -0.035em;
- line-height: 1.4;
- }
- .eyebrow {
- font-size: 10px;
- letter-spacing: 0.11em;
- color: var(--color-muted);
- font-weight: 500;
- }
- .page-description {
- margin-top: 9px;
- color: var(--color-muted);
- font-size: 12px;
- line-height: 1.7;
- }
- .button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- padding: 9px 14px;
- min-height: 36px;
- border: 1px solid transparent;
- border-radius: 0;
- font-size: 12px;
- font-weight: 500;
- white-space: nowrap;
- transition:
- background 0.15s,
- border-color 0.15s;
- }
- .button-primary {
- background: var(--app-button-primary);
- color: var(--app-on-accent);
- }
- .button-primary:hover:not(:disabled) {
- background: var(--app-button-hover);
- }
- .button-primary:active:not(:disabled) {
- background: var(--app-button-pressed);
- }
- .button:disabled {
- background: var(--app-disabled-bg);
- color: var(--app-disabled-text);
- opacity: 1;
- }
- .button-secondary {
- background: var(--app-control);
- color: var(--app-ink);
- }
- .button-secondary:hover:not(:disabled) {
- background: var(--app-control-hover);
- }
-
- .text-button {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- font-size: 11px;
- color: var(--color-muted);
- padding: 6px 0;
- }
- .text-button:hover:not(:disabled) {
- color: var(--color-accent);
- }
- .panel {
- --app-field: var(--app-control);
- --app-field-hover: var(--app-control-hover);
- background: var(--app-surface);
- border-radius: 0;
- }
-
- /* 次级信息用低对比底色成组,避免用边框把工作台切成碎片。 */
- .surface-inset {
- --app-field: var(--app-surface);
- --app-field-hover: var(--app-surface);
- background: var(--app-subtle);
- }
- /* 首项与其它条目统一内边距;背景过渡仅用于主题切换,不随鼠标或焦点变化。 */
- .record-list > article {
- padding: 20px;
- transition: background-color 160ms ease;
- }
- .record-list > article:nth-child(even) {
- background: var(--app-subtle);
- }
- /* 主体奇数行常驻原悬停底色,与偶数行形成固定斑马纹;内部控件仍保留交互反馈。 */
- .subject-record-list > article:nth-child(odd) {
- background: var(--app-control);
- }
- /* 形态块与所在主体条纹使用相反灰阶,避免偶数行内外同色;不增加边框或悬停态。 */
- .subject-form-card {
- margin-top: 12px;
- padding: 16px;
- min-width: 0;
- overflow-wrap: anywhere;
- background: var(--app-subtle);
- }
- .subject-record-list > article:nth-child(even) .subject-form-card {
- background: var(--app-control);
- }
-
- .table-scroll {
- overflow: hidden;
- }
- .project-table {
- width: 100%;
- border-collapse: collapse;
- text-align: left;
- font-size: 12px;
- }
- .project-table th {
- padding: 13px 20px;
- background: var(--app-control);
- color: var(--color-muted);
- font-size: 10px;
- font-weight: 500;
- white-space: nowrap;
- }
- .project-table td {
- padding: 21px 20px;
- }
- .project-table tr:last-child td {
- border-bottom: none;
- }
- .project-table tbody tr:nth-child(even) td {
- background: var(--app-subtle);
- }
- .project-table tbody tr:hover td {
- background: var(--app-selected);
- }
-
- .production-controls {
- display: grid;
- grid-template-columns: minmax(220px, 1.6fr) minmax(110px, 0.5fr) minmax(230px, 1fr) auto;
- align-items: end;
- gap: 18px;
- }
- .production-pipeline {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 14px;
- }
- .production-pipeline-card {
- padding: 20px;
- }
- .production-step {
- display: grid;
- place-items: center;
- width: 36px;
- height: 36px;
- flex-shrink: 0;
- border-radius: 0;
- background: var(--app-subtle);
- color: var(--app-ink);
- }
- .production-stats {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 8px;
- }
- .production-stats div,
- .production-status-grid div {
- padding: 10px;
- border-radius: 0;
- background: var(--app-subtle);
- }
- .production-stats dt,
- .production-status-grid dt {
- color: var(--color-muted);
- font-size: 10px;
- }
- .production-stats dd,
- .production-status-grid dd {
- margin-top: 5px;
- font-size: 13px;
- font-family: ui-monospace, monospace;
- }
- .production-status-grid {
- display: grid;
- grid-template-columns: repeat(5, minmax(0, 1fr));
- gap: 10px;
- }
- .production-workspace {
- display: grid;
- grid-template-columns: 220px minmax(0, 1fr);
- align-items: start;
- overflow: hidden;
- }
- .production-shot-list {
- max-height: 760px;
- overflow: hidden;
- background: var(--app-surface);
- padding-block: 8px;
- }
- .production-stage {
- padding: 20px 0;
- }
- .production-stage:first-of-type {
- border-top: none;
- }
- .production-stage-heading {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
- }
- .keyframe-grid {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 12px;
- }
- .keyframe-card {
- overflow: hidden;
- border-radius: 0;
- background: var(--app-subtle);
- }
- .keyframe-card .asset-image {
- aspect-ratio: 16 / 9;
- border-radius: 0;
- }
- .video-record {
- display: flex;
- overflow: hidden;
- border-radius: 0;
- background: var(--app-subtle);
- }
- .production-video {
- width: min(44%, 390px);
- min-height: 170px;
- background: var(--app-ink);
- object-fit: contain;
- }
-
- @media (max-width: 1100px) {
- .production-pipeline {
- grid-template-columns: 1fr;
- }
- .production-controls {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
- }
- @media (max-width: 760px) {
- .production-controls,
- .production-workspace {
- grid-template-columns: minmax(0, 1fr);
- }
- .production-shot-list {
- display: flex;
- max-height: none;
- overflow: hidden;
- border-right: none;
- }
- .production-status-grid,
- .keyframe-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
- .video-record {
- display: block;
- }
- .production-video {
- width: 100%;
- }
- }
- .empty-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- min-height: 300px;
- padding: 52px 24px;
- text-align: center;
- }
-
- .dialog-footer {
- display: flex;
- justify-content: flex-end;
- gap: 10px;
- padding-top: 20px;
- }
- .field-label {
- display: block;
- margin-bottom: 9px;
- font-size: 12px;
- font-weight: 500;
- color: var(--app-ink);
- }
- .input {
- width: 100%;
- background: var(--app-field);
- color: var(--color-ink);
- border-radius: 0;
- padding: 9px 11px;
- font-size: 12px;
- line-height: 1.7;
- }
- .input::placeholder {
- color: var(--app-muted);
- }
- .input:disabled {
- background: var(--app-subtle);
- opacity: 0.6;
- }
- .alert {
- padding: 12px 15px;
- border-radius: 0;
- background: var(--app-subtle);
- color: var(--app-ink);
- font-size: 12px;
- line-height: 1.8;
- overflow-wrap: anywhere;
- }
- .alert-error {
- background: var(--app-subtle);
- color: var(--app-danger);
- }
-
- .stage-strip {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 14px;
- }
- .stage-item {
- display: flex;
- align-items: center;
- gap: 7px;
- color: var(--app-muted);
- font-size: 11px;
- }
- .stage-item.done {
- color: var(--app-ink);
- }
- .stage-arrow {
- margin-left: 12px;
- color: var(--app-muted);
- }
-
- .content-with-history {
- display: grid;
- grid-template-columns: minmax(0, 1fr) 207px;
- min-height: 480px;
- overflow: hidden;
- }
- .history-panel {
- padding: 23px 21px;
- background: var(--app-subtle);
- }
-
- .form-image-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
- gap: 20px;
- }
- .form-image-card {
- overflow: hidden;
- }
- .asset-image {
- display: grid;
- place-items: center;
- width: 100%;
- aspect-ratio: 4 / 3;
- overflow: hidden;
- background: var(--app-subtle);
- }
- .asset-image img {
- width: 100%;
- height: 100%;
- min-height: 0;
- }
- .asset-image-empty {
- display: flex;
- align-items: center;
- justify-content: center;
- flex-direction: column;
- gap: 10px;
- padding: 20px;
- color: var(--color-muted);
- font-size: 11px;
- text-align: center;
- }
- .image-history {
- overflow: hidden;
- }
- .image-history-item .asset-image-empty {
- font-size: 9px;
- padding: 6px;
- gap: 4px;
- }
- .image-history-item .asset-image-empty svg {
- width: 14px;
- height: 14px;
- }
- .storyboard-controls {
- display: grid;
- grid-template-columns: minmax(180px, 1fr) 100px 145px auto;
- gap: 18px;
- align-items: end;
- }
- .generation-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 20px;
- padding-top: 20px;
- margin-top: 20px;
- }
- .generation-row > div:last-child {
- flex-shrink: 0;
- }
- .storyboard-workspace {
- display: grid;
- grid-template-columns: 220px minmax(0, 1fr);
- overflow: hidden;
- }
- .shot-list {
- max-height: 1000px;
- overflow: hidden;
- background: var(--app-surface);
- padding: 0 8px 20px;
- }
- .reference-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
- gap: 14px;
- }
- .reference-card {
- background: var(--app-subtle);
- border-radius: 0;
- overflow: hidden;
- }
- .reference-placeholder {
- display: grid;
- place-items: center;
- aspect-ratio: 4 / 3;
- font-size: 11px;
- color: var(--color-muted);
- background: var(--app-subtle);
- }
- .storyboard-json {
- padding: 16px;
- border-radius: 0;
- background: var(--app-subtle);
- font:
- 11px/1.9 ui-monospace,
- monospace;
- }
- .script-summary {
- font-size: 12px;
- color: var(--color-muted);
- line-height: 1.9;
- padding: 17px 0 22px;
- margin-bottom: 23px;
- }
- .script-body {
- white-space: pre-wrap;
- overflow-wrap: anywhere;
- font-size: 14px;
- line-height: 2.15;
- color: var(--app-ink);
- }
- .script-notes {
- margin-top: 36px;
- padding-top: 20px;
- font-size: 12px;
- }
- .script-notes dt {
- color: var(--app-ink);
- font-weight: 500;
- margin-bottom: 6px;
- }
- .script-notes dd {
- color: var(--color-muted);
- line-height: 1.8;
- margin-bottom: 15px;
- }
- .detail-grid {
- display: grid;
- grid-template-columns: 70px minmax(0, 1fr);
- gap: 15px;
- font-size: 12px;
- line-height: 1.9;
- }
- .detail-grid dt {
- color: var(--color-muted);
- }
- .detail-grid dd {
- white-space: pre-wrap;
- overflow-wrap: anywhere;
- }
- .json-view {
- white-space: pre-wrap;
- overflow-wrap: anywhere;
- padding: 16px;
- background: var(--app-subtle);
- border-radius: 0;
- font:
- 11px/1.9 ui-monospace,
- monospace;
- }
-
- .group-preview {
- margin-top: 24px;
- padding-top: 20px;
- }
- .group-chip {
- display: flex;
- align-items: center;
- gap: 10px;
- background: var(--app-subtle);
- border-radius: 0;
- padding: 8px 11px;
- font-size: 11px;
- }
- .recovery-strip {
- background: var(--app-subtle);
- display: flex;
- justify-content: space-between;
- flex-wrap: wrap;
- align-items: center;
- gap: 15px;
- padding: 17px;
- border-radius: 0;
- }
- .subject-ref {
- display: inline-flex;
- background: var(--app-subtle);
- padding: 3px 6px;
- border-radius: 0;
- color: var(--app-ink);
- font-size: 10px;
- }
- .subject-details summary {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- color: var(--app-ink);
- font-size: 11px;
- list-style: none;
- }
- .subject-details summary::-webkit-details-marker {
- display: none;
- }
- .subject-details[open] summary svg {
- transform: rotate(180deg);
- }
- .beat-section {
- margin-top: 25px;
- padding-top: 23px;
- }
- .beat-heading {
- display: flex;
- align-items: center;
- gap: 10px;
- }
- .beat-number {
- color: var(--app-muted);
- font:
- 11px ui-monospace,
- monospace;
- }
- .shot-row {
- display: flex;
- gap: 17px;
- padding: 17px;
- border-radius: 0;
- background: var(--app-subtle);
- }
- .shot-label {
- flex-shrink: 0;
- width: 47px;
- font-size: 10px;
- color: var(--app-muted);
- }
- .skip-link {
- position: fixed;
- z-index: 100;
- top: -80px;
- left: 15px;
- padding: 10px 15px;
- background: var(--app-surface);
- border: 1px solid var(--color-accent);
- }
- .skip-link:focus {
- top: 10px;
- }
-}
-
-/* 中等宽度保留正文空间,执行记录下移,移动端导航收为图标。 */
-@media (max-width: 1200px) {
- .generation-row {
- align-items: flex-start;
- flex-direction: column;
- gap: 12px;
- }
- .storyboard-controls {
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
- }
- .page-container {
- padding-inline: 26px;
- }
-
- .content-with-history {
- grid-template-columns: minmax(0, 1fr);
- }
- .history-panel {
- border-left: 0;
- }
-}
-@media (max-width: 760px) {
- .storyboard-controls {
- grid-template-columns: minmax(0, 1fr);
- gap: 12px;
- }
- .storyboard-workspace {
- grid-template-columns: minmax(0, 1fr);
- }
- .shot-list {
- max-height: 240px;
- border-right: 0;
- }
-
- .page-container {
- padding: 25px 18px 40px;
- }
-
- .page-heading {
- align-items: flex-start;
- flex-direction: column;
- gap: 16px;
- }
- .page-heading h1 {
- font-size: 23px;
- }
-
- .stage-strip {
- gap: 9px;
- }
- .stage-arrow {
- margin-left: 3px;
- }
-
- .project-table td,
- .project-table th {
- padding-inline: 14px;
- }
- .shot-row {
- gap: 12px;
- padding: 13px;
- }
-}
-@media (prefers-reduced-motion: reduce) {
- *,
- *::before,
- *::after {
- animation-duration: 0.01ms !important;
- transition-duration: 0.01ms !important;
- }
-}
diff --git a/src/styles/admin.css b/src/styles/admin.css
new file mode 100644
index 0000000..c2df976
--- /dev/null
+++ b/src/styles/admin.css
@@ -0,0 +1,433 @@
+/* 管理后台视口边界:document 不滚动,各个工作区和面板独立滚动。 */
+:root {
+ --app-ink: #191919;
+ --app-muted: #707070;
+ --app-body: #ededed;
+ --app-surface: #ffffff;
+ --app-subtle: #f7f7f7;
+ --app-control: #f0f0f0;
+ --app-control-hover: #e5e5e5;
+ --app-field: #ffffff;
+ --app-field-hover: #ffffff;
+ --app-border: #e5e5e5;
+ --app-inverse: #ffffff;
+ --app-accent: #07c160;
+ --app-accent-hover: #2dcb79;
+ --app-accent-text: #087c42;
+ --app-on-accent: #ffffff;
+ --app-button-primary: #078640;
+ --app-button-hover: #087c42;
+ --app-button-pressed: #066b36;
+ --app-disabled-bg: #e4e4e4;
+ --app-disabled-text: #8c8c8c;
+ --app-selected: #e4f4e9;
+ --app-shadow: #00000018;
+ --app-danger: #a93232;
+ --app-success: #087c42;
+}
+:root[data-theme='dark'] {
+ --app-ink: #d9d9d9;
+ --app-muted: #a3a3a3;
+ --app-body: #111111;
+ --app-surface: #191919;
+ --app-subtle: #232323;
+ --app-control: #2b2b2b;
+ --app-control-hover: #353535;
+ --app-field: #2b2b2b;
+ --app-field-hover: #353535;
+ --app-disabled-bg: #303030;
+ --app-disabled-text: #808080;
+ --app-border: #333333;
+ --app-inverse: #111111;
+ --app-accent-text: #5cd693;
+ --app-selected: #173527;
+ --app-shadow: #00000055;
+ --app-danger: #fa7373;
+ --app-success: #5cd693;
+}
+html,
+body,
+#app,
+.app-provider {
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+}
+html {
+ background: var(--app-body);
+}
+body {
+ margin: 0;
+}
+/* 外壳固定品牌与底部设置;只有菜单自身使用 NScrollbar 滚动。 */
+/* Naive 菜单背景默认左右内缩 8px;只铺满导航行,不改图标与文字缩进。 */
+/* 按钮保持直角,单图标按钮以 Naive 当前尺寸为边长,加载时也不改变宽度。 */
+.n-button {
+ border-radius: 0;
+}
+.n-button.icon-button {
+ width: var(--n-height);
+ min-width: var(--n-height);
+ height: var(--n-height);
+ padding: 0;
+ flex: 0 0 auto;
+}
+.n-button.icon-button .n-button__icon {
+ margin: 0;
+}
+.n-button:focus-visible {
+ outline: 2px solid var(--app-accent-text);
+ outline-offset: 2px;
+}
+/* 标题移除额外按钮后让名称使用剩余空间;未完成项目的引导仍留在内容区。 */
+/* 常用工具固定在滚动区外;低频操作使用局部抽屉,不叠加占高面板。 */
+.workspace-toolbar {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px 16px;
+ min-height: 34px;
+}
+.toolbar-episode {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 0 1 340px;
+ min-width: 180px;
+ font-size: 12px;
+}
+.toolbar-episode .n-select {
+ min-width: 0;
+}
+.toolbar-metrics,
+.toolbar-summary {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px 16px;
+ color: var(--app-muted);
+ font-size: 12px;
+ line-height: 1.6;
+}
+.toolbar-actions {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-left: auto;
+}
+/* 身份说明与折叠入口分开,避免正文紧贴下一组操作。 */
+/* 与 34px 中号输入框共用一行;不再通过 pb-2 / pb-3 手动垫高复选框。 */
+.n-checkbox.control-row-checkbox {
+ align-self: end;
+ align-items: center;
+ min-height: 34px;
+ padding-block: 0;
+}
+.control-row-checkbox .n-checkbox-box-wrapper {
+ align-self: center;
+}
+/* 拆解配置跟随抽屉实际宽度;标题 20px + 间距 8px,下方统一使用 34px 控件行。 */
+/* 筛选与关联行整体吸顶,底部不留固定分隔带;列表自己的顶部间距随内容滚走。 */
+/* 使用成熟的多列排版实现瀑布流;完整卡片不跨列,窄容器自动减少列数。 */
+.workspace-feedback.n-scrollbar {
+ flex-shrink: 0;
+ height: auto;
+ max-height: min(24dvh, 160px);
+ overflow: hidden;
+}
+.workspace-feedback .n-alert + .n-alert {
+ margin-top: 8px;
+}
+.overview-content {
+ padding-bottom: 8px;
+}
+/* 目录统一为固定标题/筛选区和独立滚动列表;不再依赖按钮的默认行高与内边距。 */
+.directory-panel {
+ --app-field: var(--app-surface);
+ --app-field-hover: var(--app-surface);
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ padding: 0;
+ overflow: hidden;
+ background: var(--app-subtle);
+}
+.directory-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ flex-shrink: 0;
+ gap: 8px;
+ padding: 18px 16px 14px;
+ font-size: 11px;
+ color: var(--app-muted);
+}
+.directory-heading h3 {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--app-ink);
+}
+.directory-heading h3 span {
+ margin-left: 8px;
+ font-size: 11px;
+ font-weight: 400;
+ color: var(--app-muted);
+}
+.directory-scroll {
+ flex: 1;
+}
+.directory-list-content {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 8px 0 16px;
+}
+/* 可选前导内容用于主体母版,不改变剧集与镜头目录的原有排版。 */
+.directory-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 1px 5px;
+ border-radius: 0;
+ background: var(--app-control);
+}
+.directory-status svg {
+ flex-shrink: 0;
+}
+/* 标签与操作由同一 NTabs 导航行管理,suffix 共享底线及垂直中心。 */
+.tabs-toolbar {
+ container: tabs-toolbar / inline-size;
+ width: 100%;
+ min-width: 0;
+}
+.workspace-tabs.n-tabs {
+ width: 100%;
+ min-width: 0;
+}
+.workspace-tabs > .n-tabs-nav {
+ align-items: stretch;
+}
+.workspace-tabs .n-tabs-tab {
+ min-height: 40px;
+ box-sizing: border-box;
+}
+.workspace-tabs .n-tabs-nav-scroll-wrapper {
+ min-width: 0;
+}
+.workspace-tabs .n-tabs-nav__suffix {
+ align-items: center;
+ flex-shrink: 0;
+ padding-left: 20px;
+}
+.tabs-toolbar-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ gap: 4px 14px;
+ min-height: 40px;
+}
+.tabs-toolbar-actions .n-button,
+.tabs-toolbar-actions .text-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 32px;
+ white-space: nowrap;
+}
+/* 窄内容区允许 suffix 独占一行;Tabs 本身继续使用组件内置横向滚动。 */
+@container tabs-toolbar (max-width: 600px) {
+ .workspace-tabs > .n-tabs-nav {
+ flex-wrap: wrap;
+ }
+ .workspace-tabs .n-tabs-nav-scroll-wrapper {
+ flex-basis: 100%;
+ }
+ .workspace-tabs .n-tabs-nav__suffix {
+ width: 100%;
+ justify-content: flex-end;
+ padding-left: 0;
+ }
+}
+/* 筛选按内容区的实际宽度换行,避免侧栏展开后下拉框把其他控件挤到下一行。 */
+/* 镜头选择和图库筛选共用一行,以控件中心对齐;窄屏保留完整筛选组,不挤压复选框。 */
+.content-with-history {
+ height: clamp(360px, 66dvh, 850px);
+ min-height: 0;
+ grid-template-columns: minmax(0, 1fr) 207px;
+}
+.content-with-history > main,
+.content-with-history > aside {
+ min-height: 0;
+ overflow: hidden;
+ overscroll-behavior: contain;
+}
+/* 拆解结果使用剩余高度,不能再用视口比例定高后隐藏长列表。 */
+/* 剧集选择、统计和下一步入口共用一行,避免空操作行与双层顶部留白。 */
+/* 连续阅读区保留双栏,执行记录按需展开,不挤占默认阅读宽度。 */
+/* 面板只负责尺寸与裁切;滚动和深浅主题滚动条统一交给 Naive UI。 */
+.panel-scroll.n-scrollbar {
+ height: 100%;
+ min-height: 0;
+ min-width: 0;
+ overflow: hidden;
+}
+.n-scrollbar > .n-scrollbar-container {
+ overscroll-behavior: contain;
+}
+.production-detail,
+.storyboard-detail,
+.content-with-history > main {
+ min-width: 0;
+ min-height: 0;
+}
+.image-history.n-scrollbar,
+.table-scroll.n-scrollbar {
+ height: auto;
+ overflow: hidden;
+}
+.image-history.n-scrollbar {
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+}
+.image-history-heading {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px 16px;
+ margin-block: 16px 8px;
+ font-size: 12px;
+}
+.image-history-content {
+ display: flex;
+ gap: 12px;
+ padding: 4px 4px 12px;
+ width: max-content;
+}
+.code-scroll.n-scrollbar {
+ height: auto;
+ max-height: 420px;
+ overflow: hidden;
+}
+.code-scroll .json-view,
+.code-scroll .storyboard-json {
+ max-height: none;
+ overflow: visible;
+}
+/* 项目筛选与搜索框同高,保留更宽的点击区;窄屏可自然换行。 */
+/* 状态说明与操作各自成组,避免小标签夹在不同尺寸的按钮之间。 */
+.dialog-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding-top: 16px;
+}
+/* 预览尺寸不随原图分辨率增长,历史缩略图也不能被按钮默认宽度覆盖。 */
+.n-button__content {
+ gap: 6px;
+}
+.n-button:disabled {
+ opacity: 1;
+}
+.n-button.image-history-item {
+ width: 128px;
+ min-width: 128px;
+ max-width: 128px;
+ flex: 0 0 128px;
+ height: auto;
+ padding: 6px;
+ white-space: normal;
+ background: var(--app-subtle);
+}
+.n-button.image-history-item.selected {
+ background: var(--app-selected);
+ box-shadow: inset 0 0 0 2px var(--app-accent);
+}
+.n-button.image-history-item .n-button__content {
+ display: block;
+ width: 100%;
+ text-align: left;
+ overflow: hidden;
+}
+.image-history-item .asset-image {
+ height: 88px;
+ min-height: 0;
+ aspect-ratio: auto;
+}
+.image-history-label {
+ display: block;
+ margin-top: 6px;
+ font-size: 11px;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+}
+.image-history-meta {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 6px;
+ color: var(--app-muted);
+ font-size: 10px;
+ line-height: 1.5;
+}
+/* 选角进度按抽屉实际宽度排列;按钮尺寸显式覆盖组件默认值。 */
+/* 主体搜索靠左,统计紧随输入框;窄内容区自然换行,不占满整行或挤压控件。 */
+/* 诊断筛选按实际空间换行,长 checkpoint ID 不撑开弹窗。 */
+/* 镜头影响和素材定位使用固定底色;高亮只表示当前定位,不代表素材失效。 */
+/* 关联素材始终单行横向浏览,数量和长名称不会撑高定位区或压缩图片区域。 */
+/* 低频记录收起后归还正文宽度,不留下历史列的占位。 */
+/* 补充说明只按需展开,继续使用平直背景层级,不增加边框或圆角。 */
+.select-control {
+ min-width: 130px;
+}
+.n-checkbox {
+ align-items: flex-start;
+}
+.n-checkbox .n-checkbox__label {
+ white-space: normal;
+}
+.n-alert {
+ line-height: 1.8;
+}
+.n-collapse.panel {
+ padding: 16px;
+}
+.n-table.project-table th {
+ background: var(--app-subtle);
+}
+@media (max-width: 1000px) {
+ .content-with-history {
+ grid-template-columns: minmax(0, 1fr) 180px;
+ }
+}
+@media (max-width: 760px) {
+ .toolbar-episode {
+ flex: 1;
+ }
+ .toolbar-actions {
+ gap: 8px;
+ }
+ .directory-panel {
+ height: 100%;
+ border-right: 0;
+ }
+ .directory-heading {
+ padding: 12px 14px 8px;
+ }
+ .directory-list-content {
+ flex-direction: row;
+ width: max-content;
+ align-items: stretch;
+ padding-bottom: 12px;
+ }
+ .content-with-history {
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: minmax(0, 1fr) 130px;
+ }
+}
diff --git a/src/styles/styles.css b/src/styles/styles.css
new file mode 100644
index 0000000..f504062
--- /dev/null
+++ b/src/styles/styles.css
@@ -0,0 +1,280 @@
+@import 'tailwindcss';
+/* 微信风格明暗主题:灰阶表面搭配绿色操作态,业务排版保持不变。 */
+@theme {
+ --color-ink: var(--app-ink);
+ --color-muted: var(--app-muted);
+ --color-faint: var(--app-muted);
+ --color-line: var(--app-border);
+ --color-accent: var(--app-accent-text);
+ --color-danger: var(--app-danger);
+ --color-success: var(--app-success);
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
+}
+@layer base {
+ * {
+ box-sizing: border-box;
+ }
+ body {
+ margin: 0;
+ background: var(--app-body);
+ color: var(--color-ink);
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+ }
+ button,
+ input,
+ textarea,
+ select {
+ font: inherit;
+ }
+ button,
+ a,
+ summary {
+ -webkit-tap-highlight-color: transparent;
+ }
+ button:not(:disabled),
+ summary,
+ select {
+ cursor: pointer;
+ }
+ button:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ }
+ a {
+ text-decoration: none;
+ color: inherit;
+ }
+ :focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 3px;
+ }
+ input:focus-visible,
+ textarea:focus-visible,
+ select:focus-visible {
+ outline-offset: 1px;
+ }
+ ::selection {
+ background: var(--app-selected);
+ color: var(--app-ink);
+ }
+}
+@layer components {
+ .eyebrow {
+ font-size: 10px;
+ letter-spacing: 0.11em;
+ color: var(--color-muted);
+ font-weight: 500;
+ }
+ .button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 9px 14px;
+ min-height: 36px;
+ border: 1px solid transparent;
+ border-radius: 0;
+ font-size: 12px;
+ font-weight: 500;
+ white-space: nowrap;
+ transition:
+ background 0.15s,
+ border-color 0.15s;
+ }
+ .button-primary {
+ background: var(--app-button-primary);
+ color: var(--app-on-accent);
+ }
+ .button-primary:hover:not(:disabled) {
+ background: var(--app-button-hover);
+ }
+ .button-primary:active:not(:disabled) {
+ background: var(--app-button-pressed);
+ }
+ .button:disabled {
+ background: var(--app-disabled-bg);
+ color: var(--app-disabled-text);
+ opacity: 1;
+ }
+ .button-secondary {
+ background: var(--app-control);
+ color: var(--app-ink);
+ }
+ .button-secondary:hover:not(:disabled) {
+ background: var(--app-control-hover);
+ }
+ .text-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ color: var(--color-muted);
+ padding: 6px 0;
+ }
+ .text-button:hover:not(:disabled) {
+ color: var(--color-accent);
+ }
+ .panel {
+ --app-field: var(--app-control);
+ --app-field-hover: var(--app-control-hover);
+ background: var(--app-surface);
+ border-radius: 0;
+ }
+ /* 次级信息用低对比底色成组,避免用边框把工作台切成碎片。 */
+ .surface-inset {
+ --app-field: var(--app-surface);
+ --app-field-hover: var(--app-surface);
+ background: var(--app-subtle);
+ }
+ /* 首项与其它条目统一内边距;背景过渡仅用于主题切换,不随鼠标或焦点变化。 */
+ .record-list > article {
+ padding: 20px;
+ transition: background-color 160ms ease;
+ }
+ .record-list > article:nth-child(even) {
+ background: var(--app-subtle);
+ }
+ /* 主体奇数行常驻原悬停底色,与偶数行形成固定斑马纹;内部控件仍保留交互反馈。 */
+ /* 形态块与所在主体条纹使用相反灰阶,避免偶数行内外同色;不增加边框或悬停态。 */
+ .table-scroll {
+ overflow: hidden;
+ }
+ .project-table {
+ width: 100%;
+ border-collapse: collapse;
+ text-align: left;
+ font-size: 12px;
+ }
+ .project-table th {
+ padding: 13px 20px;
+ background: var(--app-control);
+ color: var(--color-muted);
+ font-size: 10px;
+ font-weight: 500;
+ white-space: nowrap;
+ }
+ .project-table td {
+ padding: 21px 20px;
+ }
+ .project-table tr:last-child td {
+ border-bottom: none;
+ }
+ .project-table tbody tr:nth-child(even) td {
+ background: var(--app-subtle);
+ }
+ .project-table tbody tr:hover td {
+ background: var(--app-selected);
+ }
+ .production-step {
+ display: grid;
+ place-items: center;
+ width: 36px;
+ height: 36px;
+ flex-shrink: 0;
+ border-radius: 0;
+ background: var(--app-subtle);
+ color: var(--app-ink);
+ }
+ .dialog-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ padding-top: 20px;
+ }
+ .field-label {
+ display: block;
+ margin-bottom: 9px;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--app-ink);
+ }
+ .input {
+ width: 100%;
+ background: var(--app-field);
+ color: var(--color-ink);
+ border-radius: 0;
+ padding: 9px 11px;
+ font-size: 12px;
+ line-height: 1.7;
+ }
+ .input::placeholder {
+ color: var(--app-muted);
+ }
+ .input:disabled {
+ background: var(--app-subtle);
+ opacity: 0.6;
+ }
+ .alert-error {
+ background: var(--app-subtle);
+ color: var(--app-danger);
+ }
+ .content-with-history {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 207px;
+ min-height: 480px;
+ overflow: hidden;
+ }
+ .image-history {
+ overflow: hidden;
+ }
+ .image-history-item .asset-image-empty {
+ font-size: 9px;
+ padding: 6px;
+ gap: 4px;
+ }
+ .image-history-item .asset-image-empty svg {
+ width: 14px;
+ height: 14px;
+ }
+ .reference-placeholder {
+ display: grid;
+ place-items: center;
+ aspect-ratio: 4 / 3;
+ font-size: 11px;
+ color: var(--color-muted);
+ background: var(--app-subtle);
+ }
+ .detail-grid {
+ display: grid;
+ grid-template-columns: 70px minmax(0, 1fr);
+ gap: 15px;
+ font-size: 12px;
+ line-height: 1.9;
+ }
+ .detail-grid dt {
+ color: var(--color-muted);
+ }
+ .detail-grid dd {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ }
+ .subject-ref {
+ display: inline-flex;
+ background: var(--app-subtle);
+ padding: 3px 6px;
+ border-radius: 0;
+ color: var(--app-ink);
+ font-size: 10px;
+ }
+}
+/* 中等宽度保留正文空间,执行记录下移,移动端导航收为图标。 */
+@media (max-width: 1200px) {
+ .content-with-history {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+@media (max-width: 760px) {
+ .project-table td,
+ .project-table th {
+ padding-inline: 14px;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/src/testing/styles.ts b/src/testing/styles.ts
new file mode 100644
index 0000000..f67fefe
--- /dev/null
+++ b/src/testing/styles.ts
@@ -0,0 +1,239 @@
+import { readFileSync, readdirSync } from 'node:fs'
+
+const SPACE: Record = {
+ '0': '0',
+ px: '1px',
+ '0.5': '2px',
+ '1': '4px',
+ '1.5': '6px',
+ '2': '8px',
+ '2.5': '10px',
+ '3': '12px',
+ '3.5': '14px',
+ '4': '16px',
+ '5': '20px',
+ '6': '24px',
+ '7': '28px',
+ '8': '32px',
+ '9': '36px',
+ '10': '40px',
+ '11': '44px',
+ '12': '48px',
+ '14': '56px'
+}
+
+const STATIC: Record = {
+ flex: 'display: flex;',
+ grid: 'display: grid;',
+ block: 'display: block;',
+ hidden: 'display: none;',
+ contents: 'display: contents;',
+ 'inline-flex': 'display: inline-flex;',
+ 'flex-col': 'flex-direction: column;',
+ 'flex-wrap': 'flex-wrap: wrap;',
+ 'items-center': 'align-items: center;',
+ 'items-end': 'align-items: end;',
+ 'items-start': 'align-items: flex-start;',
+ 'items-stretch': 'align-items: stretch;',
+ 'self-start': 'align-self: start;',
+ 'justify-center': 'justify-content: center;',
+ 'justify-start': 'justify-content: flex-start;',
+ 'justify-between': 'justify-content: space-between;',
+ 'justify-self-end': 'justify-self: end;',
+ 'shrink-0': 'flex-shrink: 0;',
+ 'flex-1': 'flex: 1;',
+ 'overflow-hidden': 'overflow: hidden;',
+ 'min-h-0': 'min-height: 0;',
+ 'min-w-0': 'min-width: 0;',
+ 'w-full': 'width: 100%;',
+ 'w-max': 'width: max-content;',
+ 'h-full': 'height: 100%;',
+ 'h-auto': 'height: auto;',
+ 'h-dvh': 'height: 100dvh;',
+ 'max-w-full': 'max-width: 100%;',
+ 'max-h-none': 'max-height: none;',
+ 'whitespace-nowrap': 'white-space: nowrap;',
+ 'whitespace-normal': 'white-space: normal;',
+ 'whitespace-pre-wrap': 'white-space: pre-wrap;',
+ 'text-center': 'text-align: center;',
+ 'text-left': 'text-align: left;',
+ 'wrap-anywhere': 'overflow-wrap: anywhere;',
+ 'rounded-none': 'border-radius: 0;',
+ relative: 'position: relative;',
+ sticky: 'position: sticky;',
+ fixed: 'position: fixed;',
+ 'top-0': 'top: 0;',
+ 'left-0': 'left: 0;',
+ 'right-0': 'right: 0;',
+ 'bottom-0': 'bottom: 0;',
+ 'inset-0': 'inset: 0;',
+ 'list-none': 'list-style: none;',
+ 'place-items-center': 'place-items: center;',
+ 'place-content-center': 'place-content: center;',
+ 'object-contain': 'object-fit: contain;',
+ 'overscroll-contain': 'overscroll-behavior: contain;',
+ 'break-inside-avoid': 'break-inside: avoid;',
+ 'outline-none': 'outline: none;',
+ 'text-ellipsis': 'text-overflow: ellipsis;',
+ 'font-normal': 'font-weight: 400;',
+ 'font-medium': 'font-weight: 500;',
+ 'font-semibold': 'font-weight: 600;',
+ 'font-mono': 'font-family: ui-monospace, monospace;',
+ 'text-xs': 'font-size: 12px;',
+ 'text-sm': 'font-size: 14px;',
+ 'text-base': 'font-size: 16px;',
+ 'leading-normal': 'line-height: 1.5;',
+ 'm-0': 'margin: 0;',
+ 'p-0': 'padding: 0;',
+ 'border-0': 'border: 0;',
+ 'border-t-0': 'border-top: none;',
+ 'mx-auto': 'margin-inline: auto;',
+ 'mx-0': 'margin-inline: 0;',
+ 'my-0': 'margin-block: 0;',
+ 'ml-auto': 'margin-left: auto;',
+ 'px-0': 'padding-inline: 0;',
+ 'bg-transparent': 'background: transparent;',
+ 'order-0': 'order: 0;',
+ 'order-1': 'order: 1;',
+ 'order-2': 'order: 2;',
+ 'aspect-auto': 'aspect-ratio: auto;',
+ 'aspect-video': 'aspect-ratio: 16 / 9;',
+ 'rotate-180': 'transform: rotate(180deg);',
+ 'text-ink': 'color: var(--app-ink);',
+ 'text-muted': 'color: var(--app-muted);',
+ 'text-accent': 'color: var(--app-accent-text);',
+ 'bg-ink': 'background: var(--app-ink);',
+ 'z-10': 'z-index: 10;'
+}
+
+const PREFIX_PROP: Record = {
+ 'min-h': 'min-height',
+ 'min-w': 'min-width',
+ 'max-h': 'max-height',
+ 'max-w': 'max-width',
+ 'grid-cols': 'grid-template-columns',
+ 'grid-rows': 'grid-template-rows',
+ col: 'grid-column',
+ flex: 'flex',
+ w: 'width',
+ h: 'height',
+ p: 'padding',
+ px: 'padding-inline',
+ py: 'padding-block',
+ pt: 'padding-top',
+ pr: 'padding-right',
+ pb: 'padding-bottom',
+ pl: 'padding-left',
+ m: 'margin',
+ mx: 'margin-inline',
+ my: 'margin-block',
+ mt: 'margin-top',
+ mr: 'margin-right',
+ mb: 'margin-bottom',
+ ml: 'margin-left',
+ gap: 'gap',
+ 'gap-x': 'column-gap',
+ 'gap-y': 'row-gap',
+ text: 'font-size',
+ leading: 'line-height',
+ tracking: 'letter-spacing',
+ top: 'top',
+ right: 'right',
+ bottom: 'bottom',
+ left: 'left',
+ inset: 'inset',
+ z: 'z-index',
+ shadow: 'box-shadow',
+ outline: 'outline',
+ 'outline-offset': 'outline-offset',
+ bg: 'background',
+ columns: 'columns',
+ aspect: 'aspect-ratio'
+}
+
+const PREFIXES = Object.keys(PREFIX_PROP).toSorted((a, b) => b.length - a.length)
+
+/**
+ * 把单个 Tailwind 工具类还原成 CSS 声明,供样式契约测试对照。
+ * @param util 工具类名
+ * @returns CSS 声明,无法还原时返回空字符串
+ */
+function expandUtil(util: string): string {
+ if (util.startsWith('[') && util.endsWith(']')) {
+ const inner = util.slice(1, -1)
+ const colon = inner.indexOf(':')
+ if (colon > 0) return `${inner.slice(0, colon)}: ${inner.slice(colon + 1)};`
+ }
+ if (STATIC[util]) return STATIC[util]!
+ const paren = util.match(/^([a-z-]+)-\((--[\w-]+)\)$/)
+ if (paren) {
+ const prop = PREFIX_PROP[paren[1]!] ?? paren[1]
+ const cssVar = paren[2]!
+ if (prop === 'background') return `background: var(${cssVar});`
+ if (paren[1] === 'text') return `color: var(${cssVar});`
+ return `${prop}: var(${cssVar});`
+ }
+ const arb = util.match(/^(-?[a-z-]+)-\[(.+)\]$/)
+ if (arb) {
+ const name = arb[1]!
+ const raw = arb[2]!.replace(/_/g, ' ').replace(/,(?=\S)/g, ', ')
+ const prop = PREFIX_PROP[name.replace(/^-/, '')]
+ if (!prop) return `${name}: ${raw};`
+ if (name.startsWith('-')) return `${prop}: -${raw};`
+ return `${prop}: ${raw};`
+ }
+ for (const prefix of PREFIXES) {
+ if (!util.startsWith(`${prefix}-`)) continue
+ const key = util.slice(prefix.length + 1)
+ const px = SPACE[key]
+ const prop = PREFIX_PROP[prefix]
+ if (px !== undefined && prop) return `${prop}: ${px};`
+ }
+ return ''
+}
+
+/**
+ * 把 @apply 行展开成 CSS 声明,并补回测试仍在匹配的 padding/margin 简写。
+ * @param css 含 @apply 的样式源码
+ * @returns 展开后的样式文本
+ */
+function expandApply(css: string): string {
+ return css.replace(/@apply\s+([^;]+);/g, (_all, utils: string) => {
+ const decls = utils.trim().split(/\s+/).map(expandUtil).filter(Boolean)
+ const valueOf = (prefix: string) =>
+ decls
+ .find(item => item.startsWith(prefix))
+ ?.slice(prefix.length)
+ .replace(';', '')
+ .trim()
+ const paddingX = valueOf('padding-inline:')
+ const paddingY = valueOf('padding-block:')
+ const paddingT = valueOf('padding-top:')
+ const paddingB = valueOf('padding-bottom:')
+ if (paddingY && paddingX) decls.push(`padding: ${paddingY} ${paddingX};`)
+ else if (paddingT && paddingX && paddingB) decls.push(`padding: ${paddingT} ${paddingX} ${paddingB};`)
+ const marginX = valueOf('margin-inline:')
+ const marginT = valueOf('margin-top:')
+ const marginB = valueOf('margin-bottom:')
+ if (marginT && marginX && marginB) decls.push(`margin: ${marginT} ${marginX} ${marginB};`)
+ if (marginT && marginB) decls.push(`margin-block: ${marginT} ${marginB};`)
+ const gapY = valueOf('row-gap:')
+ const gapX = valueOf('column-gap:')
+ if (gapY && gapX) decls.push(`gap: ${gapY} ${gapX};`)
+ return decls.join(' ')
+ })
+}
+
+/**
+ * 读取全局 CSS 与全部组件内 style,供样式契约测试对照搬迁后的规则。
+ * @returns 拼接后的样式源码
+ */
+export function readAllStyles(): string {
+ const vueFiles = readdirSync('src', { recursive: true, encoding: 'utf8' }).filter(path =>
+ String(path).endsWith('.vue')
+ )
+ const raw = ['src/styles/styles.css', 'src/styles/admin.css', ...vueFiles.map(path => `src/${path}`)]
+ .map(path => readFileSync(path, 'utf8'))
+ .join('\n')
+ return expandApply(raw)
+}
diff --git a/src/components/ui/ActionMenu.test.ts b/tests/components/ui/ActionMenu.test.ts
similarity index 90%
rename from src/components/ui/ActionMenu.test.ts
rename to tests/components/ui/ActionMenu.test.ts
index 90f9bb8..dc2b9dc 100644
--- a/src/components/ui/ActionMenu.test.ts
+++ b/tests/components/ui/ActionMenu.test.ts
@@ -1,12 +1,12 @@
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'
+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(() => {
diff --git a/src/components/ui/AssetImage.test.ts b/tests/components/ui/AssetImage.test.ts
similarity index 98%
rename from src/components/ui/AssetImage.test.ts
rename to tests/components/ui/AssetImage.test.ts
index cfe53cf..c0c6e5f 100644
--- a/src/components/ui/AssetImage.test.ts
+++ b/tests/components/ui/AssetImage.test.ts
@@ -1,7 +1,7 @@
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'
+import AssetImage from '@/components/ui/AssetImage.vue'
let wrapper: VueWrapper | undefined
afterEach(() => {
diff --git a/src/components/ui/DirectoryItem.test.ts b/tests/components/ui/DirectoryItem.test.ts
similarity index 91%
rename from src/components/ui/DirectoryItem.test.ts
rename to tests/components/ui/DirectoryItem.test.ts
index c0a5fed..dd4ed1b 100644
--- a/src/components/ui/DirectoryItem.test.ts
+++ b/tests/components/ui/DirectoryItem.test.ts
@@ -1,8 +1,8 @@
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'
+import DirectoryItem from '@/components/ui/DirectoryItem.vue'
+import { readAllStyles } from '@/testing/styles'
let wrapper: VueWrapper | undefined
afterEach(() => wrapper?.unmount())
@@ -53,7 +53,7 @@ describe('统一目录条目', () => {
it('显式保护最小高度、留白、换行和窄屏横向条目,避免旧按钮样式重新挤压目录', () => {
// DOM 环境不计算视觉几何;这些约束不能替代浏览器验收。
- const css = readFileSync('src/admin.css', 'utf8')
+ 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/)
@@ -68,7 +68,7 @@ describe('统一目录条目', () => {
it('侧栏与主体、镜头、剧集目录统一直角满行,保留文字内边距和移动端滚动', () => {
// happy-dom 无法计算伪元素几何,保护实际控制选中背景宽度的 CSS 规则。
- const css = readFileSync('src/admin.css', 'utf8')
+ 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/
)
@@ -76,18 +76,16 @@ describe('统一目录条目', () => {
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(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 = readFileSync('src/admin.css', 'utf8')
+ 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(
diff --git a/src/components/ui/flatTheme.test.ts b/tests/components/ui/flatTheme.test.ts
similarity index 91%
rename from src/components/ui/flatTheme.test.ts
rename to tests/components/ui/flatTheme.test.ts
index 82d6d96..44d6874 100644
--- a/src/components/ui/flatTheme.test.ts
+++ b/tests/components/ui/flatTheme.test.ts
@@ -15,7 +15,8 @@ import {
NTabs
} from 'naive-ui'
import { readFileSync, readdirSync } from 'node:fs'
-import { useTheme } from '../../composables/useTheme'
+import { useTheme } from '@/composables/useTheme'
+import { readAllStyles } from '@/testing/styles'
let wrapper: VueWrapper | undefined
afterEach(() => {
@@ -132,7 +133,7 @@ describe('全站平面主题', () => {
it('普通按钮只在键盘焦点可见时显示外框,不用失焦或禁止聚焦隐藏点击反馈', () => {
// DOM 环境不模拟真实鼠标/键盘焦点判定,保护共享 CSS 与触发器契约。
- const css = readFileSync('src/admin.css', 'utf8')
+ const css = readAllStyles()
expect(css).toMatch(
/\.n-button:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--app-accent-text\);[^}]*outline-offset:\s*2px;/
)
@@ -143,7 +144,7 @@ describe('全站平面主题', () => {
it('图库筛选吸顶,关联内容横向单行滚动,不给头部和图片区制造双重滚动边界', () => {
// 静态保护吸顶和横向尺寸规则;真实视口滚动与位置仍需浏览器验收。
- const css = readFileSync('src/admin.css', 'utf8')
+ 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\);/
)
@@ -166,7 +167,7 @@ describe('全站平面主题', () => {
})
it('瀑布流自适应列宽,卡片保持完整并按封面尺寸预留高度', () => {
- const css = readFileSync('src/admin.css', 'utf8')
+ const css = readAllStyles()
expect(css).toMatch(
/\.gallery-workspace-page \.form-image-masonry\s*\{[^}]*display:\s*block;[^}]*columns:\s*260px;[^}]*column-gap:\s*20px;/
)
@@ -174,15 +175,15 @@ describe('全站平面主题', () => {
/\.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\);/
+ /\.form-image-masonry \.asset-image\s*\{[^}]*aspect-ratio:\s*var\(--form-image-aspect,\s*4\s*\/\s*3\);/
)
})
it('图库顶部统一镜头选择和筛选的背景与垂直对齐,窄屏整组换行', () => {
// DOM 环境不计算几何;保护容器宽度断点和居中契约,真实坐标仍需浏览器验收。
- const css = readFileSync('src/admin.css', 'utf8')
+ const css = readAllStyles()
expect(css).toMatch(
- /\.form-image-filter-region\s*\{[^}]*padding:\s*14px 16px;[^}]*background:\s*var\(--app-subtle\);/
+ /\.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(
@@ -192,12 +193,14 @@ describe('全站平面主题', () => {
/@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;/)
+ 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 = readFileSync('src/admin.css', 'utf8')
+ 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/
)
@@ -221,23 +224,22 @@ describe('全站平面主题', () => {
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)\)/)
+ 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(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(
+ 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.css', 'utf8')
+ const css = readFileSync('src/styles/styles.css', 'utf8')
expect(css).toMatch(
/\.record-list > article\s*\{[^}]*padding:\s*20px;[^}]*transition:\s*background-color 160ms ease;/
)
diff --git a/src/components/ui/layout.test.ts b/tests/components/ui/layout.test.ts
similarity index 76%
rename from src/components/ui/layout.test.ts
rename to tests/components/ui/layout.test.ts
index 5f04da1..470de4f 100644
--- a/src/components/ui/layout.test.ts
+++ b/tests/components/ui/layout.test.ts
@@ -2,17 +2,18 @@ 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 { 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 { drawerPanel } from '@/testing/naive'
+import { readAllStyles } from '@/testing/styles'
let wrapper: VueWrapper | undefined
afterEach(() => {
@@ -26,7 +27,7 @@ afterEach(() => {
describe('管理后台组件边界', () => {
it('Tabs suffix 与标签共用导航行,窄屏换行但不产生外层滚动', () => {
// DOM 测试不计算坐标;保护对齐尺寸与容器断点,视觉验收仍需浏览器。
- const css = readFileSync('src/admin.css', 'utf8')
+ 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;/)
@@ -36,31 +37,32 @@ describe('管理后台组件边界', () => {
it('滚动边界样式包含 document 锁定和工作区收缩约束', () => {
// happy-dom 不计算视口几何;这里只保护 CSS 契约,真实滚动仍需浏览器验收。
- const adminCss = readFileSync('src/admin.css', 'utf8')
+ 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 = readFileSync('src/admin.css', 'utf8') + readFileSync('src/styles.css', 'utf8')
+ const css = readAllStyles()
expect(css).not.toMatch(/overflow(?:-[xy])?\s*:\s*(?:auto|scroll)\b/)
})
it('历史缩略图尺寸在非分层样式中覆盖 Naive 按钮,原图不能撑大预览或横向条目', () => {
// happy-dom 不计算几何;专门保护此前失效的层叠和固定尺寸约束。
- const css = readFileSync('src/admin.css', 'utf8')
+ const css = readFileSync('src/styles/admin.css', 'utf8')
expect(css).not.toContain('@layer')
- expect(css).toMatch(
+ 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(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(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(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/)
+ 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 = readFileSync('src/admin.css', 'utf8')
+ 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/
@@ -74,7 +76,7 @@ describe('管理后台组件边界', () => {
})
it('拆解分栏跟随剩余高度收缩,移动端历史区不挤出结果区', () => {
// DOM 环境不计算几何,保护完整的 flex → grid → NScrollbar 高度链。
- const css = readFileSync('src/admin.css', 'utf8')
+ 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(
@@ -149,6 +151,13 @@ describe('管理后台组件边界', () => {
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="后端连接"]')
@@ -159,14 +168,17 @@ describe('管理后台组件边界', () => {
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/
- )
+ 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).toMatch(/\.admin-sider:not\([^)]*\)\s*\{[^}]*bottom:\s*0;[^}]*height:\s*auto/)
+ 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('剧集 {
}
})
+ 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,
@@ -298,7 +361,7 @@ describe('管理后台组件边界', () => {
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(
+ expect(readAllStyles()).toMatch(
/\.production-pipeline-card > \.confirm-action\s*\{[^}]*margin-block:\s*16px 4px/
)
})
diff --git a/src/composables/usePolling.test.ts b/tests/composables/usePolling.test.ts
similarity index 98%
rename from src/composables/usePolling.test.ts
rename to tests/composables/usePolling.test.ts
index 5582b11..09aae0b 100644
--- a/src/composables/usePolling.test.ts
+++ b/tests/composables/usePolling.test.ts
@@ -1,6 +1,6 @@
import { effectScope, nextTick, ref } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { usePolling } from './usePolling'
+import { usePolling } from '@/composables/usePolling'
afterEach(() => vi.useRealTimers())
diff --git a/src/composables/useTheme.test.ts b/tests/composables/useTheme.test.ts
similarity index 98%
rename from src/composables/useTheme.test.ts
rename to tests/composables/useTheme.test.ts
index d16ce4b..0673cb8 100644
--- a/src/composables/useTheme.test.ts
+++ b/tests/composables/useTheme.test.ts
@@ -1,6 +1,6 @@
import { effectScope, nextTick, type EffectScope } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { useTheme } from './useTheme'
+import { useTheme } from '@/composables/useTheme'
import { readFileSync } from 'node:fs'
let scope: EffectScope | undefined
@@ -99,7 +99,7 @@ describe('微信风格明暗主题', () => {
it.each([false, true])('明暗模式 %s 的组件表面、语义色与业务 CSS 一致,主要文字保持可读', dark => {
const state = setup(dark)
const { common, Menu, Button, Layout, Input, Select } = state.overrides.value
- const css = readFileSync('src/admin.css', 'utf8')
+ const css = readFileSync('src/styles/admin.css', 'utf8')
const lightTokens = css.match(/:root\s*\{([^}]+)\}/)![1]!
const darkTokens = css.match(/:root\[data-theme='dark'\]\s*\{([^}]+)\}/)![1]!
const tokens = Object.fromEntries(
diff --git a/src/features/breakdown/BreakdownPage.test.ts b/tests/features/breakdown/BreakdownPage.test.ts
similarity index 95%
rename from src/features/breakdown/BreakdownPage.test.ts
rename to tests/features/breakdown/BreakdownPage.test.ts
index 52d78f2..585fb30 100644
--- a/src/features/breakdown/BreakdownPage.test.ts
+++ b/tests/features/breakdown/BreakdownPage.test.ts
@@ -3,12 +3,13 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NSelect } from 'naive-ui'
import { readFileSync } from 'node:fs'
-import BreakdownPage from './BreakdownPage.vue'
-import { projectContextKey, type useProjectData } from '../projects/context'
-import type { ProjectDetail } from '../projects/types'
-import type { Checkpoint } from '../workflows/types'
-import type { BreakdownModule, EpisodePlan } from './types'
-import { drawerPanel, selectMenu } from '../../testing/naive'
+import BreakdownPage from '@/features/breakdown/BreakdownPage.vue'
+import { projectContextKey, type useProjectData } from '@/features/projects/context'
+import type { ProjectDetail } from '@/features/projects/types'
+import type { Checkpoint } from '@/features/workflows/types'
+import type { BreakdownModule, EpisodePlan } from '@/features/breakdown/types'
+import { drawerPanel, selectMenu } from '@/testing/naive'
+import { readAllStyles } from '@/testing/styles'
let wrapper: VueWrapper | undefined
afterEach(() => {
@@ -176,10 +177,11 @@ describe('拆解页内容滚动', () => {
it('拆解配置按抽屉宽度换行,单位不收缩,三类控件对齐且说明位于控件下方', () => {
// DOM 环境不计算坐标,检查统一标题偏移、34px 控件行和容器断点契约。
- const css = readFileSync('src/admin.css', 'utf8')
+ const css = readAllStyles()
expect(css).toMatch(/\.breakdown-settings-panel\s*\{\s*container:\s*breakdown-settings \/ inline-size;/)
expect(css).toMatch(/\.breakdown-config\s*\{[^}]*--breakdown-label-offset:\s*28px;/)
- expect(css).toMatch(/\.breakdown-config \.field-label\s*\{[^}]*margin:\s*0 0 8px;[^}]*line-height:\s*20px;/)
+ expect(css).toMatch(/\.breakdown-config \.field-label\s*\{[^}]*margin(?:-bottom)?:\s*(?:0 0 )?8px/)
+ expect(css).toMatch(/\.breakdown-config \.field-label\s*\{[^}]*line-height:\s*20px/)
expect(css).toMatch(/\.breakdown-group-unit\s*\{[^}]*flex-shrink:\s*0;[^}]*white-space:\s*nowrap;/)
expect(css).toMatch(
/\.n-checkbox\.control-row-checkbox\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*34px;/
@@ -190,7 +192,7 @@ describe('拆解页内容滚动', () => {
expect(css).toContain('@container breakdown-settings (max-width: 780px)')
expect(css).toContain('@container breakdown-settings (max-width: 560px)')
expect(css).toContain('repeat(auto-fit, minmax(min(100%, 160px), 1fr))')
- expect(readFileSync('src/styles.css', 'utf8')).not.toContain('.breakdown-config')
+ expect(readFileSync('src/styles/styles.css', 'utf8')).not.toContain('.breakdown-config')
})
it.each(['人物', '场景', '道具'])('%s 使用共用固定斑马纹样式,搜索后保持列表条目结构', async label => {
@@ -222,7 +224,7 @@ describe('拆解页内容滚动', () => {
it('主体搜索与统计靠左居中,窄内容区可换行,输入框不再使用外置图标容器', () => {
// DOM 环境不计算布局,保护输入框宽度、主题统计色与窄屏换行的样式契约。
- const css = readFileSync('src/admin.css', 'utf8')
+ const css = readAllStyles()
expect(css).toMatch(
/\.subject-list-toolbar\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;[^}]*align-items:\s*center;[^}]*justify-content:\s*flex-start;/
)
@@ -230,7 +232,7 @@ describe('拆解页内容滚动', () => {
/\.subject-list-search\.n-input\s*\{[^}]*flex:\s*0 1 260px;[^}]*min-width:\s*0;[^}]*max-width:\s*100%;/
)
expect(css).toMatch(/\.subject-list-count\s*\{[^}]*color:\s*var\(--app-muted\);/)
- expect(css + readFileSync('src/styles.css', 'utf8')).not.toContain('.search-field')
+ expect(css + readFileSync('src/styles/styles.css', 'utf8')).not.toContain('.search-field')
})
it.each([
@@ -277,7 +279,7 @@ describe('拆解页内容滚动', () => {
it('形态块在两种主体条纹上采用相反灰阶,以间距分组且没有整块悬停态', () => {
// DOM 环境不提供真实主题绘制,检查奇偶行底色、内边距和状态选择器契约。
- const css = readFileSync('src/styles.css', 'utf8')
+ const css = readAllStyles()
expect(css).toMatch(
/\.subject-form-card\s*\{[^}]*margin-top:\s*12px;[^}]*padding:\s*16px;[^}]*background:\s*var\(--app-subtle\);/
)
@@ -289,12 +291,12 @@ describe('拆解页内容滚动', () => {
it('主体奇数行常驻原悬停底色,偶数行不变,整行不再随鼠标或焦点变色', () => {
// DOM 环境无法模拟浏览器 :hover 命中;检查静态底色与无整行交互选择器的契约。
- const css = readFileSync('src/styles.css', 'utf8')
+ const css = readAllStyles()
expect(css).toMatch(/\.record-list > article:nth-child\(even\)\s*\{\s*background:\s*var\(--app-subtle\);/)
expect(css).toMatch(
/\.subject-record-list > article:nth-child\(odd\)\s*\{\s*background:\s*var\(--app-control\);/
)
- expect(css + readFileSync('src/admin.css', 'utf8')).not.toMatch(
+ expect(css + readFileSync('src/styles/admin.css', 'utf8')).not.toMatch(
/\.subject-record-list[^{}]*(?::hover|:focus-within)/
)
// 仅移除整行变色,不影响内部链接、折叠等控件的键盘焦点提示。
@@ -352,7 +354,7 @@ describe('拆解页内容滚动', () => {
it('分镜顶部无叠加操作行,选择标签保持单行且统计操作垂直居中', () => {
// 仅保护样式契约,DOM 环境不提供真实布局坐标。
- const css = readFileSync('src/admin.css', 'utf8')
+ const css = readAllStyles()
expect(css).toMatch(/\.breakdown-storyboard\s*\{[^}]*padding:\s*16px 20px 20px;/)
expect(css).toMatch(/\.breakdown-episode-picker > span\s*\{[^}]*flex-shrink:\s*0;[^}]*white-space:\s*nowrap;/)
expect(css).toMatch(/\.breakdown-storyboard-summary\s*\{[^}]*align-items:\s*center;/)
diff --git a/src/features/create-drama/EpisodeReader.test.ts b/tests/features/create-drama/EpisodeReader.test.ts
similarity index 97%
rename from src/features/create-drama/EpisodeReader.test.ts
rename to tests/features/create-drama/EpisodeReader.test.ts
index 27aa2ab..bc8d158 100644
--- a/src/features/create-drama/EpisodeReader.test.ts
+++ b/tests/features/create-drama/EpisodeReader.test.ts
@@ -1,9 +1,9 @@
import { mount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { readFileSync } from 'node:fs'
-import EpisodeReader from './EpisodeReader.vue'
-import type { Episode } from '../projects/types'
+import EpisodeReader from '@/features/create-drama/EpisodeReader.vue'
+import { readAllStyles } from '@/testing/styles'
+import type { Episode } from '@/features/projects/types'
const episodes: Episode[] = [
{ episode: 1, title: '来信', summary: '第一集摘要', content: '第一场:旧书店。', conflict: '信件失踪' },
@@ -64,7 +64,7 @@ function currentEpisode() {
describe('连续剧本阅读器', () => {
it('正文容器靠左并保留目录间距,宽屏不再使用自动外边距居中', () => {
// DOM 测试不计算布局;保护正文定位样式,目录联动仍由后续交互测试验证。
- const css = readFileSync('src/admin.css', 'utf8')
+ const css = readAllStyles()
const content = css.match(/\.reader-content\s*\{([^}]+)\}/)![1]!
expect(content).toContain('max-width: 900px')
expect(content).toContain('margin-inline: 0')
diff --git a/src/features/production/capabilities.test.ts b/tests/features/production/capabilities.test.ts
similarity index 93%
rename from src/features/production/capabilities.test.ts
rename to tests/features/production/capabilities.test.ts
index 2617dd4..c8308e8 100644
--- a/src/features/production/capabilities.test.ts
+++ b/tests/features/production/capabilities.test.ts
@@ -1,16 +1,16 @@
import { defineComponent } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { projectContextKey } from '../projects/context'
-import { testProjectContext } from '../../testing/project-context'
-import { getOperation } from '../workflows/operations'
-import { formFixture } from '../subject-images/testing/fixtures'
-import { directionsResult } from '../storyboard/testing/fixtures'
-import { checkPipeline, useAdvancedProduction } from './useAdvancedProduction'
-import { useProduction } from './useProduction'
-import { getProductionSession } from './model'
-import AdvancedProduction from './components/AdvancedProduction.vue'
-import { expandSections } from '../../testing/naive'
+import { projectContextKey } from '@/features/projects/context'
+import { testProjectContext } from '@/testing/project-context'
+import { getOperation } from '@/features/workflows/operations'
+import { formFixture } from '@/features/subject-images/testing/fixtures'
+import { directionsResult } from '@/features/storyboard/testing/fixtures'
+import { checkPipeline, useAdvancedProduction } from '@/features/production/useAdvancedProduction'
+import { useProduction } from '@/features/production/useProduction'
+import { getProductionSession } from '@/features/production/model'
+import AdvancedProduction from '@/features/production/components/AdvancedProduction.vue'
+import { expandSections } from '@/testing/naive'
let wrapper: VueWrapper | undefined
/** 从实际挂载的确认弹窗查找操作按钮。 */
diff --git a/src/features/production/production.test.ts b/tests/features/production/production.test.ts
similarity index 94%
rename from src/features/production/production.test.ts
rename to tests/features/production/production.test.ts
index 1b7a67e..f03e7eb 100644
--- a/src/features/production/production.test.ts
+++ b/tests/features/production/production.test.ts
@@ -1,8 +1,8 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { mediaAssetUrl } from '../../lib/assets'
-import KeyframeDialog from './components/KeyframeDialog.vue'
-import { productionApi } from './api'
+import { mediaAssetUrl } from '@/lib/assets'
+import KeyframeDialog from '@/features/production/components/KeyframeDialog.vue'
+import { productionApi } from '@/features/production/api'
import {
isActiveVideo,
issueLabel,
@@ -10,8 +10,8 @@ import {
primaryVideo,
productionStatusLabel,
validOptionalSize
-} from './model'
-import { keyframeFixture, videoFixture } from './testing/fixtures'
+} from '@/features/production/model'
+import { keyframeFixture, videoFixture } from '@/features/production/testing/fixtures'
let wrapper: VueWrapper | undefined
afterEach(() => {
diff --git a/src/features/production/quality.test.ts b/tests/features/production/quality.test.ts
similarity index 96%
rename from src/features/production/quality.test.ts
rename to tests/features/production/quality.test.ts
index b562f13..a24b6c0 100644
--- a/src/features/production/quality.test.ts
+++ b/tests/features/production/quality.test.ts
@@ -2,9 +2,9 @@ import { defineComponent, reactive, ref } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NCheckbox, NInputNumber, NRadioGroup } from 'naive-ui'
-import { testProjectContext } from '../../testing/project-context'
-import { productionApi } from './api'
-import { qualityApi } from './quality-api'
+import { testProjectContext } from '@/testing/project-context'
+import { productionApi } from '@/features/production/api'
+import { qualityApi } from '@/features/production/quality-api'
import {
allowedTextLines,
qualityKey,
@@ -13,14 +13,14 @@ import {
savedVideoValidation,
videoRepairInfo,
validQualityInput
-} from './quality'
-import type { KeyframeReadiness } from './types'
-import type { QualityInput, QualityTarget } from './quality.types'
-import { useQuality } from './useQuality'
-import { keyframeFixture, videoFixture } from './testing/fixtures'
-import QualityDialog from './components/QualityDialog.vue'
-import QualityResult from './components/QualityResult.vue'
-import ModelCapabilities from './components/ModelCapabilities.vue'
+} from '@/features/production/quality'
+import type { KeyframeReadiness } from '@/features/production/types'
+import type { QualityInput, QualityTarget } from '@/features/production/quality.types'
+import { useQuality } from '@/features/production/useQuality'
+import { keyframeFixture, videoFixture } from '@/features/production/testing/fixtures'
+import QualityDialog from '@/features/production/components/QualityDialog.vue'
+import QualityResult from '@/features/production/components/QualityResult.vue'
+import ModelCapabilities from '@/features/production/components/ModelCapabilities.vue'
let wrapper: VueWrapper | undefined
let sequence = 0
diff --git a/src/features/projects/access.test.ts b/tests/features/projects/access.test.ts
similarity index 95%
rename from src/features/projects/access.test.ts
rename to tests/features/projects/access.test.ts
index 541dfba..7f49116 100644
--- a/src/features/projects/access.test.ts
+++ b/tests/features/projects/access.test.ts
@@ -3,11 +3,12 @@ import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
-import App from '../../App.vue'
-import ProjectLayout from './ProjectLayout.vue'
-import { isProjectComplete } from './access'
-import { projectsApi } from './api'
-import type { ProjectDetail, ProjectStatus } from './types'
+import App from '@/App.vue'
+import ProjectLayout from '@/features/projects/ProjectLayout.vue'
+import { isProjectComplete } from '@/features/projects/access'
+import { projectsApi } from '@/features/projects/api'
+import type { ProjectDetail, ProjectStatus } from '@/features/projects/types'
+import { readAllStyles } from '@/testing/styles'
const downstream = ['breakdown', 'visual-style', 'subject-identity', 'subject-images', 'storyboard', 'production']
let wrapper: VueWrapper | undefined
@@ -195,12 +196,14 @@ describe('剧本完成前的下游访问限制', () => {
it('配置复选框居中对齐,输入表面随面板背景分层而非增加边框', () => {
// 保护布局契约;实际像素对齐仍需浏览器视觉验收。
- const css = readFileSync('src/admin.css', 'utf8')
+ const css = readAllStyles()
expect(css).toMatch(
/\.n-checkbox\.control-row-checkbox\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*34px;[^}]*padding-block:\s*0/
)
expect(css).toMatch(/\.app-dialog\s*\{[^}]*--app-field:\s*var\(--app-control\)/)
- expect(readFileSync('src/styles.css', 'utf8')).toMatch(/\.panel\s*\{[^}]*--app-field:\s*var\(--app-control\)/)
+ expect(readFileSync('src/styles/styles.css', 'utf8')).toMatch(
+ /\.panel\s*\{[^}]*--app-field:\s*var\(--app-control\)/
+ )
for (const path of [
'production/ProductionPage.vue',
'storyboard/StoryboardPage.vue',
diff --git a/src/features/storyboard/directory.test.ts b/tests/features/storyboard/directory.test.ts
similarity index 84%
rename from src/features/storyboard/directory.test.ts
rename to tests/features/storyboard/directory.test.ts
index edcaa2a..b45f7fc 100644
--- a/src/features/storyboard/directory.test.ts
+++ b/tests/features/storyboard/directory.test.ts
@@ -1,8 +1,8 @@
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'
+import BeatShotDirectory from '@/features/storyboard/components/BeatShotDirectory.vue'
+import { readAllStyles } from '@/testing/styles'
+import { designedShot } from '@/features/storyboard/testing/fixtures'
let wrapper: VueWrapper | undefined
afterEach(() => wrapper?.unmount())
@@ -50,13 +50,14 @@ describe('Beat 两级镜头目录', () => {
it('组内吸顶与移动端归属提示分别保护,不在桌面重复展示 Beat', () => {
// happy-dom 不计算吸顶位置,保护 CSS 边界,实际滚动仍需浏览器验收。
- const css = readFileSync('src/admin.css', 'utf8')
+ const css = readAllStyles()
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/)
+ expect(css).toMatch(/@media \(max-width: 760px\)[\s\S]*\.directory-mobile-beat\s*\{\s*display:\s*inline/)
+ expect(css).toMatch(
+ /@media \(max-width: 760px\)[\s\S]*\.beat-directory-group,\s*\.beat-directory-items\s*\{\s*display:\s*contents/
+ )
})
})
diff --git a/src/features/storyboard/model.test.ts b/tests/features/storyboard/model.test.ts
similarity index 91%
rename from src/features/storyboard/model.test.ts
rename to tests/features/storyboard/model.test.ts
index 7793461..ad44acf 100644
--- a/src/features/storyboard/model.test.ts
+++ b/tests/features/storyboard/model.test.ts
@@ -1,6 +1,16 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { directionLabel, mergeDesignedShots, referenceImageUrl, storyboardPrerequisites } from './model'
-import { directionsResult, episodeShots, storyboardCheckpoint, visualStatesResult } from './testing/fixtures'
+import {
+ directionLabel,
+ mergeDesignedShots,
+ referenceImageUrl,
+ storyboardPrerequisites
+} from '@/features/storyboard/model'
+import {
+ directionsResult,
+ episodeShots,
+ storyboardCheckpoint,
+ visualStatesResult
+} from '@/features/storyboard/testing/fixtures'
afterEach(() => vi.unstubAllEnvs())
diff --git a/src/features/subject-identity/identity.test.ts b/tests/features/subject-identity/identity.test.ts
similarity index 96%
rename from src/features/subject-identity/identity.test.ts
rename to tests/features/subject-identity/identity.test.ts
index 3b671b8..b7524a2 100644
--- a/src/features/subject-identity/identity.test.ts
+++ b/tests/features/subject-identity/identity.test.ts
@@ -1,11 +1,11 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NImage, NScrollbar } from 'naive-ui'
-import { AssetImage } from '../../components/ui'
-import IdentityImageDialog from './components/IdentityImageDialog.vue'
-import IdentityGallery from './components/IdentityGallery.vue'
-import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
-import { subjectIdentityApi } from './api'
+import { AssetImage } from '@/components/ui'
+import IdentityImageDialog from '@/features/subject-identity/components/IdentityImageDialog.vue'
+import IdentityGallery from '@/features/subject-identity/components/IdentityGallery.vue'
+import CastingCandidateDialog from '@/features/subject-identity/components/CastingCandidateDialog.vue'
+import { subjectIdentityApi } from '@/features/subject-identity/api'
import {
canBeAnchor,
castingStatusLabel,
@@ -13,10 +13,10 @@ import {
groupIdentitySubjects,
mergeCastingSubjects,
readImageProvenance
-} from './model'
-import { identityImageFixture } from './testing/fixtures'
-import { formFixture } from '../subject-images/testing/fixtures'
-import { expandSections, selectControl } from '../../testing/naive'
+} from '@/features/subject-identity/model'
+import { identityImageFixture } from '@/features/subject-identity/testing/fixtures'
+import { formFixture } from '@/features/subject-images/testing/fixtures'
+import { expandSections, selectControl } from '@/testing/naive'
let wrapper: VueWrapper | undefined
afterEach(() => {
diff --git a/src/features/subject-identity/thumbnail.test.ts b/tests/features/subject-identity/thumbnail.test.ts
similarity index 97%
rename from src/features/subject-identity/thumbnail.test.ts
rename to tests/features/subject-identity/thumbnail.test.ts
index c7abf97..188646d 100644
--- a/src/features/subject-identity/thumbnail.test.ts
+++ b/tests/features/subject-identity/thumbnail.test.ts
@@ -1,8 +1,8 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NImage } from 'naive-ui'
-import IdentityThumbnail from './components/IdentityThumbnail.vue'
-import { identityFixture, identityImageFixture } from './testing/fixtures'
+import IdentityThumbnail from '@/features/subject-identity/components/IdentityThumbnail.vue'
+import { identityFixture, identityImageFixture } from '@/features/subject-identity/testing/fixtures'
let wrapper: VueWrapper | undefined
let observers: {
diff --git a/src/features/subject-images/asset-impact.test.ts b/tests/features/subject-images/asset-impact.test.ts
similarity index 97%
rename from src/features/subject-images/asset-impact.test.ts
rename to tests/features/subject-images/asset-impact.test.ts
index c269ee0..a21c15d 100644
--- a/src/features/subject-images/asset-impact.test.ts
+++ b/tests/features/subject-images/asset-impact.test.ts
@@ -2,16 +2,16 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NSelect } from 'naive-ui'
-import { testProjectContext } from '../../testing/project-context'
-import { projectContextKey } from '../projects/context'
-import { formFixture, imageFixture } from './testing/fixtures'
-import { referenceLocation, referenceTargets } from '../production/asset-links'
-import StaleKeyframeNotice from '../production/components/StaleKeyframeNotice.vue'
-import SubjectImagesPage from './SubjectImagesPage.vue'
-import ProductionPage from '../production/ProductionPage.vue'
-import { directionsResult, storyboardCheckpoint } from '../storyboard/testing/fixtures'
-import type { ProductionIssue } from '../production/types'
-import type { ShotReferences } from '../storyboard/types'
+import { testProjectContext } from '@/testing/project-context'
+import { projectContextKey } from '@/features/projects/context'
+import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
+import { referenceLocation, referenceTargets } from '@/features/production/asset-links'
+import StaleKeyframeNotice from '@/features/production/components/StaleKeyframeNotice.vue'
+import SubjectImagesPage from '@/features/subject-images/SubjectImagesPage.vue'
+import ProductionPage from '@/features/production/ProductionPage.vue'
+import { directionsResult, storyboardCheckpoint } from '@/features/storyboard/testing/fixtures'
+import type { ProductionIssue } from '@/features/production/types'
+import type { ShotReferences } from '@/features/storyboard/types'
let wrapper: VueWrapper | undefined
afterEach(() => {
diff --git a/src/features/subject-images/images.test.ts b/tests/features/subject-images/images.test.ts
similarity index 95%
rename from src/features/subject-images/images.test.ts
rename to tests/features/subject-images/images.test.ts
index a684e30..c1115b2 100644
--- a/src/features/subject-images/images.test.ts
+++ b/tests/features/subject-images/images.test.ts
@@ -1,12 +1,12 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { AssetImage } from '../../components/ui'
-import { getOperation } from '../workflows/operations'
-import GenerateImageDialog from './components/GenerateImageDialog.vue'
-import ImageGalleryDialog from './components/ImageGalleryDialog.vue'
-import { coverImage, hasRunningImages, primaryImage, validImageSize } from './model'
-import { formFixture, imageFixture } from './testing/fixtures'
-import { expandSections } from '../../testing/naive'
+import { AssetImage } from '@/components/ui'
+import { getOperation } from '@/features/workflows/operations'
+import GenerateImageDialog from '@/features/subject-images/components/GenerateImageDialog.vue'
+import ImageGalleryDialog from '@/features/subject-images/components/ImageGalleryDialog.vue'
+import { coverImage, hasRunningImages, primaryImage, validImageSize } from '@/features/subject-images/model'
+import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
+import { expandSections } from '@/testing/naive'
let wrapper: VueWrapper | undefined
/** Naive 将弹窗挂载到 body,需要从实际弹窗找到按钮。 */
diff --git a/src/features/subject-images/layout.test.ts b/tests/features/subject-images/layout.test.ts
similarity index 87%
rename from src/features/subject-images/layout.test.ts
rename to tests/features/subject-images/layout.test.ts
index 8e06e47..23afe68 100644
--- a/src/features/subject-images/layout.test.ts
+++ b/tests/features/subject-images/layout.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
-import { formCoverAspectRatio } from './layout'
-import { formFixture, imageFixture } from './testing/fixtures'
+import { formCoverAspectRatio } from '@/features/subject-images/layout'
+import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
describe('图库瀑布流图片比例', () => {
it.each([
diff --git a/src/features/subject-images/prompts.test.ts b/tests/features/subject-images/prompts.test.ts
similarity index 93%
rename from src/features/subject-images/prompts.test.ts
rename to tests/features/subject-images/prompts.test.ts
index 0992601..56d3377 100644
--- a/src/features/subject-images/prompts.test.ts
+++ b/tests/features/subject-images/prompts.test.ts
@@ -1,14 +1,14 @@
import { defineComponent } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { projectContextKey } from '../projects/context'
-import { testProjectContext } from '../../testing/project-context'
-import { getOperation } from '../workflows/operations'
-import { formFixture, imageFixture } from './testing/fixtures'
-import { currentIdentityAnchorId, getImageSession, isPrimaryIdentityStale } from './model'
-import { useSubjectImages } from './useSubjectImages'
-import FormPromptDialog from './components/FormPromptDialog.vue'
-import GenerateImageDialog from './components/GenerateImageDialog.vue'
+import { projectContextKey } from '@/features/projects/context'
+import { testProjectContext } from '@/testing/project-context'
+import { getOperation } from '@/features/workflows/operations'
+import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
+import { currentIdentityAnchorId, getImageSession, isPrimaryIdentityStale } from '@/features/subject-images/model'
+import { useSubjectImages } from '@/features/subject-images/useSubjectImages'
+import FormPromptDialog from '@/features/subject-images/components/FormPromptDialog.vue'
+import GenerateImageDialog from '@/features/subject-images/components/GenerateImageDialog.vue'
let wrapper: VueWrapper | undefined
const projectId = 'capability-test'
diff --git a/src/features/workflows/diagnostics.test.ts b/tests/features/workflows/diagnostics.test.ts
similarity index 97%
rename from src/features/workflows/diagnostics.test.ts
rename to tests/features/workflows/diagnostics.test.ts
index 5d0bee0..abf1228 100644
--- a/src/features/workflows/diagnostics.test.ts
+++ b/tests/features/workflows/diagnostics.test.ts
@@ -1,8 +1,8 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import WorkflowDiagnosticsDialog from './WorkflowDiagnosticsDialog.vue'
-import { loadWorkflowDiagnostics, type WorkflowTimelineGroup } from './diagnostics'
-import type { Checkpoint } from './types'
+import WorkflowDiagnosticsDialog from '@/features/workflows/WorkflowDiagnosticsDialog.vue'
+import { loadWorkflowDiagnostics, type WorkflowTimelineGroup } from '@/features/workflows/diagnostics'
+import type { Checkpoint } from '@/features/workflows/types'
let wrapper: VueWrapper | undefined
afterEach(() => {
diff --git a/src/features/workflows/operations.test.ts b/tests/features/workflows/operations.test.ts
similarity index 91%
rename from src/features/workflows/operations.test.ts
rename to tests/features/workflows/operations.test.ts
index 2bd0c58..e1c7b1c 100644
--- a/src/features/workflows/operations.test.ts
+++ b/tests/features/workflows/operations.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
-import { getOperation, runOperation } from './operations'
+import { getOperation, runOperation } from '@/features/workflows/operations'
describe('项目级长请求互斥', () => {
it('同一项目不同时执行两个 graph,错误不伪装成功', async () => {
diff --git a/src/features/workflows/pages.test.ts b/tests/features/workflows/pages.test.ts
similarity index 97%
rename from src/features/workflows/pages.test.ts
rename to tests/features/workflows/pages.test.ts
index a1ca29e..03907b4 100644
--- a/src/features/workflows/pages.test.ts
+++ b/tests/features/workflows/pages.test.ts
@@ -3,34 +3,34 @@ import { NImage, NRadioGroup, NScrollbar } from 'naive-ui'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import CreateProjectDialog from '../projects/components/CreateProjectDialog.vue'
-import BreakdownPage from '../breakdown/BreakdownPage.vue'
-import CreateDramaPage from '../create-drama/CreateDramaPage.vue'
-import ProjectsPage from '../projects/ProjectsPage.vue'
-import { projectContextKey, type useProjectData } from '../projects/context'
-import type { ProjectDetail } from '../projects/types'
-import type { Checkpoint } from './types'
-import StoryboardPage from '../storyboard/StoryboardPage.vue'
-import ShotTools from '../storyboard/components/ShotTools.vue'
+import CreateProjectDialog from '@/features/projects/components/CreateProjectDialog.vue'
+import BreakdownPage from '@/features/breakdown/BreakdownPage.vue'
+import CreateDramaPage from '@/features/create-drama/CreateDramaPage.vue'
+import ProjectsPage from '@/features/projects/ProjectsPage.vue'
+import { projectContextKey, type useProjectData } from '@/features/projects/context'
+import type { ProjectDetail } from '@/features/projects/types'
+import type { Checkpoint } from '@/features/workflows/types'
+import StoryboardPage from '@/features/storyboard/StoryboardPage.vue'
+import ShotTools from '@/features/storyboard/components/ShotTools.vue'
import {
designedShot,
directionsResult,
storyboardCheckpoint,
visualStatesResult
-} from '../storyboard/testing/fixtures'
-import { getStoryboardSession } from '../storyboard/model'
-import { getOperation } from './operations'
-import SubjectImagesPage from '../subject-images/SubjectImagesPage.vue'
-import { formFixture, imageFixture } from '../subject-images/testing/fixtures'
-import { getImageSession } from '../subject-images/model'
-import VisualStylePage from '../visual-style/VisualStylePage.vue'
-import SubjectIdentityPage from '../subject-identity/SubjectIdentityPage.vue'
-import { styleFixture, styleImageFixture } from '../visual-style/testing/fixtures'
-import { identityFixture, identityImageFixture } from '../subject-identity/testing/fixtures'
-import { getIdentitySession } from '../subject-identity/model'
-import { drawerPanel, expandSections, selectControl, selectMenu } from '../../testing/naive'
-import ProductionPage from '../production/ProductionPage.vue'
-import { getProductionSession } from '../production/model'
+} from '@/features/storyboard/testing/fixtures'
+import { getStoryboardSession } from '@/features/storyboard/model'
+import { getOperation } from '@/features/workflows/operations'
+import SubjectImagesPage from '@/features/subject-images/SubjectImagesPage.vue'
+import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
+import { getImageSession } from '@/features/subject-images/model'
+import VisualStylePage from '@/features/visual-style/VisualStylePage.vue'
+import SubjectIdentityPage from '@/features/subject-identity/SubjectIdentityPage.vue'
+import { styleFixture, styleImageFixture } from '@/features/visual-style/testing/fixtures'
+import { identityFixture, identityImageFixture } from '@/features/subject-identity/testing/fixtures'
+import { getIdentitySession } from '@/features/subject-identity/model'
+import { drawerPanel, expandSections, selectControl, selectMenu } from '@/testing/naive'
+import ProductionPage from '@/features/production/ProductionPage.vue'
+import { getProductionSession } from '@/features/production/model'
/** 新资产页面复用已有项目上下文和内存路由,不接入真实模型。 */
async function mountAssets(page: 'style' | 'identity', query = '') {
diff --git a/src/features/workflows/selectors.test.ts b/tests/features/workflows/selectors.test.ts
similarity index 94%
rename from src/features/workflows/selectors.test.ts
rename to tests/features/workflows/selectors.test.ts
index 63e5642..f35407d 100644
--- a/src/features/workflows/selectors.test.ts
+++ b/tests/features/workflows/selectors.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
-import { breakdownSnapshot, recoveryOptions, workflowCheckpoints } from './selectors'
-import type { Checkpoint } from './types'
-import type { BreakdownState } from '../breakdown/types'
+import { breakdownSnapshot, recoveryOptions, workflowCheckpoints } from '@/features/workflows/selectors'
+import type { Checkpoint } from '@/features/workflows/types'
+import type { BreakdownState } from '@/features/breakdown/types'
/** 只构造测试所需的真实 checkpoint 字段,避免页面依赖虚构 DTO。 */
function checkpoint(index: number, state: BreakdownState, workflowName = 'breakdown'): Checkpoint {
diff --git a/src/lib/http.test.ts b/tests/lib/http.test.ts
similarity index 96%
rename from src/lib/http.test.ts
rename to tests/lib/http.test.ts
index 1562377..6513c10 100644
--- a/src/lib/http.test.ts
+++ b/tests/lib/http.test.ts
@@ -1,9 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { ApiError, optionalResource, request } from './http'
-import { projectsApi } from '../features/projects/api'
-import { breakdownApi } from '../features/breakdown/api'
-import { storyboardApi } from '../features/storyboard/api'
-import { subjectImagesApi } from '../features/subject-images/api'
+import { ApiError, optionalResource, request } from '@/lib/http'
+import { projectsApi } from '@/features/projects/api'
+import { breakdownApi } from '@/features/breakdown/api'
+import { storyboardApi } from '@/features/storyboard/api'
+import { subjectImagesApi } from '@/features/subject-images/api'
afterEach(() => {
vi.unstubAllGlobals()
diff --git a/tsconfig.json b/tsconfig.json
index 0fe055b..ff21647 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -13,7 +13,10 @@
"resolveJsonModule": true,
"skipLibCheck": true,
"noEmit": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ },
"types": ["vite/client", "node"]
},
- "include": ["src/**/*.ts", "src/**/*.vue", "*.config.ts"]
+ "include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts", "*.config.ts"]
}
diff --git a/vite.config.ts b/vite.config.ts
index f6a7606..27a1e75 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,3 +1,4 @@
+import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
@@ -7,6 +8,7 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [vue(), tailwindcss()],
+ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
server: {
port: 5173,
proxy: {
diff --git a/vitest.config.ts b/vitest.config.ts
index 6f0ddc6..44acb0d 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,5 +1,10 @@
+import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
/** 使用轻量 DOM 测试 API、异步状态和 Vue 页面行为。 */
-export default defineConfig({ plugins: [vue()], test: { environment: 'happy-dom', restoreMocks: true } })
+export default defineConfig({
+ plugins: [vue()],
+ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
+ test: { environment: 'happy-dom', restoreMocks: true }
+})