收口前端平台组件库能力

新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
2026-06-10 10:24:18 +08:00
parent a4ee6ff698
commit 1ad25e30f8
226 changed files with 23364 additions and 7825 deletions

View File

@@ -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();
});

View File

@@ -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" />
)}

View File

@@ -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)]">

View File

@@ -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_disabledrequestId: req-big-fish-gallery',
message: 'creation_entry_disabledrequestId: req-big-fish-gallery',
}}
onClose={() => {}}
/>,

View File

@@ -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>

View File

@@ -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(

View File

@@ -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"

View File

@@ -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>

View File

@@ -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

View File

@@ -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>