feat: refine UI, improve UX, optimize the analysis pipeline, and add character standing positions

This commit is contained in:
saturn
2026-04-02 17:39:16 +08:00
parent c3e74c228a
commit 9703714b69
153 changed files with 4472 additions and 1088 deletions

View File

@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest'
import { copyPreviewJsonText } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/AIDataModalPreviewPane'
describe('AIDataModalPreviewPane copy helper', () => {
it('falls back to execCommand when clipboard api rejects', async () => {
const writeText = vi.fn(async () => {
throw new Error('clipboard denied')
})
const appendChild = vi.fn()
const removeChild = vi.fn()
const select = vi.fn()
const textarea = {
value: '',
style: {} as Record<string, string>,
select,
}
vi.stubGlobal('navigator', { clipboard: { writeText } })
vi.stubGlobal('document', {
body: {
appendChild,
removeChild,
},
createElement: vi.fn(() => textarea),
execCommand: vi.fn(() => true),
})
await expect(copyPreviewJsonText('{"a":1}')).resolves.toBeUndefined()
expect(writeText).toHaveBeenCalledWith('{"a":1}')
expect(appendChild).toHaveBeenCalledWith(textarea)
expect(select).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,48 @@
import * as React from 'react'
import { createElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import AIDataModal from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/AIDataModal'
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}))
vi.mock('react-dom', () => ({
createPortal: (node: unknown) => node,
}))
describe('AIDataModal', () => {
it('在查看数据预览中展示角色完整数据与 slot', () => {
Reflect.set(globalThis, 'React', React)
vi.stubGlobal('document', { body: {} })
const html = renderToStaticMarkup(
createElement(AIDataModal, {
isOpen: true,
onClose: () => undefined,
panelNumber: 1,
shotType: 'medium shot',
cameraMove: 'static',
description: '皇帝立于大殿中央',
location: '皇宫大殿',
characters: [
{
name: '皇帝',
appearance: '朝服形象',
slot: '皇宫正中龙椅前方台阶下的位置',
},
],
videoPrompt: 'dramatic court scene',
photographyRules: null,
actingNotes: null,
videoRatio: '16:9',
onSave: () => undefined,
}),
)
expect(html).toContain('&quot;characters&quot;')
expect(html).toContain('&quot;appearance&quot;: &quot;朝服形象&quot;')
expect(html).toContain('&quot;slot&quot;: &quot;皇宫正中龙椅前方台阶下的位置&quot;')
})
})

View File

@@ -0,0 +1,46 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { CapsuleNav, EpisodeSelector } from '@/components/ui/CapsuleNav'
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}))
vi.mock('@/components/ui/icons', () => ({
AppIcon: ({ name, className }: { name: string; className?: string }) =>
createElement('span', { 'data-icon': name, className }),
}))
describe('CapsuleNav layering', () => {
it('keeps fixed workspace navigation below modal overlays', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement('div', null,
createElement(CapsuleNav, {
items: [
{ id: 'config', icon: 'sparkles', label: '配置', status: 'active' as const },
],
activeId: 'config',
onItemClick: () => undefined,
projectId: 'project-1',
}),
createElement(EpisodeSelector, {
episodes: [
{ id: 'episode-1', title: '剧集 1' },
],
currentId: 'episode-1',
onSelect: () => undefined,
projectName: '项目 A',
}),
),
)
expect(html).toContain('fixed top-20 left-1/2 -translate-x-1/2 z-40')
expect(html).toContain('fixed top-20 left-6 z-40')
expect(html).not.toContain('z-50 animate-fadeInDown')
expect(html).not.toContain('z-[60]')
})
})

View File

@@ -22,8 +22,56 @@ describe('ImageGenerationInlineCountButton', () => {
}),
)
expect(html).toContain('role="button"')
expect(html).toContain('aria-disabled="true"')
expect(html).toContain('opacity-60 cursor-not-allowed')
expect(html).not.toContain('<select disabled=""')
expect(html).toContain('rounded-full bg-white/12')
expect(html).toContain('inline-flex shrink-0 items-center whitespace-nowrap leading-none')
})
it('renders the count control as a rounded inline pill with the chevron inside it', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(ImageGenerationInlineCountButton, {
prefix: createElement('span', null, '重新生成'),
suffix: createElement('span', null, '张'),
value: 2,
options: [1, 2, 3],
onValueChange: () => undefined,
onClick: () => undefined,
ariaLabel: '选择重新生成数量',
}),
)
expect(html).toContain('重新生成')
expect(html).toContain('张')
expect(html).toContain('whitespace-nowrap')
expect(html).toContain('rounded-full bg-white/12')
expect(html).toContain('right-2')
expect(html).toContain('hover:bg-white/16')
})
it('can render a regenerate action without exposing the count selector', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(ImageGenerationInlineCountButton, {
prefix: createElement('span', null, '重新生成'),
suffix: null,
value: 2,
options: [1, 2, 3],
onValueChange: () => undefined,
onClick: () => undefined,
showCountControl: false,
ariaLabel: '重新生成当前图片',
className: 'inline-flex h-6 items-center justify-center rounded-md px-1.5',
}),
)
expect(html).toContain('重新生成')
expect(html).toContain('type="button"')
expect(html).not.toContain('<select')
expect(html).not.toContain('rounded-full bg-white/12')
})
})

View File

@@ -0,0 +1,72 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import LongTextDetectionPrompt from '@/components/story-input/LongTextDetectionPrompt'
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-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('LongTextDetectionPrompt', () => {
afterEach(() => {
vi.clearAllMocks()
portalMocks.currentPortalTarget = null
Reflect.deleteProperty(globalThis, 'React')
Reflect.deleteProperty(globalThis, 'document')
})
it('renders through document.body at modal layer without the removed gradient border wrapper', () => {
const fakeDocument = {
body: { nodeName: 'BODY' },
}
Reflect.set(globalThis, 'React', React)
Reflect.set(globalThis, 'document', fakeDocument)
portalMocks.currentPortalTarget = fakeDocument.body
const html = renderToStaticMarkup(
createElement(LongTextDetectionPrompt, {
open: true,
copy: {
title: '建议使用智能分集',
description: '检测到文本较长',
strongRecommend: '建议拆分',
smartSplitLabel: '智能分集',
smartSplitBadge: '推荐',
continueLabel: '仍然单集创作',
continueHint: '单集模式',
},
onClose: () => undefined,
onSmartSplit: () => undefined,
onContinue: () => undefined,
}),
)
expect(portalMocks.createPortalMock).toHaveBeenCalledTimes(1)
expect(portalMocks.createPortalMock.mock.calls[0]?.[1]).toBe(fakeDocument.body)
expect(html).toContain('data-portal-target="body"')
expect(html).toContain('z-[120]')
expect(html).toContain('border-[var(--glass-stroke-base)]')
expect(html).not.toContain('p-[1.5px]')
})
})

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { lockModalPageScroll } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/modal-scroll-lock'
describe('modal scroll lock', () => {
it('locks page scroll while modal is open and restores previous styles on cleanup', () => {
const doc = {
body: {
style: {
overflow: 'auto',
},
},
documentElement: {
style: {
overflow: 'scroll',
},
},
}
const restore = lockModalPageScroll(doc)
expect(doc.body.style.overflow).toBe('hidden')
expect(doc.documentElement.style.overflow).toBe('hidden')
restore()
expect(doc.body.style.overflow).toBe('auto')
expect(doc.documentElement.style.overflow).toBe('scroll')
})
})

View File

@@ -0,0 +1,64 @@
import * as React from 'react'
import { createElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import WorkspaceRunStreamConsoles from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/WorkspaceRunStreamConsoles'
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}))
vi.mock('@/components/llm-console/LLMStageStreamCard', () => ({
__esModule: true,
default: ({ title }: { title: string }) => createElement('section', null, `LLMStageStreamCard:${title}`),
}))
function createStreamState(overrides?: Partial<React.ComponentProps<typeof WorkspaceRunStreamConsoles>['storyToScriptStream']>) {
return {
status: 'running' as const,
isVisible: true,
isRecoveredRunning: true,
stages: [],
selectedStep: null,
activeStepId: null,
outputText: '',
activeMessage: '',
overallProgress: 0,
isRunning: false,
errorMessage: '',
stop: () => undefined,
reset: () => undefined,
selectStep: () => undefined,
retryStep: async () => ({
runId: 'run-1',
status: 'running',
summary: null,
payload: null,
errorMessage: '',
}),
...overrides,
}
}
describe('WorkspaceRunStreamConsoles', () => {
it('shows fallback running console when a recovered run has no stages yet', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(WorkspaceRunStreamConsoles, {
storyToScriptStream: createStreamState(),
scriptToStoryboardStream: createStreamState({
status: 'idle',
isVisible: false,
isRecoveredRunning: false,
}),
storyToScriptConsoleMinimized: false,
scriptToStoryboardConsoleMinimized: true,
onStoryToScriptMinimizedChange: () => undefined,
onScriptToStoryboardMinimizedChange: () => undefined,
}),
)
expect(html).toContain('LLMStageStreamCard:runConsole.storyToScript')
})
})