Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -525,6 +525,7 @@ test('creation start card maps backend jump-hop draft to template card', () => {
|
||||
profileId: 'jump-hop-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'jump-hop-session-1',
|
||||
themeText: '跳一跳生成草稿',
|
||||
workTitle: '跳一跳生成草稿',
|
||||
workDescription: '后端仍在生成跳一跳玩法。',
|
||||
themeTags: ['跳一跳'],
|
||||
|
||||
@@ -1,144 +1,205 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { JumpHopDraftResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { JumpHopWorkProfileResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
|
||||
import { JumpHopResultView } from './JumpHopResultView';
|
||||
|
||||
const draft: JumpHopDraftResponse = {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'profile-1',
|
||||
workTitle: '云端跳台',
|
||||
workDescription: '一路跳到星星。',
|
||||
themeTags: ['云朵', '星空'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
characterPrompt: '纸片小兔',
|
||||
tilePrompt: '柔软云朵平台',
|
||||
endMoodPrompt: '星光门',
|
||||
characterAsset: {
|
||||
assetId: 'character-1',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
imageObjectKey: 'jump-hop/character.png',
|
||||
assetObjectId: 'asset-character',
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: '角色图',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
tileAtlasAsset: {
|
||||
assetId: 'tiles-1',
|
||||
imageSrc: 'data:image/png;base64,tiles',
|
||||
imageObjectKey: 'jump-hop/tiles.png',
|
||||
assetObjectId: 'asset-tiles',
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: '地块图',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
tileAssets: [
|
||||
{
|
||||
tileType: 'start',
|
||||
imageSrc: 'data:image/png;base64,tile-start',
|
||||
imageObjectKey: 'jump-hop/tile-start.png',
|
||||
assetObjectId: 'asset-tile-start',
|
||||
sourceAtlasCell: 'A1',
|
||||
visualWidth: 128,
|
||||
visualHeight: 96,
|
||||
topSurfaceRadius: 24,
|
||||
landingRadius: 28,
|
||||
},
|
||||
{
|
||||
tileType: 'finish',
|
||||
imageSrc: 'data:image/png;base64,tile-finish',
|
||||
imageObjectKey: 'jump-hop/tile-finish.png',
|
||||
assetObjectId: 'asset-tile-finish',
|
||||
sourceAtlasCell: 'A2',
|
||||
visualWidth: 128,
|
||||
visualHeight: 96,
|
||||
topSurfaceRadius: 24,
|
||||
landingRadius: 28,
|
||||
},
|
||||
],
|
||||
path: {
|
||||
seed: 'jump-hop-seed',
|
||||
difficulty: 'standard',
|
||||
platforms: [
|
||||
{
|
||||
platformId: 'platform-1',
|
||||
tileType: 'start',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 48,
|
||||
height: 36,
|
||||
landingRadius: 22,
|
||||
perfectRadius: 12,
|
||||
scoreValue: 1,
|
||||
},
|
||||
{
|
||||
platformId: 'platform-2',
|
||||
tileType: 'finish',
|
||||
x: 16,
|
||||
y: 18,
|
||||
width: 60,
|
||||
height: 42,
|
||||
landingRadius: 22,
|
||||
perfectRadius: 12,
|
||||
scoreValue: 2,
|
||||
},
|
||||
],
|
||||
finishIndex: 1,
|
||||
cameraPreset: 'default',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 1.2,
|
||||
maxChargeMs: 1800,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 50,
|
||||
},
|
||||
},
|
||||
coverComposite: 'data:image/png;base64,cover',
|
||||
generationStatus: 'ready',
|
||||
};
|
||||
vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({
|
||||
useJumpHopLeaderboard: vi.fn(),
|
||||
}));
|
||||
|
||||
test('jump hop result view exposes test run and publish actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
const onEdit = vi.fn();
|
||||
const onStartTestRun = vi.fn();
|
||||
const onPublish = vi.fn();
|
||||
const onRegenerateCharacter = vi.fn();
|
||||
const onRegenerateTiles = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
|
||||
leaderboard: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
test('跳一跳结果页展示排行榜列表', () => {
|
||||
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
|
||||
leaderboard: {
|
||||
profileId: 'jump-hop-profile-test',
|
||||
items: [
|
||||
{
|
||||
rank: 1,
|
||||
playerId: 'player-1',
|
||||
successfulJumpCount: 12,
|
||||
durationMs: 40123,
|
||||
updatedAt: '2026-05-27T00:00:00Z',
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
playerId: 'player-2',
|
||||
successfulJumpCount: 10,
|
||||
durationMs: 38210,
|
||||
updatedAt: '2026-05-26T00:00:00Z',
|
||||
},
|
||||
],
|
||||
viewerBest: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<JumpHopResultView
|
||||
profile={draft}
|
||||
onBack={onBack}
|
||||
onEdit={onEdit}
|
||||
onStartTestRun={onStartTestRun}
|
||||
onPublish={onPublish}
|
||||
onRegenerateCharacter={onRegenerateCharacter}
|
||||
onRegenerateTiles={onRegenerateTiles}
|
||||
profile={buildProfile({ publicationStatus: 'published' })}
|
||||
onBack={() => {}}
|
||||
onEdit={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
onPublish={() => {}}
|
||||
onRegenerateTiles={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('云端跳台')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '试玩' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '试玩' }));
|
||||
await user.click(screen.getByRole('button', { name: '发布' }));
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
await user.click(screen.getByRole('button', { name: '返回编辑' }));
|
||||
await user.click(screen.getByRole('button', { name: '角色' }));
|
||||
await user.click(screen.getByRole('button', { name: '地块' }));
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledTimes(1);
|
||||
expect(onPublish).toHaveBeenCalledTimes(1);
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
expect(onEdit).toHaveBeenCalledTimes(1);
|
||||
expect(onRegenerateCharacter).toHaveBeenCalledTimes(1);
|
||||
expect(onRegenerateTiles).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('排行榜')).toBeTruthy();
|
||||
expect(screen.getByText('player-1')).toBeTruthy();
|
||||
expect(screen.getByText('12 跳')).toBeTruthy();
|
||||
expect(screen.getByText('00:40')).toBeTruthy();
|
||||
expect(screen.getByText('player-2')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => {
|
||||
render(
|
||||
<JumpHopResultView
|
||||
profile={buildProfile()}
|
||||
onBack={() => {}}
|
||||
onEdit={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
onPublish={() => {}}
|
||||
onRegenerateTiles={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('jump-hop-result-character-logo').getAttribute('src')).toBe(
|
||||
'/branding/jump-hop-taonier-character.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳草稿结果页不请求公开排行榜', () => {
|
||||
render(
|
||||
<JumpHopResultView
|
||||
profile={buildProfile({ publicationStatus: 'draft' })}
|
||||
onBack={() => {}}
|
||||
onEdit={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
onPublish={() => {}}
|
||||
onRegenerateTiles={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(useJumpHopLeaderboard).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('排行榜')).toBeNull();
|
||||
});
|
||||
|
||||
function buildProfile(
|
||||
options: {
|
||||
publicationStatus?: JumpHopWorkProfileResponse['summary']['publicationStatus'];
|
||||
} = {},
|
||||
): JumpHopWorkProfileResponse {
|
||||
return {
|
||||
summary: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-profile-test',
|
||||
profileId: 'jump-hop-profile-test',
|
||||
ownerUserId: 'user-test',
|
||||
sourceSessionId: 'jump-hop-session-test',
|
||||
themeText: '测试',
|
||||
workTitle: '测试',
|
||||
workDescription: '测试',
|
||||
themeTags: ['测试'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: options.publicationStatus ?? 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-27T00:00:00Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
draft: {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'jump-hop-profile-test',
|
||||
themeText: '测试',
|
||||
workTitle: '测试',
|
||||
workDescription: '测试',
|
||||
themeTags: ['测试'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
defaultCharacter: {
|
||||
characterId: 'jump-hop-default-runner',
|
||||
displayName: '默认角色',
|
||||
modelKind: 'builtin-three',
|
||||
bodyColor: '#f59e0b',
|
||||
accentColor: '#2563eb',
|
||||
},
|
||||
characterPrompt: '默认角色',
|
||||
tilePrompt: '地块',
|
||||
endMoodPrompt: null,
|
||||
characterAsset: {
|
||||
assetId: 'builtin',
|
||||
imageSrc: 'builtin://jump-hop/default-character',
|
||||
imageObjectKey: '',
|
||||
assetObjectId: 'builtin',
|
||||
generationProvider: 'builtin-three',
|
||||
prompt: '默认角色',
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
tileAtlasAsset: {
|
||||
assetId: 'builtin',
|
||||
imageSrc: 'builtin://jump-hop/default-character',
|
||||
imageObjectKey: '',
|
||||
assetObjectId: 'builtin',
|
||||
generationProvider: 'builtin-three',
|
||||
prompt: '默认角色',
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
tileAssets: [],
|
||||
path: null,
|
||||
coverComposite: null,
|
||||
backButtonAsset: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
path: null as never,
|
||||
defaultCharacter: {
|
||||
characterId: 'jump-hop-default-runner',
|
||||
displayName: '默认角色',
|
||||
modelKind: 'builtin-three',
|
||||
bodyColor: '#f59e0b',
|
||||
accentColor: '#2563eb',
|
||||
},
|
||||
characterAsset: {
|
||||
assetId: 'builtin',
|
||||
imageSrc: 'builtin://jump-hop/default-character',
|
||||
imageObjectKey: '',
|
||||
assetObjectId: 'builtin',
|
||||
generationProvider: 'builtin-three',
|
||||
prompt: '默认角色',
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
tileAtlasAsset: {
|
||||
assetId: 'builtin',
|
||||
imageSrc: 'builtin://jump-hop/default-character',
|
||||
imageObjectKey: '',
|
||||
assetObjectId: 'builtin',
|
||||
generationProvider: 'builtin-three',
|
||||
prompt: '默认角色',
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
tileAssets: [],
|
||||
backButtonAsset: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,18 +2,22 @@ import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Play,
|
||||
RefreshCcw,
|
||||
Send,
|
||||
Shuffle,
|
||||
} from 'lucide-react';
|
||||
import { type CSSProperties, useMemo, useState } from 'react';
|
||||
import { type CSSProperties, useState } from 'react';
|
||||
|
||||
import type {
|
||||
JumpHopDraftResponse,
|
||||
JumpHopPath,
|
||||
JumpHopPlatform,
|
||||
JumpHopTileAsset,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import {
|
||||
formatJumpHopDurationLabel,
|
||||
selectJumpHopTileAsset,
|
||||
} from '../../services/jump-hop/jumpHopRuntimeModel';
|
||||
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type JumpHopResultViewProps = {
|
||||
@@ -34,7 +38,6 @@ type JumpHopResultViewProps = {
|
||||
onEdit: () => void;
|
||||
onStartTestRun: () => void;
|
||||
onPublish: () => void;
|
||||
onRegenerateCharacter: () => void;
|
||||
onRegenerateTiles: () => void;
|
||||
};
|
||||
|
||||
@@ -44,43 +47,6 @@ function isJumpHopWorkProfile(
|
||||
return 'summary' in profile;
|
||||
}
|
||||
|
||||
type MiniMapPlatform = {
|
||||
platform: JumpHopPlatform;
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isStart: boolean;
|
||||
isFinish: boolean;
|
||||
};
|
||||
|
||||
const difficultyToneByValue: Record<
|
||||
JumpHopPath['difficulty'],
|
||||
{ accent: string; soft: string; label: string }
|
||||
> = {
|
||||
advanced: {
|
||||
accent: '#df7f40',
|
||||
soft: 'rgba(249, 115, 22, 0.16)',
|
||||
label: '进阶',
|
||||
},
|
||||
challenge: {
|
||||
accent: '#b64a35',
|
||||
soft: 'rgba(182, 98, 63, 0.16)',
|
||||
label: '挑战',
|
||||
},
|
||||
easy: {
|
||||
accent: '#14b8a6',
|
||||
soft: 'rgba(20, 184, 166, 0.16)',
|
||||
label: '轻松',
|
||||
},
|
||||
standard: {
|
||||
accent: '#2563eb',
|
||||
soft: 'rgba(37, 99, 235, 0.16)',
|
||||
label: '标准',
|
||||
},
|
||||
};
|
||||
|
||||
const tileToneByType: Record<string, string> = {
|
||||
accent: '#c4b5fd',
|
||||
bonus: '#fde68a',
|
||||
@@ -90,155 +56,191 @@ const tileToneByType: Record<string, string> = {
|
||||
target: '#fecdd3',
|
||||
};
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC =
|
||||
'/branding/jump-hop-taonier-character.png';
|
||||
|
||||
function JumpHopDefaultCharacterPreview() {
|
||||
return (
|
||||
<div className="relative grid aspect-[1/1] place-items-center overflow-hidden bg-[linear-gradient(180deg,#eff6ff_0%,#fff7ed_100%)]">
|
||||
<div className="absolute inset-x-[18%] bottom-[14%] h-[14%] rounded-full bg-slate-900/12 blur-[2px]" />
|
||||
<img
|
||||
src={JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="relative z-10 h-[78%] w-[78%] object-contain drop-shadow-[0_12px_18px_rgba(146,64,14,0.2)]"
|
||||
data-testid="jump-hop-result-character-logo"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePathPlatforms(path: JumpHopPath | null | undefined) {
|
||||
const platforms = path?.platforms ?? [];
|
||||
if (platforms.length === 0) {
|
||||
return [];
|
||||
function JumpHopTilePoolPreview({
|
||||
tileAssets,
|
||||
tileAtlasAsset,
|
||||
tileAtlasFallbackSrc,
|
||||
}: {
|
||||
tileAssets: JumpHopTileAsset[];
|
||||
tileAtlasAsset?: JumpHopDraftResponse['tileAtlasAsset'] | null;
|
||||
tileAtlasFallbackSrc?: string | null;
|
||||
}) {
|
||||
const visibleTiles = tileAssets.slice(0, 25);
|
||||
const atlasSrc =
|
||||
tileAtlasAsset?.imageSrc?.trim() || tileAtlasFallbackSrc?.trim() || '';
|
||||
const atlasRefreshKey = tileAtlasAsset?.assetObjectId || atlasSrc;
|
||||
if (visibleTiles.length > 0) {
|
||||
return (
|
||||
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
|
||||
{visibleTiles.map((tile, index) => (
|
||||
<div
|
||||
key={tile.tileId ?? `${tile.sourceAtlasCell}-${index}`}
|
||||
className="grid min-h-0 place-items-center overflow-hidden rounded-[0.45rem] border border-white/80 bg-slate-50"
|
||||
>
|
||||
{tile.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={tile.imageSrc}
|
||||
refreshKey={tile.assetObjectId}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="h-4 w-4 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
tileToneByType[tile.tileType] ?? tileToneByType.normal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const coordinatePlatforms = platforms.filter(
|
||||
(platform) => isFiniteNumber(platform.x) && isFiniteNumber(platform.y),
|
||||
);
|
||||
const shouldUseCoordinates = coordinatePlatforms.length >= 2;
|
||||
const xValues = shouldUseCoordinates
|
||||
? coordinatePlatforms.map((platform) => platform.x)
|
||||
: [];
|
||||
const yValues = shouldUseCoordinates
|
||||
? coordinatePlatforms.map((platform) => platform.y)
|
||||
: [];
|
||||
const minX = Math.min(...xValues);
|
||||
const maxX = Math.max(...xValues);
|
||||
const minY = Math.min(...yValues);
|
||||
const maxY = Math.max(...yValues);
|
||||
const xRange = Math.max(maxX - minX, 1);
|
||||
const yRange = Math.max(maxY - minY, 1);
|
||||
const denominator = Math.max(platforms.length - 1, 1);
|
||||
|
||||
return platforms.map((platform, index): MiniMapPlatform => {
|
||||
const sequenceRatio = index / denominator;
|
||||
const hasCoordinates =
|
||||
shouldUseCoordinates &&
|
||||
isFiniteNumber(platform.x) &&
|
||||
isFiniteNumber(platform.y);
|
||||
const x = hasCoordinates
|
||||
? 12 + ((platform.x - minX) / xRange) * 76
|
||||
: 12 + sequenceRatio * 76;
|
||||
const y = hasCoordinates
|
||||
? 14 + ((platform.y - minY) / yRange) * 72
|
||||
: 50 + Math.sin(sequenceRatio * Math.PI * 2.3) * 18;
|
||||
|
||||
return {
|
||||
platform,
|
||||
index,
|
||||
x,
|
||||
y,
|
||||
width: Math.min(Math.max(platform.width || 54, 42), 82),
|
||||
height: Math.min(Math.max(platform.height || 42, 34), 68),
|
||||
isStart: index === 0 || platform.tileType === 'start',
|
||||
isFinish:
|
||||
index === path?.finishIndex ||
|
||||
platform.tileType === 'finish' ||
|
||||
platform.tileType === 'target',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function JumpHopPathMiniMap({ path }: { path: JumpHopPath }) {
|
||||
const platforms = useMemo(() => normalizePathPlatforms(path), [path]);
|
||||
const tone =
|
||||
difficultyToneByValue[path.difficulty] ?? difficultyToneByValue.standard;
|
||||
const pathPoints = platforms
|
||||
.map((platform) => `${platform.x},${platform.y}`)
|
||||
.join(' ');
|
||||
|
||||
if (platforms.length === 0) {
|
||||
return null;
|
||||
if (atlasSrc) {
|
||||
return (
|
||||
<ResolvedAssetImage
|
||||
src={atlasSrc}
|
||||
refreshKey={atlasRefreshKey}
|
||||
alt=""
|
||||
className="aspect-[1/1] w-full object-cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative aspect-[1/1] w-full overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef8ff_100%)]"
|
||||
style={
|
||||
{
|
||||
'--jump-hop-path-accent': tone.accent,
|
||||
'--jump-hop-path-soft': tone.soft,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_24%_18%,rgba(255,255,255,0.92),transparent_28%),radial-gradient(circle_at_75%_78%,rgba(125,211,252,0.24),transparent_32%)]" />
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline
|
||||
points={pathPoints}
|
||||
fill="none"
|
||||
stroke="var(--jump-hop-path-soft)"
|
||||
strokeWidth="11"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
|
||||
{Array.from({ length: 25 }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="rounded-[0.45rem] border border-white/80"
|
||||
style={{
|
||||
background:
|
||||
Object.values(tileToneByType)[index % Object.values(tileToneByType).length],
|
||||
}}
|
||||
/>
|
||||
<polyline
|
||||
points={pathPoints}
|
||||
fill="none"
|
||||
stroke="var(--jump-hop-path-accent)"
|
||||
strokeWidth="2.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
</svg>
|
||||
{platforms.map((item) => {
|
||||
const tileTone =
|
||||
tileToneByType[item.platform.tileType] ?? tileToneByType.normal;
|
||||
const scoreBoost =
|
||||
isFiniteNumber(item.platform.scoreValue) &&
|
||||
item.platform.scoreValue > 1;
|
||||
const style = {
|
||||
left: `${item.x}%`,
|
||||
top: `${item.y}%`,
|
||||
width: `${item.width}%`,
|
||||
height: `${item.height}%`,
|
||||
background: tileTone,
|
||||
borderColor: item.isFinish ? tone.accent : 'rgba(255,255,255,0.92)',
|
||||
zIndex: 10 + item.index,
|
||||
} as CSSProperties;
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JumpHopFirstPlatformsPreview({
|
||||
path,
|
||||
tileAssets,
|
||||
}: {
|
||||
path: JumpHopPath | null | undefined;
|
||||
tileAssets: JumpHopTileAsset[];
|
||||
}) {
|
||||
const platforms = (path?.platforms ?? []).slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="relative aspect-[1/1] overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef8ff_100%)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_30%,rgba(255,255,255,0.92),transparent_34%)]" />
|
||||
{platforms.map((platform, index) => {
|
||||
const asset = selectJumpHopTileAsset(
|
||||
tileAssets,
|
||||
path?.seed,
|
||||
index,
|
||||
platform.platformId,
|
||||
);
|
||||
const style = {
|
||||
left: `${50 + (index - 1) * 24}%`,
|
||||
top: `${68 - index * 22}%`,
|
||||
width: `${34 - index * 3}%`,
|
||||
zIndex: 10 + index,
|
||||
} as CSSProperties;
|
||||
return (
|
||||
<div
|
||||
key={
|
||||
item.platform.platformId ||
|
||||
`${item.index}-${item.platform.tileType}`
|
||||
}
|
||||
className="absolute grid max-h-9 max-w-11 min-h-6 min-w-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-[0.72rem] border-2 shadow-[0_8px_18px_rgba(15,23,42,0.13)]"
|
||||
key={platform.platformId || index}
|
||||
className="absolute aspect-[1.16/1] -translate-x-1/2 -translate-y-1/2"
|
||||
style={style}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
item.isStart || item.isFinish ? tone.accent : '#ffffff',
|
||||
boxShadow: scoreBoost ? `0 0 0 4px ${tone.soft}` : undefined,
|
||||
}}
|
||||
/>
|
||||
{item.isStart || item.isFinish ? (
|
||||
<span className="absolute -top-2.5 rounded-full bg-slate-950/78 px-1.5 py-0.5 text-[0.58rem] font-black leading-none text-white">
|
||||
{item.isStart ? '起' : '终'}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="absolute inset-x-[12%] bottom-[-6%] h-[22%] rounded-full bg-slate-900/14 blur-[3px]" />
|
||||
{asset?.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={asset.imageSrc}
|
||||
refreshKey={asset.assetObjectId}
|
||||
alt=""
|
||||
className="relative h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="relative h-full w-full rounded-[18%] border-2 border-white/90 shadow-[0_10px_22px_rgba(15,23,42,0.14)]"
|
||||
style={{
|
||||
background:
|
||||
tileToneByType[platform.tileType] ?? tileToneByType.normal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="absolute left-2 top-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-strong)] shadow-sm">
|
||||
{tone.label}
|
||||
{platforms.length === 0 ? (
|
||||
<div className="absolute inset-0 grid place-items-center text-sm font-bold text-[var(--platform-text-soft)]">
|
||||
路径
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JumpHopResultLeaderboard({
|
||||
profileId,
|
||||
}: {
|
||||
profileId?: string | null;
|
||||
}) {
|
||||
const { leaderboard, isLoading, error } = useJumpHopLeaderboard(profileId);
|
||||
const items = leaderboard?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
排行榜
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[var(--platform-text-soft)]" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="absolute bottom-2 right-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-base)] shadow-sm">
|
||||
{platforms.length}
|
||||
<div className="mt-3 grid gap-2">
|
||||
{items.slice(0, 5).map((entry) => (
|
||||
<div
|
||||
key={`${entry.rank}-${entry.playerId}`}
|
||||
className="grid grid-cols-[1.8rem_minmax(0,1fr)_auto_auto] items-center gap-2 rounded-[0.75rem] bg-white/70 px-2 py-2 text-xs font-bold text-[var(--platform-text-base)]"
|
||||
>
|
||||
<span className="text-[var(--platform-text-soft)]">
|
||||
{entry.rank}
|
||||
</span>
|
||||
<span className="truncate">{entry.playerId}</span>
|
||||
<span>{entry.successfulJumpCount} 跳</span>
|
||||
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-[0.75rem] bg-white/60 px-2 py-2 text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
{error ?? '暂无成绩'}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -252,7 +254,6 @@ export function JumpHopResultView({
|
||||
onEdit,
|
||||
onStartTestRun,
|
||||
onPublish,
|
||||
onRegenerateCharacter,
|
||||
onRegenerateTiles,
|
||||
}: JumpHopResultViewProps) {
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
@@ -264,12 +265,15 @@ export function JumpHopResultView({
|
||||
path: NonNullable<JumpHopDraftResponse['path']>;
|
||||
};
|
||||
const path = isWorkProfile ? profile.path : safeDraft.path;
|
||||
const characterAsset = isWorkProfile
|
||||
? profile.characterAsset
|
||||
: safeDraft.characterAsset;
|
||||
const tileAtlasAsset = isWorkProfile
|
||||
? profile.tileAtlasAsset
|
||||
: safeDraft.tileAtlasAsset;
|
||||
const tileAssets = isWorkProfile ? profile.tileAssets : safeDraft.tileAssets;
|
||||
const profileId = isWorkProfile
|
||||
? profile.summary.profileId
|
||||
: safeDraft.profileId;
|
||||
const canShowLeaderboard =
|
||||
isWorkProfile && profile.summary.publicationStatus === 'published';
|
||||
const titleSource = isWorkProfile
|
||||
? profile.summary.workTitle
|
||||
: profile.workTitle;
|
||||
@@ -278,15 +282,12 @@ export function JumpHopResultView({
|
||||
: profile.workDescription;
|
||||
const title = titleSource?.trim() || safeDraft.workTitle.trim() || '跳一跳';
|
||||
const summary = summarySource?.trim() || safeDraft.workDescription.trim();
|
||||
const pathPlatforms = normalizePathPlatforms(path);
|
||||
const canRenderPathMiniMap = pathPlatforms.length > 0;
|
||||
const hasAssets = Boolean(
|
||||
profile.characterImageSrc?.trim() ||
|
||||
profile.tileAtlasImageSrc?.trim() ||
|
||||
profile.tileAtlasImageSrc?.trim() ||
|
||||
profile.pathPreviewImageSrc?.trim() ||
|
||||
characterAsset?.imageSrc?.trim() ||
|
||||
tileAtlasAsset?.imageSrc?.trim() ||
|
||||
canRenderPathMiniMap,
|
||||
tileAssets.length > 0 ||
|
||||
path?.platforms.length,
|
||||
);
|
||||
|
||||
const handlePublish = async () => {
|
||||
@@ -310,15 +311,6 @@ export function JumpHopResultView({
|
||||
返回
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerateCharacter}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
角色
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerateTiles}
|
||||
@@ -343,69 +335,25 @@ export function JumpHopResultView({
|
||||
) : null}
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{profile.characterImageSrc || characterAsset?.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={
|
||||
('characterImageSrc' in profile
|
||||
? profile.characterImageSrc
|
||||
: null) ??
|
||||
characterAsset?.imageSrc ??
|
||||
''
|
||||
}
|
||||
alt="角色图"
|
||||
className="aspect-[1/1] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
角色
|
||||
</div>
|
||||
)}
|
||||
<JumpHopDefaultCharacterPreview />
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{profile.tileAtlasImageSrc || tileAtlasAsset?.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={
|
||||
('tileAtlasImageSrc' in profile
|
||||
? profile.tileAtlasImageSrc
|
||||
: null) ??
|
||||
tileAtlasAsset?.imageSrc ??
|
||||
''
|
||||
}
|
||||
alt="地块图"
|
||||
className="aspect-[1/1] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
地块
|
||||
</div>
|
||||
)}
|
||||
<JumpHopTilePoolPreview
|
||||
tileAssets={tileAssets}
|
||||
tileAtlasAsset={tileAtlasAsset}
|
||||
tileAtlasFallbackSrc={
|
||||
('tileAtlasImageSrc' in profile
|
||||
? profile.tileAtlasImageSrc
|
||||
: null) ??
|
||||
null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{path && canRenderPathMiniMap ? (
|
||||
<JumpHopPathMiniMap path={path} />
|
||||
) : 'pathPreviewImageSrc' in profile &&
|
||||
profile.pathPreviewImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={profile.pathPreviewImageSrc}
|
||||
alt="路径预览"
|
||||
className="aspect-[1/1] w-full object-cover"
|
||||
/>
|
||||
) : path ? (
|
||||
<div className="grid aspect-[1/1] place-items-center px-3 text-center">
|
||||
<div>
|
||||
<div className="text-3xl font-black text-[var(--platform-text-strong)]">
|
||||
{path.platforms.length}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
{path.difficulty}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
路径
|
||||
</div>
|
||||
)}
|
||||
<JumpHopFirstPlatformsPreview
|
||||
path={path}
|
||||
tileAssets={tileAssets}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!hasAssets ? (
|
||||
@@ -419,6 +367,9 @@ export function JumpHopResultView({
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
结果操作
|
||||
</div>
|
||||
{canShowLeaderboard ? (
|
||||
<JumpHopResultLeaderboard profileId={profileId} />
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
resolveMiniGameGenerationProgressTickState,
|
||||
} from './PlatformEntryFlowShellImpl';
|
||||
import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
|
||||
|
||||
describe('resolveMiniGameGenerationProgressTickState', () => {
|
||||
test('returns jump hop and wooden fish generation states for progress ticking', () => {
|
||||
const jumpHopState = createMiniGameDraftGenerationState('jump-hop');
|
||||
const woodenFishState = createMiniGameDraftGenerationState('wooden-fish');
|
||||
|
||||
expect(
|
||||
resolveMiniGameGenerationProgressTickState('jump-hop-generating', {
|
||||
'jump-hop': jumpHopState,
|
||||
}),
|
||||
).toBe(jumpHopState);
|
||||
expect(
|
||||
resolveMiniGameGenerationProgressTickState('wooden-fish-generating', {
|
||||
'wooden-fish': woodenFishState,
|
||||
}),
|
||||
).toBe(woodenFishState);
|
||||
});
|
||||
|
||||
test('returns null when the stage does not need generation ticking', () => {
|
||||
expect(
|
||||
resolveMiniGameGenerationProgressTickState('platform', {
|
||||
'jump-hop': createMiniGameDraftGenerationState('jump-hop'),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,10 @@ import type {
|
||||
BabyObjectMatchDraft,
|
||||
CreateBabyObjectMatchDraftRequest,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
JumpHopJumpRequest,
|
||||
JumpHopWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
ExecuteMatch3DActionRequest,
|
||||
@@ -67,6 +70,7 @@ import type {
|
||||
PuzzleAgentSessionSnapshot,
|
||||
SendPuzzleAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { isPuzzleCompileActionReady } from './puzzleDraftGenerationState';
|
||||
import type { PuzzleCreativeTemplateSelection } from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
|
||||
import type {
|
||||
PuzzleRunSnapshot,
|
||||
@@ -127,6 +131,7 @@ import {
|
||||
} from '../../services/authService';
|
||||
import {
|
||||
createBarkBattleDraft,
|
||||
deleteBarkBattleWork,
|
||||
listBarkBattleGallery,
|
||||
listBarkBattleWorks,
|
||||
publishBarkBattleWork,
|
||||
@@ -188,6 +193,7 @@ import {
|
||||
jumpHopClient,
|
||||
type JumpHopGalleryCardResponse,
|
||||
type JumpHopRunResponse,
|
||||
type JumpHopRuntimeRequestOptions,
|
||||
type JumpHopSessionResponse,
|
||||
type JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
@@ -386,6 +392,7 @@ import {
|
||||
resolvePuzzleWorkCoverImageSrc,
|
||||
} from '../custom-world-home/creationWorkShelf';
|
||||
import {
|
||||
buildPlatformPublicGalleryCardKey,
|
||||
isBarkBattleGalleryEntry,
|
||||
isBigFishGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
@@ -462,7 +469,6 @@ import {
|
||||
type PlatformErrorDialogPayload,
|
||||
} from './PlatformErrorDialog';
|
||||
import { PlatformFeedbackView } from './PlatformFeedbackView';
|
||||
import { shouldTickPlatformGenerationProgressClock } from './platformGenerationProgressClock';
|
||||
import {
|
||||
PlatformTaskCompletionDialog,
|
||||
type PlatformTaskCompletionDialogPayload,
|
||||
@@ -513,6 +519,31 @@ type PuzzleBackgroundCompileTask = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type MiniGameGenerationProgressTickStateMap = Partial<
|
||||
Record<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
|
||||
>;
|
||||
|
||||
export function resolveMiniGameGenerationProgressTickState(
|
||||
selectionStage: SelectionStage,
|
||||
states: MiniGameGenerationProgressTickStateMap,
|
||||
) {
|
||||
const stageKindMap: Partial<
|
||||
Record<SelectionStage, MiniGameDraftGenerationKind>
|
||||
> = {
|
||||
'puzzle-generating': 'puzzle',
|
||||
'big-fish-generating': 'big-fish',
|
||||
'square-hole-generating': 'square-hole',
|
||||
'match3d-generating': 'match3d',
|
||||
'baby-object-match-generating': 'baby-object-match',
|
||||
'jump-hop-generating': 'jump-hop',
|
||||
'puzzle-clear-generating': 'puzzle-clear',
|
||||
'wooden-fish-generating': 'wooden-fish',
|
||||
};
|
||||
const kind = stageKindMap[selectionStage];
|
||||
|
||||
return kind ? (states[kind] ?? null) : null;
|
||||
}
|
||||
|
||||
type PuzzleDetailReturnTarget = {
|
||||
tab: PlatformHomeTab;
|
||||
};
|
||||
@@ -574,6 +605,7 @@ type BabyObjectMatchGenerationPhase = 'generating' | 'ready' | 'failed';
|
||||
|
||||
type RecommendRuntimeState = {
|
||||
activeKind: RecommendRuntimeKind | null;
|
||||
barkBattlePublishedConfig: BarkBattlePublishedConfig | null;
|
||||
babyObjectMatchDraft: BabyObjectMatchDraft | null;
|
||||
bigFishRun: BigFishRuntimeSnapshotResponse | null;
|
||||
jumpHopRun: JumpHopRunResponse['run'] | null;
|
||||
@@ -621,11 +653,11 @@ const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
|
||||
'publish_missing_main_chapter',
|
||||
'publish_missing_first_act',
|
||||
]);
|
||||
const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS =
|
||||
const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS: JumpHopRuntimeRequestOptions =
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS;
|
||||
const RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS =
|
||||
const RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS: JumpHopRuntimeRequestOptions =
|
||||
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
|
||||
async function buildRecommendRuntimeGuestOptions() {
|
||||
async function buildRecommendRuntimeGuestOptions(): Promise<JumpHopRuntimeRequestOptions> {
|
||||
const { token } = await ensureRuntimeGuestToken();
|
||||
return {
|
||||
...RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
@@ -640,9 +672,9 @@ function shouldUseRecommendRuntimeGuestAuth(
|
||||
async function buildRecommendRuntimeAuthOptions(
|
||||
authUi: { user?: { id?: string } | null } | null | undefined,
|
||||
embedded?: boolean,
|
||||
) {
|
||||
): Promise<JumpHopRuntimeRequestOptions> {
|
||||
if (!embedded) {
|
||||
return {};
|
||||
return RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
|
||||
}
|
||||
|
||||
if (shouldUseRecommendRuntimeGuestAuth(authUi)) {
|
||||
@@ -662,28 +694,7 @@ function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
|
||||
}
|
||||
|
||||
function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) {
|
||||
const kind = isBigFishGalleryEntry(entry)
|
||||
? 'big-fish'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? 'puzzle'
|
||||
: isPuzzleClearGalleryEntry(entry)
|
||||
? 'puzzle-clear'
|
||||
: 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}`;
|
||||
return buildPlatformPublicGalleryCardKey(entry);
|
||||
}
|
||||
|
||||
function getPlatformRecommendRuntimeKind(
|
||||
@@ -769,7 +780,7 @@ function isRecommendRuntimeReadyForEntry(
|
||||
return Boolean(state.visualNovelRun);
|
||||
}
|
||||
if (expectedKind === 'bark-battle') {
|
||||
return true;
|
||||
return Boolean(state.barkBattlePublishedConfig);
|
||||
}
|
||||
if (expectedKind === 'edutainment') {
|
||||
return Boolean(state.babyObjectMatchDraft);
|
||||
@@ -2084,6 +2095,7 @@ function buildJumpHopPendingSession(
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: item.profileId,
|
||||
themeText: item.themeText || item.workTitle,
|
||||
workTitle: item.workTitle,
|
||||
workDescription: item.workDescription,
|
||||
themeTags: item.themeTags,
|
||||
@@ -2899,6 +2911,7 @@ function buildPendingJumpHopWorks(
|
||||
profileId: `jump-hop-profile-${sessionId}`,
|
||||
ownerUserId: '',
|
||||
sourceSessionId: sessionId,
|
||||
themeText: '跳一跳',
|
||||
workTitle: '跳一跳草稿',
|
||||
workDescription:
|
||||
state.status === 'failed'
|
||||
@@ -3785,6 +3798,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [jumpHopRun, setJumpHopRun] = useState<
|
||||
JumpHopRunResponse['run'] | null
|
||||
>(null);
|
||||
const [jumpHopRuntimeRequestOptions, setJumpHopRuntimeRequestOptions] =
|
||||
useState<JumpHopRuntimeRequestOptions | null>(null);
|
||||
const [jumpHopWork, setJumpHopWork] =
|
||||
useState<JumpHopWorkProfileResponse | null>(null);
|
||||
const [jumpHopGalleryEntries, setJumpHopGalleryEntries] = useState<
|
||||
@@ -5644,30 +5659,27 @@ export function PlatformEntryFlowShellImpl({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeGenerationState =
|
||||
selectionStage === 'puzzle-generating'
|
||||
? puzzleGenerationState
|
||||
: selectionStage === 'match3d-generating'
|
||||
? match3dGenerationState
|
||||
: selectionStage === 'big-fish-generating'
|
||||
? bigFishGenerationState
|
||||
: selectionStage === 'square-hole-generating'
|
||||
? squareHoleGenerationState
|
||||
: selectionStage === 'jump-hop-generating'
|
||||
? jumpHopGenerationState
|
||||
: selectionStage === 'puzzle-clear-generating'
|
||||
? puzzleClearGenerationState
|
||||
: selectionStage === 'wooden-fish-generating'
|
||||
? woodenFishGenerationState
|
||||
: selectionStage === 'baby-object-match-generating'
|
||||
? babyObjectMatchGenerationState
|
||||
: null;
|
||||
const shouldTickProgress = shouldTickPlatformGenerationProgressClock({
|
||||
const activeGenerationState = resolveMiniGameGenerationProgressTickState(
|
||||
selectionStage,
|
||||
generationState: activeGenerationState,
|
||||
visualNovelGenerationStartedAtMs,
|
||||
visualNovelGenerationPhase,
|
||||
});
|
||||
{
|
||||
puzzle: puzzleGenerationState,
|
||||
'big-fish': bigFishGenerationState,
|
||||
'square-hole': squareHoleGenerationState,
|
||||
match3d: match3dGenerationState,
|
||||
'baby-object-match': babyObjectMatchGenerationState,
|
||||
'jump-hop': jumpHopGenerationState,
|
||||
'puzzle-clear': puzzleClearGenerationState,
|
||||
'wooden-fish': woodenFishGenerationState,
|
||||
},
|
||||
);
|
||||
const shouldTickProgress =
|
||||
selectionStage === 'visual-novel-generating'
|
||||
? visualNovelGenerationStartedAtMs != null &&
|
||||
visualNovelGenerationPhase !== 'ready' &&
|
||||
visualNovelGenerationPhase !== 'failed'
|
||||
: activeGenerationState != null &&
|
||||
activeGenerationState.phase !== 'ready' &&
|
||||
activeGenerationState.phase !== 'failed';
|
||||
|
||||
if (!shouldTickProgress) {
|
||||
return undefined;
|
||||
@@ -6505,7 +6517,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
sessionController.setCreationTypeError(errorMessage);
|
||||
setPuzzleCreationError(errorMessage);
|
||||
},
|
||||
onActionComplete: async ({ payload, response, setSession }) => {
|
||||
onActionComplete: async ({ payload, response, session, setSession }) => {
|
||||
setPuzzleOperation(response.operation);
|
||||
setSession(response.session);
|
||||
const formPayload = buildPuzzleFormPayloadFromAction(payload);
|
||||
@@ -6529,6 +6541,47 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
if (payload.action === 'compile_puzzle_draft') {
|
||||
const openResult = selectionStageRef.current === 'puzzle-generating';
|
||||
if (!isPuzzleCompileActionReady(response.session)) {
|
||||
const nextPayload =
|
||||
formPayload ?? buildPuzzleFormPayloadFromSession(response.session);
|
||||
const fallbackGenerationState = createPuzzleDraftGenerationStateFromPayload(
|
||||
nextPayload,
|
||||
response.session,
|
||||
);
|
||||
const nextGenerationState = mergePuzzleSessionProgressIntoGenerationState(
|
||||
puzzleGenerationState ?? fallbackGenerationState,
|
||||
response.session,
|
||||
);
|
||||
activePuzzleGenerationSessionIdRef.current = response.session.sessionId;
|
||||
setSelectionStage('puzzle-generating');
|
||||
markDraftGenerating('puzzle', [
|
||||
response.session.sessionId,
|
||||
buildPuzzleResultWorkId(response.session.sessionId),
|
||||
response.session.publishedProfileId,
|
||||
buildPuzzleResultProfileId(response.session.sessionId),
|
||||
]);
|
||||
markPendingDraftGenerating(
|
||||
'puzzle',
|
||||
response.session.sessionId,
|
||||
buildPendingPuzzleDraftMetadata(nextPayload),
|
||||
);
|
||||
setPuzzleGenerationState(nextGenerationState);
|
||||
setPuzzleBackgroundCompileTasks((current) => {
|
||||
const next = { ...current };
|
||||
if (session.sessionId !== response.session.sessionId) {
|
||||
delete next[session.sessionId];
|
||||
}
|
||||
next[response.session.sessionId] = {
|
||||
session: response.session,
|
||||
payload: nextPayload,
|
||||
generationState: nextGenerationState,
|
||||
error: null,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
void refreshPuzzleShelf();
|
||||
return { openResult: false };
|
||||
}
|
||||
setPuzzleGenerationState((current) =>
|
||||
current
|
||||
? resolveFinishedMiniGameDraftGenerationState(current, 'ready', {
|
||||
@@ -7445,6 +7498,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasRecoverableGeneratedPuzzleDraft(latestSession)) {
|
||||
const payload =
|
||||
puzzleGenerationViewPayload ??
|
||||
buildPuzzleFormPayloadFromSession(latestSession);
|
||||
const generationState =
|
||||
puzzleGenerationViewState ??
|
||||
createPuzzleDraftGenerationStateFromPayload(payload, latestSession);
|
||||
await recoverCompletedPuzzleDraftGeneration({
|
||||
sessionId: latestSession.sessionId,
|
||||
payload,
|
||||
generationState,
|
||||
setSession: setPuzzleSession,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPuzzleSession(latestSession);
|
||||
setPuzzleBackgroundCompileTasks((current) => {
|
||||
const task = current[activePuzzleGenerationSessionId];
|
||||
@@ -7488,6 +7557,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
};
|
||||
}, [
|
||||
activePuzzleGenerationSessionId,
|
||||
puzzleGenerationViewPayload,
|
||||
puzzleGenerationViewState,
|
||||
recoverCompletedPuzzleDraftGeneration,
|
||||
shouldPollPuzzleGenerationSession,
|
||||
setPuzzleSession,
|
||||
]);
|
||||
@@ -7616,6 +7688,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setJumpHopSession(null);
|
||||
setJumpHopWork(null);
|
||||
setJumpHopRun(null);
|
||||
setJumpHopRuntimeRequestOptions(null);
|
||||
setJumpHopGenerationState(null);
|
||||
enterCreateTab();
|
||||
setShowCreationTypeModal(false);
|
||||
@@ -8778,6 +8851,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setJumpHopRuntimeReturnStage('jump-hop-result');
|
||||
setJumpHopGenerationState(null);
|
||||
setJumpHopSession(null);
|
||||
setJumpHopRuntimeRequestOptions(null);
|
||||
setJumpHopError(null);
|
||||
returnToCreationFlowSource();
|
||||
}, [returnToCreationFlowSource]);
|
||||
@@ -9774,20 +9848,26 @@ export function PlatformEntryFlowShellImpl({
|
||||
const executeSquareHoleAction = squareHoleFlow.executeAction;
|
||||
|
||||
const retryMatch3DDraftGeneration = useCallback(() => {
|
||||
if (match3dFormDraftPayload && !match3dSession?.draft?.profileId) {
|
||||
void createMatch3DDraftFromForm(match3dFormDraftPayload);
|
||||
if (match3dSession?.sessionId) {
|
||||
const retryPayload =
|
||||
match3dFormDraftPayload ??
|
||||
buildMatch3DFormPayloadFromSession(match3dSession);
|
||||
void executeMatch3DAction({
|
||||
action: 'match3d_compile_draft',
|
||||
generateClickSound: retryPayload.generateClickSound,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void executeMatch3DAction({
|
||||
action: 'match3d_compile_draft',
|
||||
generateClickSound: match3dFormDraftPayload?.generateClickSound,
|
||||
});
|
||||
if (match3dFormDraftPayload) {
|
||||
void createMatch3DDraftFromForm(match3dFormDraftPayload);
|
||||
return;
|
||||
}
|
||||
}, [
|
||||
createMatch3DDraftFromForm,
|
||||
executeMatch3DAction,
|
||||
match3dFormDraftPayload,
|
||||
match3dSession?.draft?.profileId,
|
||||
match3dSession,
|
||||
]);
|
||||
|
||||
const retrySquareHoleAssetGeneration = useCallback(() => {
|
||||
@@ -9820,6 +9900,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
setJumpHopWork(null);
|
||||
setJumpHopRun(null);
|
||||
setJumpHopRuntimeRequestOptions(null);
|
||||
setJumpHopGenerationState(generationState);
|
||||
setIsJumpHopBusy(true);
|
||||
setSelectionStage('jump-hop-generating');
|
||||
@@ -9834,6 +9915,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
created.session.sessionId,
|
||||
{
|
||||
actionType: 'compile-draft',
|
||||
themeText:
|
||||
payload?.themeText ?? created.session.draft?.themeText,
|
||||
workTitle: payload?.workTitle ?? created.session.draft?.workTitle,
|
||||
workDescription:
|
||||
payload?.workDescription ??
|
||||
@@ -9948,7 +10031,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [compileJumpHopSession, jumpHopSession, setSelectionStage]);
|
||||
|
||||
const regenerateJumpHopAsset = useCallback(
|
||||
async (actionType: 'regenerate-character' | 'regenerate-tiles') => {
|
||||
async (actionType: 'regenerate-tiles') => {
|
||||
if (!jumpHopSession?.sessionId) {
|
||||
setSelectionStage('jump-hop-workspace');
|
||||
return;
|
||||
@@ -9964,6 +10047,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
jumpHopSession.sessionId,
|
||||
{
|
||||
actionType,
|
||||
profileId:
|
||||
jumpHopWork?.summary.profileId ?? jumpHopSession.draft?.profileId,
|
||||
themeText: jumpHopSession.draft?.themeText,
|
||||
workTitle: jumpHopSession.draft?.workTitle,
|
||||
workDescription: jumpHopSession.draft?.workDescription,
|
||||
themeTags: jumpHopSession.draft?.themeTags,
|
||||
@@ -9989,9 +10075,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
} catch (error) {
|
||||
const errorMessage = resolveRpgCreationErrorMessage(
|
||||
error,
|
||||
actionType === 'regenerate-character'
|
||||
? '重新生成跳一跳角色失败。'
|
||||
: '重新生成跳一跳地块失败。',
|
||||
'重新生成跳一跳地块失败。',
|
||||
);
|
||||
setJumpHopError(errorMessage);
|
||||
setJumpHopGenerationState(
|
||||
@@ -10067,7 +10151,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setJumpHopError(null);
|
||||
setJumpHopRuntimeReturnStage('jump-hop-result');
|
||||
try {
|
||||
const response = await jumpHopClient.startRun(profileId);
|
||||
const response = await jumpHopClient.startRun(profileId, {
|
||||
runtimeMode: 'draft',
|
||||
});
|
||||
setJumpHopRun(response.run);
|
||||
setSelectionStage('jump-hop-runtime');
|
||||
} catch (error) {
|
||||
@@ -10098,13 +10184,30 @@ export function PlatformEntryFlowShellImpl({
|
||||
setJumpHopError(null);
|
||||
setJumpHopRuntimeReturnStage(options.returnStage ?? 'work-detail');
|
||||
try {
|
||||
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
|
||||
authUi,
|
||||
options.embedded,
|
||||
const runtimeGuestOptions =
|
||||
options.embedded || shouldUseRecommendRuntimeGuestAuth(authUi)
|
||||
? await buildRecommendRuntimeAuthOptions(authUi, true)
|
||||
: RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
|
||||
setJumpHopRuntimeRequestOptions(
|
||||
runtimeGuestOptions.runtimeGuestToken?.trim()
|
||||
? {
|
||||
runtimeGuestToken: runtimeGuestOptions.runtimeGuestToken,
|
||||
authImpact: runtimeGuestOptions.authImpact,
|
||||
skipAuth: runtimeGuestOptions.skipAuth,
|
||||
skipRefresh: runtimeGuestOptions.skipRefresh,
|
||||
notifyAuthStateChange:
|
||||
runtimeGuestOptions.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized:
|
||||
runtimeGuestOptions.clearAuthOnUnauthorized,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const [detail, runResponse] = await Promise.all([
|
||||
jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null),
|
||||
jumpHopClient.startRun(normalizedProfileId, runtimeGuestOptions),
|
||||
jumpHopClient.startRun(normalizedProfileId, {
|
||||
...runtimeGuestOptions,
|
||||
runtimeMode: 'published',
|
||||
}),
|
||||
]);
|
||||
if (detail?.item) {
|
||||
setJumpHopWork(detail.item);
|
||||
@@ -10142,7 +10245,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsJumpHopBusy(true);
|
||||
setJumpHopError(null);
|
||||
try {
|
||||
const response = await jumpHopClient.restartRun(runId);
|
||||
const response = await jumpHopClient.restartRun(
|
||||
runId,
|
||||
jumpHopRuntimeRequestOptions ?? undefined,
|
||||
);
|
||||
setJumpHopRun(response.run);
|
||||
} catch (error) {
|
||||
setJumpHopError(
|
||||
@@ -10151,16 +10257,29 @@ export function PlatformEntryFlowShellImpl({
|
||||
} finally {
|
||||
setIsJumpHopBusy(false);
|
||||
}
|
||||
}, [jumpHopRun?.runId, startJumpHopTestRunFromProfile]);
|
||||
}, [
|
||||
jumpHopRun?.runId,
|
||||
jumpHopRuntimeRequestOptions,
|
||||
startJumpHopTestRunFromProfile,
|
||||
]);
|
||||
|
||||
const submitJumpHopJumpAction = useCallback(
|
||||
async (payload: { chargeMs: number }) => {
|
||||
async (
|
||||
payload: Pick<
|
||||
JumpHopJumpRequest,
|
||||
'dragDistance' | 'dragVectorX' | 'dragVectorY'
|
||||
>,
|
||||
) => {
|
||||
const runId = jumpHopRun?.runId;
|
||||
if (!runId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await jumpHopClient.submitJump(runId, payload);
|
||||
const response = await jumpHopClient.submitJump(
|
||||
runId,
|
||||
payload,
|
||||
jumpHopRuntimeRequestOptions ?? undefined,
|
||||
);
|
||||
setJumpHopRun(response.run);
|
||||
} catch (error) {
|
||||
setJumpHopError(
|
||||
@@ -10168,7 +10287,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
}
|
||||
},
|
||||
[jumpHopRun?.runId],
|
||||
[jumpHopRun?.runId, jumpHopRuntimeRequestOptions],
|
||||
);
|
||||
|
||||
const compilePuzzleClearSession = useCallback(
|
||||
@@ -11123,15 +11242,25 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
|
||||
const retryPuzzleDraftGeneration = useCallback(() => {
|
||||
if (puzzleFormDraftPayload) {
|
||||
void createPuzzleDraftFromForm(puzzleFormDraftPayload);
|
||||
if (puzzleSession?.sessionId) {
|
||||
const retryPayload =
|
||||
puzzleFormDraftPayload ??
|
||||
buildPuzzleFormPayloadFromSession(puzzleSession);
|
||||
void executePuzzleAction(
|
||||
buildPuzzleCompileActionFromFormPayload(retryPayload),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void executePuzzleAction(
|
||||
buildPuzzleCompileActionFromFormPayload(puzzleFormDraftPayload),
|
||||
);
|
||||
}, [createPuzzleDraftFromForm, executePuzzleAction, puzzleFormDraftPayload]);
|
||||
if (puzzleFormDraftPayload) {
|
||||
void createPuzzleDraftFromForm(puzzleFormDraftPayload);
|
||||
}
|
||||
}, [
|
||||
createPuzzleDraftFromForm,
|
||||
executePuzzleAction,
|
||||
puzzleFormDraftPayload,
|
||||
puzzleSession,
|
||||
]);
|
||||
|
||||
const retryVisualNovelDraftGeneration = useCallback(() => {
|
||||
if (!visualNovelFormDraftPayload) {
|
||||
@@ -12929,6 +13058,154 @@ export function PlatformEntryFlowShellImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const handleDeleteJumpHopWork = useCallback(
|
||||
(work: JumpHopWorkSummaryResponse) => {
|
||||
if (deletingCreationWorkId) {
|
||||
return;
|
||||
}
|
||||
const noticeKeys = collectDraftNoticeKeys('jump-hop', [
|
||||
work.workId,
|
||||
work.profileId,
|
||||
work.sourceSessionId,
|
||||
]);
|
||||
|
||||
requestDeleteCreationWork({
|
||||
id: work.workId,
|
||||
title: work.workTitle || '未命名跳一跳',
|
||||
detail:
|
||||
work.publicationStatus === 'published'
|
||||
? '删除后会从你的作品列表和公开广场中移除。'
|
||||
: '删除后会从你的作品列表中移除。',
|
||||
run: () => {
|
||||
setDeletingCreationWorkId(work.workId);
|
||||
setJumpHopError(null);
|
||||
|
||||
void jumpHopClient
|
||||
.deleteWork(work.profileId)
|
||||
.then((response) => {
|
||||
markDraftNoticeSeen(noticeKeys);
|
||||
setJumpHopWorks(response.items);
|
||||
void refreshJumpHopGallery();
|
||||
})
|
||||
.catch((error) => {
|
||||
setJumpHopError(
|
||||
resolvePuzzleErrorMessage(error, '删除跳一跳作品失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeletingCreationWorkId(null);
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
deletingCreationWorkId,
|
||||
markDraftNoticeSeen,
|
||||
refreshJumpHopGallery,
|
||||
requestDeleteCreationWork,
|
||||
resolvePuzzleErrorMessage,
|
||||
setJumpHopError,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDeleteWoodenFishWork = useCallback(
|
||||
(work: WoodenFishWorkSummaryResponse) => {
|
||||
if (deletingCreationWorkId) {
|
||||
return;
|
||||
}
|
||||
const noticeKeys = collectDraftNoticeKeys('wooden-fish', [
|
||||
work.workId,
|
||||
work.profileId,
|
||||
work.sourceSessionId,
|
||||
]);
|
||||
|
||||
requestDeleteCreationWork({
|
||||
id: work.workId,
|
||||
title: work.workTitle || '未命名敲木鱼',
|
||||
detail:
|
||||
work.publicationStatus === 'published'
|
||||
? '删除后会从你的作品列表和公开广场中移除。'
|
||||
: '删除后会从你的作品列表中移除。',
|
||||
run: () => {
|
||||
setDeletingCreationWorkId(work.workId);
|
||||
setWoodenFishError(null);
|
||||
|
||||
void woodenFishClient
|
||||
.deleteWork(work.profileId)
|
||||
.then((response) => {
|
||||
markDraftNoticeSeen(noticeKeys);
|
||||
setWoodenFishWorks(response.items);
|
||||
void refreshWoodenFishGallery();
|
||||
})
|
||||
.catch((error) => {
|
||||
setWoodenFishError(
|
||||
resolvePuzzleErrorMessage(error, '删除敲木鱼作品失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeletingCreationWorkId(null);
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
deletingCreationWorkId,
|
||||
markDraftNoticeSeen,
|
||||
refreshWoodenFishGallery,
|
||||
requestDeleteCreationWork,
|
||||
resolvePuzzleErrorMessage,
|
||||
setWoodenFishError,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDeleteBarkBattleWork = useCallback(
|
||||
(work: BarkBattleWorkSummary) => {
|
||||
if (deletingCreationWorkId) {
|
||||
return;
|
||||
}
|
||||
const noticeKeys = collectDraftNoticeKeys('bark-battle', [
|
||||
work.workId,
|
||||
work.draftId,
|
||||
]);
|
||||
|
||||
requestDeleteCreationWork({
|
||||
id: work.workId,
|
||||
title: work.title || '未命名汪汪声浪',
|
||||
detail:
|
||||
work.status === 'published'
|
||||
? '删除后会从你的作品列表和公开广场中移除。'
|
||||
: '删除后会从你的作品列表中移除。',
|
||||
run: () => {
|
||||
setDeletingCreationWorkId(work.workId);
|
||||
setBarkBattleError(null);
|
||||
|
||||
void deleteBarkBattleWork(work.workId)
|
||||
.then((response) => {
|
||||
markDraftNoticeSeen(noticeKeys);
|
||||
setBarkBattleWorks(mergeBarkBattleWorksByWorkId(response.items));
|
||||
void refreshBarkBattleGallery();
|
||||
})
|
||||
.catch((error) => {
|
||||
setBarkBattleError(
|
||||
resolveBarkBattleErrorMessage(error, '删除汪汪声浪作品失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeletingCreationWorkId(null);
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
deletingCreationWorkId,
|
||||
markDraftNoticeSeen,
|
||||
refreshBarkBattleGallery,
|
||||
requestDeleteCreationWork,
|
||||
resolveBarkBattleErrorMessage,
|
||||
setBarkBattleError,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDeleteVisualNovelWork = useCallback(
|
||||
(work: VisualNovelWorkSummary) => {
|
||||
if (deletingCreationWorkId) {
|
||||
@@ -15139,6 +15416,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(selectedPublicWorkDetail)) {
|
||||
setPublicWorkDetailError(null);
|
||||
void startJumpHopRunFromProfile(selectedPublicWorkDetail.profileId, {
|
||||
returnStage: 'work-detail',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
runProtectedAction(() => {
|
||||
if (isBigFishGalleryEntry(selectedPublicWorkDetail)) {
|
||||
const work = mapPublicWorkDetailToBigFishWork(selectedPublicWorkDetail);
|
||||
@@ -15699,37 +15984,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
run={jumpHopRun}
|
||||
isBusy={isJumpHopBusy}
|
||||
error={jumpHopError}
|
||||
runtimeRequestOptions={jumpHopRuntimeRequestOptions ?? undefined}
|
||||
onBack={() => {
|
||||
setActiveRecommendRuntimeKind(null);
|
||||
}}
|
||||
onRestart={() => {
|
||||
if (!jumpHopRun?.runId || isJumpHopBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsJumpHopBusy(true);
|
||||
setJumpHopError(null);
|
||||
void jumpHopClient
|
||||
.restartRun(jumpHopRun.runId)
|
||||
.then((response) => {
|
||||
setJumpHopRun(response.run);
|
||||
})
|
||||
.catch((error) => {
|
||||
setJumpHopError(
|
||||
resolveRpgCreationErrorMessage(error, '重新开始跳一跳失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsJumpHopBusy(false);
|
||||
});
|
||||
void restartJumpHopRuntimeRun();
|
||||
}}
|
||||
onJump={async (payload) => {
|
||||
const runId = jumpHopRun?.runId;
|
||||
if (!runId) {
|
||||
throw new Error('跳一跳运行态缺少 runId。');
|
||||
}
|
||||
const response = await jumpHopClient.submitJump(runId, payload);
|
||||
setJumpHopRun(response.run);
|
||||
await submitJumpHopJumpAction(payload);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -15975,6 +16238,30 @@ export function PlatformEntryFlowShellImpl({
|
||||
swapPuzzleClearCardsInRun,
|
||||
]);
|
||||
|
||||
const activeRecommendEntry =
|
||||
activeRecommendEntryKey && !isDesktopLayout
|
||||
? (recommendRuntimeEntries.find(
|
||||
(entry) =>
|
||||
getPlatformPublicGalleryEntryKey(entry) ===
|
||||
activeRecommendEntryKey,
|
||||
) ?? null)
|
||||
: null;
|
||||
const isActiveRecommendRuntimeReady =
|
||||
activeRecommendEntry !== null &&
|
||||
isRecommendRuntimeReadyForEntry(activeRecommendEntry, {
|
||||
activeKind: activeRecommendRuntimeKind,
|
||||
barkBattlePublishedConfig,
|
||||
babyObjectMatchDraft,
|
||||
bigFishRun,
|
||||
jumpHopRun,
|
||||
match3dRun,
|
||||
puzzleRun,
|
||||
puzzleClearRun,
|
||||
squareHoleRun,
|
||||
visualNovelRun,
|
||||
woodenFishRun,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isDesktopLayout ||
|
||||
@@ -15992,26 +16279,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
const activeRecommendEntry = activeRecommendEntryKey
|
||||
? (recommendRuntimeEntries.find(
|
||||
(entry) =>
|
||||
getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey,
|
||||
) ?? null)
|
||||
: null;
|
||||
const isActiveRecommendRuntimeReady =
|
||||
activeRecommendEntry !== null &&
|
||||
isRecommendRuntimeReadyForEntry(activeRecommendEntry, {
|
||||
activeKind: activeRecommendRuntimeKind,
|
||||
babyObjectMatchDraft,
|
||||
bigFishRun,
|
||||
jumpHopRun,
|
||||
match3dRun,
|
||||
puzzleRun,
|
||||
puzzleClearRun,
|
||||
squareHoleRun,
|
||||
visualNovelRun,
|
||||
woodenFishRun,
|
||||
});
|
||||
if (
|
||||
(activeRecommendEntry !== null && isActiveRecommendRuntimeReady) ||
|
||||
isStartingRecommendEntry
|
||||
@@ -16027,9 +16294,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [
|
||||
activeRecommendEntryKey,
|
||||
activeRecommendRuntimeKind,
|
||||
activeRecommendEntry,
|
||||
barkBattlePublishedConfig,
|
||||
babyObjectMatchDraft,
|
||||
bigFishRun,
|
||||
jumpHopRun,
|
||||
isActiveRecommendRuntimeReady,
|
||||
isStartingRecommendEntry,
|
||||
match3dRun,
|
||||
platformBootstrap.isLoadingPlatform,
|
||||
@@ -17316,7 +17586,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
: null
|
||||
}
|
||||
onDeleteJumpHop={null}
|
||||
onDeleteJumpHop={
|
||||
isJumpHopCreationVisible
|
||||
? (item) => {
|
||||
handleDeleteJumpHopWork(item);
|
||||
}
|
||||
: null
|
||||
}
|
||||
onOpenPuzzleClearDetail={
|
||||
isPuzzleClearCreationVisible
|
||||
? (item) => {
|
||||
@@ -17334,7 +17610,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
void openWoodenFishDraft(item);
|
||||
});
|
||||
}}
|
||||
onDeleteWoodenFish={null}
|
||||
onDeleteWoodenFish={(item) => {
|
||||
handleDeleteWoodenFishWork(item);
|
||||
}}
|
||||
match3dItems={match3dShelfItems}
|
||||
onOpenMatch3DDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
@@ -17398,6 +17676,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
openBarkBattleDraft(item);
|
||||
});
|
||||
}}
|
||||
onDeleteBarkBattle={(item) => {
|
||||
handleDeleteBarkBattleWork(item);
|
||||
}}
|
||||
visualNovelItems={visualNovelShelfItems}
|
||||
onOpenVisualNovelDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
@@ -17482,6 +17763,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
|
||||
recommendRuntimeContent={recommendRuntimeContent}
|
||||
activeRecommendEntryKey={activeRecommendEntryKey}
|
||||
isRecommendRuntimeReady={isActiveRecommendRuntimeReady}
|
||||
isStartingRecommendEntry={
|
||||
isStartingRecommendEntry ||
|
||||
isBigFishBusy ||
|
||||
@@ -17847,7 +18129,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
backLabel="返回创作中心"
|
||||
settingActionLabel={null}
|
||||
retryLabel="重新生成草稿"
|
||||
settingTitle="当前玩法信息"
|
||||
settingDescription={null}
|
||||
progressTitle="大鱼吃小鱼草稿生成进度"
|
||||
activeBadgeLabel="草稿生成中"
|
||||
@@ -18253,7 +18534,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
backLabel="返回创作中心"
|
||||
settingActionLabel={null}
|
||||
retryLabel="重新生成草稿"
|
||||
settingTitle="当前宝贝识物信息"
|
||||
settingDescription={null}
|
||||
progressTitle="宝贝识物草稿生成进度"
|
||||
activeBadgeLabel="草稿生成中"
|
||||
@@ -18454,7 +18734,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
backLabel="返回创作中心"
|
||||
settingActionLabel={null}
|
||||
retryLabel="重新生成图片"
|
||||
settingTitle="当前方洞挑战"
|
||||
settingDescription={null}
|
||||
progressTitle="方洞挑战图片生成进度"
|
||||
activeBadgeLabel="图片生成中"
|
||||
@@ -18697,9 +18976,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
}}
|
||||
onStartTestRun={startJumpHopTestRunFromProfile}
|
||||
onPublish={publishJumpHopDraft}
|
||||
onRegenerateCharacter={() => {
|
||||
void regenerateJumpHopAsset('regenerate-character');
|
||||
}}
|
||||
onRegenerateTiles={() => {
|
||||
void regenerateJumpHopAsset('regenerate-tiles');
|
||||
}}
|
||||
@@ -18735,6 +19011,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
profile={jumpHopWork}
|
||||
isBusy={isJumpHopBusy}
|
||||
error={jumpHopError}
|
||||
runtimeRequestOptions={jumpHopRuntimeRequestOptions ?? undefined}
|
||||
onBack={() => {
|
||||
setSelectionStage(jumpHopRuntimeReturnStage);
|
||||
}}
|
||||
@@ -19250,7 +19527,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
backLabel="返回创作中心"
|
||||
settingActionLabel={null}
|
||||
retryLabel="重新生成草稿"
|
||||
settingTitle="当前视觉小说信息"
|
||||
settingDescription={null}
|
||||
progressTitle="视觉小说草稿生成进度"
|
||||
activeBadgeLabel="草稿生成中"
|
||||
@@ -19501,7 +19777,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
backLabel="返回创作中心"
|
||||
settingActionLabel={null}
|
||||
retryLabel="继续生成草稿"
|
||||
settingTitle="当前世界信息"
|
||||
settingDescription={null}
|
||||
progressTitle="世界草稿生成进度"
|
||||
activeBadgeLabel="草稿编译中"
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -13,14 +13,16 @@ import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopWorkDetailResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
JumpHopWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
BabyObjectMatchDraft,
|
||||
CreateBabyObjectMatchDraftRequest,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type {
|
||||
JumpHopWorkDetailResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
@@ -40,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 {
|
||||
@@ -50,6 +53,7 @@ import { ApiClientError } from '../../services/apiClient';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
createBarkBattleDraft,
|
||||
deleteBarkBattleWork,
|
||||
generateAllBarkBattleImageAssets,
|
||||
listBarkBattleGallery,
|
||||
listBarkBattleWorks,
|
||||
@@ -67,6 +71,7 @@ import {
|
||||
submitBigFishInput,
|
||||
} from '../../services/big-fish-runtime';
|
||||
import { listBigFishWorks } from '../../services/big-fish-works';
|
||||
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
|
||||
import {
|
||||
type CreationEntryConfig,
|
||||
fetchCreationEntryConfig,
|
||||
@@ -86,7 +91,6 @@ import {
|
||||
regenerateBabyObjectMatchDraftAssets,
|
||||
saveBabyObjectMatchDraft,
|
||||
} from '../../services/edutainment-baby-object';
|
||||
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
|
||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
|
||||
import {
|
||||
@@ -607,6 +611,24 @@ vi.mock('../../services/puzzle-runtime', () => ({
|
||||
usePuzzleRuntimeProp: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
|
||||
jumpHopClient: {
|
||||
createSession: vi.fn(),
|
||||
deleteWork: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
getGalleryDetail: vi.fn(),
|
||||
getLeaderboard: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
getWorkDetail: vi.fn(),
|
||||
listGallery: vi.fn(),
|
||||
listWorks: vi.fn(),
|
||||
publishWork: vi.fn(),
|
||||
restartRun: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
submitJump: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
|
||||
...rpgEntryLibraryServiceMocks,
|
||||
}));
|
||||
@@ -634,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(),
|
||||
@@ -653,34 +676,24 @@ vi.mock('../../services/edutainment-baby-object', () => ({
|
||||
saveBabyObjectMatchDraft: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
|
||||
jumpHopClient: {
|
||||
createSession: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
getGalleryDetail: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
getWorkDetail: vi.fn(),
|
||||
listGallery: vi.fn(),
|
||||
listWorks: vi.fn(),
|
||||
publishWork: vi.fn(),
|
||||
restartRun: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
submitJump: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
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(),
|
||||
getSession: vi.fn(),
|
||||
getWorkDetail: vi.fn(),
|
||||
listGallery: vi.fn(),
|
||||
listWorks: vi.fn(),
|
||||
listGallery: vi.fn(async () => ({
|
||||
hasMore: false,
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
})),
|
||||
listWorks: vi.fn(async () => ({ items: [] })),
|
||||
publishWork: vi.fn(),
|
||||
restartRun: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -798,22 +811,6 @@ vi.mock('../../services/visual-novel-works', () => ({
|
||||
updateVisualNovelWork: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
|
||||
woodenFishClient: {
|
||||
checkpointRun: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
finishRun: vi.fn(),
|
||||
getGalleryDetail: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
getWorkDetail: vi.fn(),
|
||||
listGallery: vi.fn(),
|
||||
listWorks: vi.fn(),
|
||||
publishWork: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/visual-novel-creation', () => ({
|
||||
compileVisualNovelWorkProfile: vi.fn(),
|
||||
createVisualNovelSession: vi.fn(),
|
||||
@@ -1629,6 +1626,7 @@ function buildMockJumpHopWork(
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId,
|
||||
themeText: '云朵跳台',
|
||||
workTitle: '云端跳台',
|
||||
workDescription: '一路跳到星星。',
|
||||
themeTags: ['云朵', '星空'],
|
||||
@@ -1642,6 +1640,7 @@ function buildMockJumpHopWork(
|
||||
tileAssets,
|
||||
path,
|
||||
coverComposite: 'data:image/png;base64,cover',
|
||||
backButtonAsset: null,
|
||||
generationStatus: 'ready' as const,
|
||||
};
|
||||
|
||||
@@ -1652,6 +1651,7 @@ function buildMockJumpHopWork(
|
||||
profileId,
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'jump-hop-session-1',
|
||||
themeText: draft.themeText,
|
||||
workTitle: draft.workTitle,
|
||||
workDescription: draft.workDescription,
|
||||
themeTags: draft.themeTags,
|
||||
@@ -1671,6 +1671,7 @@ function buildMockJumpHopWork(
|
||||
characterAsset,
|
||||
tileAtlasAsset,
|
||||
tileAssets,
|
||||
backButtonAsset: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2725,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({
|
||||
@@ -2733,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({
|
||||
@@ -2741,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('未找到跳一跳会话'),
|
||||
);
|
||||
@@ -2753,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('未找到敲木鱼会话'),
|
||||
);
|
||||
@@ -4228,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({
|
||||
@@ -4921,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({
|
||||
@@ -5127,7 +5348,7 @@ test('match3d result trial passes generated models into first runtime mount', as
|
||||
await waitFor(() => {
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-profile-draft-1',
|
||||
{},
|
||||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
});
|
||||
expect(
|
||||
@@ -5220,7 +5441,7 @@ test('match3d result trial passes generated 2D image views into first runtime mo
|
||||
await waitFor(() => {
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-profile-draft-2d-1',
|
||||
{},
|
||||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
});
|
||||
expect(
|
||||
@@ -6244,10 +6465,10 @@ test('opening a compiled draft with a missing agent session falls back to draft
|
||||
await waitFor(() => {
|
||||
expect(fallbackDraftPanel.getAttribute('aria-hidden')).toBe('false');
|
||||
expect(
|
||||
within(fallbackDraftPanel).getByText(
|
||||
screen.getAllByText(
|
||||
'这份共创草稿已失效,已为你返回草稿列表,请重新开始创作。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(window.location.search).toBe('');
|
||||
@@ -6363,6 +6584,213 @@ test('logged out public detail gates puzzle start and remix before real actions'
|
||||
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('logged out public jump-hop detail starts runtime without requireAuth', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
const publishedJumpHopWork: JumpHopWorkProfileResponse = {
|
||||
summary: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-public-1',
|
||||
profileId: 'jump-hop-profile-public-12345678',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'jump-hop-session-public-1',
|
||||
themeText: '云上方块',
|
||||
workTitle: '云上方块跳一跳',
|
||||
workDescription: '在云层地块之间连续弹跳。',
|
||||
themeTags: ['云层', '跳跃'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'published',
|
||||
playCount: 3,
|
||||
updatedAt: '2026-05-29T10:00:00.000Z',
|
||||
publishedAt: '2026-05-29T10:00:00.000Z',
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
draft: {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'jump-hop-profile-public-12345678',
|
||||
themeText: '云上方块',
|
||||
workTitle: '云上方块跳一跳',
|
||||
workDescription: '在云层地块之间连续弹跳。',
|
||||
themeTags: ['云层', '跳跃'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
defaultCharacter: {
|
||||
characterId: 'builtin-default',
|
||||
displayName: '默认角色',
|
||||
modelKind: 'builtin-three',
|
||||
bodyColor: '#df7f40',
|
||||
accentColor: '#2563eb',
|
||||
},
|
||||
characterPrompt: '',
|
||||
tilePrompt: '云上方块',
|
||||
endMoodPrompt: null,
|
||||
characterAsset: null,
|
||||
tileAtlasAsset: null,
|
||||
tileAssets: [],
|
||||
path: null,
|
||||
coverComposite: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
path: {
|
||||
seed: 'jump-hop-public-seed',
|
||||
difficulty: 'standard',
|
||||
platforms: [
|
||||
{
|
||||
platformId: 'platform-0',
|
||||
tileType: 'start',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
landingRadius: 0.7,
|
||||
perfectRadius: 0.25,
|
||||
scoreValue: 1,
|
||||
},
|
||||
{
|
||||
platformId: 'platform-1',
|
||||
tileType: 'normal',
|
||||
x: 1,
|
||||
y: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
landingRadius: 0.7,
|
||||
perfectRadius: 0.25,
|
||||
scoreValue: 1,
|
||||
},
|
||||
{
|
||||
platformId: 'platform-2',
|
||||
tileType: 'normal',
|
||||
x: -1,
|
||||
y: 2,
|
||||
width: 1,
|
||||
height: 1,
|
||||
landingRadius: 0.7,
|
||||
perfectRadius: 0.25,
|
||||
scoreValue: 1,
|
||||
},
|
||||
],
|
||||
finishIndex: 2,
|
||||
cameraPreset: 'portrait-top-down',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 1,
|
||||
maxChargeMs: 1800,
|
||||
hitBonus: 0,
|
||||
perfectBonus: 0,
|
||||
},
|
||||
},
|
||||
defaultCharacter: {
|
||||
characterId: 'builtin-default',
|
||||
displayName: '默认角色',
|
||||
modelKind: 'builtin-three',
|
||||
bodyColor: '#df7f40',
|
||||
accentColor: '#2563eb',
|
||||
},
|
||||
characterAsset: {
|
||||
assetId: 'builtin-character',
|
||||
imageSrc: '',
|
||||
imageObjectKey: '',
|
||||
assetObjectId: '',
|
||||
generationProvider: 'builtin',
|
||||
prompt: '',
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
tileAtlasAsset: {
|
||||
assetId: 'tile-atlas-1',
|
||||
imageSrc: '/generated-jump-hop-assets/public/atlas.png',
|
||||
imageObjectKey: 'generated-jump-hop-assets/public/atlas.png',
|
||||
assetObjectId: 'asset-tile-atlas-1',
|
||||
generationProvider: 'gpt-image-2',
|
||||
prompt: '云上方块',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
tileAssets: [],
|
||||
};
|
||||
const publishedJumpHopRun: JumpHopRuntimeRunSnapshotResponse = {
|
||||
runId: 'jump-hop-run-public-1',
|
||||
profileId: publishedJumpHopWork.summary.profileId,
|
||||
ownerUserId: '',
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 0,
|
||||
successfulJumpCount: 0,
|
||||
durationMs: 0,
|
||||
score: 0,
|
||||
combo: 0,
|
||||
path: publishedJumpHopWork.path,
|
||||
lastJump: null,
|
||||
startedAtMs: 1_779_999_000_000,
|
||||
finishedAtMs: null,
|
||||
};
|
||||
|
||||
window.history.replaceState(null, '', '/works/detail?work=JH-12345678');
|
||||
vi.mocked(jumpHopClient.listGallery).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
publicWorkCode: 'JH-12345678',
|
||||
workId: publishedJumpHopWork.summary.workId,
|
||||
profileId: publishedJumpHopWork.summary.profileId,
|
||||
ownerUserId: publishedJumpHopWork.summary.ownerUserId,
|
||||
authorDisplayName: '跳跃作者',
|
||||
themeText: publishedJumpHopWork.summary.themeText,
|
||||
workTitle: publishedJumpHopWork.summary.workTitle,
|
||||
workDescription: publishedJumpHopWork.summary.workDescription,
|
||||
coverImageSrc: null,
|
||||
themeTags: publishedJumpHopWork.summary.themeTags,
|
||||
difficulty: publishedJumpHopWork.summary.difficulty,
|
||||
stylePreset: publishedJumpHopWork.summary.stylePreset,
|
||||
publicationStatus: publishedJumpHopWork.summary.publicationStatus,
|
||||
playCount: publishedJumpHopWork.summary.playCount,
|
||||
updatedAt: publishedJumpHopWork.summary.updatedAt,
|
||||
publishedAt: publishedJumpHopWork.summary.publishedAt,
|
||||
generationStatus: publishedJumpHopWork.summary.generationStatus,
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(jumpHopClient.getWorkDetail).mockResolvedValue({
|
||||
item: publishedJumpHopWork,
|
||||
});
|
||||
vi.mocked(jumpHopClient.startRun).mockResolvedValue({
|
||||
run: publishedJumpHopRun,
|
||||
});
|
||||
vi.mocked(jumpHopClient.getLeaderboard).mockResolvedValue({
|
||||
profileId: publishedJumpHopWork.summary.profileId,
|
||||
items: [],
|
||||
viewerBest: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: () => {},
|
||||
requireAuth,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('详情')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(jumpHopClient.startRun).toHaveBeenCalledWith(
|
||||
publishedJumpHopWork.summary.profileId,
|
||||
expect.objectContaining({
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
runtimeMode: 'published',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(requireAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('owned public puzzle detail edits original draft instead of remixing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const ownedPuzzleWork = {
|
||||
@@ -7823,6 +8251,7 @@ test('direct jump hop result route restores work detail by profile id', async ()
|
||||
profileId: 'jump-hop-profile-restore-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: null,
|
||||
themeText: '恢复后的云端跳台',
|
||||
workTitle: '恢复后的云端跳台',
|
||||
workDescription: '从 profileId 回读完整跳一跳结果。',
|
||||
themeTags: ['云朵'],
|
||||
@@ -8995,7 +9424,7 @@ test('public code search opens a published Match3D work by M3 code and starts ru
|
||||
await waitFor(() => {
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-profile-public-1',
|
||||
{},
|
||||
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
});
|
||||
expect(
|
||||
@@ -9067,7 +9496,7 @@ test('published Match3D runtime receives persisted generated models', async () =
|
||||
await waitFor(() => {
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-profile-model-1',
|
||||
{},
|
||||
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
});
|
||||
expect(
|
||||
@@ -9135,10 +9564,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();
|
||||
});
|
||||
|
||||
@@ -11364,3 +11793,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',
|
||||
@@ -823,6 +873,7 @@ function renderLoggedOutHomeView(
|
||||
| 'recommendRuntimeContent'
|
||||
| 'activeRecommendEntryKey'
|
||||
| 'isStartingRecommendEntry'
|
||||
| 'isRecommendRuntimeReady'
|
||||
| 'recommendRuntimeError'
|
||||
| 'onSelectNextRecommendEntry'
|
||||
| 'onSelectPreviousRecommendEntry'
|
||||
@@ -883,6 +934,7 @@ function renderLoggedOutHomeView(
|
||||
}
|
||||
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
|
||||
isStartingRecommendEntry={overrides.isStartingRecommendEntry}
|
||||
isRecommendRuntimeReady={overrides.isRecommendRuntimeReady}
|
||||
recommendRuntimeError={overrides.recommendRuntimeError}
|
||||
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
|
||||
onSelectPreviousRecommendEntry={
|
||||
@@ -1081,6 +1133,7 @@ afterEach(() => {
|
||||
mockBuildReferralCenter(),
|
||||
);
|
||||
mockGetRpgProfileTasks.mockResolvedValue(mockBuildTaskCenter());
|
||||
mockRefreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token');
|
||||
mockClaimRpgProfileTaskReward.mockResolvedValue({
|
||||
taskId: 'daily_login',
|
||||
dayKey: 20260503,
|
||||
@@ -2447,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 }));
|
||||
|
||||
@@ -2465,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 () => {
|
||||
@@ -2534,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();
|
||||
@@ -2548,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');
|
||||
});
|
||||
|
||||
@@ -2563,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');
|
||||
@@ -2616,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');
|
||||
@@ -2628,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();
|
||||
|
||||
@@ -2668,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();
|
||||
@@ -2805,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);
|
||||
|
||||
@@ -2982,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();
|
||||
@@ -3590,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();
|
||||
@@ -3703,7 +3893,10 @@ test('logged out mobile recommend page renders runtime instead of cover', () =>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
||||
expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
|
||||
expect(
|
||||
document.querySelector('.platform-recommend-runtime-cover'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('加载中...')).toBeNull();
|
||||
expect(
|
||||
document.querySelector('.platform-public-work-card__cover'),
|
||||
).toBeNull();
|
||||
@@ -3712,7 +3905,7 @@ test('logged out mobile recommend page renders runtime instead of cover', () =>
|
||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('mobile recommend loading state is themed instead of hardcoded black', () => {
|
||||
test('mobile recommend startup keeps cover visible without loading copy', () => {
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||
@@ -3720,8 +3913,123 @@ test('mobile recommend loading state is themed instead of hardcoded black', () =
|
||||
recommendRuntimeContent: null,
|
||||
});
|
||||
|
||||
expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
|
||||
expect(screen.getByText('加载中...')).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector('.platform-recommend-runtime-cover'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('加载中...')).toBeNull();
|
||||
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('mobile recommend next level keeps runtime visual stable when active work changes', async () => {
|
||||
const animationCallbacks: FrameRequestCallback[] = [];
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn((callback: FrameRequestCallback) => {
|
||||
animationCallbacks.push(callback);
|
||||
return animationCallbacks.length;
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
const firstEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-feed-1',
|
||||
profileId: 'puzzle-profile-feed-1',
|
||||
ownerUserId: 'user-feed-1',
|
||||
publicWorkCode: 'PZ-FEED1',
|
||||
worldName: '当前拼图',
|
||||
coverImageSrc: 'current-cover.png',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
const similarEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-similar-1',
|
||||
profileId: 'puzzle-profile-similar-1',
|
||||
ownerUserId: 'user-feed-2',
|
||||
publicWorkCode: 'PZ-SIMILAR1',
|
||||
worldName: '相似拼图',
|
||||
coverImageSrc: 'similar-cover.png',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const { rerender } = renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [firstEntry, similarEntry],
|
||||
activeRecommendEntryKey: 'puzzle:user-feed-1:puzzle-profile-feed-1',
|
||||
isRecommendRuntimeReady: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
animationCallbacks.splice(0).forEach((callback) => callback(16));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('.platform-recommend-runtime-cover')?.className,
|
||||
).toContain('platform-recommend-runtime-cover--hidden');
|
||||
});
|
||||
|
||||
rerender(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: vi.fn(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab="home"
|
||||
isDesktopLayout={false}
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
saveEntries={[]}
|
||||
saveError={null}
|
||||
featuredEntries={[]}
|
||||
latestEntries={[firstEntry, similarEntry]}
|
||||
myEntries={[]}
|
||||
historyEntries={[]}
|
||||
profileDashboard={null}
|
||||
isLoadingPlatform={false}
|
||||
isLoadingDashboard={false}
|
||||
isResumingSaveWorldKey={null}
|
||||
platformError={null}
|
||||
dashboardError={null}
|
||||
onContinueGame={vi.fn()}
|
||||
onResumeSave={vi.fn()}
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={vi.fn()}
|
||||
recommendRuntimeContent={<div data-testid="recommend-runtime" />}
|
||||
activeRecommendEntryKey="puzzle:user-feed-2:puzzle-profile-similar-1"
|
||||
isRecommendRuntimeReady
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
const rail = document.querySelector(
|
||||
'.platform-recommend-swipe-rail',
|
||||
) as HTMLElement | null;
|
||||
expect(rail?.className).toContain('platform-recommend-swipe-rail--settled');
|
||||
expect(rail?.style.transform).toBe('translate3d(0, 0px, 0)');
|
||||
expect(screen.getByLabelText('相似拼图 作品信息')).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector('.platform-recommend-runtime-cover')?.className,
|
||||
).toContain('platform-recommend-runtime-cover--hidden');
|
||||
});
|
||||
|
||||
test('logged in recommend runtime preloads adjacent work previews and drag switches like video feed', () => {
|
||||
|
||||
@@ -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,
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
type CSSProperties,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -80,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,
|
||||
@@ -136,6 +137,7 @@ import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntry
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
|
||||
import {
|
||||
buildPlatformPublicGalleryCardKey,
|
||||
buildPlatformWorldDisplayTags,
|
||||
describePlatformThemeLabel,
|
||||
formatPlatformWorkDisplayName,
|
||||
@@ -153,8 +155,8 @@ import {
|
||||
isWoodenFishGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformWorldCardLike,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
resolvePlatformWorldFallbackCoverImage,
|
||||
@@ -196,6 +198,7 @@ export interface RpgEntryHomeViewProps {
|
||||
recommendRuntimeContent?: ReactNode;
|
||||
activeRecommendEntryKey?: string | null;
|
||||
isStartingRecommendEntry?: boolean;
|
||||
isRecommendRuntimeReady?: boolean;
|
||||
recommendRuntimeError?: string | null;
|
||||
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
@@ -247,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']);
|
||||
@@ -302,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,
|
||||
@@ -318,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 = {
|
||||
@@ -947,6 +955,115 @@ function RecommendRuntimePreviewCard({
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendRuntimeCover({
|
||||
entry,
|
||||
className = '',
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
className?: string;
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-recommend-runtime-cover ${className}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{coverImage || fallbackCoverImage ? (
|
||||
<PlatformWorkCoverArtwork
|
||||
entry={entry}
|
||||
imageSrc={coverImage}
|
||||
fallbackSrc={fallbackCoverImage}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_22%_18%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.05),rgba(0,0,0,0.34))]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendRuntimeMountedProbe({
|
||||
onMounted,
|
||||
}: {
|
||||
onMounted: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const animationFrameId = window.requestAnimationFrame(onMounted);
|
||||
return () => window.cancelAnimationFrame(animationFrameId);
|
||||
}, [onMounted]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function RecommendRuntimeVisual({
|
||||
entry,
|
||||
runtimeContent,
|
||||
isStarting,
|
||||
isRuntimeReady,
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
runtimeContent?: ReactNode;
|
||||
isStarting: boolean;
|
||||
isRuntimeReady: boolean;
|
||||
}) {
|
||||
const [isRuntimeMounted, setIsRuntimeMounted] = useState(false);
|
||||
const activeEntryKey = buildPublicGalleryCardKey(entry);
|
||||
const previousEntryKeyRef = useRef(activeEntryKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousEntryKeyRef.current === activeEntryKey) {
|
||||
return;
|
||||
}
|
||||
previousEntryKeyRef.current = activeEntryKey;
|
||||
setIsRuntimeMounted((currentValue) => {
|
||||
// 中文注释:拼图推荐流“下一关”会在同一个 run 内切到相似作品;
|
||||
// 此时只更新作品信息和分享基准,不应重显封面造成运行态闪跳。
|
||||
if (currentValue && !isStarting && isRuntimeReady) {
|
||||
return currentValue;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [activeEntryKey, isRuntimeReady, isStarting]);
|
||||
|
||||
const handleRuntimeMounted = useCallback(() => {
|
||||
if (!isStarting && isRuntimeReady) {
|
||||
setIsRuntimeMounted(true);
|
||||
}
|
||||
}, [isRuntimeReady, isStarting]);
|
||||
|
||||
const shouldShowCover =
|
||||
!runtimeContent || isStarting || !isRuntimeReady || !isRuntimeMounted;
|
||||
|
||||
return (
|
||||
<div className="platform-recommend-runtime-visual">
|
||||
{runtimeContent ? (
|
||||
<Suspense fallback={null}>
|
||||
<div
|
||||
className="platform-recommend-runtime-viewport"
|
||||
aria-hidden={shouldShowCover}
|
||||
>
|
||||
{runtimeContent}
|
||||
</div>
|
||||
<RecommendRuntimeMountedProbe
|
||||
key={activeEntryKey}
|
||||
onMounted={handleRuntimeMounted}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
<RecommendRuntimeCover
|
||||
entry={entry}
|
||||
className={
|
||||
shouldShowCover ? '' : 'platform-recommend-runtime-cover--hidden'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendSwipeCard({
|
||||
entry,
|
||||
authorAvatarUrl,
|
||||
@@ -1802,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({
|
||||
@@ -2400,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 ? (
|
||||
@@ -2410,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>
|
||||
@@ -2449,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 ? (
|
||||
@@ -2458,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}
|
||||
@@ -2485,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>
|
||||
@@ -4027,6 +4129,7 @@ export function RpgEntryHomeView({
|
||||
recommendRuntimeContent,
|
||||
activeRecommendEntryKey = null,
|
||||
isStartingRecommendEntry = false,
|
||||
isRecommendRuntimeReady = false,
|
||||
recommendRuntimeError = null,
|
||||
onSelectNextRecommendEntry,
|
||||
onSelectPreviousRecommendEntry,
|
||||
@@ -5022,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);
|
||||
@@ -5691,10 +5828,6 @@ export function RpgEntryHomeView({
|
||||
{recommendRuntimeError}
|
||||
</button>
|
||||
</section>
|
||||
) : isStartingRecommendEntry ? (
|
||||
<section className="platform-recommend-runtime-panel">
|
||||
<div className="platform-recommend-runtime-state">加载中...</div>
|
||||
</section>
|
||||
) : activeRecommendEntry ? (
|
||||
<div
|
||||
ref={recommendCardStageRef}
|
||||
@@ -5736,9 +5869,12 @@ export function RpgEntryHomeView({
|
||||
)}
|
||||
isActive
|
||||
visual={
|
||||
<div className="platform-recommend-runtime-viewport">
|
||||
{recommendRuntimeContent}
|
||||
</div>
|
||||
<RecommendRuntimeVisual
|
||||
entry={activeRecommendEntry}
|
||||
runtimeContent={recommendRuntimeContent}
|
||||
isStarting={isStartingRecommendEntry}
|
||||
isRuntimeReady={isRecommendRuntimeReady}
|
||||
/>
|
||||
}
|
||||
onDragPointerDown={beginRecommendDrag}
|
||||
onDragPointerMove={moveRecommendDrag}
|
||||
@@ -6324,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
|
||||
@@ -6333,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"
|
||||
@@ -6365,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>
|
||||
@@ -6397,7 +6533,7 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
label="累计游戏时长"
|
||||
label="累计游玩"
|
||||
value="暂不可用"
|
||||
icon={Clock3}
|
||||
imageSrc={profileClockImage}
|
||||
@@ -6405,7 +6541,7 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playedWorks"
|
||||
label="已玩游戏数量"
|
||||
label="已玩游戏"
|
||||
value="暂不可用"
|
||||
icon={BookOpen}
|
||||
imageSrc={profileGamepadImage}
|
||||
@@ -6424,7 +6560,7 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
label="累计游戏时长"
|
||||
label="累计游玩"
|
||||
value={totalPlayTime}
|
||||
icon={Clock3}
|
||||
imageSrc={profileClockImage}
|
||||
@@ -6432,7 +6568,7 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playedWorks"
|
||||
label="已玩游戏数量"
|
||||
label="已玩游戏"
|
||||
value={`${formatDashboardCount(playedWorkCount)}个`}
|
||||
icon={BookOpen}
|
||||
imageSrc={profileGamepadImage}
|
||||
@@ -6452,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>
|
||||
@@ -6479,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
|
||||
@@ -6505,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}
|
||||
@@ -6521,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}
|
||||
|
||||
@@ -268,7 +268,7 @@ test('resolves public work author from display name and public user code before
|
||||
expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe('敲木鱼玩家');
|
||||
});
|
||||
|
||||
test('public work author display hides phone masks and public user codes on cards', () => {
|
||||
test('public work author display keeps phone masks and hides bare public user codes on cards', () => {
|
||||
const card = mapWoodenFishWorkToPlatformGalleryCard({
|
||||
publicWorkCode: 'WF-AUTHOR2',
|
||||
workId: 'wooden-fish-work-author-mask',
|
||||
@@ -294,8 +294,18 @@ test('public work author display hides phone masks and public user codes on card
|
||||
displayName: '158****3533',
|
||||
avatarUrl: null,
|
||||
}),
|
||||
).toBe('玩家');
|
||||
expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe('玩家');
|
||||
).toBe('158****3533');
|
||||
expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe(
|
||||
'158****3533 · SY-00000003',
|
||||
);
|
||||
|
||||
const publicCodeOnlyCard = {
|
||||
...card,
|
||||
authorDisplayName: 'SY-00000003',
|
||||
};
|
||||
expect(resolvePlatformWorkAuthorDisplayName(publicCodeOnlyCard, null)).toBe(
|
||||
'玩家',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps baby object match public card code and template label intact', () => {
|
||||
|
||||
@@ -397,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 {
|
||||
@@ -987,9 +1012,6 @@ function normalizePlatformPublicAuthorName(value: string | null | undefined) {
|
||||
}
|
||||
|
||||
const compact = normalized.replace(/\s+/gu, '');
|
||||
if (/^\d+\*+\d+(?:[·.-]?SY-\d+)?$/iu.test(compact)) {
|
||||
return '';
|
||||
}
|
||||
if (/^SY-\d+$/iu.test(compact)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -111,53 +111,11 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
|
||||
resultStage: 'jump-hop-result',
|
||||
fields: [
|
||||
{
|
||||
id: 'workTitle',
|
||||
id: 'themeText',
|
||||
kind: 'text',
|
||||
label: '作品标题',
|
||||
label: '主题',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'workDescription',
|
||||
kind: 'text',
|
||||
label: '作品简介',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'themeTags',
|
||||
kind: 'text',
|
||||
label: '主题标签',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'difficulty',
|
||||
kind: 'select',
|
||||
label: '难度',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'stylePreset',
|
||||
kind: 'select',
|
||||
label: '风格',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'characterPrompt',
|
||||
kind: 'text',
|
||||
label: '角色提示词',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'tilePrompt',
|
||||
kind: 'text',
|
||||
label: '地块提示词',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'endMoodPrompt',
|
||||
kind: 'text',
|
||||
label: '终点氛围',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
'wooden-fish': {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ function createSessionResponse(): JumpHopSessionResponse {
|
||||
};
|
||||
}
|
||||
|
||||
test('jump hop workspace submits structured payload after required fields are filled', async () => {
|
||||
test('jump hop workspace submits theme payload after required field is filled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitted = vi.fn();
|
||||
const sessionResponse = createSessionResponse();
|
||||
@@ -46,14 +46,11 @@ test('jump hop workspace submits structured payload after required fields are fi
|
||||
const submitButton = screen.getByRole('button', { name: '生成' });
|
||||
expect(submitButton).toHaveProperty('disabled', true);
|
||||
|
||||
await user.type(screen.getByLabelText('作品标题'), '云朵跳台');
|
||||
await user.type(screen.getByLabelText('作品简介'), '在云端一路跳到星星。');
|
||||
await user.type(screen.getByLabelText('主题标签'), '云朵 星星');
|
||||
await user.selectOptions(screen.getByLabelText('难度'), 'standard');
|
||||
await user.selectOptions(screen.getByLabelText('风格'), 'paper-toy');
|
||||
await user.type(screen.getByLabelText('角色提示词'), '一只纸片小兔');
|
||||
await user.type(screen.getByLabelText('地块提示词'), '柔软云朵平台');
|
||||
await user.type(screen.getByLabelText('终点氛围'), '星光门');
|
||||
expect(screen.getByLabelText('主题')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('作品标题')).toBeNull();
|
||||
expect(screen.queryByLabelText('角色提示词')).toBeNull();
|
||||
|
||||
await user.type(screen.getByLabelText('主题'), '云朵跳台');
|
||||
|
||||
expect(submitButton).toHaveProperty('disabled', false);
|
||||
await user.click(submitButton);
|
||||
@@ -61,21 +58,22 @@ test('jump hop workspace submits structured payload after required fields are fi
|
||||
await waitFor(() => {
|
||||
expect(mockCreateSession).toHaveBeenCalledWith({
|
||||
templateId: 'jump-hop',
|
||||
workTitle: '云朵跳台',
|
||||
workDescription: '在云端一路跳到星星。',
|
||||
themeTags: ['云朵', '星星'],
|
||||
themeText: '云朵跳台',
|
||||
workTitle: '云朵跳台跳一跳',
|
||||
workDescription: '云朵跳台主题的俯视角跳跃作品',
|
||||
themeTags: ['云朵跳台', '跳一跳', '休闲'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
characterPrompt: '一只纸片小兔',
|
||||
tilePrompt: '柔软云朵平台',
|
||||
endMoodPrompt: '星光门',
|
||||
stylePreset: 'minimal-blocks',
|
||||
characterPrompt: '内置默认 3D 角色',
|
||||
tilePrompt: '云朵跳台主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
|
||||
endMoodPrompt: null,
|
||||
});
|
||||
});
|
||||
expect(onSubmitted).toHaveBeenCalledWith(
|
||||
sessionResponse,
|
||||
expect.objectContaining({
|
||||
templateId: 'jump-hop',
|
||||
workTitle: '云朵跳台',
|
||||
themeText: '云朵跳台',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,7 @@ import { ArrowLeft, Loader2, Send } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
JumpHopDifficulty,
|
||||
JumpHopSessionResponse,
|
||||
JumpHopStylePreset,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
} from '../../../../packages/shared/src/contracts/jumpHop';
|
||||
import { jumpHopClient } from '../../../services/jump-hop/jumpHopClient';
|
||||
@@ -22,27 +20,31 @@ type JumpHopCreationWorkspaceProps = {
|
||||
};
|
||||
|
||||
type JumpHopWorkspaceFormState = {
|
||||
workTitle: string;
|
||||
workDescription: string;
|
||||
themeTags: string;
|
||||
difficulty: JumpHopDifficulty;
|
||||
stylePreset: JumpHopStylePreset;
|
||||
characterPrompt: string;
|
||||
tilePrompt: string;
|
||||
endMoodPrompt: string;
|
||||
themeText: string;
|
||||
};
|
||||
|
||||
const DEFAULT_FORM_STATE: JumpHopWorkspaceFormState = {
|
||||
workTitle: '',
|
||||
workDescription: '',
|
||||
themeTags: '',
|
||||
difficulty: 'easy',
|
||||
stylePreset: 'minimal-blocks',
|
||||
characterPrompt: '',
|
||||
tilePrompt: '',
|
||||
endMoodPrompt: '',
|
||||
themeText: '',
|
||||
};
|
||||
|
||||
function buildJumpHopWorkspacePayload(
|
||||
formState: JumpHopWorkspaceFormState,
|
||||
): JumpHopWorkspaceCreateRequest {
|
||||
const themeText = formState.themeText.trim();
|
||||
return {
|
||||
templateId: 'jump-hop',
|
||||
themeText,
|
||||
workTitle: `${themeText}跳一跳`,
|
||||
workDescription: `${themeText}主题的俯视角跳跃作品`,
|
||||
themeTags: [themeText, '跳一跳', '休闲'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
characterPrompt: '内置默认 3D 角色',
|
||||
tilePrompt: `${themeText}主题的正面30度视角主题物体图集,物体本身作为跳跃落点`,
|
||||
endMoodPrompt: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function JumpHopCreationWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
@@ -56,14 +58,7 @@ export function JumpHopCreationWorkspace({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
formState.workTitle.trim() &&
|
||||
formState.workDescription.trim() &&
|
||||
formState.themeTags.trim() &&
|
||||
formState.characterPrompt.trim() &&
|
||||
formState.tilePrompt.trim(),
|
||||
),
|
||||
() => Boolean(formState.themeText.trim()),
|
||||
[formState],
|
||||
);
|
||||
|
||||
@@ -77,20 +72,7 @@ export function JumpHopCreationWorkspace({
|
||||
setLocalError(null);
|
||||
|
||||
try {
|
||||
const payload: JumpHopWorkspaceCreateRequest = {
|
||||
templateId: 'jump-hop',
|
||||
workTitle: formState.workTitle.trim(),
|
||||
workDescription: formState.workDescription.trim(),
|
||||
themeTags: formState.themeTags
|
||||
.split(/[,,、\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
difficulty: formState.difficulty,
|
||||
stylePreset: formState.stylePreset,
|
||||
characterPrompt: formState.characterPrompt.trim(),
|
||||
tilePrompt: formState.tilePrompt.trim(),
|
||||
endMoodPrompt: formState.endMoodPrompt.trim() || null,
|
||||
};
|
||||
const payload = buildJumpHopWorkspacePayload(formState);
|
||||
const response = await jumpHopClient.createSession(payload);
|
||||
onSubmitted(response, payload);
|
||||
} catch (caughtError) {
|
||||
@@ -124,143 +106,22 @@ export function JumpHopCreationWorkspace({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-3">
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品标题
|
||||
主题
|
||||
</span>
|
||||
<input
|
||||
value={formState.workTitle}
|
||||
value={formState.themeText}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workTitle: event.target.value,
|
||||
themeText: 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 sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品简介
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.workDescription}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
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 sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
主题标签
|
||||
</span>
|
||||
<input
|
||||
value={formState.themeTags}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
themeTags: 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>
|
||||
<select
|
||||
value={formState.difficulty}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
difficulty: event.target.value as JumpHopDifficulty,
|
||||
}))
|
||||
}
|
||||
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"
|
||||
>
|
||||
<option value="easy">easy</option>
|
||||
<option value="standard">standard</option>
|
||||
<option value="advanced">advanced</option>
|
||||
<option value="challenge">challenge</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
风格
|
||||
</span>
|
||||
<select
|
||||
value={formState.stylePreset}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
stylePreset: event.target.value as JumpHopStylePreset,
|
||||
}))
|
||||
}
|
||||
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"
|
||||
>
|
||||
<option value="minimal-blocks">minimal-blocks</option>
|
||||
<option value="paper-toy">paper-toy</option>
|
||||
<option value="neon-glass">neon-glass</option>
|
||||
<option value="forest-stone">forest-stone</option>
|
||||
<option value="future-metal">future-metal</option>
|
||||
<option value="custom">custom</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
角色提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.characterPrompt}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
characterPrompt: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
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 sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
地块提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.tilePrompt}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
tilePrompt: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
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 sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
终点氛围
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.endMoodPrompt}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
endMoodPrompt: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{localError || error ? (
|
||||
|
||||
@@ -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(
|
||||
|
||||
164
src/index.css
164
src/index.css
@@ -5262,6 +5262,31 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.platform-recommend-runtime-visual {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--platform-recommend-runtime-fill);
|
||||
}
|
||||
|
||||
.platform-recommend-runtime-cover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
overflow: hidden;
|
||||
background: var(--platform-recommend-runtime-fill);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transition: opacity 420ms ease;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.platform-recommend-runtime-cover--hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.platform-recommend-swipe-stage {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
@@ -6162,15 +6187,15 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-profile-page {
|
||||
gap: 0.75rem;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.platform-profile-header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 1.05rem 0.95rem 0.9rem;
|
||||
padding: 0.95rem 0.9rem 0.78rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.62);
|
||||
border-radius: 1.8rem;
|
||||
border-radius: 1.55rem;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.96),
|
||||
@@ -6208,9 +6233,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: 0.62rem;
|
||||
min-width: 0;
|
||||
padding-top: 0.2rem;
|
||||
padding-top: 0.1rem;
|
||||
padding-right: 4.25rem;
|
||||
}
|
||||
|
||||
@@ -6218,8 +6243,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.85rem;
|
||||
height: 1.85rem;
|
||||
width: 1.45rem;
|
||||
height: 1.45rem;
|
||||
border: 1px solid rgba(210, 185, 166, 0.7);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
@@ -6244,10 +6269,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
min-height: 6.7rem;
|
||||
padding: 1rem 1rem 1rem 0.95rem;
|
||||
min-height: 5.9rem;
|
||||
padding: 0.82rem 0.9rem 0.82rem 0.85rem;
|
||||
border: 0;
|
||||
border-radius: 1.55rem;
|
||||
background: linear-gradient(135deg, #eaa06a, #cf7a4a 58%, #b55c3b);
|
||||
@@ -6260,8 +6285,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
width: 3.45rem;
|
||||
height: 3.45rem;
|
||||
flex: none;
|
||||
border-radius: 1.1rem;
|
||||
background: rgba(255, 245, 233, 0.26);
|
||||
@@ -6269,13 +6294,13 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-profile-membership-card__crown {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
}
|
||||
|
||||
.platform-profile-membership-card__action {
|
||||
flex: none;
|
||||
padding: 0.92rem 1.05rem;
|
||||
padding: 0.66rem 0.86rem;
|
||||
border: 1px solid rgba(255, 250, 244, 0.88);
|
||||
border-radius: 9999px;
|
||||
color: white;
|
||||
@@ -6295,7 +6320,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-profile-stats-panel {
|
||||
padding: 1rem 0.9rem;
|
||||
padding: 0.78rem 0.72rem;
|
||||
}
|
||||
|
||||
.platform-profile-stat-card {
|
||||
@@ -6310,8 +6335,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
flex: none;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 243, 230, 0.9);
|
||||
@@ -6347,10 +6372,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
.platform-profile-daily-task-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
min-height: 8rem;
|
||||
padding: 1rem 1rem 1rem 1.05rem;
|
||||
min-height: 6.8rem;
|
||||
padding: 0.82rem 0.9rem 0.82rem 0.95rem;
|
||||
border: 1px solid rgba(235, 221, 208, 0.82);
|
||||
border-radius: 1.55rem;
|
||||
background: rgba(255, 250, 246, 0.9);
|
||||
@@ -6360,7 +6385,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
|
||||
.platform-profile-daily-task-card__track {
|
||||
display: inline-flex;
|
||||
width: 9rem;
|
||||
width: min(8rem, 42vw);
|
||||
height: 0.45rem;
|
||||
overflow: hidden;
|
||||
border-radius: 9999px;
|
||||
@@ -6375,31 +6400,20 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-profile-daily-task-card__mascot {
|
||||
width: 7.4rem;
|
||||
width: 6.3rem;
|
||||
height: auto;
|
||||
align-self: end;
|
||||
margin-bottom: -0.2rem;
|
||||
}
|
||||
|
||||
.platform-profile-daily-task-card__action {
|
||||
flex: none;
|
||||
padding: 0.85rem 1.15rem;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(135deg, #f08b44, #e56a27);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 12px 24px rgba(229, 106, 39, 0.24);
|
||||
}
|
||||
|
||||
.platform-profile-shortcut-panel {
|
||||
padding: 0.95rem 0.85rem 1rem;
|
||||
padding: 0.78rem 0.68rem 0.82rem;
|
||||
}
|
||||
|
||||
.platform-profile-shortcut-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.25rem;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.platform-profile-shortcut-button {
|
||||
@@ -6412,8 +6426,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.65rem;
|
||||
height: 2.65rem;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
@@ -6450,7 +6464,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-profile-settings-panel {
|
||||
padding: 0.2rem 0;
|
||||
padding: 0.1rem 0;
|
||||
}
|
||||
|
||||
.platform-profile-settings-row {
|
||||
@@ -6466,8 +6480,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
width: 2.05rem;
|
||||
height: 2.05rem;
|
||||
flex: none;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 245, 234, 0.95);
|
||||
@@ -6514,12 +6528,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.platform-profile-page {
|
||||
gap: 0.82rem;
|
||||
gap: 0.68rem;
|
||||
}
|
||||
|
||||
.platform-profile-header {
|
||||
padding: 0.95rem 0.85rem 0.82rem;
|
||||
border-radius: 1.4rem;
|
||||
padding: 0.82rem 0.78rem 0.72rem;
|
||||
border-radius: 1.22rem;
|
||||
}
|
||||
|
||||
.platform-profile-header__identity {
|
||||
@@ -6529,7 +6543,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
|
||||
.platform-profile-header__identity-row {
|
||||
align-items: flex-start;
|
||||
gap: 0.78rem;
|
||||
gap: 0.68rem;
|
||||
}
|
||||
|
||||
.platform-profile-header__text {
|
||||
@@ -6538,12 +6552,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-profile-header__name {
|
||||
font-size: clamp(1rem, 4.8vw, 1.2rem);
|
||||
font-size: clamp(0.98rem, 4.4vw, 1.12rem);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.platform-profile-header__code {
|
||||
margin-top: 0.55rem;
|
||||
margin-top: 0.42rem;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -6560,28 +6574,28 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-profile-membership-card {
|
||||
min-height: 5.85rem;
|
||||
padding: 0.78rem 0.82rem;
|
||||
gap: 0.72rem;
|
||||
min-height: 5.25rem;
|
||||
padding: 0.66rem 0.72rem;
|
||||
gap: 0.62rem;
|
||||
}
|
||||
|
||||
.platform-profile-membership-card__badge {
|
||||
width: 3.2rem;
|
||||
height: 3.2rem;
|
||||
width: 2.85rem;
|
||||
height: 2.85rem;
|
||||
}
|
||||
|
||||
.platform-profile-membership-card__title {
|
||||
font-size: 1rem;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.platform-profile-membership-card__subtitle {
|
||||
margin-top: 0.4rem;
|
||||
font-size: 12px;
|
||||
margin-top: 0.3rem;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.platform-profile-membership-card__action {
|
||||
padding: 0.64rem 0.78rem;
|
||||
padding: 0.5rem 0.62rem;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -6593,7 +6607,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-profile-stats-panel {
|
||||
padding: 0.72rem 0.66rem;
|
||||
padding: 0.62rem 0.56rem;
|
||||
}
|
||||
|
||||
.platform-profile-stats-grid {
|
||||
@@ -6601,19 +6615,19 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-profile-stat-card {
|
||||
min-height: 4.95rem;
|
||||
min-height: 4.45rem;
|
||||
align-items: center;
|
||||
gap: 0.56rem;
|
||||
padding: 0.55rem 0.42rem;
|
||||
padding: 0.48rem 0.34rem;
|
||||
}
|
||||
|
||||
.platform-profile-stat-card__icon {
|
||||
width: 1.95rem;
|
||||
height: 1.95rem;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.platform-profile-stat-card__value {
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.08;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -6623,7 +6637,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
|
||||
.platform-profile-stat-card__label {
|
||||
margin-top: 0.22rem;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
line-height: 1.18;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -6632,18 +6646,18 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-profile-daily-task-card {
|
||||
min-height: 6.55rem;
|
||||
padding: 0.8rem 0.8rem 0.8rem 0.86rem;
|
||||
min-height: 5.75rem;
|
||||
padding: 0.68rem 0.72rem 0.68rem 0.76rem;
|
||||
border-radius: 1.12rem;
|
||||
gap: 0.7rem;
|
||||
gap: 0.58rem;
|
||||
}
|
||||
|
||||
.platform-profile-daily-task-card__track {
|
||||
width: min(7rem, 52vw);
|
||||
width: min(7.8rem, 48vw);
|
||||
}
|
||||
|
||||
.platform-profile-daily-task-card__mascot {
|
||||
width: min(5.2rem, 24vw);
|
||||
width: min(4.6rem, 21vw);
|
||||
}
|
||||
|
||||
.platform-profile-daily-task-card__title {
|
||||
@@ -6666,17 +6680,17 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-profile-shortcut-panel {
|
||||
padding: 0.78rem 0.66rem 0.8rem;
|
||||
padding: 0.64rem 0.54rem 0.68rem;
|
||||
}
|
||||
|
||||
.platform-profile-shortcut-grid {
|
||||
gap: 0.12rem;
|
||||
gap: 0.08rem;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.platform-profile-shortcut-button {
|
||||
min-height: 4.35rem;
|
||||
padding: 0.48rem 0.08rem 0.52rem;
|
||||
min-height: 4rem;
|
||||
padding: 0.42rem 0.06rem 0.44rem;
|
||||
}
|
||||
|
||||
.platform-profile-shortcut-button__label {
|
||||
@@ -6698,8 +6712,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-profile-shortcut-button__icon {
|
||||
width: 2.12rem;
|
||||
height: 2.12rem;
|
||||
width: 1.96rem;
|
||||
height: 1.96rem;
|
||||
}
|
||||
|
||||
.platform-profile-settings-panel {
|
||||
@@ -6707,7 +6721,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-profile-settings-row {
|
||||
padding: 0.72rem 0.8rem;
|
||||
padding: 0.62rem 0.74rem;
|
||||
}
|
||||
|
||||
.platform-profile-settings-row__icon {
|
||||
|
||||
@@ -159,7 +159,37 @@ describe('appPageRoutes', () => {
|
||||
|
||||
expect(window.location.pathname).toBe('/creation/rpg/result');
|
||||
expect(window.location.search).toBe(
|
||||
'?sessionId=session-1&profileId=profile-1&draftId=draft-1&workId=work-1',
|
||||
'?sessionId=session-1&profileId=profile-1&draftId=draft-1&workId=work-1&clientRuntime=wechat_mini_program',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves mini program runtime context while normalizing app paths', () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientType=mini_program&clientRuntime=wechat_mini_program&miniProgramEnv=trial',
|
||||
);
|
||||
|
||||
pushAppHistoryPath('/');
|
||||
|
||||
expect(window.location.pathname).toBe('/');
|
||||
expect(window.location.search).toBe(
|
||||
'?clientType=mini_program&clientRuntime=wechat_mini_program&miniProgramEnv=trial',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps mini program runtime context when navigating to explicit query routes', () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=wechat_mini_program',
|
||||
);
|
||||
|
||||
pushAppHistoryPath('/works/detail?work=PZ-7A7B18D9');
|
||||
|
||||
expect(window.location.pathname).toBe('/works/detail');
|
||||
expect(window.location.search).toBe(
|
||||
'?work=PZ-7A7B18D9&clientRuntime=wechat_mini_program',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -76,6 +76,12 @@ const ROUTE_STAGE_BY_PATH = new Map(
|
||||
STAGE_ROUTE_ENTRIES.map(([stage, path]) => [path, stage] as const),
|
||||
) as Map<string, SelectionStage>;
|
||||
|
||||
const APP_RUNTIME_CONTEXT_QUERY_KEYS = [
|
||||
'clientType',
|
||||
'clientRuntime',
|
||||
'miniProgramEnv',
|
||||
] as const;
|
||||
|
||||
export function normalizeAppPath(pathname: string) {
|
||||
const trimmedPathname = pathname.trim().toLowerCase();
|
||||
|
||||
@@ -139,13 +145,12 @@ export function isKnownMainAppPagePath(pathname: string) {
|
||||
export function pushAppHistoryPath(path: string) {
|
||||
const nextUrl = new URL(path, window.location.origin);
|
||||
const normalizedPath = normalizeAppPath(nextUrl.pathname);
|
||||
const nextSearch =
|
||||
nextUrl.search ||
|
||||
buildPreservedAppSearch(
|
||||
window.location.pathname,
|
||||
normalizedPath,
|
||||
window.location.search,
|
||||
);
|
||||
const nextSearch = buildPreservedAppSearch(
|
||||
nextUrl.search,
|
||||
window.location.pathname,
|
||||
normalizedPath,
|
||||
window.location.search,
|
||||
);
|
||||
const nextRelativeUrl = `${normalizedPath}${nextSearch}`;
|
||||
const currentRelativeUrl = `${normalizeAppPath(window.location.pathname)}${window.location.search}`;
|
||||
if (currentRelativeUrl === nextRelativeUrl) {
|
||||
@@ -157,16 +162,39 @@ export function pushAppHistoryPath(path: string) {
|
||||
}
|
||||
|
||||
function buildPreservedAppSearch(
|
||||
explicitNextSearch: string,
|
||||
currentPathname: string,
|
||||
normalizedPath: string,
|
||||
search: string,
|
||||
) {
|
||||
const preservedParams = new URLSearchParams(explicitNextSearch);
|
||||
|
||||
if (
|
||||
!isCreationRestorePath(normalizedPath) ||
|
||||
!isSameCreationFlowPath(currentPathname, normalizedPath)
|
||||
!explicitNextSearch &&
|
||||
isCreationRestorePath(normalizedPath) &&
|
||||
isSameCreationFlowPath(currentPathname, normalizedPath)
|
||||
) {
|
||||
return '';
|
||||
const creationParams = new URLSearchParams(
|
||||
buildCreationUrlSearchFromParams(search),
|
||||
);
|
||||
creationParams.forEach((value, key) => {
|
||||
preservedParams.set(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
return buildCreationUrlSearchFromParams(search);
|
||||
const currentParams = new URLSearchParams(search);
|
||||
// 中文注释:小程序 WebView 依赖这些宿主上下文判断登录和充值通道,不能被前端阶段导航清掉。
|
||||
APP_RUNTIME_CONTEXT_QUERY_KEYS.forEach((key) => {
|
||||
if (preservedParams.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = currentParams.get(key)?.trim();
|
||||
if (value) {
|
||||
preservedParams.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const queryString = preservedParams.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
getCurrentAuthUser,
|
||||
getPublicAuthUserById,
|
||||
liftAuthRiskBlock,
|
||||
isWechatMiniProgramWebViewRuntime,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
redeemRegistrationInviteCode,
|
||||
@@ -80,6 +81,7 @@ function createWindowMock(overrides: Record<string, unknown> = {}) {
|
||||
|
||||
describe('authService', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('window', createWindowMock());
|
||||
clearStoredAccessToken({ emit: false });
|
||||
@@ -428,6 +430,26 @@ describe('authService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('detects mini program user agent before the WeChat bridge is ready', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent:
|
||||
'Mozilla/5.0 iPhone MicroMessenger/8.0.49 NetType/WIFI Language/zh_CN miniProgram',
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
createWindowMock({
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '',
|
||||
assign: vi.fn(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(isWechatMiniProgramWebViewRuntime()).toBe(true);
|
||||
});
|
||||
|
||||
it('waits for an existing WeChat JS SDK script before opening the native auth page', async () => {
|
||||
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
|
||||
options.success?.();
|
||||
|
||||
@@ -90,9 +90,15 @@ export function isWechatMiniProgramWebViewRuntime() {
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
const userAgent =
|
||||
typeof navigator === 'undefined' ? '' : navigator.userAgent || '';
|
||||
const normalizedUserAgent = userAgent.toLowerCase();
|
||||
|
||||
return (
|
||||
params.get('clientRuntime') === 'wechat_mini_program' ||
|
||||
params.get('clientType') === 'mini_program' ||
|
||||
(normalizedUserAgent.includes('micromessenger') &&
|
||||
normalizedUserAgent.includes('miniprogram')) ||
|
||||
Boolean(window.wx?.miniProgram?.postMessage)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createBarkBattleDraft,
|
||||
deleteBarkBattleWork,
|
||||
generateAllBarkBattleImageAssets,
|
||||
publishBarkBattleWork,
|
||||
regenerateBarkBattleImageAsset,
|
||||
@@ -73,6 +74,21 @@ describe('barkBattleCreationClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes a draft or published work through runtime works API', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({ items: [] });
|
||||
|
||||
await deleteBarkBattleWork('bark-battle-work-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/bark-battle/works/bark-battle-work-1',
|
||||
{ method: 'DELETE' },
|
||||
'删除汪汪声浪作品失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ retryUnsafeMethods: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('persists generated image slots into an existing draft config', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({ draftId: 'draft-1' });
|
||||
|
||||
|
||||
@@ -290,6 +290,17 @@ export function listBarkBattleWorks(
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteBarkBattleWork(workId: string) {
|
||||
return requestJson<BarkBattleWorksResponse>(
|
||||
`${BARK_BATTLE_RUNTIME_API_BASE}/works/${encodeURIComponent(workId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除汪汪声浪作品失败',
|
||||
{
|
||||
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function listBarkBattleGallery() {
|
||||
return requestJson<BarkBattleWorksResponse>(
|
||||
`${BARK_BATTLE_RUNTIME_API_BASE}/gallery`,
|
||||
@@ -441,6 +452,7 @@ export async function generateAllBarkBattleImageAssets(payload: {
|
||||
|
||||
export const barkBattleCreationClient = {
|
||||
createDraft: createBarkBattleDraft,
|
||||
deleteWork: deleteBarkBattleWork,
|
||||
generateAllImageAssets: generateAllBarkBattleImageAssets,
|
||||
listGallery: listBarkBattleGallery,
|
||||
listWorks: listBarkBattleWorks,
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
type BarkBattleImageGenerationFailures,
|
||||
type BarkBattleUploadedAsset,
|
||||
createBarkBattleDraft,
|
||||
deleteBarkBattleWork,
|
||||
generateAllBarkBattleImageAssets,
|
||||
listBarkBattleGallery,
|
||||
listBarkBattleWorks,
|
||||
|
||||
142
src/services/jump-hop/jumpHopClient.test.ts
Normal file
142
src/services/jump-hop/jumpHopClient.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const requestJsonMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const { createCreationAgentClientMock } = vi.hoisted(() => ({
|
||||
createCreationAgentClientMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../creation-agent', () => ({
|
||||
createCreationAgentClient: createCreationAgentClientMock,
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
createCreationAgentClientMock.mockReset();
|
||||
createCreationAgentClientMock.mockReturnValue({
|
||||
createSession: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
streamMessage: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
});
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
test('jump hop delete work uses creation works endpoint', async () => {
|
||||
const { jumpHopClient } = await import('./jumpHopClient');
|
||||
requestJsonMock.mockResolvedValueOnce({ items: [] });
|
||||
|
||||
await jumpHopClient.deleteWork('jump-hop-profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/creation/jump-hop/works/jump-hop-profile-1',
|
||||
{ method: 'DELETE' },
|
||||
'删除跳一跳作品失败',
|
||||
);
|
||||
});
|
||||
|
||||
test('jump hop creation keeps image2 generation requests alive long enough', async () => {
|
||||
await import('./jumpHopClient');
|
||||
|
||||
expect(createCreationAgentClientMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createSessionTimeoutMs: 20 * 60 * 1000,
|
||||
executeActionTimeoutMs: 20 * 60 * 1000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('jump hop work detail preserves flattened back button asset', async () => {
|
||||
const backButtonAsset = {
|
||||
assetId: 'back-button-1',
|
||||
imageSrc: '/generated-jump-hop-assets/back-button-1.png',
|
||||
imageObjectKey: 'jump-hop/back-button-1.png',
|
||||
assetObjectId: 'asset-object-back-button-1',
|
||||
generationProvider: 'image2',
|
||||
prompt: '主题返回按钮',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
};
|
||||
const characterAsset = {
|
||||
assetId: 'character-1',
|
||||
imageSrc: 'builtin://jump-hop/default-character',
|
||||
imageObjectKey: '',
|
||||
assetObjectId: 'character-object-1',
|
||||
generationProvider: 'builtin-three',
|
||||
prompt: '内置默认角色',
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
const draft = {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'profile-1',
|
||||
themeText: '森林茶馆',
|
||||
workTitle: '森林茶馆跳一跳',
|
||||
workDescription: '森林茶馆主题',
|
||||
themeTags: ['森林茶馆', '跳一跳'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
defaultCharacter: null,
|
||||
characterPrompt: '内置默认角色',
|
||||
tilePrompt: '森林茶馆主题地块',
|
||||
endMoodPrompt: null,
|
||||
characterAsset,
|
||||
tileAtlasAsset: characterAsset,
|
||||
tileAssets: [],
|
||||
path: {
|
||||
seed: 'profile-1',
|
||||
difficulty: 'standard',
|
||||
platforms: [],
|
||||
scoring: {
|
||||
perfectRadiusRatio: 0.24,
|
||||
hitRadiusRatio: 0.52,
|
||||
maxChargeMs: 1200,
|
||||
minChargeMs: 80,
|
||||
maxJumpDistance: 5,
|
||||
},
|
||||
},
|
||||
coverComposite: null,
|
||||
backButtonAsset: null,
|
||||
generationStatus: 'ready',
|
||||
};
|
||||
requestJsonMock.mockResolvedValue({
|
||||
item: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'work-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'owner-1',
|
||||
sourceSessionId: 'session-1',
|
||||
themeText: '森林茶馆',
|
||||
workTitle: '森林茶馆跳一跳',
|
||||
workDescription: '森林茶馆主题',
|
||||
themeTags: ['森林茶馆', '跳一跳'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-05T00:00:00Z',
|
||||
publishedAt: '2026-06-05T00:00:00Z',
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
draft,
|
||||
path: draft.path,
|
||||
defaultCharacter: null,
|
||||
characterAsset,
|
||||
tileAtlasAsset: characterAsset,
|
||||
tileAssets: [],
|
||||
backButtonAsset,
|
||||
},
|
||||
});
|
||||
|
||||
const { jumpHopClient } = await import('./jumpHopClient');
|
||||
const response = await jumpHopClient.getWorkDetail('profile-1');
|
||||
|
||||
expect(response.item.backButtonAsset).toEqual(backButtonAsset);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
JumpHopGalleryCardResponse,
|
||||
JumpHopGalleryDetailResponse,
|
||||
JumpHopGalleryResponse,
|
||||
JumpHopLeaderboardResponse,
|
||||
JumpHopRunResponse,
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopSessionResponse,
|
||||
@@ -12,8 +13,8 @@ import type {
|
||||
JumpHopWorkDetailResponse,
|
||||
JumpHopWorkMutationResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
JumpHopWorksResponse,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
JumpHopWorksResponse,
|
||||
JumpHopWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import {
|
||||
@@ -30,12 +31,23 @@ import {
|
||||
const JUMP_HOP_API_BASE = '/api/creation/jump-hop/sessions';
|
||||
const JUMP_HOP_WORKS_API_BASE = '/api/creation/jump-hop/works';
|
||||
const JUMP_HOP_RUNTIME_API_BASE = '/api/runtime/jump-hop';
|
||||
// 中文注释:跳一跳创作会等待背景图、25 格图集、切片和 OSS 写入,不能沿用共创会话默认 15 秒超时。
|
||||
const JUMP_HOP_GENERATION_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const JUMP_HOP_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
type JumpHopRuntimeRequestOptions = RuntimeGuestRequestOptions;
|
||||
export type JumpHopRuntimeRequestOptions = RuntimeGuestRequestOptions;
|
||||
type JumpHopRuntimeMode = 'draft' | 'published';
|
||||
type JumpHopStartRunOptions = JumpHopRuntimeRequestOptions & {
|
||||
runtimeMode?: JumpHopRuntimeMode;
|
||||
};
|
||||
type JumpHopJumpPayload = {
|
||||
dragDistance: number;
|
||||
dragVectorX?: number;
|
||||
dragVectorY?: number;
|
||||
};
|
||||
|
||||
export type {
|
||||
JumpHopActionRequest,
|
||||
@@ -44,6 +56,7 @@ export type {
|
||||
JumpHopGalleryCardResponse,
|
||||
JumpHopGalleryDetailResponse,
|
||||
JumpHopGalleryResponse,
|
||||
JumpHopLeaderboardResponse,
|
||||
JumpHopRunResponse,
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopSessionResponse,
|
||||
@@ -51,16 +64,10 @@ export type {
|
||||
JumpHopWorkDetailResponse,
|
||||
JumpHopWorkMutationResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
JumpHopWorksResponse,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
JumpHopWorksResponse,
|
||||
};
|
||||
export type CreateJumpHopSessionRequest = {
|
||||
themeText: string;
|
||||
characterDescription: string;
|
||||
tileStyle: string;
|
||||
difficulty: string;
|
||||
rhythmPreference: string;
|
||||
};
|
||||
export type CreateJumpHopSessionRequest = JumpHopWorkspaceCreateRequest;
|
||||
export type ExecuteJumpHopActionRequest = JumpHopActionRequest;
|
||||
export type JumpHopSessionSnapshot = JumpHopSessionSnapshotResponse;
|
||||
|
||||
@@ -82,6 +89,8 @@ const jumpHopCreationClient = createCreationAgentClient<
|
||||
streamIncomplete: '跳一跳共创消息流式结果不完整',
|
||||
executeAction: '执行跳一跳共创操作失败',
|
||||
},
|
||||
createSessionTimeoutMs: JUMP_HOP_GENERATION_TIMEOUT_MS,
|
||||
executeActionTimeoutMs: JUMP_HOP_GENERATION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
type FlattenedJumpHopWorkProfileResponse = Omit<
|
||||
@@ -104,6 +113,7 @@ function normalizeJumpHopWorkProfile(
|
||||
profileId: flattened.profileId,
|
||||
ownerUserId: flattened.ownerUserId,
|
||||
sourceSessionId: flattened.sourceSessionId ?? null,
|
||||
themeText: flattened.themeText || flattened.workTitle,
|
||||
workTitle: flattened.workTitle,
|
||||
workDescription: flattened.workDescription,
|
||||
themeTags: flattened.themeTags,
|
||||
@@ -122,9 +132,12 @@ function normalizeJumpHopWorkProfile(
|
||||
summary,
|
||||
draft: flattened.draft,
|
||||
path: flattened.path,
|
||||
defaultCharacter: flattened.defaultCharacter ?? flattened.draft?.defaultCharacter,
|
||||
characterAsset: flattened.characterAsset,
|
||||
tileAtlasAsset: flattened.tileAtlasAsset,
|
||||
tileAssets: flattened.tileAssets,
|
||||
backButtonAsset:
|
||||
flattened.backButtonAsset ?? flattened.draft?.backButtonAsset ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,11 +243,20 @@ export async function publishJumpHopWork(profileId: string) {
|
||||
return normalizeJumpHopWorkMutationResponse(response);
|
||||
}
|
||||
|
||||
export async function deleteJumpHopWork(profileId: string) {
|
||||
return requestJson<JumpHopWorksResponse>(
|
||||
`${JUMP_HOP_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除跳一跳作品失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function startJumpHopRuntimeRun(
|
||||
profileId: string,
|
||||
options: JumpHopRuntimeRequestOptions = {},
|
||||
options: JumpHopStartRunOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const runtimeMode = options.runtimeMode ?? 'published';
|
||||
return requestJson<JumpHopRunResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs`,
|
||||
{
|
||||
@@ -243,7 +265,7 @@ export async function startJumpHopRuntimeRun(
|
||||
'content-type': 'application/json',
|
||||
...buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
body: JSON.stringify({ profileId }),
|
||||
body: JSON.stringify({ profileId, runtimeMode }),
|
||||
},
|
||||
'启动跳一跳运行态失败',
|
||||
{
|
||||
@@ -254,12 +276,14 @@ export async function startJumpHopRuntimeRun(
|
||||
|
||||
export async function submitJumpHopJump(
|
||||
runId: string,
|
||||
payload: { chargeMs: number },
|
||||
payload: JumpHopJumpPayload,
|
||||
options: JumpHopRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const requestPayload = {
|
||||
chargeMs: payload.chargeMs,
|
||||
dragDistance: payload.dragDistance,
|
||||
dragVectorX: payload.dragVectorX,
|
||||
dragVectorY: payload.dragVectorY,
|
||||
clientEventId: `jump-${runId}-${Date.now()}`,
|
||||
};
|
||||
|
||||
@@ -278,6 +302,22 @@ export async function submitJumpHopJump(
|
||||
);
|
||||
}
|
||||
|
||||
export async function getJumpHopLeaderboard(
|
||||
profileId: string,
|
||||
options: JumpHopRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<JumpHopLeaderboardResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}/leaderboard`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
'读取跳一跳排行榜失败',
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export async function restartJumpHopRuntimeRun(
|
||||
runId: string,
|
||||
options: JumpHopRuntimeRequestOptions = {},
|
||||
@@ -302,6 +342,7 @@ export async function restartJumpHopRuntimeRun(
|
||||
|
||||
export const jumpHopClient = {
|
||||
createSession: createJumpHopCreationSession,
|
||||
deleteWork: deleteJumpHopWork,
|
||||
getSession: getJumpHopCreationSession,
|
||||
executeAction: executeJumpHopCreationAction,
|
||||
getGalleryDetail: getJumpHopGalleryDetail,
|
||||
@@ -309,6 +350,7 @@ export const jumpHopClient = {
|
||||
listGallery: listJumpHopGallery,
|
||||
listWorks: listJumpHopWorks,
|
||||
publishWork: publishJumpHopWork,
|
||||
getLeaderboard: getJumpHopLeaderboard,
|
||||
restartRun: restartJumpHopRuntimeRun,
|
||||
startRun: startJumpHopRuntimeRun,
|
||||
submitJump: submitJumpHopJump,
|
||||
|
||||
498
src/services/jump-hop/jumpHopRuntimeModel.test.ts
Normal file
498
src/services/jump-hop/jumpHopRuntimeModel.test.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
JumpHopPath,
|
||||
JumpHopTileAsset,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import {
|
||||
buildJumpHopVisiblePlatforms,
|
||||
getJumpHopBackendDragVector,
|
||||
getJumpHopCharacterVisualPosition,
|
||||
getJumpHopJumpFeedbackLabel,
|
||||
getJumpHopLandingAssistVisualPosition,
|
||||
getJumpHopPlatformVisualSize,
|
||||
getJumpHopStatusLabel,
|
||||
resolveJumpHopCharacterCanvasPosition,
|
||||
selectJumpHopTileAsset,
|
||||
} from './jumpHopRuntimeModel';
|
||||
|
||||
test('跳一跳地块池按平台编号从 25 个素材中抽取而不是按类型压扁', () => {
|
||||
const tileAssets = Array.from({ length: 25 }, (_, index) => ({
|
||||
tileType: 'normal',
|
||||
tileId: `tile-${String(index + 1).padStart(2, '0')}`,
|
||||
imageSrc: `asset-${index + 1}`,
|
||||
imageObjectKey: `key-${index + 1}`,
|
||||
assetObjectId: `object-${index + 1}`,
|
||||
sourceAtlasCell: `row-1-col-${index + 1}`,
|
||||
atlasRow: 1,
|
||||
atlasCol: index + 1,
|
||||
visualWidth: 256,
|
||||
visualHeight: 192,
|
||||
topSurfaceRadius: 42,
|
||||
landingRadius: 34,
|
||||
})) satisfies JumpHopTileAsset[];
|
||||
|
||||
const first = selectJumpHopTileAsset(tileAssets, '森林茶馆', 1, 'platform-1');
|
||||
const second = selectJumpHopTileAsset(tileAssets, '森林茶馆', 2, 'platform-2');
|
||||
|
||||
expect(first?.imageSrc).not.toBe(second?.imageSrc);
|
||||
expect(first?.imageSrc).toMatch(/^asset-/);
|
||||
expect(second?.imageSrc).toMatch(/^asset-/);
|
||||
});
|
||||
|
||||
test('跳一跳可见平台窗口固定为 3 个并携带选中的地块素材', () => {
|
||||
const path: JumpHopPath = {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [
|
||||
platform(0, 0, 'start'),
|
||||
platform(1.2, 1.8, 'normal'),
|
||||
platform(-0.3, 3.5, 'target'),
|
||||
platform(0.8, 5.1, 'normal'),
|
||||
],
|
||||
};
|
||||
const tileAssets = Array.from({ length: 25 }, (_, index) => ({
|
||||
tileType: 'normal',
|
||||
tileId: `tile-${String(index + 1).padStart(2, '0')}`,
|
||||
imageSrc: `asset-${index + 1}`,
|
||||
imageObjectKey: `key-${index + 1}`,
|
||||
assetObjectId: `object-${index + 1}`,
|
||||
sourceAtlasCell: `row-1-col-${index + 1}`,
|
||||
visualWidth: 256,
|
||||
visualHeight: 192,
|
||||
topSurfaceRadius: 42,
|
||||
landingRadius: 34,
|
||||
})) satisfies JumpHopTileAsset[];
|
||||
|
||||
const visible = buildJumpHopVisiblePlatforms(path, 1, tileAssets);
|
||||
|
||||
expect(visible).toHaveLength(3);
|
||||
expect(visible[0]?.asset?.imageSrc).toMatch(/^asset-/);
|
||||
expect(visible[1]?.asset?.imageSrc).toMatch(/^asset-/);
|
||||
expect(visible[2]?.asset?.imageSrc).toMatch(/^asset-/);
|
||||
});
|
||||
|
||||
test('跳一跳三块可见地块按下方中部上方展开且角色落在当前地块上', () => {
|
||||
const path: JumpHopPath = {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [
|
||||
platform(0, 0, 'start'),
|
||||
platform(0.8, 1.2, 'normal'),
|
||||
platform(-0.2, 2.4, 'target'),
|
||||
],
|
||||
};
|
||||
|
||||
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||
const character = getJumpHopCharacterVisualPosition(
|
||||
{
|
||||
runId: 'run-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 0,
|
||||
successfulJumpCount: 0,
|
||||
durationMs: 0,
|
||||
score: 0,
|
||||
combo: 0,
|
||||
path,
|
||||
lastJump: null,
|
||||
startedAtMs: 1000,
|
||||
finishedAtMs: null,
|
||||
},
|
||||
visible,
|
||||
);
|
||||
|
||||
expect(visible[0]?.screenY).toBeGreaterThanOrEqual(68);
|
||||
expect(visible[0]?.screenY).toBeLessThanOrEqual(80);
|
||||
expect(visible[1]?.screenY).toBeGreaterThanOrEqual(40);
|
||||
expect(visible[1]?.screenY).toBeLessThan(visible[0]?.screenY ?? 0);
|
||||
expect(visible[2]?.screenY).toBeLessThan(visible[1]?.screenY ?? 0);
|
||||
expect(visible[2]?.screenY).toBeLessThanOrEqual(26);
|
||||
expect(Math.abs((visible[1]?.screenX ?? 0) - (visible[0]?.screenX ?? 0))).toBeGreaterThan(5);
|
||||
expect(Math.abs((visible[2]?.screenX ?? 0) - (visible[1]?.screenX ?? 0))).toBeGreaterThan(5);
|
||||
expect(character?.screenX).toBeCloseTo(visible[0]?.screenX ?? 0, 1);
|
||||
expect(character?.screenY).toBeCloseTo((visible[0]?.screenY ?? 0) - 3, 1);
|
||||
});
|
||||
|
||||
test('跳一跳可见地块按深度保留不同视觉尺寸', () => {
|
||||
const path: JumpHopPath = {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [
|
||||
platform(0, 0, 'start'),
|
||||
platform(0.8, 1.2, 'normal'),
|
||||
platform(-0.2, 2.4, 'target'),
|
||||
],
|
||||
};
|
||||
|
||||
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||
const currentSize = getJumpHopPlatformVisualSize(
|
||||
visible[0]!.platform,
|
||||
visible[0]!.scale,
|
||||
);
|
||||
const targetSize = getJumpHopPlatformVisualSize(
|
||||
visible[1]!.platform,
|
||||
visible[1]!.scale,
|
||||
);
|
||||
const previewSize = getJumpHopPlatformVisualSize(
|
||||
visible[2]!.platform,
|
||||
visible[2]!.scale,
|
||||
);
|
||||
|
||||
expect(currentSize.width).toBeGreaterThan(targetSize.width);
|
||||
expect(targetSize.width).toBeGreaterThan(previewSize.width);
|
||||
expect(currentSize.height).toBeGreaterThan(targetSize.height);
|
||||
expect(targetSize.height).toBeGreaterThan(previewSize.height);
|
||||
});
|
||||
|
||||
test('跳一跳三维角色画布坐标与屏幕坐标同向映射到下方起始地块', () => {
|
||||
const path: JumpHopPath = {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [
|
||||
platform(0, 0, 'start'),
|
||||
platform(0.8, 1.2, 'normal'),
|
||||
platform(-0.2, 2.4, 'target'),
|
||||
],
|
||||
};
|
||||
|
||||
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||
const character = getJumpHopCharacterVisualPosition(
|
||||
{
|
||||
runId: 'run-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 0,
|
||||
successfulJumpCount: 0,
|
||||
durationMs: 0,
|
||||
score: 0,
|
||||
combo: 0,
|
||||
path,
|
||||
lastJump: null,
|
||||
startedAtMs: 1000,
|
||||
finishedAtMs: null,
|
||||
},
|
||||
visible,
|
||||
);
|
||||
|
||||
const canvasPosition = resolveJumpHopCharacterCanvasPosition(character, {
|
||||
width: 320,
|
||||
height: 568,
|
||||
});
|
||||
|
||||
expect(canvasPosition?.x).toBeGreaterThan(140);
|
||||
expect(canvasPosition?.x).toBeLessThan(180);
|
||||
expect(canvasPosition?.y).toBeGreaterThan(380);
|
||||
expect(canvasPosition?.y).toBeLessThan(450);
|
||||
});
|
||||
|
||||
test('跳一跳运行态当前地块视觉尺寸按原调参结果放大一倍', () => {
|
||||
const size = getJumpHopPlatformVisualSize(platform(0, 0, 'start'), 1.08);
|
||||
|
||||
expect(size.width).toBeCloseTo(125.28, 2);
|
||||
expect(size.height).toBeCloseTo(103.68, 2);
|
||||
});
|
||||
|
||||
test('跳一跳落点辅助标识按后端落点规则随拖拽方向和距离投影', () => {
|
||||
const path: JumpHopPath = {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [
|
||||
platform(0, 0, 'start'),
|
||||
platform(0.8, 1.2, 'normal'),
|
||||
platform(-0.2, 2.4, 'target'),
|
||||
],
|
||||
};
|
||||
const run = {
|
||||
runId: 'run-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 0,
|
||||
successfulJumpCount: 0,
|
||||
durationMs: 0,
|
||||
score: 0,
|
||||
combo: 0,
|
||||
path,
|
||||
lastJump: null,
|
||||
startedAtMs: 1000,
|
||||
finishedAtMs: null,
|
||||
} as const;
|
||||
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||
const character = getJumpHopCharacterVisualPosition(run, visible);
|
||||
const current = visible[0]!;
|
||||
const target = visible[1]!;
|
||||
const stageSize = { width: 320, height: 568 };
|
||||
const currentCanvasPosition = {
|
||||
x: (current.screenX / 100) * stageSize.width,
|
||||
y: (current.screenY / 100) * stageSize.height,
|
||||
};
|
||||
const targetCanvasPosition = {
|
||||
x: (target.screenX / 100) * stageSize.width,
|
||||
y: (target.screenY / 100) * stageSize.height,
|
||||
};
|
||||
const targetWorldDistance = Math.hypot(
|
||||
target.platform.x - current.platform.x,
|
||||
target.platform.y - current.platform.y,
|
||||
);
|
||||
const fullDragDistance =
|
||||
targetWorldDistance / path.scoring.chargeToDistanceRatio;
|
||||
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
|
||||
const dragVectorY = -(targetCanvasPosition.y - currentCanvasPosition.y);
|
||||
|
||||
const fullAssist = getJumpHopLandingAssistVisualPosition(
|
||||
run,
|
||||
visible,
|
||||
character,
|
||||
stageSize,
|
||||
fullDragDistance,
|
||||
dragVectorX,
|
||||
dragVectorY,
|
||||
);
|
||||
const halfAssist = getJumpHopLandingAssistVisualPosition(
|
||||
run,
|
||||
visible,
|
||||
character,
|
||||
stageSize,
|
||||
fullDragDistance / 2,
|
||||
dragVectorX,
|
||||
dragVectorY,
|
||||
);
|
||||
|
||||
expect(fullAssist?.screenX).toBeCloseTo(target.screenX, 1);
|
||||
expect(fullAssist?.screenY).toBeCloseTo(target.screenY, 1);
|
||||
expect(halfAssist?.screenX).toBeCloseTo(
|
||||
current.screenX + (target.screenX - current.screenX) / 2,
|
||||
1,
|
||||
);
|
||||
expect(halfAssist?.screenY).toBeCloseTo(
|
||||
current.screenY + (target.screenY - current.screenY) / 2,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳落点辅助标识使用屏幕坐标向后拖拽并投向上方目标地块', () => {
|
||||
const path: JumpHopPath = {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [
|
||||
platform(0, 0, 'start'),
|
||||
platform(0.8, 1.2, 'normal'),
|
||||
platform(-0.2, 2.4, 'target'),
|
||||
],
|
||||
};
|
||||
const run = {
|
||||
runId: 'run-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 0,
|
||||
successfulJumpCount: 0,
|
||||
durationMs: 0,
|
||||
score: 0,
|
||||
combo: 0,
|
||||
path,
|
||||
lastJump: null,
|
||||
startedAtMs: 1000,
|
||||
finishedAtMs: null,
|
||||
} as const;
|
||||
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||
const character = getJumpHopCharacterVisualPosition(run, visible);
|
||||
const current = visible[0]!;
|
||||
const target = visible[1]!;
|
||||
const stageSize = { width: 320, height: 568 };
|
||||
const currentCanvasPosition = {
|
||||
x: (current.screenX / 100) * stageSize.width,
|
||||
y: (current.screenY / 100) * stageSize.height,
|
||||
};
|
||||
const targetCanvasPosition = {
|
||||
x: (target.screenX / 100) * stageSize.width,
|
||||
y: (target.screenY / 100) * stageSize.height,
|
||||
};
|
||||
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
|
||||
const dragVectorY = currentCanvasPosition.y - targetCanvasPosition.y;
|
||||
const targetWorldDistance = Math.hypot(
|
||||
target.platform.x - current.platform.x,
|
||||
target.platform.y - current.platform.y,
|
||||
);
|
||||
const fullDragDistance =
|
||||
targetWorldDistance / path.scoring.chargeToDistanceRatio;
|
||||
|
||||
const assist = getJumpHopLandingAssistVisualPosition(
|
||||
run,
|
||||
visible,
|
||||
character,
|
||||
stageSize,
|
||||
fullDragDistance,
|
||||
dragVectorX,
|
||||
dragVectorY,
|
||||
);
|
||||
|
||||
expect(dragVectorY).toBeGreaterThan(0);
|
||||
expect(assist?.screenX).toBeCloseTo(target.screenX, 1);
|
||||
expect(assist?.screenY).toBeCloseTo(target.screenY, 1);
|
||||
});
|
||||
|
||||
test('跳一跳后端落点向量会把屏幕拖拽换算为世界尺度一致的反向弹射', () => {
|
||||
const path: JumpHopPath = {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [
|
||||
platform(0, 0, 'start'),
|
||||
platform(0.8, 1.2, 'normal'),
|
||||
platform(-0.2, 2.4, 'target'),
|
||||
],
|
||||
};
|
||||
const run = {
|
||||
runId: 'run-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 0,
|
||||
successfulJumpCount: 0,
|
||||
durationMs: 0,
|
||||
score: 0,
|
||||
combo: 0,
|
||||
path,
|
||||
lastJump: null,
|
||||
startedAtMs: 1000,
|
||||
finishedAtMs: null,
|
||||
} as const;
|
||||
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||
const current = visible[0]!;
|
||||
const target = visible[1]!;
|
||||
const stageSize = { width: 320, height: 568 };
|
||||
const currentCanvasPosition = {
|
||||
x: (current.screenX / 100) * stageSize.width,
|
||||
y: (current.screenY / 100) * stageSize.height,
|
||||
};
|
||||
const targetCanvasPosition = {
|
||||
x: (target.screenX / 100) * stageSize.width,
|
||||
y: (target.screenY / 100) * stageSize.height,
|
||||
};
|
||||
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
|
||||
const dragVectorY = currentCanvasPosition.y - targetCanvasPosition.y;
|
||||
const backendVector = getJumpHopBackendDragVector(
|
||||
run,
|
||||
visible,
|
||||
stageSize,
|
||||
dragVectorX,
|
||||
dragVectorY,
|
||||
);
|
||||
|
||||
expect(backendVector.dragVectorX).toBeLessThan(0);
|
||||
expect(backendVector.dragVectorY).toBeGreaterThan(0);
|
||||
expect(Math.abs(backendVector.dragVectorY)).toBeLessThan(Math.abs(dragVectorY));
|
||||
});
|
||||
|
||||
test('跳一跳运行态公开反馈不再展示旧 perfect 和通关语义', () => {
|
||||
expect(getJumpHopStatusLabel('cleared')).toBe('结束');
|
||||
expect(
|
||||
getJumpHopJumpFeedbackLabel({
|
||||
runId: 'run-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 1,
|
||||
successfulJumpCount: 1,
|
||||
durationMs: 0,
|
||||
score: 1,
|
||||
combo: 0,
|
||||
path: {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [platform(0, 0, 'start'), platform(1.2, 1.8, 'normal')],
|
||||
},
|
||||
lastJump: {
|
||||
chargeMs: 300,
|
||||
jumpDistance: 1.2,
|
||||
targetPlatformIndex: 1,
|
||||
landedX: 1.2,
|
||||
landedY: 1.8,
|
||||
result: 'perfect',
|
||||
},
|
||||
startedAtMs: 1000,
|
||||
finishedAtMs: null,
|
||||
}),
|
||||
).toBe('落地');
|
||||
});
|
||||
|
||||
function platform(x: number, y: number, tileType: 'start' | 'normal' | 'target') {
|
||||
return {
|
||||
platformId: `platform-${x}-${y}`,
|
||||
tileType,
|
||||
x,
|
||||
y,
|
||||
width: 1,
|
||||
height: 1,
|
||||
landingRadius: 0.5,
|
||||
perfectRadius: 0.2,
|
||||
scoreValue: 1,
|
||||
};
|
||||
}
|
||||
479
src/services/jump-hop/jumpHopRuntimeModel.ts
Normal file
479
src/services/jump-hop/jumpHopRuntimeModel.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import type {
|
||||
JumpHopPath,
|
||||
JumpHopPlatform,
|
||||
JumpHopRunStatus,
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopTileAsset,
|
||||
JumpHopTileType,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
|
||||
export type JumpHopVisiblePlatform = {
|
||||
platform: JumpHopPlatform;
|
||||
index: number;
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
sceneX: number;
|
||||
sceneY: number;
|
||||
sceneZ: number;
|
||||
scale: number;
|
||||
asset: JumpHopTileAsset | null;
|
||||
};
|
||||
|
||||
export type JumpHopCharacterVisualPosition = {
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
sceneX: number;
|
||||
sceneY: number;
|
||||
sceneZ: number;
|
||||
isMiss: boolean;
|
||||
};
|
||||
|
||||
export type JumpHopCanvasSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type JumpHopPlatformVisualSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type JumpHopLandingAssistVisualPosition = {
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
targetPlatformIndex: number;
|
||||
};
|
||||
|
||||
export type JumpHopBackendDragVector = {
|
||||
dragVectorX: number;
|
||||
dragVectorY: number;
|
||||
};
|
||||
|
||||
const VISIBLE_PLATFORM_COUNT = 3;
|
||||
const JUMP_HOP_STAGE_WORLD_SCALE = 4.2;
|
||||
const JUMP_HOP_STAGE_FORWARD_SCALE = 3;
|
||||
const JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y = [78, 50, 22] as const;
|
||||
const JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER = 2;
|
||||
const JUMP_HOP_SCREEN_X_WORLD_PERCENT = 16 * 0.96;
|
||||
|
||||
const tileToneByType: Record<JumpHopTileType, string> = {
|
||||
accent: '#e0f2fe',
|
||||
bonus: '#fef3c7',
|
||||
finish: '#dcfce7',
|
||||
normal: '#f8fafc',
|
||||
start: '#e0f2fe',
|
||||
target: '#fee2e2',
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function hashJumpHopString(value: string) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (const character of value) {
|
||||
hash ^= character.codePointAt(0) ?? 0;
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function selectJumpHopTileAsset(
|
||||
tileAssets: JumpHopTileAsset[] | null | undefined,
|
||||
seedText: string | null | undefined,
|
||||
platformIndex: number,
|
||||
platformId: string,
|
||||
) {
|
||||
const pool = (tileAssets ?? []).filter(Boolean);
|
||||
if (pool.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSeed = seedText?.trim() || 'jump-hop';
|
||||
const signature = `${normalizedSeed}:${platformIndex}:${platformId}`;
|
||||
const selectedIndex = hashJumpHopString(signature) % pool.length;
|
||||
return pool[selectedIndex] ?? null;
|
||||
}
|
||||
|
||||
export function buildJumpHopVisiblePlatforms(
|
||||
path: JumpHopPath | null | undefined,
|
||||
currentPlatformIndex: number,
|
||||
tileAssets: JumpHopTileAsset[] | null | undefined,
|
||||
) {
|
||||
const platforms = path?.platforms ?? [];
|
||||
const current = platforms[currentPlatformIndex] ?? platforms[0];
|
||||
if (!current) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const start = Math.max(0, currentPlatformIndex);
|
||||
const end = Math.min(platforms.length, currentPlatformIndex + VISIBLE_PLATFORM_COUNT);
|
||||
const visible = platforms.slice(start, end);
|
||||
const worldScale = 0.96;
|
||||
|
||||
return visible.map((platform, offset): JumpHopVisiblePlatform => {
|
||||
const index = start + offset;
|
||||
const dx = platform.x - current.x;
|
||||
const dy = platform.y - current.y;
|
||||
const depth = index - currentPlatformIndex;
|
||||
const asset = selectJumpHopTileAsset(
|
||||
tileAssets,
|
||||
path?.seed ?? null,
|
||||
index,
|
||||
platform.platformId,
|
||||
);
|
||||
const screenY =
|
||||
depth <= 0
|
||||
? JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[0]
|
||||
: depth === 1
|
||||
? JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[1]
|
||||
: JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[2];
|
||||
const screenX = clamp(50 + dx * 16 * worldScale, 14, 86);
|
||||
|
||||
return {
|
||||
platform,
|
||||
index,
|
||||
screenX,
|
||||
screenY,
|
||||
sceneX: dx * JUMP_HOP_STAGE_WORLD_SCALE,
|
||||
sceneY: 0,
|
||||
sceneZ: dy * JUMP_HOP_STAGE_FORWARD_SCALE,
|
||||
scale: clamp(1.08 - Math.max(0, depth) * 0.12, 0.8, 1.1),
|
||||
asset,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getJumpHopPlatformVisualSize(
|
||||
platform: JumpHopPlatform,
|
||||
scale: number,
|
||||
): JumpHopPlatformVisualSize {
|
||||
return {
|
||||
width:
|
||||
clamp(platform.width * 0.96, 58, 118) *
|
||||
scale *
|
||||
JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER,
|
||||
height:
|
||||
clamp(platform.height * 0.78, 48, 92) *
|
||||
scale *
|
||||
JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER,
|
||||
};
|
||||
}
|
||||
|
||||
function getJumpHopCurrentTargetPlatforms(
|
||||
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||
platforms: JumpHopVisiblePlatform[],
|
||||
) {
|
||||
if (!run) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentIndex = run.currentPlatformIndex;
|
||||
const currentPlatform =
|
||||
platforms.find((item) => item.index === currentIndex) ??
|
||||
platforms[0] ??
|
||||
null;
|
||||
const targetPlatform =
|
||||
platforms.find((item) => item.index === currentIndex + 1) ??
|
||||
platforms[1] ??
|
||||
null;
|
||||
|
||||
if (!currentPlatform || !targetPlatform) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
currentPlatform,
|
||||
targetPlatform,
|
||||
};
|
||||
}
|
||||
|
||||
function getJumpHopCanvasPosition(
|
||||
platform: JumpHopVisiblePlatform,
|
||||
stageSize: JumpHopCanvasSize,
|
||||
) {
|
||||
return {
|
||||
x: (platform.screenX / 100) * stageSize.width,
|
||||
y: (platform.screenY / 100) * stageSize.height,
|
||||
};
|
||||
}
|
||||
|
||||
function getJumpHopScreenWorldScales(
|
||||
currentPlatform: JumpHopVisiblePlatform,
|
||||
targetPlatform: JumpHopVisiblePlatform,
|
||||
stageSize: JumpHopCanvasSize,
|
||||
) {
|
||||
const currentCanvasPosition = getJumpHopCanvasPosition(
|
||||
currentPlatform,
|
||||
stageSize,
|
||||
);
|
||||
const targetCanvasPosition = getJumpHopCanvasPosition(
|
||||
targetPlatform,
|
||||
stageSize,
|
||||
);
|
||||
const targetWorldDeltaX =
|
||||
targetPlatform.platform.x - currentPlatform.platform.x;
|
||||
const targetWorldDeltaY =
|
||||
targetPlatform.platform.y - currentPlatform.platform.y;
|
||||
const targetScreenDeltaX = targetCanvasPosition.x - currentCanvasPosition.x;
|
||||
const targetScreenDeltaY = targetCanvasPosition.y - currentCanvasPosition.y;
|
||||
const targetWorldDistance = Math.hypot(targetWorldDeltaX, targetWorldDeltaY);
|
||||
const targetScreenDistance = Math.hypot(
|
||||
targetScreenDeltaX,
|
||||
targetScreenDeltaY,
|
||||
);
|
||||
const fallbackPixelsPerWorldUnit =
|
||||
targetWorldDistance > 0.0001 && targetScreenDistance > 0.0001
|
||||
? targetScreenDistance / targetWorldDistance
|
||||
: stageSize.height * 0.18;
|
||||
const xPixelsPerWorldUnit =
|
||||
Math.abs(targetWorldDeltaX) > 0.0001 &&
|
||||
Math.abs(targetScreenDeltaX) > 0.0001
|
||||
? Math.abs(targetScreenDeltaX / targetWorldDeltaX)
|
||||
: Math.max(stageSize.width * (JUMP_HOP_SCREEN_X_WORLD_PERCENT / 100), 1);
|
||||
const yPixelsPerWorldUnit =
|
||||
Math.abs(targetWorldDeltaY) > 0.0001 &&
|
||||
Math.abs(targetScreenDeltaY) > 0.0001
|
||||
? Math.abs(targetScreenDeltaY / targetWorldDeltaY)
|
||||
: fallbackPixelsPerWorldUnit;
|
||||
const signedXScreenPerWorld =
|
||||
Math.abs(targetWorldDeltaX) > 0.0001 &&
|
||||
Math.abs(targetScreenDeltaX) > 0.0001
|
||||
? targetScreenDeltaX / targetWorldDeltaX
|
||||
: xPixelsPerWorldUnit;
|
||||
const signedYScreenPerWorld =
|
||||
Math.abs(targetWorldDeltaY) > 0.0001 &&
|
||||
Math.abs(targetScreenDeltaY) > 0.0001
|
||||
? targetScreenDeltaY / targetWorldDeltaY
|
||||
: -yPixelsPerWorldUnit;
|
||||
|
||||
return {
|
||||
currentCanvasPosition,
|
||||
targetPlatform,
|
||||
xPixelsPerWorldUnit,
|
||||
yPixelsPerWorldUnit: Math.max(yPixelsPerWorldUnit, 1),
|
||||
signedXScreenPerWorld,
|
||||
signedYScreenPerWorld,
|
||||
};
|
||||
}
|
||||
|
||||
export function getJumpHopBackendDragVector(
|
||||
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||
platforms: JumpHopVisiblePlatform[],
|
||||
stageSize: JumpHopCanvasSize,
|
||||
dragVectorX: number,
|
||||
dragVectorY: number,
|
||||
): JumpHopBackendDragVector {
|
||||
const pair = getJumpHopCurrentTargetPlatforms(run, platforms);
|
||||
if (!pair || stageSize.width <= 0 || stageSize.height <= 0) {
|
||||
return {
|
||||
dragVectorX,
|
||||
dragVectorY,
|
||||
};
|
||||
}
|
||||
|
||||
const scales = getJumpHopScreenWorldScales(
|
||||
pair.currentPlatform,
|
||||
pair.targetPlatform,
|
||||
stageSize,
|
||||
);
|
||||
|
||||
return {
|
||||
dragVectorX: dragVectorX / scales.xPixelsPerWorldUnit,
|
||||
dragVectorY: dragVectorY / scales.yPixelsPerWorldUnit,
|
||||
};
|
||||
}
|
||||
|
||||
export function getJumpHopLandingAssistVisualPosition(
|
||||
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||
platforms: JumpHopVisiblePlatform[],
|
||||
characterPosition: JumpHopCharacterVisualPosition | null,
|
||||
stageSize: JumpHopCanvasSize,
|
||||
dragDistance: number,
|
||||
dragVectorX: number | null,
|
||||
dragVectorY: number | null,
|
||||
) {
|
||||
if (
|
||||
!run ||
|
||||
run.status !== 'playing' ||
|
||||
!characterPosition ||
|
||||
stageSize.width <= 0 ||
|
||||
stageSize.height <= 0 ||
|
||||
dragDistance <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pair = getJumpHopCurrentTargetPlatforms(run, platforms);
|
||||
if (!pair) {
|
||||
return null;
|
||||
}
|
||||
const { currentPlatform, targetPlatform } = pair;
|
||||
|
||||
const dragX = dragVectorX ?? 0;
|
||||
const dragY = dragVectorY ?? 0;
|
||||
const dragLength = Math.hypot(dragX, dragY);
|
||||
if (dragLength < 0.0001) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scales = getJumpHopScreenWorldScales(
|
||||
currentPlatform,
|
||||
targetPlatform,
|
||||
stageSize,
|
||||
);
|
||||
const backendDragVector = getJumpHopBackendDragVector(
|
||||
run,
|
||||
platforms,
|
||||
stageSize,
|
||||
dragX,
|
||||
dragY,
|
||||
);
|
||||
const jumpWorldX = -backendDragVector.dragVectorX;
|
||||
const jumpWorldY = backendDragVector.dragVectorY;
|
||||
const jumpWorldLength = Math.hypot(jumpWorldX, jumpWorldY);
|
||||
if (jumpWorldLength < 0.0001) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxDragDistance =
|
||||
run.path.scoring.maxChargeMs > 0 ? run.path.scoring.maxChargeMs : 180;
|
||||
const chargeToDistanceRatio =
|
||||
run.path.scoring.chargeToDistanceRatio > 0
|
||||
? run.path.scoring.chargeToDistanceRatio
|
||||
: 0.008;
|
||||
const projectedWorldDistance =
|
||||
clamp(dragDistance, 0, maxDragDistance) * chargeToDistanceRatio;
|
||||
const landedWorldDeltaX =
|
||||
(jumpWorldX / jumpWorldLength) * projectedWorldDistance;
|
||||
const landedWorldDeltaY =
|
||||
(jumpWorldY / jumpWorldLength) * projectedWorldDistance;
|
||||
const landedPixelX =
|
||||
scales.currentCanvasPosition.x +
|
||||
landedWorldDeltaX * scales.signedXScreenPerWorld;
|
||||
const landedPixelY =
|
||||
scales.currentCanvasPosition.y +
|
||||
landedWorldDeltaY * scales.signedYScreenPerWorld;
|
||||
|
||||
return {
|
||||
screenX: clamp((landedPixelX / stageSize.width) * 100, 6, 94),
|
||||
screenY: clamp((landedPixelY / stageSize.height) * 100, 10, 92),
|
||||
targetPlatformIndex: targetPlatform.index,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveJumpHopCharacterCanvasPosition(
|
||||
characterPosition: JumpHopCharacterVisualPosition | null,
|
||||
size: JumpHopCanvasSize,
|
||||
) {
|
||||
if (!characterPosition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: (characterPosition.screenX / 100) * size.width,
|
||||
y: (characterPosition.screenY / 100) * size.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function getJumpHopCharacterVisualPosition(
|
||||
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||
platforms: JumpHopVisiblePlatform[],
|
||||
) {
|
||||
if (!run) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const landedPlatform = platforms.find(
|
||||
(item) => item.index === run.currentPlatformIndex,
|
||||
);
|
||||
if (landedPlatform) {
|
||||
return {
|
||||
screenX: landedPlatform.screenX,
|
||||
screenY: landedPlatform.screenY - 3,
|
||||
sceneX: landedPlatform.sceneX,
|
||||
sceneY: landedPlatform.sceneY + 0.84,
|
||||
sceneZ: landedPlatform.sceneZ,
|
||||
isMiss: false,
|
||||
};
|
||||
}
|
||||
|
||||
const lastJump = run.lastJump;
|
||||
if (lastJump && run.status === 'failed') {
|
||||
const targetPlatform = platforms.find(
|
||||
(item) => item.index === lastJump.targetPlatformIndex,
|
||||
);
|
||||
if (targetPlatform) {
|
||||
return {
|
||||
screenX: targetPlatform.screenX + 8,
|
||||
screenY: targetPlatform.screenY - 2,
|
||||
sceneX: targetPlatform.sceneX + 0.7,
|
||||
sceneY: targetPlatform.sceneY + 0.48,
|
||||
sceneZ: targetPlatform.sceneZ - 0.4,
|
||||
isMiss: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getJumpHopRunDurationMs(
|
||||
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||
nowMs: number,
|
||||
) {
|
||||
if (!run) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (run.status === 'playing' && run.startedAtMs > 0) {
|
||||
return Math.max(0, nowMs - run.startedAtMs);
|
||||
}
|
||||
|
||||
return run.durationMs;
|
||||
}
|
||||
|
||||
export function formatJumpHopDurationLabel(durationMs: number) {
|
||||
const safeDuration = Math.max(0, Math.floor(durationMs));
|
||||
const totalSeconds = Math.floor(safeDuration / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
export function getJumpHopStatusLabel(
|
||||
status: JumpHopRunStatus | undefined,
|
||||
) {
|
||||
if (status === 'cleared') {
|
||||
return '结束';
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return '失败';
|
||||
}
|
||||
return '进行中';
|
||||
}
|
||||
|
||||
export function getJumpHopJumpFeedbackLabel(
|
||||
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||
) {
|
||||
const result = run?.lastJump?.result;
|
||||
if (result === 'perfect') {
|
||||
return '落地';
|
||||
}
|
||||
if (result === 'finish') {
|
||||
return '落地';
|
||||
}
|
||||
if (result === 'hit') {
|
||||
return '落地';
|
||||
}
|
||||
if (result === 'miss') {
|
||||
return '落空';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getJumpHopTileTone(tileType: JumpHopTileType) {
|
||||
return tileToneByType[tileType];
|
||||
}
|
||||
86
src/services/jump-hop/useJumpHopLeaderboard.test.tsx
Normal file
86
src/services/jump-hop/useJumpHopLeaderboard.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getStoredAccessToken,
|
||||
setStoredAccessToken,
|
||||
} from '../apiClient';
|
||||
import { ensureRuntimeGuestToken } from '../authService';
|
||||
import {
|
||||
jumpHopClient,
|
||||
type JumpHopLeaderboardResponse,
|
||||
} from './jumpHopClient';
|
||||
import { useJumpHopLeaderboard } from './useJumpHopLeaderboard';
|
||||
|
||||
vi.mock('../authService', () => ({
|
||||
ensureRuntimeGuestToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./jumpHopClient', () => ({
|
||||
jumpHopClient: {
|
||||
getLeaderboard: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const leaderboardResponse: JumpHopLeaderboardResponse = {
|
||||
profileId: 'jump-hop-profile-test',
|
||||
items: [
|
||||
{
|
||||
rank: 1,
|
||||
playerId: 'player-1',
|
||||
successfulJumpCount: 10,
|
||||
durationMs: 3210,
|
||||
updatedAt: '2026-05-27T00:00:00Z',
|
||||
},
|
||||
],
|
||||
viewerBest: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setStoredAccessToken('', { emit: false });
|
||||
vi.mocked(ensureRuntimeGuestToken).mockResolvedValue({
|
||||
token: 'runtime-guest-token',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
subject: 'guest-runtime-test',
|
||||
scope: 'public-play',
|
||||
});
|
||||
vi.mocked(jumpHopClient.getLeaderboard).mockResolvedValue(
|
||||
leaderboardResponse,
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳排行榜在已有登录态时使用本地账号请求,不再额外申请 guest token', async () => {
|
||||
setStoredAccessToken('stored-access-token', { emit: false });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useJumpHopLeaderboard('jump-hop-profile-test'),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(getStoredAccessToken()).toBe('stored-access-token');
|
||||
expect(ensureRuntimeGuestToken).not.toHaveBeenCalled();
|
||||
expect(jumpHopClient.getLeaderboard).toHaveBeenCalledWith(
|
||||
'jump-hop-profile-test',
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
expect(result.current.leaderboard).toEqual(leaderboardResponse);
|
||||
});
|
||||
test('跳一跳排行榜在匿名模式下会申请 guest token', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useJumpHopLeaderboard('jump-hop-profile-test'),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(ensureRuntimeGuestToken).toHaveBeenCalledTimes(1);
|
||||
expect(jumpHopClient.getLeaderboard).toHaveBeenCalledWith(
|
||||
'jump-hop-profile-test',
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
);
|
||||
expect(result.current.leaderboard).toEqual(leaderboardResponse);
|
||||
});
|
||||
85
src/services/jump-hop/useJumpHopLeaderboard.ts
Normal file
85
src/services/jump-hop/useJumpHopLeaderboard.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
getStoredAccessToken,
|
||||
} from '../apiClient';
|
||||
import { ensureRuntimeGuestToken } from '../authService';
|
||||
import {
|
||||
jumpHopClient,
|
||||
type JumpHopLeaderboardResponse,
|
||||
type JumpHopRuntimeRequestOptions,
|
||||
} from './jumpHopClient';
|
||||
|
||||
type JumpHopLeaderboardState = {
|
||||
leaderboard: JumpHopLeaderboardResponse | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useJumpHopLeaderboard(
|
||||
profileId: string | null | undefined,
|
||||
runtimeRequestOptions?: JumpHopRuntimeRequestOptions,
|
||||
): JumpHopLeaderboardState {
|
||||
const normalizedProfileId = profileId?.trim() ?? '';
|
||||
const [leaderboard, setLeaderboard] =
|
||||
useState<JumpHopLeaderboardResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useMemo(
|
||||
() => async () => {
|
||||
if (!normalizedProfileId) {
|
||||
setLeaderboard(null);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (runtimeRequestOptions) {
|
||||
const response = await jumpHopClient.getLeaderboard(
|
||||
normalizedProfileId,
|
||||
runtimeRequestOptions,
|
||||
);
|
||||
setLeaderboard(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (getStoredAccessToken()) {
|
||||
const response = await jumpHopClient.getLeaderboard(
|
||||
normalizedProfileId,
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
);
|
||||
setLeaderboard(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeGuest = await ensureRuntimeGuestToken();
|
||||
const response = await jumpHopClient.getLeaderboard(
|
||||
normalizedProfileId,
|
||||
{ runtimeGuestToken: runtimeGuest.token },
|
||||
);
|
||||
setLeaderboard(response);
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '读取排行榜失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[normalizedProfileId, runtimeRequestOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { leaderboard, isLoading, error, refresh };
|
||||
}
|
||||
@@ -490,7 +490,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('jump hop draft generation exposes character and tile atlas pipeline', () => {
|
||||
test('jump hop draft generation exposes theme and tile atlas pipeline', () => {
|
||||
const state = createMiniGameDraftGenerationState('jump-hop');
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(
|
||||
@@ -500,23 +500,20 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
|
||||
expect(progress?.steps.map((step) => step.id)).toEqual([
|
||||
'jump-hop-draft',
|
||||
'jump-hop-character',
|
||||
'jump-hop-tile-atlas',
|
||||
'jump-hop-slice-tiles',
|
||||
'jump-hop-write-draft',
|
||||
]);
|
||||
expect(progress?.phaseId).toBe('jump-hop-character');
|
||||
expect(progress?.phaseLabel).toBe('生成角色形象');
|
||||
expect(progress?.phaseId).toBe('jump-hop-tile-atlas');
|
||||
expect(progress?.phaseLabel).toBe('生成 5x5 地块图集');
|
||||
expect(progress?.estimatedRemainingMs).toBe(265_000);
|
||||
});
|
||||
|
||||
test('jump hop generation anchors expose theme, character and tile style', () => {
|
||||
test('jump hop generation anchors expose theme and tile atlas', () => {
|
||||
const entries = buildJumpHopGenerationAnchorEntries(null, {
|
||||
themeText: '云端糖果塔',
|
||||
characterDescription: '披着星星披风的小旅人',
|
||||
tileStyle: '纸模玩具',
|
||||
difficulty: '标准',
|
||||
rhythmPreference: '轻快',
|
||||
templateId: 'jump-hop',
|
||||
tilePrompt: '云端糖果塔主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
@@ -525,15 +522,10 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
label: '主题',
|
||||
value: '云端糖果塔',
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-character',
|
||||
label: '角色',
|
||||
value: '披着星星披风的小旅人',
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-tile-style',
|
||||
label: '地块',
|
||||
value: '纸模玩具',
|
||||
label: '地块图集',
|
||||
value: '云端糖果塔主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -68,7 +68,6 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'baby-object-images'
|
||||
| 'baby-object-ready'
|
||||
| 'jump-hop-draft'
|
||||
| 'jump-hop-character'
|
||||
| 'jump-hop-tile-atlas'
|
||||
| 'jump-hop-slice-tiles'
|
||||
| 'jump-hop-write-draft'
|
||||
@@ -404,32 +403,26 @@ const JUMP_HOP_STEPS = [
|
||||
{
|
||||
id: 'jump-hop-draft',
|
||||
label: '整理玩法草稿',
|
||||
detail: '建立主题、难度和路径基础数据。',
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-character',
|
||||
label: '生成角色形象',
|
||||
detail: '生成可进入运行态的俯视角角色图。',
|
||||
weight: 34,
|
||||
detail: '保存主题并派生作品信息和默认角色配置。',
|
||||
weight: 12,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-tile-atlas',
|
||||
label: '生成地块图集',
|
||||
detail: '生成起点、普通、目标和终点地块图集。',
|
||||
weight: 34,
|
||||
label: '生成 5x5 地块图集',
|
||||
detail: '调用 image2 生成 25 个主题地块素材。',
|
||||
weight: 54,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-slice-tiles',
|
||||
label: '切分地块素材',
|
||||
detail: '切分透明地块 PNG 并校验落点半径。',
|
||||
weight: 14,
|
||||
label: '切分 25 个地块',
|
||||
detail: '按 5 行 5 列切分透明地块 PNG。',
|
||||
weight: 24,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-write-draft',
|
||||
label: '写入正式草稿',
|
||||
detail: '保存角色、地块、路径和封面合成结果。',
|
||||
weight: 8,
|
||||
detail: '保存地块池、无限路径缓冲和运行态配置。',
|
||||
weight: 10,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
@@ -709,7 +702,7 @@ function resolveJumpHopPhaseByElapsedMs(
|
||||
return 'jump-hop-tile-atlas';
|
||||
}
|
||||
if (elapsedMs >= 12_000) {
|
||||
return 'jump-hop-character';
|
||||
return 'jump-hop-tile-atlas';
|
||||
}
|
||||
return 'jump-hop-draft';
|
||||
}
|
||||
@@ -1188,21 +1181,12 @@ export function buildJumpHopGenerationAnchorEntries(
|
||||
draft?.workTitle?.trim() ||
|
||||
'',
|
||||
},
|
||||
{
|
||||
key: 'jump-hop-character',
|
||||
label: '角色',
|
||||
value:
|
||||
formPayload?.characterDescription?.trim() ||
|
||||
config?.characterDescription?.trim() ||
|
||||
draft?.characterPrompt?.trim() ||
|
||||
'',
|
||||
},
|
||||
{
|
||||
key: 'jump-hop-tile-style',
|
||||
label: '地块',
|
||||
label: '地块图集',
|
||||
value:
|
||||
formPayload?.tileStyle?.trim() ||
|
||||
config?.tileStyle?.trim() ||
|
||||
formPayload?.tilePrompt?.trim() ||
|
||||
config?.tilePrompt?.trim() ||
|
||||
draft?.stylePreset?.trim() ||
|
||||
'',
|
||||
},
|
||||
|
||||
@@ -50,3 +50,16 @@ test('wooden fish list works uses creation works endpoint', async () => {
|
||||
'读取敲木鱼作品列表失败',
|
||||
);
|
||||
});
|
||||
|
||||
test('wooden fish delete work uses creation works endpoint', async () => {
|
||||
const { woodenFishClient } = await import('./woodenFishClient');
|
||||
requestJsonMock.mockResolvedValueOnce({ items: [] });
|
||||
|
||||
await woodenFishClient.deleteWork('wooden-fish-profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/creation/wooden-fish/works/wooden-fish-profile-1',
|
||||
{ method: 'DELETE' },
|
||||
'删除敲木鱼作品失败',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -233,6 +233,14 @@ export async function publishWoodenFishWork(profileId: string) {
|
||||
return normalizeWoodenFishWorkMutationResponse(response);
|
||||
}
|
||||
|
||||
export async function deleteWoodenFishWork(profileId: string) {
|
||||
return requestJson<WoodenFishWorksResponse>(
|
||||
`${WOODEN_FISH_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除敲木鱼作品失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function startWoodenFishRuntimeRun(
|
||||
profileId: string,
|
||||
options: WoodenFishRuntimeRequestOptions = {},
|
||||
@@ -317,6 +325,7 @@ export async function finishWoodenFishRun(
|
||||
export const woodenFishClient = {
|
||||
checkpointRun: checkpointWoodenFishRun,
|
||||
createSession: createWoodenFishCreationSession,
|
||||
deleteWork: deleteWoodenFishWork,
|
||||
executeAction: executeWoodenFishCreationAction,
|
||||
finishRun: finishWoodenFishRun,
|
||||
getGalleryDetail: getWoodenFishGalleryDetail,
|
||||
|
||||
Reference in New Issue
Block a user