refactor: 统一主体与镜头目录并优化筛选工具栏布局
This commit is contained in:
@@ -89,6 +89,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
|||||||
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
||||||
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
|
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
|
||||||
- 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。
|
- 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。
|
||||||
|
- 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。
|
||||||
- 生成配置与批量操作放在可折叠区域,回执到达后自动展开;查询错误始终可见,不隐藏在折叠内容中。
|
- 生成配置与批量操作放在可折叠区域,回执到达后自动展开;查询错误始终可见,不隐藏在折叠内容中。
|
||||||
- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。
|
- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。
|
||||||
- 以黑白灰为主,仅错误、警告、成功等语义状态保留辅助色。Naive UI 和业务内容共用主题;加载前应用偏好,减少主题闪烁。
|
- 以黑白灰为主,仅错误、警告、成功等语义状态保留辅助色。Naive UI 和业务内容共用主题;加载前应用偏好,减少主题闪烁。
|
||||||
|
|||||||
+214
-72
@@ -261,6 +261,11 @@ body {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.production-workspace,
|
||||||
|
.identity-workspace,
|
||||||
|
.storyboard-workspace {
|
||||||
|
grid-template-columns: clamp(240px, 22%, 280px) minmax(0, 1fr);
|
||||||
|
}
|
||||||
.production-shot-list {
|
.production-shot-list {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -271,15 +276,6 @@ body {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
.identity-workspace > aside {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.identity-workspace {
|
|
||||||
grid-template-columns: 220px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
.identity-workspace > div {
|
.identity-workspace > div {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -301,6 +297,193 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
/* 目录统一为固定标题/筛选区和独立滚动列表;不再依赖按钮的默认行高与内边距。 */
|
||||||
|
.directory-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--app-subtle);
|
||||||
|
}
|
||||||
|
.directory-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 18px 16px 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--app-muted);
|
||||||
|
}
|
||||||
|
.directory-heading h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-ink);
|
||||||
|
}
|
||||||
|
.directory-heading h3 span {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--app-muted);
|
||||||
|
}
|
||||||
|
.directory-filters {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
.directory-filters > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.directory-scroll {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.directory-list-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px 16px;
|
||||||
|
}
|
||||||
|
.directory-group-heading {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 14px 6px 2px;
|
||||||
|
font:
|
||||||
|
11px/1.6 ui-monospace,
|
||||||
|
monospace;
|
||||||
|
color: var(--app-muted);
|
||||||
|
}
|
||||||
|
.directory-group-heading:first-child {
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
.n-button.directory-item {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
min-height: 88px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 5px;
|
||||||
|
white-space: normal;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.n-button.directory-item .n-button__content {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.directory-item-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.directory-item-eyebrow {
|
||||||
|
font:
|
||||||
|
11px/1.5 ui-monospace,
|
||||||
|
monospace;
|
||||||
|
color: var(--app-muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.directory-item-title {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--app-ink);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.directory-item-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 8px;
|
||||||
|
color: var(--app-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.7;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.directory-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.directory-status svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.n-button.directory-item.selected {
|
||||||
|
background: var(--app-surface);
|
||||||
|
box-shadow: inset 3px 0 var(--app-ink);
|
||||||
|
}
|
||||||
|
.n-button.directory-item:focus-visible {
|
||||||
|
outline: 2px solid var(--app-ink);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
/* Tabs 的默认整行宽度限制在左侧网格内,导出按钮始终处于同一行右端。 */
|
||||||
|
.result-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.result-toolbar > .n-tabs {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.result-toolbar > .n-button {
|
||||||
|
justify-self: end;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.result-toolbar .n-tabs-tab__label {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
/* 筛选按内容区的实际宽度换行,避免侧栏展开后下拉框把其他控件挤到下一行。 */
|
||||||
|
.form-image-filter-region {
|
||||||
|
container-type: inline-size;
|
||||||
|
margin-block: 16px;
|
||||||
|
}
|
||||||
|
.form-image-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(200px, 360px) 152px minmax(260px, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px 16px;
|
||||||
|
}
|
||||||
|
.form-image-filters > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.filter-toggles {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.filter-toggles .n-checkbox__label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.filter-result-count {
|
||||||
|
justify-self: end;
|
||||||
|
color: var(--app-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
@container (max-width: 900px) {
|
||||||
|
.form-image-filters {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 152px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@container (max-width: 460px) {
|
||||||
|
.form-image-filters {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 120px;
|
||||||
|
column-gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
.content-with-history {
|
.content-with-history {
|
||||||
height: clamp(360px, 66dvh, 850px);
|
height: clamp(360px, 66dvh, 850px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -483,28 +666,9 @@ body {
|
|||||||
.shot-list {
|
.shot-list {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.production-shot-list-content {
|
|
||||||
padding-block: 8px;
|
|
||||||
}
|
|
||||||
.shot-list-content {
|
|
||||||
padding: 0 8px 20px;
|
|
||||||
}
|
|
||||||
.history-content {
|
.history-content {
|
||||||
padding: 23px 21px;
|
padding: 23px 21px;
|
||||||
}
|
}
|
||||||
.production-shot-list-content,
|
|
||||||
.shot-list-content,
|
|
||||||
.identity-subject-list-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.n-button.production-shot-link,
|
|
||||||
.n-button.shot-link,
|
|
||||||
.n-button.identity-subject-item {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.image-history.n-scrollbar,
|
.image-history.n-scrollbar,
|
||||||
.table-scroll.n-scrollbar {
|
.table-scroll.n-scrollbar {
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -608,21 +772,11 @@ body {
|
|||||||
.n-button:disabled {
|
.n-button:disabled {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.n-button.shot-link,
|
|
||||||
.n-button.production-shot-link,
|
|
||||||
.n-button.identity-subject-item,
|
|
||||||
.n-button.casting-item,
|
.n-button.casting-item,
|
||||||
.n-button.image-history-item {
|
.n-button.image-history-item {
|
||||||
height: auto;
|
height: auto;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
.n-button.shot-link .n-button__content {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-start;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.n-button.production-shot-link .n-button__content,
|
|
||||||
.n-button.identity-subject-item .n-button__content,
|
|
||||||
.n-button.image-history-item .n-button__content {
|
.n-button.image-history-item .n-button__content {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -633,11 +787,6 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.n-button.shot-link .n-button__border,
|
|
||||||
.n-button.production-shot-link .n-button__border,
|
|
||||||
.n-button.identity-subject-item .n-button__border {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.search-field {
|
.search-field {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -724,46 +873,39 @@ body {
|
|||||||
.workspace-heading .production-controls {
|
.workspace-heading .production-controls {
|
||||||
grid-template-columns: minmax(100px, 1fr) 80px;
|
grid-template-columns: minmax(100px, 1fr) 80px;
|
||||||
}
|
}
|
||||||
.production-workspace {
|
.production-workspace,
|
||||||
|
.storyboard-workspace {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-rows: 110px minmax(0, 1fr);
|
grid-template-rows: 184px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
.production-shot-list {
|
.directory-panel {
|
||||||
display: block;
|
height: 100%;
|
||||||
overflow: hidden;
|
border-right: 0;
|
||||||
height: 110px;
|
border-bottom: 1px solid var(--app-border);
|
||||||
}
|
}
|
||||||
.production-shot-list-content {
|
.directory-heading {
|
||||||
|
padding: 12px 14px 8px;
|
||||||
|
}
|
||||||
|
.directory-list-content {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
|
align-items: stretch;
|
||||||
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
.n-button.production-shot-link {
|
.n-button.directory-item {
|
||||||
width: 180px;
|
width: 228px;
|
||||||
min-height: 98px;
|
min-height: 104px;
|
||||||
|
}
|
||||||
|
.directory-group-heading {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.identity-workspace {
|
.identity-workspace {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-rows: 180px minmax(0, 1fr);
|
grid-template-rows: 250px minmax(0, 1fr);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.identity-workspace > aside {
|
.directory-filters {
|
||||||
display: grid;
|
grid-template-columns: minmax(0, 1fr) 120px;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
||||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
|
||||||
gap: 6px 8px;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
.identity-workspace > aside > * {
|
|
||||||
margin-top: 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.identity-workspace > aside > h3,
|
|
||||||
.identity-workspace > aside > .identity-subject-list,
|
|
||||||
.identity-workspace > aside > .n-button {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
.storyboard-workspace {
|
|
||||||
grid-template-columns: 120px minmax(0, 1fr);
|
|
||||||
}
|
}
|
||||||
.storyboard-controls {
|
.storyboard-controls {
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | undefined
|
||||||
|
afterEach(() => wrapper?.unmount())
|
||||||
|
|
||||||
|
describe('统一目录条目', () => {
|
||||||
|
it('编号、长标题和多项状态各占独立层级,沿用 Naive 按钮', () => {
|
||||||
|
const title = '雨夜霓虹街全景与记忆当铺外的人群和闪烁灯牌'
|
||||||
|
wrapper = mount(DirectoryItem, {
|
||||||
|
props: { active: false },
|
||||||
|
slots: {
|
||||||
|
eyebrow: 'BEAT 2 / SHOT 3',
|
||||||
|
default: title,
|
||||||
|
meta: '<span class="directory-status">设计已保存</span><span class="directory-status">待状态</span>'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(wrapper.getComponent(NButton).props()).toMatchObject({ quaternary: true, block: true })
|
||||||
|
expect(wrapper.get('button').attributes('type')).toBe('button')
|
||||||
|
expect(wrapper.get('.directory-item-eyebrow').text()).toBe('BEAT 2 / SHOT 3')
|
||||||
|
expect(wrapper.get('.directory-item-title').text()).toBe(title)
|
||||||
|
expect(wrapper.get('.directory-item-meta').findAll('.directory-status')).toHaveLength(2)
|
||||||
|
expect(wrapper.find('.truncate').exists()).toBe(false)
|
||||||
|
expect(wrapper.findAll('.directory-item-body > span')).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击透传给业务层,选中态与无障碍属性保持同步', async () => {
|
||||||
|
const onClick = vi.fn<() => void>()
|
||||||
|
wrapper = mount(DirectoryItem, { props: { active: false }, attrs: { onClick }, slots: { default: '林默' } })
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(onClick).toHaveBeenCalledOnce()
|
||||||
|
expect(wrapper.attributes('aria-current')).toBeUndefined()
|
||||||
|
await wrapper.setProps({ active: true })
|
||||||
|
expect(wrapper.get('button').classes()).toContain('selected')
|
||||||
|
expect(wrapper.attributes('aria-current')).toBe('true')
|
||||||
|
expect(wrapper.find('.directory-item-eyebrow').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.directory-item-meta').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('显式保护最小高度、留白、换行和窄屏横向条目,避免旧按钮样式重新挤压目录', () => {
|
||||||
|
// DOM 环境不计算视觉几何;这些约束不能替代浏览器验收。
|
||||||
|
const css = readFileSync('src/admin.css', 'utf8')
|
||||||
|
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/)
|
||||||
|
expect(css).toMatch(/\.directory-list-content\s*\{[^}]*flex-direction:\s*row;[^}]*width:\s*max-content/)
|
||||||
|
expect(css).toContain('grid-template-columns: clamp(240px, 22%, 280px) minmax(0, 1fr)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('导出工具栏与筛选器使用受约束的网格,不让 Tabs 和 Select 默认宽度强制换行', () => {
|
||||||
|
const css = readFileSync('src/admin.css', 'utf8')
|
||||||
|
expect(css).toMatch(/\.result-toolbar\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) auto/)
|
||||||
|
expect(css).toMatch(
|
||||||
|
/\.form-image-filters\s*\{[^}]*grid-template-columns:\s*minmax\(200px, 360px\) 152px minmax\(260px, 1fr\) auto/
|
||||||
|
)
|
||||||
|
expect(css).toMatch(
|
||||||
|
/@container \(max-width: 900px\)\s*\{\s*\.form-image-filters\s*\{[^}]*minmax\(0, 1fr\) 152px/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NButton } from 'naive-ui'
|
||||||
|
|
||||||
|
/** 统一主体与镜头目录条目:编号、标题、状态各占独立层级,不裁切长标题。 */
|
||||||
|
defineProps<{ active: boolean }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NButton
|
||||||
|
quaternary
|
||||||
|
block
|
||||||
|
class="directory-item"
|
||||||
|
:class="{ selected: active }"
|
||||||
|
:aria-current="active ? 'true' : undefined"
|
||||||
|
>
|
||||||
|
<span class="directory-item-body">
|
||||||
|
<span v-if="$slots.eyebrow" class="directory-item-eyebrow"><slot name="eyebrow" /></span>
|
||||||
|
<span class="directory-item-title"><slot /></span>
|
||||||
|
<span v-if="$slots.meta" class="directory-item-meta"><slot name="meta" /></span>
|
||||||
|
</span>
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
@@ -3,3 +3,4 @@ export { default as AppDialog } from './AppDialog.vue'
|
|||||||
export { default as EmptyState } from './EmptyState.vue'
|
export { default as EmptyState } from './EmptyState.vue'
|
||||||
export { default as StatusBadge } from './StatusBadge.vue'
|
export { default as StatusBadge } from './StatusBadge.vue'
|
||||||
export { default as AssetImage } from './AssetImage.vue'
|
export { default as AssetImage } from './AssetImage.vue'
|
||||||
|
export { default as DirectoryItem } from './DirectoryItem.vue'
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ function exportResult() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="mt-7">
|
<div class="mt-7">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="result-toolbar">
|
||||||
<NTabs v-model:value="tab" type="line" size="small" aria-label="拆解结果"
|
<NTabs v-model:value="tab" type="line" size="small" aria-label="拆解结果"
|
||||||
><NTab v-for="option in moduleOptions" :key="option.value" :name="option.value"
|
><NTab v-for="option in moduleOptions" :key="option.value" :name="option.value"
|
||||||
>{{ option.label
|
>{{ option.label
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue'
|
import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue'
|
||||||
import { EmptyState, StatusBadge } from '../../components/ui'
|
import { DirectoryItem, EmptyState, StatusBadge } from '../../components/ui'
|
||||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||||
import { issueLabel, productionStatusLabel } from './model'
|
import { issueLabel, productionStatusLabel } from './model'
|
||||||
import { useProduction } from './useProduction'
|
import { useProduction } from './useProduction'
|
||||||
@@ -344,55 +344,65 @@ watch(
|
|||||||
<span class="text-xs text-muted">{{ shots.length }} 个正式镜头</span>
|
<span class="text-xs text-muted">{{ shots.length }} 个正式镜头</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shot" class="panel production-workspace">
|
<div v-if="shot" class="panel production-workspace">
|
||||||
<nav class="production-shot-list" aria-label="本集生产镜头">
|
<nav class="production-shot-list directory-panel" aria-label="本集生产镜头">
|
||||||
<NScrollbar class="panel-scroll" content-class="production-shot-list-content" x-scrollable>
|
<div class="directory-heading">
|
||||||
<NButton
|
<h3>
|
||||||
|
生产镜头 <span>{{ shots.length }} 镜</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<NScrollbar
|
||||||
|
class="panel-scroll directory-scroll"
|
||||||
|
content-class="directory-list-content production-shot-list-content"
|
||||||
|
x-scrollable
|
||||||
|
>
|
||||||
|
<DirectoryItem
|
||||||
v-for="item in shots"
|
v-for="item in shots"
|
||||||
:key="item.shotId"
|
:key="item.shotId"
|
||||||
:aria-current="item.shotId === shot.shotId ? 'true' : undefined"
|
:active="item.shotId === shot.shotId"
|
||||||
@click="selectedShot = item.shotId"
|
@click="selectedShot = item.shotId"
|
||||||
class="production-shot-link"
|
class="production-shot-link"
|
||||||
:class="{ selected: item.shotId === shot.shotId }"
|
>{{ item.title }}
|
||||||
><span class="font-mono text-[10px] text-muted">B{{ item.beatNo }} / S{{ item.shotNo }}</span
|
<template #eyebrow>BEAT {{ item.beatNo }} / SHOT {{ item.shotNo }}</template>
|
||||||
><span class="mt-1 block truncate text-xs font-medium">{{ item.title }}</span
|
<template #meta
|
||||||
><span
|
><span
|
||||||
v-if="
|
|
||||||
query.data.value?.keyframes.items.find(row => row.shotId === item.shotId)
|
|
||||||
?.primaryKeyframeStale
|
|
||||||
"
|
|
||||||
class="mt-2 flex items-center gap-2 text-[10px]"
|
|
||||||
>
|
|
||||||
<CircleAlert :size="12" />主首帧已过期 </span
|
|
||||||
><span
|
|
||||||
v-else-if="
|
|
||||||
query.data.value?.videos.items.find(row => row.shotId === item.shotId)
|
|
||||||
?.primaryVideoStale
|
|
||||||
"
|
|
||||||
class="mt-2 flex items-center gap-2 text-[10px]"
|
|
||||||
>
|
|
||||||
<CircleAlert :size="12" />主视频已过期 </span
|
|
||||||
><span v-else class="mt-2 flex items-center gap-2 text-[10px] text-muted">
|
|
||||||
<CheckCircle2
|
|
||||||
v-if="
|
v-if="
|
||||||
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status ===
|
query.data.value?.keyframes.items.find(row => row.shotId === item.shotId)
|
||||||
'skipped'
|
?.primaryKeyframeStale
|
||||||
"
|
"
|
||||||
:size="12"
|
class="directory-status"
|
||||||
/>
|
>
|
||||||
<CircleAlert
|
<CircleAlert :size="12" />主首帧已过期 </span
|
||||||
|
><span
|
||||||
v-else-if="
|
v-else-if="
|
||||||
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status ===
|
query.data.value?.videos.items.find(row => row.shotId === item.shotId)
|
||||||
'blocked'
|
?.primaryVideoStale
|
||||||
"
|
"
|
||||||
:size="12"
|
class="directory-status"
|
||||||
/>
|
>
|
||||||
{{
|
<CircleAlert :size="12" />主视频已过期 </span
|
||||||
productionStatusLabel(
|
><span v-else class="directory-status">
|
||||||
query.data.value?.videoStatus.items.find(row => row.shotId === item.shotId)
|
<CheckCircle2
|
||||||
?.status || 'not_started'
|
v-if="
|
||||||
)
|
query.data.value?.videos.items.find(row => row.shotId === item.shotId)
|
||||||
}}
|
?.status === 'skipped'
|
||||||
</span></NButton
|
"
|
||||||
|
:size="12"
|
||||||
|
/>
|
||||||
|
<CircleAlert
|
||||||
|
v-else-if="
|
||||||
|
query.data.value?.videos.items.find(row => row.shotId === item.shotId)
|
||||||
|
?.status === 'blocked'
|
||||||
|
"
|
||||||
|
:size="12"
|
||||||
|
/>
|
||||||
|
{{
|
||||||
|
productionStatusLabel(
|
||||||
|
query.data.value?.videoStatus.items.find(row => row.shotId === item.shotId)
|
||||||
|
?.status || 'not_started'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span></template
|
||||||
|
></DirectoryItem
|
||||||
>
|
>
|
||||||
</NScrollbar>
|
</NScrollbar>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ArrowLeft, Download, RefreshCw } from '@lucide/vue'
|
import { ArrowLeft, Download, RefreshCw } from '@lucide/vue'
|
||||||
import { EmptyState } from '../../components/ui'
|
import { DirectoryItem, EmptyState } from '../../components/ui'
|
||||||
import { downloadText } from '../../lib/format'
|
import { downloadText } from '../../lib/format'
|
||||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||||
import GenerationReceipt from './components/GenerationReceipt.vue'
|
import GenerationReceipt from './components/GenerationReceipt.vue'
|
||||||
@@ -310,31 +310,35 @@ watch(
|
|||||||
正在读取正式分镜数据……
|
正在读取正式分镜数据……
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="shot" class="panel storyboard-workspace">
|
<div v-else-if="shot" class="panel storyboard-workspace">
|
||||||
<nav class="shot-list" aria-label="本集镜头">
|
<nav class="shot-list directory-panel" aria-label="本集镜头">
|
||||||
<NScrollbar class="panel-scroll" content-class="shot-list-content">
|
<div class="directory-heading">
|
||||||
<p class="px-4 pb-3 pt-5 text-[10px] text-muted">{{ shots.length }} 个镜头 · 按 Beat 排列</p>
|
<h3>
|
||||||
|
镜头目录 <span>{{ shots.length }} 镜</span>
|
||||||
|
</h3>
|
||||||
|
<span>按 Beat 排列</span>
|
||||||
|
</div>
|
||||||
|
<NScrollbar
|
||||||
|
class="panel-scroll directory-scroll"
|
||||||
|
content-class="directory-list-content shot-list-content"
|
||||||
|
x-scrollable
|
||||||
|
>
|
||||||
<template v-for="(item, index) in shots" :key="item.shotId"
|
<template v-for="(item, index) in shots" :key="item.shotId"
|
||||||
><h4
|
><h4
|
||||||
v-if="index === 0 || shots[index - 1]?.beatNo !== item.beatNo"
|
v-if="index === 0 || shots[index - 1]?.beatNo !== item.beatNo"
|
||||||
class="px-4 pb-2 pt-4 font-mono text-[10px] text-muted"
|
class="directory-group-heading"
|
||||||
>
|
>
|
||||||
BEAT {{ String(item.beatNo).padStart(2, '0') }}
|
BEAT {{ String(item.beatNo).padStart(2, '0') }}
|
||||||
</h4>
|
</h4>
|
||||||
<NButton
|
<DirectoryItem
|
||||||
:aria-current="shot.shotId === item.shotId ? 'true' : undefined"
|
:active="shot.shotId === item.shotId"
|
||||||
@click="selectedShot = item.shotId"
|
@click="selectedShot = item.shotId"
|
||||||
class="shot-link"
|
class="shot-link"
|
||||||
:class="{ selected: shot.shotId === item.shotId }"
|
>{{ item.title }}
|
||||||
><span class="font-mono text-[10px] text-muted">{{
|
<template #eyebrow>BEAT {{ item.beatNo }} / SHOT {{ item.shotNo }}</template>
|
||||||
String(item.shotNo).padStart(2, '0')
|
<template #meta>
|
||||||
}}</span
|
<span class="directory-status">{{ item.direction ? '设计已保存' : '待设计' }}</span>
|
||||||
><span class="min-w-0"
|
<span class="directory-status">{{ item.visualState ? '状态已保存' : '待状态' }}</span>
|
||||||
><span class="block truncate text-xs font-medium">{{ item.title }}</span
|
</template></DirectoryItem
|
||||||
><span class="mt-1 block text-[10px] text-muted"
|
|
||||||
>{{ item.direction ? '设计已保存' : '待设计' }} ·
|
|
||||||
{{ item.visualState ? '状态已保存' : '待状态' }}</span
|
|
||||||
></span
|
|
||||||
></NButton
|
|
||||||
></template
|
></template
|
||||||
>
|
>
|
||||||
</NScrollbar>
|
</NScrollbar>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import {
|
|||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { UserCheck } from '@lucide/vue'
|
import { RefreshCw, UserCheck } from '@lucide/vue'
|
||||||
import { AssetImage, EmptyState, StatusBadge } from '../../components/ui'
|
import { AssetImage, DirectoryItem, EmptyState, StatusBadge } from '../../components/ui'
|
||||||
import { downloadText } from '../../lib/format'
|
import { downloadText } from '../../lib/format'
|
||||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||||
import { castingStatusLabel, currentAnchor } from './model'
|
import { castingStatusLabel, currentAnchor } from './model'
|
||||||
@@ -336,62 +336,69 @@ watch(
|
|||||||
></NScrollbar
|
></NScrollbar
|
||||||
>
|
>
|
||||||
<div v-if="subjects.length" class="identity-workspace">
|
<div v-if="subjects.length" class="identity-workspace">
|
||||||
<aside class="panel min-w-0 p-4">
|
<aside class="panel directory-panel identity-directory">
|
||||||
<h3 class="text-sm font-medium">
|
<div class="directory-heading">
|
||||||
主体目录 <span class="ml-2 text-xs text-muted">{{ subjects.length }}</span>
|
<h3>
|
||||||
</h3>
|
主体目录 <span>{{ filtered.length }} / {{ subjects.length }}</span>
|
||||||
<NInput
|
</h3>
|
||||||
placeholder="搜索主体或引用"
|
|
||||||
aria-label="搜索主体身份"
|
|
||||||
:input-props="{ 'aria-label': '搜索主体身份' }"
|
|
||||||
class="mt-4"
|
|
||||||
v-model:value="search"
|
|
||||||
></NInput>
|
|
||||||
<NSelect
|
|
||||||
aria-label="身份主体类型"
|
|
||||||
v-model:value="module"
|
|
||||||
:options="[
|
|
||||||
{ label: String('全部类型'), value: 'all' },
|
|
||||||
{ label: String('人物'), value: 'character' },
|
|
||||||
{ label: String('场景'), value: 'scene' },
|
|
||||||
{ label: String('道具'), value: 'prop' }
|
|
||||||
]"
|
|
||||||
class="mt-3"
|
|
||||||
></NSelect>
|
|
||||||
<NScrollbar
|
|
||||||
class="identity-subject-list panel-scroll mt-4"
|
|
||||||
content-class="identity-subject-list-content"
|
|
||||||
>
|
|
||||||
<NButton
|
<NButton
|
||||||
|
:disabled="catalog.loading.value || castingQuery.loading.value"
|
||||||
|
@click="refreshDirectory"
|
||||||
|
quaternary
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
aria-label="刷新主体目录"
|
||||||
|
title="刷新主体目录"
|
||||||
|
><RefreshCw :size="14"
|
||||||
|
/></NButton>
|
||||||
|
</div>
|
||||||
|
<div class="directory-filters">
|
||||||
|
<NInput
|
||||||
|
placeholder="搜索主体或引用"
|
||||||
|
aria-label="搜索主体身份"
|
||||||
|
:input-props="{ 'aria-label': '搜索主体身份' }"
|
||||||
|
v-model:value="search"
|
||||||
|
clearable
|
||||||
|
></NInput>
|
||||||
|
<NSelect
|
||||||
|
aria-label="身份主体类型"
|
||||||
|
v-model:value="module"
|
||||||
|
:options="[
|
||||||
|
{ label: String('全部类型'), value: 'all' },
|
||||||
|
{ label: String('人物'), value: 'character' },
|
||||||
|
{ label: String('场景'), value: 'scene' },
|
||||||
|
{ label: String('道具'), value: 'prop' }
|
||||||
|
]"
|
||||||
|
></NSelect>
|
||||||
|
</div>
|
||||||
|
<NScrollbar
|
||||||
|
class="identity-subject-list panel-scroll directory-scroll"
|
||||||
|
content-class="directory-list-content identity-subject-list-content"
|
||||||
|
x-scrollable
|
||||||
|
>
|
||||||
|
<DirectoryItem
|
||||||
v-for="item in filtered"
|
v-for="item in filtered"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:aria-pressed="selectedId === item.id"
|
:aria-pressed="selectedId === item.id"
|
||||||
|
:active="selectedId === item.id"
|
||||||
@click="select(item.id)"
|
@click="select(item.id)"
|
||||||
class="identity-subject-item"
|
class="identity-subject-item"
|
||||||
:class="{ selected: selectedId === item.id }"
|
>{{ item.name }}
|
||||||
><span class="block font-medium">{{ item.name }}</span
|
<template #eyebrow>{{ item.ref }}</template>
|
||||||
><span class="mt-1 block text-[11px] text-muted"
|
<template #meta>
|
||||||
>{{ item.ref }} · {{ item.forms.length }} 个形态<span
|
<span>{{ item.forms.length }} 个形态</span
|
||||||
|
><span
|
||||||
v-if="casting?.items.find(row => row.subjectId === item.id)"
|
v-if="casting?.items.find(row => row.subjectId === item.id)"
|
||||||
|
class="directory-status"
|
||||||
>
|
>
|
||||||
·
|
|
||||||
{{
|
{{
|
||||||
castingStatusLabel(casting.items.find(row => row.subjectId === item.id)!.status)
|
castingStatusLabel(casting.items.find(row => row.subjectId === item.id)!.status)
|
||||||
}}</span
|
}}</span
|
||||||
></span
|
>
|
||||||
></NButton
|
</template></DirectoryItem
|
||||||
>
|
>
|
||||||
<p v-if="!filtered.length" class="py-6 text-xs text-muted">没有匹配主体,请调整筛选。</p>
|
<p v-if="!filtered.length" class="py-6 text-xs text-muted">没有匹配主体,请调整筛选。</p>
|
||||||
</NScrollbar>
|
</NScrollbar>
|
||||||
<NButton
|
|
||||||
:disabled="catalog.loading.value || castingQuery.loading.value"
|
|
||||||
@click="refreshDirectory"
|
|
||||||
text
|
|
||||||
size="small"
|
|
||||||
class="mt-4"
|
|
||||||
>
|
|
||||||
刷新主体目录
|
|
||||||
</NButton>
|
|
||||||
</aside>
|
</aside>
|
||||||
<NScrollbar class="identity-detail panel-scroll min-w-0">
|
<NScrollbar class="identity-detail panel-scroll min-w-0">
|
||||||
<NAlert v-if="pendingSelection" type="info" :show-icon="false" class="mb-4"
|
<NAlert v-if="pendingSelection" type="info" :show-icon="false" class="mb-4"
|
||||||
|
|||||||
@@ -211,29 +211,31 @@ function resetFilters() {
|
|||||||
</ul>
|
</ul>
|
||||||
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话;图片以刷新后的数据库记录为准。</p>
|
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话;图片以刷新后的数据库记录为准。</p>
|
||||||
</section>
|
</section>
|
||||||
<div class="my-6 flex flex-wrap items-center gap-4">
|
<div class="form-image-filter-region">
|
||||||
<label class="search-field">
|
<div class="form-image-filters" role="group" aria-label="形态图片筛选">
|
||||||
<Search :size="14" />
|
|
||||||
<NInput
|
<NInput
|
||||||
placeholder="搜索主体、引用或形态"
|
placeholder="搜索主体、引用或形态"
|
||||||
:input-props="{ 'aria-label': '搜索形态图片' }"
|
:input-props="{ 'aria-label': '搜索形态图片' }"
|
||||||
v-model:value="search"
|
v-model:value="search"
|
||||||
|
clearable
|
||||||
|
><template #prefix><Search :size="14" /></template
|
||||||
></NInput>
|
></NInput>
|
||||||
</label>
|
<NSelect
|
||||||
<NSelect
|
aria-label="筛选主体类型"
|
||||||
aria-label="筛选主体类型"
|
v-model:value="module"
|
||||||
v-model:value="module"
|
:options="[
|
||||||
:options="[
|
{ label: String('全部类型'), value: 'all' },
|
||||||
{ label: String('全部类型'), value: 'all' },
|
{ label: String('人物'), value: 'character' },
|
||||||
{ label: String('人物'), value: 'character' },
|
{ label: String('场景'), value: 'scene' },
|
||||||
{ label: String('场景'), value: 'scene' },
|
{ label: String('道具'), value: 'prop' }
|
||||||
{ label: String('道具'), value: 'prop' }
|
]"
|
||||||
]"
|
></NSelect>
|
||||||
class="select-control"
|
<div class="filter-toggles">
|
||||||
></NSelect>
|
<NCheckbox v-model:checked="onlyMissing">仅看缺少主图</NCheckbox>
|
||||||
<NCheckbox v-model:checked="onlyMissing" class="flex items-center gap-2 text-xs">仅看缺少主图 </NCheckbox>
|
<NCheckbox v-model:checked="onlyStale">仅看身份过期</NCheckbox>
|
||||||
<NCheckbox v-model:checked="onlyStale" class="flex items-center gap-2 text-xs">仅看身份过期 </NCheckbox>
|
</div>
|
||||||
<span class="ml-auto text-xs text-muted">{{ filtered.length }} 个形态</span>
|
<span class="filter-result-count">{{ filtered.length }} 个形态</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!query.data.value && query.loading.value" class="py-10 text-sm text-muted" role="status">
|
<p v-if="!query.data.value && query.loading.value" class="py-10 text-sm text-muted" role="status">
|
||||||
正在读取形态和已有图片…
|
正在读取形态和已有图片…
|
||||||
|
|||||||
@@ -177,6 +177,10 @@ describe('内容级滚动下的生产反馈', () => {
|
|||||||
expect(directory.get('.n-scrollbar-container').element).not.toBe(detail.get('.n-scrollbar-container').element)
|
expect(directory.get('.n-scrollbar-container').element).not.toBe(detail.get('.n-scrollbar-container').element)
|
||||||
const items = directory.findAll('.production-shot-link')
|
const items = directory.findAll('.production-shot-link')
|
||||||
expect(items).toHaveLength(2)
|
expect(items).toHaveLength(2)
|
||||||
|
expect(directory.get('.directory-heading').text()).toContain('生产镜头')
|
||||||
|
expect(directory.get('.directory-heading').element.closest('.n-scrollbar-container')).toBeNull()
|
||||||
|
expect(items[0]!.find('.directory-item-title').exists()).toBe(true)
|
||||||
|
expect(items[0]!.find('.directory-item-meta .directory-status').exists()).toBe(true)
|
||||||
await items[1]!.trigger('click')
|
await items[1]!.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(items[1]!.attributes('aria-current')).toBe('true')
|
expect(items[1]!.attributes('aria-current')).toBe('true')
|
||||||
@@ -232,9 +236,12 @@ describe('视觉风格与主体身份工作区', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
await mountAssets('identity', '?subjectId=subject-db-1')
|
await mountAssets('identity', '?subjectId=subject-db-1')
|
||||||
|
const sidebar = wrapper!.get('.identity-directory')
|
||||||
|
expect(sidebar.get('.directory-filters').element.closest('.n-scrollbar-container')).toBeNull()
|
||||||
|
expect(sidebar.get('.identity-subject-list').findAll('.directory-item')).toHaveLength(2)
|
||||||
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
|
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
button('刷新主体目录').click()
|
await wrapper!.get('[aria-label="刷新主体目录"]').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper!.findAll('.identity-subject-item')[1]!.attributes('aria-pressed')).toBe('true')
|
expect(wrapper!.findAll('.identity-subject-item')[1]!.attributes('aria-pressed')).toBe('true')
|
||||||
})
|
})
|
||||||
@@ -631,6 +638,47 @@ describe('视觉风格与主体身份工作区', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('工作台页面交互', () => {
|
describe('工作台页面交互', () => {
|
||||||
|
it('形态筛选搜索、类型和勾选组合仍只过滤本地显示,控件与结果数处于同一工具栏', async () => {
|
||||||
|
const person = formFixture()
|
||||||
|
const scene = {
|
||||||
|
...formFixture(),
|
||||||
|
id: 'scene-form',
|
||||||
|
images: [],
|
||||||
|
subject: { ...person.subject, id: 'scene-subject', ref: '@SC0001', name: '旧书店', module: 'scene' }
|
||||||
|
}
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => jsonResponse([person, scene]))
|
||||||
|
vi.stubGlobal('fetch', fetcher)
|
||||||
|
await mountSubjectImages()
|
||||||
|
const filters = wrapper!.get('.form-image-filters')
|
||||||
|
expect(filters.get('.filter-result-count').text()).toBe('2 个形态')
|
||||||
|
expect(filters.find('input[aria-label="搜索形态图片"]').exists()).toBe(true)
|
||||||
|
expect(filters.findAll('.filter-toggles [role="checkbox"]')).toHaveLength(2)
|
||||||
|
await filters.findAll('.filter-toggles [role="checkbox"]')[0]!.trigger('click')
|
||||||
|
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
|
||||||
|
expect(wrapper!.get('.form-image-card').text()).toContain('旧书店')
|
||||||
|
selectControl(wrapper!, 'aria-label', '筛选主体类型').vm.$emit('update:value', 'character')
|
||||||
|
await flushPromises()
|
||||||
|
expect(filters.get('.filter-result-count').text()).toBe('0 个形态')
|
||||||
|
selectControl(wrapper!, 'aria-label', '筛选主体类型').vm.$emit('update:value', 'all')
|
||||||
|
await filters.get('input[aria-label="搜索形态图片"]').setValue('旧书店')
|
||||||
|
expect(filters.get('.filter-result-count').text()).toBe('1 个形态')
|
||||||
|
await filters.findAll('.filter-toggles [role="checkbox"]')[1]!.trigger('click')
|
||||||
|
expect(filters.get('.filter-result-count').text()).toBe('0 个形态')
|
||||||
|
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('拆解导出按钮紧邻标签栏且保持空快照禁用状态', () => {
|
||||||
|
wrapper = mount(BreakdownPage, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
|
||||||
|
})
|
||||||
|
const toolbar = wrapper.get('.result-toolbar')
|
||||||
|
expect(toolbar.find('[aria-label="拆解结果"]').exists()).toBe(true)
|
||||||
|
const exportButton = toolbar.get<HTMLButtonElement>(':scope > button')
|
||||||
|
expect(exportButton.text()).toBe('导出 JSON')
|
||||||
|
expect(exportButton.element.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('形态图库自动显示后端已有图片,不需要再次生图;筛选只改变显示', async () => {
|
it('形态图库自动显示后端已有图片,不需要再次生图;筛选只改变显示', async () => {
|
||||||
const fetcher = vi
|
const fetcher = vi
|
||||||
.fn<typeof fetch>()
|
.fn<typeof fetch>()
|
||||||
|
|||||||
@@ -72,22 +72,6 @@
|
|||||||
max-height: 560px;
|
max-height: 560px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.identity-subject-item {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.identity-subject-item:hover {
|
|
||||||
background: var(--app-subtle);
|
|
||||||
}
|
|
||||||
.identity-subject-item.selected {
|
|
||||||
background: var(--app-subtle);
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
.casting-stats {
|
.casting-stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
@@ -341,20 +325,6 @@
|
|||||||
background: var(--app-surface);
|
background: var(--app-surface);
|
||||||
padding-block: 8px;
|
padding-block: 8px;
|
||||||
}
|
}
|
||||||
.production-shot-link {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 13px 15px;
|
|
||||||
border-left: 2px solid transparent;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.production-shot-link:hover {
|
|
||||||
background: var(--app-subtle);
|
|
||||||
}
|
|
||||||
.production-shot-link.selected {
|
|
||||||
border-left-color: var(--app-ink);
|
|
||||||
background: var(--app-subtle);
|
|
||||||
}
|
|
||||||
.production-stage {
|
.production-stage {
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
border-top: 1px solid var(--color-line);
|
border-top: 1px solid var(--color-line);
|
||||||
@@ -417,15 +387,6 @@
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px solid var(--color-line);
|
border-bottom: 1px solid var(--color-line);
|
||||||
}
|
}
|
||||||
.production-shot-link {
|
|
||||||
width: 155px;
|
|
||||||
min-width: 155px;
|
|
||||||
border-left: none;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
}
|
|
||||||
.production-shot-link.selected {
|
|
||||||
border-bottom-color: var(--app-ink);
|
|
||||||
}
|
|
||||||
.production-status-grid,
|
.production-status-grid,
|
||||||
.keyframe-grid {
|
.keyframe-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -617,22 +578,6 @@
|
|||||||
background: var(--app-surface);
|
background: var(--app-surface);
|
||||||
padding: 0 8px 20px;
|
padding: 0 8px 20px;
|
||||||
}
|
}
|
||||||
.shot-link {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.shot-link:hover {
|
|
||||||
background: var(--app-subtle);
|
|
||||||
}
|
|
||||||
.shot-link.selected {
|
|
||||||
background: var(--app-subtle);
|
|
||||||
color: var(--app-ink);
|
|
||||||
}
|
|
||||||
.reference-grid {
|
.reference-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
|||||||
Reference in New Issue
Block a user