style: 恢复标签页分隔线并统一工具栏与图标按钮
This commit is contained in:
@@ -90,6 +90,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
|||||||
- 项目标题栏只显示返回入口和名称,不再放置状态标签与刷新按钮;项目仍每 6 秒自动刷新,读取失败时在错误提示内提供重试入口。
|
- 项目标题栏只显示返回入口和名称,不再放置状态标签与刷新按钮;项目仍每 6 秒自动刷新,读取失败时在错误提示内提供重试入口。
|
||||||
- 只有当前项目正式状态为 `completed` 才解锁下游工作区;草稿、生成中、待审核、失败和读取期间默认禁用下游导航。直接访问下游链接只显示返回剧本创作的引导,不挂载操作组件;轮询确认完成后自动解锁,切换项目立即重新判定。页面内提交守卫同步检查完成状态;这是前端交互限制,不代替后端权限与业务校验。
|
- 只有当前项目正式状态为 `completed` 才解锁下游工作区;草稿、生成中、待审核、失败和读取期间默认禁用下游导航。直接访问下游链接只显示返回剧本创作的引导,不挂载操作组件;轮询确认完成后自动解锁,切换项目立即重新判定。页面内提交守卫同步检查完成状态;这是前端交互限制,不代替后端权限与业务校验。
|
||||||
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
||||||
|
- “我的剧本”状态筛选采用中号控件,与搜索框等高并扩大点击区域;刷新、主题、折叠和关闭等单图标按钮统一使用直角正方形,加载时保持尺寸不变,保留可访问名称。
|
||||||
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
|
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
|
||||||
- 身份与形态图库采用限高预览及紧邻其下的横向历史栏;缩略图固定宽度,保留全部候选、辅助视图、失败及进行中记录。点击仅切换预览,不自动切换母版或主图;图片尺寸规则放在非分层组件覆盖样式中,避免被 Naive 按钮默认宽度覆盖。
|
- 身份与形态图库采用限高预览及紧邻其下的横向历史栏;缩略图固定宽度,保留全部候选、辅助视图、失败及进行中记录。点击仅切换预览,不自动切换母版或主图;图片尺寸规则放在非分层组件覆盖样式中,避免被 Naive 按钮默认宽度覆盖。
|
||||||
- 图片统一使用 `NImage`:身份/形态图库的详情大图采用 `contain`,完整显示人物与画面;母版小图、历史缩略图及形态/风格/分镜卡片、首帧缩略图保留 `cover`。母版与展示图片可点击原生预览查看原图;历史缩略图只切换记录,再点击大图查看原图。“查看图片与记录”仍单独打开历史管理。
|
- 图片统一使用 `NImage`:身份/形态图库的详情大图采用 `contain`,完整显示人物与画面;母版小图、历史缩略图及形态/风格/分镜卡片、首帧缩略图保留 `cover`。母版与展示图片可点击原生预览查看原图;历史缩略图只切换记录,再点击大图查看原图。“查看图片与记录”仍单独打开历史管理。
|
||||||
@@ -106,6 +107,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
|||||||
- 绿色选中复选框使用白色对勾;批量配置中的复选框与中号输入框共用 34px 控件行并垂直居中。浅色输入框按所在区域使用白底或浅灰底,与页面/抽屉或白色面板区分;聚焦与禁用反馈继续保留。
|
- 绿色选中复选框使用白色对勾;批量配置中的复选框与中号输入框共用 34px 控件行并垂直居中。浅色输入框按所在区域使用白底或浅灰底,与页面/抽屉或白色面板区分;聚焦与禁用反馈继续保留。
|
||||||
- 采用微信风格配色:浅色为浅灰背景与白色内容,暗色为近黑背景与深灰内容;绿色用于主操作、选中态和进度。按钮文字、目录文字与主绿色分别配置以保证对比度,错误/警告仍使用独立语义色。Naive UI 和业务内容共用主题,浏览器主题色随之切换;加载前应用偏好,减少主题闪烁。
|
- 采用微信风格配色:浅色为浅灰背景与白色内容,暗色为近黑背景与深灰内容;绿色用于主操作、选中态和进度。按钮文字、目录文字与主绿色分别配置以保证对比度,错误/警告仍使用独立语义色。Naive UI 和业务内容共用主题,浏览器主题色随之切换;加载前应用偏好,减少主题闪烁。
|
||||||
- 全站采用低边框、直角样式:面板、提示框、标签、图片卡片和表单用背景层次代替静态描边;表格与长记录列表使用交替底色,抽屉用灰底承托内容。保留输入框聚焦/校验反馈、复选框、选中指示和必要的浮层阴影;开关、加载等功能图形不强制改形状。
|
- 全站采用低边框、直角样式:面板、提示框、标签、图片卡片和表单用背景层次代替静态描边;表格与长记录列表使用交替底色,抽屉用灰底承托内容。保留输入框聚焦/校验反馈、复选框、选中指示和必要的浮层阴影;开关、加载等功能图形不强制改形状。
|
||||||
|
- 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` 管理滚动。
|
||||||
|
|||||||
+6
-3
@@ -122,7 +122,7 @@ const menuOptions = computed<MenuOption[]>(() => [
|
|||||||
quaternary
|
quaternary
|
||||||
block
|
block
|
||||||
class="admin-settings-button"
|
class="admin-settings-button"
|
||||||
:class="{ 'is-collapsed': collapsed }"
|
:class="{ 'is-collapsed': collapsed, 'icon-button': collapsed }"
|
||||||
aria-label="后端连接"
|
aria-label="后端连接"
|
||||||
@click="settingsOpen = true"
|
@click="settingsOpen = true"
|
||||||
>
|
>
|
||||||
@@ -139,11 +139,14 @@ const menuOptions = computed<MenuOption[]>(() => [
|
|||||||
<div class="flex min-w-0 items-center gap-3">
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
<NButton
|
<NButton
|
||||||
quaternary
|
quaternary
|
||||||
circle
|
class="icon-button"
|
||||||
:aria-label="collapsed ? '展开侧栏' : '折叠侧栏'"
|
:aria-label="collapsed ? '展开侧栏' : '折叠侧栏'"
|
||||||
|
:title="collapsed ? '展开侧栏' : '折叠侧栏'"
|
||||||
@click="collapsed = !collapsed"
|
@click="collapsed = !collapsed"
|
||||||
>
|
>
|
||||||
<PanelLeftOpen v-if="collapsed" :size="18" /><PanelLeftClose v-else :size="18" />
|
<template #icon
|
||||||
|
><PanelLeftOpen v-if="collapsed" :size="18" /><PanelLeftClose v-else :size="18"
|
||||||
|
/></template>
|
||||||
</NButton>
|
</NButton>
|
||||||
<span class="truncate text-sm">{{ route.meta.title || '工作空间' }}</span>
|
<span class="truncate text-sm">{{ route.meta.title || '工作空间' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+69
-1
@@ -100,6 +100,10 @@ body {
|
|||||||
.n-button.admin-settings-button.is-collapsed {
|
.n-button.admin-settings-button.is-collapsed {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
|
width: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
.admin-settings-button.is-collapsed .n-button__icon {
|
.admin-settings-button.is-collapsed .n-button__icon {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -144,10 +148,20 @@ body {
|
|||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
/* 图标按钮保留原有方形点击热区;仅取消 circle 属性产生的圆形外观。 */
|
/* 按钮保持直角,单图标按钮以 Naive 当前尺寸为边长,加载时也不改变宽度。 */
|
||||||
.n-button {
|
.n-button {
|
||||||
border-radius: 0;
|
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 {
|
.n-button:focus-visible {
|
||||||
outline: 2px solid var(--app-accent-text);
|
outline: 2px solid var(--app-accent-text);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
@@ -1037,6 +1051,60 @@ body {
|
|||||||
.project-search {
|
.project-search {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
}
|
}
|
||||||
|
/* 项目筛选与搜索框同高,保留更宽的点击区;窄屏可自然换行。 */
|
||||||
|
.project-status-filters.n-radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
height: auto;
|
||||||
|
min-height: var(--n-height);
|
||||||
|
gap: 4px 1px;
|
||||||
|
}
|
||||||
|
.project-status-filters .n-radio-group__splitor {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.project-status-filters .n-radio-button {
|
||||||
|
min-width: 76px;
|
||||||
|
padding-inline: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
/* 状态说明与操作各自成组,避免小标签夹在不同尺寸的按钮之间。 */
|
||||||
|
.visual-style-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px 24px;
|
||||||
|
margin-block: 20px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--app-subtle);
|
||||||
|
}
|
||||||
|
.visual-style-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.visual-style-status > svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--app-muted);
|
||||||
|
}
|
||||||
|
.visual-style-status strong {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.visual-style-status p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--app-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.visual-style-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
.project-name-cell {
|
.project-name-cell {
|
||||||
display: block;
|
display: block;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -27,13 +27,12 @@ function selectTheme(key: string | number) {
|
|||||||
<template #trigger>
|
<template #trigger>
|
||||||
<NButton
|
<NButton
|
||||||
quaternary
|
quaternary
|
||||||
circle
|
class="theme-toggle icon-button"
|
||||||
class="theme-toggle"
|
|
||||||
:aria-label="`主题模式:${current.label}`"
|
:aria-label="`主题模式:${current.label}`"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
:aria-expanded="open"
|
:aria-expanded="open"
|
||||||
>
|
>
|
||||||
<component :is="current.icon" :size="18" aria-hidden="true" />
|
<template #icon><component :is="current.icon" :size="18" aria-hidden="true" /></template>
|
||||||
</NButton>
|
</NButton>
|
||||||
</template>
|
</template>
|
||||||
{{ current.label }} · 切换主题
|
{{ current.label }} · 切换主题
|
||||||
|
|||||||
@@ -35,9 +35,14 @@ const host = inject(workspaceHostKey, undefined)
|
|||||||
<template #header
|
<template #header
|
||||||
><div class="workspace-tools-heading">
|
><div class="workspace-tools-heading">
|
||||||
<span>{{ title }}</span
|
<span>{{ title }}</span
|
||||||
><NButton quaternary circle aria-label="关闭操作面板" @click="open = false"
|
><NButton
|
||||||
><X :size="18"
|
quaternary
|
||||||
/></NButton></div
|
class="icon-button"
|
||||||
|
aria-label="关闭操作面板"
|
||||||
|
title="关闭操作面板"
|
||||||
|
@click="open = false"
|
||||||
|
><template #icon><X :size="18" /></template
|
||||||
|
></NButton></div
|
||||||
></template>
|
></template>
|
||||||
<slot />
|
<slot />
|
||||||
<template #footer><span class="text-xs text-muted">关闭面板不会取消已提交的后台任务。</span></template>
|
<template #footer><span class="text-xs text-muted">关闭面板不会取消已提交的后台任务。</span></template>
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import { h } from 'vue'
|
import { h } from 'vue'
|
||||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { NAlert, NButton, NCard, NCheckbox, NConfigProvider, NInput, NSelect, NTable, NTag } from 'naive-ui'
|
import {
|
||||||
|
NAlert,
|
||||||
|
NButton,
|
||||||
|
NCard,
|
||||||
|
NCheckbox,
|
||||||
|
NConfigProvider,
|
||||||
|
NInput,
|
||||||
|
NSelect,
|
||||||
|
NTable,
|
||||||
|
NTag,
|
||||||
|
NTabPane,
|
||||||
|
NTabs
|
||||||
|
} from 'naive-ui'
|
||||||
import { readFileSync, readdirSync } from 'node:fs'
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
import { useTheme } from '../../composables/useTheme'
|
import { useTheme } from '../../composables/useTheme'
|
||||||
|
|
||||||
@@ -74,6 +86,10 @@ describe('全站平面主题', () => {
|
|||||||
h(NAlert, { type: 'error' }, () => '生成失败'),
|
h(NAlert, { type: 'error' }, () => '生成失败'),
|
||||||
h(NCard, {}, () => '内容'),
|
h(NCard, {}, () => '内容'),
|
||||||
h(NTag, {}, () => '待设计'),
|
h(NTag, {}, () => '待设计'),
|
||||||
|
h(NTabs, { type: 'line', value: 'one' }, () => [
|
||||||
|
h(NTabPane, { name: 'one', tab: '人物' }, () => '人物内容'),
|
||||||
|
h(NTabPane, { name: 'two', tab: '场景' }, () => '场景内容')
|
||||||
|
]),
|
||||||
h(NTable, { striped: true }, () => h('tbody', [h('tr', [h('td', '记录')])]))
|
h(NTable, { striped: true }, () => h('tbody', [h('tr', [h('td', '记录')])]))
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -103,6 +119,31 @@ 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' : '#e5e5e5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('单图标按钮以当前控件高度为边长,尺寸与内容按钮互不影响', () => {
|
||||||
|
// happy-dom 不计算几何尺寸,此项约束共用样式和调用方,实际布局仍需浏览器验收。
|
||||||
|
const css = readFileSync('src/admin.css', 'utf8')
|
||||||
|
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/
|
||||||
|
)
|
||||||
|
expect(css).toMatch(
|
||||||
|
/\.project-status-filters \.n-radio-button\s*\{[^}]*min-width:\s*76px;[^}]*padding-inline:\s*18px/
|
||||||
|
)
|
||||||
|
expect(css).toMatch(/\.project-status-filters\.n-radio-group\s*\{[^}]*flex-wrap:\s*wrap;[^}]*height:\s*auto;/)
|
||||||
|
for (const file of [
|
||||||
|
'src/App.vue',
|
||||||
|
'src/components/ui/ThemeToggle.vue',
|
||||||
|
'src/components/ui/WorkspaceTools.vue',
|
||||||
|
'src/features/projects/ProjectsPage.vue',
|
||||||
|
'src/features/subject-identity/SubjectIdentityPage.vue'
|
||||||
|
]) {
|
||||||
|
const source = readFileSync(file, 'utf8')
|
||||||
|
expect(source).toContain('icon-button')
|
||||||
|
expect(source).not.toMatch(/<NButton\b[^>]*\bcircle\b/)
|
||||||
|
}
|
||||||
|
expect(readFileSync('src/components/ui/DirectoryItem.vue', 'utf8')).not.toContain('icon-button')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('业务面板、列表分组和标签以背景区分,保留键盘焦点与选中指示', () => {
|
it('业务面板、列表分组和标签以背景区分,保留键盘焦点与选中指示', () => {
|
||||||
|
|||||||
@@ -215,7 +215,8 @@ export function useTheme() {
|
|||||||
tabTextColorActiveLine: accentText,
|
tabTextColorActiveLine: accentText,
|
||||||
tabTextColorHoverLine: accentText,
|
tabTextColorHoverLine: accentText,
|
||||||
barColor: primary,
|
barColor: primary,
|
||||||
tabBorderColor: 'transparent'
|
// Tabs 保留横向分隔线,明确导航与内容边界;选中下划线仍使用主绿色。
|
||||||
|
tabBorderColor: dark ? '#333333' : '#e5e5e5'
|
||||||
},
|
},
|
||||||
Tag: {
|
Tag: {
|
||||||
textColorPrimary: accentText,
|
textColorPrimary: accentText,
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function clearFilters() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="project-index-toolbar">
|
<div class="project-index-toolbar">
|
||||||
<NRadioGroup v-model:value="filter" size="small" aria-label="项目状态筛选"
|
<NRadioGroup v-model:value="filter" size="medium" class="project-status-filters" aria-label="项目状态筛选"
|
||||||
><NRadioButton v-for="item in filters" :key="item.value" :value="item.value">{{
|
><NRadioButton v-for="item in filters" :key="item.value" :value="item.value">{{
|
||||||
item.label
|
item.label
|
||||||
}}</NRadioButton></NRadioGroup
|
}}</NRadioButton></NRadioGroup
|
||||||
@@ -89,9 +89,14 @@ function clearFilters() {
|
|||||||
:input-props="{ 'aria-label': '搜索项目' }"
|
:input-props="{ 'aria-label': '搜索项目' }"
|
||||||
><template #prefix><Search :size="15" /></template
|
><template #prefix><Search :size="15" /></template
|
||||||
></NInput>
|
></NInput>
|
||||||
<NButton :loading="query.loading.value" aria-label="刷新项目" @click="query.refresh"
|
<NButton
|
||||||
><RefreshCw :size="16"
|
class="icon-button"
|
||||||
/></NButton>
|
:loading="query.loading.value"
|
||||||
|
aria-label="刷新项目"
|
||||||
|
title="刷新项目"
|
||||||
|
@click="query.refresh"
|
||||||
|
><template #icon><RefreshCw :size="16" /></template
|
||||||
|
></NButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NAlert v-if="query.error.value" type="error" :show-icon="false"
|
<NAlert v-if="query.error.value" type="error" :show-icon="false"
|
||||||
|
|||||||
@@ -423,12 +423,12 @@ watch(
|
|||||||
:disabled="catalog.loading.value || castingQuery.loading.value"
|
:disabled="catalog.loading.value || castingQuery.loading.value"
|
||||||
@click="refreshDirectory"
|
@click="refreshDirectory"
|
||||||
quaternary
|
quaternary
|
||||||
circle
|
class="icon-button"
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="刷新主体目录"
|
aria-label="刷新主体目录"
|
||||||
title="刷新主体目录"
|
title="刷新主体目录"
|
||||||
><RefreshCw :size="14"
|
><template #icon><RefreshCw :size="14" /></template
|
||||||
/></NButton>
|
></NButton>
|
||||||
</div>
|
</div>
|
||||||
<div class="directory-filters">
|
<div class="directory-filters">
|
||||||
<NInput
|
<NInput
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
import { NAlert, NButton, NTag } from 'naive-ui'
|
import { NAlert, NButton } from 'naive-ui'
|
||||||
|
import { LoaderCircle, LockKeyhole, LockKeyholeOpen, Palette, RefreshCw, TriangleAlert } from '@lucide/vue'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { usePolling } from '../../composables/usePolling'
|
import { usePolling } from '../../composables/usePolling'
|
||||||
import { runOperation } from '../workflows/operations'
|
import { runOperation } from '../workflows/operations'
|
||||||
@@ -24,6 +25,16 @@ const editorRevision = ref(0)
|
|||||||
const imageRevision = ref(0)
|
const imageRevision = ref(0)
|
||||||
const dirty = ref(false)
|
const dirty = ref(false)
|
||||||
|
|
||||||
|
/** 状态是只读说明,与操作按钮分组;读取失败不能被误报成尚未创建。 */
|
||||||
|
const statusInfo = computed(() => {
|
||||||
|
if (query.error.value) return { label: '读取失败', hint: '请刷新重试,现有内容不会被覆盖', icon: TriangleAlert }
|
||||||
|
if (!query.data.value) return { label: '正在读取', hint: '正在获取项目视觉风格', icon: LoaderCircle }
|
||||||
|
if (!style.value) return { label: '尚未创建', hint: '可使用 AI 生成,或在下方手动填写', icon: Palette }
|
||||||
|
return style.value.isLocked
|
||||||
|
? { label: '已锁定', hint: 'AI 不会覆盖,仍可在下方手动编辑', icon: LockKeyhole }
|
||||||
|
: { label: '未锁定', hint: '定稿后可在下方锁定,避免 AI 覆盖', icon: LockKeyholeOpen }
|
||||||
|
})
|
||||||
|
|
||||||
/** 防止错误项目的数据进入编辑器;空风格是正常的首次使用状态。 */
|
/** 防止错误项目的数据进入编辑器;空风格是正常的首次使用状态。 */
|
||||||
function assertProject(value: VisualStyle | null, id: string) {
|
function assertProject(value: VisualStyle | null, id: string) {
|
||||||
if (value && (value.projectId !== id || value.images.some(image => image.visualStyleId !== value.id)))
|
if (value && (value.projectId !== id || value.images.some(image => image.visualStyleId !== value.id)))
|
||||||
@@ -101,10 +112,15 @@ function removeImage(image: VisualStyleImage) {
|
|||||||
>
|
>
|
||||||
</div></template
|
</div></template
|
||||||
>
|
>
|
||||||
<div class="my-5 flex flex-wrap items-center gap-3">
|
<div class="visual-style-toolbar">
|
||||||
<NTag size="small" :bordered="false">{{
|
<div class="visual-style-status" role="status">
|
||||||
style ? (style.isLocked ? '已锁定' : '未锁定') : '尚未创建'
|
<component :is="statusInfo.icon" :size="18" aria-hidden="true" />
|
||||||
}}</NTag>
|
<div>
|
||||||
|
<strong>{{ statusInfo.label }}</strong>
|
||||||
|
<p>{{ statusInfo.hint }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="visual-style-actions">
|
||||||
<ConfirmAction
|
<ConfirmAction
|
||||||
:label="style ? 'AI 重新生成风格' : 'AI 生成风格'"
|
:label="style ? 'AI 重新生成风格' : 'AI 生成风格'"
|
||||||
:disabled="disabled || !!style?.isLocked || dirty"
|
:disabled="disabled || !!style?.isLocked || dirty"
|
||||||
@@ -112,7 +128,10 @@ function removeImage(image: VisualStyleImage) {
|
|||||||
description="调用文本模型生成整体及分类风格。重新生成会覆盖已保存的文本与硬约束;旧身份、旧图片和已有提示词不会自动更新。"
|
description="调用文本模型生成整体及分类风格。重新生成会覆盖已保存的文本与硬约束;旧身份、旧图片和已有提示词不会自动更新。"
|
||||||
@confirm="generate"
|
@confirm="generate"
|
||||||
/>
|
/>
|
||||||
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small">刷新风格</NButton>
|
<NButton :loading="query.loading.value" @click="query.refresh"
|
||||||
|
><template #icon><RefreshCw :size="16" /></template>刷新风格</NButton
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mb-4"
|
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mb-4"
|
||||||
>{{ query.error.value }} 请确认后端 dev 已更新并重启;查询失败不等同于尚未创建。
|
>{{ query.error.value }} 请确认后端 dev 已更新并重启;查询失败不等同于尚未创建。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { computed, nextTick, ref } from 'vue'
|
import { computed, nextTick, ref } from 'vue'
|
||||||
import { NImage, NScrollbar } from 'naive-ui'
|
import { NImage, NRadioGroup, NScrollbar } from 'naive-ui'
|
||||||
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'
|
||||||
@@ -223,6 +223,28 @@ describe('内容级滚动下的生产反馈', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('视觉风格与主体身份工作区', () => {
|
describe('视觉风格与主体身份工作区', () => {
|
||||||
|
it.each([
|
||||||
|
{ locked: null, label: '尚未创建', action: 'AI 生成风格', disabled: false },
|
||||||
|
{ locked: false, label: '未锁定', action: 'AI 重新生成风格', disabled: false },
|
||||||
|
{ locked: true, label: '已锁定', action: 'AI 重新生成风格', disabled: true }
|
||||||
|
])('视觉风格 $label 使用独立状态说明与等高操作按钮,保留锁定限制', async ({ locked, label, action, disabled }) => {
|
||||||
|
const fetcher = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockImplementation(async () => jsonResponse(locked === null ? null : styleFixture({ isLocked: locked })))
|
||||||
|
vi.stubGlobal('fetch', fetcher)
|
||||||
|
await mountAssets('style')
|
||||||
|
const status = wrapper!.get('.visual-style-status')
|
||||||
|
expect(status.attributes('role')).toBe('status')
|
||||||
|
expect(status.get('strong').text()).toBe(label)
|
||||||
|
expect(status.find('button').exists()).toBe(false)
|
||||||
|
expect(wrapper!.find('.visual-style-toolbar .n-tag').exists()).toBe(false)
|
||||||
|
expect(wrapper!.get('.visual-style-actions').findAll('button')).toHaveLength(2)
|
||||||
|
expect(button(action).disabled).toBe(disabled)
|
||||||
|
expect(button(action).style.getPropertyValue('--n-height')).toBe('34px')
|
||||||
|
expect(button('刷新风格').style.getPropertyValue('--n-height')).toBe('34px')
|
||||||
|
expect(fetcher.mock.calls.every(([, options]) => !options?.method || options.method === 'GET')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('主体目录显示已确认母版和默认占位,点缩略图只切换主体,不重复读取所有角色图库', async () => {
|
it('主体目录显示已确认母版和默认占位,点缩略图只切换主体,不重复读取所有角色图库', async () => {
|
||||||
const first = formFixture()
|
const first = formFixture()
|
||||||
const forms = [
|
const forms = [
|
||||||
@@ -1326,6 +1348,11 @@ describe('工作台页面交互', () => {
|
|||||||
await router.push('/')
|
await router.push('/')
|
||||||
wrapper = mount(ProjectsPage, { attachTo: document.body, global: { plugins: [router] } })
|
wrapper = mount(ProjectsPage, { attachTo: document.body, global: { plugins: [router] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
expect(wrapper.getComponent(NRadioGroup).props('size')).toBe('medium')
|
||||||
|
const refresh = wrapper.get('[aria-label="刷新项目"]')
|
||||||
|
expect(refresh.classes()).toContain('icon-button')
|
||||||
|
expect(refresh.find('.n-button__icon svg').exists()).toBe(true)
|
||||||
|
expect(refresh.text()).toBe('')
|
||||||
expect(wrapper.text()).toContain('雨夜来信')
|
expect(wrapper.text()).toContain('雨夜来信')
|
||||||
await wrapper.get('input[aria-label="搜索项目"]').setValue('不存在的关键词')
|
await wrapper.get('input[aria-label="搜索项目"]').setValue('不存在的关键词')
|
||||||
expect(wrapper.text()).toContain('没有匹配的剧本')
|
expect(wrapper.text()).toContain('没有匹配的剧本')
|
||||||
|
|||||||
Reference in New Issue
Block a user