feat: surface platform errors in copyable dialogs

This commit is contained in:
kdletters
2026-05-26 14:27:18 +08:00
parent 44c65df5c9
commit fbda614156
16 changed files with 715 additions and 191 deletions
+2 -14
View File
@@ -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 ? (
<>
@@ -1,12 +1,12 @@
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 { 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 +43,6 @@ type CustomWorldCreationHubProps = {
loading: boolean;
error: string | null;
onRetry: () => void;
createError?: string | null;
createBusy?: boolean;
entryConfig: CreationEntryConfig;
creationTypes: readonly PlatformCreationTypeCard[];
@@ -154,7 +153,6 @@ export function CustomWorldCreationHub({
loading,
error,
onRetry,
createError = null,
createBusy = false,
entryConfig,
creationTypes,
@@ -360,7 +358,6 @@ export function CustomWorldCreationHub({
{showStartCard ? (
<CustomWorldCreationStartCard
busy={createBusy}
error={createError}
entryConfig={entryConfig}
creationTypes={creationTypes}
onCreateType={onCreateType}
@@ -377,12 +374,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>
);
@@ -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
@@ -0,0 +1,60 @@
/* @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';
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();
});
});
@@ -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>
);
}
@@ -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>
@@ -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>
@@ -1826,11 +1826,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();
@@ -1847,6 +1854,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 () => {
@@ -1909,7 +1917,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,
});
@@ -1920,9 +1928,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,
});
@@ -1932,9 +1941,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',
});
@@ -1950,6 +1960,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 () => {
@@ -2007,7 +2018,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(
@@ -2101,7 +2112,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';
@@ -2111,6 +2122,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 () => {
@@ -2184,7 +2196,7 @@ test('profile invite shortcut shows reward subtitle and invited users', async ()
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(),
{},
@@ -2192,20 +2204,16 @@ test('profile redeem invite shortcut sits between invite and community for fresh
);
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(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 () => {
@@ -2226,6 +2234,7 @@ test('profile redeem invite shortcut hides after redeemed or one day old', async
expect(
within(firstShortcutRegion).queryByRole('button', { name: /填邀请码/u }),
).toBeNull();
await screen.findByText('1 / 1');
unmount();
renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' });
@@ -2237,6 +2246,7 @@ test('profile redeem invite shortcut hides after redeemed or one day old', async
name: /填邀请码/u,
}),
).toBeNull();
await screen.findByText('1 / 1');
});
test('invite query opens login modal for logged out users', async () => {
@@ -2269,9 +2279,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,
@@ -2279,9 +2290,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(() => {
@@ -2291,12 +2300,7 @@ 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('opens reward code modal from profile action on mobile', async () => {
+74 -65
View File
@@ -255,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] -
@@ -277,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 = {
@@ -2449,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,
}: {
@@ -4218,12 +4220,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 }>
@@ -4776,7 +4776,7 @@ export function RpgEntryHomeView({
document.removeEventListener('visibilitychange', handleResume);
};
}, [handleWechatPayResult]);
const loadTaskCenter = () => {
const loadTaskCenter = useCallback(() => {
setTaskCenterError(null);
setIsLoadingTaskCenter(true);
void getRpgProfileTasks()
@@ -4788,11 +4788,24 @@ export function RpgEntryHomeView({
);
})
.finally(() => setIsLoadingTaskCenter(false));
};
}, []);
useEffect(() => {
if (activeTab !== 'profile' || !isAuthenticated) {
setTaskCenter(null);
setTaskCenterError(null);
return;
}
loadTaskCenter();
}, [activeTab, isAuthenticated, loadTaskCenter]);
const openTaskCenterPanel = () => {
setIsTaskCenterOpen(true);
setTaskClaimSuccess(null);
loadTaskCenter();
if (!taskCenter) {
loadTaskCenter();
}
};
const openQrScannerPanel = () => {
if (!authUi?.user) {
@@ -6185,14 +6198,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>
@@ -6202,7 +6225,7 @@ export function RpgEntryHomeView({
className="platform-profile-daily-task-card__mascot"
/>
<span className="platform-profile-daily-task-card__action">
去完成
{profileTaskCardSummary.actionLabel}
</span>
</button>
@@ -6267,20 +6290,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} />
</>
) : (