style: 精简主题提示并优化分镜工具栏与分隔线
This commit is contained in:
@@ -103,12 +103,14 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
|||||||
- 身份、分镜、生产与图库的批量配置、说明和诊断统一放入 `WorkspaceTools`(Naive UI Drawer),按需覆盖当前工作区,不挤压列表。面板支持 Esc 或关闭按钮;关闭不会取消任务或清除配置。历史回执可从入口查看,新回执主动打开诊断;查询错误始终在外层可见。
|
- 身份、分镜、生产与图库的批量配置、说明和诊断统一放入 `WorkspaceTools`(Naive UI Drawer),按需覆盖当前工作区,不挤压列表。面板支持 Esc 或关闭按钮;关闭不会取消任务或清除配置。历史回执可从入口查看,新回执主动打开诊断;查询错误始终在外层可见。
|
||||||
- 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class`。
|
- 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class`。
|
||||||
- 右上角主题入口以太阳/月亮/显示器图标显示当前偏好,点击选择浅色、暗黑或跟随系统。默认跟随系统,选择保存在本机浏览器;存储被禁用时仍可在当前会话切换。后端连接设置固定在左侧导航底部,折叠时仅显示图标,导航菜单独立滚动。
|
- 右上角主题入口以太阳/月亮/显示器图标显示当前偏好,点击选择浅色、暗黑或跟随系统。默认跟随系统,选择保存在本机浏览器;存储被禁用时仍可在当前会话切换。后端连接设置固定在左侧导航底部,折叠时仅显示图标,导航菜单独立滚动。
|
||||||
|
- 主题按钮仅在点击时展开模式菜单,不再显示悬浮提示;保留屏幕阅读器名称。浅色 Tabs 分隔线使用 `#dadada`,暗黑模式继续使用深灰分隔线。
|
||||||
- 实心绿色按钮采用白色文字,正常/悬停/按下底色分别调整以保证可读性;禁用按钮为灰底灰字,保留原生 disabled 行为。分镜与生产页的剧集选择器不重复显示“剧集”前缀,保留可访问名称。
|
- 实心绿色按钮采用白色文字,正常/悬停/按下底色分别调整以保证可读性;禁用按钮为灰底灰字,保留原生 disabled 行为。分镜与生产页的剧集选择器不重复显示“剧集”前缀,保留可访问名称。
|
||||||
- 绿色选中复选框使用白色对勾;批量配置中的复选框与中号输入框共用 34px 控件行并垂直居中。浅色输入框按所在区域使用白底或浅灰底,与页面/抽屉或白色面板区分;聚焦与禁用反馈继续保留。
|
- 绿色选中复选框使用白色对勾;批量配置中的复选框与中号输入框共用 34px 控件行并垂直居中。浅色输入框按所在区域使用白底或浅灰底,与页面/抽屉或白色面板区分;聚焦与禁用反馈继续保留。
|
||||||
- 采用微信风格配色:浅色为浅灰背景与白色内容,暗色为近黑背景与深灰内容;绿色用于主操作、选中态和进度。按钮文字、目录文字与主绿色分别配置以保证对比度,错误/警告仍使用独立语义色。Naive UI 和业务内容共用主题,浏览器主题色随之切换;加载前应用偏好,减少主题闪烁。
|
- 采用微信风格配色:浅色为浅灰背景与白色内容,暗色为近黑背景与深灰内容;绿色用于主操作、选中态和进度。按钮文字、目录文字与主绿色分别配置以保证对比度,错误/警告仍使用独立语义色。Naive UI 和业务内容共用主题,浏览器主题色随之切换;加载前应用偏好,减少主题闪烁。
|
||||||
- 全站采用低边框、直角样式:面板、提示框、标签、图片卡片和表单用背景层次代替静态描边;表格与长记录列表使用交替底色,抽屉用灰底承托内容。保留输入框聚焦/校验反馈、复选框、选中指示和必要的浮层阴影;开关、加载等功能图形不强制改形状。
|
- 全站采用低边框、直角样式:面板、提示框、标签、图片卡片和表单用背景层次代替静态描边;表格与长记录列表使用交替底色,抽屉用灰底承托内容。保留输入框聚焦/校验反馈、复选框、选中指示和必要的浮层阴影;开关、加载等功能图形不强制改形状。
|
||||||
- Tabs 例外保留底部分隔线,浅色和暗黑模式均区分导航与正文;选中下划线仍使用绿色。视觉风格工具栏将“图标+状态说明”与等高操作按钮分组,读取失败不再显示为“尚未创建”,锁定与未保存草稿的生成限制不变。
|
- Tabs 例外保留底部分隔线,浅色和暗黑模式均区分导航与正文;选中下划线仍使用绿色。视觉风格工具栏将“图标+状态说明”与等高操作按钮分组,读取失败不再显示为“尚未创建”,锁定与未保存草稿的生成限制不变。
|
||||||
- 剧本正文的执行记录/导出/进入拆解,以及拆解结果的 JSON 导出统一放在 `NTabs` 的 `suffix` 插槽,与标签垂直居中并共用底线。浅色 Tabs 分隔线单独加深,不影响其它组件的低边框风格;窄内容区由容器断点把操作放到下一行,标签仍使用内置横向滚动。
|
- 剧本正文的执行记录/导出/进入拆解,以及拆解结果的 JSON 导出统一放在 `NTabs` 的 `suffix` 插槽,与标签垂直居中并共用底线。浅色 Tabs 分隔线单独加深,不影响其它组件的低边框风格;窄内容区由容器断点把操作放到下一行,标签仍使用内置横向滚动。
|
||||||
|
- 拆解分镜区的剧集选择、节拍/镜头统计与“进入分镜设计”共用一条紧凑工具栏;“选择剧集”不拆行。选择器默认显示正文对应的真实剧集,轮询移除当前剧集时同步回退;没有剧情目标与情绪弧线时不保留空信息面板。
|
||||||
- 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。
|
- 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。
|
||||||
|
|
||||||
布局样式集中在 `src/admin.css`,业务排版保留在 `src/styles.css`。新增页面应使用 `WorkspacePage`(内容优先页开启 `compact`),低频操作使用 `WorkspaceTools`;避免通过 `body` 或 `window.scrollTo` 管理滚动。
|
布局样式集中在 `src/admin.css`,业务排版保留在 `src/styles.css`。新增页面应使用 `WorkspacePage`(内容优先页开启 `compact`),低频操作使用 `WorkspaceTools`;避免通过 `body` 或 `window.scrollTo` 管理滚动。
|
||||||
|
|||||||
@@ -851,6 +851,51 @@ body {
|
|||||||
.breakdown-results-scroll .n-scrollbar-content {
|
.breakdown-results-scroll .n-scrollbar-content {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
/* 剧集选择、统计和下一步入口共用一行,避免空操作行与双层顶部留白。 */
|
||||||
|
.breakdown-storyboard {
|
||||||
|
padding: 16px 20px 20px;
|
||||||
|
}
|
||||||
|
.breakdown-storyboard-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.breakdown-episode-picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 0 1 420px;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.breakdown-episode-picker > span {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.breakdown-episode-picker .n-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.breakdown-storyboard-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 20px;
|
||||||
|
min-height: 34px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.breakdown-storyboard-count {
|
||||||
|
color: var(--app-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.breakdown-storyboard-summary .text-button {
|
||||||
|
min-height: 34px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
/* 连续阅读区保留双栏,执行记录按需展开,不挤占默认阅读宽度。 */
|
/* 连续阅读区保留双栏,执行记录按需展开,不挤占默认阅读宽度。 */
|
||||||
.script-section .n-tabs-tab__label {
|
.script-section .n-tabs-tab__label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h, ref } from 'vue'
|
import { computed, h, ref } from 'vue'
|
||||||
import { NButton, NDropdown, NTooltip } from 'naive-ui'
|
import { NButton, NDropdown } from 'naive-ui'
|
||||||
import { Monitor, Moon, Sun } from '@lucide/vue'
|
import { Monitor, Moon, Sun } from '@lucide/vue'
|
||||||
import type { ThemePreference } from '../../composables/useTheme'
|
import type { ThemePreference } from '../../composables/useTheme'
|
||||||
|
|
||||||
@@ -23,19 +23,14 @@ function selectTheme(key: string | number) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NDropdown v-model:show="open" trigger="click" :value="preference" :options="options" @select="selectTheme">
|
<NDropdown v-model:show="open" trigger="click" :value="preference" :options="options" @select="selectTheme">
|
||||||
<NTooltip :disabled="open">
|
<NButton
|
||||||
<template #trigger>
|
quaternary
|
||||||
<NButton
|
class="theme-toggle icon-button"
|
||||||
quaternary
|
:aria-label="`主题模式:${current.label}`"
|
||||||
class="theme-toggle icon-button"
|
aria-haspopup="menu"
|
||||||
:aria-label="`主题模式:${current.label}`"
|
:aria-expanded="open"
|
||||||
aria-haspopup="menu"
|
>
|
||||||
:aria-expanded="open"
|
<template #icon><component :is="current.icon" :size="18" aria-hidden="true" /></template>
|
||||||
>
|
</NButton>
|
||||||
<template #icon><component :is="current.icon" :size="18" aria-hidden="true" /></template>
|
|
||||||
</NButton>
|
|
||||||
</template>
|
|
||||||
{{ current.label }} · 切换主题
|
|
||||||
</NTooltip>
|
|
||||||
</NDropdown>
|
</NDropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ describe('全站平面主题', () => {
|
|||||||
expect(theme.common?.borderColor).not.toBe('transparent')
|
expect(theme.common?.borderColor).not.toBe('transparent')
|
||||||
expect(theme.Checkbox).not.toHaveProperty('border')
|
expect(theme.Checkbox).not.toHaveProperty('border')
|
||||||
expect(theme.Tabs?.barColor).toBe('#07c160')
|
expect(theme.Tabs?.barColor).toBe('#07c160')
|
||||||
expect(value('.n-tabs', '--n-tab-border-color')).toBe(dark ? '#333333' : '#bfbfbf')
|
expect(value('.n-tabs', '--n-tab-border-color')).toBe(dark ? '#333333' : '#dadada')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('单图标按钮以当前控件高度为边长,尺寸与内容按钮互不影响', () => {
|
it('单图标按钮以当前控件高度为边长,尺寸与内容按钮互不影响', () => {
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { h } from 'vue'
|
|||||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { NConfigProvider, NDrawer, NDrawerContent, NModal, NScrollbar } from 'naive-ui'
|
import { NConfigProvider, NDrawer, NDrawerContent, NModal, NScrollbar, NTooltip } from 'naive-ui'
|
||||||
import App from '../../App.vue'
|
import App from '../../App.vue'
|
||||||
import WorkspacePage from './WorkspacePage.vue'
|
import WorkspacePage from './WorkspacePage.vue'
|
||||||
import AppDialog from './AppDialog.vue'
|
import AppDialog from './AppDialog.vue'
|
||||||
import WorkspaceTools from './WorkspaceTools.vue'
|
import WorkspaceTools from './WorkspaceTools.vue'
|
||||||
import ProjectLayout from '../../features/projects/ProjectLayout.vue'
|
import ProjectLayout from '../../features/projects/ProjectLayout.vue'
|
||||||
import ConfirmAction from '../../features/workflows/ConfirmAction.vue'
|
import ConfirmAction from '../../features/workflows/ConfirmAction.vue'
|
||||||
|
import ThemeToggle from './ThemeToggle.vue'
|
||||||
import { projectsApi } from '../../features/projects/api'
|
import { projectsApi } from '../../features/projects/api'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
@@ -120,6 +121,8 @@ describe('管理后台组件边界', () => {
|
|||||||
expect(wrapper.get('#main-content .workspace-page').text()).toBe('镜头详情')
|
expect(wrapper.get('#main-content .workspace-page').text()).toBe('镜头详情')
|
||||||
expect(wrapper.get('.theme-toggle').text()).toBe('')
|
expect(wrapper.get('.theme-toggle').text()).toBe('')
|
||||||
expect(wrapper.get('.theme-toggle').attributes('aria-haspopup')).toBe('menu')
|
expect(wrapper.get('.theme-toggle').attributes('aria-haspopup')).toBe('menu')
|
||||||
|
expect(wrapper.getComponent(ThemeToggle).findComponent(NTooltip).exists()).toBe(false)
|
||||||
|
expect(wrapper.get('.theme-toggle').attributes('title')).toBeUndefined()
|
||||||
expect(wrapper.get('.theme-toggle svg').classes()).toContain('lucide-monitor')
|
expect(wrapper.get('.theme-toggle svg').classes()).toContain('lucide-monitor')
|
||||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
for (const [key, label, icon] of [
|
for (const [key, label, icon] of [
|
||||||
|
|||||||
@@ -47,13 +47,13 @@ function contrast(foreground: string, background: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('微信风格明暗主题', () => {
|
describe('微信风格明暗主题', () => {
|
||||||
it('浅色 Tabs 分隔线与页面、内容表面保持可辨识的灰阶差异', () => {
|
it('浅色 Tabs 分隔线使用约定的 #dadada,与页面和内容表面保留灰阶差异', () => {
|
||||||
const { overrides } = setup()
|
const { overrides } = setup()
|
||||||
const color = overrides.value.Tabs!.tabBorderColor!
|
const color = overrides.value.Tabs!.tabBorderColor!
|
||||||
if (typeof color !== 'string') throw new Error('Tabs 分隔线颜色必须为字符串')
|
if (typeof color !== 'string') throw new Error('Tabs 分隔线颜色必须为字符串')
|
||||||
expect(color).toBe('#bfbfbf')
|
expect(color).toBe('#dadada')
|
||||||
expect(contrast(color, '#ededed')).toBeGreaterThan(1.5)
|
expect(contrast(color, '#ededed')).toBeGreaterThan(1.15)
|
||||||
expect(contrast(color, '#ffffff')).toBeGreaterThan(1.5)
|
expect(contrast(color, '#ffffff')).toBeGreaterThan(1.15)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('默认跟随系统并实时响应,销毁时移除监听', async () => {
|
it('默认跟随系统并实时响应,销毁时移除监听', async () => {
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export function useTheme() {
|
|||||||
tabTextColorHoverLine: accentText,
|
tabTextColorHoverLine: accentText,
|
||||||
barColor: primary,
|
barColor: primary,
|
||||||
// Tabs 保留横向分隔线,明确导航与内容边界;选中下划线仍使用主绿色。
|
// Tabs 保留横向分隔线,明确导航与内容边界;选中下划线仍使用主绿色。
|
||||||
tabBorderColor: dark ? '#333333' : '#bfbfbf'
|
tabBorderColor: dark ? '#333333' : '#dadada'
|
||||||
},
|
},
|
||||||
Tag: {
|
Tag: {
|
||||||
textColorPrimary: accentText,
|
textColorPrimary: accentText,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { NSelect } from 'naive-ui'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
import BreakdownPage from './BreakdownPage.vue'
|
import BreakdownPage from './BreakdownPage.vue'
|
||||||
import { projectContextKey, type useProjectData } from '../projects/context'
|
import { projectContextKey, type useProjectData } from '../projects/context'
|
||||||
import type { ProjectDetail } from '../projects/types'
|
import type { ProjectDetail } from '../projects/types'
|
||||||
@@ -138,6 +140,63 @@ async function selectTab(label: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('拆解页内容滚动', () => {
|
describe('拆解页内容滚动', () => {
|
||||||
|
it('分镜选择与计数、入口同排,默认显示实际剧集,切换后内容和统计同步', async () => {
|
||||||
|
const record = checkpoint()
|
||||||
|
const result = record.state.breakdownResult!
|
||||||
|
const first = result.storyboardPlans![0]!
|
||||||
|
const second = { ...first, episodeNo: 2, episodeTitle: '第二集', beats: first.beats.slice(0, 2) }
|
||||||
|
result.storyboardPlans!.push(second)
|
||||||
|
result.storyboardEpisodeShots!.push({
|
||||||
|
episodeNo: 2,
|
||||||
|
episodePlan: second,
|
||||||
|
beatShots: result.storyboardEpisodeShots![0]!.beatShots.slice(0, 2)
|
||||||
|
})
|
||||||
|
const provided = mountPage([record])
|
||||||
|
await selectTab('分镜')
|
||||||
|
const toolbar = wrapper!.get('.breakdown-storyboard-toolbar')
|
||||||
|
expect(toolbar.get('.breakdown-episode-picker > span').text()).toBe('选择剧集')
|
||||||
|
const select = toolbar.getComponent(NSelect)
|
||||||
|
expect(select.props('value')).toBe(1)
|
||||||
|
expect(toolbar.text()).toContain('长剧集')
|
||||||
|
expect(toolbar.get('.breakdown-storyboard-summary').text()).toContain('25 个节拍 · 25 个镜头')
|
||||||
|
expect(toolbar.get('.breakdown-storyboard-summary router-link-stub').attributes('to')).toBe(
|
||||||
|
'/projects/breakdown-scroll-test/storyboard'
|
||||||
|
)
|
||||||
|
expect(wrapper!.find('.breakdown-storyboard > .surface-inset').exists()).toBe(false)
|
||||||
|
select.vm.$emit('update:value', 2)
|
||||||
|
await flushPromises()
|
||||||
|
expect(select.props('value')).toBe(2)
|
||||||
|
expect(toolbar.text()).toContain('第二集')
|
||||||
|
expect(toolbar.get('.breakdown-storyboard-summary').text()).toContain('2 个节拍 · 2 个镜头')
|
||||||
|
expect(wrapper!.findAll('.beat-section')).toHaveLength(2)
|
||||||
|
// 轮询删除当前选项时,输入框与正文一起回到仍存在的第一集。
|
||||||
|
provided.data.value!.checkpoints = [checkpoint()]
|
||||||
|
await flushPromises()
|
||||||
|
expect(select.props('value')).toBe(1)
|
||||||
|
expect(wrapper!.findAll('.beat-section')).toHaveLength(25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('分镜为空时保留空提示与进入设计入口,不显示空选择器', async () => {
|
||||||
|
const record = checkpoint()
|
||||||
|
record.state.breakdownResult!.storyboardPlans = []
|
||||||
|
record.state.breakdownResult!.storyboardEpisodeShots = []
|
||||||
|
mountPage([record])
|
||||||
|
await selectTab('分镜')
|
||||||
|
expect(wrapper!.get('.breakdown-storyboard').text()).toContain('分镜规划尚未生成')
|
||||||
|
expect(wrapper!.find('.breakdown-episode-picker').exists()).toBe(false)
|
||||||
|
expect(wrapper!.get('.breakdown-storyboard-summary router-link-stub').attributes('to')).toBe(
|
||||||
|
'/projects/breakdown-scroll-test/storyboard'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('分镜顶部无叠加操作行,选择标签保持单行且统计操作垂直居中', () => {
|
||||||
|
// 仅保护样式契约,DOM 环境不提供真实布局坐标。
|
||||||
|
const css = readFileSync('src/admin.css', 'utf8')
|
||||||
|
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;/)
|
||||||
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['人物', 'character 主体 30'],
|
['人物', 'character 主体 30'],
|
||||||
['场景', 'scene 主体 30'],
|
['场景', 'scene 主体 30'],
|
||||||
|
|||||||
@@ -410,12 +410,13 @@ function exportResult() {
|
|||||||
/></div
|
/></div
|
||||||
></template>
|
></template>
|
||||||
<div v-if="tab === 'storyboard'">
|
<div v-if="tab === 'storyboard'">
|
||||||
<div class="mb-4 flex justify-end">
|
<StoryboardList :plans="plans" :episodes="shots">
|
||||||
<RouterLink :to="`/projects/${project?.id}/storyboard`" class="text-button"
|
<template #actions>
|
||||||
>进入分镜设计<ArrowRight :size="14"
|
<RouterLink :to="`/projects/${project?.id}/storyboard`" class="text-button"
|
||||||
/></RouterLink>
|
>进入分镜设计<ArrowRight :size="14"
|
||||||
</div>
|
/></RouterLink>
|
||||||
<StoryboardList :plans="plans" :episodes="shots" />
|
</template>
|
||||||
|
</StoryboardList>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="tab === 'tasks'" class="p-5">
|
<div v-if="tab === 'tasks'" class="p-5">
|
||||||
<NScrollbar x-scrollable v-if="snapshot?.tasks?.length" class="table-scroll">
|
<NScrollbar x-scrollable v-if="snapshot?.tasks?.length" class="table-scroll">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { EpisodePlan, EpisodeShots } from '../types'
|
|||||||
const props = defineProps<{ plans: EpisodePlan[]; episodes: EpisodeShots[] }>()
|
const props = defineProps<{ plans: EpisodePlan[]; episodes: EpisodeShots[] }>()
|
||||||
const selected = ref<number>()
|
const selected = ref<number>()
|
||||||
const plans = computed(() => (props.plans.length ? props.plans : props.episodes.map(item => item.episodePlan)))
|
const plans = computed(() => (props.plans.length ? props.plans : props.episodes.map(item => item.episodePlan)))
|
||||||
|
/** 选择器与正文统一使用最终规划编号;初次读取或当前剧集被移除时回退到第一集。 */
|
||||||
const plan = computed(() => plans.value.find(item => item.episodeNo === selected.value) ?? plans.value[0])
|
const plan = computed(() => plans.value.find(item => item.episodeNo === selected.value) ?? plans.value[0])
|
||||||
const episode = computed(() => props.episodes.find(item => item.episodeNo === plan.value?.episodeNo))
|
const episode = computed(() => props.episodes.find(item => item.episodeNo === plan.value?.episodeNo))
|
||||||
const purposeLabels: Record<string, string> = {
|
const purposeLabels: Record<string, string> = {
|
||||||
@@ -26,81 +27,82 @@ const purposeLabels: Record<string, string> = {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="plan" class="p-5 lg:p-7">
|
<div class="breakdown-storyboard">
|
||||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
|
<div v-if="plan || $slots.actions" class="breakdown-storyboard-toolbar">
|
||||||
<label class="flex items-center gap-3 text-sm"
|
<label v-if="plan" class="breakdown-episode-picker"
|
||||||
>选择剧集<NSelect
|
><span>选择剧集</span
|
||||||
|
><NSelect
|
||||||
aria-label="选择分镜剧集"
|
aria-label="选择分镜剧集"
|
||||||
v-model:value="selected"
|
:value="plan.episodeNo"
|
||||||
:options="[
|
@update:value="selected = $event"
|
||||||
...(selected === undefined
|
:options="
|
||||||
? [
|
plans.map(item => ({
|
||||||
{
|
|
||||||
label: String(' 第 ' + plan.episodeNo + ' 集 · ' + plan.episodeTitle),
|
|
||||||
value: undefined
|
|
||||||
}
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...plans.map(item => ({
|
|
||||||
label: String(' 第 ' + item.episodeNo + ' 集 · ' + item.episodeTitle),
|
label: String(' 第 ' + item.episodeNo + ' 集 · ' + item.episodeTitle),
|
||||||
value: item.episodeNo
|
value: item.episodeNo
|
||||||
}))
|
}))
|
||||||
]"
|
"
|
||||||
class="select-control"
|
></NSelect
|
||||||
></NSelect></label
|
></label>
|
||||||
><span class="text-xs text-muted"
|
<div class="breakdown-storyboard-summary">
|
||||||
>{{ plan.beats.length }} 个节拍 ·
|
<span v-if="plan" class="breakdown-storyboard-count"
|
||||||
{{ episode?.beatShots.reduce((sum, beat) => sum + beat.shots.length, 0) ?? 0 }} 个镜头</span
|
>{{ plan.beats.length }} 个节拍 ·
|
||||||
>
|
{{ episode?.beatShots.reduce((sum, beat) => sum + beat.shots.length, 0) ?? 0 }} 个镜头</span
|
||||||
</div>
|
|
||||||
<div class="mb-7 surface-inset p-4">
|
|
||||||
<p class="font-medium">{{ plan.storyGoal }}</p>
|
|
||||||
<p class="mt-2 text-sm leading-6 text-muted">{{ plan.emotionalArc }}</p>
|
|
||||||
</div>
|
|
||||||
<NAlert v-if="!episode" type="info" :show-icon="false" class="mb-5">
|
|
||||||
本集已完成节拍规划,镜头尚未生成。请等待进度更新,或在确认中断后使用“补齐缺失镜头”。
|
|
||||||
</NAlert>
|
|
||||||
<section v-for="beat in plan.beats" :key="beat.beatNo" class="beat-section">
|
|
||||||
<div class="beat-heading">
|
|
||||||
<span class="beat-number">{{ String(beat.beatNo).padStart(2, '0') }}</span>
|
|
||||||
<h3 class="text-sm font-semibold">{{ beat.title }}</h3>
|
|
||||||
<NTag size="small" :bordered="false">{{ purposeLabels[beat.purpose] || beat.purpose }}</NTag>
|
|
||||||
</div>
|
|
||||||
<p class="mb-4 mt-3 text-sm leading-6 text-muted">{{ beat.description }}</p>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<article
|
|
||||||
v-for="shot in episode?.beatShots.find(item => item.beatNo === beat.beatNo)?.shots ?? []"
|
|
||||||
:key="shot.shotNo"
|
|
||||||
class="shot-row"
|
|
||||||
>
|
>
|
||||||
<div class="shot-label">
|
<slot name="actions" />
|
||||||
镜头 {{ String(shot.shotNo).padStart(2, '0')
|
|
||||||
}}<span class="mt-2 flex items-center gap-1 text-[11px]"
|
|
||||||
><Clock3 :size="11" />{{ shot.durationSeconds }}s</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<h4 class="text-sm font-medium">{{ shot.title }}</h4>
|
|
||||||
<p class="mt-2 text-sm leading-7">{{ shot.description }}</p>
|
|
||||||
<p class="mt-2 text-xs leading-6 text-muted">视觉重点 · {{ shot.visualFocus }}</p>
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
|
||||||
<code v-for="ref in shot.subjectRefs" :key="ref" class="subject-ref">{{ ref }}</code>
|
|
||||||
</div>
|
|
||||||
<p v-if="shot.subjectBindings?.length" class="mt-2 text-xs leading-6 text-muted">
|
|
||||||
{{
|
|
||||||
shot.subjectBindings
|
|
||||||
.map(binding => binding.subjectRef + ' · ' + binding.formName)
|
|
||||||
.join(' / ')
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
<template v-if="plan">
|
||||||
|
<div v-if="plan.storyGoal || plan.emotionalArc" class="mb-7 surface-inset p-4">
|
||||||
|
<p v-if="plan.storyGoal" class="font-medium">{{ plan.storyGoal }}</p>
|
||||||
|
<p v-if="plan.emotionalArc" class="text-sm leading-6 text-muted" :class="{ 'mt-2': !!plan.storyGoal }">
|
||||||
|
{{ plan.emotionalArc }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NAlert v-if="!episode" type="info" :show-icon="false" class="mb-5">
|
||||||
|
本集已完成节拍规划,镜头尚未生成。请等待进度更新,或在确认中断后使用“补齐缺失镜头”。
|
||||||
|
</NAlert>
|
||||||
|
<section v-for="beat in plan.beats" :key="beat.beatNo" class="beat-section">
|
||||||
|
<div class="beat-heading">
|
||||||
|
<span class="beat-number">{{ String(beat.beatNo).padStart(2, '0') }}</span>
|
||||||
|
<h3 class="text-sm font-semibold">{{ beat.title }}</h3>
|
||||||
|
<NTag size="small" :bordered="false">{{ purposeLabels[beat.purpose] || beat.purpose }}</NTag>
|
||||||
|
</div>
|
||||||
|
<p class="mb-4 mt-3 text-sm leading-6 text-muted">{{ beat.description }}</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<article
|
||||||
|
v-for="shot in episode?.beatShots.find(item => item.beatNo === beat.beatNo)?.shots ?? []"
|
||||||
|
:key="shot.shotNo"
|
||||||
|
class="shot-row"
|
||||||
|
>
|
||||||
|
<div class="shot-label">
|
||||||
|
镜头 {{ String(shot.shotNo).padStart(2, '0')
|
||||||
|
}}<span class="mt-2 flex items-center gap-1 text-[11px]"
|
||||||
|
><Clock3 :size="11" />{{ shot.durationSeconds }}s</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h4 class="text-sm font-medium">{{ shot.title }}</h4>
|
||||||
|
<p class="mt-2 text-sm leading-7">{{ shot.description }}</p>
|
||||||
|
<p class="mt-2 text-xs leading-6 text-muted">视觉重点 · {{ shot.visualFocus }}</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<code v-for="ref in shot.subjectRefs" :key="ref" class="subject-ref">{{ ref }}</code>
|
||||||
|
</div>
|
||||||
|
<p v-if="shot.subjectBindings?.length" class="mt-2 text-xs leading-6 text-muted">
|
||||||
|
{{
|
||||||
|
shot.subjectBindings
|
||||||
|
.map(binding => binding.subjectRef + ' · ' + binding.formName)
|
||||||
|
.join(' / ')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<EmptyState
|
||||||
|
v-else
|
||||||
|
title="分镜规划尚未生成"
|
||||||
|
description="完成主体整理后,工作流会先规划每集的剧情节拍,再逐集生成镜头。"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState
|
|
||||||
v-else
|
|
||||||
title="分镜规划尚未生成"
|
|
||||||
description="完成主体整理后,工作流会先规划每集的剧情节拍,再逐集生成镜头。"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user