style: polish UI and improve UX

This commit is contained in:
saturn
2026-03-28 18:58:21 +08:00
parent ca5d8a58f7
commit c3e74c228a
19 changed files with 1182 additions and 267 deletions

View File

@@ -0,0 +1,158 @@
import * as React from 'react'
import { createElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import { AssetGrid } from '@/app/[locale]/workspace/asset-hub/components/AssetGrid'
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>()
return {
...actual,
useState: <T,>(initialState: T | (() => T)) => {
const resolvedInitialState = typeof initialState === 'function'
? (initialState as () => T)()
: initialState
if (resolvedInitialState === 'all') {
return actual.useState('location' as T)
}
return actual.useState(resolvedInitialState)
},
}
})
vi.mock('@/app/[locale]/workspace/asset-hub/components/CharacterCard', () => ({
CharacterCard: () => null,
}))
vi.mock('@/app/[locale]/workspace/asset-hub/components/LocationCard', () => ({
LocationCard: () => null,
}))
vi.mock('@/app/[locale]/workspace/asset-hub/components/VoiceCard', () => ({
VoiceCard: () => null,
}))
vi.mock('@/components/task/TaskStatusInline', () => ({
default: () => null,
}))
const messages = {
assetHub: {
allAssets: '所有资产',
characters: '角色',
locations: '场景',
props: '道具',
voices: '音色',
addAsset: '新建资产',
addCharacter: '新建角色',
addLocation: '新建场景',
addProp: '新建道具',
addVoice: '新建音色',
downloadAll: '打包下载',
downloadAllTitle: '下载全部图片资产',
downloading: '打包中...',
emptyState: '暂无资产',
emptyStateHint: '点击上方按钮添加角色或场景',
filteredEmptyHint: '点击新建资产添加资产',
pagination: {
previous: '上一页',
next: '下一页',
},
},
} as const
const renderWithIntl = (node: ReactElement) => {
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
children: node,
}
return renderToStaticMarkup(
createElement(NextIntlClientProvider, providerProps),
)
}
describe('AssetGrid', () => {
it('空状态下使用与资产库一致的 compact 分段控件,并在中间显示新建资产按钮', () => {
Reflect.set(globalThis, 'React', React)
const html = renderWithIntl(
createElement(AssetGrid, {
assets: [],
loading: false,
onAddCharacter: () => undefined,
onAddLocation: () => undefined,
onAddProp: () => undefined,
onAddVoice: () => undefined,
onDownloadAll: () => undefined,
isDownloading: false,
selectedFolderId: null,
}),
)
expect(html).toContain('inline-block max-w-full min-w-max')
expect(html).toContain('inline-grid grid-flow-col auto-cols-[minmax(96px,max-content)]')
expect(html).toContain('justify-center')
expect(html).toContain('>新建资产<')
})
it('当前筛选分类没有资产时显示添加提示文案', () => {
Reflect.set(globalThis, 'React', React)
const html = renderWithIntl(
createElement(AssetGrid, {
assets: [
{
id: 'character-1',
kind: 'character',
family: 'visual',
scope: 'project',
name: '角色A',
folderId: null,
capabilities: {
canGenerate: true,
canSelectRender: false,
canRevertRender: false,
canModifyRender: false,
canUploadRender: false,
canBindVoice: false,
canCopyFromGlobal: false,
},
taskRefs: [],
taskState: { isRunning: false, lastError: null },
variants: [],
introduction: null,
profileData: null,
profileConfirmed: null,
profileTaskRefs: [],
profileTaskState: { isRunning: false, lastError: null },
voice: {
voiceType: null,
voiceId: null,
customVoiceUrl: null,
media: null,
},
},
],
loading: false,
onAddCharacter: () => undefined,
onAddLocation: () => undefined,
onAddProp: () => undefined,
onAddVoice: () => undefined,
onDownloadAll: () => undefined,
isDownloading: false,
selectedFolderId: null,
}),
)
expect(html).toContain('点击新建资产添加资产')
})
})

View File

@@ -0,0 +1,107 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RatioSelector, StylePresetSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
const portalMocks = vi.hoisted(() => {
return {
currentPortalTarget: null as unknown,
createPortalMock: vi.fn((node: React.ReactNode, target: unknown) => {
const targetLabel = target === portalMocks.currentPortalTarget ? 'body' : 'unknown'
return createElement('div', { 'data-portal-target': targetLabel }, node)
}),
}
})
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>()
return {
...actual,
useState: <T,>(initialState: T | (() => T)) => {
const resolvedInitialState = typeof initialState === 'function'
? (initialState as () => T)()
: initialState
if (resolvedInitialState === false) {
return actual.useState(true as T)
}
return actual.useState(resolvedInitialState)
},
}
})
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom')
return {
...actual,
createPortal: portalMocks.createPortalMock,
}
})
vi.mock('@/components/ui/icons', () => ({
AppIcon: ({ name, className }: { name: string; className?: string }) =>
createElement('span', { 'data-icon': name, className }),
}))
describe('RatioStyleSelectors', () => {
afterEach(() => {
vi.clearAllMocks()
portalMocks.currentPortalTarget = null
Reflect.deleteProperty(globalThis, 'React')
Reflect.deleteProperty(globalThis, 'document')
})
it('renders ratio, style, and style preset dropdown panels through a portal to document.body', () => {
const fakeDocument = {
body: { nodeName: 'BODY' },
}
Reflect.set(globalThis, 'React', React)
portalMocks.currentPortalTarget = fakeDocument.body
Reflect.set(globalThis, 'document', fakeDocument)
const html = renderToStaticMarkup(
createElement('div', null,
createElement(RatioSelector, {
value: '9:16',
onChange: () => undefined,
options: [
{ value: '9:16', label: '9:16' },
{ value: '16:9', label: '16:9' },
],
}),
createElement(StyleSelector, {
value: 'realistic',
onChange: () => undefined,
options: [
{ value: 'realistic', label: '真人风格' },
{ value: 'american-comic', label: '美漫风格' },
],
}),
createElement(StylePresetSelector, {
value: 'horror-suspense',
onChange: () => undefined,
options: [
{ value: 'horror-suspense', label: '恐怖悬疑', description: '压迫氛围' },
{ value: 'dark-noir', label: '暗黑黑色', description: '冷峻低照' },
],
}),
),
)
expect(portalMocks.createPortalMock).toHaveBeenCalledTimes(3)
expect(portalMocks.createPortalMock.mock.calls[0]?.[1]).toBe(fakeDocument.body)
expect(portalMocks.createPortalMock.mock.calls[1]?.[1]).toBe(fakeDocument.body)
expect(portalMocks.createPortalMock.mock.calls[2]?.[1]).toBe(fakeDocument.body)
expect(html).toContain('data-portal-target="body"')
expect(html).toContain('data-icon="sparklesAlt"')
expect(html).toContain('data-icon="clapperboard"')
expect(html).toContain('真人风格')
expect(html).toContain('16:9')
expect(html).toContain('恐怖悬疑')
expect(html).toContain('压迫氛围')
})
})

View File

@@ -0,0 +1,51 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import StoryInputComposer from '@/components/story-input/StoryInputComposer'
vi.mock('@/components/selectors/RatioStyleSelectors', () => ({
RatioSelector: ({
getUsage: _getUsage,
...props
}: Record<string, unknown> & { getUsage?: unknown }) => createElement('div', props, 'RatioSelector'),
StyleSelector: (props: Record<string, unknown>) => createElement('div', props, 'StyleSelector'),
StylePresetSelector: (props: Record<string, unknown>) => createElement('div', props, 'StylePresetSelector'),
}))
describe('StoryInputComposer', () => {
it('renders a shared composer shell with configurable textarea rows and shared controls', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(StoryInputComposer, {
value: '测试内容',
onValueChange: () => undefined,
placeholder: '请输入内容',
minRows: 8,
videoRatio: '9:16',
onVideoRatioChange: () => undefined,
ratioOptions: [{ value: '9:16', label: '9:16' }],
artStyle: 'realistic',
onArtStyleChange: () => undefined,
styleOptions: [{ value: 'realistic', label: '真人风格' }],
stylePresetValue: 'horror-suspense',
onStylePresetChange: () => undefined,
stylePresetOptions: [{ value: 'horror-suspense', label: '恐怖悬疑', description: '压迫氛围' }],
topRight: createElement('span', null, '字数4'),
footer: createElement('p', null, '当前配置'),
secondaryActions: createElement('button', { type: 'button' }, 'AI 帮我写'),
primaryAction: createElement('button', { type: 'button' }, '开始创作'),
}),
)
expect(html).toContain('rows="8"')
expect(html).toContain('RatioSelector')
expect(html).toContain('StyleSelector')
expect(html).toContain('StylePresetSelector')
expect(html).toContain('字数4')
expect(html).toContain('当前配置')
expect(html).toContain('AI 帮我写')
expect(html).toContain('开始创作')
})
})