feat: 调整微信风格明暗主题并完整展示详情图片

This commit is contained in:
GouJ
2026-09-01 19:09:35 +08:00
parent 3a60eebae0
commit 565442c9b1
11 changed files with 202 additions and 70 deletions
+2 -2
View File
@@ -90,14 +90,14 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。 - 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。 - 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
- 身份与形态图库采用限高预览及紧邻其下的横向历史栏;缩略图固定宽度,保留全部候选、辅助视图、失败及进行中记录。点击仅切换预览,不自动切换母版或主图;图片尺寸规则放在非分层组件覆盖样式中,避免被 Naive 按钮默认宽度覆盖。 - 身份与形态图库采用限高预览及紧邻其下的横向历史栏;缩略图固定宽度,保留全部候选、辅助视图、失败及进行中记录。点击仅切换预览,不自动切换母版或主图;图片尺寸规则放在非分层组件覆盖样式中,避免被 Naive 按钮默认宽度覆盖。
- 身份母版、图库大图、历史缩略图形态/风格/分镜参考图及首帧统一通过 `NImage` 采用 `cover` 铺满。母版与展示图片可点击原生预览查看完整原图;历史缩略图只切换选中记录,再点击大图查看原图,避免与候选选择混淆。“查看图片与记录”仍单独打开历史管理。 - 图片统一使用 `NImage`:身份/形态图库的详情大图采用 `contain`,完整显示人物与画面;母版小图、历史缩略图形态/风格/分镜卡片、首帧缩略图保留 `cover`。母版与展示图片可点击原生预览查看原图;历史缩略图只切换记录,再点击大图查看原图。“查看图片与记录”仍单独打开历史管理。
- 拆解页固定精简状态栏与结果标签,设置、预览及恢复操作收进“拆解设置与恢复”面板;人物、场景、道具、分镜和任务明细共用独立结果滚动区,执行记录单独滚动。结果区占满剩余高度,切换标签回到顶部,轮询刷新或开关设置不重置阅读位置;失败与校验提示在面板关闭时仍然可见。 - 拆解页固定精简状态栏与结果标签,设置、预览及恢复操作收进“拆解设置与恢复”面板;人物、场景、道具、分镜和任务明细共用独立结果滚动区,执行记录单独滚动。结果区占满剩余高度,切换标签回到顶部,轮询刷新或开关设置不重置阅读位置;失败与校验提示在面板关闭时仍然可见。
- 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。 - 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。
- 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。 - 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。
- 身份、分镜、生产与图库的批量配置、说明和诊断统一放入 `WorkspaceTools`Naive UI Drawer),按需覆盖当前工作区,不挤压列表。面板支持 Esc 或关闭按钮;关闭不会取消任务或清除配置。历史回执可从入口查看,新回执主动打开诊断;查询错误始终在外层可见。 - 身份、分镜、生产与图库的批量配置、说明和诊断统一放入 `WorkspaceTools`Naive UI Drawer),按需覆盖当前工作区,不挤压列表。面板支持 Esc 或关闭按钮;关闭不会取消任务或清除配置。历史回执可从入口查看,新回执主动打开诊断;查询错误始终在外层可见。
- 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class` - 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class`
- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。 - 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。
- 以黑白灰为主,仅错误警告、成功等语义状态保留辅助色。Naive UI 和业务内容共用主题;加载前应用偏好,减少主题闪烁。 - 采用微信风格配色:浅色为浅灰背景与白色内容,暗色为近黑背景与深灰内容;绿色用于主操作、选中态和进度。按钮文字、目录文字与主绿色分别配置以保证对比度,错误警告仍使用独立语义色。Naive UI 和业务内容共用主题,浏览器主题色随之切换;加载前应用偏好,减少主题闪烁。
- 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。 - 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。
布局样式集中在 `src/admin.css`,业务排版保留在 `src/styles.css`。新增页面应使用 `WorkspacePage`(内容优先页开启 `compact`),低频操作使用 `WorkspaceTools`;避免通过 `body``window.scrollTo` 管理滚动。 布局样式集中在 `src/admin.css`,业务排版保留在 `src/styles.css`。新增页面应使用 `WorkspacePage`(内容优先页开启 `compact`),低频操作使用 `WorkspaceTools`;避免通过 `body``window.scrollTo` 管理滚动。
+2 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#202020" /> <meta name="theme-color" content="#ededed" />
<script> <script>
// 在应用挂载前恢复主题,避免深色模式出现白色闪屏。 // 在应用挂载前恢复主题,避免深色模式出现白色闪屏。
try { try {
@@ -13,6 +13,7 @@
((!preference || preference === 'system') && matchMedia('(prefers-color-scheme: dark)').matches) ((!preference || preference === 'system') && matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.dataset.theme = dark ? 'dark' : 'light' document.documentElement.dataset.theme = dark ? 'dark' : 'light'
document.documentElement.style.colorScheme = dark ? 'dark' : 'light' document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
document.querySelector('meta[name="theme-color"]').setAttribute('content', dark ? '#111111' : '#ededed')
} catch { } catch {
/* 存储不可用时由应用主题配置接管。 */ /* 存储不可用时由应用主题配置接管。 */
} }
+34 -25
View File
@@ -1,25 +1,34 @@
/* 管理后台视口边界:document 不滚动,各个工作区和面板独立滚动。 */ /* 管理后台视口边界:document 不滚动,各个工作区和面板独立滚动。 */
:root { :root {
--app-ink: #202020; --app-ink: #191919;
--app-muted: #737373; --app-muted: #707070;
--app-body: #ededed;
--app-surface: #ffffff; --app-surface: #ffffff;
--app-subtle: #f5f5f5; --app-subtle: #f7f7f7;
--app-border: #e5e5e5; --app-border: #e5e5e5;
--app-inverse: #ffffff; --app-inverse: #ffffff;
--app-accent: #07c160;
--app-accent-hover: #2dcb79;
--app-accent-text: #087c42;
--app-on-accent: #082b17;
--app-selected: #e4f4e9;
--app-shadow: #00000018; --app-shadow: #00000018;
--app-danger: #b42318; --app-danger: #a93232;
--app-success: #447454; --app-success: #087c42;
} }
:root[data-theme='dark'] { :root[data-theme='dark'] {
--app-ink: #ededed; --app-ink: #d9d9d9;
--app-muted: #a3a3a3; --app-muted: #a3a3a3;
--app-surface: #1c1c1c; --app-body: #111111;
--app-subtle: #252525; --app-surface: #191919;
--app-border: #363636; --app-subtle: #232323;
--app-inverse: #171717; --app-border: #333333;
--app-inverse: #111111;
--app-accent-text: #5cd693;
--app-selected: #173527;
--app-shadow: #00000055; --app-shadow: #00000055;
--app-danger: #ee8b83; --app-danger: #fa7373;
--app-success: #91ba9b; --app-success: #5cd693;
} }
html, html,
body, body,
@@ -30,7 +39,7 @@ body,
overflow: hidden; overflow: hidden;
} }
html { html {
background: var(--app-surface); background: var(--app-body);
} }
body { body {
margin: 0; margin: 0;
@@ -515,11 +524,11 @@ body {
flex-shrink: 0; flex-shrink: 0;
} }
.n-button.directory-item.selected { .n-button.directory-item.selected {
background: var(--app-surface); background: var(--app-selected);
box-shadow: inset 3px 0 var(--app-ink); box-shadow: inset 3px 0 var(--app-accent);
} }
.n-button.directory-item:focus-visible { .n-button.directory-item:focus-visible {
outline: 2px solid var(--app-ink); outline: 2px solid var(--app-accent);
outline-offset: -2px; outline-offset: -2px;
} }
/* Tabs 的默认整行宽度限制在左侧网格内,导出按钮始终处于同一行右端。 */ /* Tabs 的默认整行宽度限制在左侧网格内,导出按钮始终处于同一行右端。 */
@@ -695,9 +704,9 @@ body {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.n-button.reader-episode-link.active { .n-button.reader-episode-link.active {
background: var(--app-surface); background: var(--app-selected);
color: var(--app-ink); color: var(--app-accent-text);
box-shadow: inset 3px 0 var(--app-ink); box-shadow: inset 3px 0 var(--app-accent);
} }
.reader-page { .reader-page {
min-width: 0; min-width: 0;
@@ -706,7 +715,7 @@ body {
background: var(--app-surface); background: var(--app-surface);
} }
.reader-scroll .n-scrollbar-container:focus-visible { .reader-scroll .n-scrollbar-container:focus-visible {
outline: 2px solid var(--app-ink); outline: 2px solid var(--app-accent);
outline-offset: -3px; outline-offset: -3px;
} }
.reader-content { .reader-content {
@@ -922,8 +931,8 @@ body {
white-space: normal; white-space: normal;
} }
.n-button.image-history-item.selected { .n-button.image-history-item.selected {
background: var(--app-subtle); background: var(--app-selected);
box-shadow: inset 0 0 0 2px var(--app-ink); box-shadow: inset 0 0 0 2px var(--app-accent);
} }
.n-button.image-history-item .n-button__content { .n-button.image-history-item .n-button__content {
display: block; display: block;
@@ -973,8 +982,8 @@ body {
background: var(--app-surface); background: var(--app-surface);
} }
.n-button.casting-item.selected { .n-button.casting-item.selected {
background: var(--app-subtle); background: var(--app-selected);
box-shadow: inset 3px 0 0 var(--app-ink); box-shadow: inset 3px 0 0 var(--app-accent);
} }
.n-button.casting-item .n-button__content { .n-button.casting-item .n-button__content {
display: flex; display: flex;
@@ -1043,7 +1052,7 @@ body {
background: #080808; background: #080808;
} }
.button-primary { .button-primary {
color: var(--app-inverse); color: var(--app-on-accent);
} }
.skip-link:focus { .skip-link:focus {
z-index: 3000; z-index: 3000;
+1 -1
View File
@@ -58,7 +58,7 @@ describe('管理后台组件边界', () => {
) )
expect(css).toMatch(/\.casting-item-name\s*\{[^}]*overflow-wrap:\s*anywhere/) expect(css).toMatch(/\.casting-item-name\s*\{[^}]*overflow-wrap:\s*anywhere/)
expect(css).toMatch(/\.casting-item-status\s*\{[^}]*flex-shrink:\s*0/) expect(css).toMatch(/\.casting-item-status\s*\{[^}]*flex-shrink:\s*0/)
expect(css).toMatch(/\.n-button\.casting-item\.selected\s*\{[^}]*var\(--app-subtle\)[^}]*var\(--app-ink\)/) expect(css).toMatch(/\.n-button\.casting-item\.selected\s*\{[^}]*var\(--app-selected\)[^}]*var\(--app-accent\)/)
}) })
it('拆解分栏跟随剩余高度收缩,移动端历史区不挤出结果区', () => { it('拆解分栏跟随剩余高度收缩,移动端历史区不挤出结果区', () => {
// DOM 环境不计算几何,保护完整的 flex → grid → NScrollbar 高度链。 // DOM 环境不计算几何,保护完整的 flex → grid → NScrollbar 高度链。
+76 -4
View File
@@ -1,6 +1,7 @@
import { effectScope, nextTick, type EffectScope } from 'vue' import { effectScope, nextTick, type EffectScope } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { useTheme } from './useTheme' import { useTheme } from './useTheme'
import { readFileSync } from 'node:fs'
let scope: EffectScope | undefined let scope: EffectScope | undefined
afterEach(() => { afterEach(() => {
@@ -30,7 +31,22 @@ function setup(dark = false) {
} }
} }
describe('黑白双主题', () => { /** 将十六进制颜色换算为 sRGB 相对亮度。 */
function luminance(hex: string) {
const channels = [1, 3, 5].map(index => {
const value = parseInt(hex.slice(index, index + 2), 16) / 255
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
})
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722
}
/** 检查主题文字对比度,避免绿色按钮和浅色选中项难以辨认。 */
function contrast(foreground: string, background: string) {
const values = [luminance(foreground), luminance(background)]
return (Math.max(...values) + 0.05) / (Math.min(...values) + 0.05)
}
describe('微信风格明暗主题', () => {
it('默认跟随系统并实时响应,销毁时移除监听', async () => { it('默认跟随系统并实时响应,销毁时移除监听', async () => {
const state = setup() const state = setup()
expect(state.preference.value).toBe('system') expect(state.preference.value).toBe('system')
@@ -60,15 +76,71 @@ describe('黑白双主题', () => {
it('深浅模式同时更新 Naive 主题和业务颜色标记', async () => { it('深浅模式同时更新 Naive 主题和业务颜色标记', async () => {
const state = setup() const state = setup()
expect(state.overrides.value.common?.primaryColor).toBe('#202020') expect(state.overrides.value.common?.primaryColor).toBe('#07c160')
expect(state.overrides.value.common?.bodyColor).toBe('#ededed')
state.preference.value = 'dark' state.preference.value = 'dark'
await nextTick() await nextTick()
expect(state.theme.value?.name).toBe('dark') expect(state.theme.value?.name).toBe('dark')
expect(state.overrides.value.common?.primaryColor).toBe('#f5f5f5') expect(state.overrides.value.common?.primaryColor).toBe('#07c160')
expect(state.overrides.value.Button?.textColorPrimary).toBe('#171717') expect(state.overrides.value.common?.bodyColor).toBe('#111111')
expect(state.overrides.value.Button?.textColorPrimary).toBe('#082b17')
expect(document.documentElement.dataset.theme).toBe('dark') expect(document.documentElement.dataset.theme).toBe('dark')
}) })
it.each([false, true])('明暗模式 %s 的组件表面、语义色与业务 CSS 一致,主要文字保持可读', dark => {
const state = setup(dark)
const { common, Menu, Button, Layout } = state.overrides.value
const css = readFileSync('src/admin.css', 'utf8')
const lightTokens = css.match(/:root\s*\{([^}]+)\}/)![1]!
const darkTokens = css.match(/:root\[data-theme='dark'\]\s*\{([^}]+)\}/)![1]!
const tokens = Object.fromEntries(
[...(lightTokens + (dark ? darkTokens : '')).matchAll(/--app-([\w-]+):\s*(#[\da-f]+);/g)].map(match => [
match[1],
match[2]
])
)
expect(common).toMatchObject({
bodyColor: tokens.body,
cardColor: tokens.surface,
modalColor: tokens.surface,
tableColor: tokens.surface,
inputColor: tokens.surface,
borderColor: tokens.border,
textColor1: tokens.ink,
textColor3: tokens.muted,
primaryColor: tokens.accent,
successColor: tokens.success,
errorColor: tokens.danger
})
expect(Layout).toMatchObject({ color: tokens.body, headerColor: tokens.surface, siderColor: tokens.subtle })
expect(Menu).toMatchObject({ itemColorActive: tokens.selected, itemTextColorActive: tokens['accent-text'] })
expect(Button?.textColorPrimary).toBe(tokens['on-accent'])
for (const background of [common!.primaryColor!, common!.primaryColorHover!, common!.primaryColorPressed!]) {
expect(contrast(String(Button!.textColorPrimary), background)).toBeGreaterThanOrEqual(4.5)
}
expect(contrast(tokens.ink!, tokens.surface!)).toBeGreaterThanOrEqual(4.5)
expect(contrast(tokens.muted!, tokens.subtle!)).toBeGreaterThanOrEqual(4.5)
expect(contrast(tokens['accent-text']!, tokens.selected!)).toBeGreaterThanOrEqual(4.5)
})
it('系统切换时浏览器主题色与页面同步', async () => {
const meta = document.createElement('meta')
meta.name = 'theme-color'
document.head.appendChild(meta)
try {
const state = setup()
expect(meta.content).toBe('#ededed')
state.change(true)
await nextTick()
expect(meta.content).toBe('#111111')
state.preference.value = 'light'
await nextTick()
expect(meta.content).toBe('#ededed')
} finally {
meta.remove()
}
})
it('无效存储回退系统模式', () => { it('无效存储回退系统模式', () => {
localStorage.setItem('drama-studio-theme', 'unexpected') localStorage.setItem('drama-studio-theme', 'unexpected')
const state = setup(true) const state = setup(true)
+70 -27
View File
@@ -24,48 +24,88 @@ export function useTheme() {
const theme = computed(() => (isDark.value ? darkTheme : null)) const theme = computed(() => (isDark.value ? darkTheme : null))
const overrides = computed<GlobalThemeOverrides>(() => { const overrides = computed<GlobalThemeOverrides>(() => {
const dark = isDark.value const dark = isDark.value
const primary = dark ? '#f5f5f5' : '#202020' // 微信风格灰阶表面与绿色操作态;文字绿独立加深,避免浅底小字对比不足。
const hover = dark ? '#d4d4d4' : '#444444' const primary = '#07c160'
const hover = '#2dcb79'
const pressed = '#06ad56'
const onPrimary = '#082b17'
const accentText = dark ? '#5cd693' : '#087c42'
const body = dark ? '#111111' : '#ededed'
const surface = dark ? '#191919' : '#ffffff'
const subtle = dark ? '#232323' : '#f7f7f7'
const text = dark ? '#d9d9d9' : '#191919'
const muted = dark ? '#a3a3a3' : '#707070'
const selected = dark ? '#173527' : '#e4f4e9'
const info = dark ? '#91a8d2' : '#576b95'
const danger = dark ? '#fa7373' : '#a93232'
const warning = dark ? '#e8b66a' : '#916019'
return { return {
common: { common: {
primaryColor: primary, primaryColor: primary,
primaryColorHover: hover, primaryColorHover: hover,
primaryColorPressed: hover, primaryColorPressed: pressed,
primaryColorSuppl: primary, primaryColorSuppl: primary,
infoColor: primary, infoColor: info,
infoColorHover: hover, infoColorHover: info,
infoColorPressed: hover, infoColorPressed: info,
infoColorSuppl: primary, infoColorSuppl: info,
successColor: accentText,
successColorHover: accentText,
successColorPressed: accentText,
successColorSuppl: accentText,
errorColor: danger,
errorColorHover: danger,
errorColorPressed: danger,
errorColorSuppl: danger,
warningColor: warning,
warningColorHover: warning,
warningColorPressed: warning,
warningColorSuppl: warning,
borderRadius: '6px', borderRadius: '6px',
bodyColor: dark ? '#141414' : '#f5f5f5', bodyColor: body,
cardColor: dark ? '#1c1c1c' : '#ffffff', cardColor: surface,
modalColor: dark ? '#202020' : '#ffffff', modalColor: surface,
popoverColor: dark ? '#262626' : '#ffffff', popoverColor: dark ? '#2c2c2c' : surface,
tableColor: dark ? '#1c1c1c' : '#ffffff', tableColor: surface,
borderColor: dark ? '#363636' : '#e5e5e5', inputColor: surface,
textColorBase: dark ? '#ededed' : '#202020', actionColor: subtle,
borderColor: dark ? '#333333' : '#e5e5e5',
dividerColor: dark ? '#333333' : '#e5e5e5',
hoverColor: subtle,
textColorBase: text,
textColor1: text,
textColor2: text,
textColor3: muted,
fontFamily: fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif" "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
}, },
Button: { Button: {
textColorPrimary: dark ? '#171717' : '#ffffff', textColorPrimary: onPrimary,
textColorHoverPrimary: dark ? '#171717' : '#ffffff', textColorHoverPrimary: onPrimary,
textColorPressedPrimary: dark ? '#171717' : '#ffffff' textColorPressedPrimary: onPrimary,
textColorFocusPrimary: onPrimary,
textColorDisabledPrimary: onPrimary,
textColorTextPrimary: accentText,
textColorGhostPrimary: accentText
}, },
Layout: { Layout: {
color: dark ? '#141414' : '#f5f5f5', color: body,
headerColor: dark ? '#1c1c1c' : '#ffffff', headerColor: surface,
siderColor: dark ? '#1c1c1c' : '#ffffff' siderColor: subtle
}, },
Menu: { Menu: {
itemColorActive: dark ? '#333333' : '#eeeeee', itemColorActive: selected,
itemColorActiveHover: dark ? '#383838' : '#e7e7e7', itemColorActiveHover: selected,
itemTextColorActive: primary, itemColorHover: dark ? '#2c2c2c' : '#e8e8e8',
itemTextColorActiveHover: primary, itemTextColorActive: accentText,
itemIconColorActive: primary, itemTextColorActiveHover: accentText,
itemIconColorActiveHover: primary itemIconColorActive: accentText,
itemIconColorActiveHover: accentText
}, },
Checkbox: { checkMarkColor: dark ? '#171717' : '#ffffff' }, Tabs: { tabTextColorActiveLine: accentText, tabTextColorHoverLine: accentText, barColor: primary },
Tag: { textColorPrimary: accentText },
Checkbox: { checkMarkColor: onPrimary },
Switch: { railColorActive: primary },
Progress: { fillColor: primary } Progress: { fillColor: primary }
} }
}) })
@@ -78,6 +118,9 @@ export function useTheme() {
watchEffect(() => { watchEffect(() => {
document.documentElement.dataset.theme = isDark.value ? 'dark' : 'light' document.documentElement.dataset.theme = isDark.value ? 'dark' : 'light'
document.documentElement.style.colorScheme = isDark.value ? 'dark' : 'light' document.documentElement.style.colorScheme = isDark.value ? 'dark' : 'light'
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', isDark.value ? '#111111' : '#ededed')
try { try {
localStorage.setItem(storageKey, preference.value) localStorage.setItem(storageKey, preference.value)
} catch { } catch {
@@ -64,6 +64,7 @@ function chooseAnchor() {
:src="selected.status === 'completed' ? selected.imageUrl : null" :src="selected.status === 'completed' ? selected.imageUrl : null"
:alt="`${subjectName} · ${identityViewLabels[selected.viewType]}`" :alt="`${subjectName} · ${identityViewLabels[selected.viewType]}`"
class="asset-image-preview mt-4" class="asset-image-preview mt-4"
object-fit="contain"
preview preview
:empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端图片结果'" :empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端图片结果'"
/> />
@@ -45,7 +45,7 @@ function input(selector: string, value: string) {
} }
describe('身份图与母版契约', () => { describe('身份图与母版契约', () => {
it('大图和历史统一 cover,缩略图只切换记录,大图单独打开完整原图', async () => { it('详情大图 contain 完整显示,历史仍为 cover,缩略图只切换记录', async () => {
wrapper = mount(IdentityGallery, { wrapper = mount(IdentityGallery, {
attachTo: document.body, attachTo: document.body,
props: { props: {
@@ -66,13 +66,14 @@ describe('身份图与母版契约', () => {
}) })
const images = wrapper.findAllComponents(NImage) const images = wrapper.findAllComponents(NImage)
expect(images).toHaveLength(3) expect(images).toHaveLength(3)
expect(images.every(image => image.props('objectFit') === 'cover')).toBe(true) expect(images.map(image => image.props('objectFit'))).toEqual(['contain', 'cover', 'cover'])
expect(images.map(image => image.props('previewDisabled'))).toEqual([false, true, true]) expect(images.map(image => image.props('previewDisabled'))).toEqual([false, true, true])
await wrapper.get('[aria-label="查看身份图片 front"] img').trigger('click') await wrapper.get('[aria-label="查看身份图片 front"] img').trigger('click')
await flushPromises() await flushPromises()
expect(wrapper.get('[aria-label="查看身份图片 front"]').attributes('aria-pressed')).toBe('true') expect(wrapper.get('[aria-label="查看身份图片 front"]').attributes('aria-pressed')).toBe('true')
expect(document.querySelector('.n-image-preview-container')).toBeNull() expect(document.querySelector('.n-image-preview-container')).toBeNull()
const main = wrapper.get('.asset-image-preview img') const main = wrapper.get('.asset-image-preview img')
expect((main.element as HTMLImageElement).style.objectFit).toBe('contain')
expect(main.attributes('src')).toContain('/storage/front.png') expect(main.attributes('src')).toContain('/storage/front.png')
await main.trigger('click') await main.trigger('click')
await flushPromises() await flushPromises()
@@ -101,6 +101,7 @@ async function choosePrimary() {
:alt="`${form?.subject.name} · ${form?.name}`" :alt="`${form?.subject.name} · ${form?.name}`"
:empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端生成结果'" :empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端生成结果'"
class="asset-image-preview" class="asset-image-preview"
object-fit="contain"
preview preview
/> />
<div class="image-history-heading"> <div class="image-history-heading">
@@ -39,6 +39,8 @@ describe('形态图片选择与操作', () => {
await flushPromises() await flushPromises()
const history = document.querySelector<HTMLElement>('[aria-label="图片历史"]')! const history = document.querySelector<HTMLElement>('[aria-label="图片历史"]')!
expect(history.querySelectorAll('.image-history-item')).toHaveLength(3) expect(history.querySelectorAll('.image-history-item')).toHaveLength(3)
expect(document.querySelector<HTMLImageElement>('.asset-image-preview img')?.style.objectFit).toBe('contain')
expect([...history.querySelectorAll('img')].every(image => image.style.objectFit === 'cover')).toBe(true)
expect(history.previousElementSibling?.textContent).toContain('历史记录 · 3 条') expect(history.previousElementSibling?.textContent).toContain('历史记录 · 3 条')
expect(history.previousElementSibling?.previousElementSibling?.classList.contains('asset-image-preview')).toBe( expect(history.previousElementSibling?.previousElementSibling?.classList.contains('asset-image-preview')).toBe(
true true
@@ -49,6 +51,7 @@ describe('形态图片选择与操作', () => {
'/storage/candidate.png' '/storage/candidate.png'
) )
expect(history.querySelector('[aria-label="查看图片 candidate"]')?.getAttribute('aria-pressed')).toBe('true') expect(history.querySelector('[aria-label="查看图片 candidate"]')?.getAttribute('aria-pressed')).toBe('true')
expect(document.querySelector<HTMLImageElement>('.asset-image-preview img')?.style.objectFit).toBe('contain')
history.querySelector<HTMLButtonElement>('[aria-label="查看图片 failed"]')!.click() history.querySelector<HTMLButtonElement>('[aria-label="查看图片 failed"]')!.click()
await flushPromises() await flushPromises()
expect(document.querySelector('.asset-image-preview')?.textContent).toContain('本次生成失败') expect(document.querySelector('.asset-image-preview')?.textContent).toContain('本次生成失败')
+9 -8
View File
@@ -1,12 +1,12 @@
@import 'tailwindcss'; @import 'tailwindcss';
/* 黑白工作台主题:业务排版保留,全部颜色由语义变量控制。 */ /* 微信风格明暗主题:灰阶表面搭配绿色操作态,业务排版保持不变。 */
@theme { @theme {
--color-ink: var(--app-ink); --color-ink: var(--app-ink);
--color-muted: var(--app-muted); --color-muted: var(--app-muted);
--color-faint: var(--app-muted); --color-faint: var(--app-muted);
--color-line: var(--app-border); --color-line: var(--app-border);
--color-accent: var(--app-ink); --color-accent: var(--app-accent-text);
--color-danger: var(--app-danger); --color-danger: var(--app-danger);
--color-success: var(--app-success); --color-success: var(--app-success);
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
@@ -18,7 +18,7 @@
} }
body { body {
margin: 0; margin: 0;
background: var(--app-subtle); background: var(--app-body);
color: var(--color-ink); color: var(--color-ink);
font-family: var(--font-sans); font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -57,7 +57,8 @@
outline-offset: 1px; outline-offset: 1px;
} }
::selection { ::selection {
background: var(--app-border); background: var(--app-selected);
color: var(--app-ink);
} }
} }
@@ -151,12 +152,12 @@
border-color 0.15s; border-color 0.15s;
} }
.button-primary { .button-primary {
background: var(--color-accent); background: var(--app-accent);
color: var(--app-inverse); color: var(--app-on-accent);
border-color: var(--color-accent); border-color: var(--app-accent);
} }
.button-primary:hover:not(:disabled) { .button-primary:hover:not(:disabled) {
background: var(--app-ink); background: var(--app-accent-hover);
} }
.button-secondary { .button-secondary {
background: var(--app-surface); background: var(--app-surface);