收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
@@ -72,3 +72,61 @@ test('dispatches wooden fish creation type selection', () => {
|
||||
|
||||
expect(onSelectWoodenFish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders locked creation type badge with PlatformPillBadge', () => {
|
||||
const onSelectWoodenFish = vi.fn();
|
||||
const woodenFishEntry = entryConfig.creationTypes[0]!;
|
||||
const lockedEntryConfig = {
|
||||
...entryConfig,
|
||||
creationTypes: [
|
||||
{
|
||||
...woodenFishEntry,
|
||||
badge: '即将开放',
|
||||
open: false,
|
||||
},
|
||||
],
|
||||
} satisfies CreationEntryConfig;
|
||||
|
||||
render(
|
||||
<PlatformEntryCreationTypeModal
|
||||
isOpen
|
||||
isBusy={false}
|
||||
entryConfig={lockedEntryConfig}
|
||||
creationTypes={derivePlatformCreationTypes(
|
||||
lockedEntryConfig.creationTypes,
|
||||
)}
|
||||
onClose={() => {}}
|
||||
onSelectRpg={() => {}}
|
||||
onSelectBigFish={() => {}}
|
||||
onSelectMatch3D={() => {}}
|
||||
onSelectSquareHole={() => {}}
|
||||
onSelectJumpHop={() => {}}
|
||||
onSelectWoodenFish={onSelectWoodenFish}
|
||||
onSelectPuzzle={() => {}}
|
||||
onSelectCreativeAgent={() => {}}
|
||||
onSelectBarkBattle={() => {}}
|
||||
onSelectVisualNovel={() => {}}
|
||||
onSelectBabyObjectMatch={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const card = screen.getByRole('button', { name: /敲木鱼/u });
|
||||
const badge = screen.getByText('即将开放');
|
||||
|
||||
expect((card as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(badge.className).toContain('rounded-full');
|
||||
expect(badge.className).toContain('bg-white/72');
|
||||
expect(badge.querySelector('svg')).toBeTruthy();
|
||||
const lockIconBadge = Array.from(
|
||||
card.querySelectorAll('[aria-hidden="true"]'),
|
||||
).find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement && element.className.includes('h-7'),
|
||||
);
|
||||
expect(lockIconBadge?.className).toContain('bg-white/18');
|
||||
expect(lockIconBadge?.className).toContain('text-white/72');
|
||||
|
||||
fireEvent.click(card);
|
||||
|
||||
expect(onSelectWoodenFish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ArrowRight, LockKeyhole } from 'lucide-react';
|
||||
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import { PlatformIconBadge } from '../common/PlatformIconBadge';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import {
|
||||
getVisiblePlatformCreationTypes,
|
||||
@@ -61,15 +63,21 @@ function CreationTypeCard(props: {
|
||||
/>
|
||||
<div className="relative z-10 flex min-h-6 items-start justify-end gap-3 px-4 pt-4">
|
||||
{item.locked ? (
|
||||
<span className="platform-pill platform-pill--neutral gap-1 px-3 text-[var(--platform-text-soft)]">
|
||||
<LockKeyhole className="h-3.5 w-3.5" />
|
||||
<PlatformPillBadge
|
||||
tone="neutral"
|
||||
size="sm"
|
||||
icon={<LockKeyhole className="h-3.5 w-3.5" />}
|
||||
className="gap-1 px-3 text-[var(--platform-text-soft)]"
|
||||
>
|
||||
{lockedBadge}
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
) : null}
|
||||
{item.locked ? (
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-white/18 text-white/72">
|
||||
<LockKeyhole className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<PlatformIconBadge
|
||||
icon={<LockKeyhole className="h-3.5 w-3.5" />}
|
||||
size="xs"
|
||||
tone="heroMuted"
|
||||
/>
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4 text-white/80" />
|
||||
)}
|
||||
|
||||
@@ -363,26 +363,24 @@ import {
|
||||
} from '../../services/wooden-fish/woodenFishClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
||||
import { PublishShareModal } from '../common/PublishShareModal';
|
||||
import type { PublishShareModalPayload } from '../common/publishShareModalModel';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { UnifiedConfirmDialog } from '../common/UnifiedConfirmDialog';
|
||||
import { resolveCreativeAgentTargetSelectionStage } from '../creative-agent/creativeAgentViewModel';
|
||||
import {
|
||||
buildCreationWorkShelfItems,
|
||||
type CreationWorkShelfItem,
|
||||
isPersistedBarkBattleDraftGenerating,
|
||||
} from '../custom-world-home/creationWorkShelf';
|
||||
import {
|
||||
selectAdjacentPlatformRecommendEntry,
|
||||
} from '../rpg-entry/rpgEntryPublicGalleryViewModel';
|
||||
import { selectAdjacentPlatformRecommendEntry } from '../rpg-entry/rpgEntryPublicGalleryViewModel';
|
||||
import {
|
||||
isBigFishGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isJumpHopGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
isPuzzleClearGalleryEntry,
|
||||
mapPuzzleClearWorkToPlatformGalleryCard,
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformPublicWorkCode,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
@@ -525,7 +523,6 @@ import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
|
||||
import { PlatformErrorDialog } from './PlatformErrorDialog';
|
||||
import { PlatformFeedbackView } from './PlatformFeedbackView';
|
||||
import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel';
|
||||
import { buildPlatformRecommendedEntries } from './platformRecommendation';
|
||||
import {
|
||||
buildMatch3DProfileFromSession,
|
||||
hasMatch3DRuntimeAsset,
|
||||
@@ -636,6 +633,7 @@ import {
|
||||
buildPuzzleResultWorkId,
|
||||
} from './platformPuzzleIdentityModel';
|
||||
import { mergePuzzleServiceRuntimeState } from './platformPuzzleRuntimeStateModel';
|
||||
import { buildPlatformRecommendedEntries } from './platformRecommendation';
|
||||
import {
|
||||
type PlatformPuzzleRuntimeAuthMode,
|
||||
resolvePlatformRecommendRuntimeAuthPlan,
|
||||
@@ -764,6 +762,11 @@ type DeleteCreationWorkConfirmation = {
|
||||
run: () => void;
|
||||
};
|
||||
|
||||
type WorkNotFoundRecoveryDialogState = {
|
||||
message: string;
|
||||
nextPath: '/';
|
||||
};
|
||||
|
||||
async function resumePuzzleProfileSaveArchiveRaw(worldKey: string) {
|
||||
return requestRpgRuntimeJson<
|
||||
ProfileSaveArchiveResumeResponse<PuzzleSaveArchiveState>
|
||||
@@ -992,22 +995,22 @@ function isMissingPuzzleWorkError(error: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
function maybeAlertWorkNotFoundAndReturnHome() {
|
||||
function resolveWorkNotFoundRecoveryDialogState(): WorkNotFoundRecoveryDialogState | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const recoveryAction = resolveWorkNotFoundRecoveryAction(
|
||||
window.location.pathname,
|
||||
);
|
||||
if (!recoveryAction) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// 中文注释:直接打开公开详情或运行态深链失效时,确认提示后必须离开空详情页。
|
||||
window.alert('作品不存在或已下架,将返回首页。');
|
||||
pushAppHistoryPath(recoveryAction.nextPath);
|
||||
return true;
|
||||
return {
|
||||
message: '作品不存在或已下架,将返回首页。',
|
||||
nextPath: recoveryAction.nextPath,
|
||||
};
|
||||
}
|
||||
|
||||
function hasSeenPuzzleOnboarding() {
|
||||
@@ -1194,7 +1197,9 @@ const PuzzleClearResultView = lazy(async () => {
|
||||
});
|
||||
|
||||
const PuzzleClearRuntimeShell = lazy(async () => {
|
||||
const module = await import('../puzzle-clear-runtime/PuzzleClearRuntimeShell');
|
||||
const module = await import(
|
||||
'../puzzle-clear-runtime/PuzzleClearRuntimeShell'
|
||||
);
|
||||
return {
|
||||
default: module.PuzzleClearRuntimeShell,
|
||||
};
|
||||
@@ -1372,13 +1377,13 @@ function CreationResultRecoveryPanel({
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{message}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
onClick={onAction}
|
||||
className="platform-button platform-button--primary mt-4 min-h-11 justify-center px-4 py-3 text-sm"
|
||||
size="md"
|
||||
className="mt-4 min-h-11"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1690,6 +1695,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
>(null);
|
||||
const [pendingDeleteCreationWork, setPendingDeleteCreationWork] =
|
||||
useState<DeleteCreationWorkConfirmation | null>(null);
|
||||
const [workNotFoundRecoveryDialog, setWorkNotFoundRecoveryDialog] =
|
||||
useState<WorkNotFoundRecoveryDialogState | null>(null);
|
||||
const [
|
||||
claimingPuzzlePointIncentiveProfileId,
|
||||
setClaimingPuzzlePointIncentiveProfileId,
|
||||
@@ -2130,10 +2137,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
const returnPlatformHomeAfterMissingWork = useCallback(() => {
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
if (!maybeAlertWorkNotFoundAndReturnHome()) {
|
||||
pushAppHistoryPath('/');
|
||||
const recoveryDialog = resolveWorkNotFoundRecoveryDialogState();
|
||||
if (recoveryDialog) {
|
||||
setWorkNotFoundRecoveryDialog(recoveryDialog);
|
||||
return;
|
||||
}
|
||||
pushAppHistoryPath('/');
|
||||
}, [setPlatformTab, setSelectionStage]);
|
||||
const confirmWorkNotFoundRecovery = useCallback(() => {
|
||||
const nextPath = workNotFoundRecoveryDialog?.nextPath ?? '/';
|
||||
setWorkNotFoundRecoveryDialog(null);
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
pushAppHistoryPath(nextPath);
|
||||
}, [setPlatformTab, setSelectionStage, workNotFoundRecoveryDialog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionStage === 'profile-feedback') {
|
||||
@@ -2899,8 +2916,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
woodenFishGalleryEntries,
|
||||
],
|
||||
);
|
||||
const { featuredEntries: featuredGalleryEntries, latestEntries: latestGalleryEntries } =
|
||||
publicGalleryFeeds;
|
||||
const {
|
||||
featuredEntries: featuredGalleryEntries,
|
||||
latestEntries: latestGalleryEntries,
|
||||
} = publicGalleryFeeds;
|
||||
const recommendRuntimeEntries = useMemo(
|
||||
() =>
|
||||
buildPlatformRecommendedEntries({
|
||||
@@ -3083,23 +3102,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const progressTickDecision =
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage,
|
||||
miniGameStates: {
|
||||
puzzle: puzzleGenerationState,
|
||||
match3d: match3dGenerationState,
|
||||
'big-fish': bigFishGenerationState,
|
||||
'square-hole': squareHoleGenerationState,
|
||||
'jump-hop': jumpHopGenerationState,
|
||||
'wooden-fish': woodenFishGenerationState,
|
||||
'baby-object-match': babyObjectMatchGenerationState,
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: visualNovelGenerationStartedAtMs,
|
||||
phase: visualNovelGenerationPhase,
|
||||
},
|
||||
});
|
||||
const progressTickDecision = resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage,
|
||||
miniGameStates: {
|
||||
puzzle: puzzleGenerationState,
|
||||
match3d: match3dGenerationState,
|
||||
'big-fish': bigFishGenerationState,
|
||||
'square-hole': squareHoleGenerationState,
|
||||
'jump-hop': jumpHopGenerationState,
|
||||
'wooden-fish': woodenFishGenerationState,
|
||||
'baby-object-match': babyObjectMatchGenerationState,
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: visualNovelGenerationStartedAtMs,
|
||||
phase: visualNovelGenerationPhase,
|
||||
},
|
||||
});
|
||||
|
||||
if (!progressTickDecision.shouldTick) {
|
||||
return undefined;
|
||||
@@ -3602,7 +3620,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
markPendingDraftFailed('match3d', session.sessionId);
|
||||
markDraftFailed(
|
||||
'match3d',
|
||||
[session.draft?.profileId, session.publishedProfileId, session.sessionId],
|
||||
[
|
||||
session.draft?.profileId,
|
||||
session.publishedProfileId,
|
||||
session.sessionId,
|
||||
],
|
||||
errorMessage,
|
||||
);
|
||||
try {
|
||||
@@ -3884,7 +3906,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
markPendingDraftFailed('square-hole', session.sessionId);
|
||||
markDraftFailed(
|
||||
'square-hole',
|
||||
[session.draft?.profileId, session.publishedProfileId, session.sessionId],
|
||||
[
|
||||
session.draft?.profileId,
|
||||
session.publishedProfileId,
|
||||
session.sessionId,
|
||||
],
|
||||
errorMessage,
|
||||
);
|
||||
void refreshSquareHoleShelf().catch(() => undefined);
|
||||
@@ -3964,15 +3990,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (!isPuzzleCompileActionReady(response.session)) {
|
||||
const nextPayload =
|
||||
formPayload ?? buildPuzzleFormPayloadFromSession(response.session);
|
||||
const fallbackGenerationState = createPuzzleDraftGenerationStateFromPayload(
|
||||
nextPayload,
|
||||
response.session,
|
||||
);
|
||||
const nextGenerationState = mergePuzzleSessionProgressIntoGenerationState(
|
||||
puzzleGenerationState ?? fallbackGenerationState,
|
||||
response.session,
|
||||
);
|
||||
activePuzzleGenerationSessionIdRef.current = response.session.sessionId;
|
||||
const fallbackGenerationState =
|
||||
createPuzzleDraftGenerationStateFromPayload(
|
||||
nextPayload,
|
||||
response.session,
|
||||
);
|
||||
const nextGenerationState =
|
||||
mergePuzzleSessionProgressIntoGenerationState(
|
||||
puzzleGenerationState ?? fallbackGenerationState,
|
||||
response.session,
|
||||
);
|
||||
activePuzzleGenerationSessionIdRef.current =
|
||||
response.session.sessionId;
|
||||
setSelectionStage('puzzle-generating');
|
||||
markDraftGenerating('puzzle', [
|
||||
response.session.sessionId,
|
||||
@@ -7648,8 +7677,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
...current.filter(
|
||||
(item) =>
|
||||
item.workId !== response.work!.summary.workId &&
|
||||
item.sourceSessionId !==
|
||||
response.work!.summary.sourceSessionId,
|
||||
item.sourceSessionId !== response.work!.summary.sourceSessionId,
|
||||
),
|
||||
]);
|
||||
markPendingDraftReady(
|
||||
@@ -7771,7 +7799,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
workTitle: puzzleClearSession.draft?.workTitle,
|
||||
workDescription: puzzleClearSession.draft?.workDescription,
|
||||
themePrompt: puzzleClearSession.draft?.themePrompt,
|
||||
boardBackgroundPrompt: puzzleClearSession.draft?.boardBackgroundPrompt,
|
||||
boardBackgroundPrompt:
|
||||
puzzleClearSession.draft?.boardBackgroundPrompt,
|
||||
generateBoardBackground:
|
||||
puzzleClearSession.draft?.generateBoardBackground,
|
||||
boardBackgroundAsset: puzzleClearSession.draft?.boardBackgroundAsset,
|
||||
@@ -7796,11 +7825,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
setPuzzleClearError(errorMessage);
|
||||
setPuzzleClearGenerationState(
|
||||
resolveFinishedMiniGameDraftGenerationState(
|
||||
generationState,
|
||||
'failed',
|
||||
{ error: errorMessage },
|
||||
),
|
||||
resolveFinishedMiniGameDraftGenerationState(generationState, 'failed', {
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsPuzzleClearBusy(false);
|
||||
@@ -7827,7 +7854,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleClearWork(response.item);
|
||||
setPuzzleClearWorks((current) => [
|
||||
response.item.summary,
|
||||
...current.filter((item) => item.workId !== response.item.summary.workId),
|
||||
...current.filter(
|
||||
(item) => item.workId !== response.item.summary.workId,
|
||||
),
|
||||
]);
|
||||
void refreshPuzzleClearShelf();
|
||||
void refreshPuzzleClearGallery();
|
||||
@@ -7840,7 +7869,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPublicWorkDetailError(null);
|
||||
selectionStageRef.current = 'work-detail';
|
||||
setSelectionStage('work-detail');
|
||||
pushAppHistoryPath(buildPublicWorkStagePath('work-detail', publicWorkCode));
|
||||
pushAppHistoryPath(
|
||||
buildPublicWorkStagePath('work-detail', publicWorkCode),
|
||||
);
|
||||
openPublishShareModal({
|
||||
title: response.item.summary.workTitle || '拼消消',
|
||||
publicWorkCode,
|
||||
@@ -7942,7 +7973,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
setPuzzleClearError(null);
|
||||
setPuzzleClearRun(retryPuzzleClearLocalLevel(puzzleClearRun, puzzleClearWork));
|
||||
setPuzzleClearRun(
|
||||
retryPuzzleClearLocalLevel(puzzleClearRun, puzzleClearWork),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10816,6 +10849,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
[
|
||||
openPublicWorkDetail,
|
||||
platformBootstrap.platformTab,
|
||||
returnPlatformHomeAfterMissingWork,
|
||||
resolvePuzzleErrorMessage,
|
||||
setIsPuzzleBusy,
|
||||
setPuzzleError,
|
||||
@@ -10971,7 +11005,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
notices: draftGenerationNotices,
|
||||
generation: {
|
||||
activeSessionId: jumpHopSession?.sessionId,
|
||||
hasActiveGenerationFailure: jumpHopGenerationState?.phase === 'failed',
|
||||
hasActiveGenerationFailure:
|
||||
jumpHopGenerationState?.phase === 'failed',
|
||||
},
|
||||
});
|
||||
markDraftNoticeSeen(openIntent.noticeKeys);
|
||||
@@ -11055,7 +11090,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
try {
|
||||
const detail = await puzzleClearClient.getRuntimeWorkDetail(profileId);
|
||||
setPuzzleClearWork(detail.item);
|
||||
openPublicWorkDetail(mapPuzzleClearWorkToPlatformGalleryCard(detail.item));
|
||||
openPublicWorkDetail(
|
||||
mapPuzzleClearWorkToPlatformGalleryCard(detail.item),
|
||||
);
|
||||
} catch (error) {
|
||||
setPublicWorkDetailError(
|
||||
resolveRpgCreationErrorMessage(error, '读取拼消消详情失败。'),
|
||||
@@ -11322,11 +11359,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleError(null);
|
||||
setPublicWorkDetailError(null);
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
if (!maybeAlertWorkNotFoundAndReturnHome()) {
|
||||
pushAppHistoryPath('/');
|
||||
}
|
||||
returnPlatformHomeAfterMissingWork();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11357,8 +11390,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
notices: draftGenerationNotices,
|
||||
generation: {
|
||||
activeSessionId: puzzleSession?.sessionId,
|
||||
hasActiveGenerationFailure:
|
||||
activeGenerationState?.phase === 'failed',
|
||||
hasActiveGenerationFailure: activeGenerationState?.phase === 'failed',
|
||||
hasActiveGenerationRunning: isMiniGameDraftGenerating(
|
||||
activeGenerationState ?? null,
|
||||
),
|
||||
@@ -11405,9 +11437,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
const failedError = backgroundTask?.error ?? openIntent.errorMessage;
|
||||
if (!failedSession) {
|
||||
try {
|
||||
const { session: latestSession } = await getPuzzleAgentSession(
|
||||
sourceSessionId,
|
||||
);
|
||||
const { session: latestSession } =
|
||||
await getPuzzleAgentSession(sourceSessionId);
|
||||
failedSession = latestSession;
|
||||
failedPayload = buildPuzzleFormPayloadFromSession(latestSession);
|
||||
} catch {
|
||||
@@ -11490,9 +11521,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
if (openIntent.type === 'restore-generating') {
|
||||
try {
|
||||
const { session: latestSession } = await getPuzzleAgentSession(
|
||||
sourceSessionId,
|
||||
);
|
||||
const { session: latestSession } =
|
||||
await getPuzzleAgentSession(sourceSessionId);
|
||||
const payload = buildPuzzleFormPayloadFromSession(latestSession);
|
||||
const startedAtMs = resolveMiniGameDraftGenerationStartedAtMs(
|
||||
latestSession.updatedAt,
|
||||
@@ -11541,9 +11571,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
markDraftNoticeSeen(noticeKeys);
|
||||
|
||||
const restoredSession = await puzzleFlow.restoreDraft(
|
||||
sourceSessionId,
|
||||
);
|
||||
const restoredSession = await puzzleFlow.restoreDraft(sourceSessionId);
|
||||
if (!restoredSession) {
|
||||
await refreshPuzzleShelf().catch(() => undefined);
|
||||
return;
|
||||
@@ -11591,8 +11619,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
forceDraft: options.forceDraft,
|
||||
generation: {
|
||||
activeSessionId: match3dSession?.sessionId,
|
||||
hasActiveGenerationFailure:
|
||||
activeGenerationState?.phase === 'failed',
|
||||
hasActiveGenerationFailure: activeGenerationState?.phase === 'failed',
|
||||
hasActiveGenerationRunning: isMiniGameDraftGenerating(
|
||||
activeGenerationState ?? null,
|
||||
),
|
||||
@@ -11787,9 +11814,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
markDraftNoticeSeen(noticeKeys);
|
||||
|
||||
const restoredSession = await match3dFlow.restoreDraft(
|
||||
sourceSessionId,
|
||||
);
|
||||
const restoredSession = await match3dFlow.restoreDraft(sourceSessionId);
|
||||
if (!restoredSession) {
|
||||
await refreshMatch3DShelf().catch(() => undefined);
|
||||
return;
|
||||
@@ -13238,8 +13263,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
activeRecommendEntryKey && !isDesktopLayout
|
||||
? (recommendRuntimeEntries.find(
|
||||
(entry) =>
|
||||
getPlatformPublicGalleryEntryKey(entry) ===
|
||||
activeRecommendEntryKey,
|
||||
getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey,
|
||||
) ?? null)
|
||||
: null;
|
||||
const isActiveRecommendRuntimeReady =
|
||||
@@ -13255,7 +13279,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
hasVisualNovelRun: Boolean(visualNovelRun),
|
||||
hasWoodenFishRun: Boolean(woodenFishRun),
|
||||
puzzleRunEntryProfileId: puzzleRun?.entryProfileId ?? null,
|
||||
puzzleRunCurrentLevelProfileId: puzzleRun?.currentLevel?.profileId ?? null,
|
||||
puzzleRunCurrentLevelProfileId:
|
||||
puzzleRun?.currentLevel?.profileId ?? null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -13620,10 +13645,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
const detailEntry = mapPuzzleClearWorkToPlatformGalleryCard(entry);
|
||||
return (
|
||||
canExposePublicWork(detailEntry) &&
|
||||
isSamePuzzleClearPublicWorkCode(
|
||||
normalizedKeyword,
|
||||
entry.profileId,
|
||||
)
|
||||
isSamePuzzleClearPublicWorkCode(normalizedKeyword, entry.profileId)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14044,7 +14066,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
jumpHopItems: isJumpHopCreationVisible ? jumpHopShelfItems : [],
|
||||
woodenFishItems: woodenFishShelfItems,
|
||||
match3dItems: match3dShelfItems,
|
||||
squareHoleItems: isSquareHoleCreationVisible ? squareHoleShelfItems : [],
|
||||
squareHoleItems: isSquareHoleCreationVisible
|
||||
? squareHoleShelfItems
|
||||
: [],
|
||||
puzzleItems: puzzleShelfItems,
|
||||
babyObjectMatchItems: isBabyObjectMatchVisible
|
||||
? babyObjectMatchDrafts
|
||||
@@ -14276,7 +14300,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
puzzleShelfError ??
|
||||
puzzleError ??
|
||||
(isVisualNovelCreationOpen ? visualNovelError : null) ??
|
||||
babyObjectMatchError ??
|
||||
babyObjectMatchError ??
|
||||
puzzleClearError ??
|
||||
barkBattleError)
|
||||
}
|
||||
@@ -14652,8 +14676,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
onDelete={
|
||||
detailNavigation.isSelectedWorldOwned
|
||||
? () => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.handleDeleteSelectedWorld();
|
||||
const deleteModel =
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'rpg-library',
|
||||
entry: selectedDetailEntry,
|
||||
});
|
||||
requestDeleteCreationWork({
|
||||
id: deleteModel.id,
|
||||
title: deleteModel.title,
|
||||
detail: deleteModel.detail,
|
||||
run: () => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.handleDeleteSelectedWorld();
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
: null
|
||||
@@ -15684,7 +15720,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
profile={jumpHopWork}
|
||||
isBusy={isJumpHopBusy}
|
||||
error={jumpHopError}
|
||||
runtimeRequestOptions={jumpHopRuntimeRequestOptions ?? undefined}
|
||||
runtimeRequestOptions={
|
||||
jumpHopRuntimeRequestOptions ?? undefined
|
||||
}
|
||||
onBack={() => {
|
||||
setSelectionStage(jumpHopRuntimeReturnStage);
|
||||
}}
|
||||
@@ -16144,7 +16182,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
<UnifiedCreationPage
|
||||
spec={getUnifiedSpec('visual-novel')}
|
||||
onBack={leaveVisualNovelFlow}
|
||||
isBackDisabled={isVisualNovelBusy || isVisualNovelStreamingReply}
|
||||
isBackDisabled={
|
||||
isVisualNovelBusy || isVisualNovelStreamingReply
|
||||
}
|
||||
>
|
||||
<VisualNovelAgentWorkspace
|
||||
session={visualNovelSession}
|
||||
@@ -16842,29 +16882,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<UnifiedModal
|
||||
<UnifiedConfirmDialog
|
||||
open={Boolean(draftGenerationPointNotice)}
|
||||
title={draftGenerationPointNotice?.title ?? '泥点提示'}
|
||||
description={draftGenerationPointNoticeDescription}
|
||||
onClose={() => setDraftGenerationPointNotice(null)}
|
||||
confirmLabel="知道了"
|
||||
closeOnBackdrop
|
||||
size="sm"
|
||||
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftGenerationPointNotice(null)}
|
||||
className="platform-button platform-button--primary min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{draftGenerationPointNotice?.message}
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
{draftGenerationPointNotice?.message}
|
||||
</UnifiedConfirmDialog>
|
||||
<PublishShareModal
|
||||
open={Boolean(publishSharePayload)}
|
||||
payload={publishSharePayload}
|
||||
@@ -16882,7 +16912,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.5rem]"
|
||||
/>
|
||||
<UnifiedModal
|
||||
<UnifiedConfirmDialog
|
||||
open={Boolean(workNotFoundRecoveryDialog)}
|
||||
title="作品不可用"
|
||||
onClose={confirmWorkNotFoundRecovery}
|
||||
confirmLabel="知道了"
|
||||
closeOnBackdrop
|
||||
size="sm"
|
||||
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||
>
|
||||
{workNotFoundRecoveryDialog?.message}
|
||||
</UnifiedConfirmDialog>
|
||||
<UnifiedConfirmDialog
|
||||
open={Boolean(pendingDeleteCreationWork)}
|
||||
title="删除作品"
|
||||
description={
|
||||
@@ -16891,36 +16933,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
: undefined
|
||||
}
|
||||
onClose={closeDeleteCreationWorkConfirmation}
|
||||
closeDisabled={Boolean(deletingCreationWorkId)}
|
||||
busy={Boolean(deletingCreationWorkId)}
|
||||
closeOnBackdrop={!deletingCreationWorkId}
|
||||
size="sm"
|
||||
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeDeleteCreationWorkConfirmation}
|
||||
disabled={Boolean(deletingCreationWorkId)}
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmDeleteCreationWork}
|
||||
disabled={Boolean(deletingCreationWorkId)}
|
||||
className="platform-button platform-button--danger min-h-0 rounded-full px-4 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{deletingCreationWorkId ? '删除中' : '确认删除'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
showCancel
|
||||
confirmLabel="确认删除"
|
||||
busyConfirmLabel="删除中"
|
||||
confirmTone="danger"
|
||||
onConfirm={confirmDeleteCreationWork}
|
||||
>
|
||||
<div className="text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{pendingDeleteCreationWork?.detail}
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
{pendingDeleteCreationWork?.detail}
|
||||
</UnifiedConfirmDialog>
|
||||
<AnimatePresence>
|
||||
{(searchedPublicUser || publicSearchError) && (
|
||||
<motion.div
|
||||
@@ -16939,17 +16964,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
{publicSearchError ? '未找到结果' : '命中用户'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformModalCloseButton
|
||||
onClick={() => {
|
||||
setSearchedPublicUser(null);
|
||||
setPublicSearchError(null);
|
||||
}}
|
||||
className="platform-icon-button"
|
||||
aria-label="关闭搜索结果"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
label="关闭搜索结果"
|
||||
variant="platformIcon"
|
||||
/>
|
||||
</div>
|
||||
{publicSearchError ? (
|
||||
<div className="mt-4 text-sm leading-6 text-[var(--platform-text-soft)]">
|
||||
|
||||
@@ -36,15 +36,18 @@ describe('PlatformErrorDialog', () => {
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '发生错误' });
|
||||
expect(within(dialog).getByText('拼图草稿 puzzle-session-123')).toBeTruthy();
|
||||
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',
|
||||
),
|
||||
[
|
||||
'来源:拼图草稿 puzzle-session-123',
|
||||
'错误:图片生成失败,请稍后再试。',
|
||||
].join('\n'),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -64,8 +67,7 @@ describe('PlatformErrorDialog', () => {
|
||||
<PlatformErrorDialog
|
||||
error={{
|
||||
source: '大鱼草稿',
|
||||
message:
|
||||
'creation_entry_disabled(requestId: req-big-fish-gallery)',
|
||||
message: 'creation_entry_disabled(requestId: req-big-fish-gallery)',
|
||||
}}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
|
||||
import { PlatformInfoBlock } from '../common/PlatformInfoBlock';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { useCopyFeedback } from '../common/useCopyFeedback';
|
||||
|
||||
export type PlatformErrorDialogPayload = {
|
||||
source: string;
|
||||
@@ -31,44 +32,23 @@ export function PlatformErrorDialog({
|
||||
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 { copyState, copyText, resetCopyState } = useCopyFeedback();
|
||||
const dialogError = isBlacklistedPlatformError(error) ? null : error;
|
||||
const reportText = useMemo(
|
||||
() => (dialogError ? buildPlatformErrorReport(dialogError) : ''),
|
||||
[dialogError],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCopyState('idle');
|
||||
}, [dialogError?.source, dialogError?.message]);
|
||||
resetCopyState();
|
||||
}, [dialogError?.source, dialogError?.message, resetCopyState]);
|
||||
|
||||
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);
|
||||
});
|
||||
void copyText(reportText);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -82,43 +62,25 @@ export function PlatformErrorDialog({
|
||||
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"
|
||||
<CopyFeedbackButton
|
||||
state={copyState}
|
||||
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>
|
||||
idleLabel="复制报错"
|
||||
actionSurface="platform"
|
||||
actionFullWidth
|
||||
className="sm:w-auto"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{dialogError ? (
|
||||
<>
|
||||
<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)]">
|
||||
{dialogError.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)]">
|
||||
{dialogError.message}
|
||||
</div>
|
||||
</div>
|
||||
<PlatformInfoBlock label="来源">
|
||||
{dialogError.source}
|
||||
</PlatformInfoBlock>
|
||||
<PlatformInfoBlock label="错误" multiline>
|
||||
{dialogError.message}
|
||||
</PlatformInfoBlock>
|
||||
</>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
|
||||
@@ -31,7 +31,21 @@ test('PlatformFeedbackView renders reference feedback fields', () => {
|
||||
expect(screen.getByText('上传凭证')).toBeTruthy();
|
||||
expect(screen.getByLabelText('联系电话')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '提交' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '查看反馈与投诉记录' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '查看反馈与投诉记录' }),
|
||||
).toBeTruthy();
|
||||
|
||||
const descriptionPanel = screen.getByLabelText('问题描述').closest('section');
|
||||
const evidencePanel = screen
|
||||
.getByText('上传凭证(提供问题截图)')
|
||||
.closest('section');
|
||||
const phonePanel = screen.getByLabelText('联系电话').closest('section');
|
||||
|
||||
for (const panel of [descriptionPanel, evidencePanel, phonePanel]) {
|
||||
expect(panel?.className).toContain('platform-subpanel');
|
||||
expect(panel?.className).toContain('rounded-[1.25rem]');
|
||||
expect(panel?.className).toContain('p-4');
|
||||
}
|
||||
});
|
||||
|
||||
test('PlatformFeedbackView validates minimum description length before submit', () => {
|
||||
@@ -73,9 +87,12 @@ test('PlatformFeedbackView previews image data urls and submits evidence items',
|
||||
render(<PlatformFeedbackView onBack={vi.fn()} onSubmit={onSubmit} />);
|
||||
|
||||
const file = new File(['feedback'], 'preview.png', { type: 'image/png' });
|
||||
fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
|
||||
target: { files: [file] },
|
||||
});
|
||||
fireEvent.change(
|
||||
document.querySelector('input[type="file"]') as HTMLInputElement,
|
||||
{
|
||||
target: { files: [file] },
|
||||
},
|
||||
);
|
||||
|
||||
const preview = await screen.findByAltText('反馈凭证预览');
|
||||
expect(preview.getAttribute('src')).toBe(
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { ArrowLeft, CheckCircle2, ImagePlus, Send, X } from 'lucide-react';
|
||||
import { ArrowLeft, CheckCircle2, Send } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import type { ProfileFeedbackEvidenceItemInput } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard';
|
||||
import { PlatformUploadTile } from '../common/PlatformUploadTile';
|
||||
|
||||
const MIN_FEEDBACK_DESCRIPTION_LENGTH = 10;
|
||||
const MAX_FEEDBACK_DESCRIPTION_LENGTH = 200;
|
||||
@@ -55,7 +60,9 @@ export function PlatformFeedbackView({
|
||||
const evidenceInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [description, setDescription] = useState('');
|
||||
const [contactPhone, setContactPhone] = useState('');
|
||||
const [evidencePreviews, setEvidencePreviews] = useState<EvidencePreview[]>([]);
|
||||
const [evidencePreviews, setEvidencePreviews] = useState<EvidencePreview[]>(
|
||||
[],
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -97,7 +104,8 @@ export function PlatformFeedbackView({
|
||||
setError('反馈凭证只支持图片类型');
|
||||
return;
|
||||
}
|
||||
const remainingCount = MAX_FEEDBACK_EVIDENCE_COUNT - evidencePreviews.length;
|
||||
const remainingCount =
|
||||
MAX_FEEDBACK_EVIDENCE_COUNT - evidencePreviews.length;
|
||||
if (remainingCount <= 0 || selectedFiles.length > remainingCount) {
|
||||
setError('最多上传四张凭证');
|
||||
}
|
||||
@@ -142,7 +150,9 @@ export function PlatformFeedbackView({
|
||||
setSubmitted(false);
|
||||
})
|
||||
.catch((readError: unknown) => {
|
||||
setError(readError instanceof Error ? readError.message : '图片读取失败');
|
||||
setError(
|
||||
readError instanceof Error ? readError.message : '图片读取失败',
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -199,7 +209,9 @@ export function PlatformFeedbackView({
|
||||
.then(() => setSubmitted(true))
|
||||
.catch((submitError: unknown) => {
|
||||
setSubmitted(false);
|
||||
setError(submitError instanceof Error ? submitError.message : '提交失败');
|
||||
setError(
|
||||
submitError instanceof Error ? submitError.message : '提交失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsSubmitting(false));
|
||||
};
|
||||
@@ -208,14 +220,16 @@ export function PlatformFeedbackView({
|
||||
<div className="platform-page-stage platform-remap-surface min-h-0 flex-1 overflow-y-auto text-[var(--platform-text-strong)]">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-[30rem] flex-col px-4 pb-6 pt-4">
|
||||
<header className="flex shrink-0 items-center gap-3 pb-3">
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
onClick={onBack}
|
||||
aria-label="返回我的页签"
|
||||
className="platform-button platform-button--ghost h-10 w-10 shrink-0 justify-center rounded-full p-0"
|
||||
tone="ghost"
|
||||
shape="pill"
|
||||
size="xs"
|
||||
className="h-10 w-10 shrink-0 p-0"
|
||||
>
|
||||
<ArrowLeft className="h-[1.125rem] w-[1.125rem]" />
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
<div className="min-w-0 text-base font-black text-[var(--platform-text-strong)]">
|
||||
帮助与反馈
|
||||
</div>
|
||||
@@ -226,7 +240,7 @@ export function PlatformFeedbackView({
|
||||
反馈问题
|
||||
</div>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.2rem] px-4 py-4">
|
||||
<PlatformSubpanel radius="md">
|
||||
<label
|
||||
htmlFor="profile-feedback-description"
|
||||
className="block text-base font-semibold text-[var(--platform-text-strong)]"
|
||||
@@ -244,43 +258,28 @@ export function PlatformFeedbackView({
|
||||
<div className="text-right text-xs text-[var(--platform-text-soft)]">
|
||||
{descriptionLength}/{MAX_FEEDBACK_DESCRIPTION_LENGTH}
|
||||
</div>
|
||||
</section>
|
||||
</PlatformSubpanel>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.2rem] px-4 py-4">
|
||||
<PlatformSubpanel radius="md">
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
上传凭证(提供问题截图)
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{evidencePreviews.map((preview) => (
|
||||
<div
|
||||
<PlatformUploadPreviewCard
|
||||
key={preview.id}
|
||||
className="relative h-[5.75rem] w-[5.75rem] overflow-hidden rounded-xl border border-[var(--platform-subpanel-border)] bg-[var(--platform-input-fill)]"
|
||||
>
|
||||
<img
|
||||
src={preview.dataUrl}
|
||||
alt="反馈凭证预览"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEvidencePreview(preview.id)}
|
||||
aria-label="移除上传凭证"
|
||||
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/55 text-white"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
imageSrc={preview.dataUrl}
|
||||
imageAlt="反馈凭证预览"
|
||||
removeLabel="移除上传凭证"
|
||||
onRemove={() => removeEvidencePreview(preview.id)}
|
||||
/>
|
||||
))}
|
||||
{evidencePreviews.length < MAX_FEEDBACK_EVIDENCE_COUNT ? (
|
||||
<button
|
||||
type="button"
|
||||
<PlatformUploadTile
|
||||
onClick={openEvidencePicker}
|
||||
className="flex h-[5.75rem] w-[5.75rem] flex-col items-center justify-center rounded-xl border border-dashed border-[var(--platform-subpanel-border)] bg-[var(--platform-input-fill)] text-[var(--platform-text-soft)] transition hover:border-[var(--platform-surface-hover-border)] hover:text-[var(--platform-text-strong)]"
|
||||
>
|
||||
<ImagePlus className="h-6 w-6" />
|
||||
<span className="mt-2 text-xs font-medium">上传凭证</span>
|
||||
<span className="mt-0.5 text-[11px]">(最多四张)</span>
|
||||
</button>
|
||||
label="上传凭证"
|
||||
hint="(最多四张)"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<input
|
||||
@@ -291,9 +290,9 @@ export function PlatformFeedbackView({
|
||||
className="hidden"
|
||||
onChange={(event) => addEvidenceFiles(event.target.files)}
|
||||
/>
|
||||
</section>
|
||||
</PlatformSubpanel>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.2rem] px-4 py-4">
|
||||
<PlatformSubpanel radius="md">
|
||||
<label
|
||||
htmlFor="profile-feedback-phone"
|
||||
className="block text-base font-semibold text-[var(--platform-text-strong)]"
|
||||
@@ -309,34 +308,35 @@ export function PlatformFeedbackView({
|
||||
placeholder="选填,如您填写则将会同步开发者与您联系"
|
||||
className="mt-3 w-full border-0 bg-transparent text-sm leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
||||
/>
|
||||
</section>
|
||||
</PlatformSubpanel>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[1.2rem] border border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] px-4 py-3 text-sm font-medium text-[var(--platform-button-danger-text)]">
|
||||
<PlatformStatusMessage tone="error" surface="profile">
|
||||
{error}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{submitted ? (
|
||||
<div className="flex items-center gap-2 rounded-[1.2rem] border border-[var(--platform-success-border)] bg-[var(--platform-success-bg)] px-4 py-3 text-sm font-medium text-[var(--platform-success-text)]">
|
||||
<PlatformStatusMessage tone="success" surface="profile">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
反馈已提交
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{notice ? (
|
||||
<div className="rounded-[1.2rem] border border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] px-4 py-3 text-sm font-medium text-[var(--platform-cool-text)]">
|
||||
<PlatformStatusMessage tone="info" surface="profile">
|
||||
{notice}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
fullWidth
|
||||
size="md"
|
||||
className="mt-2 h-12 text-base"
|
||||
onClick={submitFeedback}
|
||||
disabled={isSubmitting}
|
||||
className="platform-button platform-button--primary mt-2 h-12 w-full justify-center text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{isSubmitting ? '提交中' : '提交'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { CheckCircle2, Copy } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
|
||||
import { PlatformInfoBlock } from '../common/PlatformInfoBlock';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { useCopyFeedback } from '../common/useCopyFeedback';
|
||||
|
||||
export type PlatformTaskCompletionDialogPayload = {
|
||||
source: string;
|
||||
@@ -30,43 +31,22 @@ export function PlatformTaskCompletionDialog({
|
||||
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 { copyState, copyText, resetCopyState } = useCopyFeedback();
|
||||
const reportText = useMemo(
|
||||
() => (completion ? buildPlatformTaskCompletionReport(completion) : ''),
|
||||
[completion],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCopyState('idle');
|
||||
}, [completion?.source, completion?.message]);
|
||||
resetCopyState();
|
||||
}, [completion?.source, completion?.message, resetCopyState]);
|
||||
|
||||
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);
|
||||
});
|
||||
void copyText(reportText);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -80,43 +60,25 @@ export function PlatformTaskCompletionDialog({
|
||||
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"
|
||||
<CopyFeedbackButton
|
||||
state={copyState}
|
||||
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>
|
||||
idleLabel="复制内容"
|
||||
actionSurface="platform"
|
||||
actionFullWidth
|
||||
className="sm:w-auto"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{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>
|
||||
<PlatformInfoBlock label="来源">
|
||||
{completion.source}
|
||||
</PlatformInfoBlock>
|
||||
<PlatformInfoBlock label="状态" multiline>
|
||||
{completion.message}
|
||||
</PlatformInfoBlock>
|
||||
</>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import * as clipboardService from '../../services/clipboard';
|
||||
import {
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
@@ -34,6 +35,10 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/clipboard', () => ({
|
||||
copyTextToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
function createPuzzleEntry(): PlatformPuzzleGalleryCard {
|
||||
return {
|
||||
sourceType: 'puzzle',
|
||||
@@ -108,6 +113,7 @@ function createWoodenFishEntry(): PlatformWoodenFishGalleryCard {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('PlatformWorkDetailView renders compact stats and date time', () => {
|
||||
@@ -209,6 +215,34 @@ test('PlatformWorkDetailView calls like handler', () => {
|
||||
expect(onLike).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('PlatformWorkDetailView copies public work code and share text', async () => {
|
||||
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
|
||||
|
||||
render(
|
||||
<PlatformWorkDetailView
|
||||
entry={createPuzzleEntry()}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={vi.fn()}
|
||||
onLike={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onRemix={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'PZ-001' }));
|
||||
|
||||
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith('PZ-001');
|
||||
expect(await screen.findByText('已复制')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '分享' }));
|
||||
|
||||
expect(clipboardService.copyTextToClipboard).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('作品号:PZ-001'),
|
||||
);
|
||||
expect(await screen.findByText('分享内容已复制')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('PlatformWorkDetailView switches remix action label for owned work edit', () => {
|
||||
render(
|
||||
<PlatformWorkDetailView
|
||||
|
||||
@@ -16,7 +16,9 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
|
||||
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { CopyCodeButton } from '../common/CopyCodeButton';
|
||||
import { CopyFeedbackMessage } from '../common/CopyFeedbackMessage';
|
||||
import { useCopyFeedback } from '../common/useCopyFeedback';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import {
|
||||
buildPlatformWorldDisplayTags,
|
||||
@@ -30,8 +32,8 @@ import {
|
||||
isPuzzleClearGalleryEntry,
|
||||
isWoodenFishGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
resolvePlatformWorldStats,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
@@ -130,12 +132,16 @@ export function PlatformWorkDetailView({
|
||||
entry,
|
||||
authorSummary,
|
||||
);
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const {
|
||||
copyState,
|
||||
copyText: copyWorkCodeText,
|
||||
resetCopyState: resetWorkCodeCopyState,
|
||||
} = useCopyFeedback();
|
||||
const {
|
||||
copyState: shareState,
|
||||
copyText: copyShareText,
|
||||
resetCopyState: resetShareCopyState,
|
||||
} = useCopyFeedback();
|
||||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
@@ -181,7 +187,14 @@ export function PlatformWorkDetailView({
|
||||
|
||||
useEffect(() => {
|
||||
setActiveCoverIndex(0);
|
||||
}, [entry.profileId, coverSlides.length]);
|
||||
resetWorkCodeCopyState();
|
||||
resetShareCopyState();
|
||||
}, [
|
||||
entry.profileId,
|
||||
coverSlides.length,
|
||||
resetShareCopyState,
|
||||
resetWorkCodeCopyState,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveCoverIndex((current) =>
|
||||
@@ -224,10 +237,7 @@ export function PlatformWorkDetailView({
|
||||
return;
|
||||
}
|
||||
|
||||
void copyTextToClipboard(publicWorkCode).then((copied) => {
|
||||
setCopyState(copied ? 'copied' : 'failed');
|
||||
window.setTimeout(() => setCopyState('idle'), 1400);
|
||||
});
|
||||
void copyWorkCodeText(publicWorkCode);
|
||||
};
|
||||
|
||||
const sharePublicWork = () => {
|
||||
@@ -236,10 +246,7 @@ export function PlatformWorkDetailView({
|
||||
}
|
||||
|
||||
const shareText = `邀请你来玩《${entry.worldName}》\n作品号:${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
|
||||
void copyTextToClipboard(shareText).then((copied) => {
|
||||
setShareState(copied ? 'copied' : 'failed');
|
||||
window.setTimeout(() => setShareState('idle'), 1400);
|
||||
});
|
||||
void copyShareText(shareText);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -432,23 +439,24 @@ export function PlatformWorkDetailView({
|
||||
</div>
|
||||
<p className="platform-work-detail__copy">{entry.summaryText}</p>
|
||||
{publicWorkCode ? (
|
||||
<button
|
||||
type="button"
|
||||
<CopyCodeButton
|
||||
state={copyState}
|
||||
code={publicWorkCode}
|
||||
codeLabel={null}
|
||||
accessibleLabel={publicWorkCode}
|
||||
title="复制作品号"
|
||||
className="platform-work-detail__code"
|
||||
onClick={copyPublicWorkCode}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
<span>{publicWorkCode}</span>
|
||||
{copyState !== 'idle' ? (
|
||||
<span>{copyState === 'copied' ? '已复制' : '复制失败'}</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
{shareState !== 'idle' ? (
|
||||
<div className="platform-work-detail__toast">
|
||||
{shareState === 'copied' ? '分享内容已复制' : '分享失败'}
|
||||
</div>
|
||||
idleIcon={<Copy className="h-4 w-4" />}
|
||||
copiedIcon={<Copy className="h-4 w-4" />}
|
||||
/>
|
||||
) : null}
|
||||
<CopyFeedbackMessage
|
||||
state={shareState}
|
||||
className="platform-work-detail__toast"
|
||||
copiedLabel="分享内容已复制"
|
||||
failedLabel="分享失败"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user