Merge remote-tracking branch 'origin/master' into codex/tiaoyitiao

# Conflicts:
#	server-rs/crates/api-server/src/jump_hop.rs
#	server-rs/crates/api-server/src/modules/jump_hop.rs
This commit is contained in:
2026-06-06 21:04:46 +08:00
451 changed files with 25780 additions and 2687 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import React, { type ReactNode } from 'react';
import type { CustomWorldCoverRenderMode } from '../services/customWorldCover';
import { ResolvedAssetImage } from './ResolvedAssetImage';
@@ -74,6 +74,12 @@ describe('CustomWorldGenerationView', () => {
expect((container.firstChild as HTMLElement).className).toContain(
'z-[1]',
);
expect((container.firstChild as HTMLElement).className).toContain(
'overflow-hidden',
);
expect((container.firstChild as HTMLElement).className).not.toContain(
'overflow-y-auto',
);
const pageVideo = screen.getByTestId(
'generation-page-background-video',
@@ -114,6 +120,14 @@ describe('CustomWorldGenerationView', () => {
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
'bg-white/58',
);
expect(
screen.getByTestId('generation-hero-wait-card').parentElement
?.className,
).toContain('mt-3');
expect(
screen.getByTestId('generation-hero-wait-card').parentElement
?.className,
).toContain('px-0');
expect(screen.getByText('预计等待').className).toContain('text-[9px]');
expect(screen.getByText('已耗时').className).toContain('text-[9px]');
expect(screen.getByText('预计等待').parentElement?.className).toContain(
@@ -231,6 +245,10 @@ describe('CustomWorldGenerationView', () => {
expect(
screen.getByTestId('generation-current-step-card').className,
).toContain('bg-white/58');
expect(
screen.getByTestId('generation-current-step-card').parentElement
?.className,
).toContain('mt-5');
expect(
screen.getByRole('progressbar', { name: '编译草稿 进度' }),
).toBeTruthy();
@@ -238,10 +256,11 @@ describe('CustomWorldGenerationView', () => {
expect(screen.queryByText('写回结果')).toBeNull();
expect(screen.queryByText('当前批次')).toBeNull();
expect(screen.queryByText('正在整理当前设定步骤')).toBeNull();
expect(screen.queryByText('竖屏生成题材')).toBeNull();
},
);
test('keeps the setting information panel as compact information cards', () => {
test('does not render setting information cards on generation pages', () => {
render(
<CustomWorldGenerationView
settingText="大鱼吃小鱼题材"
@@ -258,19 +277,15 @@ describe('CustomWorldGenerationView', () => {
backLabel="返回创作中心"
settingDescription={null}
settingActionLabel={null}
settingTitle="当前大鱼吃小鱼信息"
progressTitle="大鱼吃小鱼草稿生成进度"
/>,
);
expect(screen.getByText('当前大鱼吃小鱼信息')).toBeTruthy();
expect(screen.getByText('当前大鱼吃小鱼信息').className).toContain('text-[13px]');
expect(screen.getByText('题材')).toBeTruthy();
expect(screen.getByText('题材').className).toContain('text-[9px]');
expect(screen.getByText('火锅')).toBeTruthy();
expect(screen.getByText('火锅').className).toContain('text-[13px]');
expect(screen.getByText('素材数量')).toBeTruthy();
expect(screen.getByText('20 种素材')).toBeTruthy();
expect(screen.queryByText('当前大鱼吃小鱼信息')).toBeNull();
expect(screen.queryByText('题材')).toBeNull();
expect(screen.queryByText('火锅')).toBeNull();
expect(screen.queryByText('素材数量')).toBeNull();
expect(screen.queryByText('20 种素材')).toBeNull();
expect(screen.queryByText('大鱼吃小鱼题材')).toBeNull();
expect(screen.getByTestId('generation-page-background-video')).toBeTruthy();
});
+14 -93
View File
@@ -97,33 +97,18 @@ function resolveCurrentGenerationStep(
);
}
function buildFallbackRenderKey(
value: string | null | undefined,
fallback: string,
) {
const normalizedValue = value?.trim();
return normalizedValue ? normalizedValue : fallback;
}
export function CustomWorldGenerationView({
settingText,
anchorEntries = [],
progress,
isGenerating,
onBack,
onEditSetting,
onRetry,
onInterrupt,
backLabel = '返回',
settingActionLabel = '修改设定',
retryLabel = '重新开始生成',
interruptLabel = '中断世界生成',
settingTitle = '玩家设定',
settingDescription = '这段文本会直接驱动本轮世界框架、角色与场景生成。',
progressTitle = '生成进度',
activeBadgeLabel = '世界建设中',
idleBadgeLabel = '等待操作',
structuredEmptyText = '正在整理当前设定结构,请稍后。',
hideBatchModule = false,
}: CustomWorldGenerationViewProps) {
void hideBatchModule;
@@ -138,12 +123,6 @@ export function CustomWorldGenerationView({
: isGenerating
? '进行中'
: '待处理';
const hasStructuredAnchors = anchorEntries.length > 0;
// 允许不同生成场景按需隐藏第二模块的说明和次级返回动作。
const normalizedSettingActionLabel = settingActionLabel?.trim() ?? '';
const normalizedSettingDescription = settingDescription?.trim() ?? '';
const hasSettingActionLabel = normalizedSettingActionLabel.length > 0;
const hasSettingDescription = normalizedSettingDescription.length > 0;
const estimatedWaitText =
progress?.estimatedRemainingMs != null
? formatDuration(progress.estimatedRemainingMs)
@@ -153,11 +132,10 @@ export function CustomWorldGenerationView({
return (
<div
className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-y-auto overscroll-y-contain bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5"
style={{ WebkitOverflowScrolling: 'touch' }}
className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5"
>
<GenerationPageBackdrop />
<div className="relative z-10 mb-6 flex shrink-0 items-center justify-between gap-3 py-2 sm:mb-6">
<div className="relative z-30 mb-4 flex shrink-0 items-center justify-between gap-3 py-2 sm:mb-5">
<button
type="button"
onClick={onBack}
@@ -171,7 +149,10 @@ export function CustomWorldGenerationView({
</div>
</div>
<div className="relative z-10 flex flex-none flex-col gap-4">
<div
className="relative z-10 flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<section className="overflow-hidden px-0 pb-2 pt-0 sm:px-0">
<GenerationProgressHero
title={progressTitle}
@@ -181,7 +162,7 @@ export function CustomWorldGenerationView({
elapsedText={elapsedText}
/>
<div className="mt-[-0.15rem] px-0 sm:px-0">
<div className="mt-5 px-0 sm:mt-[-0.15rem] sm:px-0">
<GenerationCurrentStepCard
label={currentStepLabel}
statusLabel={currentStepStatusLabel}
@@ -191,24 +172,13 @@ export function CustomWorldGenerationView({
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
{!isGenerating ? (
<>
{hasSettingActionLabel ? (
<button
type="button"
onClick={onEditSetting}
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
>
{normalizedSettingActionLabel}
</button>
) : null}
<button
type="button"
onClick={onRetry}
className="platform-button platform-button--primary w-full sm:w-auto"
>
{retryLabel}
</button>
</>
<button
type="button"
onClick={onRetry}
className="platform-button platform-button--primary w-full sm:w-auto"
>
{retryLabel}
</button>
) : onInterrupt ? (
<button
type="button"
@@ -220,55 +190,6 @@ export function CustomWorldGenerationView({
) : null}
</div>
</section>
<section className="overflow-hidden rounded-[1.75rem] border border-[#eadcd1] bg-[rgba(255,250,246,0.92)] px-4 py-4 shadow-[0_20px_56px_rgba(112,57,30,0.08)] backdrop-blur-[5px] sm:px-5 sm:py-5">
<div className="mb-4 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[13px] font-black tracking-[0.08em] text-[#111111]">
{settingTitle}
</div>
{hasSettingDescription ? (
<div className="mt-2 text-[13px] leading-6 text-[#7e6656]">
{normalizedSettingDescription}
</div>
) : null}
</div>
{hasSettingActionLabel ? (
<button
type="button"
onClick={onEditSetting}
disabled={isGenerating}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
>
{normalizedSettingActionLabel}
</button>
) : null}
</div>
{hasStructuredAnchors ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{anchorEntries.map((entry, index) => (
<div
key={buildFallbackRenderKey(
entry.id,
`anchor-entry-${index}`,
)}
className="rounded-[1.15rem] border border-[#ead6c7] bg-white/74 px-4 py-4"
>
<div className="text-[9px] font-bold tracking-[0.12em] text-[#8e6f5d] sm:text-[10px]">
{entry.label}
</div>
<div className="mt-2 whitespace-pre-line text-[13px] leading-7 text-[#111111]">
{entry.value}
</div>
</div>
))}
</div>
) : (
<div className="whitespace-pre-line rounded-[1.15rem] border border-[#ead6c7] bg-white/74 px-4 py-4 text-[13px] leading-7 text-[#111111] md:max-h-[16rem] md:overflow-y-auto">
{settingText || structuredEmptyText}
</div>
)}
</section>
</div>
</div>
);
+2 -2
View File
@@ -133,7 +133,7 @@ export function GenerationProgressHero({
const ringFillDasharray = `${ringMetrics.progressLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`;
return (
<div className="relative mx-auto flex w-full min-w-0 max-w-[60rem] flex-col items-center px-1 pb-1 pt-1 sm:pt-4">
<div className="relative mx-auto flex w-full min-w-0 max-w-[60rem] flex-col items-center px-0 pb-1 pt-1 sm:pt-4">
<div className="sr-only">
{title}
{phaseLabel ? ` ${phaseLabel}` : ''}
@@ -215,7 +215,7 @@ export function GenerationProgressHero({
</div>
</div>
<div className="relative z-20 mt-[-0.3rem] grid w-full grid-cols-2 gap-2 px-0.5 sm:absolute sm:inset-0 sm:mt-0 sm:block sm:px-0">
<div className="relative z-20 mt-3 grid w-full grid-cols-2 gap-2 px-0 sm:absolute sm:inset-0 sm:mt-0 sm:block sm:px-0">
<div
className="w-full rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:absolute sm:left-0 sm:top-1/2 sm:w-[8rem] sm:-translate-y-1/2 sm:px-3 sm:py-2.5"
data-testid="generation-hero-wait-card"
+1 -1
View File
@@ -1,4 +1,4 @@
import { type ImgHTMLAttributes, useEffect, useState } from 'react';
import React, { type ImgHTMLAttributes, useEffect, useState } from 'react';
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
+55 -7
View File
@@ -17,10 +17,13 @@ const baseUser: AuthUser = {
displayName: '138****8000',
avatarUrl: null,
publicUserCode: 'user-tester',
phoneNumber: '13800138000',
phoneNumberMasked: '138****8000',
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: true,
wechatDisplayName: '微信旅人甲',
wechatAccount: 'wx-openid-bind-001',
};
function renderAccountModal(overrides?: {
@@ -112,6 +115,10 @@ test('settings header uses a generic title instead of the phone number', () => {
expect(screen.queryByText('当前主题')).toBeNull();
expect(screen.queryByRole('button', { name: '退出登录' })).toBeNull();
expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
});
test('direct account entry does not render the settings shell as another dialog', () => {
@@ -129,12 +136,53 @@ test('direct account entry does not render the settings shell as another dialog'
).toBeNull();
});
test('account panel uses compact binding cards and keeps logout actions at the bottom', () => {
renderAccountModal({ entryMode: 'account' });
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('账号信息')).toBeTruthy();
expect(within(accountDialog).queryByText('身份信息')).toBeNull();
expect(
within(accountDialog).queryByText(
'统一查看身份、安全状态、登录设备与最近操作。',
),
).toBeNull();
expect(within(accountDialog).queryByText('登录方式')).toBeNull();
expect(within(accountDialog).getByText('绑定手机号')).toBeTruthy();
expect(within(accountDialog).getByText('13800138000')).toBeTruthy();
expect(within(accountDialog).queryByText('138****8000')).toBeNull();
expect(within(accountDialog).getByText('绑定微信')).toBeTruthy();
expect(within(accountDialog).getByText('微信旅人甲')).toBeTruthy();
expect(within(accountDialog).queryByText('wx-openid-bind-001')).toBeNull();
const compactCards = accountDialog.querySelectorAll(
'[data-account-binding-card]',
);
expect(compactCards).toHaveLength(2);
expect(
within(compactCards[0] as HTMLElement).getByRole('button', {
name: '更换手机号',
}),
).toBeTruthy();
expect(
within(compactCards[1] as HTMLElement).getByRole('button', {
name: '更换微信号',
}),
).toBeTruthy();
const accountContent =
accountDialog.querySelector('[data-account-content]') ?? accountDialog;
expect(
accountContent.lastElementChild?.getAttribute('data-account-actions'),
).toBe('true');
});
test('account actions open in independent panels instead of inline expansion', async () => {
const user = userEvent.setup();
renderAccountModal();
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(accountDialog).toBeTruthy();
@@ -162,7 +210,7 @@ test('nested settings panels keep back navigation without an extra close action'
renderAccountModal();
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const accountHeader = accountDialog.firstElementChild as HTMLElement | null;
@@ -201,7 +249,7 @@ test('settings overlays move focus away from inert triggers and restore it on ba
renderAccountModal();
const accountTrigger = screen.getByRole('button', { name: /账号信息/ });
const accountTrigger = screen.getByRole('button', { name: /账号与安全/ });
expect(document.activeElement).not.toBe(accountTrigger);
await user.click(accountTrigger);
@@ -283,7 +331,7 @@ test('account panel includes merged security devices and audit sections', async
],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
@@ -324,7 +372,7 @@ test('current merged session group hides kick action and shows count', async ()
],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
@@ -348,7 +396,7 @@ test('remote merged session group can be revoked with loading state', async () =
revokingSessionIds: ['usess_remote'],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const revokeButton = within(accountDialog).getByRole('button', {
@@ -373,7 +421,7 @@ test('remote session revoke passes the grouped session payload', async () => {
onRevokeSession,
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
await user.click(
within(screen.getByRole('dialog', { name: '账号信息' })).getByRole(
'button',
+107 -114
View File
@@ -1,3 +1,4 @@
import { ArrowLeft } from 'lucide-react';
import {
type ReactNode,
useCallback,
@@ -65,8 +66,8 @@ const SETTINGS_SECTIONS: Array<{
label: string;
detail: string;
}> = [
{ id: 'appearance', label: '主题外观', detail: '亮暗主题' },
{ id: 'account', label: '账号信息', detail: '身份与安全' },
{ id: 'appearance', label: '主题设置', detail: '亮暗主题' },
{ id: 'account', label: '账号与安全', detail: '身份与设备' },
];
const ACCOUNT_MODAL_MAX_HEIGHT =
@@ -93,17 +94,6 @@ function normalizeSettingsSection(
return null;
}
function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) {
switch (loginMethod) {
case 'wechat':
return '微信登录';
case 'phone':
return '手机号登录';
default:
return '账号登录';
}
}
function formatSessionTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
@@ -166,7 +156,7 @@ function OverlayPanel({
onClose,
children,
}: {
eyebrow: string;
eyebrow?: string;
title: string;
description?: string;
action?: ReactNode;
@@ -184,12 +174,16 @@ function OverlayPanel({
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
{eyebrow ? (
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
) : null}
<div
className={`${eyebrow ? 'mt-2 text-2xl' : 'text-xl sm:text-2xl'} font-semibold text-[var(--platform-text-strong)]`}
>
{title}
</div>
{description ? (
@@ -204,9 +198,10 @@ function OverlayPanel({
<button
type="button"
autoFocus
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
className="platform-button platform-button--ghost min-h-0 gap-1.5 rounded-full px-3 py-1.5 text-xs"
onClick={onBack}
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
) : (
@@ -446,17 +441,16 @@ export function AccountModal({
? '正在同步平台设置...'
: '平台设置已同步';
const accountSummaryCards = [
['登录方式', resolveLoginMethodLabel(user.loginMethod)],
['手机号', user.phoneNumberMasked || '未绑定'],
['微信绑定', user.wechatBound ? '已绑定' : '未绑定'],
] as const;
const boundPhoneNumber =
user.phoneNumber?.trim() || user.phoneNumberMasked || '未绑定';
const boundWechatDisplayName =
user.wechatDisplayName?.trim() || (user.wechatBound ? '已绑定' : '未绑定');
const sectionSummaries: Record<PrimarySettingsSection, string> = {
appearance:
platformTheme === 'dark' ? '当前使用暗色主题。' : '当前使用亮色主题。',
account:
user.phoneNumberMasked || user.wechatBound
user.phoneNumber || user.phoneNumberMasked || user.wechatBound
? '查看身份、安全状态、登录设备与操作记录。'
: '查看账号绑定状态与安全记录。',
};
@@ -524,7 +518,7 @@ export function AccountModal({
{activeSection === 'appearance' ? (
<OverlayPanel
eyebrow="平台偏好"
title="主题外观"
title="主题设置"
description="切换平台亮色或暗色主题。"
onBack={closeSectionPanel}
onClose={onClose}
@@ -568,70 +562,79 @@ export function AccountModal({
{activeSection === 'account' ? (
<OverlayPanel
eyebrow="身份信息"
title="账号信息"
description="统一查看身份、安全状态、登录设备与最近操作。"
standalone={isDirectAccountMode}
onBack={isDirectAccountMode ? undefined : closeSectionPanel}
onClose={onClose}
>
<div className="flex min-h-0 flex-col gap-4">
<div data-account-content className="flex min-h-0 flex-col gap-3">
{accountNotice ? (
<div className="platform-banner platform-banner--success text-sm">
{accountNotice}
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
{accountSummaryCards.map(([label, value]) => (
<div
key={label}
className="platform-subpanel rounded-2xl px-4 py-3"
>
<div className="text-xs tracking-[0.18em] text-[var(--platform-text-soft)]">
{label}
</div>
<div className="mt-2 text-sm font-semibold text-[var(--platform-text-strong)]">
{value}
<div className="grid gap-2.5 sm:grid-cols-2">
<div
data-account-binding-card
className="platform-subpanel rounded-2xl px-3.5 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
onClick={(event) => {
changePhoneTriggerRef.current = event.currentTarget;
setAccountNotice('');
resetChangePhoneDraft();
setIsChangePhonePanelOpen(true);
}}
>
</button>
</div>
))}
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
{boundPhoneNumber}
</div>
</div>
<div
data-account-binding-card
className="platform-subpanel rounded-2xl px-3.5 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
onClick={() => {
setAccountNotice('更换微信号功能暂未接入。');
}}
>
</button>
</div>
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
{boundWechatDisplayName}
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
className="platform-button platform-button--ghost h-11 w-full text-sm"
onClick={() => {
void onLogout();
}}
>
退
</button>
<button
type="button"
className="platform-button platform-button--danger h-11 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
onClick={(event) => {
passwordTriggerRef.current = event.currentTarget;
setAccountNotice('');
@@ -644,40 +647,12 @@ export function AccountModal({
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={(event) => {
changePhoneTriggerRef.current = event.currentTarget;
setAccountNotice('');
resetChangePhoneDraft();
setIsChangePhonePanelOpen(true);
}}
>
</button>
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
@@ -690,7 +665,7 @@ export function AccountModal({
</button>
</div>
<div className="mt-4 grid gap-3">
<div className="mt-3 grid gap-2.5">
{loadingRiskBlocks ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
@@ -734,15 +709,12 @@ export function AccountModal({
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
@@ -755,7 +727,7 @@ export function AccountModal({
</button>
</div>
<div className="mt-4 grid gap-3">
<div className="mt-3 grid gap-2.5">
{loadingSessions ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
@@ -818,15 +790,12 @@ export function AccountModal({
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
@@ -839,7 +808,7 @@ export function AccountModal({
</button>
</div>
<div className="mt-4 grid gap-3">
<div className="mt-3 grid gap-2.5">
{loadingAuditLogs ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
@@ -873,6 +842,30 @@ export function AccountModal({
)}
</div>
</div>
<div
data-account-actions
className="grid gap-2.5 pt-1 sm:grid-cols-2"
>
<button
type="button"
className="platform-button platform-button--ghost h-10 w-full text-sm"
onClick={() => {
void onLogout();
}}
>
退
</button>
<button
type="button"
className="platform-button platform-button--danger h-10 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
</div>
{isChangePhonePanelOpen ? (
@@ -15,10 +15,6 @@ vi.mock('../../services/bark-battle-creation', () => ({
updateBarkBattleDraftConfig: vi.fn(),
}));
vi.mock('./BarkBattlePreviewCard', () => ({
BarkBattlePreviewCard: () => <div></div>,
}));
const draft = {
draftId: 'bark-battle-draft-1',
workId: 'BB-12345678',
@@ -61,6 +57,12 @@ describe('BarkBattleGeneratingView', () => {
expect(container.firstChild).toBeTruthy();
expect((container.firstChild as HTMLElement).className).toContain('z-[1]');
expect((container.firstChild as HTMLElement).className).toContain(
'overflow-hidden',
);
expect((container.firstChild as HTMLElement).className).not.toContain(
'overflow-y-auto',
);
expect(screen.getByText('总进度')).toBeTruthy();
expect(screen.getByText('总进度').className).toContain('text-[9px]');
const pageVideo = screen.getByTestId(
@@ -100,6 +102,14 @@ describe('BarkBattleGeneratingView', () => {
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
'bg-white/58',
);
expect(
screen.getByTestId('generation-hero-wait-card').parentElement
?.className,
).toContain('mt-3');
expect(
screen.getByTestId('generation-hero-wait-card').parentElement
?.className,
).toContain('px-0');
expect(screen.getByText('预计等待').className).toContain('text-[9px]');
expect(screen.getByText('已耗时').className).toContain('text-[9px]');
expect(screen.getByText('预计等待').parentElement?.className).toContain(
@@ -218,7 +228,13 @@ describe('BarkBattleGeneratingView', () => {
expect(
screen.getByTestId('generation-current-step-card').className,
).toContain('bg-white/58');
expect(screen.getByText('预览信息').className).toContain('text-[13px]');
expect(
screen.getByTestId('generation-current-step-card').parentElement
?.className,
).toContain('mt-5');
expect(screen.queryByText('预览信息')).toBeNull();
expect(screen.queryByText('汪汪声浪预览')).toBeNull();
expect(screen.queryByText('霓虹公园擂台')).toBeNull();
expect(screen.queryByText('对手形象')).toBeNull();
expect(screen.queryByText('竞技背景')).toBeNull();
expect(onComplete).not.toHaveBeenCalled();
@@ -17,7 +17,6 @@ import {
GenerationPageBackdrop,
GenerationProgressHero,
} from '../GenerationProgressHero';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
type BarkBattleGeneratingViewProps = {
draft: BarkBattleDraftConfig;
@@ -355,54 +354,49 @@ export function BarkBattleGeneratingView({
}, [draft, onComplete, onError]);
return (
<div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-y-auto bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5 xl:px-6 xl:pb-5 xl:pt-5">
<div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5 xl:px-6 xl:pb-5 xl:pt-5">
<GenerationPageBackdrop />
<div className="relative z-10 mx-auto flex w-full max-w-[48rem] flex-col">
<div className="mb-6 flex shrink-0 items-center justify-between gap-3 sm:mb-6">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm ${isBusy ? 'opacity-45' : ''}`}
>
<ArrowLeft className="h-5 w-5" strokeWidth={2.6} />
<span className="break-keep"></span>
</button>
<span className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
</span>
</div>
<div className="relative z-30 mx-auto mb-4 flex w-full max-w-[48rem] shrink-0 items-center justify-between gap-3 sm:mb-5">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm ${isBusy ? 'opacity-45' : ''}`}
>
<ArrowLeft className="h-5 w-5" strokeWidth={2.6} />
<span className="break-keep"></span>
</button>
<span className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
</span>
</div>
<section className="flex flex-col gap-4">
<div className="grid content-start gap-3 overflow-hidden px-0 pb-0 pt-0">
<GenerationProgressHero
title="汪汪声浪素材生成进度"
phaseLabel={draft.title || '未命名声浪竞技场'}
progressValue={progressValue}
estimatedWaitText="3 分钟"
elapsedText={formatGenerationDuration(elapsedMs)}
<div
className="relative z-10 mx-auto flex min-h-0 w-full max-w-[48rem] flex-1 flex-col overflow-y-auto overscroll-y-contain"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<section className="grid content-start gap-3 overflow-hidden px-0 pb-2 pt-0">
<GenerationProgressHero
title="汪汪声浪素材生成进度"
phaseLabel={draft.title || '未命名声浪竞技场'}
progressValue={progressValue}
estimatedWaitText="3 分钟"
elapsedText={formatGenerationDuration(elapsedMs)}
/>
<div className="mt-5 sm:mt-[-0.15rem]">
<GenerationCurrentStepCard
label={currentStepLabel}
statusLabel={currentStepStatusLabel}
progressValue={currentStepProgress}
/>
<div className="mt-[-0.15rem]">
<GenerationCurrentStepCard
label={currentStepLabel}
statusLabel={currentStepStatusLabel}
progressValue={currentStepProgress}
/>
</div>
{error || primaryFailureMessage ? (
<div className="rounded-[1.4rem] border border-[#d88969]/35 bg-white/76 px-4 py-3 text-sm leading-6 text-[#a6402f] backdrop-blur-md">
{error ?? primaryFailureMessage}
</div>
) : null}
</div>
<section className="overflow-hidden rounded-[1.75rem] border border-[#eadcd1] bg-[rgba(255,250,246,0.92)] px-4 py-4 shadow-[0_20px_56px_rgba(112,57,30,0.08)] backdrop-blur-[5px] sm:px-5 sm:py-5">
<div className="mb-4 text-[13px] font-black tracking-[0.08em] text-[#111111]">
{error || primaryFailureMessage ? (
<div className="rounded-[1.4rem] border border-[#d88969]/35 bg-white/76 px-4 py-3 text-sm leading-6 text-[#a6402f] backdrop-blur-md">
{error ?? primaryFailureMessage}
</div>
<BarkBattlePreviewCard config={previewDraft} />
</section>
) : null}
</section>
</div>
</div>
@@ -1,15 +1,11 @@
import { Mic, Pause, Upload } from 'lucide-react';
import { useRef, useState } from 'react';
export type CreativeAudioAsset = {
assetId: string;
audioSrc: string;
audioObjectKey: string;
assetObjectId: string;
source: string;
prompt?: string | null;
durationMs?: number | null;
};
import {
type CreativeAudioAsset,
readCreativeAudioFileAsAsset,
} from './creativeAudioFileAsset';
import { trimLeadingSilenceFromRecordedAudioFile } from './creativeAudioSilenceTrim';
type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
disabled?: boolean;
@@ -25,32 +21,6 @@ type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
) => Promise<TAsset>;
};
export function readCreativeAudioFileAsAsset<TAsset extends CreativeAudioAsset>(
file: File,
source: 'uploaded' | 'recorded',
) {
return new Promise<TAsset>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('音频读取失败,请重试。'));
return;
}
resolve({
assetId: `local-${source}-${Date.now()}`,
audioSrc: reader.result,
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs: null,
} as TAsset);
};
reader.readAsDataURL(file);
});
}
export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
disabled = false,
title,
@@ -94,7 +64,8 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
const file = new File([blob], buildRecordedFileName(), {
type: blob.type,
});
void readFileAsAsset(file, 'recorded')
void trimLeadingSilenceFromRecordedAudioFile(file)
.then((trimmedFile) => readFileAsAsset(trimmedFile, 'recorded'))
.then(onAssetChange)
.catch((caughtError) => {
onError(
@@ -0,0 +1,35 @@
export type CreativeAudioAsset = {
assetId: string;
audioSrc: string;
audioObjectKey: string;
assetObjectId: string;
source: string;
prompt?: string | null;
durationMs?: number | null;
};
export function readCreativeAudioFileAsAsset<TAsset extends CreativeAudioAsset>(
file: File,
source: 'uploaded' | 'recorded',
) {
return new Promise<TAsset>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('音频读取失败,请重试。'));
return;
}
resolve({
assetId: `local-${source}-${Date.now()}`,
audioSrc: reader.result,
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs: null,
} as TAsset);
};
reader.readAsDataURL(file);
});
}
@@ -0,0 +1,77 @@
import { expect, test } from 'vitest';
import {
buildLeadingSilenceTrimmedWavBlob,
findFirstAudibleFrame,
} from './creativeAudioSilenceTrim';
function createAudioBufferStub(
channels: number[][],
sampleRate = 1000,
): AudioBuffer {
return {
length: channels[0]?.length ?? 0,
numberOfChannels: channels.length,
sampleRate,
duration: (channels[0]?.length ?? 0) / sampleRate,
getChannelData: (channel: number) =>
new Float32Array(channels[channel] ?? []),
} as AudioBuffer;
}
test('findFirstAudibleFrame skips leading frames that are silent across all channels', () => {
const buffer = createAudioBufferStub([
[0, 0.003, -0.006, 0.012, 0.02],
[0, -0.004, 0.009, 0, 0],
]);
expect(findFirstAudibleFrame(buffer, 0.01)).toBe(3);
});
test('buildLeadingSilenceTrimmedWavBlob writes a wav that starts at the first audible frame', async () => {
const buffer = createAudioBufferStub(
[
[0, 0, 0, 0.25, -0.5],
[0, 0, 0, -0.25, 0.5],
],
1000,
);
const blob = buildLeadingSilenceTrimmedWavBlob(buffer, {
silenceThreshold: 0.01,
minimumTrimDurationMs: 1,
});
expect(blob).not.toBeNull();
expect(blob?.type).toBe('audio/wav');
const bytes = await blob!.arrayBuffer();
const view = new DataView(bytes);
expect(String.fromCharCode(...new Uint8Array(bytes, 0, 4))).toBe('RIFF');
expect(String.fromCharCode(...new Uint8Array(bytes, 8, 4))).toBe('WAVE');
expect(String.fromCharCode(...new Uint8Array(bytes, 36, 4))).toBe('data');
expect(view.getUint32(40, true)).toBe(8);
expect(view.getInt16(44, true)).toBeCloseTo(8191, -1);
expect(view.getInt16(46, true)).toBeCloseTo(-8192, -1);
expect(view.getInt16(48, true)).toBeCloseTo(-16384, -1);
expect(view.getInt16(50, true)).toBeCloseTo(16383, -1);
});
test('buildLeadingSilenceTrimmedWavBlob keeps the original recording when no leading silence is removable', () => {
const startsImmediately = createAudioBufferStub([[0.2, 0.1, 0.05]], 1000);
const allSilent = createAudioBufferStub([[0, 0.001, -0.001]], 1000);
expect(
buildLeadingSilenceTrimmedWavBlob(startsImmediately, {
silenceThreshold: 0.01,
minimumTrimDurationMs: 1,
}),
).toBeNull();
expect(
buildLeadingSilenceTrimmedWavBlob(allSilent, {
silenceThreshold: 0.01,
minimumTrimDurationMs: 1,
}),
).toBeNull();
});
@@ -0,0 +1,190 @@
type BrowserAudioGlobal = typeof globalThis & {
webkitAudioContext?: typeof AudioContext;
};
export type LeadingSilenceTrimOptions = {
silenceThreshold?: number;
minimumTrimDurationMs?: number;
};
const DEFAULT_SILENCE_THRESHOLD = 0.01;
const DEFAULT_MINIMUM_TRIM_DURATION_MS = 20;
const WAV_HEADER_BYTE_LENGTH = 44;
const WAV_BITS_PER_SAMPLE = 16;
const WAV_BYTES_PER_SAMPLE = WAV_BITS_PER_SAMPLE / 8;
export function findFirstAudibleFrame(
buffer: AudioBuffer,
silenceThreshold = DEFAULT_SILENCE_THRESHOLD,
) {
const threshold = Math.max(0, silenceThreshold);
for (let frameIndex = 0; frameIndex < buffer.length; frameIndex += 1) {
for (
let channelIndex = 0;
channelIndex < buffer.numberOfChannels;
channelIndex += 1
) {
const channelData = buffer.getChannelData(channelIndex);
if (Math.abs(channelData[frameIndex] ?? 0) > threshold) {
return frameIndex;
}
}
}
return null;
}
export function buildLeadingSilenceTrimmedWavBlob(
buffer: AudioBuffer,
options: LeadingSilenceTrimOptions = {},
) {
const silenceThreshold =
options.silenceThreshold ?? DEFAULT_SILENCE_THRESHOLD;
const minimumTrimDurationMs =
options.minimumTrimDurationMs ?? DEFAULT_MINIMUM_TRIM_DURATION_MS;
const firstAudibleFrame = findFirstAudibleFrame(buffer, silenceThreshold);
if (firstAudibleFrame === null) {
return null;
}
const minimumTrimFrames = Math.max(
1,
Math.round((buffer.sampleRate * minimumTrimDurationMs) / 1000),
);
if (firstAudibleFrame < minimumTrimFrames) {
return null;
}
const frameCount = buffer.length - firstAudibleFrame;
if (frameCount <= 0) {
return null;
}
return encodeAudioBufferSectionToWavBlob(
buffer,
firstAudibleFrame,
frameCount,
);
}
export async function trimLeadingSilenceFromRecordedAudioFile(
file: File,
options: LeadingSilenceTrimOptions = {},
) {
try {
const decodedBuffer = await decodeRecordedAudioFile(file);
if (!decodedBuffer) {
return file;
}
const trimmedBlob = buildLeadingSilenceTrimmedWavBlob(
decodedBuffer,
options,
);
if (!trimmedBlob) {
return file;
}
return new File([trimmedBlob], buildTrimmedAudioFileName(file.name), {
type: trimmedBlob.type,
lastModified: Date.now(),
});
} catch {
// 录音裁剪只是体验优化,浏览器解码失败时必须保留用户刚录好的原始文件。
return file;
}
}
function getAudioContextConstructor() {
const audioGlobal = globalThis as BrowserAudioGlobal;
return audioGlobal.AudioContext ?? audioGlobal.webkitAudioContext ?? null;
}
async function decodeRecordedAudioFile(file: File) {
const AudioContextConstructor = getAudioContextConstructor();
if (!AudioContextConstructor) {
return null;
}
const context = new AudioContextConstructor();
try {
const bytes = await file.arrayBuffer();
return await context.decodeAudioData(bytes.slice(0));
} finally {
void context.close();
}
}
function encodeAudioBufferSectionToWavBlob(
buffer: AudioBuffer,
startFrame: number,
frameCount: number,
) {
// MediaRecorder 输出格式不稳定;解码后统一写成 WAV,避免再依赖浏览器重新编码。
const channelCount = Math.max(1, buffer.numberOfChannels);
const dataByteLength =
frameCount * channelCount * WAV_BYTES_PER_SAMPLE;
const output = new ArrayBuffer(WAV_HEADER_BYTE_LENGTH + dataByteLength);
const view = new DataView(output);
const channelData = Array.from({ length: channelCount }, (_value, index) =>
index < buffer.numberOfChannels
? buffer.getChannelData(index)
: new Float32Array(buffer.length),
);
writeAscii(view, 0, 'RIFF');
view.setUint32(4, 36 + dataByteLength, true);
writeAscii(view, 8, 'WAVE');
writeAscii(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channelCount, true);
view.setUint32(24, buffer.sampleRate, true);
view.setUint32(
28,
buffer.sampleRate * channelCount * WAV_BYTES_PER_SAMPLE,
true,
);
view.setUint16(32, channelCount * WAV_BYTES_PER_SAMPLE, true);
view.setUint16(34, WAV_BITS_PER_SAMPLE, true);
writeAscii(view, 36, 'data');
view.setUint32(40, dataByteLength, true);
let outputOffset = WAV_HEADER_BYTE_LENGTH;
for (let frameOffset = 0; frameOffset < frameCount; frameOffset += 1) {
const sourceFrame = startFrame + frameOffset;
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
const sample = channelData[channelIndex]?.[sourceFrame] ?? 0;
view.setInt16(outputOffset, toSignedPcm16(sample), true);
outputOffset += WAV_BYTES_PER_SAMPLE;
}
}
return new Blob([output], { type: 'audio/wav' });
}
function toSignedPcm16(sample: number) {
const clamped = Math.max(-1, Math.min(1, sample));
return clamped < 0
? Math.round(clamped * 0x8000)
: Math.round(clamped * 0x7fff);
}
function writeAscii(view: DataView, offset: number, value: string) {
for (let index = 0; index < value.length; index += 1) {
view.setUint8(offset + index, value.charCodeAt(index));
}
}
function buildTrimmedAudioFileName(fileName: string) {
const normalizedName = fileName.trim();
if (!normalizedName) {
return 'recorded-audio.wav';
}
return /\.[^.]+$/u.test(normalizedName)
? normalizedName.replace(/\.[^.]+$/u, '.wav')
: `${normalizedName}.wav`;
}
@@ -6,6 +6,7 @@ import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contra
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleClearWorkSummaryResponse } from '../../../packages/shared/src/contracts/puzzleClear';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
@@ -77,6 +78,9 @@ type CustomWorldCreationHubProps = {
| ((item: WoodenFishWorkSummaryResponse) => void)
| null;
onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
puzzleClearItems?: PuzzleClearWorkSummaryResponse[];
onOpenPuzzleClearDetail?: ((item: PuzzleClearWorkSummaryResponse) => void) | null;
onDeletePuzzleClear?: ((item: PuzzleClearWorkSummaryResponse) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
@@ -193,6 +197,9 @@ export function CustomWorldCreationHub({
woodenFishItems = [],
onOpenWoodenFishDetail = null,
onDeleteWoodenFish = null,
puzzleClearItems = [],
onOpenPuzzleClearDetail = null,
onDeletePuzzleClear = null,
puzzleItems = [],
onOpenPuzzleDetail,
onDeletePuzzle = null,
@@ -228,6 +235,7 @@ export function CustomWorldCreationHub({
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
jumpHopItems,
woodenFishItems,
puzzleClearItems,
puzzleItems,
babyObjectMatchItems,
barkBattleItems,
@@ -239,6 +247,7 @@ export function CustomWorldCreationHub({
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeleteJumpHop: Boolean(onDeleteJumpHop),
canDeleteWoodenFish: Boolean(onDeleteWoodenFish),
canDeletePuzzleClear: Boolean(onDeletePuzzleClear),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
@@ -256,6 +265,8 @@ export function CustomWorldCreationHub({
onDeleteJumpHop: onDeleteJumpHop ?? undefined,
onOpenWoodenFishDetail: onOpenWoodenFishDetail ?? undefined,
onDeleteWoodenFish: onDeleteWoodenFish ?? undefined,
onOpenPuzzleClearDetail: onOpenPuzzleClearDetail ?? undefined,
onDeletePuzzleClear: onDeletePuzzleClear ?? undefined,
onOpenPuzzleDetail,
onDeletePuzzle: onDeletePuzzle ?? undefined,
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
@@ -284,6 +295,7 @@ export function CustomWorldCreationHub({
onDeleteVisualNovel,
onDeleteJumpHop,
onDeleteWoodenFish,
onDeletePuzzleClear,
onClaimPuzzlePointIncentive,
onOpenBigFishDetail,
onOpenDraft,
@@ -294,8 +306,10 @@ export function CustomWorldCreationHub({
onOpenSquareHoleDetail,
onOpenVisualNovelDetail,
onOpenWoodenFishDetail,
onOpenPuzzleClearDetail,
onEnterPublished,
getWorkState,
puzzleClearItems,
puzzleItems,
rpgLibraryEntries,
onOpenJumpHopDetail,
@@ -369,6 +383,9 @@ export function CustomWorldCreationHub({
case 'wooden-fish':
onOpenWoodenFishDetail?.(item.source.item);
return;
case 'puzzle-clear':
onOpenPuzzleClearDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);
@@ -7,6 +7,7 @@ import {
Trash2,
} from 'lucide-react';
import {
default as React,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
@@ -62,6 +63,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
'square-hole': '/creation-type-references/square-hole.webp',
'jump-hop': '/creation-type-references/jump-hop.webp',
'wooden-fish': '/wooden-fish/default-hit-object.png',
'puzzle-clear': '/creation-type-references/puzzle.webp',
puzzle: '/creation-type-references/puzzle.webp',
'baby-object-match': '/creation-type-references/creative-agent.webp',
'bark-battle': '/creation-type-references/bark-battle.webp',
@@ -99,6 +99,47 @@ test('buildCreationWorkShelfItems maps wooden fish items with WF public code', (
expect(onOpenWoodenFishDetail).toHaveBeenCalledWith(woodenFishWork);
});
test('buildCreationWorkShelfItems maps puzzle clear items with PC public code', () => {
const onOpenPuzzleClearDetail = vi.fn();
const puzzleClearWork = {
runtimeKind: 'puzzle-clear' as const,
workId: 'puzzle-clear-work-1',
profileId: 'puzzle-clear-profile-12345678',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-clear-session-1',
workTitle: '星港拼消消',
workDescription: '霓虹星港主题。',
themePrompt: '霓虹星港',
coverImageSrc: '/generated-puzzle-clear-assets/profile/atlas.png',
publicationStatus: 'published',
playCount: 6,
updatedAt: '2026-05-30T00:00:00.000Z',
publishedAt: '2026-05-30T00:00:00.000Z',
publishReady: true,
generationStatus: 'ready' as const,
};
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
puzzleClearItems: [puzzleClearWork],
onOpenPuzzleClearDetail,
});
items[0]?.actions.open();
expect(items).toHaveLength(1);
expect(items[0]?.kind).toBe('puzzle-clear');
expect(items[0]?.status).toBe('published');
expect(items[0]?.publicWorkCode).toBe('PC-12345678');
expect(items[0]?.sharePath).toContain('/works/detail?work=PC-12345678');
expect(items[0]?.openActionLabel).toBe('查看详情');
expect(items[0]?.badges.some((badge) => badge.label === '拼消消')).toBe(true);
expect(items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value).toBe(6);
expect(onOpenPuzzleClearDetail).toHaveBeenCalledWith(puzzleClearWork);
});
test('buildCreationWorkShelfItems keeps published bark battle over duplicate draft', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
@@ -3,6 +3,7 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleClearWorkSummaryResponse } from '../../../packages/shared/src/contracts/puzzleClear';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
@@ -17,6 +18,7 @@ import {
buildBigFishPublicWorkCode,
buildJumpHopPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzleClearPublicWorkCode,
buildPuzzlePublicWorkCode,
buildSquareHolePublicWorkCode,
buildVisualNovelPublicWorkCode,
@@ -37,6 +39,7 @@ export type CreationWorkShelfKind =
| 'square-hole'
| 'jump-hop'
| 'wooden-fish'
| 'puzzle-clear'
| 'puzzle'
| 'baby-object-match'
| 'bark-battle'
@@ -97,6 +100,10 @@ export type CreationWorkShelfSource =
kind: 'wooden-fish';
item: WoodenFishWorkSummaryResponse;
}
| {
kind: 'puzzle-clear';
item: PuzzleClearWorkSummaryResponse;
}
| {
kind: 'puzzle';
item: PuzzleWorkSummary;
@@ -165,6 +172,7 @@ export function buildCreationWorkShelfItems(params: {
squareHoleItems?: SquareHoleWorkSummary[];
jumpHopItems?: JumpHopWorkSummaryResponse[];
woodenFishItems?: WoodenFishWorkSummaryResponse[];
puzzleClearItems?: PuzzleClearWorkSummaryResponse[];
puzzleItems: PuzzleWorkSummary[];
babyObjectMatchItems?: BabyObjectMatchDraft[];
barkBattleItems?: BarkBattleWorkSummary[];
@@ -175,6 +183,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteSquareHole?: boolean;
canDeleteJumpHop?: boolean;
canDeleteWoodenFish?: boolean;
canDeletePuzzleClear?: boolean;
canDeletePuzzle?: boolean;
canDeleteBabyObjectMatch?: boolean;
canDeleteBarkBattle?: boolean;
@@ -192,6 +201,8 @@ export function buildCreationWorkShelfItems(params: {
onDeleteJumpHop?: (item: JumpHopWorkSummaryResponse) => void;
onOpenWoodenFishDetail?: (item: WoodenFishWorkSummaryResponse) => void;
onDeleteWoodenFish?: (item: WoodenFishWorkSummaryResponse) => void;
onOpenPuzzleClearDetail?: (item: PuzzleClearWorkSummaryResponse) => void;
onDeletePuzzleClear?: (item: PuzzleClearWorkSummaryResponse) => void;
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
@@ -213,6 +224,7 @@ export function buildCreationWorkShelfItems(params: {
squareHoleItems = [],
jumpHopItems = [],
woodenFishItems = [],
puzzleClearItems = [],
puzzleItems,
babyObjectMatchItems = [],
barkBattleItems = [],
@@ -223,6 +235,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteSquareHole = false,
canDeleteJumpHop = false,
canDeleteWoodenFish = false,
canDeletePuzzleClear = false,
canDeletePuzzle = false,
canDeleteBabyObjectMatch = false,
canDeleteBarkBattle = false,
@@ -240,6 +253,8 @@ export function buildCreationWorkShelfItems(params: {
onDeleteJumpHop,
onOpenWoodenFishDetail,
onDeleteWoodenFish,
onOpenPuzzleClearDetail,
onDeletePuzzleClear,
onOpenPuzzleDetail,
onDeletePuzzle,
onClaimPuzzlePointIncentive,
@@ -290,6 +305,12 @@ export function buildCreationWorkShelfItems(params: {
onDelete: onDeleteWoodenFish,
}),
),
...puzzleClearItems.map((item) =>
mapPuzzleClearWorkToShelfItem(item, canDeletePuzzleClear, {
onOpen: onOpenPuzzleClearDetail,
onDelete: onDeletePuzzleClear,
}),
),
...puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
onOpen: onOpenPuzzleDetail,
@@ -903,6 +924,56 @@ function mapWoodenFishWorkToShelfItem(
};
}
function mapPuzzleClearWorkToShelfItem(
item: PuzzleClearWorkSummaryResponse,
canDelete: boolean,
adapter: WorkShelfAdapter<PuzzleClearWorkSummaryResponse>,
): CreationWorkShelfItem {
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
status === 'published'
? buildPuzzleClearPublicWorkCode(item.profileId)
: null;
const title = item.workTitle.trim() || '拼消消';
const summary =
item.workDescription.trim() || (status === 'draft' ? '未填写作品描述' : '');
return {
id: item.workId,
kind: 'puzzle-clear',
status,
title,
summary,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc: normalizeCoverImageSrc(item.coverImageSrc),
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode,
sharePath:
publicWorkCode && status === 'published'
? buildPublicWorkStagePath('work-detail', publicWorkCode)
: null,
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
badges: [
buildStatusBadge(status),
{ id: 'type', label: '拼消消', tone: 'neutral' },
],
metrics:
status === 'published'
? buildPublishedMetrics({
playCount: item.playCount,
remixCount: 0,
likeCount: 0,
})
: [],
actions: buildWorkShelfActions(item, adapter),
source: { kind: 'puzzle-clear', item },
};
}
function resolveAuthorDisplayName(...sources: Array<unknown>) {
for (const source of sources) {
const authorDisplayName =
@@ -1119,6 +1190,8 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
return isPersistedPuzzleDraftGenerating(item.source.item);
case 'wooden-fish':
return item.source.item.generationStatus === 'generating';
case 'puzzle-clear':
return item.source.item.generationStatus === 'generating';
case 'bark-battle':
return isPersistedBarkBattleDraftGenerating(item.source.item);
default:
File diff suppressed because it is too large Load Diff
@@ -36,6 +36,10 @@ export type SelectionStage =
| 'jump-hop-result'
| 'jump-hop-runtime'
| 'jump-hop-gallery-detail'
| 'puzzle-clear-workspace'
| 'puzzle-clear-generating'
| 'puzzle-clear-result'
| 'puzzle-clear-runtime'
| 'bark-battle-workspace'
| 'bark-battle-generating'
| 'bark-battle-result'
@@ -0,0 +1,73 @@
import { describe, expect, test } from 'vitest';
import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
import { shouldTickPlatformGenerationProgressClock } from './platformGenerationProgressClock';
describe('platformGenerationProgressClock', () => {
test('ticks while puzzle clear generation is still running', () => {
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'puzzle-clear-generating',
generationState: createMiniGameDraftGenerationState('puzzle-clear'),
}),
).toBe(true);
});
test('stops ticking after puzzle clear generation is ready or failed', () => {
const runningState = createMiniGameDraftGenerationState('puzzle-clear');
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'puzzle-clear-generating',
generationState: { ...runningState, phase: 'ready' },
}),
).toBe(false);
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'puzzle-clear-generating',
generationState: { ...runningState, phase: 'failed' },
}),
).toBe(false);
});
test('ticks for other shared mini game generation stages', () => {
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'jump-hop-generating',
generationState: createMiniGameDraftGenerationState('jump-hop'),
}),
).toBe(true);
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'wooden-fish-generating',
generationState: createMiniGameDraftGenerationState('wooden-fish'),
}),
).toBe(true);
});
test('ticks visual novel generation from its phase source', () => {
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'visual-novel-generating',
visualNovelGenerationStartedAtMs: 1000,
visualNovelGenerationPhase: 'generating',
}),
).toBe(true);
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'visual-novel-generating',
visualNovelGenerationStartedAtMs: 1000,
visualNovelGenerationPhase: 'ready',
}),
).toBe(false);
});
test('does not tick when no generating stage is active', () => {
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'platform',
generationState: createMiniGameDraftGenerationState('puzzle-clear'),
}),
).toBe(false);
});
});
@@ -0,0 +1,36 @@
import type { MiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
import type { SelectionStage } from './platformEntryTypes';
type VisualNovelEntryGenerationPhase = 'generating' | 'ready' | 'failed';
type PlatformGenerationProgressClockInput = {
selectionStage: SelectionStage;
generationState?: MiniGameDraftGenerationState | null;
visualNovelGenerationStartedAtMs?: number | null;
visualNovelGenerationPhase?: VisualNovelEntryGenerationPhase;
};
export function shouldTickPlatformGenerationProgressClock({
selectionStage,
generationState,
visualNovelGenerationStartedAtMs,
visualNovelGenerationPhase,
}: PlatformGenerationProgressClockInput) {
if (selectionStage === 'visual-novel-generating') {
return (
visualNovelGenerationStartedAtMs != null &&
visualNovelGenerationPhase !== 'ready' &&
visualNovelGenerationPhase !== 'failed'
);
}
if (!selectionStage.endsWith('-generating')) {
return false;
}
return Boolean(
generationState &&
generationState.phase !== 'ready' &&
generationState.phase !== 'failed',
);
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { isPuzzleCompileActionReady } from './puzzleDraftGenerationState';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
describe('isPuzzleCompileActionReady', () => {
it('keeps compile action generating until the draft has a cover image', () => {
const session = {
sessionId: 'puzzle-session-1',
draft: {
coverImageSrc: null,
levels: [
{
generationStatus: 'generating',
coverImageSrc: null,
},
],
},
} as PuzzleAgentSessionSnapshot;
expect(isPuzzleCompileActionReady(session)).toBe(false);
});
it('treats compile action as ready after the selected cover exists', () => {
const session = {
sessionId: 'puzzle-session-1',
draft: {
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
levels: [
{
generationStatus: 'ready',
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
},
],
},
} as PuzzleAgentSessionSnapshot;
expect(isPuzzleCompileActionReady(session)).toBe(true);
});
});
@@ -0,0 +1,20 @@
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
function hasText(value: string | null | undefined) {
return typeof value === 'string' && value.trim().length > 0;
}
export function isPuzzleCompileActionReady(
session: PuzzleAgentSessionSnapshot,
) {
const draft = session.draft;
if (!draft) {
return false;
}
if (hasText(draft.coverImageSrc)) {
return true;
}
return (
draft.levels?.some((level) => hasText(level.coverImageSrc)) === true
);
}
@@ -0,0 +1,222 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ImgHTMLAttributes } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import type { PuzzleClearSessionResponse } from '../../../packages/shared/src/contracts/puzzleClear';
import { puzzleClearClient } from '../../services/puzzle-clear/puzzleClearClient';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { PuzzleClearWorkspace } from './PuzzleClearWorkspace';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
refreshKey: _refreshKey,
...rest
}: {
src?: string | null;
alt?: string;
className?: string;
refreshKey?: unknown;
[key: string]: unknown;
}) =>
src ? (
<img
src={src}
alt={alt}
className={className}
{...(rest as ImgHTMLAttributes<HTMLImageElement>)}
/>
) : null,
}));
vi.mock('../../services/puzzleReferenceImage', () => ({
readPuzzleReferenceImageAsDataUrl: vi.fn(),
}));
vi.mock('../../services/puzzle-clear/puzzleClearClient', () => ({
puzzleClearClient: {
createSession: vi.fn(),
},
}));
function createSessionResponse(): PuzzleClearSessionResponse {
return {
session: {
sessionId: 'puzzle-clear-session-1',
ownerUserId: 'user-1',
status: 'draft',
draft: {
templateId: 'puzzle-clear',
templateName: '拼消消',
profileId: null,
workTitle: '星港拼消消',
workDescription: '霓虹星港主题',
themePrompt: '霓虹星港',
boardBackgroundPrompt: '星港中央棋盘底图',
generateBoardBackground: false,
boardBackgroundAsset: null,
cardBackImageSrc: '/creation-type-references/puzzle-clear-card-back.webp',
atlasAsset: null,
patternGroups: [],
cardAssets: [],
generationStatus: 'draft',
},
createdAt: '2026-05-30T00:00:00.000Z',
updatedAt: '2026-05-30T00:00:00.000Z',
},
};
}
beforeEach(() => {
vi.mocked(puzzleClearClient.createSession).mockReset();
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockReset();
});
test('工作台提交结构化表单与底图槽位 payload', async () => {
const response = createSessionResponse();
const onSubmitted = vi.fn();
vi.mocked(puzzleClearClient.createSession).mockResolvedValue(response);
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockResolvedValue(
'data:image/png;base64,board-background',
);
render(
<PuzzleClearWorkspace
onBack={vi.fn()}
onSubmitted={onSubmitted}
/>,
);
fireEvent.change(screen.getByLabelText('作品标题'), {
target: { value: ' 星港拼消消 ' },
});
fireEvent.change(screen.getByLabelText('简介'), {
target: { value: ' 霓虹星港主题 ' },
});
fireEvent.change(screen.getByLabelText('主题词'), {
target: { value: ' 霓虹星港 ' },
});
fireEvent.change(screen.getByLabelText('场地底图'), {
target: { value: '星港中央棋盘底图' },
});
fireEvent.change(screen.getByLabelText('上传底图'), {
target: {
files: [
new File(['fake-image'], 'board.png', {
type: 'image/png',
}),
],
},
});
await waitFor(() =>
expect(readPuzzleReferenceImageAsDataUrl).toHaveBeenCalledTimes(1),
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() =>
expect(puzzleClearClient.createSession).toHaveBeenCalledWith({
templateId: 'puzzle-clear',
workTitle: '星港拼消消',
workDescription: '霓虹星港主题',
themePrompt: '霓虹星港',
boardBackgroundPrompt: '星港中央棋盘底图',
generateBoardBackground: false,
boardBackgroundAsset: expect.objectContaining({
imageSrc: 'data:image/png;base64,board-background',
generationProvider: 'local-upload',
prompt: '星港中央棋盘底图',
}),
}),
);
expect(onSubmitted).toHaveBeenCalledWith(
response,
expect.objectContaining({
templateId: 'puzzle-clear',
workTitle: '星港拼消消',
themePrompt: '霓虹星港',
}),
);
});
test('工作台不渲染聊天式 Agent 输入', () => {
render(
<PuzzleClearWorkspace
onBack={vi.fn()}
onSubmitted={vi.fn()}
/>,
);
expect(screen.queryByText(/|||/u)).toBeNull();
});
test('关闭 AI 生成底图且未上传底图时不允许提交', async () => {
render(
<PuzzleClearWorkspace
onBack={vi.fn()}
onSubmitted={vi.fn()}
/>,
);
fireEvent.change(screen.getByLabelText('作品标题'), {
target: { value: '星港拼消消' },
});
fireEvent.change(screen.getByLabelText('主题词'), {
target: { value: '霓虹星港' },
});
fireEvent.click(screen.getByRole('checkbox', { name: 'AI 生成底图' }));
expect(
(screen.getByRole('button', { name: '生成' }) as HTMLButtonElement).disabled,
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() =>
expect(puzzleClearClient.createSession).not.toHaveBeenCalled(),
);
});
test('工作台支持原生表单提交生成', async () => {
const response = createSessionResponse();
const onSubmitted = vi.fn();
vi.mocked(puzzleClearClient.createSession).mockResolvedValue(response);
render(
<PuzzleClearWorkspace
onBack={vi.fn()}
onSubmitted={onSubmitted}
/>,
);
fireEvent.change(screen.getByLabelText('作品标题'), {
target: { value: '星港拼消消' },
});
fireEvent.change(screen.getByLabelText('主题词'), {
target: { value: '霓虹星港' },
});
fireEvent.change(screen.getByLabelText('场地底图'), {
target: { value: '星港中央棋盘底图' },
});
const submitButton = screen.getByRole('button', { name: '生成' });
const form = submitButton.closest('form');
expect(form).toBeTruthy();
fireEvent.submit(form!);
await waitFor(() =>
expect(puzzleClearClient.createSession).toHaveBeenCalledTimes(1),
);
expect(onSubmitted).toHaveBeenCalledWith(
response,
expect.objectContaining({
templateId: 'puzzle-clear',
workTitle: '星港拼消消',
}),
);
});
@@ -0,0 +1,326 @@
import { ArrowLeft, Loader2, Send } from 'lucide-react';
import { useMemo, useState } from 'react';
import type {
PuzzleClearImageAsset,
PuzzleClearSessionResponse,
PuzzleClearWorkspaceCreateRequest,
} from '../../../packages/shared/src/contracts/puzzleClear';
import { puzzleClearClient } from '../../services/puzzle-clear/puzzleClearClient';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
type PuzzleClearWorkspaceProps = {
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitted: (
result: PuzzleClearSessionResponse,
payload: PuzzleClearWorkspaceCreateRequest,
) => void;
};
type PuzzleClearWorkspaceFormState = {
workTitle: string;
workDescription: string;
themePrompt: string;
boardBackgroundPrompt: string;
boardBackgroundAsset: PuzzleClearImageAsset | null;
boardBackgroundImageSrc: string;
generateBoardBackground: boolean;
};
const DEFAULT_FORM_STATE: PuzzleClearWorkspaceFormState = {
workTitle: '',
workDescription: '',
themePrompt: '',
boardBackgroundPrompt: '',
boardBackgroundAsset: null,
boardBackgroundImageSrc: '',
generateBoardBackground: true,
};
function buildLocalBoardBackgroundAsset(
imageSrc: string,
prompt: string,
): PuzzleClearImageAsset {
return {
assetId: `local-board-background-${Date.now()}`,
imageSrc,
imageObjectKey: '',
assetObjectId: '',
generationProvider: 'local-upload',
prompt,
width: 0,
height: 0,
};
}
export function PuzzleClearWorkspace({
isBusy = false,
error = null,
onBack,
onSubmitted,
}: PuzzleClearWorkspaceProps) {
const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
const [localError, setLocalError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const hasBoardBackgroundInput = useMemo(
() =>
formState.generateBoardBackground ||
Boolean(formState.boardBackgroundAsset || formState.boardBackgroundImageSrc),
[
formState.boardBackgroundAsset,
formState.boardBackgroundImageSrc,
formState.generateBoardBackground,
],
);
const canSubmit = useMemo(
() =>
Boolean(
formState.workTitle.trim() &&
formState.themePrompt.trim() &&
hasBoardBackgroundInput,
),
[formState.themePrompt, formState.workTitle, hasBoardBackgroundInput],
);
const handleSubmit = async () => {
if (!canSubmit || isSubmitting || isBusy) {
setLocalError('请先补全输入。');
return;
}
setIsSubmitting(true);
setLocalError(null);
try {
const boardBackgroundAsset =
formState.boardBackgroundAsset ??
(formState.boardBackgroundImageSrc
? buildLocalBoardBackgroundAsset(
formState.boardBackgroundImageSrc,
formState.boardBackgroundPrompt.trim() ||
formState.themePrompt.trim(),
)
: null);
const payload: PuzzleClearWorkspaceCreateRequest = {
templateId: 'puzzle-clear',
workTitle: formState.workTitle.trim(),
workDescription: formState.workDescription.trim(),
themePrompt: formState.themePrompt.trim(),
boardBackgroundPrompt: formState.boardBackgroundPrompt.trim(),
generateBoardBackground: formState.generateBoardBackground,
boardBackgroundAsset,
};
const response = await puzzleClearClient.createSession(payload);
onSubmitted(response, payload);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '创建草稿失败。',
);
} finally {
setIsSubmitting(false);
}
};
return (
<form
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
>
<ArrowLeft className="h-4 w-4" />
</button>
</div>
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)]">
<section className="platform-subpanel flex min-h-0 flex-col gap-3 overflow-y-auto rounded-[1.25rem] p-4">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={formState.workTitle}
maxLength={32}
disabled={isBusy || isSubmitting}
onChange={(event) =>
setFormState((current) => ({
...current,
workTitle: event.target.value,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={formState.workDescription}
maxLength={120}
disabled={isBusy || isSubmitting}
rows={4}
onChange={(event) =>
setFormState((current) => ({
...current,
workDescription: event.target.value,
}))
}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={formState.themePrompt}
maxLength={80}
disabled={isBusy || isSubmitting}
onChange={(event) =>
setFormState((current) => ({
...current,
themePrompt: event.target.value,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="flex items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<span className="text-sm font-bold text-[var(--platform-text-strong)]">
AI
</span>
<input
type="checkbox"
checked={formState.generateBoardBackground}
disabled={isBusy || isSubmitting}
onChange={(event) =>
setFormState((current) => ({
...current,
generateBoardBackground: event.target.checked,
}))
}
className="h-5 w-5 accent-[var(--platform-accent)]"
/>
</label>
{localError || error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{localError ?? error}
</div>
) : null}
</section>
<div className="flex min-h-[28rem] min-w-0 flex-col">
<CreativeImageInputPanel
disabled={isBusy || isSubmitting}
isSubmitting={isSubmitting}
uploadedImageSrc={formState.boardBackgroundImageSrc}
uploadedImageAlt="场地底图"
mainImageInputId="puzzle-clear-board-background"
promptTextareaId="puzzle-clear-board-background-prompt"
prompt={formState.boardBackgroundPrompt}
promptLabel="场地底图"
promptRows={5}
aiRedraw={formState.generateBoardBackground}
promptReferenceImages={[]}
showSubmitButton={false}
submitLabel="生成"
submitDisabled={!canSubmit || isSubmitting || isBusy}
labels={{
imageField: '中央底图',
uploadImage: '上传底图',
replaceImage: '替换底图',
emptyImageHint: '上传图像',
removeImage: '移除底图',
removeImageConfirmTitle: '移除底图',
removeImageConfirmBody: '移除后将使用主题词生成中央场地底图。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '场地底图参考',
closePromptReferencePreview: '关闭预览',
}}
onMainImageFileSelect={(file) => {
void readPuzzleReferenceImageAsDataUrl(file)
.then((dataUrl) => {
setLocalError(null);
setFormState((current) => ({
...current,
boardBackgroundImageSrc: dataUrl,
boardBackgroundAsset: buildLocalBoardBackgroundAsset(
dataUrl,
current.boardBackgroundPrompt.trim() ||
current.themePrompt.trim(),
),
generateBoardBackground: false,
}));
})
.catch((caughtError) => {
setLocalError(
caughtError instanceof Error
? caughtError.message
: '底图读取失败。',
);
});
}}
onMainImageRemove={() => {
setFormState((current) => ({
...current,
boardBackgroundImageSrc: '',
boardBackgroundAsset: null,
}));
}}
onAiRedrawChange={(value) =>
setFormState((current) => ({
...current,
generateBoardBackground: value,
}))
}
onPromptChange={(value) =>
setFormState((current) => ({
...current,
boardBackgroundPrompt: value,
}))
}
onSubmit={handleSubmit}
/>
</div>
</div>
<div className="mt-auto flex justify-end gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] pt-3">
<button
type="submit"
disabled={!canSubmit || isSubmitting || isBusy}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${
!canSubmit || isSubmitting || isBusy
? 'cursor-not-allowed opacity-55'
: ''
}`}
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
</form>
);
}
export default PuzzleClearWorkspace;

Some files were not shown because too many files have changed in this diff Show More