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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { type ImgHTMLAttributes, useEffect, useState } from 'react';
|
||||
import React, { type ImgHTMLAttributes, useEffect, useState } from 'react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
35
src/components/common/creativeAudioFileAsset.ts
Normal file
35
src/components/common/creativeAudioFileAsset.ts
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
77
src/components/common/creativeAudioSilenceTrim.test.ts
Normal file
77
src/components/common/creativeAudioSilenceTrim.test.ts
Normal 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();
|
||||
});
|
||||
190
src/components/common/creativeAudioSilenceTrim.ts
Normal file
190
src/components/common/creativeAudioSilenceTrim.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
20
src/components/platform-entry/puzzleDraftGenerationState.ts
Normal file
20
src/components/platform-entry/puzzleDraftGenerationState.ts
Normal file
@@ -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: '星港拼消消',
|
||||
}),
|
||||
);
|
||||
});
|
||||
326
src/components/puzzle-clear-creation/PuzzleClearWorkspace.tsx
Normal file
326
src/components/puzzle-clear-creation/PuzzleClearWorkspace.tsx
Normal file
@@ -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;
|
||||
@@ -0,0 +1,184 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act } from 'react';
|
||||
import type { ImgHTMLAttributes } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
PuzzleClearCardAsset,
|
||||
PuzzleClearPatternGroup,
|
||||
PuzzleClearWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/puzzleClear';
|
||||
import { PuzzleClearResultView } from './PuzzleClearResultView';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
[key: string]: unknown;
|
||||
}) =>
|
||||
src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
{...(rest as ImgHTMLAttributes<HTMLImageElement>)}
|
||||
/>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
function createPatternGroup(index: number): PuzzleClearPatternGroup {
|
||||
return {
|
||||
groupId: `group-${index}`,
|
||||
shape: '1x2',
|
||||
width: 2,
|
||||
height: 1,
|
||||
atlasX: index * 64,
|
||||
atlasY: 0,
|
||||
atlasWidth: 128,
|
||||
atlasHeight: 64,
|
||||
};
|
||||
}
|
||||
|
||||
function createCard(index: number): PuzzleClearCardAsset {
|
||||
return {
|
||||
cardId: `card-${index}`,
|
||||
groupId: `group-${Math.floor(index / 2)}`,
|
||||
shape: '1x2',
|
||||
orientation: 'horizontal',
|
||||
partX: index % 2,
|
||||
partY: 0,
|
||||
imageSrc: `/cards/card-${index}.png`,
|
||||
imageObjectKey: `generated-puzzle-clear-assets/card-${index}.png`,
|
||||
assetObjectId: `assetobj_card_${index}`,
|
||||
sourceAtlasCell: `${index}:0:0`,
|
||||
};
|
||||
}
|
||||
|
||||
function createProfile(
|
||||
overrides: Partial<PuzzleClearWorkProfileResponse['summary']> = {},
|
||||
): PuzzleClearWorkProfileResponse {
|
||||
const atlasAsset = {
|
||||
assetId: 'atlas-1',
|
||||
imageSrc: '/atlas.png',
|
||||
imageObjectKey: 'generated-puzzle-clear-assets/atlas.png',
|
||||
assetObjectId: 'assetobj_atlas',
|
||||
generationProvider: 'gpt-image-2',
|
||||
prompt: '星港',
|
||||
width: 2560,
|
||||
height: 2560,
|
||||
};
|
||||
const boardBackgroundAsset = {
|
||||
...atlasAsset,
|
||||
assetId: 'board-background-1',
|
||||
imageSrc: '/board-background.png',
|
||||
imageObjectKey: 'generated-puzzle-clear-assets/board-background.png',
|
||||
assetObjectId: 'assetobj_board_background',
|
||||
};
|
||||
const patternGroups = Array.from({ length: 35 }, (_, index) =>
|
||||
createPatternGroup(index),
|
||||
);
|
||||
const cardAssets = Array.from({ length: 95 }, (_, index) => createCard(index));
|
||||
const draft = {
|
||||
templateId: 'puzzle-clear',
|
||||
templateName: '拼消消',
|
||||
profileId: 'puzzle-clear-profile-12345678',
|
||||
workTitle: '星港拼消消',
|
||||
workDescription: '霓虹星港主题',
|
||||
themePrompt: '星港',
|
||||
boardBackgroundPrompt: '星港中央棋盘底图',
|
||||
generateBoardBackground: true,
|
||||
boardBackgroundAsset,
|
||||
cardBackImageSrc: '/creation-type-references/puzzle-clear-card-back.webp',
|
||||
atlasAsset,
|
||||
patternGroups,
|
||||
cardAssets,
|
||||
generationStatus: 'ready' as const,
|
||||
};
|
||||
|
||||
return {
|
||||
summary: {
|
||||
runtimeKind: 'puzzle-clear',
|
||||
workId: 'puzzle-clear-work-12345678',
|
||||
profileId: 'puzzle-clear-profile-12345678',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-clear-session-1',
|
||||
workTitle: '星港拼消消',
|
||||
workDescription: '霓虹星港主题',
|
||||
themePrompt: '星港',
|
||||
coverImageSrc: '/atlas.png',
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-30T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
},
|
||||
draft,
|
||||
boardBackgroundAsset,
|
||||
atlasAsset,
|
||||
patternGroups,
|
||||
cardAssets,
|
||||
};
|
||||
}
|
||||
|
||||
test('结果页展示 atlas、中央底图与卡牌预览,并触发试玩、发布和图集重试', async () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const onPublish = vi.fn();
|
||||
const onRegenerateAtlas = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<PuzzleClearResultView
|
||||
profile={createProfile()}
|
||||
onBack={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onStartTestRun={onStartTestRun}
|
||||
onPublish={onPublish}
|
||||
onRegenerateAtlas={onRegenerateAtlas}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByAltText('场地底图').getAttribute('src')).toBe(
|
||||
'/board-background.png',
|
||||
);
|
||||
expect(screen.getByAltText('素材图集').getAttribute('src')).toBe('/atlas.png');
|
||||
expect(screen.getByText('35')).not.toBeNull();
|
||||
expect(screen.getByText('95')).not.toBeNull();
|
||||
expect(container.querySelectorAll('img[src^="/cards/"]')).toHaveLength(24);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /试玩/u }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /图集/u }));
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledTimes(1);
|
||||
expect(onPublish).toHaveBeenCalledTimes(1);
|
||||
expect(onRegenerateAtlas).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('结果页在素材未发布就绪时禁用发布,且不写入规则说明文案', () => {
|
||||
render(
|
||||
<PuzzleClearResultView
|
||||
profile={createProfile({ publishReady: false })}
|
||||
onBack={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onStartTestRun={vi.fn()}
|
||||
onPublish={vi.fn()}
|
||||
onRegenerateAtlas={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(screen.getByRole('button', { name: /发布/u }) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
expect(screen.queryByText(/规则|玩法说明|拖动卡片|拼接完整/u)).toBeNull();
|
||||
});
|
||||
223
src/components/puzzle-clear-result/PuzzleClearResultView.tsx
Normal file
223
src/components/puzzle-clear-result/PuzzleClearResultView.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { ArrowLeft, Loader2, Play, RefreshCcw, Send } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type {
|
||||
PuzzleClearDraftResponse,
|
||||
PuzzleClearWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/puzzleClear';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleClearResultViewProps = {
|
||||
profile: PuzzleClearDraftResponse | PuzzleClearWorkProfileResponse;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onEdit: () => void;
|
||||
onStartTestRun: () => void;
|
||||
onPublish: () => void;
|
||||
onRegenerateAtlas: () => void;
|
||||
};
|
||||
|
||||
function isPuzzleClearWorkProfile(
|
||||
profile: PuzzleClearResultViewProps['profile'],
|
||||
): profile is PuzzleClearWorkProfileResponse {
|
||||
return 'summary' in profile;
|
||||
}
|
||||
|
||||
function getDraft(profile: PuzzleClearResultViewProps['profile']) {
|
||||
return isPuzzleClearWorkProfile(profile) ? profile.draft : profile;
|
||||
}
|
||||
|
||||
export function PuzzleClearResultView({
|
||||
profile,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onEdit,
|
||||
onStartTestRun,
|
||||
onPublish,
|
||||
onRegenerateAtlas,
|
||||
}: PuzzleClearResultViewProps) {
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const isWorkProfile = isPuzzleClearWorkProfile(profile);
|
||||
const draft = getDraft(profile);
|
||||
const summary = isWorkProfile ? profile.summary : null;
|
||||
const title = summary?.workTitle?.trim() || draft.workTitle.trim() || '拼消消';
|
||||
const description =
|
||||
summary?.workDescription?.trim() || draft.workDescription.trim();
|
||||
const boardBackgroundAsset = isWorkProfile
|
||||
? profile.boardBackgroundAsset ?? draft.boardBackgroundAsset
|
||||
: draft.boardBackgroundAsset;
|
||||
const atlasAsset = isWorkProfile ? profile.atlasAsset : draft.atlasAsset;
|
||||
const patternGroups = isWorkProfile ? profile.patternGroups : draft.patternGroups;
|
||||
const cardAssets = isWorkProfile ? profile.cardAssets : draft.cardAssets;
|
||||
const previewCards = cardAssets.slice(0, 24);
|
||||
const canPublish = Boolean(isWorkProfile && summary?.publishReady);
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
await Promise.resolve(onPublish());
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl 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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerateAtlas}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
图集
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1.05fr)_minmax(19rem,0.95fr)]">
|
||||
<section className="platform-subpanel flex min-h-0 flex-col rounded-[1.25rem] p-4">
|
||||
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
{description ? (
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 grid min-h-0 flex-1 gap-3 sm:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{boardBackgroundAsset?.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={boardBackgroundAsset.imageSrc}
|
||||
alt="场地底图"
|
||||
className="aspect-[9/16] h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid aspect-[9/16] place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
底图
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{atlasAsset?.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={atlasAsset.imageSrc}
|
||||
alt="素材图集"
|
||||
className="aspect-square w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid aspect-square place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
图集
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{previewCards.map((card) => (
|
||||
<div
|
||||
key={card.cardId}
|
||||
className="aspect-square overflow-hidden rounded-[0.45rem] border border-white/80 bg-white shadow-sm"
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={card.imageSrc}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="flex min-h-0 flex-col gap-3 overflow-y-auto">
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-[0.9rem] bg-white/76 px-2 py-3">
|
||||
<div className="text-xl font-black text-[var(--platform-text-strong)]">
|
||||
{patternGroups.length}
|
||||
</div>
|
||||
<div className="mt-1 text-[0.68rem] font-bold tracking-[0.14em] text-[var(--platform-text-soft)]">
|
||||
图案组
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[0.9rem] bg-white/76 px-2 py-3">
|
||||
<div className="text-xl font-black text-[var(--platform-text-strong)]">
|
||||
{cardAssets.length}
|
||||
</div>
|
||||
<div className="mt-1 text-[0.68rem] font-bold tracking-[0.14em] text-[var(--platform-text-soft)]">
|
||||
卡片
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[0.9rem] bg-white/76 px-2 py-3">
|
||||
<div className="text-xl font-black text-[var(--platform-text-strong)]">
|
||||
{draft.generationStatus}
|
||||
</div>
|
||||
<div className="mt-1 text-[0.68rem] font-bold tracking-[0.14em] text-[var(--platform-text-soft)]">
|
||||
状态
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="platform-subpanel mt-auto rounded-[1.25rem] p-4">
|
||||
<div className="grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartTestRun}
|
||||
disabled={isBusy || !isWorkProfile}
|
||||
className="platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
试玩
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePublish}
|
||||
disabled={isBusy || isPublishing || !canPublish}
|
||||
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 px-4 py-3"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
发布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-11 justify-center px-4 py-3"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PuzzleClearResultView;
|
||||
1230
src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx
Normal file
1230
src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1476
src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx
Normal file
1476
src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ import type {
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopWorkDetailResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
JumpHopWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
BabyObjectMatchDraft,
|
||||
@@ -41,6 +42,7 @@ import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
@@ -51,6 +53,7 @@ import { ApiClientError } from '../../services/apiClient';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
createBarkBattleDraft,
|
||||
deleteBarkBattleWork,
|
||||
generateAllBarkBattleImageAssets,
|
||||
listBarkBattleGallery,
|
||||
listBarkBattleWorks,
|
||||
@@ -611,6 +614,7 @@ vi.mock('../../services/puzzle-runtime', () => ({
|
||||
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
|
||||
jumpHopClient: {
|
||||
createSession: vi.fn(),
|
||||
deleteWork: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
getGalleryDetail: vi.fn(),
|
||||
getLeaderboard: vi.fn(),
|
||||
@@ -652,6 +656,7 @@ vi.mock('../../services/big-fish-runtime', () => ({
|
||||
|
||||
vi.mock('../../services/bark-battle-creation', () => ({
|
||||
createBarkBattleDraft: vi.fn(),
|
||||
deleteBarkBattleWork: vi.fn(),
|
||||
generateAllBarkBattleImageAssets: vi.fn(),
|
||||
listBarkBattleGallery: vi.fn(),
|
||||
listBarkBattleWorks: vi.fn(),
|
||||
@@ -675,6 +680,7 @@ vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
|
||||
woodenFishClient: {
|
||||
checkpointRun: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
deleteWork: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
finishRun: vi.fn(),
|
||||
getGalleryDetail: vi.fn(),
|
||||
@@ -2720,6 +2726,7 @@ beforeEach(() => {
|
||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
||||
vi.mocked(deleteBarkBattleWork).mockResolvedValue({ items: [] });
|
||||
vi.mocked(listVisualNovelGallery).mockResolvedValue({ works: [] });
|
||||
vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] });
|
||||
vi.mocked(woodenFishClient.listGallery).mockResolvedValue({
|
||||
@@ -2728,6 +2735,7 @@ beforeEach(() => {
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({ items: [] });
|
||||
vi.mocked(woodenFishClient.deleteWork).mockResolvedValue({ items: [] });
|
||||
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([]);
|
||||
vi.mocked(deleteLocalBabyObjectMatchDraft).mockResolvedValue([]);
|
||||
vi.mocked(jumpHopClient.listGallery).mockResolvedValue({
|
||||
@@ -2736,6 +2744,7 @@ beforeEach(() => {
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(jumpHopClient.listWorks).mockResolvedValue({ items: [] });
|
||||
vi.mocked(jumpHopClient.deleteWork).mockResolvedValue({ items: [] });
|
||||
vi.mocked(jumpHopClient.getSession).mockRejectedValue(
|
||||
new Error('未找到跳一跳会话'),
|
||||
);
|
||||
@@ -2748,6 +2757,7 @@ beforeEach(() => {
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({ items: [] });
|
||||
vi.mocked(woodenFishClient.deleteWork).mockResolvedValue({ items: [] });
|
||||
vi.mocked(woodenFishClient.getSession).mockRejectedValue(
|
||||
new Error('未找到敲木鱼会话'),
|
||||
);
|
||||
@@ -4223,6 +4233,115 @@ test('background match3d draft failure notifies and reopens failed retry page',
|
||||
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('failed match3d draft retry reuses current session instead of creating another draft', async () => {
|
||||
const user = userEvent.setup();
|
||||
const failedSession = buildMockMatch3DAgentSession({
|
||||
sessionId: 'match3d-retry-failed-session',
|
||||
draft: null,
|
||||
stage: 'collecting_config',
|
||||
updatedAt: '2026-05-18T12:05:00.000Z',
|
||||
});
|
||||
const persistedFailedWork: Match3DWorkSummary = {
|
||||
workId: 'match3d-retry-failed-work',
|
||||
profileId: 'match3d-retry-failed-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: failedSession.sessionId,
|
||||
gameName: '重试抓鹅',
|
||||
themeText: '霓虹水果摊',
|
||||
summary: '抓大鹅素材生成失败,可重新打开处理。',
|
||||
tags: ['水果', '抓大鹅'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-18T12:05:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
generatedItemAssets: [],
|
||||
};
|
||||
let rejectCompile!: (reason?: unknown) => void;
|
||||
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
|
||||
session: failedSession,
|
||||
});
|
||||
vi.mocked(match3dCreationClient.executeAction).mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectCompile = reject;
|
||||
}),
|
||||
);
|
||||
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
|
||||
session: failedSession,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await findCreationTypeButton('抓大鹅'));
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
|
||||
);
|
||||
await screen.findByRole('progressbar', { name: '抓大鹅草稿生成进度' });
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
await openDraftHub(user);
|
||||
vi.mocked(listMatch3DWorks).mockResolvedValue({
|
||||
items: [persistedFailedWork],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rejectCompile(new Error('抓大鹅素材服务失败'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
const failureDialog = await screen.findByRole('dialog', {
|
||||
name: '发生错误',
|
||||
});
|
||||
await user.click(within(failureDialog).getByRole('button', { name: '关闭' }));
|
||||
|
||||
const draftPanel = getPlatformTabPanel('saves');
|
||||
await user.click(
|
||||
await within(draftPanel).findByRole('button', {
|
||||
name: /继续创作《(?:重试抓鹅|抓大鹅草稿)》/u,
|
||||
}),
|
||||
);
|
||||
const reopenedFailureDialog = await screen.findByRole('dialog', {
|
||||
name: '发生错误',
|
||||
});
|
||||
await user.click(
|
||||
within(reopenedFailureDialog).getByRole('button', { name: '关闭' }),
|
||||
);
|
||||
vi.mocked(match3dCreationClient.executeAction).mockResolvedValueOnce({
|
||||
session: buildMockMatch3DAgentSession({
|
||||
sessionId: failedSession.sessionId,
|
||||
stage: 'draft_ready',
|
||||
draft: {
|
||||
profileId: persistedFailedWork.profileId,
|
||||
gameName: persistedFailedWork.gameName,
|
||||
themeText: persistedFailedWork.themeText,
|
||||
summary: persistedFailedWork.summary,
|
||||
tags: persistedFailedWork.tags,
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: persistedFailedWork.clearCount,
|
||||
difficulty: persistedFailedWork.difficulty,
|
||||
generatedItemAssets: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '重新生成草稿' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(1);
|
||||
expect(match3dCreationClient.executeAction).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
failedSession.sessionId,
|
||||
expect.objectContaining({ action: 'match3d_compile_draft' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('running match3d persisted draft reopens progress instead of unfinished result', async () => {
|
||||
const user = userEvent.setup();
|
||||
const runningSession = buildMockMatch3DAgentSession({
|
||||
@@ -4916,6 +5035,113 @@ test('failed parallel puzzle generations stay as separate non-generating drafts'
|
||||
.toBeTruthy();
|
||||
});
|
||||
|
||||
test('failed puzzle draft retry reuses current session instead of creating another draft', async () => {
|
||||
const user = userEvent.setup();
|
||||
const failedSession = buildMockPuzzleAgentSession({
|
||||
sessionId: 'puzzle-retry-failed-session',
|
||||
draft: null,
|
||||
stage: 'collecting_anchors',
|
||||
updatedAt: '2026-05-18T12:00:00.000Z',
|
||||
});
|
||||
const persistedFailedWork: PuzzleWorkSummary = {
|
||||
workId: `puzzle-work-${failedSession.sessionId}`,
|
||||
profileId: `puzzle-profile-${failedSession.sessionId}`,
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: failedSession.sessionId,
|
||||
authorDisplayName: '测试玩家',
|
||||
workTitle: '',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
levelName: '第1关',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-05-18T12:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
generationStatus: 'failed',
|
||||
levels: [],
|
||||
};
|
||||
let rejectCompile!: (reason?: unknown) => void;
|
||||
vi.mocked(createPuzzleAgentSession).mockResolvedValue({
|
||||
session: failedSession,
|
||||
});
|
||||
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
|
||||
session: failedSession,
|
||||
});
|
||||
vi.mocked(executePuzzleAgentAction).mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectCompile = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||
await screen.findByRole('progressbar', { name: '拼图图片生成进度' });
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
await openDraftHub(user);
|
||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||
items: [persistedFailedWork],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rejectCompile(new Error('拼图图片生成失败'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
const failureDialog = await screen.findByRole('dialog', {
|
||||
name: '发生错误',
|
||||
});
|
||||
await user.click(within(failureDialog).getByRole('button', { name: '关闭' }));
|
||||
|
||||
const draftPanel = getPlatformTabPanel('saves');
|
||||
await user.click(
|
||||
await within(draftPanel).findByRole('button', {
|
||||
name: /继续创作《[^》]+》/u,
|
||||
}),
|
||||
);
|
||||
const reopenedFailureDialog = await screen.findByRole('dialog', {
|
||||
name: '发生错误',
|
||||
});
|
||||
await user.click(
|
||||
within(reopenedFailureDialog).getByRole('button', { name: '关闭' }),
|
||||
);
|
||||
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
|
||||
operation: {
|
||||
operationId: 'compile-puzzle-retry',
|
||||
type: 'compile_puzzle_draft',
|
||||
status: 'completed',
|
||||
phaseLabel: '已完成',
|
||||
phaseDetail: '草稿已生成',
|
||||
progress: 1,
|
||||
},
|
||||
session: buildMockPuzzleAgentSession({
|
||||
sessionId: failedSession.sessionId,
|
||||
stage: 'ready_to_publish',
|
||||
progressPercent: 100,
|
||||
draft: buildReadyPuzzleDraft(),
|
||||
}),
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '重新生成图片' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
|
||||
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
failedSession.sessionId,
|
||||
expect.objectContaining({ action: 'compile_puzzle_draft' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('running puzzle draft opens generation progress from draft tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
const runningSession = buildMockPuzzleAgentSession({
|
||||
@@ -9412,10 +9638,10 @@ test('starting draft generation leaves the agent workspace and shows the generat
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
expect(screen.getByText('当前世界信息')).toBeTruthy();
|
||||
expect(screen.queryByText('当前世界信息')).toBeNull();
|
||||
expect(screen.queryByText('回到工作区')).toBeNull();
|
||||
expect(screen.getByText('世界承诺')).toBeTruthy();
|
||||
expect(screen.getByText(/灯塔与禁航令共同决定谁能穿过死潮/u)).toBeTruthy();
|
||||
expect(screen.queryByText('世界承诺')).toBeNull();
|
||||
expect(screen.queryByText(/灯塔与禁航令共同决定谁能穿过死潮/u)).toBeNull();
|
||||
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -11641,3 +11867,145 @@ test('creation hub published work card reveals delete action after card action r
|
||||
expect(within(dialog).getByRole('button', { name: '确认删除' })).toBeTruthy();
|
||||
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('creation hub gives jump hop wooden fish and bark battle cards the shared delete interaction', async () => {
|
||||
const user = userEvent.setup();
|
||||
const jumpHopWork = {
|
||||
...buildMockJumpHopWork({
|
||||
summary: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-delete',
|
||||
profileId: 'jump-hop-profile-delete',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'jump-hop-session-delete',
|
||||
workTitle: '跳台删除草稿',
|
||||
workDescription: '跳一跳草稿也应接入统一删除。',
|
||||
themeTags: ['跳台'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-21T10:20:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
}).summary,
|
||||
} satisfies JumpHopWorkSummaryResponse;
|
||||
const woodenFishWork = {
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: 'wooden-fish-work-delete',
|
||||
profileId: 'wooden-fish-profile-delete',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'wooden-fish-session-delete',
|
||||
workTitle: '木鱼删除草稿',
|
||||
workDescription: '敲木鱼草稿也应接入统一删除。',
|
||||
themeTags: ['木鱼'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-21T10:10:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
} satisfies WoodenFishWorkSummaryResponse;
|
||||
const barkBattleWork = buildMockBarkBattleWork({
|
||||
workId: 'bark-battle-work-delete',
|
||||
draftId: 'bark-battle-draft-delete',
|
||||
title: '声浪删除已发布',
|
||||
summary: '汪汪声浪已发布作品也应接入统一删除。',
|
||||
updatedAt: '2026-05-21T10:00:00.000Z',
|
||||
publishedAt: '2026-05-21T10:00:00.000Z',
|
||||
});
|
||||
|
||||
vi.mocked(fetchCreationEntryConfig).mockResolvedValueOnce({
|
||||
...testCreationEntryConfig,
|
||||
creationTypes: [
|
||||
...testCreationEntryConfig.creationTypes,
|
||||
{
|
||||
id: 'jump-hop',
|
||||
title: '跳一跳',
|
||||
subtitle: '俯视角跳台挑战',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/jump-hop.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 46,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish',
|
||||
title: '敲木鱼',
|
||||
subtitle: '功德敲击小游戏',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/wooden-fish.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 47,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(jumpHopClient.listWorks).mockResolvedValue({
|
||||
items: [jumpHopWork],
|
||||
});
|
||||
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({
|
||||
items: [woodenFishWork],
|
||||
});
|
||||
vi.mocked(listBarkBattleWorks).mockResolvedValue({
|
||||
items: [barkBattleWork],
|
||||
});
|
||||
vi.mocked(jumpHopClient.deleteWork).mockResolvedValueOnce({ items: [] });
|
||||
vi.mocked(woodenFishClient.deleteWork).mockResolvedValueOnce({ items: [] });
|
||||
vi.mocked(deleteBarkBattleWork).mockResolvedValueOnce({ items: [] });
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openDraftHub(user);
|
||||
|
||||
async function revealAndConfirmDelete(
|
||||
cardName: RegExp,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
const card = await screen.findByRole('button', { name: cardName });
|
||||
card.focus();
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
const shell = card.closest('.creation-work-card-shell');
|
||||
if (!shell) {
|
||||
throw new Error('作品卡应位于统一操作壳内');
|
||||
}
|
||||
await user.click(within(shell as HTMLElement).getByRole('button', { name: '删除' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '删除作品' });
|
||||
expect(within(dialog).getByText(`确认删除《${title}》吗?`)).toBeTruthy();
|
||||
await user.click(within(dialog).getByRole('button', { name: '确认删除' }));
|
||||
}
|
||||
|
||||
await revealAndConfirmDelete(/继续创作《跳台删除草稿》/u, '跳台删除草稿');
|
||||
await waitFor(() => {
|
||||
expect(jumpHopClient.deleteWork).toHaveBeenCalledWith(
|
||||
'jump-hop-profile-delete',
|
||||
);
|
||||
});
|
||||
|
||||
await revealAndConfirmDelete(/继续创作《木鱼删除草稿》/u, '木鱼删除草稿');
|
||||
await waitFor(() => {
|
||||
expect(woodenFishClient.deleteWork).toHaveBeenCalledWith(
|
||||
'wooden-fish-profile-delete',
|
||||
);
|
||||
});
|
||||
|
||||
await revealAndConfirmDelete(/查看详情《声浪删除已发布》/u, '声浪删除已发布');
|
||||
await waitFor(() => {
|
||||
expect(deleteBarkBattleWork).toHaveBeenCalledWith(
|
||||
'bark-battle-work-delete',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,8 +32,10 @@ import {
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
type PlatformEdutainmentGalleryCard,
|
||||
type PlatformJumpHopGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformPuzzleGalleryCard,
|
||||
type PlatformWoodenFishGalleryCard,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
const {
|
||||
@@ -321,6 +323,7 @@ const {
|
||||
const {
|
||||
mockGetPublicAuthUserByCode,
|
||||
mockGetPublicAuthUserById,
|
||||
mockRefreshStoredAccessToken,
|
||||
mockUpdateAuthProfile,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetPublicAuthUserByCode: vi.fn(
|
||||
@@ -341,9 +344,14 @@ const {
|
||||
avatarUrl: null,
|
||||
}),
|
||||
),
|
||||
mockRefreshStoredAccessToken: vi.fn(async () => 'jwt-refreshed-token'),
|
||||
mockUpdateAuthProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
refreshStoredAccessToken: mockRefreshStoredAccessToken,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
getPublicAuthUserByCode: mockGetPublicAuthUserByCode,
|
||||
getPublicAuthUserById: mockGetPublicAuthUserById,
|
||||
@@ -413,11 +421,6 @@ const originalUserAgent = navigator.userAgent;
|
||||
const originalMaxTouchPoints = navigator.maxTouchPoints;
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
const DEFAULT_PROFILE_CREATED_AT = '2026-04-01T00:00:00.000Z';
|
||||
|
||||
function buildFreshProfileCreatedAt() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function dispatchPointerEvent(
|
||||
target: HTMLElement,
|
||||
@@ -481,6 +484,53 @@ const puzzlePublicEntry = {
|
||||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const jumpHopPublicEntry = {
|
||||
sourceType: 'jump-hop',
|
||||
workId: 'jump-hop-work-public-1',
|
||||
profileId: 'jump-hop-profile-public-1',
|
||||
sourceSessionId: 'jump-hop-session-public-1',
|
||||
publicWorkCode: 'JH-EPUBLIC1',
|
||||
ownerUserId: 'jump-hop-user-1',
|
||||
authorDisplayName: '跳台作者',
|
||||
worldName: '星桥跳台',
|
||||
subtitle: '标准路线',
|
||||
summaryText: '一条用于公开分享的跳一跳路线。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['跳一跳'],
|
||||
playCount: 8,
|
||||
remixCount: 1,
|
||||
likeCount: 3,
|
||||
recentPlayCount7d: 2,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-20T10:00:00.000Z',
|
||||
updatedAt: '2026-05-20T10:00:00.000Z',
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'storybook',
|
||||
} satisfies PlatformJumpHopGalleryCard;
|
||||
|
||||
const woodenFishPublicEntry = {
|
||||
sourceType: 'wooden-fish',
|
||||
workId: 'wooden-fish-work-public-1',
|
||||
profileId: 'wooden-fish-profile-public-1',
|
||||
sourceSessionId: 'wooden-fish-session-public-1',
|
||||
publicWorkCode: 'WF-EPUBLIC1',
|
||||
ownerUserId: 'wooden-fish-user-1',
|
||||
authorUsername: null,
|
||||
authorDisplayName: '木鱼作者',
|
||||
worldName: '莲台木鱼',
|
||||
subtitle: '敲木鱼',
|
||||
summaryText: '一件用于公开分享的敲木鱼作品。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['敲木鱼'],
|
||||
playCount: 9,
|
||||
remixCount: 2,
|
||||
likeCount: 4,
|
||||
recentPlayCount7d: 3,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-21T10:00:00.000Z',
|
||||
updatedAt: '2026-05-21T10:00:00.000Z',
|
||||
} satisfies PlatformWoodenFishGalleryCard;
|
||||
|
||||
const remixRankEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-remix-rank',
|
||||
@@ -1083,6 +1133,7 @@ afterEach(() => {
|
||||
mockBuildReferralCenter(),
|
||||
);
|
||||
mockGetRpgProfileTasks.mockResolvedValue(mockBuildTaskCenter());
|
||||
mockRefreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token');
|
||||
mockClaimRpgProfileTaskReward.mockResolvedValue({
|
||||
taskId: 'daily_login',
|
||||
dayKey: 20260503,
|
||||
@@ -2449,7 +2500,7 @@ test('profile daily task shortcut reflects task progress and claim updates', asy
|
||||
await waitFor(() => {
|
||||
expect(within(dailyTask).getByText('1 / 1')).toBeTruthy();
|
||||
});
|
||||
expect(within(dailyTask).getByText('领取')).toBeTruthy();
|
||||
expect(dailyTask.querySelector('.platform-profile-daily-task-card__action')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /每日任务/u }));
|
||||
|
||||
@@ -2467,7 +2518,80 @@ test('profile daily task shortcut reflects task progress and claim updates', asy
|
||||
expect(await screen.findByText('已领取 10 泥点')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '已领取' })).toBeNull();
|
||||
expect(screen.getByText('暂无任务')).toBeTruthy();
|
||||
expect(within(dailyTask).getByText('已完成')).toBeTruthy();
|
||||
expect(within(dailyTask).queryByText('已完成')).toBeNull();
|
||||
});
|
||||
|
||||
test('profile daily task refreshes at Beijing midnight reset', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-03T15:59:58.000Z'));
|
||||
|
||||
mockGetRpgProfileTasks
|
||||
.mockResolvedValueOnce(
|
||||
mockBuildTaskCenter({
|
||||
walletBalance: 10,
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'daily_login',
|
||||
title: '每日登录',
|
||||
description: '',
|
||||
eventKey: 'profile.login.daily',
|
||||
cycle: 'daily',
|
||||
threshold: 1,
|
||||
progressCount: 1,
|
||||
rewardPoints: 10,
|
||||
status: 'claimed',
|
||||
dayKey: 20260503,
|
||||
claimedAt: '2026-05-03T15:59:00Z',
|
||||
updatedAt: '2026-05-03T15:59:00Z',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-05-03T15:59:00Z',
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
mockBuildTaskCenter({
|
||||
walletBalance: 10,
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'daily_login',
|
||||
title: '每日登录',
|
||||
description: '',
|
||||
eventKey: 'profile.login.daily',
|
||||
cycle: 'daily',
|
||||
threshold: 1,
|
||||
progressCount: 1,
|
||||
rewardPoints: 10,
|
||||
status: 'claimable',
|
||||
dayKey: 20260504,
|
||||
claimedAt: null,
|
||||
updatedAt: '2026-05-03T16:00:00Z',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-05-03T16:00:00Z',
|
||||
}),
|
||||
);
|
||||
|
||||
renderProfileView();
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockRefreshStoredAccessToken).toHaveBeenCalledWith({
|
||||
clearOnFailure: false,
|
||||
});
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /每日任务/u }));
|
||||
|
||||
expect(screen.getByRole('button', { name: '领取' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile task center keeps only the highest priority actionable task', async () => {
|
||||
@@ -2536,7 +2660,7 @@ test('profile total play time card always uses hours', async () => {
|
||||
});
|
||||
|
||||
const playTimeCard = screen.getByRole('button', {
|
||||
name: /游戏时长/u,
|
||||
name: /累计游玩/u,
|
||||
});
|
||||
|
||||
expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy();
|
||||
@@ -2550,10 +2674,11 @@ test('profile played works card shows count unit', async () => {
|
||||
});
|
||||
|
||||
const playedCard = screen.getByRole('button', {
|
||||
name: /已玩游戏数量\s*1个/u,
|
||||
name: /已玩游戏\s*1个/u,
|
||||
});
|
||||
|
||||
expect(within(playedCard).getByText('1个')).toBeTruthy();
|
||||
expect(within(playedCard).queryByText('已玩游戏数量')).toBeNull();
|
||||
await screen.findByText('1 / 1');
|
||||
});
|
||||
|
||||
@@ -2565,8 +2690,8 @@ test('profile stats cards are centered without update timestamp', async () => {
|
||||
const walletCard = screen.getByRole('button', {
|
||||
name: /泥点余额\s*0/u,
|
||||
});
|
||||
const playTimeCard = screen.getByRole('button', { name: /游戏时长|累计游戏时长/u });
|
||||
const playedCard = screen.getByRole('button', { name: /已玩游戏数量\s*0个/u });
|
||||
const playTimeCard = screen.getByRole('button', { name: /累计游玩\s*0小时/u });
|
||||
const playedCard = screen.getByRole('button', { name: /已玩游戏\s*0个/u });
|
||||
|
||||
for (const card of [walletCard, playTimeCard, playedCard]) {
|
||||
expect(card.className).toContain('platform-profile-stat-card');
|
||||
@@ -2618,8 +2743,8 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
expect(statPanel.className).toContain('platform-profile-stats-panel');
|
||||
expect(statPanel.querySelector('.platform-profile-stats-grid')).toBeTruthy();
|
||||
expect(within(statPanel).getByRole('button', { name: /泥点余额\s*70/u })).toBeTruthy();
|
||||
expect(within(statPanel).getByRole('button', { name: /累计游戏时长\s*0小时/u })).toBeTruthy();
|
||||
expect(within(statPanel).getByRole('button', { name: /已玩游戏数量\s*0个/u })).toBeTruthy();
|
||||
expect(within(statPanel).getByRole('button', { name: /累计游玩\s*0小时/u })).toBeTruthy();
|
||||
expect(within(statPanel).getByRole('button', { name: /已玩游戏\s*0个/u })).toBeTruthy();
|
||||
expect(
|
||||
within(statPanel).getByRole('button', { name: /泥点余额\s*70/u }).className,
|
||||
).toContain('platform-profile-stat-card');
|
||||
@@ -2630,6 +2755,8 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
expect(dailyTask.querySelector('.platform-profile-daily-task-card__title')).toBeTruthy();
|
||||
expect(dailyTask.querySelector('.platform-profile-daily-task-card__desc')).toBeTruthy();
|
||||
expect(dailyTask.querySelector('.platform-profile-daily-task-card__progress')).toBeTruthy();
|
||||
expect(dailyTask.querySelector('.platform-profile-daily-task-card__action')).toBeNull();
|
||||
expect(dailyTask.textContent).not.toContain('去完成');
|
||||
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
|
||||
expect(await within(dailyTask).findByText('1 / 1')).toBeTruthy();
|
||||
|
||||
@@ -2670,13 +2797,22 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
within(shortcutRegion).getByRole('button', { name: new RegExp(label, 'u') }),
|
||||
).toBeTruthy();
|
||||
}
|
||||
expect(
|
||||
within(
|
||||
within(shortcutRegion).getByRole('button', { name: /反馈与建议/u }),
|
||||
).getByText('帮我们优化产品'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(
|
||||
within(shortcutRegion).getByRole('button', { name: /反馈与建议/u }),
|
||||
).queryByText('帮助我们做得更好'),
|
||||
).toBeNull();
|
||||
|
||||
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
|
||||
for (const label of ['主题设置', '账号与安全', '通用设置']) {
|
||||
expect(
|
||||
within(settingsRegion).getByRole('button', { name: new RegExp(label, 'u') }),
|
||||
).toBeTruthy();
|
||||
}
|
||||
expect(within(settingsRegion).getByRole('button', { name: /通用设置/u })).toBeTruthy();
|
||||
expect(within(settingsRegion).queryByRole('button', { name: /主题设置/u })).toBeNull();
|
||||
expect(within(settingsRegion).queryByRole('button', { name: /账号与安全/u })).toBeNull();
|
||||
expect(settingsRegion.querySelectorAll('.platform-profile-settings-row')).toHaveLength(1);
|
||||
expect(
|
||||
within(settingsRegion).queryByRole('button', { name: /存档/u }),
|
||||
).toBeNull();
|
||||
@@ -2807,7 +2943,8 @@ test('profile community shortcut shows reward subtitle and invited users', async
|
||||
expect(screen.queryByRole('button', { name: /邀请好友/u })).toBeNull();
|
||||
|
||||
const communityButton = screen.getByRole('button', { name: /玩家社区/u });
|
||||
expect(within(communityButton).getByText('交流心得 领取福利')).toBeTruthy();
|
||||
expect(within(communityButton).getByText('交流心得')).toBeTruthy();
|
||||
expect(within(communityButton).queryByText('交流心得 领取福利')).toBeNull();
|
||||
|
||||
await user.click(communityButton);
|
||||
|
||||
@@ -2984,8 +3121,12 @@ test('profile page shows legal entries and hides archive shortcuts', async () =>
|
||||
const dailyTask = screen.getByRole('button', { name: /每日任务/u });
|
||||
expect(dailyTask).toBeTruthy();
|
||||
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
|
||||
expect(dailyTask.querySelector('.platform-profile-daily-task-card__action')).toBeNull();
|
||||
|
||||
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
|
||||
expect(within(settingsRegion).getByRole('button', { name: /通用设置/u })).toBeTruthy();
|
||||
expect(within(settingsRegion).queryByRole('button', { name: /主题设置/u })).toBeNull();
|
||||
expect(within(settingsRegion).queryByRole('button', { name: /账号与安全/u })).toBeNull();
|
||||
expect(
|
||||
within(settingsRegion).queryByRole('button', { name: /存档/u }),
|
||||
).toBeNull();
|
||||
@@ -3592,6 +3733,53 @@ test('logged out recommend page can enter runtime without login gate', () => {
|
||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('mobile recommend meta matches active jump hop runtime entry', () => {
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry, jumpHopPublicEntry],
|
||||
activeRecommendEntryKey: 'jump-hop:jump-hop-user-1:jump-hop-profile-public-1',
|
||||
recommendRuntimeContent: (
|
||||
<div data-testid="recommend-runtime">跳一跳运行内容</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('recommend-runtime').textContent).toContain(
|
||||
'跳一跳运行内容',
|
||||
);
|
||||
const meta = document.querySelector(
|
||||
'.platform-recommend-work-meta[data-active="true"]',
|
||||
) as HTMLElement | null;
|
||||
expect(meta?.getAttribute('aria-label')).toBe('星桥跳台 作品信息');
|
||||
if (!meta) {
|
||||
throw new Error('缺少当前推荐作品信息');
|
||||
}
|
||||
expect(within(meta).getByText('跳台作者')).toBeTruthy();
|
||||
expect(within(meta).getByText('星桥跳台')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('mobile recommend meta matches active wooden fish runtime entry', () => {
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry, woodenFishPublicEntry],
|
||||
activeRecommendEntryKey:
|
||||
'wooden-fish:wooden-fish-user-1:wooden-fish-profile-public-1',
|
||||
recommendRuntimeContent: (
|
||||
<div data-testid="recommend-runtime">敲木鱼运行内容</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('recommend-runtime').textContent).toContain(
|
||||
'敲木鱼运行内容',
|
||||
);
|
||||
const meta = document.querySelector(
|
||||
'.platform-recommend-work-meta[data-active="true"]',
|
||||
) as HTMLElement | null;
|
||||
expect(meta?.getAttribute('aria-label')).toBe('莲台木鱼 作品信息');
|
||||
if (!meta) {
|
||||
throw new Error('缺少当前推荐作品信息');
|
||||
}
|
||||
expect(within(meta).getByText('木鱼作者')).toBeTruthy();
|
||||
expect(within(meta).getByText('莲台木鱼')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('logged out desktop recommend rail enters runtime without login modal', async () => {
|
||||
mockDesktopLayout();
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
Gamepad2,
|
||||
GitFork,
|
||||
Heart,
|
||||
Loader2,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
Loader2,
|
||||
Palette,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
Search,
|
||||
Settings,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Star,
|
||||
@@ -81,6 +80,7 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
||||
import { refreshStoredAccessToken } from '../../services/apiClient';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
getPublicAuthUserByCode,
|
||||
@@ -137,6 +137,7 @@ import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntry
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
|
||||
import {
|
||||
buildPlatformPublicGalleryCardKey,
|
||||
buildPlatformWorldDisplayTags,
|
||||
describePlatformThemeLabel,
|
||||
formatPlatformWorkDisplayName,
|
||||
@@ -147,14 +148,15 @@ import {
|
||||
isEdutainmentGalleryEntry,
|
||||
isJumpHopGalleryEntry,
|
||||
isMatch3DGalleryEntry,
|
||||
isPuzzleClearGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
isSquareHoleGalleryEntry,
|
||||
isVisualNovelGalleryEntry,
|
||||
isWoodenFishGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformWorldCardLike,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
resolvePlatformWorldFallbackCoverImage,
|
||||
@@ -248,6 +250,9 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
|
||||
'saves',
|
||||
'profile',
|
||||
];
|
||||
const PROFILE_TASK_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const PROFILE_TASK_BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
const PROFILE_TASK_MIN_RESET_DELAY_MS = 1000;
|
||||
const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
const AVATAR_OUTPUT_SIZE = 256;
|
||||
const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
@@ -303,15 +308,8 @@ function buildProfileTaskCardSummary(center: ProfileTaskCenterResponse | null) {
|
||||
const progressCount = Math.min(task?.progressCount ?? 0, threshold);
|
||||
const rewardPoints =
|
||||
task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS;
|
||||
const actionLabel =
|
||||
task?.status === 'claimable'
|
||||
? '领取'
|
||||
: task?.status === 'claimed'
|
||||
? '已完成'
|
||||
: '去完成';
|
||||
|
||||
return {
|
||||
actionLabel,
|
||||
progressCount,
|
||||
progressPercent: Math.round((progressCount / threshold) * 100),
|
||||
rewardPoints,
|
||||
@@ -319,6 +317,15 @@ function buildProfileTaskCardSummary(center: ProfileTaskCenterResponse | null) {
|
||||
};
|
||||
}
|
||||
|
||||
function getDelayUntilNextProfileTaskReset(nowMs = Date.now()) {
|
||||
const shiftedNow = nowMs + PROFILE_TASK_BEIJING_OFFSET_MS;
|
||||
const nextDayStart =
|
||||
Math.floor(shiftedNow / PROFILE_TASK_DAY_MS) * PROFILE_TASK_DAY_MS +
|
||||
PROFILE_TASK_DAY_MS;
|
||||
const nextResetAt = nextDayStart - PROFILE_TASK_BEIJING_OFFSET_MS;
|
||||
return Math.max(PROFILE_TASK_MIN_RESET_DELAY_MS, nextResetAt - nowMs);
|
||||
}
|
||||
|
||||
type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
|
||||
type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
|
||||
type BarcodeDetectorLike = {
|
||||
@@ -1912,22 +1919,7 @@ function isExactPublicWorkCodeSearch(
|
||||
}
|
||||
|
||||
function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
|
||||
const kind = isBigFishGalleryEntry(entry)
|
||||
? 'big-fish'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? 'puzzle'
|
||||
: isMatch3DGalleryEntry(entry)
|
||||
? 'match3d'
|
||||
: isSquareHoleGalleryEntry(entry)
|
||||
? 'square-hole'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? 'visual-novel'
|
||||
: isBarkBattleGalleryEntry(entry)
|
||||
? 'bark-battle'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? `edutainment:${entry.templateId}`
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
return buildPlatformPublicGalleryCardKey(entry);
|
||||
}
|
||||
|
||||
function PlatformWorkSearchResults({
|
||||
@@ -2034,6 +2026,9 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('拼图');
|
||||
}
|
||||
if (isPuzzleClearGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('拼消消');
|
||||
}
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('抓大鹅');
|
||||
}
|
||||
@@ -2507,7 +2502,7 @@ function ProfileStatCard({
|
||||
type="button"
|
||||
onClick={onClick ? () => onClick(cardKey) : undefined}
|
||||
aria-label={`${label} ${value}`}
|
||||
className="platform-profile-stat-card flex min-h-[5.75rem] items-center justify-center gap-2 px-3 py-3 text-center transition"
|
||||
className="platform-profile-stat-card flex min-h-[5.25rem] items-center justify-center gap-2 px-2.5 py-2.5 text-center transition"
|
||||
>
|
||||
<div className="platform-profile-stat-card__icon">
|
||||
{imageSrc ? (
|
||||
@@ -2517,10 +2512,10 @@ function ProfileStatCard({
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<div className="platform-profile-stat-card__value whitespace-nowrap text-lg font-black leading-none text-[var(--platform-text-strong)]">
|
||||
<div className="platform-profile-stat-card__value whitespace-nowrap text-[16px] font-black leading-none text-[var(--platform-text-strong)]">
|
||||
{value}
|
||||
</div>
|
||||
<div className="platform-profile-stat-card__label mt-1 whitespace-nowrap text-[12px] font-medium text-[var(--platform-text-soft)]">
|
||||
<div className="platform-profile-stat-card__label mt-1 whitespace-nowrap text-[11px] font-medium text-[var(--platform-text-soft)]">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2556,7 +2551,7 @@ function ProfileShortcutButton({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick ?? undefined}
|
||||
className="platform-profile-shortcut-button flex min-h-[5.25rem] w-full flex-col items-center justify-center gap-2 px-2.5 py-3 text-center transition"
|
||||
className="platform-profile-shortcut-button flex min-h-[4.75rem] w-full flex-col items-center justify-center gap-1.5 px-2 py-2.5 text-center transition"
|
||||
>
|
||||
<div className="platform-profile-shortcut-button__icon">
|
||||
{imageSrc ? (
|
||||
@@ -2565,11 +2560,11 @@ function ProfileShortcutButton({
|
||||
<Icon className="h-[1.125rem] w-[1.125rem]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="platform-profile-shortcut-button__label whitespace-nowrap text-[13px] font-semibold text-[var(--platform-text-strong)]">
|
||||
<div className="platform-profile-shortcut-button__label whitespace-nowrap text-[12px] font-semibold text-[var(--platform-text-strong)]">
|
||||
{label}
|
||||
</div>
|
||||
{subLabel ? (
|
||||
<div className="platform-profile-shortcut-button__sub-label flex min-h-4 items-center justify-center gap-1 whitespace-nowrap text-[11px] font-medium text-[var(--platform-text-soft)]">
|
||||
<div className="platform-profile-shortcut-button__sub-label flex min-h-4 items-center justify-center gap-1 whitespace-nowrap text-[10px] font-medium text-[var(--platform-text-soft)]">
|
||||
{subLabel}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -2592,13 +2587,13 @@ function ProfileSettingsRow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="platform-profile-settings-row flex w-full items-center justify-between gap-3 px-4 py-4 text-left transition"
|
||||
className="platform-profile-settings-row flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="platform-profile-settings-row__icon">
|
||||
<Icon className="h-5 w-5" />
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="truncate text-[15px] font-semibold text-[var(--platform-text-strong)]">
|
||||
<span className="truncate text-[14px] font-semibold text-[var(--platform-text-strong)]">
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
@@ -5130,6 +5125,40 @@ export function RpgEntryHomeView({
|
||||
loadTaskCenter();
|
||||
}, [activeTab, isAuthenticated, loadTaskCenter, profileTaskRefreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'profile' || !isAuthenticated) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timer: number | null = null;
|
||||
|
||||
const scheduleNextReset = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(() => {
|
||||
void refreshStoredAccessToken({ clearOnFailure: false })
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
loadTaskCenter();
|
||||
scheduleNextReset();
|
||||
});
|
||||
}, getDelayUntilNextProfileTaskReset());
|
||||
};
|
||||
|
||||
scheduleNextReset();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [activeTab, isAuthenticated, loadTaskCenter]);
|
||||
|
||||
const openTaskCenterPanel = () => {
|
||||
setIsTaskCenterOpen(true);
|
||||
setTaskClaimSuccess(null);
|
||||
@@ -6431,7 +6460,7 @@ export function RpgEntryHomeView({
|
||||
|
||||
<div className="platform-profile-header__text min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="platform-profile-header__name truncate text-[20px] font-black leading-tight text-[var(--platform-text-strong)]">
|
||||
<div className="platform-profile-header__name truncate text-[18px] font-black leading-tight text-[var(--platform-text-strong)]">
|
||||
{authUi.user.displayName}
|
||||
</div>
|
||||
<button
|
||||
@@ -6440,10 +6469,10 @@ export function RpgEntryHomeView({
|
||||
className="platform-profile-edit-button"
|
||||
aria-label="修改昵称"
|
||||
>
|
||||
<Pencil className="h-5 w-5" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="platform-profile-header__code mt-3 flex flex-wrap items-center gap-2 text-[13px] text-[var(--platform-text-base)]">
|
||||
<div className="platform-profile-header__code mt-2 flex flex-wrap items-center gap-2 text-[12px] text-[var(--platform-text-base)]">
|
||||
<span>陶泥号: {publicUserCode}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -6472,10 +6501,10 @@ export function RpgEntryHomeView({
|
||||
<Crown className="platform-profile-membership-card__crown" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="platform-profile-membership-card__title block text-[18px] font-black leading-tight text-white">
|
||||
<span className="platform-profile-membership-card__title block text-[16px] font-black leading-tight text-white">
|
||||
普通用户
|
||||
</span>
|
||||
<span className="platform-profile-membership-card__subtitle mt-2 block text-[13px] font-medium text-white/92">
|
||||
<span className="platform-profile-membership-card__subtitle mt-1.5 block text-[12px] font-medium text-white/92">
|
||||
升级会员,享专属特权与福利
|
||||
</span>
|
||||
</span>
|
||||
@@ -6504,7 +6533,7 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
label="累计游戏时长"
|
||||
label="累计游玩"
|
||||
value="暂不可用"
|
||||
icon={Clock3}
|
||||
imageSrc={profileClockImage}
|
||||
@@ -6512,7 +6541,7 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playedWorks"
|
||||
label="已玩游戏数量"
|
||||
label="已玩游戏"
|
||||
value="暂不可用"
|
||||
icon={BookOpen}
|
||||
imageSrc={profileGamepadImage}
|
||||
@@ -6531,7 +6560,7 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
label="累计游戏时长"
|
||||
label="累计游玩"
|
||||
value={totalPlayTime}
|
||||
icon={Clock3}
|
||||
imageSrc={profileClockImage}
|
||||
@@ -6539,7 +6568,7 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playedWorks"
|
||||
label="已玩游戏数量"
|
||||
label="已玩游戏"
|
||||
value={`${formatDashboardCount(playedWorkCount)}个`}
|
||||
icon={BookOpen}
|
||||
imageSrc={profileGamepadImage}
|
||||
@@ -6559,15 +6588,15 @@ export function RpgEntryHomeView({
|
||||
<span className="platform-profile-daily-task-card__title block text-[15px] font-black text-[var(--platform-text-strong)]">
|
||||
每日任务
|
||||
</span>
|
||||
<span className="platform-profile-daily-task-card__desc mt-4 block text-[13px] font-medium text-[var(--platform-text-base)]">
|
||||
<span className="platform-profile-daily-task-card__desc mt-2 block text-[12px] font-medium text-[var(--platform-text-base)]">
|
||||
完成任务可领取{' '}
|
||||
<span className="text-[#c45b2a]">
|
||||
{profileTaskCardSummary.rewardPoints}
|
||||
</span>{' '}
|
||||
泥点
|
||||
</span>
|
||||
<span className="platform-profile-daily-task-card__progress mt-4 flex items-center gap-3">
|
||||
<span className="platform-profile-daily-task-card__progress-value text-[14px] font-semibold text-[#dc3f0e]">
|
||||
<span className="platform-profile-daily-task-card__progress mt-3 flex items-center gap-3">
|
||||
<span className="platform-profile-daily-task-card__progress-value text-[13px] font-semibold text-[#dc3f0e]">
|
||||
{profileTaskCardSummary.progressCount} /{' '}
|
||||
{profileTaskCardSummary.threshold}
|
||||
</span>
|
||||
@@ -6586,9 +6615,6 @@ export function RpgEntryHomeView({
|
||||
alt=""
|
||||
className="platform-profile-daily-task-card__mascot"
|
||||
/>
|
||||
<span className="platform-profile-daily-task-card__action">
|
||||
{profileTaskCardSummary.actionLabel}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<section
|
||||
@@ -6612,14 +6638,14 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="玩家社区"
|
||||
subLabel="交流心得 领取福利"
|
||||
subLabel="交流心得"
|
||||
icon={MessageCircle}
|
||||
imageSrc={profileCommunityImage}
|
||||
onClick={() => openProfilePopupPanel('community')}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="反馈与建议"
|
||||
subLabel="帮助我们做得更好"
|
||||
subLabel="帮我们优化产品"
|
||||
icon={MessageCircle}
|
||||
imageSrc={profileFeedbackImage}
|
||||
onClick={onOpenFeedback}
|
||||
@@ -6628,16 +6654,6 @@ export function RpgEntryHomeView({
|
||||
</section>
|
||||
|
||||
<section className="platform-profile-settings-panel" aria-label="设置入口">
|
||||
<ProfileSettingsRow
|
||||
label="主题设置"
|
||||
icon={Palette}
|
||||
onClick={() => authUi.openSettingsModal('appearance')}
|
||||
/>
|
||||
<ProfileSettingsRow
|
||||
label="账号与安全"
|
||||
icon={ShieldCheck}
|
||||
onClick={() => authUi.openSettingsModal('account')}
|
||||
/>
|
||||
<ProfileSettingsRow
|
||||
label="通用设置"
|
||||
icon={Settings}
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
formatPlatformWorldTime,
|
||||
isBarkBattleGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isPuzzleClearGalleryEntry,
|
||||
isVisualNovelGalleryEntry,
|
||||
isWoodenFishGalleryEntry,
|
||||
mapBabyObjectMatchDraftToPlatformGalleryCard,
|
||||
mapBarkBattleWorkToPlatformGalleryCard,
|
||||
mapPuzzleClearWorkToPlatformGalleryCard,
|
||||
mapVisualNovelWorkToPlatformGalleryCard,
|
||||
mapWoodenFishWorkToPlatformGalleryCard,
|
||||
type PlatformEdutainmentGalleryCard,
|
||||
@@ -198,6 +200,35 @@ test('maps wooden fish work to platform gallery card with WF public code', () =>
|
||||
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['敲木鱼']);
|
||||
});
|
||||
|
||||
test('maps puzzle clear work to platform gallery card with PC public code', () => {
|
||||
const card = mapPuzzleClearWorkToPlatformGalleryCard({
|
||||
runtimeKind: 'puzzle-clear',
|
||||
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',
|
||||
});
|
||||
|
||||
expect(isPuzzleClearGalleryEntry(card)).toBe(true);
|
||||
expect(card.sourceType).toBe('puzzle-clear');
|
||||
expect(card.publicWorkCode).toBe('PC-12345678');
|
||||
expect(resolvePlatformPublicWorkCode(card)).toBe('PC-12345678');
|
||||
expect(resolvePlatformWorldFallbackCoverImage(card)).toBe(
|
||||
'/creation-type-references/puzzle.webp',
|
||||
);
|
||||
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['拼消消', '霓虹星港']);
|
||||
});
|
||||
|
||||
test('resolves public work author from display name and public user code before stored author name', () => {
|
||||
const card = mapWoodenFishWorkToPlatformGalleryCard({
|
||||
publicWorkCode: 'WF-AUTHOR1',
|
||||
|
||||
@@ -7,6 +7,11 @@ import type {
|
||||
JumpHopGalleryCardResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
PuzzleClearGalleryCardResponse,
|
||||
PuzzleClearWorkProfileResponse,
|
||||
PuzzleClearWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/puzzleClear';
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
buildBigFishPublicWorkCode,
|
||||
buildJumpHopPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzleClearPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
buildSquareHolePublicWorkCode,
|
||||
buildVisualNovelPublicWorkCode,
|
||||
@@ -56,6 +62,7 @@ export type PlatformWorldCardLike =
|
||||
| PlatformMatch3DGalleryCard
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformPuzzleClearGalleryCard
|
||||
| PlatformJumpHopGalleryCard
|
||||
| PlatformWoodenFishGalleryCard
|
||||
| PlatformVisualNovelGalleryCard
|
||||
@@ -213,6 +220,29 @@ export type PlatformJumpHopGalleryCard = {
|
||||
stylePreset?: string;
|
||||
};
|
||||
|
||||
export type PlatformPuzzleClearGalleryCard = {
|
||||
sourceType: 'puzzle-clear';
|
||||
workId: string;
|
||||
profileId: string;
|
||||
sourceSessionId?: string | null;
|
||||
publicWorkCode: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeTags: string[];
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
themePrompt: string;
|
||||
};
|
||||
|
||||
export type PlatformWoodenFishGalleryCard = {
|
||||
sourceType: 'wooden-fish';
|
||||
workId: string;
|
||||
@@ -294,6 +324,7 @@ export type PlatformPublicGalleryCard =
|
||||
| PlatformMatch3DGalleryCard
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformPuzzleClearGalleryCard
|
||||
| PlatformJumpHopGalleryCard
|
||||
| PlatformWoodenFishGalleryCard
|
||||
| PlatformVisualNovelGalleryCard
|
||||
@@ -342,6 +373,12 @@ export function isJumpHopGalleryEntry(
|
||||
return 'sourceType' in entry && entry.sourceType === 'jump-hop';
|
||||
}
|
||||
|
||||
export function isPuzzleClearGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformPuzzleClearGalleryCard {
|
||||
return 'sourceType' in entry && entry.sourceType === 'puzzle-clear';
|
||||
}
|
||||
|
||||
export function isWoodenFishGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformWoodenFishGalleryCard {
|
||||
@@ -360,6 +397,31 @@ export function isBarkBattleGalleryEntry(
|
||||
return 'sourceType' in entry && entry.sourceType === 'bark-battle';
|
||||
}
|
||||
|
||||
export function buildPlatformPublicGalleryCardKey(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
const kind = isBigFishGalleryEntry(entry)
|
||||
? 'big-fish'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? 'puzzle'
|
||||
: isJumpHopGalleryEntry(entry)
|
||||
? 'jump-hop'
|
||||
: isWoodenFishGalleryEntry(entry)
|
||||
? 'wooden-fish'
|
||||
: isMatch3DGalleryEntry(entry)
|
||||
? 'match3d'
|
||||
: isSquareHoleGalleryEntry(entry)
|
||||
? 'square-hole'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? 'visual-novel'
|
||||
: isBarkBattleGalleryEntry(entry)
|
||||
? 'bark-battle'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? `edutainment:${entry.templateId}`
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
work: PuzzleWorkSummary,
|
||||
): PlatformPuzzleGalleryCard {
|
||||
@@ -548,6 +610,68 @@ export function mapJumpHopWorkToPlatformGalleryCard(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePuzzleClearThemeTags(summary: PuzzleClearWorkSummaryResponse) {
|
||||
const themePrompt = summary.themePrompt.trim();
|
||||
return [
|
||||
'拼消消',
|
||||
...(themePrompt ? [themePrompt] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function getPuzzleClearRecentPlayCount(
|
||||
summary: PuzzleClearGalleryCardResponse | PuzzleClearWorkSummaryResponse,
|
||||
) {
|
||||
if (!('recentPlayCount7d' in summary)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return typeof summary.recentPlayCount7d === 'number'
|
||||
? summary.recentPlayCount7d
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function mapPuzzleClearWorkToPlatformGalleryCard(
|
||||
work:
|
||||
| PuzzleClearGalleryCardResponse
|
||||
| PuzzleClearWorkSummaryResponse
|
||||
| PuzzleClearWorkProfileResponse,
|
||||
): PlatformPuzzleClearGalleryCard {
|
||||
const summary = 'summary' in work ? work.summary : work;
|
||||
return {
|
||||
sourceType: 'puzzle-clear',
|
||||
workId: summary.workId,
|
||||
profileId: summary.profileId,
|
||||
sourceSessionId:
|
||||
'sourceSessionId' in summary ? (summary.sourceSessionId ?? null) : null,
|
||||
publicWorkCode:
|
||||
'publicWorkCode' in summary &&
|
||||
typeof summary.publicWorkCode === 'string' &&
|
||||
summary.publicWorkCode.trim()
|
||||
? summary.publicWorkCode
|
||||
: buildPuzzleClearPublicWorkCode(summary.profileId),
|
||||
ownerUserId: summary.ownerUserId,
|
||||
authorDisplayName:
|
||||
'authorDisplayName' in summary &&
|
||||
typeof summary.authorDisplayName === 'string' &&
|
||||
summary.authorDisplayName.trim()
|
||||
? summary.authorDisplayName
|
||||
: '玩家',
|
||||
worldName: summary.workTitle.trim() || '拼消消',
|
||||
subtitle: '拼消消',
|
||||
summaryText: summary.workDescription,
|
||||
coverImageSrc: summary.coverImageSrc ?? null,
|
||||
themeTags: normalizePuzzleClearThemeTags(summary),
|
||||
playCount: summary.playCount ?? 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: getPuzzleClearRecentPlayCount(summary),
|
||||
visibility: 'published',
|
||||
publishedAt: summary.publishedAt ?? null,
|
||||
updatedAt: summary.updatedAt,
|
||||
themePrompt: summary.themePrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWoodenFishWorkToPlatformGalleryCard(
|
||||
work: WoodenFishGalleryCardResponse | WoodenFishWorkProfileResponse,
|
||||
): PlatformWoodenFishGalleryCard {
|
||||
@@ -708,6 +832,10 @@ export function resolvePlatformWorldFallbackCoverImage(
|
||||
return '/creation-type-references/puzzle.webp';
|
||||
}
|
||||
|
||||
if (isPuzzleClearGalleryEntry(entry)) {
|
||||
return '/creation-type-references/puzzle.webp';
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return '/creation-type-references/match3d.webp';
|
||||
}
|
||||
@@ -907,6 +1035,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['拼图'];
|
||||
}
|
||||
|
||||
if (isPuzzleClearGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
: ['拼消消'];
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
@@ -1021,6 +1155,10 @@ export function resolvePlatformPublicWorkCode(
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isPuzzleClearGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ describe('UnifiedGenerationPage', () => {
|
||||
expect(document.body.textContent).toContain('拼图图片生成进度');
|
||||
expect(screen.getByText('图片生成中')).toBeTruthy();
|
||||
expect(screen.getAllByText('生成拼图首图').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('当前拼图信息')).toBeTruthy();
|
||||
expect(screen.queryByText('当前拼图信息')).toBeNull();
|
||||
expect(screen.queryByText('一只发光的纸船')).toBeNull();
|
||||
});
|
||||
|
||||
test('jump-hop generation page uses unified copy', () => {
|
||||
@@ -66,6 +67,7 @@ describe('UnifiedGenerationPage', () => {
|
||||
|
||||
expect(document.body.textContent).toContain('跳一跳草稿生成进度');
|
||||
expect(screen.getByText('素材生成中')).toBeTruthy();
|
||||
expect(screen.getByText('当前跳一跳信息')).toBeTruthy();
|
||||
expect(screen.queryByText('当前跳一跳信息')).toBeNull();
|
||||
expect(screen.queryByText('云端糖果塔')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
|
||||
import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
|
||||
import { getUnifiedGenerationCopy } from './unifiedGenerationCopy';
|
||||
import type { UnifiedGenerationPlayId } from './unifiedGenerationCopy';
|
||||
import { getUnifiedGenerationCopy } from './unifiedGenerationCopy';
|
||||
|
||||
type UnifiedGenerationPageProps = {
|
||||
playId: UnifiedGenerationPlayId;
|
||||
@@ -45,7 +45,6 @@ export function UnifiedGenerationPage({
|
||||
backLabel="返回创作中心"
|
||||
settingActionLabel={null}
|
||||
retryLabel={copy.retryLabel}
|
||||
settingTitle={copy.settingTitle}
|
||||
settingDescription={null}
|
||||
progressTitle={copy.progressTitle}
|
||||
activeBadgeLabel={copy.activeBadgeLabel}
|
||||
|
||||
@@ -8,25 +8,21 @@ export type UnifiedGenerationPlayId = Extract<
|
||||
const UNIFIED_GENERATION_COPY = {
|
||||
puzzle: {
|
||||
retryLabel: '重新生成图片',
|
||||
settingTitle: '当前拼图信息',
|
||||
progressTitle: '拼图图片生成进度',
|
||||
activeBadgeLabel: '图片生成中',
|
||||
},
|
||||
match3d: {
|
||||
retryLabel: '重新生成草稿',
|
||||
settingTitle: '当前抓大鹅信息',
|
||||
progressTitle: '抓大鹅草稿生成进度',
|
||||
activeBadgeLabel: '素材生成中',
|
||||
},
|
||||
'jump-hop': {
|
||||
retryLabel: '重新生成草稿',
|
||||
settingTitle: '当前跳一跳信息',
|
||||
progressTitle: '跳一跳草稿生成进度',
|
||||
activeBadgeLabel: '素材生成中',
|
||||
},
|
||||
'wooden-fish': {
|
||||
retryLabel: '重新生成草稿',
|
||||
settingTitle: '当前敲木鱼信息',
|
||||
progressTitle: '敲木鱼草稿生成进度',
|
||||
activeBadgeLabel: '素材生成中',
|
||||
},
|
||||
@@ -34,7 +30,6 @@ const UNIFIED_GENERATION_COPY = {
|
||||
UnifiedGenerationPlayId,
|
||||
{
|
||||
retryLabel: string;
|
||||
settingTitle: string;
|
||||
progressTitle: string;
|
||||
activeBadgeLabel: string;
|
||||
}
|
||||
|
||||
@@ -149,6 +149,12 @@ test('运行态缺少音频资产时使用默认木鱼音', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('运行态为敲击音效预创建 10 路复音池', () => {
|
||||
render(<WoodenFishRuntimeShell profile={createProfile()} />);
|
||||
|
||||
expect(audioConstructor).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
test('顶部只展示总数,点击后展开子项计数器面板,点外部收起', () => {
|
||||
render(<WoodenFishRuntimeShell profile={createProfile()} run={createRun()} />);
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ type FloatingText = {
|
||||
y: number;
|
||||
};
|
||||
|
||||
const AUDIO_POOL_SIZE = 5;
|
||||
const AUDIO_POOL_SIZE = 10;
|
||||
const MIN_AUDIO_INTERVAL_MS = 48;
|
||||
|
||||
function getRun(
|
||||
|
||||
Reference in New Issue
Block a user