feat: 收口组件样式并调整移动端侧栏

This commit is contained in:
GJ
2026-09-04 17:19:34 +08:00
parent d252d5513f
commit e6e0f08072
68 changed files with 2717 additions and 2775 deletions
+1
View File
@@ -6,3 +6,4 @@ coverage
.eslintcache
*.tsbuildinfo
.DS_Store
.worktree/
+5 -3
View File
@@ -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 `<style>`,主题变量、base、Naive 覆盖和跨页原子 class 留在 `src/styles/styles.css` / `src/styles/admin.css`
- Naive UI:布局、菜单、表格、选择器、输入框、按钮、弹窗、标签、折叠面板、进度和时间线
- Oxlint:脚本质量检查;ESLint:补充 Vue 模板语义
- Oxfmt(不是 `oxformat`):沿用后端四空格、单引号、无分号、无尾逗号的风格
- Vitest + Vue Test Utils + happy-domAPI 契约、组件交互和异步生命周期测试
- Vitest + Vue Test Utils + happy-domAPI 契约、组件交互和异步生命周期测试`*.test.ts` 放在 `tests/`,按 `src` 镜像,不和组件放在一起
- Lefthook + Commitlint:提交前执行检查,提交信息使用 `feat:``fix:``refactor:`
```bash
@@ -89,6 +89,8 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| `src/composables` | 具备取消和竞态保护的轮询、系统主题与偏好持久化 |
| `src/lib` | HTTP、错误格式、日期和导出工具 |
| `src/router` | 按 graph 拆分的懒加载路由 |
| `src/styles` | 全局主题、base、Naive 覆盖和跨页原子 class |
| `tests` | 与 `src` 镜像的 `*.test.ts`,不和组件源码放在一起 |
每个功能模块通过 `index.ts` 暴露公共入口。业务类型和行为函数均添加中文职责注释。不要把下一条 graph 的服务继续堆进 `App.vue`
@@ -126,7 +128,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
- 拆解设置中“每组集数”和“抽取模块”使用相同标题间距;输入框、复选框与预览按钮对齐到 34px 控件行,模块说明独立放在下方。“集 / 组”保持单行,窄抽屉按实际内容宽度将操作和完整模块分组换行,校验与预览逻辑不变。
- 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。
布局样式集中在 `src/admin.css`,业务排版保留在 `src/styles.css`。新增页面应使用 `WorkspacePage`(内容优先页开启 `compact`),低频操作使用 `WorkspaceTools`;避免通过 `body``window.scrollTo` 管理滚动。
布局样式集中在 `src/styles/admin.css`,业务排版保留在 `src/styles/styles.css`。新增页面应使用 `WorkspacePage`(内容优先页开启 `compact`),低频操作使用 `WorkspaceTools`;避免通过 `body``window.scrollTo` 管理滚动。
本次 UI 重构保留前端 `dev@5551653` 的业务更新,包括过期形态图、主首帧和主视频的提示与更新入口。首帧默认只补当前剧集;存在过期主首帧时优先更新全项目过期项。非覆盖模式下的过期主视频重建完成后,会接替旧主视频;普通新增候选不自动替换主资产。
+184 -23
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, h, provide, ref, shallowRef } from 'vue'
import { computed, h, onScopeDispose, provide, ref, shallowRef, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import {
NButton,
@@ -7,7 +7,6 @@ import {
NGlobalStyle,
NLayout,
NLayoutHeader,
NLayoutSider,
NMenu,
NScrollbar,
NTooltip,
@@ -33,10 +32,61 @@ import { useTheme } from './composables/useTheme'
import ThemeToggle from './components/ui/ThemeToggle.vue'
import { projectAccessKey, type ProjectAccess } from './features/projects/access'
/** 窄屏与桌面共用同一侧栏;窄屏展开时主栏右移,宽度保持折叠时的尺寸。 */
const NARROW_NAV = '(max-width: 800px)'
/** 折叠侧栏宽度,与菜单 collapsed-width 对齐。 */
const SIDER_COLLAPSED_WIDTH = 64
/** 展开侧栏宽度,窄屏裁切动画以内层保持该宽度。 */
const SIDER_EXPANDED_WIDTH = 208
/** 菜单图标尺寸,折叠/展开共用,避免切换时缩放。 */
const NAV_ICON_SIZE = 20
/** 折叠与展开共用的图标左偏移,避免切换时图标跳动。 */
const NAV_ICON_INDENT = SIDER_COLLAPSED_WIDTH / 2 - NAV_ICON_SIZE / 2
/** 应用外壳固定在视口内,业务数据仍由各工作区自行加载。 */
const route = useRoute()
const collapsed = ref(window.matchMedia('(max-width: 800px)').matches)
const narrowMedia = window.matchMedia(NARROW_NAV)
const narrow = ref(narrowMedia.matches)
const collapsed = ref(narrow.value)
const settingsOpen = ref(false)
/** 窄屏折叠只裁切文字,不把菜单收成图标,避免推挤动画时错位。 */
const menuCollapsed = computed(() => !narrow.value && collapsed.value)
/** 视口跨过断点时:进入窄屏收回侧栏。 */
function syncNarrow(event: MediaQueryListEvent) {
narrow.value = event.matches
if (event.matches) collapsed.value = true
}
narrowMedia.addEventListener('change', syncNarrow)
onScopeDispose(() => narrowMedia.removeEventListener('change', syncNarrow))
/** 宽屏与窄屏都只切换这一份侧栏的展开态。 */
function toggleNav() {
collapsed.value = !collapsed.value
}
/** 打开后端连接说明;窄屏先收回侧栏给弹窗腾出宽度。 */
function openSettings() {
if (narrow.value) collapsed.value = true
settingsOpen.value = true
}
watch(
() => route.fullPath,
() => {
if (narrow.value) collapsed.value = true
}
)
/** Esc 收回窄屏展开的侧栏。 */
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && narrow.value && !collapsed.value) collapsed.value = true
}
window.addEventListener('keydown', onKeydown)
onScopeDispose(() => window.removeEventListener('keydown', onKeydown))
/** 顶栏切换按钮的可访问名称。 */
const navToggleLabel = computed(() => (collapsed.value ? '展开侧栏' : '折叠侧栏'))
const { preference, theme, overrides } = useTheme()
const projectId = computed(() => String(route.params.projectId || ''))
const projectAccess = shallowRef<ProjectAccess | null>(null)
@@ -91,18 +141,22 @@ const menuOptions = computed<MenuOption[]>(() => [
>
<NGlobalStyle />
<a href="#main-content" class="skip-link">跳到主要内容</a>
<NLayout has-sider class="admin-layout">
<NLayoutSider
collapse-mode="width"
:collapsed="collapsed"
:width="208"
:collapsed-width="64"
:native-scrollbar="false"
<div
class="admin-layout"
:style="{
'--admin-sider-collapsed': SIDER_COLLAPSED_WIDTH + 'px',
'--admin-sider-expanded': SIDER_EXPANDED_WIDTH + 'px'
}"
>
<!-- 以下是主导航侧栏宽屏与窄屏共用同一份 -->
<aside
class="admin-sider"
content-class="admin-sider-content"
:class="{ 'is-collapsed': collapsed }"
:style="{ width: (collapsed ? SIDER_COLLAPSED_WIDTH : SIDER_EXPANDED_WIDTH) + 'px' }"
aria-label="主导航"
>
<RouterLink to="/projects" class="admin-brand" aria-label="短剧工作台首页">
<Clapperboard :size="24" /><span v-if="!collapsed"
<Clapperboard :size="24" /><span v-if="!menuCollapsed"
>短剧工作台<small>SHORT DRAMA STUDIO</small></span
>
</RouterLink>
@@ -110,9 +164,12 @@ const menuOptions = computed<MenuOption[]>(() => [
<NMenu
:value="route.path"
:options="menuOptions"
:collapsed="collapsed"
:collapsed-width="64"
:collapsed-icon-size="19"
:collapsed="menuCollapsed"
:collapsed-width="SIDER_COLLAPSED_WIDTH"
:icon-size="NAV_ICON_SIZE"
:collapsed-icon-size="NAV_ICON_SIZE"
:indent="NAV_ICON_INDENT"
:root-indent="NAV_ICON_INDENT"
/>
</NScrollbar>
<footer class="admin-sider-footer">
@@ -122,27 +179,29 @@ const menuOptions = computed<MenuOption[]>(() => [
quaternary
block
class="admin-settings-button"
:class="{ 'is-collapsed': collapsed, 'icon-button': collapsed }"
:class="{ 'is-collapsed': menuCollapsed }"
aria-label="后端连接"
@click="settingsOpen = true"
@click="openSettings"
>
<template #icon><Settings2 :size="18" /></template>
<span v-if="!collapsed">后端连接</span>
<span v-if="!menuCollapsed">后端连接</span>
</NButton>
</template>
后端连接
</NTooltip>
</footer>
</NLayoutSider>
</aside>
<!-- 以下是顶栏与主内容 -->
<NLayout class="admin-main">
<NLayoutHeader class="admin-topbar">
<div class="flex min-w-0 items-center gap-3">
<NButton
quaternary
class="icon-button"
:aria-label="collapsed ? '展开侧栏' : '折叠侧栏'"
:title="collapsed ? '展开侧栏' : '折叠侧栏'"
@click="collapsed = !collapsed"
:aria-label="navToggleLabel"
:title="navToggleLabel"
:aria-expanded="!collapsed"
@click="toggleNav"
>
<template #icon
><PanelLeftOpen v-if="collapsed" :size="18" /><PanelLeftClose v-else :size="18"
@@ -154,7 +213,7 @@ const menuOptions = computed<MenuOption[]>(() => [
</NLayoutHeader>
<main id="main-content" tabindex="-1" class="admin-content"><RouterView /></main>
</NLayout>
</NLayout>
</div>
<AppDialog
v-model:open="settingsOpen"
title="后端连接"
@@ -175,3 +234,105 @@ const menuOptions = computed<MenuOption[]>(() => [
</AppDialog>
</NConfigProvider>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "styles/styles.css";
.skip-link {
@apply fixed z-[100] -top-[80px] left-[15px] py-2.5 px-[15px] bg-(--app-surface) border-[1px_solid_var(--color-accent)];
}
.skip-link:focus {
@apply top-2.5 z-[3000];
}
.admin-layout {
--admin-sider-collapsed: 64px;
--admin-sider-expanded: 208px;
@apply flex h-dvh overflow-hidden;
}
.admin-main {
@apply min-w-0 h-full flex-1;
}
.admin-main > .n-layout-scroll-container {
@apply h-full flex flex-col overflow-hidden;
}
.admin-sider {
@apply flex flex-col h-full w-[208px] shrink-0 overflow-hidden bg-(--app-subtle);
}
.admin-sider.is-collapsed {
@apply w-16;
}
.admin-nav-scroll.n-scrollbar {
@apply flex-1 min-h-0;
}
.admin-sider-footer {
@apply shrink-0;
padding-block: 8px max(8px, env(safe-area-inset-bottom));
}
.n-button.admin-settings-button {
@apply justify-start h-11 px-5;
}
.admin-sider .n-menu .n-menu-item-content::before {
@apply left-0 right-0 rounded-none;
}
.admin-brand {
@apply flex shrink-0 items-center gap-3 h-[74px] py-0 px-5 text-ink whitespace-nowrap;
}
.admin-brand > svg {
@apply shrink-0;
}
.admin-brand span {
@apply text-[15px] font-semibold;
}
.admin-brand small {
@apply block text-[8px] tracking-[1px] font-normal text-muted mt-[3px];
}
.admin-topbar {
@apply relative z-10 flex items-center justify-between shrink-0 h-11 py-0 px-5 gap-3 bg-(--app-surface);
}
.admin-sider .n-menu-divider {
@apply h-3 bg-transparent;
}
.admin-content {
@apply flex-1 min-h-0 min-w-0 overflow-hidden outline-none;
}
@media (max-width: 800px) {
.admin-sider {
overflow: hidden;
transition: width 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}
/* 主栏宽度锁在折叠态剩余空间,侧栏变宽时把主栏推到右侧而不是压窄。 */
.admin-layout > .admin-main {
flex: 0 0 auto;
width: calc(100% - var(--admin-sider-collapsed));
min-width: calc(100% - var(--admin-sider-collapsed));
max-width: calc(100% - var(--admin-sider-collapsed));
}
.admin-brand,
.admin-nav-scroll.n-scrollbar,
.admin-sider-footer {
min-width: var(--admin-sider-expanded);
}
.admin-brand span,
.admin-sider .n-menu-item-content-header,
.admin-settings-button span {
opacity: 1;
transition: opacity 0.2s ease;
}
.admin-sider.is-collapsed .admin-brand span,
.admin-sider.is-collapsed .n-menu-item-content-header,
.admin-sider.is-collapsed .admin-settings-button span {
opacity: 0;
}
.admin-topbar {
@apply py-0 px-2.5;
}
}
@media (prefers-reduced-motion: reduce) {
.admin-sider,
.admin-brand span,
.admin-sider .n-menu-item-content-header,
.admin-settings-button span {
transition: none;
}
}
</style>
-1763
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -9,3 +9,21 @@ import { EmptyState } from './ui'
>
</section>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../styles/styles.css";
.page-container {
@apply max-w-[1540px] mx-auto pt-[37px] px-10 pb-14;
}
@media (max-width: 1200px) {
.page-container {
@apply px-[26px];
}
}
@media (max-width: 760px) {
.page-container {
@apply pt-[25px] px-[18px] pb-10;
}
}
</style>
+21
View File
@@ -24,3 +24,24 @@ const open = defineModel<boolean>('open', { default: false })
</NScrollbar>
</NModal>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.app-dialog {
--app-field: var(--app-control);
--app-field-hover: var(--app-control-hover);
}
.app-dialog {
@apply max-w-[calc(100vw-32px)] max-h-[calc(100dvh-32px)];
}
.app-dialog .n-card-header {
@apply shrink-0;
}
.dialog-body-scroll {
@apply max-h-[calc(100dvh-154px)];
}
.app-dialog .n-card__content {
@apply min-h-0;
}
</style>
+23
View File
@@ -50,3 +50,26 @@ watch(
>
</div>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.asset-image {
@apply grid place-items-center w-full aspect-[4/3] overflow-hidden bg-(--app-subtle);
}
.asset-image img {
@apply w-full h-full min-h-0;
}
.asset-image-empty {
@apply flex items-center justify-center flex-col gap-2.5 p-5 text-muted text-[11px] text-center;
}
.asset-image .n-image {
@apply w-full h-full min-h-0 min-w-0 flex justify-center;
}
.asset-image .n-image img {
@apply w-full h-full;
}
.asset-image.asset-image-preview {
@apply h-[min(42dvh,420px)] min-h-0 aspect-auto;
}
</style>
+11
View File
@@ -21,3 +21,14 @@ function toggle() {
<div class="detail-disclosure-content"><slot /></div></NCollapseItem
></NCollapse>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.detail-disclosure.n-collapse {
@apply text-xs text-muted;
}
.detail-disclosure-content {
@apply py-3 px-3.5 bg-(--app-subtle) text-ink leading-[1.8];
}
</style>
+43
View File
@@ -21,3 +21,46 @@ defineProps<{ active: boolean }>()
</span>
</NButton>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.n-button.directory-item {
@apply flex shrink-0 w-full h-auto min-h-[88px] py-3 px-3.5 rounded-none whitespace-normal text-left;
}
.n-button.directory-item .n-button__content {
@apply block w-full min-w-0;
}
.directory-item-body {
@apply flex flex-col items-start gap-[7px] min-w-0;
}
.n-button.directory-item.has-leading .n-button__content {
@apply flex items-center gap-3;
}
.directory-item-leading {
@apply flex shrink-0;
}
.directory-item.has-leading .directory-item-body {
@apply flex-1;
}
.directory-item-eyebrow {
@apply font-mono text-[11px] leading-normal text-muted wrap-anywhere;
}
.directory-item-title {
@apply text-[13px] leading-[1.7] font-medium text-ink wrap-anywhere;
}
.directory-item-meta {
@apply flex items-center flex-wrap gap-y-1.5 gap-x-2 text-muted text-[11px] leading-[1.7] wrap-anywhere;
}
.n-button.directory-item.selected {
@apply bg-(--app-selected) shadow-[inset_3px_0_var(--app-accent)];
}
.n-button.directory-item:focus-visible {
@apply outline-[2px_solid_var(--app-accent)] outline-offset-[-2px];
}
@media (max-width: 760px) {
.n-button.directory-item {
@apply w-[228px] min-h-[104px];
}
}
</style>
+11
View File
@@ -13,3 +13,14 @@ defineProps<{ title: string; description: string }>()
></template>
</NEmpty>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.empty-state {
@apply flex flex-col items-center justify-center min-h-[300px] py-[52px] px-6 text-center;
}
.empty-state {
@apply min-h-[200px] py-10 px-5;
}
</style>
+8
View File
@@ -34,3 +34,11 @@ function selectTheme(key: string | number) {
</NButton>
</NDropdown>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.n-button.theme-toggle {
@apply shrink-0 w-[34px] h-[34px];
}
</style>
+69
View File
@@ -12,3 +12,72 @@ defineProps<{ split?: boolean; compact?: boolean }>()
<NScrollbar v-else class="workspace-scroll" content-class="workspace-scroll-content"><slot /></NScrollbar>
</section>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.workspace-page {
@apply relative h-full flex flex-col min-h-0 overflow-hidden;
}
.workspace-heading-scroll.n-scrollbar {
@apply shrink-0 h-auto max-h-[min(35dvh,260px)] overflow-hidden;
}
.workspace-heading {
@apply pt-4 px-6 pb-3;
}
.content-first-workspace .workspace-heading {
@apply py-2 px-5;
}
.content-first-workspace .workspace-split {
@apply pt-0 px-5 pb-3 gap-2;
}
.content-first-workspace .workspace-scroll-content {
@apply pt-0 px-5 pb-4;
}
.workspace-heading h2 {
@apply text-base;
}
.workspace-heading .text-sm {
@apply text-xs;
}
.workspace-heading .production-controls {
@apply gap-3;
}
.workspace-heading .n-input-number {
@apply w-full;
}
.workspace-scroll {
@apply flex-1 min-h-0;
}
.workspace-scroll-content {
@apply pt-1 px-6 pb-6;
}
.workspace-scroll > .n-scrollbar-container {
@apply overscroll-contain;
}
.workspace-split {
@apply flex flex-col flex-1 min-h-0 overflow-hidden pt-1 px-6 pb-5 gap-3;
}
.workspace-split > .my-6 {
@apply m-0 shrink-0;
}
@media (max-width: 760px) {
.workspace-heading {
@apply p-3;
}
.content-first-workspace .workspace-heading {
@apply py-2 px-3;
}
.content-first-workspace .workspace-split,
.content-first-workspace .workspace-scroll-content {
@apply pt-0 px-3 pb-3;
}
.workspace-scroll-content,
.workspace-split {
@apply pt-0 px-3 pb-3;
}
.workspace-heading .production-controls {
@apply grid-cols-[minmax(100px,_1fr)_80px];
}
}
</style>
+42
View File
@@ -46,3 +46,45 @@ const open = defineModel<boolean>('open', { default: false })
</NDrawerContent>
</NDrawer>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.workspace-tools-drawer .n-drawer-header,
.workspace-tools-drawer .n-drawer-footer {
@apply bg-(--app-subtle);
}
.workspace-tools-drawer .n-drawer-body {
@apply bg-(--app-body);
}
.workspace-tools-drawer {
@apply max-w-full;
}
.workspace-tools-heading {
@apply flex items-center justify-between gap-4 w-full text-[15px];
}
.workspace-tools-body {
@apply p-5;
}
.workspace-tools-body .storyboard-controls,
.workspace-tools-body .production-controls {
@apply grid grid-cols-[repeat(auto-fit,_minmax(160px,_1fr))] gap-4 my-5;
}
.workspace-tools-body .storyboard-coverage {
@apply my-5;
}
.workspace-tools-body .generation-row {
@apply block;
}
.workspace-tools-body .generation-row > div + div {
@apply mt-3.5;
}
.workspace-tools-body .production-pipeline {
@apply grid-cols-[repeat(auto-fit,_minmax(min(100%,_230px),_1fr))];
}
@media (max-width: 760px) {
.workspace-tools-body {
@apply p-3.5;
}
}
</style>
+92
View File
@@ -497,3 +497,95 @@ function exportResult() {
</template>
</WorkspacePage>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.group-preview {
@apply mt-6 pt-5;
}
.group-chip {
@apply flex items-center gap-2.5 bg-(--app-subtle) rounded-none py-2 px-[11px] text-[11px];
}
.recovery-strip {
@apply bg-(--app-subtle) flex justify-between flex-wrap items-center gap-[15px] p-[17px] rounded-none;
}
.breakdown-settings-panel {
container: breakdown-settings / inline-size;
}
.breakdown-config {
@apply grid grid-cols-[180px_minmax(0,_1fr)_auto] items-start gap-y-4 gap-x-6;
--breakdown-label-offset: 28px;
}
.breakdown-config .field-label {
@apply mt-0 mx-0 mb-2 p-0 leading-[20px];
}
.breakdown-group-field,
.breakdown-modules-field,
.breakdown-module-option {
@apply min-w-0;
}
.breakdown-group-input {
@apply flex items-center gap-3;
}
.breakdown-group-number.n-input-number {
@apply flex-[0_0_120px] w-[120px];
}
.breakdown-group-unit {
@apply shrink-0 whitespace-nowrap text-muted text-sm;
}
.breakdown-modules-field {
@apply m-0 p-0 border-0;
}
.breakdown-module-options {
@apply grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-y-3 gap-x-4;
}
.breakdown-module-description {
@apply mt-1 mx-0 mb-0 pl-6 text-muted text-[11px] leading-[18px] wrap-anywhere;
}
.breakdown-preview-button.n-button {
@apply self-start mt-[var(--breakdown-label-offset)] whitespace-nowrap;
}
@container breakdown-settings (max-width: 780px) {
.breakdown-config {
@apply grid-cols-[180px_minmax(0,_1fr)];
}
.breakdown-preview-button.n-button {
@apply col-[2] justify-self-end mt-0;
}
}
@container breakdown-settings (max-width: 560px) {
.breakdown-config {
@apply grid-cols-[minmax(0,_1fr)];
}
.breakdown-module-options {
@apply grid-cols-[repeat(auto-fit,_minmax(min(100%,_160px),_1fr))];
}
.breakdown-preview-button.n-button {
@apply col-[1];
}
}
.result-toolbar .n-tabs-tab__label {
@apply inline-flex gap-1.5;
}
.breakdown-results-section {
@apply flex flex-col flex-1 min-h-0 min-w-0 gap-2;
}
.breakdown-results-section > .result-toolbar {
@apply shrink-0;
}
.breakdown-results-section > .content-with-history {
@apply flex-1 h-auto min-h-0 grid-rows-[minmax(0,_1fr)];
}
.breakdown-results-scroll .n-scrollbar-content {
@apply wrap-anywhere;
}
.breakdown-results-section > .content-with-history.history-hidden {
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[minmax(0,_1fr)];
}
@media (max-width: 760px) {
.breakdown-results-section > .content-with-history {
@apply grid-rows-[minmax(0,_1fr)_min(25%,_130px)];
}
}
</style>
@@ -106,3 +106,52 @@ const purposeLabels: Record<string, string> = {
/>
</div>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../../styles/styles.css";
.beat-section {
@apply mt-[25px] pt-[23px];
}
.beat-heading {
@apply flex items-center gap-2.5;
}
.beat-number {
@apply text-muted font-mono text-[11px];
}
.shot-row {
@apply flex gap-[17px] p-[17px] rounded-none bg-(--app-subtle);
}
.shot-label {
@apply shrink-0 w-[47px] text-[10px] text-muted;
}
@media (max-width: 760px) {
.shot-row {
@apply gap-3 p-[13px];
}
}
.breakdown-storyboard {
@apply pt-4 px-5 pb-5;
}
.breakdown-storyboard-toolbar {
@apply flex items-center justify-between flex-wrap gap-y-3 gap-x-6 mb-4;
}
.breakdown-episode-picker {
@apply flex items-center gap-3 flex-[0_1_420px] min-w-0 text-sm;
}
.breakdown-episode-picker > span {
@apply shrink-0 whitespace-nowrap;
}
.breakdown-episode-picker .n-select {
@apply flex-1 min-w-0;
}
.breakdown-storyboard-summary {
@apply flex items-center flex-wrap gap-y-2 gap-x-5 min-h-[34px] ml-auto;
}
.breakdown-storyboard-count {
@apply text-muted text-xs whitespace-nowrap;
}
.breakdown-storyboard-summary .text-button {
@apply min-h-[34px] whitespace-nowrap;
}
</style>
@@ -117,3 +117,35 @@ const filtered = computed(() =>
/>
</div>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../../styles/styles.css";
.subject-record-list > article:nth-child(odd) {
@apply bg-(--app-control);
}
.subject-form-card {
@apply mt-3 p-4 min-w-0 wrap-anywhere bg-(--app-subtle);
}
.subject-record-list > article:nth-child(even) .subject-form-card {
@apply bg-(--app-control);
}
.subject-details summary {
@apply inline-flex items-center gap-[5px] text-ink text-[11px] list-none;
}
.subject-details summary::-webkit-details-marker {
@apply hidden;
}
.subject-details[open] summary svg {
@apply rotate-180;
}
.subject-list-toolbar {
@apply flex flex-wrap items-center justify-start gap-y-2.5 gap-x-3 mb-5;
}
.subject-list-search.n-input {
@apply flex-[0_1_260px] min-w-0 max-w-full;
}
.subject-list-count {
@apply shrink-0 text-muted text-xs whitespace-nowrap;
}
</style>
@@ -249,3 +249,49 @@ function exportScript() {
</div></div
></WorkspacePage>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.stage-strip {
@apply flex flex-wrap items-center gap-3.5;
}
.stage-item {
@apply flex items-center gap-[7px] text-muted text-[11px];
}
.stage-item.done {
@apply text-ink;
}
.stage-arrow {
@apply ml-3 text-muted;
}
.json-view {
@apply whitespace-pre-wrap wrap-anywhere p-4 bg-(--app-subtle) rounded-none font-mono text-[11px] leading-[1.9];
}
@media (max-width: 760px) {
.stage-strip {
@apply gap-[9px];
}
.stage-arrow {
@apply ml-[3px];
}
}
.script-section {
@apply flex-1 flex flex-col min-h-0;
}
.script-section > :first-child {
@apply shrink-0;
}
.script-section .content-with-history {
@apply flex-1 h-auto min-h-0;
}
.script-workspace-page .workspace-split > :not(.script-section) {
@apply shrink-0;
}
.script-section .n-tabs-tab__label {
@apply inline-flex items-center gap-2;
}
.script-section .content-with-history.history-hidden {
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[minmax(0,_1fr)];
}
</style>
+103
View File
@@ -166,3 +166,106 @@ onBeforeUnmount(() => observer?.disconnect())
</div>
</div>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.script-summary {
@apply text-xs text-muted leading-[1.9] pt-[17px] px-0 pb-[22px] mb-[23px];
}
.script-body {
@apply whitespace-pre-wrap wrap-anywhere text-sm leading-[2.15] text-ink;
}
.script-notes {
@apply mt-9 pt-5 text-xs;
}
.script-notes dt {
@apply text-ink font-medium mb-1.5;
}
.script-notes dd {
@apply text-muted leading-[1.8] mb-[15px];
}
.episode-reader {
@apply grid grid-cols-[clamp(190px,_22%,_240px)_minmax(0,_1fr)] h-full min-h-0 min-w-0 overflow-hidden;
container-type: inline-size;
}
.reader-directory {
@apply flex flex-col min-h-0 min-w-0 bg-(--app-subtle);
}
.reader-directory-heading {
@apply flex justify-between items-center shrink-0 gap-2 pt-[22px] px-5 pb-3.5 text-[13px];
}
.reader-directory-heading h3 {
@apply font-semibold;
}
.reader-directory-heading span {
@apply text-[11px] text-muted whitespace-nowrap;
}
.reader-directory-scroll {
@apply flex-1;
}
.reader-directory-content {
@apply flex flex-col gap-1.5 pt-1 px-0 pb-[18px];
}
.n-button.reader-episode-link {
@apply shrink-0 w-full h-auto min-h-[64px] py-3 px-3.5 rounded-none text-muted whitespace-normal text-left;
}
.n-button.reader-episode-link .n-button__content {
@apply flex flex-col items-start gap-[5px] w-full min-w-0;
}
.reader-episode-label {
@apply text-[13px] font-semibold leading-normal;
}
.reader-episode-title {
@apply text-xs leading-[1.6] wrap-anywhere;
}
.n-button.reader-episode-link.active {
@apply bg-(--app-selected) text-accent shadow-[inset_3px_0_var(--app-accent)];
}
.reader-page {
@apply min-w-0 min-h-0 overflow-hidden bg-(--app-surface);
}
.reader-scroll .n-scrollbar-container:focus-visible {
@apply outline-[2px_solid_var(--app-accent)] outline-offset-[-3px];
}
.reader-content {
@apply mx-0 p-8 text-left;
/* 正文紧邻目录左对齐,保留阅读行宽,不在宽屏内容区居中。 */
max-width: 900px;
}
.reader-chapter + .reader-chapter {
@apply mt-12 pt-9;
}
.reader-chapter:last-child {
/* 末集较短时仍可定位到阅读线,不必把倒数第二集误认为当前集。 */
min-height: max(160px, calc(var(--reader-height) - 64px));
}
.reader-chapter-heading {
@apply mb-6;
}
.reader-chapter-heading p {
@apply text-muted text-[13px] font-medium mb-2.5;
}
.reader-chapter-heading h2 {
@apply text-[clamp(18px,2cqw,24px)] font-semibold leading-[1.6] wrap-anywhere;
}
@media (max-width: 1000px) {
.episode-reader {
@apply grid-cols-[180px_minmax(0,_1fr)];
}
}
@media (max-width: 600px) {
.episode-reader {
@apply grid-cols-[112px_minmax(0,_1fr)];
}
.reader-directory-heading {
@apply pt-4 px-3 pb-3 flex-wrap;
}
.n-button.reader-episode-link {
@apply py-3 px-2.5;
}
.reader-content {
@apply px-[18px];
}
}
</style>
@@ -619,3 +619,99 @@ watch(
/>
</WorkspacePage>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.production-controls {
@apply grid grid-cols-[minmax(220px,_1.6fr)_minmax(110px,_0.5fr)_minmax(230px,_1fr)_auto] items-end gap-[18px];
}
.production-pipeline {
@apply grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-3.5;
}
.production-pipeline-card {
@apply p-5;
}
.production-stats {
@apply grid grid-cols-[repeat(3,_1fr)] gap-2;
}
.production-stats div,
.production-status-grid div {
@apply p-2.5 rounded-none bg-(--app-subtle);
}
.production-stats dt,
.production-status-grid dt {
@apply text-muted text-[10px];
}
.production-stats dd,
.production-status-grid dd {
@apply mt-[5px] text-[13px] font-mono;
}
.production-status-grid {
@apply grid grid-cols-[repeat(5,_minmax(0,_1fr))] gap-2.5;
}
.production-workspace {
@apply grid grid-cols-[220px_minmax(0,_1fr)] items-start overflow-hidden;
}
.production-shot-list {
@apply max-h-[760px] overflow-hidden bg-(--app-surface) py-2;
}
@media (max-width: 1100px) {
.production-pipeline {
@apply grid-cols-[1fr];
}
.production-controls {
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
}
}
@media (max-width: 760px) {
.production-controls,
.production-workspace {
@apply grid-cols-[minmax(0,_1fr)];
}
.production-shot-list {
@apply flex max-h-none overflow-hidden;
border-right: none;
}
.production-status-grid {
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
}
}
.workspace-inline-status {
@apply flex items-center flex-wrap justify-between gap-y-2 gap-x-4 py-[7px] px-3 shrink-0 rounded-none bg-(--app-subtle) text-xs;
}
.production-pipeline-card > .confirm-action {
@apply mt-4 mb-1;
}
.production-workspace {
@apply flex-1 min-h-0 items-stretch overflow-hidden;
}
.production-workspace {
@apply grid-cols-[clamp(240px,_22%,_280px)_minmax(0,_1fr)];
}
.production-shot-list {
@apply max-h-none overflow-hidden min-h-0;
}
.production-workspace > article {
@apply overflow-hidden min-h-0 overscroll-contain;
}
.production-shot-list-content {
@apply gap-3 pt-0 px-0 pb-3;
}
.production-shot-list {
@apply p-0;
}
@media (max-width: 1000px) {
.production-controls {
@apply grid-cols-[minmax(130px,_1fr)_90px];
}
}
@media (max-width: 760px) {
.production-workspace {
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[184px_minmax(0,_1fr)];
}
.production-pipeline {
@apply grid-cols-[1fr];
}
}
</style>
@@ -259,3 +259,11 @@ async function submit() {
</p>
</AppDialog>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../../styles/styles.css";
.quality-fields {
@apply grid grid-cols-[repeat(auto-fit,_minmax(min(100%,_180px),_1fr))] items-start gap-4;
}
</style>
@@ -140,3 +140,17 @@ function exportResult() {
</NCollapse>
</section>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../../styles/styles.css";
.quality-result {
@apply mt-6 p-4 bg-(--app-subtle);
}
.quality-result-row {
@apply flex flex-wrap items-center gap-y-2 gap-x-3 mt-2.5 p-3 bg-(--app-control) text-xs wrap-anywhere;
}
.quality-json {
@apply mt-4 whitespace-pre-wrap wrap-anywhere text-[11px] leading-[1.7];
}
</style>
@@ -523,3 +523,46 @@ async function inspectSpecs() {
/>
</section>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../../styles/styles.css";
.production-stage {
@apply py-5 px-0;
}
.production-stage:first-of-type {
@apply border-t-0;
}
.production-stage-heading {
@apply flex items-start justify-between gap-4;
}
.keyframe-grid {
@apply grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-3;
}
.keyframe-card {
@apply overflow-hidden rounded-none bg-(--app-subtle);
}
.keyframe-card .asset-image {
@apply aspect-video rounded-none;
}
.video-record {
@apply flex overflow-hidden rounded-none bg-(--app-subtle);
}
.production-video {
@apply w-[min(44%,390px)] min-h-[170px] bg-ink object-contain;
}
@media (max-width: 760px) {
.keyframe-grid {
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
}
.video-record {
@apply block;
}
.production-video {
@apply w-full;
}
}
.production-video {
@apply bg-[#080808];
}
</style>
+47
View File
@@ -80,3 +80,50 @@ onScopeDispose(() => {
</div>
</section>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.project-frame {
@apply flex flex-col h-full min-h-0 overflow-hidden;
}
.project-header {
@apply shrink-0 py-[9px] px-5 bg-(--app-surface);
}
.project-header .n-page-header__main,
.project-header .n-page-header__title {
@apply min-w-0 overflow-hidden;
}
.project-title {
@apply max-w-full text-base font-semibold;
}
.project-notices.n-scrollbar {
@apply shrink-0 h-auto max-h-[100px] overflow-hidden;
}
.project-notices-content {
@apply py-2 px-6 grid gap-[5px];
}
.project-notices .n-alert {
@apply py-[7px] px-3 text-xs;
}
.project-view {
@apply flex-1 min-h-0 overflow-hidden;
}
.project-header .n-page-header-wrapper {
@apply min-w-0;
}
.project-access-gate {
@apply h-full;
}
.workspace-loading {
@apply grid place-content-center h-full;
}
@media (max-width: 760px) {
.project-header {
@apply py-2 px-3;
}
.project-title {
@apply text-sm;
}
}
</style>
+70
View File
@@ -140,3 +140,73 @@ function clearFilters() {
</p>
</WorkspacePage>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.page-heading {
@apply flex items-center justify-between gap-6 mb-7;
}
.page-heading h1 {
@apply mt-[9px] mx-0 mb-0 text-[27px] font-semibold tracking-[-0.035em] leading-[1.4];
}
.page-description {
@apply mt-[9px] text-muted text-xs leading-[1.7];
}
@media (max-width: 760px) {
.page-heading {
@apply items-start flex-col gap-4;
}
.page-heading h1 {
@apply text-[23px];
}
}
.project-table-area {
@apply flex-1 min-h-0;
}
.project-data-table {
@apply h-full;
}
.project-data-table .n-data-table-base-table {
@apply flex-1 min-h-0;
}
.project-index-heading {
@apply m-0;
}
.project-index-heading h1 {
@apply text-[22px] font-semibold;
}
.project-index-toolbar {
@apply flex items-center justify-between flex-wrap gap-3 mb-1;
}
.project-search {
@apply w-[240px];
}
.project-status-filters.n-radio-group {
@apply flex flex-wrap h-auto min-h-(--n-height) gap-y-1 gap-x-px;
}
.project-status-filters .n-radio-group__splitor {
@apply hidden;
}
.project-status-filters .n-radio-button {
@apply min-w-[76px] px-[18px] text-center;
}
.project-name-cell {
@apply block min-w-0;
}
.project-name-cell strong,
.project-name-cell small {
@apply block overflow-hidden text-ellipsis whitespace-nowrap;
}
.project-name-cell small {
@apply text-muted mt-1 text-[11px];
}
@media (max-width: 760px) {
.project-search {
@apply w-full;
}
.project-index-toolbar .n-radio-group {
@apply w-full;
}
}
</style>
@@ -418,3 +418,73 @@ watch(
></WorkspacePage
>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.storyboard-controls {
@apply grid grid-cols-[minmax(180px,_1fr)_100px_145px_auto] gap-[18px] items-end;
}
.generation-row {
@apply flex items-center justify-between gap-5 pt-5 mt-5;
}
.generation-row > div:last-child {
@apply shrink-0;
}
.storyboard-workspace {
@apply grid grid-cols-[220px_minmax(0,_1fr)] overflow-hidden;
}
.shot-list {
@apply max-h-[1000px] overflow-hidden bg-(--app-surface) pt-0 px-2 pb-5;
}
@media (max-width: 1200px) {
.generation-row {
@apply items-start flex-col gap-3;
}
.storyboard-controls {
@apply grid-cols-[minmax(0,_1fr)_minmax(0,_1fr)];
}
}
@media (max-width: 760px) {
.storyboard-controls {
@apply grid-cols-[minmax(0,_1fr)] gap-3;
}
.storyboard-workspace {
@apply grid-cols-[minmax(0,_1fr)];
}
.shot-list {
@apply max-h-[240px];
border-right: 0;
}
}
.storyboard-workspace-page .storyboard-workspace {
@apply flex-1 h-auto min-h-0 overflow-hidden;
}
.storyboard-coverage {
@apply shrink-0;
}
.storyboard-workspace {
@apply grid-cols-[clamp(240px,_22%,_280px)_minmax(0,_1fr)];
}
.storyboard-workspace {
@apply h-[clamp(360px,65dvh,850px)] min-h-0;
}
.storyboard-workspace > article,
.storyboard-workspace .shot-list {
@apply overflow-hidden min-h-0 h-full max-h-none;
}
.shot-list-content {
@apply gap-3 pt-0 px-0 pb-3;
}
.shot-list {
@apply p-0;
}
@media (max-width: 760px) {
.storyboard-workspace {
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[184px_minmax(0,_1fr)];
}
.storyboard-controls {
@apply grid-cols-[minmax(0,_1fr)_minmax(0,_1fr)];
}
}
</style>
@@ -55,3 +55,35 @@ const groups = computed(() => {
</div>
</div>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../../styles/styles.css";
.directory-group-heading {
@apply sticky top-0 z-[1] flex items-center justify-between gap-3 shrink-0 py-2.5 px-3.5 bg-(--app-control) font-mono font-semibold text-[11px] leading-[1.6] text-ink;
}
.directory-group-count {
@apply font-normal text-muted;
}
.beat-directory-group {
@apply min-w-0;
}
.beat-directory-items {
@apply flex flex-col;
}
.directory-mobile-beat {
@apply hidden;
}
@media (max-width: 760px) {
.directory-group-heading {
@apply hidden;
}
.beat-directory-group,
.beat-directory-items {
display: contents;
}
.directory-mobile-beat {
display: inline;
}
}
</style>
@@ -214,3 +214,20 @@ function exportResult(kind: 'spec' | 'prompt') {
</section>
</div>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../../styles/styles.css";
.alert {
@apply py-3 px-[15px] rounded-none bg-(--app-subtle) text-ink text-xs leading-[1.8] wrap-anywhere;
}
.reference-grid {
@apply grid grid-cols-[repeat(auto-fill,_minmax(150px,_1fr))] gap-3.5;
}
.reference-card {
@apply bg-(--app-subtle) rounded-none overflow-hidden;
}
.storyboard-json {
@apply p-4 rounded-none bg-(--app-subtle) font-mono text-[11px] leading-[1.9];
}
</style>
@@ -621,3 +621,117 @@ watch(
@generate="generateCastingCandidate"
/></WorkspacePage>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.identity-workspace {
@apply grid grid-cols-[240px_minmax(0,_1fr)] items-start gap-5;
}
.identity-subject-list {
@apply max-h-[560px] overflow-hidden;
}
.casting-stats {
@apply grid grid-cols-[repeat(5,_minmax(0,_1fr))] gap-2.5;
}
.casting-stats div {
@apply py-2.5 px-3 rounded-none bg-(--app-subtle);
}
.casting-stats dt {
@apply text-muted text-[10px];
}
.casting-stats dd {
@apply mt-[5px] font-mono text-[13px];
}
@media (max-width: 900px) {
.identity-workspace {
@apply grid-cols-[minmax(0,_1fr)];
}
.identity-subject-list {
@apply max-h-[220px];
}
.casting-stats {
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
}
}
.toolbar-type-filter {
@apply w-[152px];
}
.identity-tools-intro {
@apply mb-6;
}
.identity-workspace {
@apply flex-1 min-h-0 items-stretch overflow-hidden;
}
.identity-workspace {
@apply grid-cols-[clamp(240px,_22%,_280px)_minmax(0,_1fr)];
}
.identity-workspace > div {
@apply min-h-0 overflow-hidden overscroll-contain;
}
.identity-subject-list {
@apply max-h-none min-h-0 flex-1;
}
.directory-filters {
@apply grid gap-2.5 pt-0 px-3 pb-3 shrink-0;
}
.directory-filters > * {
@apply min-w-0;
}
.identity-subject-list-content {
@apply gap-0.5 pt-0 px-0 pb-2;
}
.n-button.identity-subject-item {
@apply min-h-[86px] py-2.5 px-3.5;
}
.n-button.identity-subject-item:not(.selected) {
@apply bg-(--app-surface);
}
.n-button.identity-subject-item:not(.selected):hover {
@apply bg-(--app-subtle);
}
.identity-subject-item .directory-item-body {
@apply gap-[3px];
}
.identity-subject-item .directory-item-title {
@apply order-0 text-sm font-semibold;
}
.identity-subject-item .directory-item-eyebrow {
@apply order-1 text-[10px];
}
.identity-subject-item .directory-item-meta {
@apply order-2 gap-y-1 gap-x-1.5 text-[10px];
}
.casting-list {
@apply grid grid-cols-[repeat(auto-fit,_minmax(min(100%,_260px),_1fr))] gap-3 mt-6 pt-5;
}
.n-button.casting-item {
@apply w-full min-w-0 h-auto min-h-[76px] py-3 px-3.5 whitespace-normal text-left bg-(--app-subtle);
}
.n-button.casting-item.selected {
@apply bg-(--app-selected) shadow-[inset_3px_0_0_var(--app-accent)];
}
.n-button.casting-item .n-button__content {
@apply flex w-full min-w-0 items-center justify-between gap-4 text-left;
}
.casting-item-identity {
@apply flex flex-col gap-[5px] min-w-0;
}
.casting-item-name {
@apply text-sm font-medium leading-normal wrap-anywhere;
}
.casting-item-ref {
@apply text-muted font-mono text-[11px] leading-normal wrap-anywhere;
}
.casting-item-status {
@apply shrink-0;
}
@media (max-width: 760px) {
.identity-workspace {
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[250px_minmax(0,_1fr)] gap-2.5;
}
.directory-filters {
@apply grid-cols-[minmax(0,_1fr)_120px];
}
}
</style>
@@ -105,3 +105,18 @@ watch(
</span>
</span>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../../styles/styles.css";
.identity-thumbnail {
@apply flex w-12 h-[64px] overflow-hidden bg-(--app-subtle);
}
.identity-thumbnail .n-image,
.identity-thumbnail .n-image img {
@apply w-full h-full;
}
.identity-thumbnail-placeholder {
@apply flex flex-col items-center justify-center w-full h-full gap-[5px] text-muted text-[10px];
}
</style>
@@ -739,3 +739,118 @@ async function showStaleForms() {
@changed="refreshAssets" /></template
></WorkspacePage>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.form-image-grid {
@apply grid grid-cols-[repeat(auto-fill,_minmax(min(260px,_100%),_1fr))] gap-5;
}
.form-image-card {
@apply overflow-hidden;
}
.gallery-workspace-page .workspace-scroll-content {
@apply pt-2;
}
.gallery-workspace-page .gallery-sticky-controls {
@apply sticky top-0 z-10 bg-(--app-body) mt-2.5 mb-0;
}
.gallery-workspace-page .form-image-grid {
@apply pt-3;
}
.gallery-workspace-page .form-image-masonry {
@apply block columns-[260px] gap-x-5;
}
.form-image-masonry > .form-image-card {
@apply break-inside-avoid mb-5;
}
.form-image-masonry .asset-image {
@apply aspect-[var(--form-image-aspect,4/3)];
}
.gallery-sticky-controls > .form-image-filter-region {
@apply my-0;
}
.gallery-workspace-page .workspace-scroll > .n-scrollbar-container {
@apply [overflow-anchor:none];
}
.form-image-filter-region {
@apply my-4 py-3.5 px-4 bg-(--app-subtle);
container-type: inline-size;
}
.form-image-toolbar {
@apply grid grid-cols-[minmax(0,_1fr)] items-center gap-y-3 gap-x-4;
}
.form-image-toolbar.has-impact-picker {
@apply grid-cols-[minmax(220px,_340px)_minmax(0,_1fr)];
}
.form-image-toolbar.has-impact-picker.has-impact-actions {
@apply grid-cols-[minmax(260px,_420px)_minmax(0,_1fr)];
}
.asset-impact-picker {
@apply flex items-center gap-2 min-w-0;
}
.asset-impact-picker .asset-impact-select {
@apply flex-1;
}
.form-image-toolbar > * {
@apply min-w-0;
}
.form-image-filters {
@apply grid grid-cols-[minmax(200px,_360px)_152px_minmax(260px,_1fr)_auto] items-center gap-y-3 gap-x-4;
}
.form-image-filters > * {
@apply min-w-0;
}
.filter-toggles {
@apply flex items-center flex-wrap gap-y-2.5 gap-x-4 text-xs;
}
.filter-toggles .n-checkbox__label {
@apply whitespace-nowrap;
}
.filter-result-count {
@apply justify-self-end text-muted text-xs whitespace-nowrap;
}
.form-image-display-controls {
@apply flex items-center justify-self-end gap-3;
}
.gallery-layout-switch {
@apply flex items-center gap-1;
}
@container (max-width: 1200px) {
.form-image-toolbar.has-impact-picker,
.form-image-toolbar.has-impact-picker.has-impact-actions {
@apply grid-cols-[minmax(0,_1fr)];
}
}
@container (max-width: 900px) {
.form-image-filters {
@apply grid-cols-[minmax(0,_1fr)_152px];
}
.form-image-display-controls {
@apply col-[1/-1];
}
}
@container (max-width: 460px) {
.form-image-filters {
@apply grid-cols-[minmax(0,_1fr)_120px] gap-x-2.5;
}
}
.asset-impact-context {
@apply flex flex-col gap-2.5 py-[5px] px-4 bg-(--app-subtle) m-0 min-w-0;
}
.asset-impact-links-scroll.n-scrollbar {
@apply h-auto min-w-0 max-w-full;
}
.asset-impact-links {
@apply flex items-center gap-3 w-max whitespace-nowrap min-h-8 py-1.5;
}
.asset-impact-links > * {
@apply shrink-0;
}
.asset-impact-select {
@apply min-w-0 w-full;
}
.form-image-card-focused {
@apply shadow-[inset_3px_0_0_var(--app-accent-text)];
}
</style>
@@ -165,3 +165,26 @@ function removeImage(image: VisualStyleImage) {
@remove="removeImage"
/></WorkspacePage>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.visual-style-toolbar {
@apply flex items-center justify-between flex-wrap gap-y-4 gap-x-6 my-5 py-3.5 px-4 bg-(--app-subtle);
}
.visual-style-status {
@apply flex items-center gap-3 min-w-0;
}
.visual-style-status > svg {
@apply shrink-0 text-muted;
}
.visual-style-status strong {
@apply text-sm font-medium;
}
.visual-style-status p {
@apply mt-1 mx-0 mb-0 text-muted text-xs leading-[1.6];
}
.visual-style-actions {
@apply flex items-center flex-wrap gap-2.5 ml-auto;
}
</style>
+8
View File
@@ -56,3 +56,11 @@ function confirm() {
</AppDialog>
</span>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.confirm-action {
@apply inline-flex items-center max-w-full;
}
</style>
+24
View File
@@ -54,3 +54,27 @@ const history = computed(() => workflowCheckpoints(props.checkpoints, props.work
/>
</aside>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.history-panel {
@apply py-[23px] px-[21px] bg-(--app-subtle);
}
@media (max-width: 1200px) {
.history-panel {
border-left: 0;
}
}
.history-panel {
@apply p-0;
}
.history-content {
@apply py-[23px] px-[21px];
}
@media (max-width: 760px) {
.history-panel {
@apply overflow-hidden min-h-0;
}
}
</style>
@@ -161,3 +161,20 @@ function exportReport() {
/>
</AppDialog>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.diagnostics-filters {
@apply flex flex-wrap items-center gap-3;
}
.diagnostics-filters > .n-input {
@apply flex-[1_1_240px] min-w-0;
}
.diagnostics-filters > .n-select {
@apply flex-[0_1_180px] min-w-0;
}
.diagnostics-records {
@apply grid gap-3 min-w-0;
}
</style>
+2 -2
View File
@@ -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')
-781
View File
@@ -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;
}
}
+433
View File
@@ -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;
}
}
+280
View File
@@ -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;
}
}
+239
View File
@@ -0,0 +1,239 @@
import { readFileSync, readdirSync } from 'node:fs'
const SPACE: Record<string, string> = {
'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<string, string> = {
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<string, string> = {
'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)
}
@@ -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(() => {
@@ -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(() => {
@@ -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(
@@ -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;/
)
@@ -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('<span>剧集</span')
@@ -174,6 +186,57 @@ describe('管理后台组件边界', () => {
}
})
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/
)
})
@@ -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())
@@ -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(
@@ -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;/)
@@ -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')
@@ -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
/** 从实际挂载的确认弹窗查找操作按钮。 */
@@ -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(() => {
@@ -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
@@ -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',
@@ -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/
)
})
})
@@ -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())
@@ -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(() => {
@@ -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: {
@@ -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(() => {
@@ -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,需要从实际弹窗找到按钮。 */
@@ -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([
@@ -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'
@@ -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(() => {
@@ -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 () => {
@@ -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 = '') {
@@ -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 {
@@ -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()
+4 -1
View File
@@ -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"]
}
+2
View File
@@ -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: {
+6 -1
View File
@@ -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 }
})