再次合并 master

合入 origin/master 最新订阅消息与计费相关更新

保留作品架 actions 收口并接入统一分享弹窗

修复创作生成泥点预检与本地余额扣减回归
This commit is contained in:
2026-06-08 17:18:38 +08:00
342 changed files with 4153 additions and 2483 deletions

View File

@@ -1043,14 +1043,10 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
expect(openedItems).toEqual([persistedDraft]);
});
test('creation hub published share icon copies share text without opening the card', async () => {
test('creation hub published share icon opens unified share payload without opening the card', async () => {
const user = userEvent.setup();
const writeText = vi.fn(async () => undefined);
const onOpenPuzzleDetail = vi.fn();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
const onShareWork = vi.fn();
render(
<CustomWorldCreationHub
@@ -1081,6 +1077,7 @@ test('creation hub published share icon copies share text without opening the ca
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={onOpenPuzzleDetail}
onShareWork={onShareWork}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
@@ -1092,19 +1089,12 @@ test('creation hub published share icon copies share text without opening the ca
await user.click(shareButton);
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('邀请你来玩《沉钟拼图》'),
);
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('作品号PZ-PROFILE1'),
);
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('/gallery/puzzle/detail?work=PZ-PROFILE1'),
);
expect(onShareWork).toHaveBeenCalledWith({
title: '沉钟拼图',
publicWorkCode: 'PZ-PROFILE1',
stage: 'puzzle-gallery-detail',
});
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
expect(
await screen.findByRole('button', { name: '分享内容已复制' }),
).toBeTruthy();
});
test('creation hub published share icon is shown directly on the card header', () => {

View File

@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { resolveSelectionStageFromPath } from '../../routing/appPageRoutes';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import type { PublishShareModalPayload } from '../common/publishShareModalModel';
import type {
PlatformCreationTypeCard,
PlatformCreationTypeId,
@@ -43,6 +45,7 @@ type CustomWorldCreationHubProps = {
deletingWorkId?: string | null;
claimingPuzzleProfileId?: string | null;
onOpenShelfItem?: (item: CreationWorkShelfItem) => void;
onShareWork?: ((payload: PublishShareModalPayload) => void) | null;
// 中文注释:底部加号入口可传入后端作品架摘要,用于推导最近使用过的模板。
recentWorkItems?: CreationWorkShelfItem[];
mode?: 'full' | 'start-only' | 'works-only';
@@ -111,6 +114,41 @@ function writeWorkMetricSnapshot(items: CreationWorkShelfItem[]) {
}
}
function resolveShelfShareStage(
sharePath: string,
): PublishShareModalPayload['stage'] | null {
let pathname = '';
try {
pathname = new URL(sharePath, 'https://genarrative.local').pathname;
} catch {
pathname = sharePath.split(/[?#]/u)[0] ?? '';
}
const stage = resolveSelectionStageFromPath(pathname);
return stage === 'platform' ? null : stage;
}
function buildCreationWorkShelfSharePayload(
item: CreationWorkShelfItem,
): PublishShareModalPayload | null {
const publicWorkCode = item.publicWorkCode?.trim();
const sharePath = item.sharePath?.trim();
if (!publicWorkCode || !sharePath) {
return null;
}
const stage = resolveShelfShareStage(sharePath);
if (!stage) {
return null;
}
return {
title: item.title,
publicWorkCode,
stage,
};
}
/** 渲染底部加号创作入口页与草稿作品架,最近创作复用最近使用过的模板入口。 */
export function CustomWorldCreationHub({
shelfItems,
@@ -124,6 +162,7 @@ export function CustomWorldCreationHub({
deletingWorkId = null,
claimingPuzzleProfileId = null,
onOpenShelfItem,
onShareWork = null,
recentWorkItems: recentWorkSourceItems,
mode = 'full',
}: CustomWorldCreationHubProps) {
@@ -178,6 +217,17 @@ export function CustomWorldCreationHub({
return item.actions.delete ?? null;
}
function buildShareAction(item: CreationWorkShelfItem) {
const payload = buildCreationWorkShelfSharePayload(item);
if (!payload) {
return null;
}
return () => {
onShareWork?.(payload);
};
}
function buildPointIncentiveAction(item: CreationWorkShelfItem) {
return item.actions.claimPointIncentive ?? null;
}
@@ -253,6 +303,7 @@ export function CustomWorldCreationHub({
}}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
onShare={buildShareAction(item)}
onClaimPointIncentive={buildPointIncentiveAction(item)}
pointIncentiveBusy={
item.source.kind === 'puzzle' &&

View File

@@ -18,7 +18,6 @@ import {
useState,
} from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import {
formatPlatformWorkDisplayName,
@@ -40,6 +39,7 @@ type CustomWorldWorkCardProps = {
onOpen: () => void;
onDelete?: (() => void) | null;
deleteBusy?: boolean;
onShare?: (() => void) | null;
onClaimPointIncentive?: (() => void) | null;
pointIncentiveBusy?: boolean;
};
@@ -231,13 +231,10 @@ export function CustomWorldWorkCard({
onOpen,
onDelete = null,
deleteBusy = false,
onShare = null,
onClaimPointIncentive = null,
pointIncentiveBusy = false,
}: CustomWorldWorkCardProps) {
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const shareResetTimerRef = useRef<number | null>(null);
const suppressOpenResetTimerRef = useRef<number | null>(null);
const suppressOpenRef = useRef(false);
const swipeGestureRef = useRef<{
@@ -253,7 +250,7 @@ export function CustomWorldWorkCard({
const [swipeOffset, setSwipeOffset] = useState(0);
const isPublished = item.status === 'published';
const canUseShareAction =
isPublished && item.canShare && Boolean(item.sharePath);
isPublished && item.canShare && Boolean(item.sharePath) && Boolean(onShare);
const swipeActionCount = onDelete ? 1 : 0;
const swipeRevealWidth = swipeActionCount * SWIPE_ACTION_WIDTH_PX;
const canClaimPointIncentive =
@@ -289,34 +286,8 @@ export function CustomWorldWorkCard({
}`,
} as CSSProperties;
const copyShareText = () => {
const publicWorkCode = item.publicWorkCode?.trim();
const sharePath = item.sharePath?.trim();
if (!publicWorkCode || !sharePath) {
return;
}
const shareUrl =
typeof window === 'undefined'
? sharePath
: new URL(sharePath, window.location.origin).href;
const shareText = `邀请你来玩《${item.title}\n作品号${publicWorkCode}\n${shareUrl}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
if (shareResetTimerRef.current !== null) {
window.clearTimeout(shareResetTimerRef.current);
}
shareResetTimerRef.current = window.setTimeout(() => {
shareResetTimerRef.current = null;
setShareState('idle');
}, 1400);
});
};
useEffect(() => {
return () => {
if (shareResetTimerRef.current !== null) {
window.clearTimeout(shareResetTimerRef.current);
}
if (suppressOpenResetTimerRef.current !== null) {
window.clearTimeout(suppressOpenResetTimerRef.current);
}
@@ -677,7 +648,7 @@ export function CustomWorldWorkCard({
event.stopPropagation();
suppressOpenRef.current = false;
closeSwipeActions();
copyShareText();
onShare?.();
}}
onKeyDown={(event) => {
event.stopPropagation();
@@ -688,20 +659,8 @@ export function CustomWorldWorkCard({
onTouchStart={(event) => {
event.stopPropagation();
}}
title={
shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
title="分享作品"
aria-label="分享"
className="creation-work-card__quick-action-button"
>
<Share2 aria-hidden="true" className="h-4 w-4" />

View File

@@ -68,7 +68,6 @@ import type {
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { isPuzzleCompileActionReady } from './puzzleDraftGenerationState';
import type { PuzzleCreativeTemplateSelection } from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
import type {
PuzzleRunSnapshot,
@@ -159,6 +158,7 @@ import {
} from '../../services/big-fish-works';
import {
type CreationEntryConfig,
DEFAULT_UNIFIED_CREATION_MUD_POINT_COST,
fetchCreationEntryConfig,
} from '../../services/creationEntryConfigService';
import {
@@ -351,6 +351,7 @@ import {
publishVisualNovelWork,
updateVisualNovelWork,
} from '../../services/visual-novel-works';
import { requestGenerationResultSubscribePermission } from '../../services/wechatMiniProgramSubscribe';
import {
woodenFishClient,
type WoodenFishGalleryCardResponse,
@@ -495,7 +496,6 @@ import {
EDUTAINMENT_HIDDEN_MESSAGE,
filterGeneralPublicWorks,
} from './platformEdutainmentVisibility';
import { buildPlatformRecommendedEntries } from './platformRecommendation';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
import {
@@ -525,6 +525,7 @@ import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
import { PlatformErrorDialog } from './PlatformErrorDialog';
import { PlatformFeedbackView } from './PlatformFeedbackView';
import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel';
import { buildPlatformRecommendedEntries } from './platformRecommendation';
import {
buildMatch3DProfileFromSession,
hasMatch3DRuntimeAsset,
@@ -649,6 +650,7 @@ import {
} from './platformSelectionStageModel';
import { PlatformTaskCompletionDialog } from './PlatformTaskCompletionDialog';
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
import { isPuzzleCompileActionReady } from './puzzleDraftGenerationState';
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail';
@@ -1732,6 +1734,18 @@ export function PlatformEntryFlowShellImpl({
creationEntryTypes,
'visual-novel',
);
const resolveCreationEntryMudPointCost = useCallback(
(id: PlatformCreationTypeId) =>
creationEntryTypes.find((item) => item.id === id)?.mudPointCost ??
DEFAULT_UNIFIED_CREATION_MUD_POINT_COST,
[creationEntryTypes],
);
const puzzleDraftGenerationPointCost =
resolveCreationEntryMudPointCost('puzzle');
const match3DDraftGenerationPointCost =
resolveCreationEntryMudPointCost('match3d');
const barkBattleDraftGenerationPointCost =
resolveCreationEntryMudPointCost('bark-battle');
const [profilePlayStats, setProfilePlayStats] =
useState<ProfilePlayStatsResponse | null>(null);
const [profilePlayStatsError, setProfilePlayStatsError] = useState<
@@ -2046,9 +2060,8 @@ export function PlatformEntryFlowShellImpl({
lastProfileDashboardSnapshotRef.current = null;
}, [authUi?.user?.id]);
const getPlatformProfileDashboardWithLocalWalletDelta = useCallback(
async (options?: Parameters<typeof getPlatformProfileDashboard>[0]) => {
const latestDashboard = await getPlatformProfileDashboard(options);
const applyProfileWalletLocalDeltaToDashboard = useCallback(
(latestDashboard: ProfileDashboardSummary | null) => {
const reconciledDelta =
reconcileProfileWalletLocalDeltaWithServerDashboard(
lastProfileDashboardSnapshotRef.current,
@@ -2064,6 +2077,13 @@ export function PlatformEntryFlowShellImpl({
},
[],
);
const getPlatformProfileDashboardWithLocalWalletDelta = useCallback(
async (options?: Parameters<typeof getPlatformProfileDashboard>[0]) => {
const latestDashboard = await getPlatformProfileDashboard(options);
return applyProfileWalletLocalDeltaToDashboard(latestDashboard);
},
[applyProfileWalletLocalDeltaToDashboard],
);
const platformBootstrap = usePlatformEntryBootstrap({
user: authUi?.user,
@@ -2175,10 +2195,12 @@ export function PlatformEntryFlowShellImpl({
const ensureEnoughDraftGenerationPointsFromServer = useCallback(
async (pointsCost: number) => {
try {
const latestDashboard = await getPlatformProfileDashboardWithLocalWalletDelta(
const latestDashboard = await getPlatformProfileDashboard(
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
);
platformBootstrap.setProfileDashboard(latestDashboard);
const dashboardWithLocalDelta =
applyProfileWalletLocalDeltaToDashboard(latestDashboard);
platformBootstrap.setProfileDashboard(dashboardWithLocalDelta);
const walletBalance = resolveProfileWalletBalance(latestDashboard);
if (walletBalance >= pointsCost) {
setDraftGenerationPointNotice(null);
@@ -2198,7 +2220,7 @@ export function PlatformEntryFlowShellImpl({
return false;
}
},
[getPlatformProfileDashboardWithLocalWalletDelta, platformBootstrap],
[applyProfileWalletLocalDeltaToDashboard, platformBootstrap],
);
const resolveBigFishErrorMessage = useCallback(
@@ -4063,7 +4085,7 @@ export function PlatformEntryFlowShellImpl({
});
}
},
beforeExecuteAction: ({ payload, session }) => {
beforeExecuteAction: async ({ payload, session }) => {
const formPayload = buildPuzzleFormPayloadFromAction(payload);
if (formPayload) {
setPuzzleFormDraftPayload(formPayload);
@@ -4102,6 +4124,7 @@ export function PlatformEntryFlowShellImpl({
error: null,
},
}));
void requestGenerationResultSubscribePermission();
},
onActionError: async ({ payload, errorMessage, session, setSession }) => {
if (payload.action !== 'compile_puzzle_draft') {
@@ -4267,21 +4290,30 @@ export function PlatformEntryFlowShellImpl({
setPuzzleCreationError(null);
setPuzzleError(null);
return ensureEnoughDraftGenerationPointsFromServer(
PUZZLE_DRAFT_GENERATION_POINT_COST,
puzzleDraftGenerationPointCost,
);
}, [ensureEnoughDraftGenerationPointsFromServer]);
}, [
ensureEnoughDraftGenerationPointsFromServer,
puzzleDraftGenerationPointCost,
]);
const preflightMatch3DDraftGeneration = useCallback(async () => {
setMatch3DError(null);
return ensureEnoughDraftGenerationPointsFromServer(
MATCH3D_DRAFT_GENERATION_POINT_COST,
match3DDraftGenerationPointCost,
);
}, [ensureEnoughDraftGenerationPointsFromServer]);
}, [
ensureEnoughDraftGenerationPointsFromServer,
match3DDraftGenerationPointCost,
]);
const preflightBarkBattleDraftGeneration = useCallback(async () => {
setBarkBattleError(null);
return ensureEnoughDraftGenerationPointsFromServer(
BARK_BATTLE_DRAFT_GENERATION_POINT_COST,
barkBattleDraftGenerationPointCost,
);
}, [ensureEnoughDraftGenerationPointsFromServer]);
}, [
barkBattleDraftGenerationPointCost,
ensureEnoughDraftGenerationPointsFromServer,
]);
const draftGenerationPointNoticeDescription = draftGenerationPointNotice
? draftGenerationPointNotice.title === '读取泥点余额失败'
? '当前表单不会丢失,关闭后可继续编辑,稍后再试。'
@@ -5263,6 +5295,7 @@ export function PlatformEntryFlowShellImpl({
try {
const actionPayload = buildPuzzleCompileActionFromFormPayload(payload);
void requestGenerationResultSubscribePermission();
const response = await executePuzzleAgentAction(
nextSession.sessionId,
actionPayload,
@@ -5430,6 +5463,7 @@ export function PlatformEntryFlowShellImpl({
isViewingPuzzleGeneration,
preflightPuzzleDraftGeneration,
puzzleFlow,
puzzleDraftGenerationPointCost,
refreshPuzzleShelf,
recoverCompletedPuzzleDraftGeneration,
refreshPlatformDashboardSilently,
@@ -5707,6 +5741,7 @@ export function PlatformEntryFlowShellImpl({
[
adjustProfileWalletBalanceLocally,
match3dRuntimeAdapter,
match3DDraftGenerationPointCost,
isViewingMatch3DGeneration,
markDraftGenerating,
markDraftFailed,
@@ -14293,6 +14328,9 @@ export function PlatformEntryFlowShellImpl({
onOpenShelfItem={(item) => {
markDraftNoticeSeen(getGenerationNoticeShelfKeys(item));
}}
onShareWork={(payload) => {
openPublishShareModal(payload);
}}
deletingWorkId={deletingCreationWorkId}
claimingPuzzleProfileId={claimingPuzzlePointIncentiveProfileId}
/>
@@ -14833,7 +14871,10 @@ export function PlatformEntryFlowShellImpl({
>
<UnifiedCreationWorkspace
playId="match3d"
spec={getUnifiedSpec('match3d')}
spec={{
...getUnifiedSpec('match3d'),
mudPointCost: match3DDraftGenerationPointCost,
}}
session={match3dSession}
isBusy={isStreamingMatch3DReply}
error={match3dError}
@@ -15946,7 +15987,10 @@ export function PlatformEntryFlowShellImpl({
>
<UnifiedCreationWorkspace
playId="puzzle"
spec={getUnifiedSpec('puzzle')}
spec={{
...getUnifiedSpec('puzzle'),
mudPointCost: puzzleDraftGenerationPointCost,
}}
session={puzzleSession}
isBusy={isStreamingPuzzleReply}
error={puzzleError}

View File

@@ -16,6 +16,7 @@ export type PlatformCreationTypeCard = {
subtitle: string;
badge: string;
imageSrc: string;
mudPointCost: number;
mudPointCostLabel: string;
locked: boolean;
categoryId: string;
@@ -144,6 +145,9 @@ export function derivePlatformCreationTypes(
subtitle: item.subtitle,
badge: item.badge,
imageSrc: item.imageSrc,
mudPointCost: normalizeMudPointCost(
item.unifiedCreationSpec?.mudPointCost,
),
mudPointCostLabel: formatMudPointCostText(
item.unifiedCreationSpec?.mudPointCost,
),

View File

@@ -300,6 +300,91 @@ function ActionCompleteHarness({
);
}
function BeforeActionHarness({ events }: { events: string[] }) {
const hasOpenedRef = useRef(false);
const flow = usePlatformCreationAgentFlowController<
ActionTestSession,
Record<string, never>,
{ session: ActionTestSession },
TestMessagePayload,
{ action: string },
{ session: ActionTestSession }
>({
client: {
createSession: async () => ({
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
},
}),
getSession: async () => ({
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
},
}),
streamMessage: async () => ({
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
}),
executeAction: async () => {
events.push('executeAction');
return {
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-ready-1' },
},
};
},
selectSession: (response) => response.session,
},
createPayload: {},
workspaceStage: 'match3d-agent-workspace',
resultStage: 'match3d-result',
platformStage: 'platform',
isCompileAction: () => true,
resolveErrorMessage: (error, fallback) =>
error instanceof Error ? error.message : fallback,
errorMessages: {
open: '打开失败',
restoreMissingSession: '缺少会话',
restore: '恢复失败',
submit: '发送失败',
execute: '执行失败',
},
enterCreateTab: () => {},
setSelectionStage: () => {},
beforeExecuteAction: async () => {
events.push('beforeExecuteAction');
await Promise.resolve();
events.push('permissionResolved');
},
});
useEffect(() => {
if (hasOpenedRef.current) {
return;
}
hasOpenedRef.current = true;
void flow.openWorkspace({});
}, [flow]);
return (
<button
type="button"
onClick={() => {
void flow.executeAction({ action: 'match3d_compile_draft' });
}}
>
</button>
);
}
function SessionChangeHarness({
onSessionChanged,
}: {
@@ -547,6 +632,28 @@ test('creation agent flow suppresses compile result stage for background complet
);
});
test('creation agent flow waits for beforeExecuteAction before network action', async () => {
const events: string[] = [];
render(<BeforeActionHarness events={events} />);
await waitFor(() => {
expect(screen.getByRole('button', { name: '执行' })).toBeTruthy();
});
await act(async () => {
screen.getByRole('button', { name: '执行' }).click();
});
await waitFor(() => {
expect(events).toEqual([
'beforeExecuteAction',
'permissionResolved',
'executeAction',
]);
});
});
test('creation agent flow notifies session changes after open restore and compile', async () => {
const onSessionChanged = vi.fn();

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { TextStreamOptions } from '../../services/aiTypes';
import type { SelectionStage } from './platformEntryTypes';
@@ -90,7 +90,7 @@ type PlatformCreationAgentFlowControllerOptions<
beforeExecuteAction?: (params: {
payload: TActionPayload;
session: TSession;
}) => void;
}) => void | Promise<void>;
onActionError?: (params: {
payload: TActionPayload;
error: unknown;
@@ -211,7 +211,7 @@ export function usePlatformCreationAgentFlowController<
setIsBusy(false);
}
},
[isBusy, options, resetStreamingReply],
[isBusy, options, resetStreamingReply, setSession],
);
const restoreDraft = useCallback(
@@ -249,7 +249,7 @@ export function usePlatformCreationAgentFlowController<
setIsBusy(false);
}
},
[options, resetStreamingReply],
[options, resetStreamingReply, setSession],
);
const submitMessage = useCallback(
@@ -309,7 +309,13 @@ export function usePlatformCreationAgentFlowController<
setIsStreamingReply(false);
}
},
[isStreamingReply, options, session, updateStreamingReplyText],
[
isStreamingReply,
options,
session,
setSession,
updateStreamingReplyText,
],
);
const executeAction = useCallback(
@@ -323,7 +329,7 @@ export function usePlatformCreationAgentFlowController<
setError(null);
try {
options.beforeExecuteAction?.({ payload, session: targetSession });
await options.beforeExecuteAction?.({ payload, session: targetSession });
const response = await options.client.executeAction(
targetSession.sessionId,
payload,
@@ -358,7 +364,7 @@ export function usePlatformCreationAgentFlowController<
setIsBusy(false);
}
},
[isBusy, options, session],
[isBusy, options, session, setSession],
);
const leaveFlow = useCallback(() => {

View File

@@ -1237,11 +1237,13 @@ describe('PuzzleResultView', () => {
const picker = await screen.findByRole('dialog', {
name: '选择历史图片',
});
expect(await within(picker).findByText('image.png')).toBeTruthy();
expect(await within(picker).findByText(/2024\/04\/21/u)).toBeTruthy();
expect(within(picker).queryByText('image.png')).toBeNull();
expect(within(picker).queryByText('账号 user-1')).toBeNull();
fireEvent.click(
await within(picker).findByRole('button', { name: /image\.png/u }),
await within(picker).findByRole('button', {
name: /选择2024\/04\/21.*的历史图片/u,
}),
);
await waitFor(() => {

View File

@@ -77,6 +77,7 @@ import { listBigFishWorks } from '../../services/big-fish-works';
import {
type CreationEntryConfig,
fetchCreationEntryConfig,
type UnifiedCreationSpec,
} from '../../services/creationEntryConfigService';
import {
cancelCreativeAgentSession,
@@ -184,6 +185,10 @@ import {
AuthUiContext,
type PlatformSettingsSection,
} from '../auth/AuthUiContext';
import {
getUnifiedCreationSpec,
type UnifiedCreationPlayId,
} from '../unified-creation/unifiedCreationSpecs';
import {
RpgEntryFlowShell,
type RpgEntryFlowShellProps,
@@ -356,6 +361,16 @@ async function findPlatformTabPanel(tab: string) {
return getPlatformTabPanel(tab);
}
function buildTestUnifiedCreationSpec(
playId: UnifiedCreationPlayId,
mudPointCost: number,
): UnifiedCreationSpec {
return {
...getUnifiedCreationSpec(playId),
mudPointCost,
};
}
const testCreationEntryConfig = {
startCard: {
title: '新建作品',
@@ -423,6 +438,7 @@ const testCreationEntryConfig = {
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
unifiedCreationSpec: buildTestUnifiedCreationSpec('puzzle', 8),
},
{
id: 'match3d',
@@ -437,6 +453,7 @@ const testCreationEntryConfig = {
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
unifiedCreationSpec: buildTestUnifiedCreationSpec('match3d', 12),
},
{
id: 'bark-battle',
@@ -451,6 +468,7 @@ const testCreationEntryConfig = {
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
unifiedCreationSpec: buildTestUnifiedCreationSpec('bark-battle', 6),
},
{
id: 'square-hole',
@@ -587,6 +605,7 @@ vi.mock('../../services/rpg-entry', () => ({
}));
vi.mock('../../services/creationEntryConfigService', () => ({
DEFAULT_UNIFIED_CREATION_MUD_POINT_COST: 10,
fetchCreationEntryConfig: vi.fn(),
}));
@@ -3975,7 +3994,7 @@ test('direct bark battle runtime public code opens published runtime', async ()
test('bark battle form checks mud points before creating image assets', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 2,
walletBalance: 5,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
@@ -3992,7 +4011,7 @@ test('bark battle form checks mud points before creating image assets', async ()
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
within(noticeDialog).getByText('本次需要 3 泥点,当前 2 泥点。'),
within(noticeDialog).getByText('本次需要 6 泥点,当前 5 泥点。'),
).toBeTruthy();
expect(screen.getByText('汪汪声浪配置表单')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '创作入口页签' })).toBeNull();
@@ -5327,7 +5346,7 @@ test('puzzle text-only form stays generating when compile starts background imag
test('puzzle form checks mud points before creating a draft', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 1,
walletBalance: 7,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
@@ -5341,7 +5360,7 @@ test('puzzle form checks mud points before creating a draft', async () => {
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
within(noticeDialog).getByText('本次需要 2 泥点,当前 1 泥点。'),
within(noticeDialog).getByText('本次需要 8 泥点,当前 7 泥点。'),
).toBeTruthy();
expect(screen.getByText('拼图工作区missing-session')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '创作入口页签' })).toBeNull();
@@ -5352,7 +5371,7 @@ test('puzzle form checks mud points before creating a draft', async () => {
test('match3d form checks mud points before creating a draft', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 9,
walletBalance: 11,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
@@ -5368,7 +5387,7 @@ test('match3d form checks mud points before creating a draft', async () => {
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
within(noticeDialog).getByText('本次需要 10 泥点,当前 9 泥点。'),
within(noticeDialog).getByText('本次需要 12 泥点,当前 11 泥点。'),
).toBeTruthy();
expect(screen.getByText('抓大鹅工作区missing-session')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '创作入口页签' })).toBeNull();

View File

@@ -68,6 +68,7 @@ export function UnifiedCreationWorkspace(props: UnifiedCreationWorkspaceProps) {
showBackButton={false}
title={null}
unifiedChrome
mudPointCost={props.spec.mudPointCost ?? undefined}
/>
</UnifiedCreationPage>
);
@@ -86,6 +87,7 @@ export function UnifiedCreationWorkspace(props: UnifiedCreationWorkspaceProps) {
showBackButton={false}
title={null}
unifiedChrome
mudPointCost={props.spec.mudPointCost ?? undefined}
/>
</UnifiedCreationPage>
);

View File

@@ -6,10 +6,7 @@ import {
puzzleAssetClient,
type PuzzleHistoryAsset,
} from '../../../services/puzzle-works/puzzleAssetClient';
import {
formatPuzzleHistoryAssetCreatedAt,
getPuzzleHistoryAssetDisplayName,
} from '../../../services/puzzle-works/puzzleHistoryAsset';
import { formatPuzzleHistoryAssetCreatedAt } from '../../../services/puzzle-works/puzzleHistoryAsset';
import { useAuthUi } from '../../auth/AuthUiContext';
import { ResolvedAssetImage } from '../../ResolvedAssetImage';
@@ -116,30 +113,28 @@ export function PuzzleHistoryAssetPickerDialog({
{!isLoading && assets.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{assets.map((asset) => {
const displayName = getPuzzleHistoryAssetDisplayName(
asset.imageSrc,
const createdAtText = formatPuzzleHistoryAssetCreatedAt(
asset.createdAt,
);
return (
<button
key={asset.assetObjectId}
type="button"
disabled={isBusy}
aria-label={`选择${createdAtText}的历史图片`}
onClick={() => onSelect(asset)}
className={`overflow-hidden rounded-[1.25rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`}
>
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={asset.imageSrc}
alt={displayName}
alt=""
className="h-full w-full object-cover"
/>
</div>
<div className="space-y-1 px-4 py-4">
<div className="truncate text-sm font-black text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="px-4 py-3">
<div className="text-xs leading-5 text-[var(--platform-text-base)]">
{formatPuzzleHistoryAssetCreatedAt(asset.createdAt)}
{createdAtText}
</div>
</div>
</button>

View File

@@ -65,6 +65,13 @@ function confirmMatch3DPointCost() {
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
}
function confirmMatch3DPointCostText(text: string) {
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗泥点',
});
expect(within(confirmDialog).getByText(text)).toBeTruthy();
}
test('match3d workspace submits derived entry form payload instead of agent chat', () => {
const onCreateFromForm = vi.fn();
const onExecuteAction = vi.fn();
@@ -112,6 +119,26 @@ test('match3d workspace submits derived entry form payload instead of agent chat
expect(onExecuteAction).not.toHaveBeenCalled();
});
test('match3d workspace shows configured mud point cost', () => {
render(
<Match3DCreationWorkspace
session={null}
onBack={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={() => {}}
mudPointCost={12}
/>,
);
expect(screen.getByText('消耗12泥点')).toBeTruthy();
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
target: { value: '陶泥甜品店' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmMatch3DPointCostText('消耗 12 泥点');
});
test('match3d workspace can defer visible chrome to the unified creation page', () => {
const { container } = render(
<Match3DCreationWorkspace

View File

@@ -20,6 +20,7 @@ type Match3DCreationWorkspaceProps = {
showBackButton?: boolean;
title?: string | null;
unifiedChrome?: boolean;
mudPointCost?: number;
};
type Match3DFormState = {
@@ -117,6 +118,7 @@ export function Match3DCreationWorkspace({
showBackButton = true,
title = '抓大鹅',
unifiedChrome = false,
mudPointCost = 10,
}: Match3DCreationWorkspaceProps) {
const [formState, setFormState] = useState<Match3DFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
@@ -324,7 +326,7 @@ export function Match3DCreationWorkspace({
)}
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
10
{mudPointCost}
</span>
</span>
</button>
@@ -345,7 +347,7 @@ export function Match3DCreationWorkspace({
</div>
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
10
{mudPointCost}
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button

View File

@@ -173,6 +173,13 @@ function confirmPuzzlePointCost() {
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
}
function confirmPuzzlePointCostText(text: string) {
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗泥点',
});
expect(within(confirmDialog).getByText(text)).toBeTruthy();
}
test('puzzle workspace submits the work form instead of agent chat', () => {
const onCreateFromForm = vi.fn();
@@ -216,6 +223,27 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
});
test('puzzle workspace shows configured mud point cost', () => {
render(
<PuzzleCreationWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={() => {}}
mudPointCost={8}
/>,
);
expect(screen.getByText('消耗8泥点')).toBeTruthy();
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一座雨后的陶泥小镇。' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmPuzzlePointCostText('消耗 8 泥点');
});
test('puzzle workspace can defer visible chrome to the unified creation page', () => {
const { container } = render(
<PuzzleCreationWorkspace
@@ -344,11 +372,13 @@ test('puzzle workspace selects a history image from the upload card', async () =
const picker = await screen.findByRole('dialog', {
name: '选择历史图片',
});
expect(await within(picker).findByText('image.png')).toBeTruthy();
expect(await within(picker).findByText(/2024\/04\/21/u)).toBeTruthy();
expect(within(picker).queryByText('image.png')).toBeNull();
expect(within(picker).queryByText('账号 user-1')).toBeNull();
fireEvent.click(
await within(picker).findByRole('button', { name: /image\.png/u }),
await within(picker).findByRole('button', {
name: /2024\/04\/21.*/u,
}),
);
await waitFor(() => {
@@ -622,7 +652,9 @@ test('puzzle workspace submits history image when AI redraw is off', async () =>
name: '选择历史图片',
});
fireEvent.click(
await within(picker).findByRole('button', { name: /image\.png/u }),
await within(picker).findByRole('button', {
name: /2024\/04\/21.*/u,
}),
);
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
@@ -636,9 +668,9 @@ test('puzzle workspace submits history image when AI redraw is off', async () =>
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '历史素材 · image.png',
workDescription: '历史素材 · image.png',
pictureDescription: '历史素材 · image.png',
seedText: '历史素材',
workDescription: '历史素材',
pictureDescription: '历史素材',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-history-1',

View File

@@ -50,6 +50,7 @@ type PuzzleCreationWorkspaceProps = {
showBackButton?: boolean;
title?: string | null;
unifiedChrome?: boolean;
mudPointCost?: number;
};
type PuzzleFormState = {
@@ -248,6 +249,7 @@ export function PuzzleCreationWorkspace({
showBackButton = true,
title = '拼图',
unifiedChrome = false,
mudPointCost = 2,
}: PuzzleCreationWorkspaceProps) {
const [formState, setFormState] = useState<PuzzleFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
@@ -667,7 +669,7 @@ export function PuzzleCreationWorkspace({
inputError={referenceImageError}
error={error}
submitLabel="生成拼图游戏草稿"
submitCostLabel={formState.aiRedraw ? '消耗2泥点' : null}
submitCostLabel={formState.aiRedraw ? `消耗${mudPointCost}泥点` : null}
submitDisabled={!canSubmit}
labels={{
imageField: '拼图画面',
@@ -760,7 +762,7 @@ export function PuzzleCreationWorkspace({
</div>
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
2
{mudPointCost}
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button