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 } +})