Merge remote-tracking branch 'origin/master' into codex/wechat-mini-program-virtual-payment
# Conflicts: # .hermes/shared-memory/decision-log.md
This commit is contained in:
@@ -131,7 +131,10 @@ describe('CustomWorldGenerationView', () => {
|
||||
'justify-start',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
||||
'pt-[4%]',
|
||||
'z-30',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
||||
'pt-[2%]',
|
||||
);
|
||||
expect(screen.getByText('总进度').className).toContain('text-[9px]');
|
||||
expect(screen.getByText('42%').className).toContain('text-[1.15rem]');
|
||||
@@ -149,7 +152,7 @@ describe('CustomWorldGenerationView', () => {
|
||||
screen
|
||||
.getByRole('progressbar', { name: progressTitle })
|
||||
.getAttribute('data-ring-start-degrees'),
|
||||
).toBe('225');
|
||||
).toBe('155');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: progressTitle })
|
||||
@@ -168,6 +171,9 @@ describe('CustomWorldGenerationView', () => {
|
||||
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
|
||||
'svg',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-ring').getAttribute('class')).toContain(
|
||||
'z-0',
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring')
|
||||
@@ -183,6 +189,16 @@ describe('CustomWorldGenerationView', () => {
|
||||
.getByTestId('generation-hero-progress-ring-track')
|
||||
.getAttribute('stroke-width'),
|
||||
).toBe('18');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-track')
|
||||
.getAttribute('transform'),
|
||||
).toBe('rotate(155 200 200)');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-fill')
|
||||
.getAttribute('transform'),
|
||||
).toBe('rotate(155 200 200)');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-fill')
|
||||
|
||||
@@ -13,7 +13,7 @@ interface CustomWorldGenerationViewProps {
|
||||
anchorEntries?: CustomWorldStructuredAnchorEntry[];
|
||||
progress: CustomWorldGenerationProgress | null;
|
||||
isGenerating: boolean;
|
||||
error: string | null;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onEditSetting: () => void;
|
||||
onRetry: () => void;
|
||||
@@ -110,7 +110,6 @@ export function CustomWorldGenerationView({
|
||||
anchorEntries = [],
|
||||
progress,
|
||||
isGenerating,
|
||||
error,
|
||||
onBack,
|
||||
onEditSetting,
|
||||
onRetry,
|
||||
@@ -123,7 +122,6 @@ export function CustomWorldGenerationView({
|
||||
settingDescription = '这段文本会直接驱动本轮世界框架、角色与场景生成。',
|
||||
progressTitle = '生成进度',
|
||||
activeBadgeLabel = '世界建设中',
|
||||
pausedBadgeLabel = '生成已暂停',
|
||||
idleBadgeLabel = '等待操作',
|
||||
structuredEmptyText = '正在整理当前设定结构,请稍后。',
|
||||
hideBatchModule = false,
|
||||
@@ -169,11 +167,7 @@ export function CustomWorldGenerationView({
|
||||
<span className="break-keep">{backLabel}</span>
|
||||
</button>
|
||||
<div className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
|
||||
{isGenerating
|
||||
? activeBadgeLabel
|
||||
: error
|
||||
? pausedBadgeLabel
|
||||
: idleBadgeLabel}
|
||||
{isGenerating ? activeBadgeLabel : idleBadgeLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,12 +189,6 @@ export function CustomWorldGenerationView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-[1.4rem] border border-[#d88969]/35 bg-white/76 px-4 py-3 text-sm leading-6 text-[#a6402f] backdrop-blur-md">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
{!isGenerating ? (
|
||||
<>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useId, useRef } from 'react';
|
||||
|
||||
import generationHeroVideo from '../../media/create_bg_video.mp4';
|
||||
|
||||
const GENERATION_PROGRESS_RING_START_DEGREES = 225;
|
||||
const GENERATION_PROGRESS_RING_START_DEGREES = 155;
|
||||
const GENERATION_PROGRESS_RING_SWEEP_DEGREES = 270;
|
||||
const GENERATION_PROGRESS_RING_VIEWBOX = 400;
|
||||
const GENERATION_PROGRESS_RING_CENTER = GENERATION_PROGRESS_RING_VIEWBOX / 2;
|
||||
@@ -173,7 +173,7 @@ export function GenerationProgressHero({
|
||||
>
|
||||
<svg
|
||||
data-testid="generation-hero-progress-ring"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
className="pointer-events-none absolute inset-0 z-0 h-full w-full"
|
||||
viewBox={`0 0 ${GENERATION_PROGRESS_RING_VIEWBOX} ${GENERATION_PROGRESS_RING_VIEWBOX}`}
|
||||
aria-hidden="true"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
@@ -220,13 +220,13 @@ export function GenerationProgressHero({
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
className="relative z-10 flex h-full w-full flex-col items-center justify-start pt-[4%] text-center sm:pt-[3%]"
|
||||
className="relative z-30 flex h-full w-full flex-col items-center justify-start pt-[2%] text-center sm:pt-[1.5%]"
|
||||
data-testid="generation-hero-progress-content"
|
||||
>
|
||||
<div className="text-[9px] font-black tracking-[0.16em] text-[#7f441f] sm:text-[10px]">
|
||||
<div className="relative z-30 text-[9px] font-black tracking-[0.16em] text-[#7f441f] sm:text-[10px]">
|
||||
总进度
|
||||
</div>
|
||||
<div className="mt-1 text-[1.15rem] font-black leading-none text-[#e45e14] sm:mt-1.5 sm:text-[1.9rem]">
|
||||
<div className="relative z-30 mt-1 text-[1.15rem] font-black leading-none text-[#e45e14] sm:mt-1.5 sm:text-[1.9rem]">
|
||||
{safeProgress}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,8 @@ const authMocks = vi.hoisted(() => ({
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
getAuthSessions: vi.fn(),
|
||||
isWechatMiniProgramWebViewRuntime: vi.fn(() => false),
|
||||
requestWechatMiniProgramPhoneLogin: vi.fn(),
|
||||
revokeAuthSessions: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
@@ -52,10 +54,12 @@ vi.mock('../../services/authService', () => ({
|
||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||
getAuthSessions: authMocks.getAuthSessions,
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
isWechatMiniProgramWebViewRuntime: authMocks.isWechatMiniProgramWebViewRuntime,
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
|
||||
logoutAuthUser: authMocks.logoutAuthUser,
|
||||
requestWechatMiniProgramPhoneLogin: authMocks.requestWechatMiniProgramPhoneLogin,
|
||||
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
|
||||
resetPassword: authMocks.resetPassword,
|
||||
revokeAuthSessions: authMocks.revokeAuthSessions,
|
||||
@@ -152,6 +156,8 @@ beforeEach(() => {
|
||||
expiresInSeconds: 300,
|
||||
});
|
||||
authMocks.startWechatLogin.mockResolvedValue(undefined);
|
||||
authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(false);
|
||||
authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
async function acceptLegalConsent(
|
||||
@@ -412,6 +418,29 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
});
|
||||
|
||||
test('auth gate uses mini program auth bridge instead of opening login modal in mini program runtime', async () => {
|
||||
const user = userEvent.setup();
|
||||
authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(true);
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'wechat'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(authMocks.startWechatLogin).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
expect(authMocks.isWechatMiniProgramWebViewRuntime).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('login modal requires first-time legal consent before sms login', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -32,11 +32,13 @@ import {
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
getCurrentAuthUser,
|
||||
isWechatMiniProgramWebViewRuntime,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
logoutAuthUser,
|
||||
redeemRegistrationInviteCode,
|
||||
requestWechatMiniProgramPhoneLogin,
|
||||
resetPassword,
|
||||
revokeAuthSessions,
|
||||
sendPhoneLoginCode,
|
||||
@@ -276,6 +278,22 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setInitialSettingsSection(null);
|
||||
}, []);
|
||||
|
||||
const requestMiniProgramLogin = useCallback(() => {
|
||||
setWechatLoading(true);
|
||||
setError('');
|
||||
void requestWechatMiniProgramPhoneLogin()
|
||||
.catch((miniProgramError) => {
|
||||
setError(
|
||||
miniProgramError instanceof Error
|
||||
? miniProgramError.message
|
||||
: '请在微信小程序内完成登录。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setWechatLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openLoginModal = useCallback(
|
||||
(postLoginAction?: (() => void) | null) => {
|
||||
if (readyUser) {
|
||||
@@ -284,9 +302,15 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
pendingProtectedActionRef.current = postLoginAction ?? null;
|
||||
if (isWechatMiniProgramWebViewRuntime()) {
|
||||
setShowLoginModal(false);
|
||||
requestMiniProgramLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
setShowLoginModal(true);
|
||||
},
|
||||
[readyUser],
|
||||
[readyUser, requestMiniProgramLogin],
|
||||
);
|
||||
|
||||
const requireAuth = useCallback(
|
||||
@@ -425,11 +449,26 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
void hydrate(++authHydrateVersionRef.current);
|
||||
};
|
||||
|
||||
const handleAuthHashChange = () => {
|
||||
const callbackResult = consumeAuthCallbackResult();
|
||||
if (!callbackResult) {
|
||||
return;
|
||||
}
|
||||
if (callbackResult.error) {
|
||||
setError(callbackResult.error);
|
||||
return;
|
||||
}
|
||||
setStatus('checking');
|
||||
void hydrate(++authHydrateVersionRef.current);
|
||||
};
|
||||
|
||||
window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
window.addEventListener('hashchange', handleAuthHashChange);
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
window.removeEventListener('hashchange', handleAuthHashChange);
|
||||
};
|
||||
}, [restoreAuthSession]);
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import type {
|
||||
AuthLoginMethod,
|
||||
} from '../../services/authService';
|
||||
import { getStoredLastLoginPhone } from '../../services/authService';
|
||||
import {
|
||||
isWechatMiniProgramWebViewRuntime,
|
||||
} from '../../services/authService';
|
||||
import { LegalDocumentModal } from '../common/LegalDocumentModal';
|
||||
import {
|
||||
getLegalDocument,
|
||||
@@ -83,6 +86,7 @@ export function LoginScreen({
|
||||
const passwordLoginEnabled = true;
|
||||
const phoneLoginEnabled = true;
|
||||
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
|
||||
const miniProgramRuntime = isWechatMiniProgramWebViewRuntime();
|
||||
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -317,7 +321,7 @@ export function LoginScreen({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{wechatLoginEnabled ? (
|
||||
{wechatLoginEnabled && !miniProgramRuntime ? (
|
||||
<WechatButton
|
||||
loading={wechatLoading}
|
||||
disabled={submitDisabled}
|
||||
@@ -364,7 +368,8 @@ export function LoginScreen({
|
||||
|
||||
{!passwordLoginEnabled &&
|
||||
!phoneLoginEnabled &&
|
||||
!wechatLoginEnabled ? (
|
||||
!wechatLoginEnabled &&
|
||||
!miniProgramRuntime ? (
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
当前登录入口暂不可用。
|
||||
</div>
|
||||
|
||||
@@ -116,7 +116,10 @@ describe('BarkBattleGeneratingView', () => {
|
||||
'justify-start',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
||||
'pt-[4%]',
|
||||
'z-30',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
||||
'pt-[2%]',
|
||||
);
|
||||
expect(screen.getByText('玩家形象')).toBeTruthy();
|
||||
expect(screen.getByText('进行中 36%')).toBeTruthy();
|
||||
@@ -142,7 +145,7 @@ describe('BarkBattleGeneratingView', () => {
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.getAttribute('data-ring-start-degrees'),
|
||||
).toBe('225');
|
||||
).toBe('155');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
@@ -161,6 +164,9 @@ describe('BarkBattleGeneratingView', () => {
|
||||
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
|
||||
'svg',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-ring').getAttribute('class')).toContain(
|
||||
'z-0',
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring')
|
||||
@@ -176,6 +182,16 @@ describe('BarkBattleGeneratingView', () => {
|
||||
.getByTestId('generation-hero-progress-ring-track')
|
||||
.getAttribute('stroke-width'),
|
||||
).toBe('18');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-track')
|
||||
.getAttribute('transform'),
|
||||
).toBe('rotate(155 200 200)');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-fill')
|
||||
.getAttribute('transform'),
|
||||
).toBe('rotate(155 200 200)');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-fill')
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
@@ -43,7 +44,6 @@ type CustomWorldCreationHubProps = {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onRetry: () => void;
|
||||
createError?: string | null;
|
||||
createBusy?: boolean;
|
||||
entryConfig: CreationEntryConfig;
|
||||
creationTypes: readonly PlatformCreationTypeCard[];
|
||||
@@ -65,6 +65,9 @@ type CustomWorldCreationHubProps = {
|
||||
jumpHopItems?: JumpHopWorkSummaryResponse[];
|
||||
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
|
||||
onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null;
|
||||
woodenFishItems?: WoodenFishWorkSummaryResponse[];
|
||||
onOpenWoodenFishDetail?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
|
||||
onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
|
||||
puzzleItems?: PuzzleWorkSummary[];
|
||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
||||
@@ -154,7 +157,6 @@ export function CustomWorldCreationHub({
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
createError = null,
|
||||
createBusy = false,
|
||||
entryConfig,
|
||||
creationTypes,
|
||||
@@ -176,6 +178,9 @@ export function CustomWorldCreationHub({
|
||||
jumpHopItems = [],
|
||||
onOpenJumpHopDetail,
|
||||
onDeleteJumpHop = null,
|
||||
woodenFishItems = [],
|
||||
onOpenWoodenFishDetail = null,
|
||||
onDeleteWoodenFish = null,
|
||||
puzzleItems = [],
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle = null,
|
||||
@@ -209,6 +214,7 @@ export function CustomWorldCreationHub({
|
||||
match3dItems,
|
||||
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
|
||||
jumpHopItems,
|
||||
woodenFishItems,
|
||||
puzzleItems,
|
||||
babyObjectMatchItems,
|
||||
barkBattleItems,
|
||||
@@ -219,6 +225,7 @@ export function CustomWorldCreationHub({
|
||||
canDeleteSquareHole:
|
||||
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
|
||||
canDeleteJumpHop: Boolean(onDeleteJumpHop),
|
||||
canDeleteWoodenFish: Boolean(onDeleteWoodenFish),
|
||||
canDeletePuzzle: Boolean(onDeletePuzzle),
|
||||
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
|
||||
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
|
||||
@@ -234,6 +241,8 @@ export function CustomWorldCreationHub({
|
||||
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
|
||||
onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined,
|
||||
onDeleteJumpHop: onDeleteJumpHop ?? undefined,
|
||||
onOpenWoodenFishDetail: onOpenWoodenFishDetail ?? undefined,
|
||||
onDeleteWoodenFish: onDeleteWoodenFish ?? undefined,
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle: onDeletePuzzle ?? undefined,
|
||||
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
|
||||
@@ -261,6 +270,7 @@ export function CustomWorldCreationHub({
|
||||
onDeleteBarkBattle,
|
||||
onDeleteVisualNovel,
|
||||
onDeleteJumpHop,
|
||||
onDeleteWoodenFish,
|
||||
onClaimPuzzlePointIncentive,
|
||||
onOpenBigFishDetail,
|
||||
onOpenDraft,
|
||||
@@ -270,6 +280,7 @@ export function CustomWorldCreationHub({
|
||||
onOpenPuzzleDetail,
|
||||
onOpenSquareHoleDetail,
|
||||
onOpenVisualNovelDetail,
|
||||
onOpenWoodenFishDetail,
|
||||
onEnterPublished,
|
||||
getWorkState,
|
||||
puzzleItems,
|
||||
@@ -277,6 +288,7 @@ export function CustomWorldCreationHub({
|
||||
onOpenSquareHoleDetail,
|
||||
onOpenJumpHopDetail,
|
||||
jumpHopItems,
|
||||
woodenFishItems,
|
||||
visualNovelItems,
|
||||
],
|
||||
);
|
||||
@@ -327,6 +339,9 @@ export function CustomWorldCreationHub({
|
||||
case 'jump-hop':
|
||||
onOpenJumpHopDetail?.(item.source.item);
|
||||
return;
|
||||
case 'wooden-fish':
|
||||
onOpenWoodenFishDetail?.(item.source.item);
|
||||
return;
|
||||
case 'rpg':
|
||||
if (item.status === 'draft') {
|
||||
onOpenDraft(item.source.item);
|
||||
@@ -360,7 +375,6 @@ export function CustomWorldCreationHub({
|
||||
{showStartCard ? (
|
||||
<CustomWorldCreationStartCard
|
||||
busy={createBusy}
|
||||
error={createError}
|
||||
entryConfig={entryConfig}
|
||||
creationTypes={creationTypes}
|
||||
onCreateType={onCreateType}
|
||||
@@ -377,12 +391,11 @@ export function CustomWorldCreationHub({
|
||||
) : null}
|
||||
|
||||
{showWorkShelf && error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-[1.4rem] px-4 py-4 text-sm leading-7">
|
||||
<div>{error}</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="platform-button platform-button--ghost mt-3 min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Coins, Trophy } from 'lucide-react';
|
||||
import { useMemo, useState, type UIEvent } from 'react';
|
||||
import { type UIEvent,useMemo, useState } from 'react';
|
||||
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import {
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
|
||||
type CustomWorldCreationStartCardProps = {
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
entryConfig: CreationEntryConfig;
|
||||
creationTypes: readonly PlatformCreationTypeCard[];
|
||||
onCreateType: (type: PlatformCreationTypeId) => void;
|
||||
@@ -25,7 +24,6 @@ function shouldShowCreationBadge(badge: string) {
|
||||
|
||||
export function CustomWorldCreationStartCard({
|
||||
busy = false,
|
||||
error = null,
|
||||
entryConfig,
|
||||
creationTypes,
|
||||
onCreateType,
|
||||
@@ -233,11 +231,6 @@ export function CustomWorldCreationStartCard({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-4 rounded-[1rem] px-3 py-2 text-sm leading-5 sm:rounded-[1.25rem] sm:leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -60,6 +60,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
|
||||
match3d: '/creation-type-references/match3d.webp',
|
||||
'square-hole': '/creation-type-references/square-hole.webp',
|
||||
'jump-hop': '/creation-type-references/jump-hop.webp',
|
||||
'wooden-fish': '/wooden-fish/default-hit-object.png',
|
||||
puzzle: '/creation-type-references/puzzle.webp',
|
||||
'baby-object-match': '/creation-type-references/creative-agent.webp',
|
||||
'bark-battle': '/creation-type-references/bark-battle.webp',
|
||||
|
||||
@@ -56,6 +56,47 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code',
|
||||
expect(items[1]?.publicWorkCode).toBeNull();
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems maps wooden fish items with WF public code', () => {
|
||||
const onOpenWoodenFishDetail = vi.fn();
|
||||
const woodenFishWork = {
|
||||
runtimeKind: 'wooden-fish' as const,
|
||||
workId: 'wooden-fish-work-1',
|
||||
profileId: 'wooden-fish-profile-12345678',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'wooden-fish-session-1',
|
||||
workTitle: '苹果敲木鱼',
|
||||
workDescription: '苹果主题木鱼。',
|
||||
themeTags: ['苹果', '休闲'],
|
||||
coverImageSrc: '/wooden-fish/apple-cover.png',
|
||||
publicationStatus: 'published',
|
||||
playCount: 9,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
generationStatus: 'ready' as const,
|
||||
};
|
||||
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
woodenFishItems: [woodenFishWork],
|
||||
onOpenWoodenFishDetail,
|
||||
});
|
||||
|
||||
items[0]?.actions.open();
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.kind).toBe('wooden-fish');
|
||||
expect(items[0]?.status).toBe('published');
|
||||
expect(items[0]?.publicWorkCode).toBe('WF-12345678');
|
||||
expect(items[0]?.sharePath).toContain('/works/detail?work=WF-12345678');
|
||||
expect(items[0]?.openActionLabel).toBe('查看详情');
|
||||
expect(items[0]?.badges.some((badge) => badge.label === '敲木鱼')).toBe(true);
|
||||
expect(items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value).toBe(9);
|
||||
expect(onOpenWoodenFishDetail).toHaveBeenCalledWith(woodenFishWork);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems keeps published bark battle over duplicate draft', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contr
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||
import {
|
||||
buildBabyObjectMatchPublicWorkCode,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
buildPuzzlePublicWorkCode,
|
||||
buildSquareHolePublicWorkCode,
|
||||
buildVisualNovelPublicWorkCode,
|
||||
buildWoodenFishPublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
@@ -34,6 +36,7 @@ export type CreationWorkShelfKind =
|
||||
| 'match3d'
|
||||
| 'square-hole'
|
||||
| 'jump-hop'
|
||||
| 'wooden-fish'
|
||||
| 'puzzle'
|
||||
| 'baby-object-match'
|
||||
| 'bark-battle'
|
||||
@@ -90,6 +93,10 @@ export type CreationWorkShelfSource =
|
||||
kind: 'jump-hop';
|
||||
item: JumpHopWorkSummaryResponse;
|
||||
}
|
||||
| {
|
||||
kind: 'wooden-fish';
|
||||
item: WoodenFishWorkSummaryResponse;
|
||||
}
|
||||
| {
|
||||
kind: 'puzzle';
|
||||
item: PuzzleWorkSummary;
|
||||
@@ -145,6 +152,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
match3dItems?: Match3DWorkSummary[];
|
||||
squareHoleItems?: SquareHoleWorkSummary[];
|
||||
jumpHopItems?: JumpHopWorkSummaryResponse[];
|
||||
woodenFishItems?: WoodenFishWorkSummaryResponse[];
|
||||
puzzleItems: PuzzleWorkSummary[];
|
||||
babyObjectMatchItems?: BabyObjectMatchDraft[];
|
||||
barkBattleItems?: BarkBattleWorkSummary[];
|
||||
@@ -154,6 +162,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteMatch3D?: boolean;
|
||||
canDeleteSquareHole?: boolean;
|
||||
canDeleteJumpHop?: boolean;
|
||||
canDeleteWoodenFish?: boolean;
|
||||
canDeletePuzzle?: boolean;
|
||||
canDeleteBabyObjectMatch?: boolean;
|
||||
canDeleteBarkBattle?: boolean;
|
||||
@@ -169,6 +178,8 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void;
|
||||
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
|
||||
onDeleteJumpHop?: (item: JumpHopWorkSummaryResponse) => void;
|
||||
onOpenWoodenFishDetail?: (item: WoodenFishWorkSummaryResponse) => void;
|
||||
onDeleteWoodenFish?: (item: WoodenFishWorkSummaryResponse) => void;
|
||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
|
||||
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
|
||||
@@ -189,6 +200,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
match3dItems = [],
|
||||
squareHoleItems = [],
|
||||
jumpHopItems = [],
|
||||
woodenFishItems = [],
|
||||
puzzleItems,
|
||||
babyObjectMatchItems = [],
|
||||
barkBattleItems = [],
|
||||
@@ -198,6 +210,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteMatch3D = false,
|
||||
canDeleteSquareHole = false,
|
||||
canDeleteJumpHop = false,
|
||||
canDeleteWoodenFish = false,
|
||||
canDeletePuzzle = false,
|
||||
canDeleteBabyObjectMatch = false,
|
||||
canDeleteBarkBattle = false,
|
||||
@@ -213,6 +226,8 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onDeleteSquareHole,
|
||||
onOpenJumpHopDetail,
|
||||
onDeleteJumpHop,
|
||||
onOpenWoodenFishDetail,
|
||||
onDeleteWoodenFish,
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle,
|
||||
onClaimPuzzlePointIncentive,
|
||||
@@ -257,6 +272,12 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onDelete: onDeleteJumpHop,
|
||||
}),
|
||||
),
|
||||
...woodenFishItems.map((item) =>
|
||||
mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, {
|
||||
onOpen: onOpenWoodenFishDetail,
|
||||
onDelete: onDeleteWoodenFish,
|
||||
}),
|
||||
),
|
||||
...puzzleItems.map((item) =>
|
||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
|
||||
onOpen: onOpenPuzzleDetail,
|
||||
@@ -815,6 +836,54 @@ function mapJumpHopWorkToShelfItem(
|
||||
};
|
||||
}
|
||||
|
||||
function mapWoodenFishWorkToShelfItem(
|
||||
item: WoodenFishWorkSummaryResponse,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<WoodenFishWorkSummaryResponse>,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
status === 'published' ? buildWoodenFishPublicWorkCode(item.profileId) : null;
|
||||
const title = item.workTitle.trim() || '敲木鱼';
|
||||
const summary =
|
||||
item.workDescription.trim() || (status === 'draft' ? '未填写作品描述' : '');
|
||||
|
||||
return {
|
||||
id: item.workId,
|
||||
kind: 'wooden-fish',
|
||||
status,
|
||||
title,
|
||||
summary,
|
||||
authorDisplayName: resolveAuthorDisplayName(item),
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: normalizeCoverImageSrc(item.coverImageSrc),
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode,
|
||||
sharePath:
|
||||
publicWorkCode && status === 'published'
|
||||
? buildPublicWorkStagePath('work-detail', publicWorkCode)
|
||||
: null,
|
||||
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
|
||||
canDelete,
|
||||
canShare: status === 'published' && Boolean(publicWorkCode),
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '敲木鱼', tone: 'neutral' },
|
||||
],
|
||||
metrics:
|
||||
status === 'published'
|
||||
? buildPublishedMetrics({
|
||||
playCount: item.playCount,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
})
|
||||
: [],
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
source: { kind: 'wooden-fish', item },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function resolveAuthorDisplayName(
|
||||
...sources: Array<unknown>
|
||||
@@ -1026,6 +1095,8 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
case 'puzzle':
|
||||
return isPersistedPuzzleDraftGenerating(item.source.item);
|
||||
case 'wooden-fish':
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
case 'bark-battle':
|
||||
return isPersistedBarkBattleDraftGenerating(item.source.item);
|
||||
default:
|
||||
|
||||
@@ -174,7 +174,7 @@ test('baby object result blocks placeholder assets and exposes regeneration', as
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('当前作品仍是占位资源,请重新生成 image-2 资源后再试玩或发布。'),
|
||||
screen.getByText('当前作品仍是占位资源,请重新生成素材后再试玩或发布。'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '试玩' }) as HTMLButtonElement)
|
||||
|
||||
@@ -158,7 +158,7 @@ export function BabyObjectMatchResultView({
|
||||
|
||||
{!hasGeneratedAssets ? (
|
||||
<div className="platform-banner mt-3 rounded-2xl text-sm leading-6">
|
||||
当前作品仍是占位资源,请重新生成 image-2 资源后再试玩或发布。
|
||||
当前作品仍是占位资源,请重新生成素材后再试玩或发布。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,6 @@ test('dispatches wooden fish creation type selection', () => {
|
||||
<PlatformEntryCreationTypeModal
|
||||
isOpen
|
||||
isBusy={false}
|
||||
error={null}
|
||||
entryConfig={entryConfig}
|
||||
creationTypes={derivePlatformCreationTypes(entryConfig.creationTypes)}
|
||||
onClose={() => {}}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
export interface PlatformEntryCreationTypeModalProps {
|
||||
isOpen: boolean;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
entryConfig: CreationEntryConfig;
|
||||
creationTypes: readonly PlatformCreationTypeCard[];
|
||||
onClose: () => void;
|
||||
@@ -94,7 +93,6 @@ function CreationTypeCard(props: {
|
||||
export function PlatformEntryCreationTypeModal({
|
||||
isOpen,
|
||||
isBusy,
|
||||
error,
|
||||
entryConfig,
|
||||
creationTypes,
|
||||
onClose,
|
||||
@@ -172,11 +170,6 @@ export function PlatformEntryCreationTypeModal({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
107
src/components/platform-entry/PlatformErrorDialog.test.tsx
Normal file
107
src/components/platform-entry/PlatformErrorDialog.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import * as clipboardService from '../../services/clipboard';
|
||||
import { PlatformErrorDialog } from './PlatformErrorDialog';
|
||||
import { PlatformTaskCompletionDialog } from './PlatformTaskCompletionDialog';
|
||||
|
||||
vi.mock('../../services/clipboard', () => ({
|
||||
copyTextToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('PlatformErrorDialog', () => {
|
||||
test('shows source, message, and copies the full error report', async () => {
|
||||
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
|
||||
|
||||
render(
|
||||
<PlatformErrorDialog
|
||||
error={{
|
||||
source: '拼图草稿 puzzle-session-123',
|
||||
message: '图片生成失败,请稍后再试。',
|
||||
}}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '发生错误' });
|
||||
expect(within(dialog).getByText('拼图草稿 puzzle-session-123')).toBeTruthy();
|
||||
expect(within(dialog).getByText('图片生成失败,请稍后再试。')).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '复制报错' }));
|
||||
|
||||
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
|
||||
['来源:拼图草稿 puzzle-session-123', '错误:图片生成失败,请稍后再试。'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '已复制' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('does not render when there is no active error', () => {
|
||||
render(<PlatformErrorDialog error={null} onClose={() => {}} />);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '发生错误' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlatformTaskCompletionDialog', () => {
|
||||
test('shows source, message, and copies the full completion report', async () => {
|
||||
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
|
||||
|
||||
render(
|
||||
<PlatformTaskCompletionDialog
|
||||
completion={{
|
||||
source: '抓大鹅草稿 match3d-notice-session-1',
|
||||
message: '生成任务已完成,可以继续查看草稿。',
|
||||
}}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '生成完成' });
|
||||
expect(
|
||||
within(dialog).getByText('抓大鹅草稿 match3d-notice-session-1'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByText('生成任务已完成,可以继续查看草稿。'),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '复制内容' }));
|
||||
|
||||
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
|
||||
[
|
||||
'来源:抓大鹅草稿 match3d-notice-session-1',
|
||||
'状态:生成任务已完成,可以继续查看草稿。',
|
||||
].join('\n'),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '已复制' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('does not render when there is no active completion', () => {
|
||||
render(
|
||||
<PlatformTaskCompletionDialog completion={null} onClose={() => {}} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '生成完成' })).toBeNull();
|
||||
});
|
||||
});
|
||||
120
src/components/platform-entry/PlatformErrorDialog.tsx
Normal file
120
src/components/platform-entry/PlatformErrorDialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
|
||||
export type PlatformErrorDialogPayload = {
|
||||
source: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type PlatformErrorDialogProps = {
|
||||
error: PlatformErrorDialogPayload | null;
|
||||
onClose: () => void;
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
};
|
||||
|
||||
function buildPlatformErrorReport(error: PlatformErrorDialogPayload) {
|
||||
return [`来源:${error.source}`, `错误:${error.message}`].join('\n');
|
||||
}
|
||||
|
||||
export function PlatformErrorDialog({
|
||||
error,
|
||||
onClose,
|
||||
overlayClassName = 'platform-theme platform-theme--light !items-center',
|
||||
panelClassName = 'platform-remap-surface rounded-[1.5rem]',
|
||||
}: PlatformErrorDialogProps) {
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const resetTimerRef = useRef<number | null>(null);
|
||||
const reportText = useMemo(
|
||||
() => (error ? buildPlatformErrorReport(error) : ''),
|
||||
[error],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCopyState('idle');
|
||||
}, [error?.source, error?.message]);
|
||||
|
||||
const copyError = () => {
|
||||
if (!reportText) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copyTextToClipboard(reportText).then((copied) => {
|
||||
setCopyState(copied ? 'copied' : 'failed');
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
resetTimerRef.current = window.setTimeout(() => {
|
||||
resetTimerRef.current = null;
|
||||
setCopyState('idle');
|
||||
}, 1400);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={Boolean(error)}
|
||||
title="发生错误"
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
overlayClassName={overlayClassName}
|
||||
panelClassName={panelClassName}
|
||||
bodyClassName="space-y-3 px-4 py-4 sm:px-5 sm:py-5"
|
||||
footerClassName="justify-end px-4 py-4 sm:px-5"
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyError}
|
||||
disabled={!reportText}
|
||||
className="platform-button platform-button--primary w-full justify-center gap-2 sm:w-auto"
|
||||
>
|
||||
{copyState === 'copied' ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
{copyState === 'copied'
|
||||
? '已复制'
|
||||
: copyState === 'failed'
|
||||
? '复制失败'
|
||||
: '复制报错'}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<>
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
|
||||
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
来源
|
||||
</div>
|
||||
<div className="mt-1 break-words text-sm font-semibold leading-5 text-[var(--platform-text-strong)]">
|
||||
{error.source}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
|
||||
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
错误
|
||||
</div>
|
||||
<div className="mt-1 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
124
src/components/platform-entry/PlatformTaskCompletionDialog.tsx
Normal file
124
src/components/platform-entry/PlatformTaskCompletionDialog.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { CheckCircle2, Copy } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
|
||||
export type PlatformTaskCompletionDialogPayload = {
|
||||
source: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type PlatformTaskCompletionDialogProps = {
|
||||
completion: PlatformTaskCompletionDialogPayload | null;
|
||||
onClose: () => void;
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
};
|
||||
|
||||
function buildPlatformTaskCompletionReport(
|
||||
completion: PlatformTaskCompletionDialogPayload,
|
||||
) {
|
||||
return [`来源:${completion.source}`, `状态:${completion.message}`].join(
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformTaskCompletionDialog({
|
||||
completion,
|
||||
onClose,
|
||||
overlayClassName = 'platform-theme platform-theme--light !items-center',
|
||||
panelClassName = 'platform-remap-surface rounded-[1.5rem]',
|
||||
}: PlatformTaskCompletionDialogProps) {
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const resetTimerRef = useRef<number | null>(null);
|
||||
const reportText = useMemo(
|
||||
() => (completion ? buildPlatformTaskCompletionReport(completion) : ''),
|
||||
[completion],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCopyState('idle');
|
||||
}, [completion?.source, completion?.message]);
|
||||
|
||||
const copyCompletion = () => {
|
||||
if (!reportText) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copyTextToClipboard(reportText).then((copied) => {
|
||||
setCopyState(copied ? 'copied' : 'failed');
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
resetTimerRef.current = window.setTimeout(() => {
|
||||
resetTimerRef.current = null;
|
||||
setCopyState('idle');
|
||||
}, 1400);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={Boolean(completion)}
|
||||
title="生成完成"
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
overlayClassName={overlayClassName}
|
||||
panelClassName={panelClassName}
|
||||
bodyClassName="space-y-3 px-4 py-4 sm:px-5 sm:py-5"
|
||||
footerClassName="justify-end px-4 py-4 sm:px-5"
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyCompletion}
|
||||
disabled={!reportText}
|
||||
className="platform-button platform-button--primary w-full justify-center gap-2 sm:w-auto"
|
||||
>
|
||||
{copyState === 'copied' ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
{copyState === 'copied'
|
||||
? '已复制'
|
||||
: copyState === 'failed'
|
||||
? '复制失败'
|
||||
: '复制内容'}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{completion ? (
|
||||
<>
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
|
||||
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
来源
|
||||
</div>
|
||||
<div className="mt-1 break-words text-sm font-semibold leading-5 text-[var(--platform-text-strong)]">
|
||||
{completion.source}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
|
||||
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
状态
|
||||
</div>
|
||||
<div className="mt-1 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
|
||||
{completion.message}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
formatPlatformWorldTime,
|
||||
isBarkBattleGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isJumpHopGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
@@ -36,7 +35,7 @@ export interface PlatformWorkDetailViewProps {
|
||||
authorAvatarUrl?: string | null;
|
||||
authorDisplayName?: string | null;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
error?: string | null;
|
||||
visibleCoverCount?: number;
|
||||
onBack: () => void;
|
||||
onLike: () => void;
|
||||
@@ -89,7 +88,6 @@ export function PlatformWorkDetailView({
|
||||
authorAvatarUrl,
|
||||
authorDisplayName,
|
||||
isBusy,
|
||||
error,
|
||||
visibleCoverCount = 1,
|
||||
onBack,
|
||||
onLike,
|
||||
@@ -432,9 +430,6 @@ export function PlatformWorkDetailView({
|
||||
{shareState === 'copied' ? '分享内容已复制' : '分享失败'}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="platform-work-detail__error">{error}</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -411,7 +411,7 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
|
||||
});
|
||||
});
|
||||
|
||||
test('puzzle workspace switches the image model from the description box', () => {
|
||||
test('puzzle workspace switches image mode without exposing model names', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -427,9 +427,9 @@ test('puzzle workspace switches the image model from the description box', () =>
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '图片模型' }));
|
||||
expect(screen.queryByRole('menuitemradio', { name: '原模型' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'nanobanana2' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '图片生成模式' }));
|
||||
expect(screen.queryByText(/gpt|nanobanana|gemini/u)).toBeNull();
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: '创意模式' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
confirmPuzzlePointCost();
|
||||
|
||||
|
||||
@@ -45,8 +45,8 @@ export function PuzzleImageModelPicker({
|
||||
className={`inline-flex min-h-8 max-w-[10rem] items-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 px-3 text-[11px] font-bold text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
aria-label="图片模型"
|
||||
title="图片模型"
|
||||
aria-label="图片生成模式"
|
||||
title="图片生成模式"
|
||||
>
|
||||
<span className="truncate">
|
||||
{getPuzzleImageModelLabel(normalizedValue)}
|
||||
|
||||
@@ -9,8 +9,8 @@ export const PUZZLE_IMAGE_MODEL_OPTIONS: Array<{
|
||||
id: PuzzleImageModelId;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: 'gpt-image-2' },
|
||||
{ id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: 'nanobanana2' },
|
||||
{ id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: '标准模式' },
|
||||
{ id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: '创意模式' },
|
||||
];
|
||||
|
||||
export function normalizePuzzleImageModel(
|
||||
@@ -25,6 +25,6 @@ export function normalizePuzzleImageModel(
|
||||
export function getPuzzleImageModelLabel(model: PuzzleImageModelId) {
|
||||
return (
|
||||
PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === model)?.label ??
|
||||
'gpt-image-2'
|
||||
'标准模式'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1305,7 +1305,7 @@ describe('PuzzleResultView', () => {
|
||||
expect(screen.queryByPlaceholderText('参考图链接或资产ID')).toBeNull();
|
||||
});
|
||||
|
||||
test('passes the selected image model when regenerating a level image', () => {
|
||||
test('passes the selected image mode without exposing model names', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -1319,9 +1319,12 @@ describe('PuzzleResultView', () => {
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '图片模型' }));
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('menuitemradio', { name: 'gpt-image-2' }),
|
||||
within(dialog).getByRole('button', { name: '图片生成模式' }),
|
||||
);
|
||||
expect(within(dialog).queryByText(/gpt|nanobanana|gemini/u)).toBeNull();
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('menuitemradio', { name: '标准模式' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /重新生成画面/u }),
|
||||
|
||||
@@ -1819,12 +1819,7 @@ export function PuzzleResultView({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && autoSaveError ? (
|
||||
{autoSaveError ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{autoSaveError}
|
||||
</div>
|
||||
|
||||
@@ -84,7 +84,6 @@ import {
|
||||
} from '../../services/edutainment-baby-object';
|
||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||
import {
|
||||
createLocalMatch3DRuntimeAdapter,
|
||||
createServerMatch3DRuntimeAdapter,
|
||||
} from '../../services/match3d-runtime';
|
||||
import {
|
||||
@@ -674,7 +673,6 @@ vi.mock('../../services/match3dGeneratedModelCache', () => ({
|
||||
}));
|
||||
|
||||
const match3dRuntimeServiceMocks = vi.hoisted(() => ({
|
||||
createLocalMatch3DRuntimeAdapter: vi.fn(),
|
||||
createServerMatch3DRuntimeAdapter: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -687,15 +685,6 @@ const match3dServerRuntimeAdapterMock = vi.hoisted(() => ({
|
||||
stopRun: vi.fn(),
|
||||
}));
|
||||
|
||||
const match3dLocalRuntimeAdapterMock = vi.hoisted(() => ({
|
||||
clickItem: vi.fn(),
|
||||
finishTimeUp: vi.fn(),
|
||||
getRun: vi.fn(),
|
||||
restartRun: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
stopRun: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-runtime', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/match3d-runtime')
|
||||
@@ -2405,9 +2394,6 @@ beforeEach(() => {
|
||||
vi.mocked(createServerMatch3DRuntimeAdapter).mockReturnValue(
|
||||
match3dServerRuntimeAdapterMock,
|
||||
);
|
||||
vi.mocked(createLocalMatch3DRuntimeAdapter).mockReturnValue(
|
||||
match3dLocalRuntimeAdapterMock,
|
||||
);
|
||||
match3dServerRuntimeAdapterMock.startRun.mockRejectedValue(
|
||||
new Error('未启动抓大鹅运行态'),
|
||||
);
|
||||
@@ -2423,21 +2409,6 @@ beforeEach(() => {
|
||||
match3dServerRuntimeAdapterMock.stopRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-profile-stopped'),
|
||||
});
|
||||
match3dLocalRuntimeAdapterMock.startRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-demo-20260525'),
|
||||
});
|
||||
match3dLocalRuntimeAdapterMock.clickItem.mockRejectedValue(
|
||||
new Error('未执行本地抓大鹅点击'),
|
||||
);
|
||||
match3dLocalRuntimeAdapterMock.restartRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-demo-20260525'),
|
||||
});
|
||||
match3dLocalRuntimeAdapterMock.finishTimeUp.mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-demo-20260525'),
|
||||
});
|
||||
match3dLocalRuntimeAdapterMock.stopRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-demo-20260525'),
|
||||
});
|
||||
window.history.replaceState(null, '', '/');
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.clear();
|
||||
@@ -5080,6 +5051,22 @@ test('completed match3d draft notice first opens trial then reopens result', asy
|
||||
resolveCompile({ session: generatedSession });
|
||||
});
|
||||
|
||||
const completionDialog = await screen.findByRole('dialog', {
|
||||
name: '生成完成',
|
||||
});
|
||||
expect(
|
||||
within(completionDialog).getByText(
|
||||
/抓大鹅草稿 match3d-notice-session-1/u,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(completionDialog).getByText(/生成任务已完成/u),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(completionDialog).getByRole('button', { name: '复制内容' }),
|
||||
).toBeTruthy();
|
||||
await user.click(within(completionDialog).getByLabelText('关闭'));
|
||||
|
||||
expect(await screen.findByLabelText('新生成完成')).toBeTruthy();
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
@@ -7503,6 +7490,48 @@ test('persisted generating puzzle draft keeps session polling on the same sessio
|
||||
expect(getPuzzleAgentSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('puzzle compile timeout shows failure dialog when reread session is still generating', async () => {
|
||||
const user = userEvent.setup();
|
||||
const runningSession = buildMockPuzzleAgentSession({
|
||||
sessionId: 'puzzle-session-timeout',
|
||||
draft: null,
|
||||
stage: 'collecting_anchors',
|
||||
progressPercent: 88,
|
||||
lastAssistantReply: '正在生成拼图草稿。',
|
||||
});
|
||||
vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({
|
||||
session: runningSession,
|
||||
});
|
||||
vi.mocked(executePuzzleAgentAction).mockRejectedValueOnce(
|
||||
Object.assign(new Error('请求超时:1800000ms'), {
|
||||
name: 'TimeoutError',
|
||||
}),
|
||||
);
|
||||
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
|
||||
session: runningSession,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '发生错误' });
|
||||
expect(within(dialog).getByText('拼图草稿 puzzle-session-timeout')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByText(
|
||||
'拼图共创操作超时,请确认运行时后端已启动后重试。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '复制报错' })).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '拼图草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('published puzzle work card restores its source session for editing', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -8332,38 +8361,6 @@ test('public code search opens a published Match3D work by M3 code and starts ru
|
||||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('public code search opens the local Match3D demo and starts local runtime', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listMatch3DGallery).mockResolvedValue({ items: [] });
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'M3-20260525');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
expect(await screen.findByText('详情')).toBeTruthy();
|
||||
expect(screen.getByText('海底糖果集市')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dLocalRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-demo-20260525',
|
||||
{},
|
||||
);
|
||||
});
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).not.toHaveBeenCalled();
|
||||
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
|
||||
'match3d-demo-20260525',
|
||||
);
|
||||
expect(
|
||||
await screen.findByText('抓大鹅运行态:match3d-run-match3d-demo-20260525'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('published Match3D runtime receives persisted generated models', async () => {
|
||||
const user = userEvent.setup();
|
||||
const match3dWork: Match3DWorkSummary = {
|
||||
|
||||
@@ -702,14 +702,22 @@ function mockNarrowMobileLayout() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfileView(
|
||||
function ProfileHomeViewHarness({
|
||||
onRechargeSuccess = vi.fn(),
|
||||
profileDashboardOverrides: Partial<
|
||||
profileDashboardOverrides = {},
|
||||
userOverrides = {},
|
||||
activeTab = 'profile',
|
||||
profileTaskRefreshKey = 0,
|
||||
}: {
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
profileDashboardOverrides?: Partial<
|
||||
NonNullable<RpgEntryHomeViewProps['profileDashboard']>
|
||||
> = {},
|
||||
userOverrides: Partial<AuthUser> = {},
|
||||
) {
|
||||
return render(
|
||||
>;
|
||||
userOverrides?: Partial<AuthUser>;
|
||||
activeTab?: RpgEntryHomeViewProps['activeTab'];
|
||||
profileTaskRefreshKey?: number;
|
||||
}) {
|
||||
return (
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: {
|
||||
@@ -742,7 +750,7 @@ function renderProfileView(
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab="profile"
|
||||
activeTab={activeTab}
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
@@ -772,8 +780,27 @@ function renderProfileView(
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
onRechargeSuccess={onRechargeSuccess}
|
||||
profileTaskRefreshKey={profileTaskRefreshKey}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderProfileView(
|
||||
onRechargeSuccess = vi.fn(),
|
||||
profileDashboardOverrides: Partial<
|
||||
NonNullable<RpgEntryHomeViewProps['profileDashboard']>
|
||||
> = {},
|
||||
userOverrides: Partial<AuthUser> = {},
|
||||
profileTaskRefreshKey = 0,
|
||||
) {
|
||||
return render(
|
||||
<ProfileHomeViewHarness
|
||||
onRechargeSuccess={onRechargeSuccess}
|
||||
profileDashboardOverrides={profileDashboardOverrides}
|
||||
userOverrides={userOverrides}
|
||||
profileTaskRefreshKey={profileTaskRefreshKey}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1902,11 +1929,18 @@ test('non-wechat profile opens reward code from recharge-shaped entry', async ()
|
||||
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('profile daily task shortcut opens task center and claims reward', async () => {
|
||||
test('profile daily task shortcut reflects task progress and claim updates', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
|
||||
const dailyTask = screen.getByRole('button', { name: /每日任务/u });
|
||||
await waitFor(() => {
|
||||
expect(within(dailyTask).getByText('1 / 1')).toBeTruthy();
|
||||
});
|
||||
expect(within(dailyTask).getByText('领取')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /每日任务/u }));
|
||||
|
||||
expect(await screen.findByText('每日登录')).toBeTruthy();
|
||||
@@ -1923,6 +1957,7 @@ test('profile daily task shortcut opens task center and claims reward', async ()
|
||||
expect(await screen.findByText('已领取 10 泥点')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '已领取' })).toBeNull();
|
||||
expect(screen.getByText('暂无任务')).toBeTruthy();
|
||||
expect(within(dailyTask).getByText('已完成')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile task center keeps only the highest priority actionable task', async () => {
|
||||
@@ -1985,7 +2020,7 @@ test('profile task center keeps only the highest priority actionable task', asyn
|
||||
expect(screen.queryByText('低优先级已完成')).toBeNull();
|
||||
});
|
||||
|
||||
test('profile total play time card always uses hours', () => {
|
||||
test('profile total play time card always uses hours', async () => {
|
||||
renderProfileView(vi.fn(), {
|
||||
totalPlayTimeMs: 90 * 60 * 1000,
|
||||
});
|
||||
@@ -1996,9 +2031,10 @@ test('profile total play time card always uses hours', () => {
|
||||
|
||||
expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy();
|
||||
expect(within(playTimeCard).queryByText('90分')).toBeNull();
|
||||
await screen.findByText('1 / 1');
|
||||
});
|
||||
|
||||
test('profile played works card shows count unit', () => {
|
||||
test('profile played works card shows count unit', async () => {
|
||||
renderProfileView(vi.fn(), {
|
||||
playedWorldCount: 1,
|
||||
});
|
||||
@@ -2008,9 +2044,10 @@ test('profile played works card shows count unit', () => {
|
||||
});
|
||||
|
||||
expect(within(playedCard).getByText('1个')).toBeTruthy();
|
||||
await screen.findByText('1 / 1');
|
||||
});
|
||||
|
||||
test('profile stats cards are centered without update timestamp', () => {
|
||||
test('profile stats cards are centered without update timestamp', async () => {
|
||||
renderProfileView(vi.fn(), {
|
||||
updatedAt: '2026-05-03T08:01:00Z',
|
||||
});
|
||||
@@ -2026,6 +2063,7 @@ test('profile stats cards are centered without update timestamp', () => {
|
||||
expect(card.className).toContain('text-center');
|
||||
}
|
||||
expect(screen.queryByText(/更新于/u)).toBeNull();
|
||||
await screen.findByText('1 / 1');
|
||||
});
|
||||
|
||||
test('mobile profile page matches the reference layout sections', async () => {
|
||||
@@ -2083,7 +2121,7 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
expect(dailyTask.querySelector('.platform-profile-daily-task-card__desc')).toBeTruthy();
|
||||
expect(dailyTask.querySelector('.platform-profile-daily-task-card__progress')).toBeTruthy();
|
||||
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
|
||||
expect(within(dailyTask).getByText('0 / 1')).toBeTruthy();
|
||||
expect(await within(dailyTask).findByText('1 / 1')).toBeTruthy();
|
||||
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
expect(
|
||||
@@ -2091,7 +2129,7 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
shortcutRegion.querySelectorAll('.platform-profile-shortcut-button'),
|
||||
).toHaveLength(5);
|
||||
).toHaveLength(4);
|
||||
expect(
|
||||
shortcutRegion
|
||||
.querySelector('.platform-profile-shortcut-grid')
|
||||
@@ -2099,7 +2137,6 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
).toBe(true);
|
||||
for (const label of [
|
||||
'泥点充值',
|
||||
'邀请好友',
|
||||
'兑换码',
|
||||
'玩家社区',
|
||||
'反馈与建议',
|
||||
@@ -2177,7 +2214,7 @@ test('profile scan action opens camera scanner instead of recharge panel', async
|
||||
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('desktop account entry uses saved avatar image when available', () => {
|
||||
test('desktop account entry uses saved avatar image when available', async () => {
|
||||
mockDesktopLayout();
|
||||
const avatarUrl = 'data:image/png;base64,AAAA';
|
||||
|
||||
@@ -2187,6 +2224,7 @@ test('desktop account entry uses saved avatar image when available', () => {
|
||||
const avatarImage = accountEntry.querySelector('img');
|
||||
expect(avatarImage?.getAttribute('src')).toBe(avatarUrl);
|
||||
expect(within(accountEntry).queryByText('测')).toBeNull();
|
||||
await screen.findByText('1 / 1');
|
||||
});
|
||||
|
||||
test('profile avatar upload uses the shared square crop tool', async () => {
|
||||
@@ -2236,83 +2274,83 @@ test('wallet ledger modal shows empty and error states', async () => {
|
||||
expect(screen.getByText('重新加载')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile invite shortcut shows reward subtitle and invited users', async () => {
|
||||
test('profile community shortcut shows reward subtitle and invited users', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderProfileView(vi.fn(), {}, { createdAt: buildFreshProfileCreatedAt() });
|
||||
|
||||
const inviteButton = screen.getByRole('button', { name: /邀请好友/u });
|
||||
expect(within(inviteButton).getByText('双方得 30 泥点')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /邀请好友/u })).toBeNull();
|
||||
|
||||
const communityButton = screen.getByRole('button', { name: /玩家社区/u });
|
||||
expect(within(communityButton).getByText('交流心得 领取福利')).toBeTruthy();
|
||||
|
||||
await user.click(inviteButton);
|
||||
await user.click(communityButton);
|
||||
|
||||
expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
await screen.findByText('邀请一个用户注册,双方都可以获得30泥点。'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('每日最多获得十次邀请奖励。')).toBeTruthy();
|
||||
expect(screen.getByText('成功邀请')).toBeTruthy();
|
||||
expect(screen.getByText('被邀请玩家')).toBeTruthy();
|
||||
expect(screen.queryByText('已奖')).toBeNull();
|
||||
expect(screen.queryByText('今日')).toBeNull();
|
||||
expect(screen.getByAltText('玩家社区微信群二维码')).toBeTruthy();
|
||||
expect(screen.getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
|
||||
expect(screen.getByText('微信群')).toBeTruthy();
|
||||
expect(screen.getByText('QQ群')).toBeTruthy();
|
||||
expect(screen.queryByText('成功邀请')).toBeNull();
|
||||
expect(screen.queryByText('被邀请玩家')).toBeNull();
|
||||
});
|
||||
|
||||
test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => {
|
||||
test('profile page hides legacy redeem invite secondary shortcut for fresh accounts', async () => {
|
||||
renderProfileView(
|
||||
vi.fn(),
|
||||
{},
|
||||
{ createdAt: buildFreshProfileCreatedAt() },
|
||||
);
|
||||
|
||||
const inviteButton = screen.getByRole('button', { name: /邀请好友/u });
|
||||
const redeemButton = await screen.findByRole('button', {
|
||||
name: /填邀请码/u,
|
||||
});
|
||||
const communityButton = screen.getByRole('button', { name: /玩家社区/u });
|
||||
const secondaryShortcuts = screen.getByRole('region', {
|
||||
name: '次级入口',
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(inviteButton).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /邀请好友/u })).toBeNull();
|
||||
expect(communityButton).toBeTruthy();
|
||||
expect(
|
||||
within(secondaryShortcuts).getByRole('button', { name: /填邀请码/u }),
|
||||
).toBeTruthy();
|
||||
expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy();
|
||||
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /填邀请码/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('profile redeem invite shortcut hides after redeemed or one day old', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockGetRpgProfileReferralInviteCenter.mockResolvedValueOnce(
|
||||
mockBuildReferralCenter({
|
||||
invitedUsers: [],
|
||||
hasRedeemedCode: true,
|
||||
boundInviterUserId: 'user-2',
|
||||
boundAt: '2026-05-01T08:00:00Z',
|
||||
}),
|
||||
);
|
||||
const { unmount } = renderProfileView();
|
||||
await user.click(screen.getByRole('button', { name: /邀请好友/u }));
|
||||
await screen.findByText('成功邀请');
|
||||
const firstShortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
expect(
|
||||
within(firstShortcutRegion).queryByRole('button', { name: /邀请好友/u }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(firstShortcutRegion).queryByRole('button', { name: /填邀请码/u }),
|
||||
).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
unmount();
|
||||
|
||||
renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' });
|
||||
const expiredShortcutRegion = screen.getByRole('region', {
|
||||
name: '常用功能',
|
||||
});
|
||||
expect(
|
||||
within(expiredShortcutRegion).queryByRole('button', {
|
||||
name: /邀请好友/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(expiredShortcutRegion).queryByRole('button', {
|
||||
name: /填邀请码/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
test('invite query opens login modal for logged out users', async () => {
|
||||
@@ -2345,9 +2383,10 @@ test('profile redeem invite modal reads query invite code after login', async ()
|
||||
expect((input as HTMLInputElement).value).toBe('SPRING2026');
|
||||
});
|
||||
|
||||
test('profile redeem invite modal submits code and hides shortcut after success', async () => {
|
||||
test('profile redeem invite query modal submits code after login', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
window.history.replaceState(null, '', '/?inviteCode=spring-2026');
|
||||
|
||||
renderProfileView(
|
||||
onRechargeSuccess,
|
||||
@@ -2355,9 +2394,7 @@ test('profile redeem invite modal submits code and hides shortcut after success'
|
||||
{ createdAt: buildFreshProfileCreatedAt() },
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /填邀请码/u }));
|
||||
const input = await screen.findByLabelText('邀请码');
|
||||
await user.type(input, 'spring-2026');
|
||||
expect(await screen.findByLabelText('邀请码')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '提交' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -2367,12 +2404,23 @@ test('profile redeem invite modal submits code and hides shortcut after success'
|
||||
});
|
||||
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText('已填写')).toBeTruthy();
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
expect(
|
||||
within(shortcutRegion).queryByRole('button', {
|
||||
name: /填邀请码/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
|
||||
});
|
||||
|
||||
test('profile task center reloads when refresh key changes', async () => {
|
||||
const { rerender } = renderProfileView();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ProfileHomeViewHarness profileTaskRefreshKey={1} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('opens reward code modal from profile action on mobile', async () => {
|
||||
@@ -2402,8 +2450,8 @@ test('profile page shows legal entries and hides archive shortcuts', async () =>
|
||||
?.classList.contains('platform-profile-shortcut-grid'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /邀请好友/u }),
|
||||
).toBeTruthy();
|
||||
within(shortcutRegion).queryByRole('button', { name: /邀请好友/u }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /玩家社区/u }),
|
||||
).toBeTruthy();
|
||||
@@ -3274,6 +3322,41 @@ test('logged out active recommend bottom tab selects next work without login', a
|
||||
expect(openLoginModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('logged out recommend card supports vertical swipe without login', () => {
|
||||
vi.useFakeTimers();
|
||||
const onSelectNextRecommendEntry = vi.fn();
|
||||
const openLoginModal = vi.fn();
|
||||
|
||||
renderLoggedOutHomeView(openLoginModal, {
|
||||
latestEntries: [
|
||||
puzzlePublicEntry,
|
||||
{
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-guest-next',
|
||||
profileId: 'puzzle-profile-guest-next',
|
||||
ownerUserId: 'user-guest-next',
|
||||
publicWorkCode: 'PZ-GUEST-NEXT',
|
||||
worldName: '访客下一张',
|
||||
},
|
||||
],
|
||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||
onSelectNextRecommendEntry,
|
||||
recommendRuntimeContent: <div data-testid="guest-recommend-runtime" />,
|
||||
});
|
||||
|
||||
const meta = screen.getByLabelText('奇幻拼图 作品信息') as HTMLElement;
|
||||
act(() => {
|
||||
dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 320 });
|
||||
dispatchPointerEvent(meta, 'pointermove', { pointerId: 1, clientY: 220 });
|
||||
dispatchPointerEvent(meta, 'pointerup', { pointerId: 1, clientY: 220 });
|
||||
vi.advanceTimersByTime(180);
|
||||
});
|
||||
|
||||
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
|
||||
expect(openLoginModal).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('mobile recommend meta loads real author avatar from public user summary', async () => {
|
||||
mockGetPublicAuthUserById.mockResolvedValueOnce({
|
||||
id: 'user-2',
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
Star,
|
||||
ThumbsUp,
|
||||
Ticket,
|
||||
UserPlus,
|
||||
UserRound,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
@@ -50,7 +49,6 @@ import profileClockImage from '../../../media/profile/_Image (1).png';
|
||||
import profileGamepadImage from '../../../media/profile/_Image (2).png';
|
||||
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
|
||||
import profileCoinsImage from '../../../media/profile/_Image (4).png';
|
||||
import profileInviteImage from '../../../media/profile/_Image (5).png';
|
||||
import profileGiftImage from '../../../media/profile/_Image (6).png';
|
||||
import profileCommunityImage from '../../../media/profile/_Image (7).png';
|
||||
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
|
||||
@@ -79,7 +77,6 @@ import type {
|
||||
WechatMiniProgramVirtualPayParams,
|
||||
WechatNativePayment,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { isMatch3DDemoProfileId } from '../../data/match3dDemoGalleryCard';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
@@ -218,6 +215,7 @@ export interface RpgEntryHomeViewProps {
|
||||
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
|
||||
onOpenFeedback?: () => void;
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
profileTaskRefreshKey?: number;
|
||||
createTabContent?: ReactNode;
|
||||
draftTabContent?: ReactNode;
|
||||
hasUnreadDraftUpdate?: boolean;
|
||||
@@ -257,18 +255,25 @@ const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
|
||||
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
|
||||
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<ProfileTaskItem['status'], number> = {
|
||||
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<
|
||||
ProfileTaskItem['status'],
|
||||
number
|
||||
> = {
|
||||
claimable: 2,
|
||||
incomplete: 1,
|
||||
disabled: 0,
|
||||
claimed: -1,
|
||||
};
|
||||
const PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS = 10;
|
||||
const PROFILE_QR_SCAN_INTERVAL_MS = 360;
|
||||
|
||||
function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
|
||||
return tasks
|
||||
.map((task, index) => ({ task, index }))
|
||||
.filter(({ task }) => task.status === 'claimable' || task.status === 'incomplete')
|
||||
.filter(
|
||||
({ task }) =>
|
||||
task.status === 'claimable' || task.status === 'incomplete',
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] -
|
||||
@@ -279,6 +284,37 @@ function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
|
||||
.map(({ task }) => task);
|
||||
}
|
||||
|
||||
function selectProfileTaskCardTask(tasks: ProfileTaskItem[]) {
|
||||
return (
|
||||
selectProfileTaskCenterTasks(tasks)[0] ??
|
||||
tasks.find((task) => task.status === 'claimed') ??
|
||||
tasks.find((task) => task.status !== 'disabled') ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function buildProfileTaskCardSummary(center: ProfileTaskCenterResponse | null) {
|
||||
const task = selectProfileTaskCardTask(center?.tasks ?? []);
|
||||
const threshold = Math.max(1, task?.threshold ?? 1);
|
||||
const progressCount = Math.min(task?.progressCount ?? 0, threshold);
|
||||
const rewardPoints =
|
||||
task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS;
|
||||
const actionLabel =
|
||||
task?.status === 'claimable'
|
||||
? '领取'
|
||||
: task?.status === 'claimed'
|
||||
? '已完成'
|
||||
: '去完成';
|
||||
|
||||
return {
|
||||
actionLabel,
|
||||
progressCount,
|
||||
progressPercent: Math.round((progressCount / threshold) * 100),
|
||||
rewardPoints,
|
||||
threshold,
|
||||
};
|
||||
}
|
||||
|
||||
type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
|
||||
type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
|
||||
type BarcodeDetectorLike = {
|
||||
@@ -2451,42 +2487,6 @@ function ProfileSettingsRow({
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSecondaryShortcutButton({
|
||||
label,
|
||||
subLabel,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
subLabel?: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const Icon = icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="platform-profile-secondary-shortcut inline-flex items-center gap-2 rounded-full px-3 py-2 text-left"
|
||||
>
|
||||
<span className="platform-profile-secondary-shortcut__icon">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[13px] font-semibold text-[var(--platform-text-strong)]">
|
||||
{label}
|
||||
</span>
|
||||
{subLabel ? (
|
||||
<span className="mt-0.5 block truncate text-[11px] font-medium text-[var(--platform-text-soft)]">
|
||||
{subLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileLegalSection({
|
||||
onOpenDocument,
|
||||
}: {
|
||||
@@ -3987,6 +3987,7 @@ export function RpgEntryHomeView({
|
||||
onOpenPlayedWork,
|
||||
onOpenFeedback,
|
||||
onRechargeSuccess,
|
||||
profileTaskRefreshKey = 0,
|
||||
createTabContent,
|
||||
draftTabContent,
|
||||
hasUnreadDraftUpdate = false,
|
||||
@@ -4028,6 +4029,7 @@ export function RpgEntryHomeView({
|
||||
useState<ProfileTaskCenterResponse | null>(null);
|
||||
const [taskCenterError, setTaskCenterError] = useState<string | null>(null);
|
||||
const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false);
|
||||
const taskCenterRequestIdRef = useRef(0);
|
||||
const [claimingTaskId, setClaimingTaskId] = useState<string | null>(null);
|
||||
const [taskClaimSuccess, setTaskClaimSuccess] = useState<string | null>(null);
|
||||
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
|
||||
@@ -4047,6 +4049,7 @@ export function RpgEntryHomeView({
|
||||
: readProfileInviteCodeFromLocationSearch(window.location.search),
|
||||
[],
|
||||
);
|
||||
const promptedLoginForInviteQueryRef = useRef(false);
|
||||
const autoOpenedInviteQueryRef = useRef(false);
|
||||
const [referralRedeemCode, setReferralRedeemCode] = useState(
|
||||
pendingProfileInviteCode,
|
||||
@@ -4225,12 +4228,10 @@ export function RpgEntryHomeView({
|
||||
profileDashboard?.totalPlayTimeMs ?? 0,
|
||||
);
|
||||
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
|
||||
const canShowReferralRedeemShortcut =
|
||||
isAuthenticated &&
|
||||
isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt) &&
|
||||
isReferralCenterInitialized &&
|
||||
Boolean(referralCenter) &&
|
||||
referralCenter?.hasRedeemedCode !== true;
|
||||
const profileTaskCardSummary = useMemo(
|
||||
() => buildProfileTaskCardSummary(taskCenter),
|
||||
[taskCenter],
|
||||
);
|
||||
const tabIcons: Record<
|
||||
PlatformHomeTab,
|
||||
ComponentType<{ className?: string }>
|
||||
@@ -4299,19 +4300,13 @@ export function RpgEntryHomeView({
|
||||
return;
|
||||
}
|
||||
|
||||
const firstCategoryGroup =
|
||||
categoryGroups.find((group) =>
|
||||
group.entries.some((entry) => !isMatch3DDemoProfileId(entry.profileId)),
|
||||
) ?? categoryGroups[0];
|
||||
const firstCategoryGroup = categoryGroups[0];
|
||||
const selectedCategoryGroup =
|
||||
categoryGroups.find((group) => group.tag === selectedCategoryTag) ?? null;
|
||||
if (
|
||||
firstCategoryGroup &&
|
||||
(!selectedCategoryGroup ||
|
||||
(!hasManualCategoryTagSelectionRef.current &&
|
||||
selectedCategoryGroup.entries.every((entry) =>
|
||||
isMatch3DDemoProfileId(entry.profileId),
|
||||
) &&
|
||||
firstCategoryGroup.tag !== selectedCategoryGroup.tag))
|
||||
) {
|
||||
setSelectedCategoryTag(firstCategoryGroup.tag);
|
||||
@@ -4397,12 +4392,15 @@ export function RpgEntryHomeView({
|
||||
return;
|
||||
}
|
||||
|
||||
autoOpenedInviteQueryRef.current = true;
|
||||
if (!authUi?.user) {
|
||||
authUi?.openLoginModal();
|
||||
if (!promptedLoginForInviteQueryRef.current) {
|
||||
promptedLoginForInviteQueryRef.current = true;
|
||||
authUi?.openLoginModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
autoOpenedInviteQueryRef.current = true;
|
||||
setReferralRedeemCode(pendingProfileInviteCode);
|
||||
setReferralError(null);
|
||||
setReferralSuccess(null);
|
||||
@@ -4803,23 +4801,49 @@ export function RpgEntryHomeView({
|
||||
document.removeEventListener('visibilitychange', handleResume);
|
||||
};
|
||||
}, [handleWechatPayResult]);
|
||||
const loadTaskCenter = () => {
|
||||
const loadTaskCenter = useCallback(() => {
|
||||
const requestId = ++taskCenterRequestIdRef.current;
|
||||
setTaskCenterError(null);
|
||||
setIsLoadingTaskCenter(true);
|
||||
void getRpgProfileTasks()
|
||||
.then(setTaskCenter)
|
||||
.then((center) => {
|
||||
if (requestId === taskCenterRequestIdRef.current) {
|
||||
setTaskCenter(center);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (requestId !== taskCenterRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
setTaskCenter(null);
|
||||
setTaskCenterError(
|
||||
error instanceof Error ? error.message : '读取每日任务失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingTaskCenter(false));
|
||||
};
|
||||
.finally(() => {
|
||||
if (requestId === taskCenterRequestIdRef.current) {
|
||||
setIsLoadingTaskCenter(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'profile' || !isAuthenticated) {
|
||||
taskCenterRequestIdRef.current += 1;
|
||||
setTaskCenter(null);
|
||||
setTaskCenterError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
loadTaskCenter();
|
||||
}, [activeTab, isAuthenticated, loadTaskCenter, profileTaskRefreshKey]);
|
||||
|
||||
const openTaskCenterPanel = () => {
|
||||
setIsTaskCenterOpen(true);
|
||||
setTaskClaimSuccess(null);
|
||||
loadTaskCenter();
|
||||
if (!taskCenter) {
|
||||
loadTaskCenter();
|
||||
}
|
||||
};
|
||||
const openQrScannerPanel = () => {
|
||||
if (!authUi?.user) {
|
||||
@@ -5266,7 +5290,6 @@ export function RpgEntryHomeView({
|
||||
(event: PointerEvent<HTMLElement>) => {
|
||||
if (
|
||||
recommendDragCommitDirection ||
|
||||
!isAuthenticated ||
|
||||
!activeRecommendEntry ||
|
||||
recommendedFeedEntries.length <= 1
|
||||
) {
|
||||
@@ -5282,7 +5305,6 @@ export function RpgEntryHomeView({
|
||||
},
|
||||
[
|
||||
activeRecommendEntry,
|
||||
isAuthenticated,
|
||||
recommendDragCommitDirection,
|
||||
recommendedFeedEntries.length,
|
||||
],
|
||||
@@ -6223,14 +6245,24 @@ export function RpgEntryHomeView({
|
||||
每日任务
|
||||
</span>
|
||||
<span className="platform-profile-daily-task-card__desc mt-4 block text-[13px] font-medium text-[var(--platform-text-base)]">
|
||||
完成任务可领取 <span className="text-[#c45b2a]">10</span> 泥点
|
||||
完成任务可领取{' '}
|
||||
<span className="text-[#c45b2a]">
|
||||
{profileTaskCardSummary.rewardPoints}
|
||||
</span>{' '}
|
||||
泥点
|
||||
</span>
|
||||
<span className="platform-profile-daily-task-card__progress mt-4 flex items-center gap-3">
|
||||
<span className="platform-profile-daily-task-card__progress-value text-[14px] font-semibold text-[#dc3f0e]">
|
||||
0 / 1
|
||||
{profileTaskCardSummary.progressCount} /{' '}
|
||||
{profileTaskCardSummary.threshold}
|
||||
</span>
|
||||
<span className="platform-profile-daily-task-card__track">
|
||||
<span className="platform-profile-daily-task-card__bar" />
|
||||
<span
|
||||
className="platform-profile-daily-task-card__bar"
|
||||
style={{
|
||||
width: `${profileTaskCardSummary.progressPercent}%`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
@@ -6240,7 +6272,7 @@ export function RpgEntryHomeView({
|
||||
className="platform-profile-daily-task-card__mascot"
|
||||
/>
|
||||
<span className="platform-profile-daily-task-card__action">
|
||||
去完成
|
||||
{profileTaskCardSummary.actionLabel}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -6256,13 +6288,6 @@ export function RpgEntryHomeView({
|
||||
imageSrc={profileCoinsImage}
|
||||
onClick={openRechargeOrRewardCodeModal}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="邀请好友"
|
||||
subLabel="双方得 30 泥点"
|
||||
icon={UserPlus}
|
||||
imageSrc={profileInviteImage}
|
||||
onClick={() => openProfilePopupPanel('invite')}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="兑换码"
|
||||
subLabel="领取福利"
|
||||
@@ -6305,20 +6330,6 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
</section>
|
||||
|
||||
{canShowReferralRedeemShortcut ? (
|
||||
<section
|
||||
className="platform-profile-secondary-shortcuts"
|
||||
aria-label="次级入口"
|
||||
>
|
||||
<ProfileSecondaryShortcutButton
|
||||
label="填邀请码"
|
||||
subLabel="新用户奖励"
|
||||
icon={Ticket}
|
||||
onClick={() => openProfilePopupPanel('redeem')}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export function resolveRpgEntryErrorMessage(error: unknown, fallback: string) {
|
||||
if (isTimeoutError(error)) {
|
||||
if (/拼图/u.test(fallback) && /操作|执行|编译|生成草稿/u.test(fallback)) {
|
||||
return '拼图共创操作超时,请确认运行时后端已启动后重试。';
|
||||
}
|
||||
if (/智能创作/u.test(fallback)) {
|
||||
return '开启智能创作工作区超时,请确认运行时后端已启动后重试。';
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
formatPlatformWorldTime,
|
||||
isBarkBattleGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isMatch3DGalleryEntry,
|
||||
isVisualNovelGalleryEntry,
|
||||
isWoodenFishGalleryEntry,
|
||||
mapBabyObjectMatchDraftToPlatformGalleryCard,
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldFallbackCoverImage,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
import { buildMatch3DDemoGalleryCard } from '../../data/match3dDemoGalleryCard';
|
||||
|
||||
test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => {
|
||||
expect(formatPlatformWorldTime('1777110165.990127Z')).toBe('2026-04-25');
|
||||
@@ -80,24 +78,6 @@ test('platform public cards use play type reference images as cover fallback', (
|
||||
);
|
||||
});
|
||||
|
||||
test('builds local Match3D demo gallery card with generated runtime assets intact', () => {
|
||||
const card = buildMatch3DDemoGalleryCard();
|
||||
|
||||
expect(isMatch3DGalleryEntry(card)).toBe(true);
|
||||
expect(card.publicWorkCode).toBe('M3-20260525');
|
||||
expect(resolvePlatformPublicWorkCode(card)).toBe('M3-20260525');
|
||||
expect(card.coverImageSrc).toBe(
|
||||
'/match3d-demo/undersea-candy-market/level-scene.png',
|
||||
);
|
||||
expect(card.generatedBackgroundAsset?.uiSpritesheetImageSrc).toBe(
|
||||
'/match3d-demo/undersea-candy-market/ui-spritesheet.png',
|
||||
);
|
||||
expect(card.generatedBackgroundAsset?.containerImageSrc).toBeNull();
|
||||
expect(card.generatedItemAssets?.[0]?.imageViews?.[0]?.imageSrc).toBe(
|
||||
'/match3d-demo/undersea-candy-market/item-slices/item-01/view-01.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
|
||||
const slides = buildPuzzleWorkCoverSlides({
|
||||
workId: 'work-1',
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
Match3DWorkProfile,
|
||||
} from '../../packages/shared/src/contracts/match3dWorks';
|
||||
import { buildMatch3DPublicWorkCode } from '../services/publicWorkCode';
|
||||
import type { PlatformMatch3DGalleryCard } from '../components/rpg-entry/rpgEntryWorldPresentation';
|
||||
|
||||
export const MATCH3D_DEMO_PROFILE_ID = 'match3d-demo-20260525';
|
||||
export const MATCH3D_DEMO_WORK_ID = 'match3d-demo-undersea-candy-market';
|
||||
export const MATCH3D_DEMO_PUBLIC_WORK_CODE =
|
||||
buildMatch3DPublicWorkCode(MATCH3D_DEMO_PROFILE_ID);
|
||||
|
||||
const MATCH3D_DEMO_ASSET_BASE = '/match3d-demo/undersea-candy-market';
|
||||
const MATCH3D_DEMO_PUBLISHED_AT = '2026-05-25T12:04:17.000+08:00';
|
||||
const MATCH3D_DEMO_ITEM_NAMES = [
|
||||
'海星糖',
|
||||
'贝壳糖',
|
||||
'珊瑚软糖',
|
||||
'珍珠泡泡糖',
|
||||
'海马棒棒糖',
|
||||
'鱼尾果冻',
|
||||
'水母棉花糖',
|
||||
'螺旋饼干',
|
||||
'海螺巧克力',
|
||||
'贝珠马卡龙',
|
||||
'珊瑚杯糕',
|
||||
'星砂软糖',
|
||||
'小鱼糖块',
|
||||
'海草曲奇',
|
||||
'泡泡杯',
|
||||
'蓝莓珊瑚糖',
|
||||
'迷你糖罐',
|
||||
'珍珠饼',
|
||||
'海浪甜甜圈',
|
||||
'贝壳蛋糕',
|
||||
] as const;
|
||||
|
||||
export const MATCH3D_DEMO_BACKGROUND_ASSET: Match3DGeneratedBackgroundAsset = {
|
||||
prompt: '海底糖果集市抓大鹅关卡背景',
|
||||
levelScenePrompt: '海底糖果集市完整关卡画面',
|
||||
levelSceneImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/level-scene.png`,
|
||||
imageSrc: `${MATCH3D_DEMO_ASSET_BASE}/background.png`,
|
||||
uiSpritesheetPrompt: '海底糖果集市 UI 透明 spritesheet',
|
||||
uiSpritesheetImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/ui-spritesheet.png`,
|
||||
itemSpritesheetPrompt: '海底糖果集市物品 10x10 透明 spritesheet',
|
||||
itemSpritesheetImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/item-spritesheet.png`,
|
||||
containerImageSrc: null,
|
||||
status: 'image_ready',
|
||||
};
|
||||
|
||||
export function buildMatch3DDemoGeneratedItemAssets() {
|
||||
return MATCH3D_DEMO_ITEM_NAMES.map<Match3DGeneratedItemAsset>(
|
||||
(itemName, itemIndex) => {
|
||||
const itemNumber = itemIndex + 1;
|
||||
const paddedItemNumber = String(itemNumber).padStart(2, '0');
|
||||
return {
|
||||
itemId: `match3d-item-${itemNumber}`,
|
||||
itemName,
|
||||
itemSize:
|
||||
itemIndex < 4 ? '大' : itemIndex < 14 ? '中' : '小',
|
||||
imageViews: Array.from({ length: 5 }, (_, viewIndex) => {
|
||||
const viewNumber = viewIndex + 1;
|
||||
return {
|
||||
viewId: `view-${String(viewNumber).padStart(2, '0')}`,
|
||||
viewIndex: viewNumber,
|
||||
imageSrc: `${MATCH3D_DEMO_ASSET_BASE}/item-slices/item-${paddedItemNumber}/view-${String(viewNumber).padStart(2, '0')}.png`,
|
||||
};
|
||||
}),
|
||||
backgroundAsset:
|
||||
itemIndex === 0 ? MATCH3D_DEMO_BACKGROUND_ASSET : null,
|
||||
status: 'image_ready',
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function buildMatch3DDemoWorkProfile(): Match3DWorkProfile {
|
||||
return {
|
||||
workId: MATCH3D_DEMO_WORK_ID,
|
||||
profileId: MATCH3D_DEMO_PROFILE_ID,
|
||||
ownerUserId: 'official-match3d-demo',
|
||||
sourceSessionId: 'match3d-demo-session-20260525',
|
||||
gameName: '海底糖果集市',
|
||||
themeText: '海底糖果集市',
|
||||
summary: '在海底糖果集市里把同款甜点抓成三消。',
|
||||
tags: ['抓大鹅', '海底糖果', '官方示例'],
|
||||
coverImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/level-scene.png`,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 21,
|
||||
difficulty: 8,
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: MATCH3D_DEMO_PUBLISHED_AT,
|
||||
publishedAt: MATCH3D_DEMO_PUBLISHED_AT,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
backgroundPrompt: MATCH3D_DEMO_BACKGROUND_ASSET.prompt,
|
||||
backgroundImageSrc: MATCH3D_DEMO_BACKGROUND_ASSET.imageSrc,
|
||||
backgroundImageObjectKey: null,
|
||||
generatedBackgroundAsset: MATCH3D_DEMO_BACKGROUND_ASSET,
|
||||
generatedItemAssets: buildMatch3DDemoGeneratedItemAssets(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMatch3DDemoGalleryCard(): PlatformMatch3DGalleryCard {
|
||||
const profile = buildMatch3DDemoWorkProfile();
|
||||
return {
|
||||
sourceType: 'match3d',
|
||||
workId: profile.workId,
|
||||
profileId: profile.profileId,
|
||||
sourceSessionId: profile.sourceSessionId,
|
||||
publicWorkCode: MATCH3D_DEMO_PUBLIC_WORK_CODE,
|
||||
ownerUserId: profile.ownerUserId,
|
||||
authorDisplayName: '官方示例',
|
||||
worldName: profile.gameName,
|
||||
subtitle: '抓大鹅 · 资源管线示例',
|
||||
summaryText: profile.summary,
|
||||
coverImageSrc: profile.coverImageSrc ?? null,
|
||||
themeTags: profile.tags,
|
||||
playCount: profile.playCount,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: 0,
|
||||
visibility: 'published',
|
||||
publishedAt: profile.publishedAt ?? null,
|
||||
updatedAt: profile.updatedAt,
|
||||
backgroundPrompt: profile.backgroundPrompt ?? null,
|
||||
backgroundImageSrc: profile.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: profile.backgroundImageObjectKey ?? null,
|
||||
generatedBackgroundAsset: profile.generatedBackgroundAsset ?? null,
|
||||
generatedItemAssets: profile.generatedItemAssets ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export const MATCH3D_DEMO_WORK_PROFILE = buildMatch3DDemoWorkProfile();
|
||||
export const MATCH3D_DEMO_GALLERY_CARD = buildMatch3DDemoGalleryCard();
|
||||
|
||||
export function isMatch3DDemoProfileId(profileId: string | null | undefined) {
|
||||
return profileId?.trim() === MATCH3D_DEMO_PROFILE_ID;
|
||||
}
|
||||
@@ -20,8 +20,8 @@ import { clearStoredAccessToken, getStoredAccessToken } from './apiClient';
|
||||
import {
|
||||
authEntry,
|
||||
bindWechatPhone,
|
||||
changePhoneNumber,
|
||||
changePassword,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
redeemRegistrationInviteCode,
|
||||
requestWechatMiniProgramPhoneLogin,
|
||||
revokeAuthSession,
|
||||
revokeAuthSessions,
|
||||
sendPhoneLoginCode,
|
||||
@@ -408,6 +409,84 @@ describe('authService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('requests mini program phone login by opening the native auth page', async () => {
|
||||
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
|
||||
options.success?.();
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
createWindowMock({
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '?clientRuntime=wechat_mini_program',
|
||||
assign: vi.fn(),
|
||||
},
|
||||
wx: {
|
||||
miniProgram: {
|
||||
navigateTo,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await requestWechatMiniProgramPhoneLogin();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(navigateTo).toHaveBeenCalledWith({
|
||||
url: '/pages/web-view/index?authAction=login&returnTo=previous',
|
||||
success: expect.any(Function),
|
||||
fail: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('waits for an existing WeChat JS SDK script before opening the native auth page', async () => {
|
||||
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
|
||||
options.success?.();
|
||||
});
|
||||
const scriptListeners = new Map<string, EventListener>();
|
||||
const existingScript = {
|
||||
addEventListener: vi.fn(
|
||||
(type: string, listener: EventListener) => {
|
||||
scriptListeners.set(type, listener);
|
||||
},
|
||||
),
|
||||
};
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
createWindowMock({
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '?clientRuntime=wechat_mini_program',
|
||||
assign: vi.fn(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('document', {
|
||||
querySelector: vi.fn(() => existingScript),
|
||||
head: {
|
||||
appendChild: vi.fn(),
|
||||
},
|
||||
createElement: vi.fn(),
|
||||
});
|
||||
|
||||
const request = requestWechatMiniProgramPhoneLogin();
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
navigateTo,
|
||||
},
|
||||
};
|
||||
scriptListeners.get('load')?.(new Event('load'));
|
||||
|
||||
await expect(request).resolves.toBe(true);
|
||||
expect(navigateTo).toHaveBeenCalledWith({
|
||||
url: '/pages/web-view/index?authAction=login&returnTo=previous',
|
||||
success: expect.any(Function),
|
||||
fail: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('loads available login methods for the unauthenticated login screen', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'wechat'],
|
||||
|
||||
@@ -20,8 +20,8 @@ import type {
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionsResponse,
|
||||
AuthSessionSummary,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatBindPhoneRequest,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
PublicUserSearchResponse,
|
||||
@@ -55,6 +55,10 @@ export type ConsumedAuthCallback = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const MINI_PROGRAM_AUTH_PAGE_URL =
|
||||
'/pages/web-view/index?authAction=login&returnTo=previous';
|
||||
|
||||
// 登录前公开认证入口不能误带旧 token,也不能先触发 refresh 探测,
|
||||
// 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。
|
||||
const PUBLIC_AUTH_REQUEST_OPTIONS = {
|
||||
@@ -80,6 +84,92 @@ export function clearRuntimeGuestTokenCache() {
|
||||
runtimeGuestTokenCache.value = null;
|
||||
}
|
||||
|
||||
export function isWechatMiniProgramWebViewRuntime() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
return (
|
||||
params.get('clientRuntime') === 'wechat_mini_program' ||
|
||||
params.get('clientType') === 'mini_program' ||
|
||||
Boolean(window.wx?.miniProgram?.postMessage)
|
||||
);
|
||||
}
|
||||
|
||||
function loadWechatMiniProgramBridge() {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.reject(new Error('请在微信小程序内完成登录'));
|
||||
}
|
||||
|
||||
if (window.wx?.miniProgram?.navigateTo) {
|
||||
return Promise.resolve(window.wx);
|
||||
}
|
||||
|
||||
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`script[src="${WECHAT_JS_SDK_URL}"]`,
|
||||
);
|
||||
const complete = () => {
|
||||
if (window.wx?.miniProgram?.navigateTo) {
|
||||
resolve(window.wx);
|
||||
} else {
|
||||
reject(new Error('请在微信小程序内完成登录'));
|
||||
}
|
||||
};
|
||||
|
||||
if (existingScript) {
|
||||
if (window.wx?.miniProgram?.navigateTo) {
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
|
||||
existingScript.addEventListener('load', complete, { once: true });
|
||||
existingScript.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error('请在微信小程序内完成登录')),
|
||||
{ once: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = WECHAT_JS_SDK_URL;
|
||||
script.async = true;
|
||||
script.onload = complete;
|
||||
script.onerror = () => reject(new Error('请在微信小程序内完成登录'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestWechatMiniProgramPhoneLogin() {
|
||||
if (!isWechatMiniProgramWebViewRuntime()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const wxBridge = await loadWechatMiniProgramBridge();
|
||||
const miniProgram = wxBridge.miniProgram;
|
||||
const navigateTo = miniProgram?.navigateTo;
|
||||
if (typeof navigateTo !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
navigateTo({
|
||||
url: MINI_PROGRAM_AUTH_PAGE_URL,
|
||||
success() {
|
||||
resolve();
|
||||
},
|
||||
fail(error) {
|
||||
reject(
|
||||
new Error(error?.errMsg || '请在微信小程序内完成登录'),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function ensureRuntimeGuestToken() {
|
||||
if (isRuntimeGuestTokenFresh(runtimeGuestTokenCache.value)) {
|
||||
return runtimeGuestTokenCache.value!;
|
||||
|
||||
@@ -279,7 +279,7 @@ async function generateBabyObjectMatchAssets(
|
||||
const assets = normalizeGeneratedAssets(response.assets, itemNames);
|
||||
const visualPackage = normalizeGeneratedVisualPackage(response.visualPackage);
|
||||
if (!assets || !visualPackage) {
|
||||
throw new Error('宝贝识物 image-2 资源生成结果不完整,请重试。');
|
||||
throw new Error('宝贝识物素材生成结果不完整,请重试。');
|
||||
}
|
||||
|
||||
return { assets, visualPackage };
|
||||
|
||||
@@ -117,15 +117,6 @@ test('local Match3D runtime adapter exposes the same runtime seam as the server
|
||||
expect(stopped.run.status).toBe('Stopped');
|
||||
});
|
||||
|
||||
test('local Match3D runtime adapter keeps the requested profile id on restart', async () => {
|
||||
const adapter = createLocalMatch3DRuntimeAdapter({ clearCount: 1 });
|
||||
const started = await adapter.startRun('match3d-demo-20260525');
|
||||
const restarted = await adapter.restartRun(started.run.runId);
|
||||
|
||||
expect(started.run.profileId).toBe('match3d-demo-20260525');
|
||||
expect(restarted.run.profileId).toBe('match3d-demo-20260525');
|
||||
});
|
||||
|
||||
test('local Match3D runtime adapter keeps authority run local to the adapter', async () => {
|
||||
const adapter = createLocalMatch3DRuntimeAdapter({ initialRun: startLocalMatch3DRun(1) });
|
||||
const first = await adapter.getRun('unused-run-id');
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
'建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
|
||||
);
|
||||
expect(progress?.steps[2]?.detail).toBe(
|
||||
'调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
|
||||
'生成 1:1 拼图首图,预计 4 分钟。',
|
||||
);
|
||||
expect(progress?.estimatedRemainingMs).toBe(446_500);
|
||||
expect(progress?.overallProgress).toBe(0);
|
||||
|
||||
@@ -167,7 +167,7 @@ function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
|
||||
steps.push({
|
||||
id: 'puzzle-cover-image',
|
||||
label: '生成拼图首图',
|
||||
detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
|
||||
detail: '生成 1:1 拼图首图,预计 4 分钟。',
|
||||
durationMs: PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS,
|
||||
});
|
||||
}
|
||||
@@ -177,15 +177,15 @@ function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
|
||||
id: 'puzzle-level-scene',
|
||||
label: '生成关卡画面',
|
||||
detail: shouldSkipPuzzleCoverGeneration(state)
|
||||
? '直接使用上传图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。'
|
||||
: '使用拼图首图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。',
|
||||
? '直接使用上传图作为参考,生成 9:16 完整关卡画面,预计 90 秒。'
|
||||
: '使用拼图首图作为参考,生成 9:16 完整关卡画面,预计 90 秒。',
|
||||
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-ui-assets',
|
||||
label: '生成UI与背景',
|
||||
detail:
|
||||
'用关卡画面作参考,并发生成 UI spritesheet 与 9:16 纯背景;两次 gpt-image-2 并发,预计 90 秒。',
|
||||
'用关卡画面作参考,并发生成 UI spritesheet 与 9:16 纯背景,预计 90 秒。',
|
||||
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
|
||||
},
|
||||
{
|
||||
@@ -305,7 +305,7 @@ const MATCH3D_STEPS = [
|
||||
{
|
||||
id: 'match3d-level-scene',
|
||||
label: '生成关卡整图',
|
||||
detail: '调用 gpt-image-2 生成 9:16 完整抓大鹅关卡画面。',
|
||||
detail: '生成 9:16 完整抓大鹅关卡画面。',
|
||||
weight: 28,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const requestJsonMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const { createCreationAgentClientMock } = vi.hoisted(() => ({
|
||||
createCreationAgentClientMock: vi.fn(),
|
||||
}));
|
||||
@@ -9,7 +11,7 @@ vi.mock('../creation-agent', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: vi.fn(),
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -22,6 +24,7 @@ beforeEach(() => {
|
||||
streamMessage: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
});
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
test('wooden fish creation keeps image2 generation requests alive long enough', async () => {
|
||||
@@ -34,3 +37,16 @@ test('wooden fish creation keeps image2 generation requests alive long enough',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('wooden fish list works uses creation works endpoint', async () => {
|
||||
const { woodenFishClient } = await import('./woodenFishClient');
|
||||
requestJsonMock.mockResolvedValueOnce({ items: [] });
|
||||
|
||||
await woodenFishClient.listWorks();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/creation/wooden-fish/works',
|
||||
{ method: 'GET' },
|
||||
'读取敲木鱼作品列表失败',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
WoodenFishWorkDetailResponse,
|
||||
WoodenFishWorkMutationResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorksResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
@@ -57,6 +58,7 @@ export type {
|
||||
WoodenFishWorkDetailResponse,
|
||||
WoodenFishWorkMutationResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorksResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
};
|
||||
export type CreateWoodenFishSessionRequest = WoodenFishWorkspaceCreateRequest;
|
||||
@@ -186,6 +188,15 @@ export async function getWoodenFishWorkDetail(profileId: string) {
|
||||
return normalizeWoodenFishWorkDetailResponse(response);
|
||||
}
|
||||
|
||||
export async function listWoodenFishWorks() {
|
||||
const response = await requestJson<WoodenFishWorksResponse>(
|
||||
WOODEN_FISH_WORKS_API_BASE,
|
||||
{ method: 'GET' },
|
||||
'读取敲木鱼作品列表失败',
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function listWoodenFishGallery() {
|
||||
return requestJson<WoodenFishGalleryResponse>(
|
||||
`${WOODEN_FISH_RUNTIME_API_BASE}/gallery`,
|
||||
@@ -312,6 +323,7 @@ export const woodenFishClient = {
|
||||
getSession: getWoodenFishCreationSession,
|
||||
getWorkDetail: getWoodenFishWorkDetail,
|
||||
listGallery: listWoodenFishGallery,
|
||||
listWorks: listWoodenFishWorks,
|
||||
publishWork: publishWoodenFishWork,
|
||||
startRun: startWoodenFishRuntimeRun,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user