Merge master into user play stats branch
Some checks failed
CI / verify (pull_request) Has been cancelled
Some checks failed
CI / verify (pull_request) Has been cancelled
This commit is contained in:
@@ -200,6 +200,7 @@ function mapBigFishWorkToShelfItem(
|
||||
id: 'level-motion-ready-count',
|
||||
label: `动作 ${item.levelMotionReadyCount}`,
|
||||
},
|
||||
{ id: 'play-count', label: `游玩 ${item.playCount ?? 0}` },
|
||||
...(item.backgroundReady
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -94,6 +94,7 @@ import {
|
||||
} from '../../services/puzzle-gallery';
|
||||
import {
|
||||
advanceLocalPuzzleNextLevel,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
} from '../../services/puzzle-runtime';
|
||||
import {
|
||||
@@ -322,6 +323,17 @@ function buildAgentResultPublishGateView(
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleResultProfileId(sessionId: string | null | undefined) {
|
||||
const normalizedSessionId = sessionId?.trim();
|
||||
if (!normalizedSessionId) {
|
||||
return null;
|
||||
}
|
||||
const stableSuffix = normalizedSessionId.startsWith('puzzle-session-')
|
||||
? normalizedSessionId.slice('puzzle-session-'.length)
|
||||
: normalizedSessionId;
|
||||
return `puzzle-profile-${stableSuffix}`;
|
||||
}
|
||||
|
||||
const CustomWorldGenerationView = lazy(async () => {
|
||||
const module = await import('../CustomWorldGenerationView');
|
||||
return {
|
||||
@@ -1178,7 +1190,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
|
||||
);
|
||||
});
|
||||
}, [bigFishSession, resolveBigFishErrorMessage, setSelectionStage]);
|
||||
void refreshBigFishShelf();
|
||||
}, [
|
||||
bigFishSession,
|
||||
refreshBigFishShelf,
|
||||
resolveBigFishErrorMessage,
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const restartBigFishRun = useCallback(() => {
|
||||
if (!bigFishSession && !bigFishRun) {
|
||||
@@ -1227,8 +1245,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
try {
|
||||
const { item } = await getPuzzleGalleryDetail(profileId);
|
||||
const { run } = await startPuzzleRun({ profileId: item.profileId });
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(startLocalPuzzleRun(item));
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
|
||||
setSelectionStage('puzzle-runtime');
|
||||
pushAppHistoryPath(
|
||||
@@ -2672,6 +2691,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
>
|
||||
<PuzzleResultView
|
||||
session={puzzleSession}
|
||||
profileId={
|
||||
puzzleSession.publishedProfileId ??
|
||||
buildPuzzleResultProfileId(puzzleSession.sessionId)
|
||||
}
|
||||
isBusy={isPuzzleBusy}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
|
||||
import * as puzzleWorksService from '../../services/puzzle-works';
|
||||
import { PuzzleResultView } from './PuzzleResultView';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
@@ -31,6 +33,15 @@ vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-works', () => ({
|
||||
updatePuzzleWork: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
@@ -149,6 +160,39 @@ function createSession(
|
||||
}
|
||||
|
||||
describe('PuzzleResultView', () => {
|
||||
test('auto saves renamed title to the puzzle work profile', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
item: {} as never,
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
profileId="puzzle-profile-session-1"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('雨夜猫街'), {
|
||||
target: { value: '暖灯猫街' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
|
||||
'puzzle-profile-session-1',
|
||||
expect.objectContaining({
|
||||
levelName: '暖灯猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
themeTags: ['猫咪', '雨夜'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('uses two tabs without author preview or persistent publish validation', () => {
|
||||
render(
|
||||
<PuzzleResultView
|
||||
@@ -168,9 +212,13 @@ describe('PuzzleResultView', () => {
|
||||
});
|
||||
|
||||
test('edits theme tags with chips instead of a persistent tag input', () => {
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
item: {} as never,
|
||||
});
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
profileId="puzzle-profile-session-1"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
@@ -256,6 +304,78 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('requires at least three theme tags before publish can pass', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
|
||||
expect(
|
||||
within(dialog).getByText('正式标签数量必须在 3 到 6 之间。'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '发布到广场',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('auto saves added and removed theme tags', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
item: {} as never,
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
profileId="puzzle-profile-session-1"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('新增题材标签'));
|
||||
fireEvent.change(screen.getByLabelText('新题材标签'), {
|
||||
target: { value: '暖灯' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加' }));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
|
||||
'puzzle-profile-session-1',
|
||||
expect.objectContaining({
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
|
||||
'puzzle-profile-session-1',
|
||||
expect.objectContaining({
|
||||
themeTags: ['雨夜', '暖灯'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('generates one image from the picture description and replaces current image', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createPortal } from 'react-dom';
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type { PuzzleResultDraft } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { updatePuzzleWork } from '../../services/puzzle-works';
|
||||
import {
|
||||
puzzleAssetClient,
|
||||
type PuzzleHistoryAsset,
|
||||
@@ -24,6 +25,7 @@ import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleResultViewProps = {
|
||||
session: PuzzleAgentSessionSnapshot;
|
||||
profileId?: string | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
@@ -32,6 +34,7 @@ type PuzzleResultViewProps = {
|
||||
};
|
||||
|
||||
type PuzzleResultTab = 'basic' | 'images';
|
||||
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
type DraftEditState = {
|
||||
levelName: string;
|
||||
@@ -39,6 +42,10 @@ type DraftEditState = {
|
||||
themeTags: string[];
|
||||
};
|
||||
|
||||
const PUZZLE_MIN_THEME_TAG_COUNT = 3;
|
||||
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
|
||||
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
|
||||
|
||||
function normalizeThemeTagInput(value: string) {
|
||||
return [
|
||||
...new Set(
|
||||
@@ -84,7 +91,16 @@ function publishBlockedReason(session: PuzzleAgentSessionSnapshot) {
|
||||
if (!session.resultPreview) {
|
||||
return ['等待结果页草稿完成后再发布。'];
|
||||
}
|
||||
return session.resultPreview.blockers.map((entry) => entry.message);
|
||||
return session.resultPreview.blockers
|
||||
.filter(
|
||||
(entry) =>
|
||||
![
|
||||
'MISSING_LEVEL_NAME',
|
||||
'INVALID_TAG_COUNT',
|
||||
'MISSING_COVER_IMAGE',
|
||||
].includes(entry.code),
|
||||
)
|
||||
.map((entry) => entry.message);
|
||||
}
|
||||
|
||||
function buildPublishReady(
|
||||
@@ -96,7 +112,10 @@ function buildPublishReady(
|
||||
const blockers = [
|
||||
...publishBlockedReason(session),
|
||||
...(editState.levelName.trim() ? [] : ['关卡名不能为空。']),
|
||||
...(editState.themeTags.length > 0 ? [] : ['至少需要 1 个题材标签。']),
|
||||
...(editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
|
||||
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT
|
||||
? []
|
||||
: [`正式标签数量必须在 ${PUZZLE_MIN_THEME_TAG_COUNT} 到 ${PUZZLE_MAX_THEME_TAG_COUNT} 之间。`]),
|
||||
...(formalImageSrc ? [] : ['请先选择一张正式拼图图片。']),
|
||||
];
|
||||
|
||||
@@ -105,7 +124,8 @@ function buildPublishReady(
|
||||
publishReady:
|
||||
Boolean(session.resultPreview?.publishReady) &&
|
||||
Boolean(editState.levelName.trim()) &&
|
||||
editState.themeTags.length > 0 &&
|
||||
editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
|
||||
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT &&
|
||||
Boolean(formalImageSrc),
|
||||
};
|
||||
}
|
||||
@@ -130,12 +150,29 @@ function resolvePuzzleFormalImageSrc(draft: PuzzleResultDraft) {
|
||||
}
|
||||
|
||||
function PuzzleResultHeader({
|
||||
autoSaveState,
|
||||
isBusy,
|
||||
onBack,
|
||||
}: {
|
||||
autoSaveState: PuzzleAutoSaveState;
|
||||
isBusy: boolean;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const autoSaveBadge =
|
||||
autoSaveState === 'saving' ? (
|
||||
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
|
||||
保存中
|
||||
</div>
|
||||
) : autoSaveState === 'saved' ? (
|
||||
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
|
||||
已自动保存
|
||||
</div>
|
||||
) : autoSaveState === 'error' ? (
|
||||
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
|
||||
保存失败
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
@@ -149,6 +186,7 @@ function PuzzleResultHeader({
|
||||
返回
|
||||
</span>
|
||||
</button>
|
||||
{autoSaveBadge}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -871,6 +909,7 @@ function PuzzleResultActionBar({
|
||||
*/
|
||||
export function PuzzleResultView({
|
||||
session,
|
||||
profileId = null,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
@@ -884,15 +923,77 @@ export function PuzzleResultView({
|
||||
const [editState, setEditState] = useState<DraftEditState | null>(
|
||||
draft ? createDraftEditState(draft) : null,
|
||||
);
|
||||
const [autoSaveState, setAutoSaveState] = useState<PuzzleAutoSaveState>('idle');
|
||||
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setEditState(null);
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
return;
|
||||
}
|
||||
setEditState(createDraftEditState(draft));
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft || !editState || !profileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedLevelName = editState.levelName.trim();
|
||||
const normalizedSummary = editState.summary.trim();
|
||||
const normalizedTags = normalizeThemeTagInput(editState.themeTags.join(','));
|
||||
const draftLevelName = draft.levelName.trim();
|
||||
const draftSummary = draft.summary.trim();
|
||||
const draftTags = normalizeThemeTagInput(draft.themeTags.join(','));
|
||||
const levelNameChanged = normalizedLevelName !== draftLevelName;
|
||||
const summaryChanged = normalizedSummary !== draftSummary;
|
||||
const tagsChanged =
|
||||
normalizedTags.length !== draftTags.length ||
|
||||
normalizedTags.some((tag, index) => tag !== draftTags[index]);
|
||||
|
||||
if (!levelNameChanged && !summaryChanged && !tagsChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoSaveState('saving');
|
||||
setAutoSaveError(null);
|
||||
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
void updatePuzzleWork(profileId, {
|
||||
levelName: normalizedLevelName,
|
||||
summary: normalizedSummary,
|
||||
themeTags: normalizedTags,
|
||||
coverImageSrc: formalImageSrc || null,
|
||||
coverAssetId: draft.coverAssetId ?? null,
|
||||
})
|
||||
.then(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setAutoSaveState('saved');
|
||||
})
|
||||
.catch((saveError) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setAutoSaveState('error');
|
||||
setAutoSaveError(
|
||||
saveError instanceof Error ? saveError.message : '自动保存失败。',
|
||||
);
|
||||
});
|
||||
}, PUZZLE_AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [draft, editState, formalImageSrc, profileId]);
|
||||
|
||||
const publishState = useMemo(() => {
|
||||
if (!draft || !editState) {
|
||||
return {
|
||||
@@ -915,7 +1016,11 @@ export function PuzzleResultView({
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
|
||||
<PuzzleResultHeader isBusy={isBusy} onBack={onBack} />
|
||||
<PuzzleResultHeader
|
||||
autoSaveState={autoSaveState}
|
||||
isBusy={isBusy}
|
||||
onBack={onBack}
|
||||
/>
|
||||
|
||||
<PuzzleResultTabs
|
||||
activeTab={activeTab}
|
||||
@@ -953,6 +1058,11 @@ export function PuzzleResultView({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && autoSaveError ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{autoSaveError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<PuzzleResultActionBar
|
||||
draft={draft}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Clock3,
|
||||
Coins,
|
||||
Copy,
|
||||
Crown,
|
||||
House,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
@@ -36,19 +35,17 @@ import type {
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileRechargeProduct,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileSaveArchiveSummary,
|
||||
RedeemProfileReferralInviteCodeResponse,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
createRpgProfileRechargeOrder,
|
||||
getRpgProfileRechargeCenter,
|
||||
getRpgProfileReferralInviteCenter,
|
||||
redeemRpgProfileReferralInviteCode,
|
||||
redeemRpgProfileRewardCode,
|
||||
} from '../../services/rpg-entry/rpgProfileClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
@@ -933,206 +930,68 @@ function ProfileShortcutButton({
|
||||
);
|
||||
}
|
||||
|
||||
function formatRechargePrice(priceCents: number) {
|
||||
const yuan = priceCents / 100;
|
||||
return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatMembershipDuration(days: number) {
|
||||
if (days >= 365) {
|
||||
return '365天';
|
||||
}
|
||||
|
||||
return `${days}天`;
|
||||
}
|
||||
|
||||
function AccountRechargeModal({
|
||||
center,
|
||||
activeTab,
|
||||
isLoading,
|
||||
function RewardCodeRedeemModal({
|
||||
value,
|
||||
isSubmitting,
|
||||
error,
|
||||
onTabChange,
|
||||
success,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onClose,
|
||||
onSelectProduct,
|
||||
}: {
|
||||
center: ProfileRechargeCenterResponse | null;
|
||||
activeTab: 'points' | 'membership';
|
||||
isLoading: boolean;
|
||||
isSubmitting: string | null;
|
||||
value: string;
|
||||
isSubmitting: boolean;
|
||||
error: string | null;
|
||||
onTabChange: (tab: 'points' | 'membership') => void;
|
||||
success: string | null;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
onSelectProduct: (product: ProfileRechargeProduct) => void;
|
||||
}) {
|
||||
const visibleProducts =
|
||||
activeTab === 'points'
|
||||
? (center?.pointProducts ?? [])
|
||||
: (center?.membershipProducts ?? []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
|
||||
<div className="relative max-h-[min(92vh,46rem)] w-full max-w-[32rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_34%,#f8fafc_100%)] text-zinc-950 shadow-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm"
|
||||
aria-label="关闭账户充值"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="max-h-[min(92vh,46rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
|
||||
<div className="pr-10">
|
||||
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
|
||||
WALLET
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-black">账户充值</div>
|
||||
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
|
||||
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
|
||||
<span>
|
||||
{center ? `${center.walletBalance}叙世币` : '叙世币账户'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 rounded-2xl bg-zinc-100/90 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('points')}
|
||||
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
|
||||
activeTab === 'points'
|
||||
? 'bg-white text-[#ff4056] shadow-sm'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
叙世币充值
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('membership')}
|
||||
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
|
||||
activeTab === 'membership'
|
||||
? 'bg-white text-[#ff4056] shadow-sm'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
会员卡充值
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-50 flex items-center justify-center px-4 py-6">
|
||||
<div className="platform-recharge-modal w-full max-w-sm overflow-hidden rounded-[1.4rem]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="text-base font-black">兑换码</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭兑换码"
|
||||
onClick={onClose}
|
||||
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 px-5 py-5">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
className="platform-profile-input w-full rounded-2xl px-4 py-3 text-sm font-semibold uppercase tracking-normal"
|
||||
placeholder="输入兑换码"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !value.trim()}
|
||||
className="platform-primary-button w-full rounded-2xl px-4 py-3 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? '兑换中' : '兑换'}
|
||||
</button>
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
<div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{Array.from({ length: activeTab === 'points' ? 6 : 3 }).map(
|
||||
(_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-24 animate-pulse rounded-xl bg-zinc-100"
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{success ? (
|
||||
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||
{success}
|
||||
</div>
|
||||
) : activeTab === 'points' ? (
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{visibleProducts.map((product) => (
|
||||
<button
|
||||
type="button"
|
||||
key={product.productId}
|
||||
disabled={Boolean(isSubmitting)}
|
||||
onClick={() => onSelectProduct(product)}
|
||||
className="relative min-h-[8.45rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white text-center shadow-sm transition hover:border-[#ff4056] disabled:opacity-70"
|
||||
>
|
||||
<div className="h-8 bg-[#ff4056] px-2 py-1.5 text-xs font-black text-white">
|
||||
{product.badgeLabel}
|
||||
</div>
|
||||
<div className="px-2 py-3">
|
||||
<div className="text-xl font-black">
|
||||
{product.pointsAmount}叙世币
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
金额:{formatRechargePrice(product.priceCents)}
|
||||
</div>
|
||||
<div className="my-2 h-px bg-zinc-100" />
|
||||
<div className="text-sm text-zinc-800">
|
||||
{isSubmitting === product.productId
|
||||
? '处理中'
|
||||
: product.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
{visibleProducts.map((product) => (
|
||||
<button
|
||||
type="button"
|
||||
key={product.productId}
|
||||
disabled={Boolean(isSubmitting)}
|
||||
onClick={() => onSelectProduct(product)}
|
||||
className="group relative min-h-[7.75rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition hover:border-[#ff4056] hover:shadow-md disabled:opacity-70"
|
||||
>
|
||||
<div className="absolute right-0 top-0 h-16 w-16 rounded-bl-[2rem] bg-[#ff4056]/10 transition group-hover:bg-[#ff4056]/16" />
|
||||
<div className="relative">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-lg font-black">
|
||||
{product.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-bold text-zinc-500">
|
||||
{formatMembershipDuration(product.durationDays)}
|
||||
</div>
|
||||
</div>
|
||||
<Crown className="h-5 w-5 shrink-0 text-[#ff4056]" />
|
||||
</div>
|
||||
<div className="mt-4 text-2xl font-black text-[#ff4056]">
|
||||
{formatRechargePrice(product.priceCents)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs font-semibold text-zinc-500">
|
||||
{isSubmitting === product.productId
|
||||
? '处理中'
|
||||
: product.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm">
|
||||
<div className="border-b border-zinc-200 px-4 py-3 text-sm font-black">
|
||||
用户等级特权
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="grid min-w-[30rem] grid-cols-5 text-center text-sm">
|
||||
{center?.benefits.map((benefit) => (
|
||||
<div key={benefit.benefitName} className="contents">
|
||||
<div className="border-b border-zinc-100 bg-zinc-50 px-2 py-3 text-left text-zinc-600">
|
||||
{benefit.benefitName}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 text-zinc-500">
|
||||
{benefit.normalValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-emerald-700">
|
||||
{benefit.monthValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-rose-500">
|
||||
{benefit.seasonValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-amber-600">
|
||||
{benefit.yearValue}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1425,16 +1284,13 @@ export function RpgEntryHomeView({
|
||||
const authUi = useAuthUi();
|
||||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||||
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
|
||||
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
|
||||
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>(
|
||||
'points',
|
||||
const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false);
|
||||
const [rewardCodeInput, setRewardCodeInput] = useState('');
|
||||
const [isSubmittingRewardCode, setIsSubmittingRewardCode] = useState(false);
|
||||
const [rewardCodeError, setRewardCodeError] = useState<string | null>(null);
|
||||
const [rewardCodeSuccess, setRewardCodeSuccess] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [rechargeCenter, setRechargeCenter] =
|
||||
useState<ProfileRechargeCenterResponse | null>(null);
|
||||
const [rechargeError, setRechargeError] = useState<string | null>(null);
|
||||
const [isLoadingRecharge, setIsLoadingRecharge] = useState(false);
|
||||
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
|
||||
useState<string | null>(null);
|
||||
const [profilePopupPanel, setProfilePopupPanel] =
|
||||
useState<ProfilePopupPanel | null>(null);
|
||||
const [referralCenter, setReferralCenter] =
|
||||
@@ -1532,36 +1388,6 @@ export function RpgEntryHomeView({
|
||||
}
|
||||
authUi?.openLoginModal();
|
||||
};
|
||||
const openRechargePanel = () => {
|
||||
setIsRechargeOpen(true);
|
||||
setRechargeError(null);
|
||||
setIsLoadingRecharge(true);
|
||||
void getRpgProfileRechargeCenter()
|
||||
.then(setRechargeCenter)
|
||||
.catch((error: unknown) => {
|
||||
setRechargeCenter(null);
|
||||
setRechargeError(
|
||||
error instanceof Error ? error.message : '读取账户充值失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingRecharge(false));
|
||||
};
|
||||
const submitRechargeProduct = (product: ProfileRechargeProduct) => {
|
||||
if (submittingRechargeProductId) {
|
||||
return;
|
||||
}
|
||||
setSubmittingRechargeProductId(product.productId);
|
||||
setRechargeError(null);
|
||||
void createRpgProfileRechargeOrder(product.productId)
|
||||
.then((response) => {
|
||||
setRechargeCenter(response.center);
|
||||
void onRechargeSuccess?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setRechargeError(error instanceof Error ? error.message : '充值失败');
|
||||
})
|
||||
.finally(() => setSubmittingRechargeProductId(null));
|
||||
};
|
||||
const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
|
||||
setProfilePopupPanel(panel);
|
||||
setReferralError(null);
|
||||
@@ -1617,6 +1443,30 @@ export function RpgEntryHomeView({
|
||||
})
|
||||
.finally(() => setIsSubmittingReferral(false));
|
||||
};
|
||||
const openRewardCodeModal = () => {
|
||||
setIsRewardCodeOpen(true);
|
||||
setRewardCodeError(null);
|
||||
setRewardCodeSuccess(null);
|
||||
};
|
||||
const submitRewardCode = () => {
|
||||
if (isSubmittingRewardCode || !rewardCodeInput.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmittingRewardCode(true);
|
||||
setRewardCodeError(null);
|
||||
setRewardCodeSuccess(null);
|
||||
void redeemRpgProfileRewardCode(rewardCodeInput)
|
||||
.then((response: RedeemProfileRewardCodeResponse) => {
|
||||
setRewardCodeInput('');
|
||||
setRewardCodeSuccess(`已到账 ${response.amountGranted} 叙世币`);
|
||||
void onRechargeSuccess?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setRewardCodeError(error instanceof Error ? error.message : '兑换失败');
|
||||
})
|
||||
.finally(() => setIsSubmittingRewardCode(false));
|
||||
};
|
||||
const submitDesktopSearch = () => {
|
||||
const keyword = desktopSearchKeyword.trim();
|
||||
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
|
||||
@@ -1964,17 +1814,13 @@ export function RpgEntryHomeView({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openRechargePanel}
|
||||
onClick={openRewardCodeModal}
|
||||
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
<Ticket className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="text-xs font-bold">会员充值</div>
|
||||
<div className="text-[10px] opacity-80">
|
||||
{rechargeCenter?.membership.status === 'active'
|
||||
? '叙世会员'
|
||||
: '普通用户'}
|
||||
</div>
|
||||
<div className="text-xs font-bold">兑换码</div>
|
||||
<div className="text-[10px] opacity-80">叙世币</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 opacity-80" />
|
||||
</button>
|
||||
@@ -2422,18 +2268,6 @@ export function RpgEntryHomeView({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isRechargeOpen ? (
|
||||
<AccountRechargeModal
|
||||
center={rechargeCenter}
|
||||
activeTab={rechargeTab}
|
||||
isLoading={isLoadingRecharge}
|
||||
isSubmitting={submittingRechargeProductId}
|
||||
error={rechargeError}
|
||||
onTabChange={setRechargeTab}
|
||||
onClose={() => setIsRechargeOpen(false)}
|
||||
onSelectProduct={submitRechargeProduct}
|
||||
/>
|
||||
) : null}
|
||||
{profilePopupPanel ? (
|
||||
<ProfileReferralModal
|
||||
panel={profilePopupPanel}
|
||||
@@ -2535,16 +2369,15 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isRechargeOpen ? (
|
||||
<AccountRechargeModal
|
||||
center={rechargeCenter}
|
||||
activeTab={rechargeTab}
|
||||
isLoading={isLoadingRecharge}
|
||||
isSubmitting={submittingRechargeProductId}
|
||||
error={rechargeError}
|
||||
onTabChange={setRechargeTab}
|
||||
onClose={() => setIsRechargeOpen(false)}
|
||||
onSelectProduct={submitRechargeProduct}
|
||||
{isRewardCodeOpen ? (
|
||||
<RewardCodeRedeemModal
|
||||
value={rewardCodeInput}
|
||||
isSubmitting={isSubmittingRewardCode}
|
||||
error={rewardCodeError}
|
||||
success={rewardCodeSuccess}
|
||||
onChange={setRewardCodeInput}
|
||||
onSubmit={submitRewardCode}
|
||||
onClose={() => setIsRewardCodeOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{profilePopupPanel ? (
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeAll, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type QuestLogEntry,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { RpgAdventurePanel } from './RpgAdventurePanel';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as Character;
|
||||
}
|
||||
|
||||
function createOption(functionId: string, actionText: string): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPendingQuest(): QuestLogEntry {
|
||||
return {
|
||||
id: 'quest-liu-1',
|
||||
issuerNpcId: 'npc-liu',
|
||||
issuerNpcName: '柳无声',
|
||||
sceneId: 'scene-bamboo',
|
||||
title: '竹林密信',
|
||||
description: '替柳无声查清竹林中的密信来源。',
|
||||
summary: '去竹林查清密信来源。',
|
||||
objective: {
|
||||
kind: 'inspect_treasure',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 0,
|
||||
status: 'active',
|
||||
reward: {
|
||||
affinityBonus: 5,
|
||||
currency: 10,
|
||||
items: [],
|
||||
},
|
||||
rewardText: '完成后可获得报酬。',
|
||||
};
|
||||
}
|
||||
|
||||
function createPendingQuestStory(quest: QuestLogEntry): StoryMoment {
|
||||
const viewOption = createOption('npc_chat_quest_offer_view', '查看任务');
|
||||
viewOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'view',
|
||||
};
|
||||
const replaceOption = createOption('npc_chat_quest_offer_replace', '更换任务');
|
||||
replaceOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'replace',
|
||||
};
|
||||
const abandonOption = createOption('npc_chat_quest_offer_abandon', '放弃任务');
|
||||
abandonOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'abandon',
|
||||
};
|
||||
|
||||
return {
|
||||
text: '柳无声把真正的委托说了出来。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '这件事我只想正式托付给你。' },
|
||||
],
|
||||
options: [viewOption, replaceOption, abandonOption],
|
||||
npcChatState: {
|
||||
npcId: 'npc-liu',
|
||||
npcName: '柳无声',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
pendingQuestOffer: {
|
||||
quest,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAcceptedQuestStory(quest: QuestLogEntry): StoryMoment {
|
||||
return {
|
||||
text: '柳无声把接下来的线索正式交给了你。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '这件事我只想正式托付给你。' },
|
||||
{ speaker: 'player', text: '这件事我愿意接下,你把关键要点交给我。' },
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '先去竹林查清密信来源。' },
|
||||
],
|
||||
options: [
|
||||
createOption('npc_chat', '这件事里你最担心哪一步'),
|
||||
createOption('npc_chat', '我回来时你最想先知道什么'),
|
||||
],
|
||||
npcChatState: {
|
||||
npcId: 'npc-liu',
|
||||
npcName: '柳无声',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
pendingQuestOffer: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function QuestOfferHarness() {
|
||||
const pendingQuest = createPendingQuest();
|
||||
const [currentStory, setCurrentStory] = useState<StoryMoment>(
|
||||
createPendingQuestStory(pendingQuest),
|
||||
);
|
||||
const [quests, setQuests] = useState<QuestLogEntry[]>([]);
|
||||
const acceptPendingOffer = vi.fn(() => {
|
||||
queueMicrotask(() => {
|
||||
setQuests([pendingQuest]);
|
||||
setCurrentStory(createAcceptedQuestStory(pendingQuest));
|
||||
});
|
||||
return pendingQuest.id;
|
||||
});
|
||||
|
||||
return (
|
||||
<RpgAdventurePanel
|
||||
aiError={null}
|
||||
currentStory={currentStory}
|
||||
isLoading={false}
|
||||
displayedOptions={currentStory.options}
|
||||
hideOptions={false}
|
||||
canRefreshOptions={false}
|
||||
onRefreshOptions={() => undefined}
|
||||
onChoice={() => undefined}
|
||||
onSubmitNpcChatInput={() => true}
|
||||
onExitNpcChat={() => true}
|
||||
onOpenCharacter={() => undefined}
|
||||
onOpenInventory={() => undefined}
|
||||
playerCharacter={createCharacter()}
|
||||
worldType={WorldType.WUXIA}
|
||||
quests={quests}
|
||||
questUi={{
|
||||
acknowledgeQuestCompletion: () => undefined,
|
||||
claimQuestReward: () => null,
|
||||
}}
|
||||
npcChatQuestOfferUi={{
|
||||
replacePendingOffer: () => false,
|
||||
abandonPendingOffer: () => false,
|
||||
acceptPendingOffer,
|
||||
}}
|
||||
goalStack={{
|
||||
northStarGoal: null,
|
||||
activeGoal: null,
|
||||
immediateStepGoal: null,
|
||||
supportGoals: [],
|
||||
}}
|
||||
goalPulse={null}
|
||||
onDismissGoalPulse={() => undefined}
|
||||
battleRewardUi={{
|
||||
reward: null,
|
||||
dismiss: () => undefined,
|
||||
}}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
playerMana={20}
|
||||
playerMaxMana={20}
|
||||
playerSkillCooldowns={{}}
|
||||
inBattle={false}
|
||||
currentNpcBattleMode={null}
|
||||
statistics={{
|
||||
playTimeMs: 0,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
questsCompleted: 0,
|
||||
questsTurnedIn: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
currentSceneName: '竹林古道',
|
||||
playerCurrency: 0,
|
||||
inventoryItemCount: 0,
|
||||
inventoryStackCount: 0,
|
||||
activeCompanionCount: 0,
|
||||
rosterCompanionCount: 0,
|
||||
}}
|
||||
musicVolume={0.6}
|
||||
onMusicVolumeChange={() => undefined}
|
||||
onSaveAndExit={() => undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (!HTMLElement.prototype.scrollTo) {
|
||||
HTMLElement.prototype.scrollTo = () => undefined;
|
||||
}
|
||||
});
|
||||
|
||||
test('quest offer accept button reuses the shared accepted-quest follow-up chain', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<QuestOfferHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /查看任务/u }));
|
||||
await user.click(await screen.findByRole('button', { name: '领取任务' }));
|
||||
|
||||
expect(await screen.findByText('任务进度:0/1')).toBeTruthy();
|
||||
expect(screen.getAllByText('竹林密信').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('待领取')).toBeNull();
|
||||
expect(screen.getByText('这件事里你最担心哪一步')).toBeTruthy();
|
||||
});
|
||||
@@ -1724,6 +1724,9 @@ export function RpgAdventurePanel({
|
||||
onAcceptPendingNpcQuestOffer={() => {
|
||||
const acceptedQuestId = npcChatQuestOfferUi.acceptPendingOffer();
|
||||
if (!acceptedQuestId) return null;
|
||||
// 中文注释:待领取任务详情弹层走的是异步服务端接取链路,
|
||||
// 这里先记录 questId,等 quest 真正进入日志后再由 effect 统一收口面板状态。
|
||||
setPendingAcceptedQuestId(acceptedQuestId);
|
||||
setSelectedQuestId(null);
|
||||
return acceptedQuestId;
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user