1
This commit is contained in:
@@ -6,6 +6,7 @@ import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
|
||||
import { AuthGate } from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
@@ -95,6 +96,7 @@ const mockUser: AuthUser = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState(null, '', '/');
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
|
||||
@@ -141,6 +143,15 @@ beforeEach(() => {
|
||||
authMocks.startWechatLogin.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
async function acceptLegalConsent(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
dialog: HTMLElement,
|
||||
) {
|
||||
await user.click(
|
||||
within(dialog).getByRole('switch', { name: '同意法律协议' }),
|
||||
);
|
||||
}
|
||||
|
||||
function ProtectedActionButton({
|
||||
onAuthenticated,
|
||||
}: {
|
||||
@@ -346,6 +357,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('验证码'), '123456');
|
||||
await acceptLegalConsent(user, dialog);
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -360,6 +372,70 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
});
|
||||
|
||||
test('login modal requires first-time legal consent before sms login', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('验证码'), '123456');
|
||||
|
||||
const loginButton = within(dialog).getByRole('button', { name: '登录' });
|
||||
const legalSwitch = within(dialog).getByRole('switch', {
|
||||
name: '同意法律协议',
|
||||
});
|
||||
expect((loginButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(legalSwitch.getAttribute('aria-checked')).toBe('false');
|
||||
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', { name: '《用户协议》' }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '用户协议' }),
|
||||
).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '我知道了' }));
|
||||
expect(legalSwitch.getAttribute('aria-checked')).toBe('false');
|
||||
|
||||
await user.click(legalSwitch);
|
||||
expect(legalSwitch.getAttribute('aria-checked')).toBe('true');
|
||||
expect(window.localStorage.getItem(LEGAL_CONSENT_STORAGE_KEY)).toBe('true');
|
||||
expect((loginButton as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test('login modal defaults legal consent to checked after stored confirmation', async () => {
|
||||
const user = userEvent.setup();
|
||||
window.localStorage.setItem(LEGAL_CONSENT_STORAGE_KEY, 'true');
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
const legalSwitch = within(dialog).getByRole('switch', {
|
||||
name: '同意法律协议',
|
||||
});
|
||||
expect(legalSwitch.getAttribute('aria-checked')).toBe('true');
|
||||
});
|
||||
|
||||
test('phone login result is not overwritten by an older guest hydrate', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAuthenticated = vi.fn();
|
||||
@@ -387,6 +463,7 @@ test('phone login result is not overwritten by an older guest hydrate', async ()
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('验证码'), '123456');
|
||||
await acceptLegalConsent(user, dialog);
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
|
||||
@@ -425,6 +502,7 @@ test('auth gate hides register entry and opens invite modal for new sms account'
|
||||
expect(within(dialog).queryByLabelText('邀请码')).toBeNull();
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('验证码'), '123456');
|
||||
await acceptLegalConsent(user, dialog);
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -475,6 +553,7 @@ test('registration invite modal can skip when invite code is empty', async () =>
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('验证码'), '123456');
|
||||
await acceptLegalConsent(user, dialog);
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
const inviteDialog = await screen.findByRole('dialog', {
|
||||
@@ -700,6 +779,7 @@ test('auth gate separates sms and password login by tabs', async () => {
|
||||
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('密码'), 'passw0rd');
|
||||
await acceptLegalConsent(user, dialog);
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
@@ -7,6 +7,13 @@ import type {
|
||||
AuthLoginMethod,
|
||||
} from '../../services/authService';
|
||||
import { getStoredLastLoginPhone } from '../../services/authService';
|
||||
import { LegalDocumentModal } from '../common/LegalDocumentModal';
|
||||
import {
|
||||
getLegalDocument,
|
||||
type LegalDocumentId,
|
||||
persistLegalConsent,
|
||||
readStoredLegalConsent,
|
||||
} from '../common/legalDocuments';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type SmsScene = 'login' | 'reset_password';
|
||||
@@ -70,6 +77,9 @@ export function LoginScreen({
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0);
|
||||
const [hint, setHint] = useState('');
|
||||
const [legalConsentChecked, setLegalConsentChecked] = useState(false);
|
||||
const [activeLegalDocumentId, setActiveLegalDocumentId] =
|
||||
useState<LegalDocumentId | null>(null);
|
||||
const passwordLoginEnabled = availableLoginMethods.includes('password');
|
||||
const phoneLoginEnabled = availableLoginMethods.includes('phone');
|
||||
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
|
||||
@@ -92,6 +102,8 @@ export function LoginScreen({
|
||||
setCooldownSeconds(0);
|
||||
setResetCooldownSeconds(0);
|
||||
setHint('');
|
||||
setLegalConsentChecked(readStoredLegalConsent());
|
||||
setActiveLegalDocumentId(null);
|
||||
setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password');
|
||||
}, [isOpen, phoneLoginEnabled]);
|
||||
|
||||
@@ -143,89 +155,117 @@ export function LoginScreen({
|
||||
}
|
||||
|
||||
const submitDisabled = loggingIn || sendingCode;
|
||||
const activeLegalDocument = activeLegalDocumentId
|
||||
? getLegalDocument(activeLegalDocumentId)
|
||||
: null;
|
||||
const toggleLegalConsent = () => {
|
||||
setLegalConsentChecked((current) => {
|
||||
const nextChecked = !current;
|
||||
if (nextChecked) {
|
||||
persistLegalConsent();
|
||||
}
|
||||
return nextChecked;
|
||||
});
|
||||
};
|
||||
const legalConsentRow = (
|
||||
<LegalConsentRow
|
||||
checked={legalConsentChecked}
|
||||
onToggle={toggleLegalConsent}
|
||||
onOpenDocument={setActiveLegalDocumentId}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[120] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="auth-login-dialog-title"
|
||||
className="platform-auth-card w-full max-w-md overflow-hidden rounded-[2rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[120] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<div
|
||||
id="auth-login-dialog-title"
|
||||
className="text-lg font-semibold text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{isResetPanelOpen ? '重置密码' : '账号入口'}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="auth-login-dialog-title"
|
||||
className="platform-auth-card w-full max-w-md overflow-hidden rounded-[2rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<div
|
||||
id="auth-login-dialog-title"
|
||||
className="text-lg font-semibold text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{isResetPanelOpen ? '重置密码' : '账号入口'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-icon-button p-2"
|
||||
aria-label="关闭登录弹窗"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-icon-button p-2"
|
||||
aria-label="关闭登录弹窗"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isResetPanelOpen ? (
|
||||
<PasswordResetPanel
|
||||
phone={resetPhone}
|
||||
code={resetCode}
|
||||
password={resetPasswordValue}
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
cooldownSeconds={resetCooldownSeconds}
|
||||
error={error}
|
||||
onPhoneChange={setResetPhone}
|
||||
onCodeChange={setResetCode}
|
||||
onPasswordChange={setResetPasswordValue}
|
||||
onBack={() => setIsResetPanelOpen(false)}
|
||||
onSendCode={async () => {
|
||||
const result = await onSendCode(resetPhone, 'reset_password');
|
||||
setResetCooldownSeconds(result.cooldownSeconds);
|
||||
}}
|
||||
onSubmit={() =>
|
||||
onResetPassword(resetPhone, resetCode, resetPasswordValue)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 px-5 py-5">
|
||||
{phoneLoginEnabled ? (
|
||||
<div
|
||||
className={`grid gap-2 ${
|
||||
passwordLoginEnabled ? 'grid-cols-2' : 'grid-cols-1'
|
||||
}`}
|
||||
role="tablist"
|
||||
aria-label="登录方式"
|
||||
>
|
||||
<LoginTabButton
|
||||
active={activeLoginTab === 'phone'}
|
||||
onClick={() => setActiveLoginTab('phone')}
|
||||
{isResetPanelOpen ? (
|
||||
<PasswordResetPanel
|
||||
phone={resetPhone}
|
||||
code={resetCode}
|
||||
password={resetPasswordValue}
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
cooldownSeconds={resetCooldownSeconds}
|
||||
error={error}
|
||||
onPhoneChange={setResetPhone}
|
||||
onCodeChange={setResetCode}
|
||||
onPasswordChange={setResetPasswordValue}
|
||||
onBack={() => setIsResetPanelOpen(false)}
|
||||
onSendCode={async () => {
|
||||
const result = await onSendCode(resetPhone, 'reset_password');
|
||||
setResetCooldownSeconds(result.cooldownSeconds);
|
||||
}}
|
||||
onSubmit={() =>
|
||||
onResetPassword(resetPhone, resetCode, resetPasswordValue)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 px-5 py-5">
|
||||
{phoneLoginEnabled ? (
|
||||
<div
|
||||
className={`grid gap-2 ${
|
||||
passwordLoginEnabled ? 'grid-cols-2' : 'grid-cols-1'
|
||||
}`}
|
||||
role="tablist"
|
||||
aria-label="登录方式"
|
||||
>
|
||||
短信登录
|
||||
</LoginTabButton>
|
||||
{passwordLoginEnabled ? (
|
||||
<LoginTabButton
|
||||
active={activeLoginTab === 'password'}
|
||||
onClick={() => setActiveLoginTab('password')}
|
||||
active={activeLoginTab === 'phone'}
|
||||
onClick={() => setActiveLoginTab('phone')}
|
||||
>
|
||||
密码登录
|
||||
短信登录
|
||||
</LoginTabButton>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{passwordLoginEnabled ? (
|
||||
<LoginTabButton
|
||||
active={activeLoginTab === 'password'}
|
||||
onClick={() => setActiveLoginTab('password')}
|
||||
>
|
||||
密码登录
|
||||
</LoginTabButton>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{passwordLoginEnabled && activeLoginTab === 'password' ? (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (
|
||||
submitDisabled ||
|
||||
!phone.trim() ||
|
||||
!password.trim() ||
|
||||
!legalConsentChecked
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void onPasswordSubmit(phone, password);
|
||||
}}
|
||||
>
|
||||
@@ -253,12 +293,16 @@ export function LoginScreen({
|
||||
</label>
|
||||
|
||||
{error ? <ErrorBanner message={error} /> : null}
|
||||
{legalConsentRow}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
submitDisabled || !phone.trim() || !password.trim()
|
||||
submitDisabled ||
|
||||
!phone.trim() ||
|
||||
!password.trim() ||
|
||||
!legalConsentChecked
|
||||
}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
@@ -296,6 +340,8 @@ export function LoginScreen({
|
||||
hint={hint}
|
||||
submitLabel="登录"
|
||||
enabled={phoneLoginEnabled}
|
||||
legalConsentChecked={legalConsentChecked}
|
||||
legalConsentNode={legalConsentRow}
|
||||
showPhoneField
|
||||
onPhoneChange={setPhone}
|
||||
onCodeChange={setCode}
|
||||
@@ -323,13 +369,89 @@ export function LoginScreen({
|
||||
当前登录入口暂不可用。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<LegalDocumentModal
|
||||
document={activeLegalDocument}
|
||||
open={Boolean(activeLegalDocument)}
|
||||
platformTheme={platformTheme}
|
||||
onClose={() => setActiveLegalDocumentId(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalConsentRow({
|
||||
checked,
|
||||
onToggle,
|
||||
onOpenDocument,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
onOpenDocument: (documentId: LegalDocumentId) => void;
|
||||
}) {
|
||||
const openDocument = (documentId: LegalDocumentId) => {
|
||||
onOpenDocument(documentId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 text-xs leading-5 text-[var(--platform-text-base)]">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label="同意法律协议"
|
||||
onClick={onToggle}
|
||||
className={`mt-0.5 flex h-5 w-9 shrink-0 items-center rounded-full border p-0.5 transition ${
|
||||
checked
|
||||
? 'justify-end border-[var(--platform-button-primary-border)] [background:var(--platform-profile-action-fill)]'
|
||||
: 'justify-start border-[var(--platform-subpanel-border)] [background:var(--platform-button-secondary-fill)]'
|
||||
}`}
|
||||
>
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-[var(--platform-button-primary-text)] text-[var(--platform-cool-text)] shadow-sm">
|
||||
{checked ? <Check className="h-3 w-3" /> : null}
|
||||
</span>
|
||||
</button>
|
||||
<div>
|
||||
我已阅读并同意
|
||||
<LegalLink
|
||||
label="《用户协议》"
|
||||
onClick={() => openDocument('user-agreement')}
|
||||
/>
|
||||
<LegalLink
|
||||
label="《隐私政策》"
|
||||
onClick={() => openDocument('privacy-policy')}
|
||||
/>
|
||||
和
|
||||
<LegalLink
|
||||
label="《免责声明》"
|
||||
onClick={() => openDocument('disclaimer')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalLink({
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="mx-0.5 align-baseline font-semibold text-[var(--platform-cool-text)] underline-offset-2 hover:underline"
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginTabButton({
|
||||
active,
|
||||
children,
|
||||
@@ -371,6 +493,8 @@ function PhoneCodeForm({
|
||||
hint,
|
||||
submitLabel,
|
||||
enabled,
|
||||
legalConsentChecked,
|
||||
legalConsentNode,
|
||||
showPhoneField,
|
||||
onPhoneChange,
|
||||
onCodeChange,
|
||||
@@ -389,6 +513,8 @@ function PhoneCodeForm({
|
||||
hint: string;
|
||||
submitLabel: string;
|
||||
enabled: boolean;
|
||||
legalConsentChecked: boolean;
|
||||
legalConsentNode: ReactNode;
|
||||
showPhoneField: boolean;
|
||||
onPhoneChange: (value: string) => void;
|
||||
onCodeChange: (value: string) => void;
|
||||
@@ -400,11 +526,17 @@ function PhoneCodeForm({
|
||||
return null;
|
||||
}
|
||||
|
||||
const submitBlocked =
|
||||
loggingIn || !phone.trim() || !code.trim() || !legalConsentChecked;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (submitBlocked) {
|
||||
return;
|
||||
}
|
||||
void onSubmit();
|
||||
}}
|
||||
>
|
||||
@@ -455,10 +587,11 @@ function PhoneCodeForm({
|
||||
|
||||
{hint ? <SuccessBanner message={hint} /> : null}
|
||||
{error ? <ErrorBanner message={error} /> : null}
|
||||
{legalConsentNode}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loggingIn || !phone.trim() || !code.trim()}
|
||||
disabled={submitBlocked}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '处理中' : submitLabel}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
LegalDocument,
|
||||
LegalDocumentBlock,
|
||||
} from './legalDocuments';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
|
||||
type LegalDocumentModalProps = {
|
||||
document: LegalDocument | null;
|
||||
open: boolean;
|
||||
platformTheme?: PlatformTheme;
|
||||
zIndexClassName?: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function LegalRichText({ text }: { text: string }) {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*)/gu);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
const boldMatch = /^\*\*([^*]+)\*\*$/u.exec(part);
|
||||
if (boldMatch) {
|
||||
return (
|
||||
<strong
|
||||
key={`${index}:${part}`}
|
||||
className="font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{boldMatch[1]}
|
||||
</strong>
|
||||
);
|
||||
}
|
||||
|
||||
return part;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalDocumentBodyBlock({
|
||||
block,
|
||||
}: {
|
||||
block: LegalDocumentBlock;
|
||||
}) {
|
||||
if (block.type === 'heading') {
|
||||
const className =
|
||||
block.level === 2
|
||||
? 'mt-5 text-base font-black leading-7 text-[var(--platform-text-strong)] first:mt-0'
|
||||
: 'mt-4 text-sm font-black leading-6 text-[var(--platform-text-strong)] first:mt-0';
|
||||
|
||||
return <div className={className}>{block.text}</div>;
|
||||
}
|
||||
|
||||
if (block.type === 'list') {
|
||||
return (
|
||||
<ul className="mt-3 list-disc space-y-2 pl-5 text-sm leading-7 text-[var(--platform-text-base)]">
|
||||
{block.items.map((item, index) => (
|
||||
<li key={`${index}:${item}`}>
|
||||
<LegalRichText text={item} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-[var(--platform-text-base)]">
|
||||
<LegalRichText text={block.text} />
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegalDocumentModal({
|
||||
document,
|
||||
open,
|
||||
platformTheme,
|
||||
zIndexClassName,
|
||||
onClose,
|
||||
}: LegalDocumentModalProps) {
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open && Boolean(document)}
|
||||
title={document?.title ?? '法律信息'}
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
closeLabel="关闭法律信息"
|
||||
zIndexClassName={zIndexClassName ?? 'z-[150]'}
|
||||
overlayClassName={`platform-theme ${
|
||||
platformTheme ? `platform-theme--${platformTheme}` : ''
|
||||
}`}
|
||||
panelClassName="platform-remap-surface rounded-t-[1.4rem] sm:rounded-[1.4rem]"
|
||||
headerClassName="items-center"
|
||||
bodyClassName="px-4 py-0 sm:px-5"
|
||||
footerClassName="justify-stretch sm:justify-end"
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-button platform-button--secondary min-h-0 w-full rounded-[0.9rem] px-4 py-2.5 text-sm sm:w-auto"
|
||||
>
|
||||
我知道了
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="max-h-[min(64vh,34rem)] overflow-y-auto py-4 pr-1">
|
||||
<div className="mb-4 flex items-center gap-2 text-[var(--platform-cool-text)]">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-xs font-black tracking-[0.2em]">
|
||||
LEGAL
|
||||
</span>
|
||||
</div>
|
||||
{document?.blocks.map((block, index) => (
|
||||
<LegalDocumentBodyBlock
|
||||
// 中文注释:法律文本没有稳定段落 id,用序号只限定在静态文档渲染列表内。
|
||||
key={`${block.type}:${index}`}
|
||||
block={block}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import disclaimerMarkdown from '../../../media/files/disclaimer.md?raw';
|
||||
import privacyPolicyMarkdown from '../../../media/files/privacy_policy.md?raw';
|
||||
import userAgreementMarkdown from '../../../media/files/user_agreement.md?raw';
|
||||
|
||||
export type LegalDocumentId =
|
||||
| 'user-agreement'
|
||||
| 'privacy-policy'
|
||||
| 'disclaimer';
|
||||
|
||||
export type LegalDocumentBlock =
|
||||
| {
|
||||
type: 'heading';
|
||||
level: 2 | 3;
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: 'paragraph';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: 'list';
|
||||
items: string[];
|
||||
};
|
||||
|
||||
export type LegalDocument = {
|
||||
id: LegalDocumentId;
|
||||
title: string;
|
||||
markdown: string;
|
||||
blocks: LegalDocumentBlock[];
|
||||
};
|
||||
|
||||
export const LEGAL_CONSENT_STORAGE_KEY =
|
||||
'genarrative.auth.legal-consent.v1';
|
||||
export const ICP_RECORD_NUMBER = '京ICP备2026025677号';
|
||||
export const ICP_RECORD_URL = 'https://beian.miit.gov.cn/';
|
||||
|
||||
function normalizeMarkdownInlineText(value: string) {
|
||||
return value.replace(/`([^`]+)`/gu, '$1').trim();
|
||||
}
|
||||
|
||||
function pushParagraph(
|
||||
blocks: LegalDocumentBlock[],
|
||||
lines: string[],
|
||||
) {
|
||||
if (lines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = normalizeMarkdownInlineText(lines.join('\n'));
|
||||
if (text) {
|
||||
blocks.push({ type: 'paragraph', text });
|
||||
}
|
||||
lines.length = 0;
|
||||
}
|
||||
|
||||
function pushList(blocks: LegalDocumentBlock[], items: string[]) {
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: 'list',
|
||||
items: items.map(normalizeMarkdownInlineText).filter(Boolean),
|
||||
});
|
||||
items.length = 0;
|
||||
}
|
||||
|
||||
function parseLegalMarkdown(markdown: string): LegalDocumentBlock[] {
|
||||
const blocks: LegalDocumentBlock[] = [];
|
||||
const paragraphLines: string[] = [];
|
||||
const listItems: string[] = [];
|
||||
|
||||
markdown.split(/\r?\n/u).forEach((rawLine) => {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (!line) {
|
||||
pushParagraph(blocks, paragraphLines);
|
||||
pushList(blocks, listItems);
|
||||
return;
|
||||
}
|
||||
|
||||
const headingMatch = /^(#{2,3})\s+(.+)$/u.exec(line);
|
||||
if (headingMatch) {
|
||||
pushParagraph(blocks, paragraphLines);
|
||||
pushList(blocks, listItems);
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
level: headingMatch[1]?.length === 2 ? 2 : 3,
|
||||
text: normalizeMarkdownInlineText(headingMatch[2] ?? ''),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const listMatch = /^(?:[-*]|\d+[.)、])\s+(.+)$/u.exec(line);
|
||||
if (listMatch) {
|
||||
pushParagraph(blocks, paragraphLines);
|
||||
listItems.push(listMatch[1] ?? '');
|
||||
return;
|
||||
}
|
||||
|
||||
pushList(blocks, listItems);
|
||||
paragraphLines.push(line);
|
||||
});
|
||||
|
||||
pushParagraph(blocks, paragraphLines);
|
||||
pushList(blocks, listItems);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
const legalDocumentDefinitions = [
|
||||
{
|
||||
id: 'user-agreement',
|
||||
title: '用户协议',
|
||||
markdown: userAgreementMarkdown,
|
||||
},
|
||||
{
|
||||
id: 'privacy-policy',
|
||||
title: '隐私政策',
|
||||
markdown: privacyPolicyMarkdown,
|
||||
},
|
||||
{
|
||||
id: 'disclaimer',
|
||||
title: '免责声明',
|
||||
markdown: disclaimerMarkdown,
|
||||
},
|
||||
] satisfies Array<{
|
||||
id: LegalDocumentId;
|
||||
title: string;
|
||||
markdown: string;
|
||||
}>;
|
||||
|
||||
export const LEGAL_DOCUMENTS: LegalDocument[] = legalDocumentDefinitions.map(
|
||||
(document) => ({
|
||||
...document,
|
||||
blocks: parseLegalMarkdown(document.markdown),
|
||||
}),
|
||||
);
|
||||
|
||||
export function getLegalDocument(id: LegalDocumentId) {
|
||||
return LEGAL_DOCUMENTS.find((document) => document.id === id) ?? null;
|
||||
}
|
||||
|
||||
export function readStoredLegalConsent() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.localStorage.getItem(LEGAL_CONSENT_STORAGE_KEY) === 'true';
|
||||
}
|
||||
|
||||
export function persistLegalConsent() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(LEGAL_CONSENT_STORAGE_KEY, 'true');
|
||||
}
|
||||
@@ -36,10 +36,11 @@ const baseSession: Match3DAgentSessionSnapshot = {
|
||||
referenceImageSrc: null,
|
||||
clearCount: 8,
|
||||
difficulty: 3,
|
||||
assetStyleId: 'low-poly',
|
||||
assetStyleLabel: '低多边形',
|
||||
assetStyleId: 'cel-cartoon',
|
||||
assetStyleLabel: '赛璐璐卡通',
|
||||
assetStylePrompt:
|
||||
'块面清晰、轮廓简洁、颜色分区明确的低多边形 3D 素材风格。',
|
||||
'明亮赛璐璐卡通 2D 游戏道具风格,清晰线稿,硬边阴影,饱和配色,轮廓醒目。',
|
||||
generateClickSound: false,
|
||||
},
|
||||
draft: null,
|
||||
messages: [
|
||||
@@ -71,8 +72,9 @@ test('match3d workspace submits derived entry form payload instead of agent chat
|
||||
|
||||
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
|
||||
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
|
||||
expect(screen.getByText('3D素材风格')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '黏土手作' })).toBeTruthy();
|
||||
expect(screen.getByText('2D素材风格')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '生成音效' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '扁平图标' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '自定义' })).toBeTruthy();
|
||||
expect(screen.getByText('消耗20光点')).toBeTruthy();
|
||||
expect(screen.queryByText('参考图')).toBeNull();
|
||||
@@ -94,14 +96,16 @@ test('match3d workspace submits derived entry form payload instead of agent chat
|
||||
referenceImageSrc: null,
|
||||
clearCount: 16,
|
||||
difficulty: 6,
|
||||
assetStyleId: 'clay-toy',
|
||||
assetStyleLabel: '黏土手作',
|
||||
assetStylePrompt: '圆润、哑光、带轻微手捏痕迹的黏土手作 3D 素材风格。',
|
||||
assetStyleId: 'flat-icon',
|
||||
assetStyleLabel: '扁平图标',
|
||||
assetStylePrompt:
|
||||
'干净扁平的 2D 游戏道具图标风格,正面视角,色块清楚,边缘硬朗,高可读性,适合移动端休闲游戏素材。',
|
||||
generateClickSound: false,
|
||||
});
|
||||
expect(onExecuteAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('match3d workspace supports custom 3d asset style prompt', () => {
|
||||
test('match3d workspace supports custom 2d asset style prompt', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -119,7 +123,7 @@ test('match3d workspace supports custom 3d asset style prompt', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '自定义' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '自定义风格' })).toBeTruthy();
|
||||
fireEvent.change(screen.getByLabelText('自定义3D素材风格描述'), {
|
||||
fireEvent.change(screen.getByLabelText('自定义2D素材风格描述'), {
|
||||
target: { value: '透明果冻材质,边缘有柔和蓝色荧光' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }));
|
||||
@@ -138,6 +142,32 @@ test('match3d workspace supports custom 3d asset style prompt', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('match3d workspace can enable click sound generation from entry toggle', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
|
||||
target: { value: '海岛甜品' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成音效' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
themeText: '海岛甜品',
|
||||
generateClickSound: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('match3d workspace falls back to compile action when restored from the legacy route', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
@@ -157,14 +187,15 @@ test('match3d workspace falls back to compile action when restored from the lega
|
||||
screen.getByRole('button', { name: '轻松' }).getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '低多边形' }).getAttribute(
|
||||
'aria-pressed',
|
||||
),
|
||||
screen
|
||||
.getByRole('button', { name: '赛璐璐卡通' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'match3d_compile_draft',
|
||||
generateClickSound: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Loader2, Plus, Sparkles, WandSparkles, X } from 'lucide-react';
|
||||
import { Loader2, Music2, Plus, Sparkles, WandSparkles, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
@@ -26,13 +26,15 @@ type Match3DFormState = {
|
||||
difficultyOptionId: Match3DDifficultyOptionId;
|
||||
assetStyleId: Match3DAssetStyleOptionId;
|
||||
customAssetStylePrompt: string;
|
||||
generateClickSound: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_FORM_STATE: Match3DFormState = {
|
||||
themeText: '',
|
||||
difficultyOptionId: 'standard',
|
||||
assetStyleId: 'clay-toy',
|
||||
assetStyleId: 'flat-icon',
|
||||
customAssetStylePrompt: '',
|
||||
generateClickSound: false,
|
||||
};
|
||||
|
||||
// 中文注释:入口页只暴露难度选项,消除次数和难度数值由选项稳定派生给后端。
|
||||
@@ -40,7 +42,7 @@ const MATCH3D_DIFFICULTY_OPTIONS = [
|
||||
{ id: 'easy', label: '轻松', clearCount: 8, difficulty: 2 },
|
||||
{ id: 'standard', label: '标准', clearCount: 12, difficulty: 4 },
|
||||
{ id: 'advanced', label: '进阶', clearCount: 16, difficulty: 6 },
|
||||
{ id: 'hardcore', label: '硬核', clearCount: 20, difficulty: 8 },
|
||||
{ id: 'hardcore', label: '硬核', clearCount: 21, difficulty: 8 },
|
||||
] as const;
|
||||
|
||||
type Match3DDifficultyOptionId =
|
||||
@@ -48,40 +50,46 @@ type Match3DDifficultyOptionId =
|
||||
|
||||
const MATCH3D_ASSET_STYLE_OPTIONS = [
|
||||
{
|
||||
id: 'clay-toy',
|
||||
label: '黏土手作',
|
||||
imageSrc: '/match3d-style-references/clay-toy.png',
|
||||
prompt: '圆润、哑光、带轻微手捏痕迹的黏土手作 3D 素材风格。',
|
||||
id: 'flat-icon',
|
||||
label: '扁平图标',
|
||||
imageSrc: '/match3d-style-references/flat-icon.png',
|
||||
prompt:
|
||||
'干净扁平的 2D 游戏道具图标风格,正面视角,色块清楚,边缘硬朗,高可读性,适合移动端休闲游戏素材。',
|
||||
},
|
||||
{
|
||||
id: 'low-poly',
|
||||
label: '低多边形',
|
||||
imageSrc: '/match3d-style-references/low-poly.png',
|
||||
prompt: '块面清晰、轮廓简洁、颜色分区明确的低多边形 3D 素材风格。',
|
||||
id: 'cel-cartoon',
|
||||
label: '赛璐璐卡通',
|
||||
imageSrc: '/match3d-style-references/cel-cartoon.png',
|
||||
prompt:
|
||||
'明亮赛璐璐卡通 2D 游戏道具风格,清晰线稿,硬边阴影,饱和配色,轮廓醒目。',
|
||||
},
|
||||
{
|
||||
id: 'toy-plastic',
|
||||
label: '玩具塑料',
|
||||
imageSrc: '/match3d-style-references/toy-plastic.png',
|
||||
prompt: '亮面、光滑、有柔和高光的玩具塑料 3D 素材风格。',
|
||||
id: 'pixel-retro',
|
||||
label: '像素复古',
|
||||
imageSrc: '/match3d-style-references/pixel-retro.png',
|
||||
prompt:
|
||||
'复古像素 2D 游戏道具素材风格,有限色板,清晰像素边缘,主体轮廓稳定,不使用真实 3D 渲染。',
|
||||
},
|
||||
{
|
||||
id: 'wood-carved',
|
||||
label: '木质雕刻',
|
||||
imageSrc: '/match3d-style-references/wood-carved.png',
|
||||
prompt: '保留木纹和手工雕刻感的温润木质 3D 素材风格。',
|
||||
id: 'watercolor',
|
||||
label: '手绘水彩',
|
||||
imageSrc: '/match3d-style-references/watercolor.png',
|
||||
prompt:
|
||||
'手绘水彩 2D 道具素材风格,柔和纸张纹理,透明叠色,边缘轻微晕染,主体仍保持清楚可读。',
|
||||
},
|
||||
{
|
||||
id: 'voxel-block',
|
||||
label: '体素积木',
|
||||
imageSrc: '/match3d-style-references/voxel-block.png',
|
||||
prompt: '由小方块构成、边缘清晰、带游戏感的体素积木 3D 素材风格。',
|
||||
id: 'sticker-outline',
|
||||
label: '贴纸描边',
|
||||
imageSrc: '/match3d-style-references/sticker-outline.png',
|
||||
prompt:
|
||||
'贴纸描边 2D 游戏道具素材风格,粗白边与深色外轮廓,柔和投影,色彩活泼,适合休闲消除游戏。',
|
||||
},
|
||||
{
|
||||
id: 'metal-mecha',
|
||||
label: '金属机甲',
|
||||
imageSrc: '/match3d-style-references/metal-mecha.png',
|
||||
prompt: '带金属拉丝、柔和高光和轻科幻感的金属机甲 3D 素材风格。',
|
||||
id: 'painterly-icon',
|
||||
label: '厚涂图标',
|
||||
imageSrc: '/match3d-style-references/painterly-icon.png',
|
||||
prompt:
|
||||
'厚涂 2D 游戏道具图标风格,笔触细腻,体积光影明确,中心构图,保持图标级清晰剪影。',
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
@@ -149,7 +157,7 @@ function resolveAssetStyleOptionId(
|
||||
return matchedOption.id;
|
||||
}
|
||||
|
||||
return assetStylePrompt?.trim() ? 'custom' : 'clay-toy';
|
||||
return assetStylePrompt?.trim() ? 'custom' : 'flat-icon';
|
||||
}
|
||||
|
||||
function resolveInitialFormState(
|
||||
@@ -164,21 +172,13 @@ function resolveInitialFormState(
|
||||
initialFormPayload?.seedText?.trim() ||
|
||||
'';
|
||||
const clearCount =
|
||||
initialFormPayload?.clearCount ??
|
||||
config?.clearCount ??
|
||||
null;
|
||||
initialFormPayload?.clearCount ?? config?.clearCount ?? null;
|
||||
const difficulty =
|
||||
initialFormPayload?.difficulty ??
|
||||
config?.difficulty ??
|
||||
null;
|
||||
initialFormPayload?.difficulty ?? config?.difficulty ?? null;
|
||||
const assetStyleId =
|
||||
initialFormPayload?.assetStyleId ??
|
||||
config?.assetStyleId ??
|
||||
null;
|
||||
initialFormPayload?.assetStyleId ?? config?.assetStyleId ?? null;
|
||||
const assetStylePrompt =
|
||||
initialFormPayload?.assetStylePrompt ??
|
||||
config?.assetStylePrompt ??
|
||||
'';
|
||||
initialFormPayload?.assetStylePrompt ?? config?.assetStylePrompt ?? '';
|
||||
|
||||
return {
|
||||
...EMPTY_FORM_STATE,
|
||||
@@ -186,6 +186,10 @@ function resolveInitialFormState(
|
||||
difficultyOptionId: resolveDifficultyOptionId(difficulty, clearCount),
|
||||
assetStyleId: resolveAssetStyleOptionId(assetStyleId, assetStylePrompt),
|
||||
customAssetStylePrompt: assetStylePrompt,
|
||||
generateClickSound:
|
||||
initialFormPayload?.generateClickSound ??
|
||||
config?.generateClickSound ??
|
||||
false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -255,11 +259,13 @@ export function Match3DAgentWorkspace({
|
||||
assetStyleId: formState.assetStyleId,
|
||||
assetStyleLabel,
|
||||
assetStylePrompt,
|
||||
generateClickSound: formState.generateClickSound,
|
||||
}),
|
||||
[
|
||||
assetStyleLabel,
|
||||
assetStylePrompt,
|
||||
formState.assetStyleId,
|
||||
formState.generateClickSound,
|
||||
selectedDifficultyOption,
|
||||
themeText,
|
||||
],
|
||||
@@ -290,7 +296,10 @@ export function Match3DAgentWorkspace({
|
||||
}
|
||||
|
||||
if (session) {
|
||||
onExecuteAction({ action: 'match3d_compile_draft' });
|
||||
onExecuteAction({
|
||||
action: 'match3d_compile_draft',
|
||||
generateClickSound: formState.generateClickSound,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -350,11 +359,11 @@ export function Match3DAgentWorkspace({
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
|
||||
<div className="min-h-0 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/52 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)]">
|
||||
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
3D素材风格
|
||||
2D素材风格
|
||||
</div>
|
||||
<div
|
||||
className="flex snap-x gap-2.5 overflow-x-auto overscroll-x-contain pb-0.5 scrollbar-hide touch-pan-x [-webkit-overflow-scrolling:touch]"
|
||||
aria-label="3D素材风格"
|
||||
aria-label="2D素材风格"
|
||||
>
|
||||
{MATCH3D_ASSET_STYLE_OPTIONS.map((option) => {
|
||||
const selected = formState.assetStyleId === option.id;
|
||||
@@ -424,8 +433,7 @@ export function Match3DAgentWorkspace({
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2 lg:grid-cols-2">
|
||||
{MATCH3D_DIFFICULTY_OPTIONS.map((option) => {
|
||||
const selected =
|
||||
formState.difficultyOptionId === option.id;
|
||||
const selected = formState.difficultyOptionId === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
@@ -450,6 +458,43 @@ export function Match3DAgentWorkspace({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
generateClickSound: !current.generateClickSound,
|
||||
}))
|
||||
}
|
||||
className={`flex min-h-12 shrink-0 items-center justify-between gap-3 rounded-[1.05rem] border px-3 py-2.5 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 ${
|
||||
formState.generateClickSound
|
||||
? 'border-rose-200 bg-rose-50/80 text-rose-700 shadow-[0_8px_18px_rgba(244,63,94,0.10)]'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/58 text-[var(--platform-text-strong)] hover:border-rose-200 hover:bg-white/86'
|
||||
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-pressed={formState.generateClickSound}
|
||||
aria-label="生成音效"
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center gap-2">
|
||||
<Music2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate text-sm font-black">生成音效</span>
|
||||
</span>
|
||||
<span
|
||||
className={`relative h-6 w-11 shrink-0 rounded-full transition ${
|
||||
formState.generateClickSound
|
||||
? 'bg-rose-400'
|
||||
: 'bg-slate-200'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white shadow-sm transition ${
|
||||
formState.generateClickSound ? 'left-6' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -511,10 +556,12 @@ export function Match3DAgentWorkspace({
|
||||
</div>
|
||||
<textarea
|
||||
value={draftCustomStylePrompt}
|
||||
onChange={(event) => setDraftCustomStylePrompt(event.target.value)}
|
||||
onChange={(event) =>
|
||||
setDraftCustomStylePrompt(event.target.value)
|
||||
}
|
||||
rows={4}
|
||||
className="mt-4 h-[7.5rem] w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
||||
aria-label="自定义3D素材风格描述"
|
||||
aria-label="自定义2D素材风格描述"
|
||||
/>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Box, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { readAssetBytes } from '../../services/assetReadUrlService';
|
||||
import { isDebugMode } from '../../config/debugMode';
|
||||
import { readMatch3DGeneratedModelBytes } from '../../services/match3dGeneratedModelCache';
|
||||
|
||||
type ThreeModule = typeof import('three');
|
||||
type GltfPayload = import('three/examples/jsm/loaders/GLTFLoader.js').GLTF;
|
||||
@@ -55,6 +56,22 @@ function centerAndScaleModel(three: ThreeModule, model: import('three').Object3D
|
||||
model.position.sub(center);
|
||||
}
|
||||
|
||||
function shouldLogMatch3DModelPreviewDiagnostics() {
|
||||
return isDebugMode() && import.meta.env.MODE !== 'test';
|
||||
}
|
||||
|
||||
function warnMatch3DModelPreviewLoadFailure(source: string, error: unknown) {
|
||||
if (!shouldLogMatch3DModelPreviewDiagnostics()) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error || 'unknown error');
|
||||
console.warn('[match3d] model preview load failed', {
|
||||
source,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
export function Match3DModelPreview({
|
||||
modelSrc,
|
||||
className = '',
|
||||
@@ -87,8 +104,6 @@ export function Match3DModelPreview({
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const teardown = () => {
|
||||
const runtime = runtimeRef.current;
|
||||
if (runtime?.animationId != null) {
|
||||
@@ -98,10 +113,6 @@ export function Match3DModelPreview({
|
||||
runtime?.renderer.dispose();
|
||||
runtime?.renderer.domElement.remove();
|
||||
runtimeRef.current = null;
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = null;
|
||||
}
|
||||
canvasHost.replaceChildren();
|
||||
};
|
||||
|
||||
@@ -113,25 +124,19 @@ export function Match3DModelPreview({
|
||||
|
||||
setStatus('loading');
|
||||
try {
|
||||
const [three, loaderModule, response] = await Promise.all([
|
||||
const [three, loaderModule, bytes] = await Promise.all([
|
||||
import('three'),
|
||||
import('three/examples/jsm/loaders/GLTFLoader.js'),
|
||||
readAssetBytes(source, { expireSeconds: 600 }),
|
||||
readMatch3DGeneratedModelBytes(source, { expireSeconds: 600 }),
|
||||
]);
|
||||
if (cancelled || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (bytes.byteLength === 0) {
|
||||
throw new Error('empty model');
|
||||
}
|
||||
|
||||
const blob = new Blob([bytes], {
|
||||
type: 'model/gltf-binary',
|
||||
});
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
const renderer = new three.WebGLRenderer({
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
@@ -167,16 +172,7 @@ export function Match3DModelPreview({
|
||||
scene.add(modelRoot);
|
||||
|
||||
const loader = new loaderModule.GLTFLoader();
|
||||
const gltf = await new Promise<GltfPayload>(
|
||||
(resolve, reject) => {
|
||||
loader.load(
|
||||
objectUrl as string,
|
||||
(loaded: GltfPayload) => resolve(loaded),
|
||||
undefined,
|
||||
(error) => reject(error),
|
||||
);
|
||||
},
|
||||
);
|
||||
const gltf = (await loader.parseAsync(bytes, '')) as GltfPayload;
|
||||
if (cancelled) {
|
||||
const cancelledModel = gltf.scene ?? gltf.scenes[0];
|
||||
if (cancelledModel) {
|
||||
@@ -277,8 +273,9 @@ export function Match3DModelPreview({
|
||||
};
|
||||
|
||||
setStatus('ready');
|
||||
} catch {
|
||||
} catch (caughtError) {
|
||||
if (!cancelled) {
|
||||
warnMatch3DModelPreviewLoadFailure(source, caughtError);
|
||||
setStatus('fallback');
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,10 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import { isDebugMode } from '../../config/debugMode';
|
||||
import { readAssetBytes } from '../../services/assetReadUrlService';
|
||||
import {
|
||||
readMatch3DGeneratedModelBytes,
|
||||
resolveMatch3DGeneratedModelAssetSource,
|
||||
} from '../../services/match3dGeneratedModelCache';
|
||||
import {
|
||||
isItemState,
|
||||
resolveRenderableItemFrame,
|
||||
@@ -111,6 +114,8 @@ type PhysicsRuntime = {
|
||||
animationId: number | null;
|
||||
camera: ThreeCamera;
|
||||
entries: Map<string, PhysicsEntry>;
|
||||
failedGeneratedModelTypeIds: Set<string>;
|
||||
generatedModelByType: Map<string, Match3DGeneratedItemAsset>;
|
||||
generatedModelTemplates: Match3DGeneratedModelTemplateMap;
|
||||
pendingSpawns: Map<string, PendingPhysicsSpawn>;
|
||||
raycaster: import('three').Raycaster;
|
||||
@@ -170,7 +175,7 @@ export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet<Match3DGeometryShape>
|
||||
function normalizeMatch3DGeneratedModelSource(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) {
|
||||
return asset.modelSrc?.trim() || asset.modelObjectKey?.trim() || '';
|
||||
return resolveMatch3DGeneratedModelAssetSource(asset);
|
||||
}
|
||||
|
||||
function compareMatch3DGeneratedTypeId(left: string, right: string) {
|
||||
@@ -213,6 +218,7 @@ export function buildMatch3DGeneratedAssetTypeMap(
|
||||
...resolved.asset,
|
||||
modelSrc: resolved.source,
|
||||
});
|
||||
debugMatch3DGeneratedModelMapped(itemTypeId, resolved.source);
|
||||
});
|
||||
|
||||
return assetMap;
|
||||
@@ -237,12 +243,16 @@ function resolveGeneratedModelSourceForItemType(
|
||||
return asset ? normalizeMatch3DGeneratedModelSource(asset) : '';
|
||||
}
|
||||
|
||||
function shouldLogMatch3DGeneratedModelDiagnostics() {
|
||||
return isDebugMode() && import.meta.env.MODE !== 'test';
|
||||
}
|
||||
|
||||
function warnMatch3DGeneratedModelLoadFailure(
|
||||
itemTypeId: string,
|
||||
source: string,
|
||||
error: unknown,
|
||||
) {
|
||||
if (!isDebugMode()) {
|
||||
if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
@@ -254,6 +264,32 @@ function warnMatch3DGeneratedModelLoadFailure(
|
||||
});
|
||||
}
|
||||
|
||||
function debugMatch3DGeneratedModelLoaded(
|
||||
itemTypeId: string,
|
||||
source: string,
|
||||
) {
|
||||
if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
|
||||
return;
|
||||
}
|
||||
console.debug('[match3d] generated model loaded', {
|
||||
itemTypeId,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
function debugMatch3DGeneratedModelMapped(
|
||||
itemTypeId: string,
|
||||
source: string,
|
||||
) {
|
||||
if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
|
||||
return;
|
||||
}
|
||||
console.debug('[match3d] generated model mapped', {
|
||||
itemTypeId,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMatch3DGeneratedModelTemplate(
|
||||
templateMap: Match3DGeneratedModelTemplateMap,
|
||||
three: ThreeModule,
|
||||
@@ -265,11 +301,10 @@ async function loadMatch3DGeneratedModelTemplate(
|
||||
if (cached?.source === source) {
|
||||
return cached.scene;
|
||||
}
|
||||
const response = await readAssetBytes(source, {
|
||||
const bytes = await readMatch3DGeneratedModelBytes(source, {
|
||||
expireSeconds: 300,
|
||||
signal,
|
||||
});
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (bytes.byteLength === 0) {
|
||||
throw new Error('抓大鹅 3D 模型内容为空');
|
||||
}
|
||||
@@ -297,6 +332,7 @@ async function loadMatch3DGeneratedModelTemplate(
|
||||
scene,
|
||||
source,
|
||||
});
|
||||
debugMatch3DGeneratedModelLoaded(itemTypeId, source);
|
||||
return scene;
|
||||
}
|
||||
|
||||
@@ -327,7 +363,6 @@ function createGeneratedModelMesh(
|
||||
}
|
||||
const position = toWorldPosition(item);
|
||||
const model = cloneThreeObjectWithMaterials(template);
|
||||
markObjectForItem(model, item.itemInstanceId);
|
||||
const bounds = new three.Box3().setFromObject(model);
|
||||
const size = bounds.getSize(new three.Vector3());
|
||||
const dimension = Math.max(size.x, size.y, size.z, 0.001);
|
||||
@@ -341,10 +376,13 @@ function createGeneratedModelMesh(
|
||||
model.position.sub(center);
|
||||
const bottomY = scaledBounds.min.y - center.y;
|
||||
model.position.y -= bottomY;
|
||||
const pivot = new three.Group();
|
||||
pivot.add(model);
|
||||
markObjectForItem(pivot, item.itemInstanceId);
|
||||
|
||||
return {
|
||||
lockReadableTop: false,
|
||||
mesh: model,
|
||||
mesh: pivot,
|
||||
radius: position.radius,
|
||||
shape: 'brick' as Match3DGeometryShape,
|
||||
topRotationY: ((item.layer % 12) / 12) * Math.PI * 2,
|
||||
@@ -1157,6 +1195,22 @@ function createItemMesh(
|
||||
);
|
||||
}
|
||||
|
||||
function shouldWaitForGeneratedModelTemplate(
|
||||
generatedModelByType: Map<string, Match3DGeneratedItemAsset>,
|
||||
templateMap: Match3DGeneratedModelTemplateMap,
|
||||
failedTypeIds: ReadonlySet<string>,
|
||||
itemTypeId: string,
|
||||
) {
|
||||
const source = resolveGeneratedModelSourceForItemType(
|
||||
generatedModelByType,
|
||||
itemTypeId,
|
||||
);
|
||||
// 中文注释:坏 GLB 或过期链接不能让整局空等模板;失败类型应立即走默认几何降级。
|
||||
return Boolean(
|
||||
source && !templateMap.has(itemTypeId) && !failedTypeIds.has(itemTypeId),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildMatch3DPhysicsEntrySignature(
|
||||
runId: string,
|
||||
item: Match3DItemSnapshot,
|
||||
@@ -1192,6 +1246,16 @@ function createPhysicsEntryFromPendingSpawn(
|
||||
now: number,
|
||||
templateMap?: Match3DGeneratedModelTemplateMap | null,
|
||||
) {
|
||||
if (
|
||||
shouldWaitForGeneratedModelTemplate(
|
||||
runtime.generatedModelByType,
|
||||
runtime.generatedModelTemplates,
|
||||
runtime.failedGeneratedModelTypeIds,
|
||||
pendingSpawn.item.itemTypeId,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const visual = createItemMesh(runtime.three, pendingSpawn.item, templateMap);
|
||||
const asset = resolveGeometryAsset(pendingSpawn.item.visualKey);
|
||||
const colliderBounds = resolveMatch3DColliderBounds(asset, visual.radius);
|
||||
@@ -1273,7 +1337,17 @@ function createPhysicsEntryFromPendingSpawn(
|
||||
|
||||
function flushPendingPhysicsSpawns(runtime: PhysicsRuntime, now: number) {
|
||||
const readySpawns = [...runtime.pendingSpawns.entries()]
|
||||
.filter(([, pendingSpawn]) => now >= pendingSpawn.spawnAtMs)
|
||||
.filter(([, pendingSpawn]) => {
|
||||
if (now < pendingSpawn.spawnAtMs) {
|
||||
return false;
|
||||
}
|
||||
return !shouldWaitForGeneratedModelTemplate(
|
||||
runtime.generatedModelByType,
|
||||
runtime.generatedModelTemplates,
|
||||
runtime.failedGeneratedModelTypeIds,
|
||||
pendingSpawn.item.itemTypeId,
|
||||
);
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left[1].spawnAtMs !== right[1].spawnAtMs) {
|
||||
return left[1].spawnAtMs - right[1].spawnAtMs;
|
||||
@@ -1317,6 +1391,7 @@ type TrayPreviewRuntime = {
|
||||
animationId: number | null;
|
||||
camera: ThreeCamera;
|
||||
entries: Map<string, ThreeObject3D>;
|
||||
failedGeneratedModelTypeIds: Set<string>;
|
||||
generatedModelTemplates: Match3DGeneratedModelTemplateMap;
|
||||
renderer: ThreeRenderer;
|
||||
scene: ThreeScene;
|
||||
@@ -1620,6 +1695,8 @@ export function Match3DTrayPreviewBoard({
|
||||
animationId: null,
|
||||
camera,
|
||||
entries: runtimeRef.current?.entries ?? new Map(),
|
||||
failedGeneratedModelTypeIds:
|
||||
runtimeRef.current?.failedGeneratedModelTypeIds ?? new Set(),
|
||||
generatedModelTemplates:
|
||||
runtimeRef.current?.generatedModelTemplates ?? new Map(),
|
||||
renderer,
|
||||
@@ -1645,6 +1722,7 @@ export function Match3DTrayPreviewBoard({
|
||||
animationId: window.requestAnimationFrame(animate),
|
||||
camera,
|
||||
entries: new Map(),
|
||||
failedGeneratedModelTypeIds: new Set(),
|
||||
generatedModelTemplates: new Map(),
|
||||
renderer,
|
||||
scene,
|
||||
@@ -1687,6 +1765,7 @@ export function Match3DTrayPreviewBoard({
|
||||
staleItemTypeIds.delete(itemTypeId);
|
||||
const hadFreshTemplate =
|
||||
runtime.generatedModelTemplates.get(itemTypeId)?.source === source;
|
||||
runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
|
||||
void loadMatch3DGeneratedModelTemplate(
|
||||
runtime.generatedModelTemplates,
|
||||
runtime.three,
|
||||
@@ -1721,6 +1800,8 @@ export function Match3DTrayPreviewBoard({
|
||||
caughtError,
|
||||
);
|
||||
runtime.generatedModelTemplates.delete(itemTypeId);
|
||||
runtime.failedGeneratedModelTypeIds.add(itemTypeId);
|
||||
setTrayModelRevision((current) => current + 1);
|
||||
});
|
||||
});
|
||||
staleItemTypeIds.forEach((itemTypeId) => {
|
||||
@@ -1729,6 +1810,7 @@ export function Match3DTrayPreviewBoard({
|
||||
disposeThreeObject(template.scene);
|
||||
}
|
||||
runtime.generatedModelTemplates.delete(itemTypeId);
|
||||
runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -1785,7 +1867,9 @@ export function Match3DTrayPreviewBoard({
|
||||
const preview = createItemMesh(
|
||||
runtime.three,
|
||||
item,
|
||||
runtime.generatedModelTemplates,
|
||||
runtime.failedGeneratedModelTypeIds.has(item.itemTypeId)
|
||||
? null
|
||||
: runtime.generatedModelTemplates,
|
||||
);
|
||||
const model = preview.mesh;
|
||||
const rotation = resolveMatch3DTrayPreviewRotation(item.visualKey);
|
||||
@@ -2050,6 +2134,8 @@ export function Match3DPhysicsBoard({
|
||||
animationId: null,
|
||||
camera,
|
||||
entries: new Map(),
|
||||
failedGeneratedModelTypeIds: new Set(),
|
||||
generatedModelByType,
|
||||
generatedModelTemplates: new Map(),
|
||||
pendingSpawns: new Map(),
|
||||
raycaster: new three.Raycaster(),
|
||||
@@ -2157,6 +2243,7 @@ export function Match3DPhysicsBoard({
|
||||
if (!runtime) {
|
||||
return undefined;
|
||||
}
|
||||
runtime.generatedModelByType = generatedModelByType;
|
||||
const abortController = new AbortController();
|
||||
const staleItemTypeIds = new Set(runtime.generatedModelTemplates.keys());
|
||||
generatedModelByType.forEach((asset, itemTypeId) => {
|
||||
@@ -2167,6 +2254,7 @@ export function Match3DPhysicsBoard({
|
||||
}
|
||||
const hadFreshTemplate =
|
||||
runtime.generatedModelTemplates.get(itemTypeId)?.source === source;
|
||||
runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
|
||||
void loadMatch3DGeneratedModelTemplate(
|
||||
runtime.generatedModelTemplates,
|
||||
runtime.three,
|
||||
@@ -2201,6 +2289,8 @@ export function Match3DPhysicsBoard({
|
||||
caughtError,
|
||||
);
|
||||
runtime.generatedModelTemplates.delete(itemTypeId);
|
||||
runtime.failedGeneratedModelTypeIds.add(itemTypeId);
|
||||
setGeneratedModelRevision((current) => current + 1);
|
||||
});
|
||||
});
|
||||
staleItemTypeIds.forEach((itemTypeId) => {
|
||||
@@ -2209,6 +2299,7 @@ export function Match3DPhysicsBoard({
|
||||
disposeThreeObject(template.scene);
|
||||
}
|
||||
runtime.generatedModelTemplates.delete(itemTypeId);
|
||||
runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
confirmLocalMatch3DClick,
|
||||
resolveLocalMatch3DItemTypeCount,
|
||||
startLocalMatch3DRun,
|
||||
} from '../../services/match3d-runtime';
|
||||
import {
|
||||
@@ -79,7 +80,10 @@ afterEach(() => {
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__;
|
||||
});
|
||||
|
||||
function renderRuntime(run: Match3DRunSnapshot) {
|
||||
function renderRuntime(
|
||||
run: Match3DRunSnapshot,
|
||||
generatedItemAssets: Match3DGeneratedItemAsset[] = [],
|
||||
) {
|
||||
let currentRun = run;
|
||||
let authorityRun = run;
|
||||
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => {
|
||||
@@ -92,6 +96,7 @@ function renderRuntime(run: Match3DRunSnapshot) {
|
||||
rerender(
|
||||
<Match3DRuntimeShell
|
||||
run={currentRun}
|
||||
generatedItemAssets={generatedItemAssets}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
@@ -102,6 +107,7 @@ function renderRuntime(run: Match3DRunSnapshot) {
|
||||
const { rerender } = render(
|
||||
<Match3DRuntimeShell
|
||||
run={currentRun}
|
||||
generatedItemAssets={generatedItemAssets}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
@@ -122,7 +128,7 @@ test('展示圆形空间和 7 格备选栏', () => {
|
||||
});
|
||||
|
||||
test('显示层把可消除物整体半径放大 2 倍且保留相对比例', () => {
|
||||
const run = startLocalMatch3DRun(25);
|
||||
const run = startLocalMatch3DRun(21);
|
||||
const firstItemByType = [...new Map(
|
||||
run.items.map((item) => [item.itemTypeId, item]),
|
||||
).values()];
|
||||
@@ -159,12 +165,7 @@ test('点击可见物品后先乐观入槽再等待确认', async () => {
|
||||
await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋盘上下文', () => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
|
||||
}
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__ = true;
|
||||
test('运行态按物品实例稳定使用生成 2D 视角素材', () => {
|
||||
const run = startLocalMatch3DRun(1);
|
||||
const selectedItem = run.items[0]!;
|
||||
const nextRun: Match3DRunSnapshot = {
|
||||
@@ -187,13 +188,31 @@ test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋
|
||||
itemTypeId: selectedItem.itemTypeId,
|
||||
visualKey: selectedItem.visualKey,
|
||||
}
|
||||
: slot,
|
||||
: slot,
|
||||
),
|
||||
};
|
||||
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
|
||||
viewId: `view-${viewIndex}`,
|
||||
viewIndex,
|
||||
imageSrc: `/match3d/strawberry-view-${viewIndex}.png`,
|
||||
imageObjectKey: null,
|
||||
})),
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
},
|
||||
];
|
||||
|
||||
renderRuntime(nextRun);
|
||||
renderRuntime(nextRun, generatedItemAssets);
|
||||
|
||||
expect(screen.getByTestId('match3d-tray-model-board')).toBeTruthy();
|
||||
const trayImage = screen.getByTestId('match3d-tray-image') as HTMLImageElement;
|
||||
expect(trayImage.src).toContain('/match3d/strawberry-view-');
|
||||
});
|
||||
|
||||
test('3D WebGL 画布锁定 CSS 尺寸,避免高 DPR 手机上溢出中心棋盘', () => {
|
||||
@@ -283,19 +302,81 @@ test('生成模型按运行态类型编号映射,并兼容仅 modelObjectKey
|
||||
);
|
||||
});
|
||||
|
||||
test('本地试玩按消除次数生成类型并在 25 类封顶', () => {
|
||||
test('运行态会先换签 generated 图片素材再渲染局内物品', async () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
|
||||
viewId: `view-${viewIndex}`,
|
||||
viewIndex,
|
||||
imageSrc: null,
|
||||
imageObjectKey:
|
||||
`generated-match3d-assets/session/profile/items/match3d-item-1/views/view-${viewIndex}.png`,
|
||||
})),
|
||||
status: 'image_ready',
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
},
|
||||
];
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
read: {
|
||||
signedUrl: 'https://oss.example.com/match3d-view.png',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
generatedItemAssets={generatedItemAssets}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('match3d-token-image').length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.getAllByTestId('match3d-token-image')[0]!.getAttribute('src')).toBe(
|
||||
'https://oss.example.com/match3d-view.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('本地试玩按难度档位生成类型并兼容历史硬核消除数', () => {
|
||||
const smallRun = startLocalMatch3DRun(12);
|
||||
const largeRun = startLocalMatch3DRun(100);
|
||||
const hardRun = startLocalMatch3DRun(20);
|
||||
const countTypes = (run: Match3DRunSnapshot) =>
|
||||
new Set(run.items.map((item) => item.itemTypeId)).size;
|
||||
|
||||
expect(countTypes(smallRun)).toBe(12);
|
||||
expect(countTypes(largeRun)).toBe(25);
|
||||
expect(largeRun.items).toHaveLength(300);
|
||||
expect(resolveLocalMatch3DItemTypeCount(8)).toBe(3);
|
||||
expect(resolveLocalMatch3DItemTypeCount(12)).toBe(9);
|
||||
expect(resolveLocalMatch3DItemTypeCount(16)).toBe(15);
|
||||
expect(resolveLocalMatch3DItemTypeCount(20)).toBe(21);
|
||||
expect(resolveLocalMatch3DItemTypeCount(21)).toBe(21);
|
||||
expect(countTypes(smallRun)).toBe(9);
|
||||
expect(countTypes(hardRun)).toBe(21);
|
||||
expect(hardRun.clearCount).toBe(21);
|
||||
expect(hardRun.items).toHaveLength(63);
|
||||
});
|
||||
|
||||
test('25 次以内生成不重复积木视觉签名', () => {
|
||||
const run = startLocalMatch3DRun(25);
|
||||
test('硬核档位生成不重复积木视觉签名', () => {
|
||||
const run = startLocalMatch3DRun(21);
|
||||
const firstItemByType = new Map(
|
||||
run.items.map((item) => [item.itemTypeId, item]),
|
||||
);
|
||||
@@ -311,14 +392,14 @@ test('25 次以内生成不重复积木视觉签名', () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(firstItemByType.size).toBe(25);
|
||||
expect(visualKeys.size).toBe(25);
|
||||
expect(signatures.size).toBe(25);
|
||||
expect(firstItemByType.size).toBe(21);
|
||||
expect(visualKeys.size).toBe(21);
|
||||
expect(signatures.size).toBe(21);
|
||||
});
|
||||
|
||||
test('积木池覆盖参考图里的特殊件', () => {
|
||||
const shapes = new Set(
|
||||
startLocalMatch3DRun(25).items.map((item) =>
|
||||
startLocalMatch3DRun(21).items.map((item) =>
|
||||
resolveGeometryAsset(item.visualKey).shape,
|
||||
),
|
||||
);
|
||||
@@ -342,8 +423,8 @@ test('3D 特殊积木件使用可辨认挤出轮廓而不是基础代理体', as
|
||||
}
|
||||
});
|
||||
|
||||
test('15 次消除时每种视觉模型只对应一次消除目标', () => {
|
||||
const run = startLocalMatch3DRun(15);
|
||||
test('进阶档位保持 15 种视觉模型并按三消组复用', () => {
|
||||
const run = startLocalMatch3DRun(16);
|
||||
const countByVisualKey = new Map<string, number>();
|
||||
const typeByVisualKey = new Map<string, Set<string>>();
|
||||
|
||||
@@ -357,23 +438,26 @@ test('15 次消除时每种视觉模型只对应一次消除目标', () => {
|
||||
}
|
||||
|
||||
expect(countByVisualKey.size).toBe(15);
|
||||
expect([...countByVisualKey.values()]).toEqual(Array(15).fill(3));
|
||||
expect([...countByVisualKey.values()].sort((left, right) => left - right)).toEqual([
|
||||
...Array(14).fill(3),
|
||||
6,
|
||||
]);
|
||||
expect(
|
||||
[...typeByVisualKey.values()].every((itemTypeIds) => itemTypeIds.size === 1),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('25 次以内的随机抽取不会刷新重复物品', () => {
|
||||
for (const clearCount of [1, 12, 15, 24, 25]) {
|
||||
test('随机抽取不会刷新重复物品', () => {
|
||||
for (const clearCount of [1, 8, 12, 16, 21]) {
|
||||
const run = startLocalMatch3DRun(clearCount);
|
||||
const visualKeys = new Set(run.items.map((item) => item.visualKey));
|
||||
|
||||
expect(visualKeys.size).toBe(clearCount);
|
||||
expect(visualKeys.size).toBe(resolveLocalMatch3DItemTypeCount(clearCount));
|
||||
}
|
||||
});
|
||||
|
||||
test('25 类型局面按五档体积比例生成尺寸', () => {
|
||||
const run = startLocalMatch3DRun(25);
|
||||
test('硬核档位按五档体积比例生成尺寸', () => {
|
||||
const run = startLocalMatch3DRun(21);
|
||||
const radiusByVisualKey = new Map<string, number>();
|
||||
for (const item of run.items) {
|
||||
radiusByVisualKey.set(item.visualKey, item.radius);
|
||||
@@ -400,15 +484,15 @@ test('25 类型局面按五档体积比例生成尺寸', () => {
|
||||
tierCounts.set(tier, (tierCounts.get(tier) ?? 0) + 1);
|
||||
}
|
||||
|
||||
expect(tierCounts.get('XL')).toBe(5);
|
||||
expect(tierCounts.get('L')).toBe(8);
|
||||
expect(tierCounts.get('M')).toBe(7);
|
||||
expect(tierCounts.get('XS')).toBe(4);
|
||||
expect(tierCounts.get('XL')).toBe(4);
|
||||
expect(tierCounts.get('L')).toBe(7);
|
||||
expect(tierCounts.get('M')).toBe(6);
|
||||
expect(tierCounts.get('XS')).toBe(3);
|
||||
expect(tierCounts.get('S')).toBe(1);
|
||||
});
|
||||
|
||||
test('同一视觉模型在复用时保持唯一尺寸', () => {
|
||||
const run = startLocalMatch3DRun(30);
|
||||
const run = startLocalMatch3DRun(21);
|
||||
const radiiByVisualKey = new Map<string, Set<number>>();
|
||||
|
||||
for (const item of run.items) {
|
||||
@@ -417,13 +501,13 @@ test('同一视觉模型在复用时保持唯一尺寸', () => {
|
||||
radiiByVisualKey.set(item.visualKey, radii);
|
||||
}
|
||||
|
||||
expect(radiiByVisualKey.size).toBe(25);
|
||||
expect(radiiByVisualKey.size).toBe(21);
|
||||
expect([...radiiByVisualKey.values()].every((radii) => radii.size === 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('托盘 3D 预览保留场内模型的相对尺寸比例', async () => {
|
||||
const three = await import('three');
|
||||
const run = startLocalMatch3DRun(25);
|
||||
const run = startLocalMatch3DRun(21);
|
||||
const firstItemByType = [...new Map(
|
||||
run.items.map((item) => [item.itemTypeId, item]),
|
||||
).values()];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -161,6 +161,7 @@ import {
|
||||
listMatch3DGallery,
|
||||
listMatch3DWorks,
|
||||
} from '../../services/match3d-works';
|
||||
import { preloadMatch3DGeneratedModelAssets } from '../../services/match3dGeneratedModelCache';
|
||||
import {
|
||||
buildBigFishGenerationAnchorEntries,
|
||||
buildMatch3DGenerationAnchorEntries,
|
||||
@@ -602,6 +603,13 @@ function mapPublicWorkDetailToMatch3DWork(
|
||||
updatedAt: entry.updatedAt,
|
||||
publishedAt: entry.publishedAt,
|
||||
publishReady: true,
|
||||
backgroundPrompt: entry.backgroundPrompt ?? null,
|
||||
backgroundImageSrc: entry.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: entry.backgroundImageObjectKey ?? null,
|
||||
generatedBackgroundAsset:
|
||||
entry.generatedItemAssets
|
||||
?.map((asset) => asset.backgroundAsset ?? null)
|
||||
.find(Boolean) ?? null,
|
||||
generatedItemAssets: entry.generatedItemAssets ?? [],
|
||||
};
|
||||
}
|
||||
@@ -633,10 +641,24 @@ function buildMatch3DProfileFromSession(
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
publishReady: Boolean(draft.publishReady),
|
||||
backgroundPrompt: draft.backgroundPrompt ?? null,
|
||||
backgroundImageSrc: draft.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null,
|
||||
generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null,
|
||||
generatedItemAssets: draft.generatedItemAssets,
|
||||
};
|
||||
}
|
||||
|
||||
function hasMatch3DGeneratedModelAsset(
|
||||
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
) {
|
||||
return Boolean(
|
||||
assets?.some(
|
||||
(asset) => asset.modelSrc?.trim() || asset.modelObjectKey?.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
run: Match3DRunSnapshot | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
@@ -650,7 +672,7 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
: [];
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
if (profileAssets.length > 0) {
|
||||
if (hasMatch3DGeneratedModelAsset(profileAssets)) {
|
||||
return profileAssets;
|
||||
}
|
||||
|
||||
@@ -659,7 +681,9 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return publicDetailAssets;
|
||||
return hasMatch3DGeneratedModelAsset(publicDetailAssets)
|
||||
? publicDetailAssets
|
||||
: profileAssets;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,7 +696,57 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
return publicDetailAssets;
|
||||
}
|
||||
|
||||
return profileAssets.length > 0 ? profileAssets : publicDetailAssets;
|
||||
return hasMatch3DGeneratedModelAsset(profileAssets)
|
||||
? profileAssets
|
||||
: publicDetailAssets;
|
||||
}
|
||||
|
||||
function resolveActiveMatch3DRuntimeProfile(
|
||||
run: Match3DRunSnapshot | null,
|
||||
runtimeProfile: Match3DWorkProfile | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
if (runProfileId && runtimeProfile?.profileId === runProfileId) {
|
||||
return runtimeProfile;
|
||||
}
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
return profile;
|
||||
}
|
||||
return runtimeProfile ?? profile;
|
||||
}
|
||||
|
||||
function resolveMatch3DRuntimeBackgroundImageSrc(
|
||||
run: Match3DRunSnapshot | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
publicWorkDetail: PlatformPublicGalleryCard | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
const profileBackground =
|
||||
profile?.backgroundImageSrc?.trim() ||
|
||||
profile?.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
profile?.backgroundImageObjectKey?.trim() ||
|
||||
profile?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
'';
|
||||
const publicBackground =
|
||||
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
|
||||
? publicWorkDetail.backgroundImageSrc?.trim() ||
|
||||
publicWorkDetail.backgroundImageObjectKey?.trim() ||
|
||||
''
|
||||
: '';
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
return profileBackground || publicBackground || null;
|
||||
}
|
||||
if (
|
||||
runProfileId &&
|
||||
publicWorkDetail &&
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return publicBackground || profileBackground || null;
|
||||
}
|
||||
return profileBackground || publicBackground || null;
|
||||
}
|
||||
|
||||
function resolveMatch3DGenerationStateFromAssets(
|
||||
@@ -699,7 +773,7 @@ function resolveMatch3DGenerationStateFromAssets(
|
||||
...current,
|
||||
phase:
|
||||
imageReadyCount > 0 || modelReadyCount > 0
|
||||
? 'match3d-generate-models'
|
||||
? 'match3d-generate-views'
|
||||
: current.phase,
|
||||
completedAssetCount: modelReadyCount,
|
||||
totalAssetCount,
|
||||
@@ -870,6 +944,11 @@ const PUZZLE_ONBOARDING_COPY = '待定待定待定';
|
||||
const PUZZLE_ONBOARDING_CLEAR_COPY = '只差一步,就可以永久保留你的梦';
|
||||
const PUZZLE_ONBOARDING_GENERATED_DELAY_MS = 700;
|
||||
|
||||
function isPuzzleOnboardingEnabled(): boolean {
|
||||
// 中文注释:当前产品要求隐藏首访新手引导,旧流程暂时保留用于后续显式恢复。
|
||||
return false;
|
||||
}
|
||||
|
||||
function escapePuzzleOnboardingSvgText(value: string) {
|
||||
return value
|
||||
.replace(/&/gu, '&')
|
||||
@@ -1020,6 +1099,10 @@ function maybeAlertWorkNotFoundAndReturnHome() {
|
||||
}
|
||||
|
||||
function hasSeenPuzzleOnboarding() {
|
||||
if (!isPuzzleOnboardingEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
@@ -1946,6 +2029,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
>([]);
|
||||
const [match3dProfile, setMatch3DProfile] =
|
||||
useState<Match3DWorkProfile | null>(null);
|
||||
const [match3dRuntimeProfile, setMatch3DRuntimeProfile] =
|
||||
useState<Match3DWorkProfile | null>(null);
|
||||
const [match3dRun, setMatch3DRun] = useState<Match3DRunSnapshot | null>(null);
|
||||
const [match3dRuntimeReturnStage, setMatch3DRuntimeReturnStage] = useState<
|
||||
'match3d-result' | 'work-detail'
|
||||
@@ -3283,6 +3368,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
session.sessionId,
|
||||
]);
|
||||
markPendingDraftGenerating('big-fish', session.sessionId);
|
||||
selectionStageRef.current = 'big-fish-generating';
|
||||
setSelectionStage('big-fish-generating');
|
||||
setBigFishGenerationState(createMiniGameDraftGenerationState('big-fish'));
|
||||
},
|
||||
@@ -3358,20 +3444,24 @@ export function PlatformEntryFlowShellImpl({
|
||||
const profileId = response.session.draft?.profileId;
|
||||
if (!profileId) {
|
||||
setMatch3DProfile(null);
|
||||
setMatch3DRuntimeProfile(null);
|
||||
return { openResult };
|
||||
}
|
||||
|
||||
let runtimeProfile: Match3DWorkProfile | null = null;
|
||||
try {
|
||||
const { item } = await getMatch3DWorkDetail(profileId);
|
||||
setMatch3DProfile({
|
||||
runtimeProfile = {
|
||||
...item,
|
||||
generatedItemAssets:
|
||||
response.session.draft?.generatedItemAssets ??
|
||||
item.generatedItemAssets,
|
||||
});
|
||||
};
|
||||
setMatch3DProfile(runtimeProfile);
|
||||
await refreshMatch3DShelf().catch(() => undefined);
|
||||
} catch {
|
||||
setMatch3DProfile(buildMatch3DProfileFromSession(response.session));
|
||||
runtimeProfile = buildMatch3DProfileFromSession(response.session);
|
||||
setMatch3DProfile(runtimeProfile);
|
||||
}
|
||||
markPendingDraftReady('match3d', response.session.sessionId, openResult);
|
||||
markDraftReady(
|
||||
@@ -3379,6 +3469,26 @@ export function PlatformEntryFlowShellImpl({
|
||||
[profileId, response.session.sessionId],
|
||||
openResult,
|
||||
);
|
||||
if (openResult && runtimeProfile) {
|
||||
try {
|
||||
await preloadMatch3DGeneratedModelAssets(
|
||||
runtimeProfile.generatedItemAssets,
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
const { run } = await startMatch3DRun(runtimeProfile.profileId);
|
||||
setMatch3DRuntimeProfile(runtimeProfile);
|
||||
setMatch3DRun(run);
|
||||
setMatch3DProfile(runtimeProfile);
|
||||
setMatch3DRuntimeReturnStage('match3d-result');
|
||||
setSelectionStage('match3d-runtime');
|
||||
} catch (error) {
|
||||
setMatch3DError(
|
||||
resolveMatch3DErrorMessage(error, '启动抓大鹅玩法失败。'),
|
||||
);
|
||||
setSelectionStage('match3d-result');
|
||||
}
|
||||
return { openResult: false };
|
||||
}
|
||||
return { openResult };
|
||||
},
|
||||
beforeExecuteAction: ({ payload, session }) => {
|
||||
@@ -3391,6 +3501,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
session.sessionId,
|
||||
]);
|
||||
markPendingDraftGenerating('match3d', session.sessionId);
|
||||
selectionStageRef.current = 'match3d-generating';
|
||||
setSelectionStage('match3d-generating');
|
||||
setMatch3DGenerationState(createMiniGameDraftGenerationState('match3d'));
|
||||
},
|
||||
@@ -3468,6 +3579,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSquareHoleGenerationState(
|
||||
createMiniGameDraftGenerationState('square-hole'),
|
||||
);
|
||||
selectionStageRef.current = 'square-hole-generating';
|
||||
setSelectionStage('square-hole-generating');
|
||||
}
|
||||
if (payload.action === 'square_hole_generate_visual_assets') {
|
||||
@@ -3484,6 +3596,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
totalAssetCount: 0,
|
||||
error: null,
|
||||
}));
|
||||
selectionStageRef.current = 'square-hole-generating';
|
||||
setSelectionStage('square-hole-generating');
|
||||
}
|
||||
},
|
||||
@@ -3746,6 +3859,46 @@ export function PlatformEntryFlowShellImpl({
|
||||
openResult,
|
||||
);
|
||||
void refreshPuzzleShelf();
|
||||
if (openResult && response.session.draft) {
|
||||
const draft = response.session.draft;
|
||||
const draftProfileId =
|
||||
response.session.publishedProfileId ??
|
||||
buildPuzzleResultProfileId(response.session.sessionId);
|
||||
if (!draft.coverImageSrc || !draftProfileId) {
|
||||
setPuzzleError(
|
||||
!draft.coverImageSrc
|
||||
? '请先选择一张正式拼图图片。'
|
||||
: '这份拼图草稿缺少会话信息,请重新开始创作。',
|
||||
);
|
||||
setSelectionStage('puzzle-result');
|
||||
return { openResult: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const { item } = await updatePuzzleWork(draftProfileId, {
|
||||
workTitle: draft.workTitle,
|
||||
workDescription: draft.workDescription,
|
||||
levelName: draft.levelName,
|
||||
summary: draft.summary,
|
||||
themeTags: draft.themeTags,
|
||||
coverImageSrc: draft.coverImageSrc,
|
||||
coverAssetId: draft.coverAssetId,
|
||||
levels: draft.levels ?? [],
|
||||
});
|
||||
const run = startLocalPuzzleRun(item);
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
setSelectionStage('puzzle-runtime');
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '启动拼图试玩失败。'),
|
||||
);
|
||||
setSelectionStage('puzzle-result');
|
||||
}
|
||||
return { openResult: false };
|
||||
}
|
||||
return { openResult };
|
||||
}
|
||||
|
||||
@@ -3792,6 +3945,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
buildPuzzleResultProfileId(session.sessionId),
|
||||
]);
|
||||
markPendingDraftGenerating('puzzle', session.sessionId);
|
||||
selectionStageRef.current = 'puzzle-generating';
|
||||
setSelectionStage('puzzle-generating');
|
||||
setPuzzleGenerationState(createMiniGameDraftGenerationState('puzzle'));
|
||||
},
|
||||
@@ -4089,6 +4243,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setMatch3DGenerationState(null);
|
||||
setMatch3DSession(null);
|
||||
setMatch3DProfile(null);
|
||||
setMatch3DRuntimeProfile(null);
|
||||
setMatch3DRun(null);
|
||||
setMatch3DError(null);
|
||||
setStreamingMatch3DReplyText('');
|
||||
@@ -4102,7 +4257,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
markPendingDraftGenerating('match3d', nextSession.sessionId);
|
||||
|
||||
await match3dFlow.executeAction(
|
||||
{ action: 'match3d_compile_draft' },
|
||||
{
|
||||
action: 'match3d_compile_draft',
|
||||
generateClickSound: payload.generateClickSound,
|
||||
},
|
||||
nextSession,
|
||||
);
|
||||
},
|
||||
@@ -4258,6 +4416,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setBigFishError(null);
|
||||
setMatch3DSession(null);
|
||||
setMatch3DProfile(null);
|
||||
setMatch3DRuntimeProfile(null);
|
||||
setMatch3DFormDraftPayload(null);
|
||||
setActiveCreationFormType('puzzle');
|
||||
setMatch3DWorks([]);
|
||||
@@ -4436,6 +4595,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const leaveMatch3DFlow = useCallback(() => {
|
||||
setMatch3DRun(null);
|
||||
setMatch3DRuntimeProfile(null);
|
||||
setMatch3DFormDraftPayload(null);
|
||||
setMatch3DGenerationState(null);
|
||||
setMatch3DRuntimeReturnStage('match3d-result');
|
||||
@@ -4888,6 +5048,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
void executeMatch3DAction({
|
||||
action: 'match3d_compile_draft',
|
||||
generateClickSound: match3dFormDraftPayload?.generateClickSound,
|
||||
});
|
||||
}, [
|
||||
createMatch3DDraftFromForm,
|
||||
@@ -5322,7 +5483,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
profile: Match3DWorkProfile | Match3DWorkSummary,
|
||||
returnStage: 'match3d-result' | 'work-detail' = 'match3d-result',
|
||||
mirrorErrorToPublicDetail = false,
|
||||
options: { embedded?: boolean } = {},
|
||||
options: { embedded?: boolean; itemTypeCountOverride?: number } = {},
|
||||
) => {
|
||||
if (isMatch3DBusy) {
|
||||
return false;
|
||||
@@ -5333,7 +5494,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
try {
|
||||
let runtimeProfile = profile;
|
||||
if ((profile.generatedItemAssets ?? []).length === 0) {
|
||||
if (!hasMatch3DGeneratedModelAsset(profile.generatedItemAssets)) {
|
||||
try {
|
||||
const { item } = await getMatch3DWorkDetail(profile.profileId);
|
||||
runtimeProfile = item;
|
||||
@@ -5341,12 +5502,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
// 中文注释:详情补读只为拿完整生成素材;失败时继续按摘要开局,避免推荐流卡死。
|
||||
}
|
||||
}
|
||||
const { run } = options.embedded
|
||||
? await startMatch3DRun(
|
||||
runtimeProfile.profileId,
|
||||
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
)
|
||||
: await startMatch3DRun(runtimeProfile.profileId);
|
||||
await preloadMatch3DGeneratedModelAssets(
|
||||
runtimeProfile.generatedItemAssets,
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
const runtimeOptions = {
|
||||
...(options.embedded ? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS : {}),
|
||||
...(typeof options.itemTypeCountOverride === 'number'
|
||||
? { itemTypeCountOverride: options.itemTypeCountOverride }
|
||||
: {}),
|
||||
};
|
||||
const { run } =
|
||||
Object.keys(runtimeOptions).length > 0
|
||||
? await startMatch3DRun(runtimeProfile.profileId, runtimeOptions)
|
||||
: await startMatch3DRun(runtimeProfile.profileId);
|
||||
// 中文注释:运行态必须锁定本次启动时的完整 profile,避免首次切屏渲染读到旧草稿 state。
|
||||
setMatch3DRuntimeProfile(runtimeProfile);
|
||||
setMatch3DRun(run);
|
||||
setMatch3DProfile(runtimeProfile);
|
||||
setMatch3DRuntimeReturnStage(returnStage);
|
||||
@@ -5492,18 +5663,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
const startPuzzleTestRunFromDraft = useCallback(
|
||||
async (draft: PuzzleResultDraft) => {
|
||||
if (isPuzzleBusy) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!draft.coverImageSrc) {
|
||||
setPuzzleError('请先选择一张正式拼图图片。');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const profileId =
|
||||
puzzleSession?.publishedProfileId ??
|
||||
buildPuzzleResultProfileId(puzzleSession?.sessionId);
|
||||
if (!profileId) {
|
||||
setPuzzleError('这份拼图草稿缺少会话信息,请重新开始创作。');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsPuzzleBusy(true);
|
||||
@@ -5525,8 +5696,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
setSelectionStage('puzzle-runtime');
|
||||
return true;
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图试玩失败。'));
|
||||
return false;
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
@@ -7168,6 +7341,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setMatch3DRun(null);
|
||||
setMatch3DError(null);
|
||||
setMatch3DProfile(null);
|
||||
setMatch3DRuntimeProfile(null);
|
||||
markDraftNoticeSeen(
|
||||
collectDraftNoticeKeys('match3d', [
|
||||
item.workId,
|
||||
@@ -7789,6 +7963,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
if (activeRecommendRuntimeKind === 'match3d') {
|
||||
const activeMatch3DRuntimeProfile = resolveActiveMatch3DRuntimeProfile(
|
||||
match3dRun,
|
||||
match3dRuntimeProfile,
|
||||
match3dProfile,
|
||||
);
|
||||
return (
|
||||
<Match3DRuntimeShell
|
||||
run={match3dRun}
|
||||
@@ -7797,7 +7976,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
embedded
|
||||
generatedItemAssets={resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
match3dRun,
|
||||
match3dProfile,
|
||||
activeMatch3DRuntimeProfile,
|
||||
activeEntry,
|
||||
)}
|
||||
backgroundImageSrc={resolveMatch3DRuntimeBackgroundImageSrc(
|
||||
match3dRun,
|
||||
activeMatch3DRuntimeProfile,
|
||||
activeEntry,
|
||||
)}
|
||||
onBack={() => {
|
||||
@@ -8004,6 +8188,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
match3dFlow,
|
||||
match3dProfile,
|
||||
match3dRun,
|
||||
match3dRuntimeProfile,
|
||||
platformBootstrap.platformTab,
|
||||
platformThemeClass,
|
||||
puzzleError,
|
||||
@@ -9733,9 +9918,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
stage: 'work-detail',
|
||||
});
|
||||
}}
|
||||
onStartTestRun={(profile) => {
|
||||
onStartTestRun={(profile, options) => {
|
||||
setMatch3DProfile(profile);
|
||||
void startMatch3DRunFromProfile(profile, 'match3d-result');
|
||||
setMatch3DRuntimeProfile(profile);
|
||||
void startMatch3DRunFromProfile(
|
||||
profile,
|
||||
'match3d-result',
|
||||
false,
|
||||
options,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -9743,6 +9934,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
)}
|
||||
|
||||
{selectionStage === 'match3d-runtime' && (
|
||||
(() => {
|
||||
const activeMatch3DRuntimeProfile =
|
||||
resolveActiveMatch3DRuntimeProfile(
|
||||
match3dRun,
|
||||
match3dRuntimeProfile,
|
||||
match3dProfile,
|
||||
);
|
||||
return (
|
||||
<motion.div
|
||||
key="match3d-runtime"
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -9759,7 +9958,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
error={match3dError}
|
||||
generatedItemAssets={resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
match3dRun,
|
||||
match3dProfile,
|
||||
activeMatch3DRuntimeProfile,
|
||||
selectedPublicWorkDetail,
|
||||
)}
|
||||
backgroundImageSrc={resolveMatch3DRuntimeBackgroundImageSrc(
|
||||
match3dRun,
|
||||
activeMatch3DRuntimeProfile,
|
||||
selectedPublicWorkDetail,
|
||||
)}
|
||||
onBack={() => {
|
||||
@@ -9824,6 +10028,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
||||
{selectionStage === 'square-hole-agent-workspace' && (
|
||||
|
||||
@@ -213,12 +213,14 @@ test('puzzle workspace keeps the reference image upload as a primary panel', ()
|
||||
);
|
||||
|
||||
expect(screen.getByText('拼图画面')).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText('若没有合适的图片可以通过填写画面描述生成画面'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen
|
||||
.getByText('若没有合适的图片可以通过填写画面描述生成画面')
|
||||
.getByText('上传图片/填写画面描述')
|
||||
.closest('.puzzle-image-upload-card'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('点击上传拼图图片').closest('.puzzle-image-upload-card')).toBeTruthy();
|
||||
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(screen.queryByLabelText('拼图创作模板')).toBeNull();
|
||||
expect(
|
||||
@@ -276,6 +278,9 @@ test('puzzle workspace selects a history image from the upload card', async () =
|
||||
|
||||
const historyButton = screen.getByRole('button', { name: '选择历史图片' });
|
||||
expect(historyButton.closest('.puzzle-image-upload-card')).toBeTruthy();
|
||||
expect(historyButton.className).toContain('top-3');
|
||||
expect(historyButton.className).toContain('right-3');
|
||||
expect(historyButton.className).not.toContain('bottom-3');
|
||||
expect(screen.getByText('历史').closest('.puzzle-image-upload-card')).toBeTruthy();
|
||||
fireEvent.click(historyButton);
|
||||
|
||||
@@ -321,7 +326,7 @@ test('puzzle upload card stays light in light theme', () => {
|
||||
);
|
||||
|
||||
expect(container.querySelector('.puzzle-image-upload-card')).toBeTruthy();
|
||||
const uploadLabel = screen.getByText('点击上传拼图图片');
|
||||
const uploadLabel = screen.getByText('上传图片/填写画面描述');
|
||||
expect(uploadLabel).toBeTruthy();
|
||||
expect(uploadLabel.closest('.puzzle-image-upload-card')).toBeTruthy();
|
||||
expect(uploadLabel.className).not.toContain('rounded-full');
|
||||
@@ -571,7 +576,13 @@ test('puzzle workspace shows AI redraw switch only after upload', async () => {
|
||||
screen.getByRole('switch', { name: 'AI重绘' }).closest('.puzzle-image-upload-card'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '移除拼图图片' })).toBeTruthy();
|
||||
expect(screen.queryByText('点击上传拼图图片')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '移除拼图图片' }).className).toContain(
|
||||
'left-3',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '选择历史图片' }).className).toContain(
|
||||
'right-3',
|
||||
);
|
||||
expect(screen.queryByText('上传图片/填写画面描述')).toBeNull();
|
||||
});
|
||||
|
||||
test('puzzle workspace confirms before removing uploaded image', async () => {
|
||||
@@ -611,7 +622,7 @@ test('puzzle workspace confirms before removing uploaded image', async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除' }));
|
||||
expect(screen.queryByAltText('拼图图片')).toBeNull();
|
||||
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(screen.getByText('点击上传拼图图片')).toBeTruthy();
|
||||
expect(screen.getByText('上传图片/填写画面描述')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('puzzle workspace opens crop tool for non-square uploads', async () => {
|
||||
|
||||
@@ -879,7 +879,7 @@ export function PuzzleAgentWorkspace({
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsHistoryPickerOpen(true)}
|
||||
className={`absolute bottom-3 right-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
className={`absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-label="选择历史图片"
|
||||
title="选择历史图片"
|
||||
>
|
||||
@@ -922,7 +922,7 @@ export function PuzzleAgentWorkspace({
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsRemoveImageConfirmOpen(true)}
|
||||
className="absolute right-3 top-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] disabled:cursor-not-allowed disabled:opacity-55"
|
||||
className="absolute left-3 top-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] disabled:cursor-not-allowed disabled:opacity-55"
|
||||
aria-label="移除拼图图片"
|
||||
title="移除拼图图片"
|
||||
>
|
||||
@@ -933,14 +933,9 @@ export function PuzzleAgentWorkspace({
|
||||
htmlFor="puzzle-image-upload-input"
|
||||
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[#ff4056] sm:bottom-10 ${isBusy ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'}`}
|
||||
>
|
||||
点击上传拼图图片
|
||||
上传图片/填写画面描述
|
||||
</label>
|
||||
)}
|
||||
{formState.referenceImageSrc ? null : (
|
||||
<div className="pointer-events-none absolute bottom-16 left-4 right-4 z-10 text-center text-[11px] font-semibold leading-4 text-[var(--platform-text-soft)]">
|
||||
若没有合适的图片可以通过填写画面描述生成画面
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -619,6 +619,120 @@ describe('PuzzleResultView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('renders UI background tab with saved prompt and runtime preview', () => {
|
||||
const base = createSession();
|
||||
const level = base.draft!.levels![0]!;
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [
|
||||
{
|
||||
...level,
|
||||
uiBackgroundPrompt: '雨夜猫街竖屏拼图UI背景',
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/ui/background.png',
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui/background.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
|
||||
|
||||
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/session/ui/background.png',
|
||||
);
|
||||
expect(screen.getByLabelText('拼图UI背景提示词')).toHaveProperty(
|
||||
'value',
|
||||
'雨夜猫街竖屏拼图UI背景',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '预览UI' }));
|
||||
const preview = screen.getByRole('dialog', { name: 'UI预览' });
|
||||
expect(within(preview).getByLabelText('拼图区边界')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('generates UI background with edited prompt and current levels snapshot', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '新拼图UI背景提示词' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成UI背景' }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
levelId: 'puzzle-level-1',
|
||||
promptText: '新拼图UI背景提示词',
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
levelsJson: expect.any(String),
|
||||
});
|
||||
const payload = onExecuteAction.mock.calls[0]![0];
|
||||
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
uiBackgroundPrompt: '新拼图UI背景提示词',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto saves UI background prompt edits through levels', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
item: {} as never,
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
profileId="puzzle-profile-session-1"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '新的自动保存UI背景提示词' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
|
||||
'puzzle-profile-session-1',
|
||||
expect.objectContaining({
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
uiBackgroundPrompt: '新的自动保存UI背景提示词',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('selects a history puzzle asset as reference image for the selected level', async () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
History,
|
||||
ImagePlus,
|
||||
LayoutTemplate,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Music,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
Plus,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Wand2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -58,7 +61,7 @@ type PuzzleResultViewProps = {
|
||||
};
|
||||
|
||||
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type PuzzleResultTab = 'levels' | 'work' | 'music';
|
||||
type PuzzleResultTab = 'levels' | 'work' | 'ui' | 'music';
|
||||
|
||||
type DraftEditState = {
|
||||
workTitle: string;
|
||||
@@ -74,6 +77,8 @@ const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
|
||||
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
|
||||
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND = 'puzzle_background_music';
|
||||
const PUZZLE_BACKGROUND_MUSIC_SLOT = 'background_music';
|
||||
const PUZZLE_UI_BACKGROUND_REFERENCE_SRC =
|
||||
'/ui-previews/puzzle-image-compact-ui-2026-05-08.png';
|
||||
|
||||
type PuzzleLevelGenerationRuntime = {
|
||||
startedAtMs: number;
|
||||
@@ -125,6 +130,26 @@ function normalizeThemeTagInput(value: string) {
|
||||
];
|
||||
}
|
||||
|
||||
function buildDefaultPuzzleUiBackgroundPrompt(
|
||||
editState: DraftEditState,
|
||||
level: PuzzleDraftLevel | null,
|
||||
) {
|
||||
const tags = editState.themeTags
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
return [
|
||||
editState.workTitle.trim(),
|
||||
editState.workDescription.trim(),
|
||||
level?.levelName.trim(),
|
||||
level?.pictureDescription.trim(),
|
||||
tags,
|
||||
'移动端拼图游戏 UI 背景,中央正方形拼图区边界清晰,拼图区外氛围与作品名称一致',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('。');
|
||||
}
|
||||
|
||||
function resolveLevelFormalImageSrc(level: PuzzleDraftLevel) {
|
||||
const selectedCandidate =
|
||||
level.candidates.find(
|
||||
@@ -169,6 +194,9 @@ function normalizeDraftLevels(draft: PuzzleResultDraft) {
|
||||
levelName: level.levelName?.trim() || '',
|
||||
pictureDescription: level.pictureDescription?.trim() || draft.summary,
|
||||
pictureReference: level.pictureReference ?? null,
|
||||
uiBackgroundPrompt: level.uiBackgroundPrompt ?? null,
|
||||
uiBackgroundImageSrc: level.uiBackgroundImageSrc ?? null,
|
||||
uiBackgroundImageObjectKey: level.uiBackgroundImageObjectKey ?? null,
|
||||
candidates: level.candidates ?? [],
|
||||
selectedCandidateId: level.selectedCandidateId ?? null,
|
||||
coverImageSrc: level.coverImageSrc ?? null,
|
||||
@@ -250,6 +278,13 @@ function mergeDraftEditStateWithIncomingState(
|
||||
coverImageSrc: incomingLevel.coverImageSrc,
|
||||
coverAssetId: incomingLevel.coverAssetId,
|
||||
pictureReference: incomingLevel.pictureReference ?? level.pictureReference,
|
||||
uiBackgroundPrompt:
|
||||
incomingLevel.uiBackgroundPrompt ?? level.uiBackgroundPrompt,
|
||||
uiBackgroundImageSrc:
|
||||
incomingLevel.uiBackgroundImageSrc ?? level.uiBackgroundImageSrc,
|
||||
uiBackgroundImageObjectKey:
|
||||
incomingLevel.uiBackgroundImageObjectKey ??
|
||||
level.uiBackgroundImageObjectKey,
|
||||
generationStatus: incomingLevel.generationStatus || 'ready',
|
||||
};
|
||||
});
|
||||
@@ -273,6 +308,9 @@ function createBlankPuzzleLevel(
|
||||
levelName: '',
|
||||
pictureDescription: '',
|
||||
pictureReference: null,
|
||||
uiBackgroundPrompt: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
uiBackgroundImageObjectKey: null,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
@@ -381,10 +419,11 @@ function PuzzleResultTabs({
|
||||
onChange: (tab: PuzzleResultTab) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
|
||||
<div className="mb-3 grid grid-cols-4 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
|
||||
{[
|
||||
{ id: 'levels' as const, label: '拼图关卡' },
|
||||
{ id: 'work' as const, label: '作品信息' },
|
||||
{ id: 'ui' as const, label: 'UI' },
|
||||
{ id: 'music' as const, label: '音乐' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
@@ -1327,6 +1366,246 @@ function PuzzleWorkInfoTab({
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleUiAssetsTab({
|
||||
editState,
|
||||
imageRefreshKey,
|
||||
isBusy,
|
||||
onChange,
|
||||
onGenerate,
|
||||
}: {
|
||||
editState: DraftEditState;
|
||||
imageRefreshKey: string;
|
||||
isBusy: boolean;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerate: (prompt: string) => void;
|
||||
}) {
|
||||
const firstLevel = editState.levels[0] ?? null;
|
||||
const formalImageSrc = firstLevel ? resolveLevelFormalImageSrc(firstLevel) : '';
|
||||
const defaultPrompt = buildDefaultPuzzleUiBackgroundPrompt(
|
||||
editState,
|
||||
firstLevel,
|
||||
);
|
||||
const prompt = firstLevel?.uiBackgroundPrompt ?? defaultPrompt;
|
||||
const normalizedPrompt = prompt.trim() || defaultPrompt.trim();
|
||||
const backgroundPreviewSrc =
|
||||
firstLevel?.uiBackgroundImageSrc?.trim() || PUZZLE_UI_BACKGROUND_REFERENCE_SRC;
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
|
||||
const updateFirstLevel = (nextLevel: PuzzleDraftLevel) => {
|
||||
onChange({
|
||||
...editState,
|
||||
levels: [nextLevel, ...editState.levels.slice(1)],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(13rem,0.78fr)_minmax(0,1fr)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
className="mx-auto aspect-[9/16] max-h-[min(62dvh,34rem)] w-full max-w-[18rem] overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 text-left shadow-sm"
|
||||
aria-label="打开拼图UI预览"
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={backgroundPreviewSrc}
|
||||
refreshKey={`${imageRefreshKey}:ui-background`}
|
||||
alt="拼图UI背景图"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
UI背景提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
disabled={isBusy || !firstLevel}
|
||||
rows={8}
|
||||
onChange={(event) => {
|
||||
if (!firstLevel) {
|
||||
return;
|
||||
}
|
||||
updateFirstLevel({
|
||||
...firstLevel,
|
||||
uiBackgroundPrompt: event.target.value,
|
||||
});
|
||||
}}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="拼图UI背景提示词"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
预览UI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!firstLevel || !normalizedPrompt || isBusy}
|
||||
onClick={() => {
|
||||
if (!firstLevel || !normalizedPrompt) {
|
||||
return;
|
||||
}
|
||||
updateFirstLevel({
|
||||
...firstLevel,
|
||||
uiBackgroundPrompt: normalizedPrompt,
|
||||
});
|
||||
onGenerate(normalizedPrompt);
|
||||
}}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="h-4 w-4" />
|
||||
)}
|
||||
{firstLevel?.uiBackgroundImageSrc ? '重新生成' : '生成UI背景'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isPreviewOpen ? (
|
||||
<PuzzleUiRuntimePreviewPanel
|
||||
backgroundPreviewSrc={backgroundPreviewSrc}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
puzzleImageSrc={formalImageSrc}
|
||||
title={editState.workTitle || firstLevel?.levelName || '拼图'}
|
||||
onClose={() => setIsPreviewOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleUiRuntimePreviewPanel({
|
||||
backgroundPreviewSrc,
|
||||
imageRefreshKey,
|
||||
puzzleImageSrc,
|
||||
title,
|
||||
onClose,
|
||||
}: {
|
||||
backgroundPreviewSrc: string;
|
||||
imageRefreshKey: string;
|
||||
puzzleImageSrc: string;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[139] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="UI预览"
|
||||
className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,48rem)] w-full max-w-sm flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<div className="min-w-0 truncate text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
UI预览
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
className="platform-icon-button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="mx-auto aspect-[9/16] max-h-[min(78dvh,42rem)] w-full max-w-[22rem] overflow-hidden rounded-[1.4rem] border border-white/22 bg-[#16211f] shadow-[0_18px_55px_rgba(15,23,42,0.24)]">
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden px-3 pb-4 pt-3 text-white">
|
||||
<ResolvedAssetImage
|
||||
src={backgroundPreviewSrc}
|
||||
refreshKey={`${imageRefreshKey}:ui-runtime-preview`}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(15,23,42,0.18)_0%,rgba(15,23,42,0.05)_45%,rgba(15,23,42,0.24)_100%)]" />
|
||||
<header className="relative z-10 grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-center gap-2">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 bg-black/28 backdrop-blur">
|
||||
<ArrowLeft size={20} />
|
||||
</span>
|
||||
<span className="min-w-0 truncate rounded-full border border-white/18 bg-black/26 px-3 py-2 text-center text-sm font-black backdrop-blur">
|
||||
{title}
|
||||
</span>
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 bg-black/28 backdrop-blur">
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section className="relative z-10 mt-4 flex min-h-0 flex-1 items-center justify-center">
|
||||
<div
|
||||
className="relative aspect-square max-w-full overflow-hidden rounded-[1.25rem] border-[8px] border-white/88 bg-white/92 shadow-[0_20px_44px_rgba(15,23,42,0.32),inset_0_0_0_2px_rgba(15,23,42,0.12)]"
|
||||
style={{ width: 'min(88%, 52dvh, 100%)' }}
|
||||
aria-label="拼图区边界"
|
||||
>
|
||||
{puzzleImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={puzzleImageSrc}
|
||||
refreshKey={`${imageRefreshKey}:ui-runtime-board`}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full w-full grid-cols-3 grid-rows-3 gap-1 bg-slate-100 p-2">
|
||||
{Array.from({ length: 9 }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="rounded-[0.45rem] bg-slate-300/70"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 rounded-[0.82rem] border-2 border-black/18" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="relative z-10 mt-3 rounded-[1.35rem] border border-white/16 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="h-12 rounded-xl bg-white/14 sm:h-14"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleMusicTab({
|
||||
editState,
|
||||
profileId,
|
||||
@@ -1341,25 +1620,20 @@ function PuzzleMusicTab({
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
}) {
|
||||
const currentMusic = editState.levels[0]?.backgroundMusic ?? null;
|
||||
const [prompt, setPrompt] = useState(() =>
|
||||
[
|
||||
editState.workTitle.trim(),
|
||||
editState.workDescription.trim(),
|
||||
editState.themeTags.join(','),
|
||||
'轻快、适合拼图游戏循环播放的背景音乐',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(','),
|
||||
);
|
||||
const [title, setTitle] = useState(() =>
|
||||
`${editState.workTitle.trim() || '拼图'}背景音乐`.slice(0, 40),
|
||||
(
|
||||
currentMusic?.title?.trim() ||
|
||||
editState.levels[0]?.levelName.trim() ||
|
||||
editState.workTitle.trim() ||
|
||||
'拼图'
|
||||
).slice(0, 40),
|
||||
);
|
||||
const [tags, setTags] = useState('轻快, 游戏, 循环, instrumental');
|
||||
const [statusText, setStatusText] = useState<string | null>(null);
|
||||
const [errorText, setErrorText] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
const canGenerate = prompt.trim().length > 0 && title.trim().length > 0;
|
||||
const canGenerate = title.trim().length > 0;
|
||||
const writeMusic = (music: CreationAudioAsset) => {
|
||||
const firstLevel = editState.levels[0];
|
||||
if (!firstLevel) {
|
||||
@@ -1383,7 +1657,7 @@ function PuzzleMusicTab({
|
||||
setErrorText(null);
|
||||
try {
|
||||
const task = await createBackgroundMusicTask({
|
||||
prompt: prompt.trim(),
|
||||
prompt: '',
|
||||
title: title.trim(),
|
||||
tags: tags.trim() || null,
|
||||
});
|
||||
@@ -1406,7 +1680,7 @@ function PuzzleMusicTab({
|
||||
assetObjectId: asset.assetObjectId ?? null,
|
||||
assetKind: asset.assetKind ?? PUZZLE_BACKGROUND_MUSIC_ASSET_KIND,
|
||||
audioSrc: asset.audioSrc,
|
||||
prompt: prompt.trim(),
|
||||
prompt: '',
|
||||
title: title.trim(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
@@ -1473,19 +1747,6 @@ function PuzzleMusicTab({
|
||||
aria-label="背景音乐风格"
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
disabled={isBusy || isGenerating}
|
||||
rows={5}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="背景音乐提示词"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canGenerate || isBusy || isGenerating}
|
||||
@@ -1497,7 +1758,7 @@ function PuzzleMusicTab({
|
||||
) : (
|
||||
<Music className="h-4 w-4" />
|
||||
)}
|
||||
生成音乐
|
||||
{currentMusic ? '重新生成音乐' : '生成音乐'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@@ -1711,6 +1972,10 @@ export function PuzzleResultView({
|
||||
levelName: level.levelName.trim(),
|
||||
pictureDescription: level.pictureDescription.trim(),
|
||||
pictureReference: level.pictureReference?.trim() || null,
|
||||
uiBackgroundPrompt: level.uiBackgroundPrompt?.trim() || null,
|
||||
uiBackgroundImageSrc: level.uiBackgroundImageSrc?.trim() || null,
|
||||
uiBackgroundImageObjectKey:
|
||||
level.uiBackgroundImageObjectKey?.trim() || null,
|
||||
generationStatus: level.generationStatus || 'idle',
|
||||
})),
|
||||
};
|
||||
@@ -1909,6 +2174,39 @@ export function PuzzleResultView({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === 'ui' ? (
|
||||
<PuzzleUiAssetsTab
|
||||
editState={editState}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
isBusy={isBusy}
|
||||
onChange={setEditState}
|
||||
onGenerate={(prompt) => {
|
||||
const firstLevel = editState.levels[0] ?? null;
|
||||
if (!firstLevel) {
|
||||
return;
|
||||
}
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
levelId: firstLevel.levelId,
|
||||
promptText: prompt,
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
summary: editState.workDescription.trim(),
|
||||
themeTags: editState.themeTags,
|
||||
levelsJson: JSON.stringify(
|
||||
editState.levels.map((level, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...level,
|
||||
uiBackgroundPrompt: prompt,
|
||||
}
|
||||
: level,
|
||||
),
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === 'music' ? (
|
||||
<PuzzleMusicTab
|
||||
editState={editState}
|
||||
|
||||
@@ -35,6 +35,13 @@ import {
|
||||
type RuntimeDragInputSession,
|
||||
type RuntimeInputPoint,
|
||||
} from '../../services/input-devices';
|
||||
import {
|
||||
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
|
||||
playRuntimeClickSound,
|
||||
playRuntimeCountdownSound,
|
||||
playRuntimeLevelClearSound,
|
||||
resolveRuntimeCountdownSecondBucket,
|
||||
} from '../../services/runtimeAudioFeedback';
|
||||
import { useMocapInput } from '../../services/useMocapInput';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
@@ -326,6 +333,11 @@ function triggerPuzzlePiecePressHapticFeedback() {
|
||||
vibrate.call(navigator, [PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS]);
|
||||
}
|
||||
|
||||
function triggerPuzzlePiecePressFeedback(volume: number) {
|
||||
triggerPuzzlePiecePressHapticFeedback();
|
||||
playRuntimeClickSound(undefined, volume);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼图运行时壳层。
|
||||
* 前端维护运行时即时交互:交换、拖动、合并、拆分与本关通关在前端裁决。
|
||||
@@ -378,6 +390,8 @@ export function PuzzleRuntimeShell({
|
||||
const previousUiPauseActiveRef = useRef(false);
|
||||
const pauseChangePromiseRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const timeExpiredSyncKeyRef = useRef<string | null>(null);
|
||||
const clearSoundKeyRef = useRef<string | null>(null);
|
||||
const countdownSoundKeyRef = useRef<string | null>(null);
|
||||
const dragSessionRef = useRef<{
|
||||
pieceId: string;
|
||||
inputId: string;
|
||||
@@ -425,6 +439,7 @@ export function PuzzleRuntimeShell({
|
||||
const mergeGroupSignatureRef = useRef<string | null>(null);
|
||||
const hintDemoTimeoutRef = useRef<number | null>(null);
|
||||
const mergeFlashTimeoutRef = useRef<number | null>(null);
|
||||
const backgroundAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const boardRef = useRef<HTMLDivElement | null>(null);
|
||||
const currentLevel = run?.currentLevel ?? null;
|
||||
const currentLevelRef = useRef(currentLevel);
|
||||
@@ -442,11 +457,21 @@ export function PuzzleRuntimeShell({
|
||||
const clearResultKey = currentLevel
|
||||
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
|
||||
: null;
|
||||
const runtimeRunId = run?.runId ?? null;
|
||||
const currentLevelIndex = currentLevel?.levelIndex ?? null;
|
||||
const currentLevelStartedAtMs = currentLevel?.startedAtMs ?? null;
|
||||
const currentLevelStatus = currentLevel?.status ?? null;
|
||||
const musicVolume = authUi?.musicVolume ?? DEFAULT_PUZZLE_MUSIC_VOLUME;
|
||||
const backgroundMusicSrc = currentLevel?.backgroundMusic?.audioSrc?.trim() || null;
|
||||
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
|
||||
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
|
||||
const { resolvedUrl: resolvedBackgroundMusicSrc } = useResolvedAssetReadUrl(backgroundMusicSrc);
|
||||
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
|
||||
currentLevel?.coverImageSrc ?? null,
|
||||
);
|
||||
const { resolvedUrl: resolvedUiBackgroundImage } = useResolvedAssetReadUrl(
|
||||
currentLevel?.uiBackgroundImageSrc ?? null,
|
||||
);
|
||||
const mocapInput = useMocapInput({enabled: runtimeStatus === 'playing'});
|
||||
const primaryMocapHand = mocapInput.latestCommand?.primaryHand;
|
||||
const primaryMocapHandState = primaryMocapHand?.state;
|
||||
@@ -472,6 +497,18 @@ export function PuzzleRuntimeShell({
|
||||
currentLevelRef.current = currentLevel;
|
||||
}, [currentLevel]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = backgroundAudioRef.current;
|
||||
if (!audio || !resolvedBackgroundMusicSrc || runtimeStatus !== 'playing') {
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
}
|
||||
return;
|
||||
}
|
||||
audio.volume = Math.max(0, Math.min(1, musicVolume));
|
||||
void audio.play().catch(() => {});
|
||||
}, [musicVolume, resolvedBackgroundMusicSrc, runtimeStatus]);
|
||||
|
||||
const commitSelectedPieceId = (pieceId: string | null) => {
|
||||
selectedPieceIdRef.current = pieceId;
|
||||
setSelectedPieceId(pieceId);
|
||||
@@ -815,6 +852,41 @@ export function PuzzleRuntimeShell({
|
||||
run?.runId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!runtimeRunId ||
|
||||
currentLevelStatus !== 'playing' ||
|
||||
currentLevelIndex === null ||
|
||||
currentLevelStartedAtMs === null
|
||||
) {
|
||||
countdownSoundKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
const secondBucket =
|
||||
displayRemainingMs <= levelAudioConfig.countdownWarningThresholdMs
|
||||
? resolveRuntimeCountdownSecondBucket(displayRemainingMs)
|
||||
: null;
|
||||
if (secondBucket === null) {
|
||||
countdownSoundKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const soundKey = `${runtimeRunId}:${currentLevelIndex}:${currentLevelStartedAtMs}:${secondBucket}`;
|
||||
if (countdownSoundKeyRef.current === soundKey) {
|
||||
return;
|
||||
}
|
||||
countdownSoundKeyRef.current = soundKey;
|
||||
playRuntimeCountdownSound(musicVolume);
|
||||
}, [
|
||||
currentLevelIndex,
|
||||
currentLevelStartedAtMs,
|
||||
currentLevelStatus,
|
||||
displayRemainingMs,
|
||||
levelAudioConfig.countdownWarningThresholdMs,
|
||||
musicVolume,
|
||||
runtimeRunId,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (hintDemoTimeoutRef.current !== null) {
|
||||
@@ -853,6 +925,10 @@ export function PuzzleRuntimeShell({
|
||||
|
||||
// 通关后先保留完整画面,再播放对角线闪光,最后延迟弹出结算弹窗。
|
||||
clearPresentationKeyRef.current = clearResultKey;
|
||||
if (clearSoundKeyRef.current !== clearResultKey) {
|
||||
clearSoundKeyRef.current = clearResultKey;
|
||||
playRuntimeLevelClearSound(musicVolume);
|
||||
}
|
||||
clearPresentationTimeouts();
|
||||
setIsClearFlashVisible(true);
|
||||
setIsClearResultReady(false);
|
||||
@@ -864,7 +940,7 @@ export function PuzzleRuntimeShell({
|
||||
setIsClearResultReady(true);
|
||||
}, PUZZLE_CLEAR_FLASH_DURATION_MS + PUZZLE_CLEAR_DIALOG_DELAY_MS),
|
||||
];
|
||||
}, [clearResultKey, currentLevel, dismissedClearKey]);
|
||||
}, [clearResultKey, currentLevel, dismissedClearKey, musicVolume]);
|
||||
|
||||
const handlePieceTap = (
|
||||
pieceId: string,
|
||||
@@ -994,7 +1070,7 @@ export function PuzzleRuntimeShell({
|
||||
syncRuntimeDragFromController(session);
|
||||
selectedPieceBeforeInputRef.current = selectedPieceIdRef.current;
|
||||
commitSelectedPieceId(session.targetId);
|
||||
triggerPuzzlePiecePressHapticFeedback();
|
||||
triggerPuzzlePiecePressFeedback(musicVolume);
|
||||
},
|
||||
onDragStart: (session) => {
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||||
@@ -1352,16 +1428,38 @@ export function PuzzleRuntimeShell({
|
||||
<div
|
||||
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
|
||||
>
|
||||
{resolvedBackgroundMusicSrc ? (
|
||||
<audio
|
||||
ref={backgroundAudioRef}
|
||||
src={resolvedBackgroundMusicSrc}
|
||||
loop
|
||||
preload="auto"
|
||||
/>
|
||||
) : null}
|
||||
<div className="puzzle-runtime-stage relative h-full w-full overflow-hidden">
|
||||
{resolvedUiBackgroundImage ? (
|
||||
<ResolvedAssetImage
|
||||
src={resolvedUiBackgroundImage}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
{currentLevel.coverImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={currentLevel.coverImageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-[0.16] blur-2xl"
|
||||
className={`absolute inset-0 h-full w-full object-cover blur-2xl ${
|
||||
resolvedUiBackgroundImage ? 'opacity-[0.06]' : 'opacity-[0.16]'
|
||||
}`}
|
||||
/>
|
||||
) : null}
|
||||
<div className="puzzle-runtime-stage__grid" />
|
||||
<div
|
||||
className={`puzzle-runtime-stage__grid ${
|
||||
resolvedUiBackgroundImage ? 'opacity-20' : ''
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="absolute left-0 top-0 z-20 w-full px-3 py-3 sm:px-4">
|
||||
<div className="grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-start gap-2 sm:grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] sm:gap-3">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,10 @@ import type {
|
||||
ProfileTaskCenterResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import {
|
||||
ICP_RECORD_NUMBER,
|
||||
ICP_RECORD_URL,
|
||||
} from '../common/legalDocuments';
|
||||
import {
|
||||
RpgEntryHomeView,
|
||||
type RpgEntryHomeViewProps,
|
||||
@@ -1089,11 +1093,48 @@ test('opens reward code modal from profile action on mobile', async () => {
|
||||
expect(screen.getByLabelText('关闭兑换码')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('shows a reachable login entry in logged out mobile shell', async () => {
|
||||
test('profile page shows legal entries and ICP record link', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderProfileView();
|
||||
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
expect(
|
||||
shortcutRegion.querySelector('.grid')?.className.includes('grid-cols-3'),
|
||||
).toBe(true);
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /每日任务/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /邀请好友/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /玩家社区/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /反馈/u }))
|
||||
.toBeTruthy();
|
||||
|
||||
const legalRegion = screen.getByRole('region', { name: '法律信息' });
|
||||
expect(within(legalRegion).getByRole('button', { name: /用户协议/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(legalRegion).getByRole('button', { name: /隐私政策/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(legalRegion).getByRole('button', { name: /免责声明/u }))
|
||||
.toBeTruthy();
|
||||
|
||||
const recordLink = within(legalRegion).getByRole('link', {
|
||||
name: ICP_RECORD_NUMBER,
|
||||
});
|
||||
expect(recordLink.getAttribute('href')).toBe(ICP_RECORD_URL);
|
||||
expect(recordLink.getAttribute('target')).toBe('_blank');
|
||||
expect(recordLink.getAttribute('rel')).toBe('noreferrer');
|
||||
|
||||
await user.click(within(legalRegion).getByRole('button', { name: /隐私政策/u }));
|
||||
expect(await screen.findByRole('dialog', { name: '隐私政策' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('shows a reachable login entry outside mobile recommend tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
const openLoginModal = vi.fn();
|
||||
|
||||
renderLoggedOutHomeView(openLoginModal);
|
||||
renderLoggedOutHomeView(openLoginModal, {}, 'category');
|
||||
await user.click(screen.getByRole('button', { name: '登录' }));
|
||||
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||
@@ -1360,6 +1401,10 @@ test('logged out mobile shell defaults to discover tab', () => {
|
||||
expect(
|
||||
screen.getByPlaceholderText('搜索作品号、名称、作者、描述'),
|
||||
).toBeTruthy();
|
||||
expect(container.querySelector('.platform-mobile-topbar')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('.platform-mobile-entry-shell--recommend'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('logged out recommend tab opens login modal and shows cover only', async () => {
|
||||
@@ -1381,6 +1426,10 @@ test('logged out recommend tab opens login modal and shows cover only', async ()
|
||||
expect(
|
||||
container.querySelector('.platform-recommend-cover-only'),
|
||||
).toBeTruthy();
|
||||
expect(container.querySelector('.platform-mobile-topbar')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('.platform-mobile-entry-shell--recommend'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
|
||||
expect(screen.queryByLabelText('奇幻拼图 作品信息')).toBeNull();
|
||||
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
||||
@@ -1647,6 +1696,7 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
});
|
||||
|
||||
const meta = screen.getByLabelText('当前拼图 作品信息') as HTMLElement;
|
||||
expect(meta.closest('[data-recommend-swipe-zone="true"]')).toBeTruthy();
|
||||
const activeRecommendCard = within(meta);
|
||||
const likeButton = activeRecommendCard.getByRole('button', {
|
||||
name: '点赞 12',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Camera,
|
||||
ChevronDown,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
Coins,
|
||||
Compass,
|
||||
Copy,
|
||||
FileText,
|
||||
Gamepad2,
|
||||
GitFork,
|
||||
Heart,
|
||||
@@ -75,6 +75,14 @@ import {
|
||||
} from '../../services/rpg-entry/rpgProfileClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { LegalDocumentModal } from '../common/LegalDocumentModal';
|
||||
import {
|
||||
getLegalDocument,
|
||||
ICP_RECORD_NUMBER,
|
||||
ICP_RECORD_URL,
|
||||
LEGAL_DOCUMENTS,
|
||||
type LegalDocumentId,
|
||||
} from '../common/legalDocuments';
|
||||
import {
|
||||
canExposePublicWork,
|
||||
EDUTAINMENT_WORK_TAG,
|
||||
@@ -825,7 +833,10 @@ function RecommendSwipeCard({
|
||||
data-active={isActive ? 'true' : 'false'}
|
||||
>
|
||||
<div className="platform-recommend-swipe-card__visual">{visual}</div>
|
||||
<div className="platform-recommend-swipe-card__meta">
|
||||
<div
|
||||
className="platform-recommend-swipe-card__meta"
|
||||
data-recommend-swipe-zone={isActive ? 'true' : 'false'}
|
||||
>
|
||||
<RecommendRuntimeMeta
|
||||
entry={entry}
|
||||
authorAvatarUrl={authorAvatarUrl}
|
||||
@@ -2103,6 +2114,53 @@ function ProfileShortcutButton({
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileLegalSection({
|
||||
onOpenDocument,
|
||||
}: {
|
||||
onOpenDocument: (documentId: LegalDocumentId) => void;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}
|
||||
aria-label="法律信息"
|
||||
>
|
||||
<div className="mb-3 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
法律信息
|
||||
</div>
|
||||
<div className="platform-subpanel overflow-hidden rounded-[1.25rem]">
|
||||
{LEGAL_DOCUMENTS.map((document, index) => (
|
||||
<button
|
||||
key={document.id}
|
||||
type="button"
|
||||
onClick={() => onOpenDocument(document.id)}
|
||||
className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--platform-button-secondary-fill)] ${
|
||||
index > 0 ? 'border-t border-[var(--platform-subpanel-border)]' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="platform-profile-chip flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
|
||||
<FileText className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{document.title}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
href={ICP_RECORD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-3 block text-center text-xs font-semibold text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)]"
|
||||
>
|
||||
{ICP_RECORD_NUMBER}
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileReferralUserAvatar({
|
||||
name,
|
||||
avatarUrl,
|
||||
@@ -3176,6 +3234,8 @@ export function RpgEntryHomeView({
|
||||
const [profileCopyState, setProfileCopyState] = useState<
|
||||
'idle' | 'copied' | 'failed'
|
||||
>('idle');
|
||||
const [activeLegalDocumentId, setActiveLegalDocumentId] =
|
||||
useState<LegalDocumentId | null>(null);
|
||||
const profileCopyResetTimerRef = useRef<number | null>(null);
|
||||
const avatarFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false);
|
||||
@@ -3296,6 +3356,9 @@ export function RpgEntryHomeView({
|
||||
const publicUserCode = buildPublicUserCode(authUi?.user);
|
||||
const avatarLabel = getUserAvatarLabel(authUi?.user);
|
||||
const avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
|
||||
const activeLegalDocument = activeLegalDocumentId
|
||||
? getLegalDocument(activeLegalDocumentId)
|
||||
: null;
|
||||
const avatarCropSize = avatarImageSize
|
||||
? Math.min(avatarImageSize.width, avatarImageSize.height) / avatarScale
|
||||
: 0;
|
||||
@@ -4931,7 +4994,7 @@ export function RpgEntryHomeView({
|
||||
className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}
|
||||
aria-label="常用功能"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ProfileShortcutButton
|
||||
label="每日任务"
|
||||
subLabel={
|
||||
@@ -4999,6 +5062,8 @@ export function RpgEntryHomeView({
|
||||
<ChevronRight className="h-4 w-4 text-[var(--platform-text-soft)]" />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
|
||||
</>
|
||||
) : (
|
||||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||||
@@ -5385,36 +5450,33 @@ export function RpgEntryHomeView({
|
||||
) : null;
|
||||
|
||||
if (!isDesktopLayout) {
|
||||
const isMobileRecommendTab = activeTab === 'home';
|
||||
|
||||
return (
|
||||
<div className="platform-mobile-entry-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
|
||||
<RpgEntryBrandLogo />
|
||||
{!isAuthenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
className="platform-button platform-button--primary shrink-0 px-3 py-2 text-xs"
|
||||
>
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
登录
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
className="platform-icon-button platform-mobile-topbar__action shrink-0"
|
||||
aria-label="通知与账户"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`platform-mobile-entry-shell ${isMobileRecommendTab ? 'platform-mobile-entry-shell--recommend' : ''} flex h-full min-h-0 min-w-0 flex-col overflow-hidden`}
|
||||
>
|
||||
{!isMobileRecommendTab ? (
|
||||
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
|
||||
<RpgEntryBrandLogo />
|
||||
{!isAuthenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
className="platform-button platform-button--primary shrink-0 px-3 py-2 text-xs"
|
||||
>
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
登录
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="platform-tab-panel-stack min-w-0 flex-1">
|
||||
{tabPanels}
|
||||
</div>
|
||||
|
||||
<div className="platform-mobile-bottom-dock mt-3 min-w-0 shrink-0">
|
||||
<div className="platform-mobile-bottom-dock min-w-0 shrink-0">
|
||||
<div
|
||||
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
@@ -5504,6 +5566,12 @@ export function RpgEntryHomeView({
|
||||
onRetry={loadWalletLedger}
|
||||
/>
|
||||
) : null}
|
||||
<LegalDocumentModal
|
||||
document={activeLegalDocument}
|
||||
open={Boolean(activeLegalDocument)}
|
||||
platformTheme={authUi?.platformTheme}
|
||||
onClose={() => setActiveLegalDocumentId(null)}
|
||||
/>
|
||||
{profileEditModals}
|
||||
</div>
|
||||
);
|
||||
@@ -5528,14 +5596,6 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
className="platform-icon-button"
|
||||
aria-label="通知与账户"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
@@ -5651,6 +5711,12 @@ export function RpgEntryHomeView({
|
||||
onRetry={loadWalletLedger}
|
||||
/>
|
||||
) : null}
|
||||
<LegalDocumentModal
|
||||
document={activeLegalDocument}
|
||||
open={Boolean(activeLegalDocument)}
|
||||
platformTheme={authUi?.platformTheme}
|
||||
onClose={() => setActiveLegalDocumentId(null)}
|
||||
/>
|
||||
{profileEditModals}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -108,6 +108,9 @@ export type PlatformMatch3DGalleryCard = {
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
backgroundPrompt?: string | null;
|
||||
backgroundImageSrc?: string | null;
|
||||
backgroundImageObjectKey?: string | null;
|
||||
generatedItemAssets?: Match3DGeneratedItemAsset[];
|
||||
};
|
||||
|
||||
@@ -255,6 +258,9 @@ export function mapMatch3DWorkToPlatformGalleryCard(
|
||||
visibility: 'published',
|
||||
publishedAt: work.publishedAt ?? null,
|
||||
updatedAt: work.updatedAt,
|
||||
backgroundPrompt: work.backgroundPrompt ?? null,
|
||||
backgroundImageSrc: work.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: work.backgroundImageObjectKey ?? null,
|
||||
generatedItemAssets: work.generatedItemAssets ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user