再次合并 master
合入 origin/master 最新订阅消息与计费相关更新 保留作品架 actions 收口并接入统一分享弹窗 修复创作生成泥点预检与本地余额扣减回归
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2528,19 +2528,32 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--platform-cool-border) 58%, transparent);
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-soft);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--platform-neutral-bg) 84%,
|
||||
var(--platform-cool-bg) 16%
|
||||
);
|
||||
box-shadow: 0 8px 18px rgba(91, 64, 42, 0.13);
|
||||
color: var(--platform-cool-text);
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
color 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.creation-work-card__quick-action-button:hover {
|
||||
transform: translateY(-1px);
|
||||
background: color-mix(in srgb, var(--platform-cool-bg) 24%, transparent);
|
||||
border-color: color-mix(in srgb, var(--platform-cool-border) 78%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--platform-neutral-bg) 72%,
|
||||
var(--platform-cool-bg) 28%
|
||||
);
|
||||
box-shadow: 0 10px 22px rgba(91, 64, 42, 0.18);
|
||||
color: var(--platform-cool-text);
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,17 @@ describe('index stylesheet unread dots', () => {
|
||||
expect(block).not.toContain('rgba(239, 68, 68');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the creation shelf share button on a visible surface', () => {
|
||||
const css = readIndexCss();
|
||||
const block = getCssBlock(css, '.creation-work-card__quick-action-button');
|
||||
|
||||
expect(block).toContain('border: 1px solid');
|
||||
expect(block).toContain('background: color-mix(');
|
||||
expect(block).toContain('var(--platform-neutral-bg)');
|
||||
expect(block).toContain('var(--platform-cool-bg)');
|
||||
expect(block).not.toContain('background: transparent;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('index stylesheet draft mobile cards', () => {
|
||||
|
||||
@@ -172,6 +172,7 @@ describe('barkBattleCreationClient', () => {
|
||||
body: JSON.stringify({
|
||||
slot: 'player-character',
|
||||
draftId: 'draft-1',
|
||||
billingPurpose: null,
|
||||
config: {
|
||||
title: '汪汪冠军杯',
|
||||
description: '',
|
||||
@@ -224,5 +225,16 @@ describe('barkBattleCreationClient', () => {
|
||||
'opponent-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
|
||||
'ui-background': '场景图片生成失败:上游超时',
|
||||
});
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/creation/bark-battle/images/generate',
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining(
|
||||
'"billingPurpose":"initial_draft_generation"',
|
||||
),
|
||||
}),
|
||||
'生成汪汪声浪素材失败',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -376,10 +376,12 @@ export function regenerateBarkBattleImageAsset(payload: {
|
||||
slot: BarkBattleAssetSlot;
|
||||
config: BarkBattleConfigEditorPayload;
|
||||
draftId?: string | null;
|
||||
billingPurpose?: BarkBattleImageAssetGenerateRequest['billingPurpose'];
|
||||
}): Promise<BarkBattleGeneratedImageAsset> {
|
||||
const request: BarkBattleImageAssetGenerateRequest = {
|
||||
slot: payload.slot,
|
||||
draftId: payload.draftId ?? null,
|
||||
billingPurpose: payload.billingPurpose ?? null,
|
||||
config: payload.config,
|
||||
};
|
||||
return requestJson<BarkBattleGeneratedImageAsset>(
|
||||
@@ -418,6 +420,7 @@ export async function generateAllBarkBattleImageAssets(payload: {
|
||||
slot,
|
||||
config: payload.config,
|
||||
draftId: payload.draftId,
|
||||
billingPurpose: 'initial_draft_generation',
|
||||
}),
|
||||
slot,
|
||||
)
|
||||
|
||||
@@ -72,8 +72,9 @@ export function isManualMockPaymentChannel(paymentChannel: string) {
|
||||
return paymentChannel.trim() === MOCK_PAYMENT_CHANNEL;
|
||||
}
|
||||
|
||||
function isWechatMiniProgramRuntime(
|
||||
location: Pick<Location, 'search'> | null | undefined,
|
||||
export function isWechatMiniProgramRuntime(
|
||||
location: Pick<Location, 'search'> | null | undefined =
|
||||
typeof window !== 'undefined' ? window.location : null,
|
||||
) {
|
||||
const params = new URLSearchParams(location?.search ?? '');
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
const PUZZLE_HISTORY_ASSET_FALLBACK_NAME = '历史拼图素材';
|
||||
|
||||
function safeDecodePathSegment(value: string) {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePuzzleHistoryTimestamp(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
@@ -47,24 +37,6 @@ function parsePuzzleHistoryTimestamp(value: string) {
|
||||
return new Date(timestampMs);
|
||||
}
|
||||
|
||||
export function getPuzzleHistoryAssetDisplayName(
|
||||
imageSrc: string | null | undefined,
|
||||
) {
|
||||
const trimmed = imageSrc?.trim() ?? '';
|
||||
if (!trimmed) {
|
||||
return PUZZLE_HISTORY_ASSET_FALLBACK_NAME;
|
||||
}
|
||||
|
||||
const pathOnly = trimmed.split(/[?#]/u)[0]?.trim() ?? '';
|
||||
if (!pathOnly) {
|
||||
return PUZZLE_HISTORY_ASSET_FALLBACK_NAME;
|
||||
}
|
||||
|
||||
const fileName = pathOnly.replace(/^\/+/u, '').split('/').filter(Boolean).pop();
|
||||
const displayName = safeDecodePathSegment(fileName ?? '').trim();
|
||||
return displayName || PUZZLE_HISTORY_ASSET_FALLBACK_NAME;
|
||||
}
|
||||
|
||||
export function formatPuzzleHistoryAssetCreatedAt(value: string) {
|
||||
const parsedDate = parsePuzzleHistoryTimestamp(value);
|
||||
if (!parsedDate) {
|
||||
@@ -82,12 +54,7 @@ export function formatPuzzleHistoryAssetCreatedAt(value: string) {
|
||||
}
|
||||
|
||||
export function getPuzzleHistoryAssetReferenceLabel(
|
||||
imageSrc: string | null | undefined,
|
||||
_imageSrc: string | null | undefined,
|
||||
) {
|
||||
const displayName = getPuzzleHistoryAssetDisplayName(imageSrc);
|
||||
if (displayName === PUZZLE_HISTORY_ASSET_FALLBACK_NAME) {
|
||||
return '历史素材';
|
||||
}
|
||||
|
||||
return `历史素材 · ${displayName}`;
|
||||
return '历史素材';
|
||||
}
|
||||
|
||||
104
src/services/wechatMiniProgramSubscribe.test.ts
Normal file
104
src/services/wechatMiniProgramSubscribe.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
requestGenerationResultSubscribePermission,
|
||||
} from './wechatMiniProgramSubscribe';
|
||||
|
||||
describe('wechatMiniProgramSubscribe', () => {
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, '', '/');
|
||||
window.wx = undefined;
|
||||
});
|
||||
|
||||
test('requests generation result subscription permission through native mini program page and resumes generation after return', async () => {
|
||||
const navigateTo = vi.fn((options) => {
|
||||
options.success?.();
|
||||
window.setTimeout(() => {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
}, 0);
|
||||
});
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/creation/puzzle?clientRuntime=wechat_mini_program',
|
||||
);
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
navigateTo,
|
||||
},
|
||||
};
|
||||
|
||||
const requested = await requestGenerationResultSubscribePermission();
|
||||
|
||||
expect(requested).toBe(true);
|
||||
expect(navigateTo).toHaveBeenCalledWith({
|
||||
url: expect.stringMatching(/^\/pages\/subscribe-message\/index\?/u),
|
||||
success: expect.any(Function),
|
||||
fail: expect.any(Function),
|
||||
});
|
||||
expect(navigateTo.mock.calls[0]?.[0].url).not.toContain('autoRequest=1');
|
||||
expect(window.location.hash).toBe('');
|
||||
});
|
||||
|
||||
test('keeps waiting even when native page returns immediately after navigate success', async () => {
|
||||
const navigateTo = vi.fn((options) => {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
options.success?.();
|
||||
});
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/creation/puzzle?clientRuntime=wechat_mini_program',
|
||||
);
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
navigateTo,
|
||||
},
|
||||
};
|
||||
|
||||
const requested = await requestGenerationResultSubscribePermission();
|
||||
|
||||
expect(requested).toBe(true);
|
||||
});
|
||||
|
||||
test('still accepts legacy hash result from native mini program page', async () => {
|
||||
const navigateTo = vi.fn((options) => {
|
||||
options.success?.();
|
||||
window.setTimeout(() => {
|
||||
window.location.hash = 'wx_subscribe_result=req-1:success';
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
}, 0);
|
||||
});
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/creation/puzzle?clientRuntime=wechat_mini_program',
|
||||
);
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
navigateTo,
|
||||
},
|
||||
};
|
||||
|
||||
const requested = await requestGenerationResultSubscribePermission();
|
||||
|
||||
expect(requested).toBe(true);
|
||||
expect(window.location.hash).toBe('');
|
||||
});
|
||||
|
||||
test('skips permission request outside mini program web-view', async () => {
|
||||
const navigateTo = vi.fn();
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
navigateTo,
|
||||
},
|
||||
};
|
||||
|
||||
const requested = await requestGenerationResultSubscribePermission();
|
||||
|
||||
expect(requested).toBe(false);
|
||||
expect(navigateTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
175
src/services/wechatMiniProgramSubscribe.ts
Normal file
175
src/services/wechatMiniProgramSubscribe.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { isWechatMiniProgramRuntime } from './payment/paymentPlatform';
|
||||
|
||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const SUBSCRIBE_RESULT_HASH_KEY = 'wx_subscribe_result';
|
||||
const SUBSCRIBE_RESULT_TIMEOUT_MS = 2_500;
|
||||
const SUBSCRIBE_RESULT_RETURN_FALLBACK_MS = 800;
|
||||
|
||||
function clearSubscribeResultHash() {
|
||||
const rawHash = window.location.hash.replace(/^#/, '');
|
||||
if (!rawHash.includes(`${SUBSCRIBE_RESULT_HASH_KEY}=`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(rawHash);
|
||||
params.delete(SUBSCRIBE_RESULT_HASH_KEY);
|
||||
const nextHash = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}${nextHash ? `#${nextHash}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
function readSubscribeResultFromHash() {
|
||||
const value = new URLSearchParams(window.location.hash.replace(/^#/, '')).get(
|
||||
SUBSCRIBE_RESULT_HASH_KEY,
|
||||
);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
clearSubscribeResultHash();
|
||||
return value;
|
||||
}
|
||||
|
||||
function waitSubscribeResultFromHash(timeoutMs = SUBSCRIBE_RESULT_TIMEOUT_MS) {
|
||||
const immediateResult = readSubscribeResultFromHash();
|
||||
if (immediateResult) {
|
||||
return Promise.resolve(immediateResult);
|
||||
}
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
let timer: number | null = null;
|
||||
let resumeFallbackTimer: number | null = null;
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('hashchange', handleHashChange);
|
||||
window.removeEventListener('focus', handleResume);
|
||||
window.removeEventListener('pageshow', handleResume);
|
||||
document.removeEventListener('visibilitychange', handleResume);
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
if (resumeFallbackTimer !== null) {
|
||||
window.clearTimeout(resumeFallbackTimer);
|
||||
}
|
||||
};
|
||||
const finish = (result: string | null) => {
|
||||
cleanup();
|
||||
resolve(result);
|
||||
};
|
||||
const consume = () => {
|
||||
const result = readSubscribeResultFromHash();
|
||||
if (result) {
|
||||
finish(result);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const handleHashChange = () => {
|
||||
consume();
|
||||
};
|
||||
const handleResume = () => {
|
||||
if (
|
||||
typeof document !== 'undefined' &&
|
||||
document.visibilityState === 'hidden'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (consume()) {
|
||||
return;
|
||||
}
|
||||
// 中文注释:订阅授权只影响后续通知,不应阻断生成;原生页返回但没有 hash
|
||||
// 回灌时,按已返回处理,让原本的生成提交流程继续执行。
|
||||
if (resumeFallbackTimer === null) {
|
||||
resumeFallbackTimer = window.setTimeout(
|
||||
() => finish('returned'),
|
||||
SUBSCRIBE_RESULT_RETURN_FALLBACK_MS,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
window.addEventListener('focus', handleResume);
|
||||
window.addEventListener('pageshow', handleResume);
|
||||
document.addEventListener('visibilitychange', handleResume);
|
||||
timer = window.setTimeout(() => finish(null), timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
function loadWechatJsSdk() {
|
||||
if (
|
||||
!isWechatMiniProgramRuntime() ||
|
||||
typeof window === 'undefined'
|
||||
) {
|
||||
return Promise.reject(new Error('not_mini_program'));
|
||||
}
|
||||
if (window.wx?.miniProgram?.navigateTo) {
|
||||
return Promise.resolve(window.wx);
|
||||
}
|
||||
|
||||
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`script[src="${WECHAT_JS_SDK_URL}"]`,
|
||||
);
|
||||
const complete = () => {
|
||||
if (window.wx?.miniProgram?.navigateTo) {
|
||||
resolve(window.wx);
|
||||
} else {
|
||||
reject(new Error('wechat_js_sdk_unavailable'));
|
||||
}
|
||||
};
|
||||
|
||||
if (existingScript) {
|
||||
existingScript.addEventListener('load', complete, { once: true });
|
||||
existingScript.addEventListener('error', () => reject(new Error('wechat_js_sdk_load_failed')), {
|
||||
once: true,
|
||||
});
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = WECHAT_JS_SDK_URL;
|
||||
script.async = true;
|
||||
script.onload = complete;
|
||||
script.onerror = () => reject(new Error('wechat_js_sdk_load_failed'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestGenerationResultSubscribePermission() {
|
||||
if (!isWechatMiniProgramRuntime() || typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let wxBridge: NonNullable<Window['wx']>;
|
||||
try {
|
||||
wxBridge = await loadWechatJsSdk();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const miniProgram = wxBridge.miniProgram;
|
||||
if (!miniProgram || typeof miniProgram.navigateTo !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const requestId = `subscribe_generation_result_${Date.now()}`;
|
||||
const resultPromise = waitSubscribeResultFromHash();
|
||||
const navigated = await new Promise<boolean>((resolve) => {
|
||||
miniProgram.navigateTo?.({
|
||||
url: `/pages/subscribe-message/index?requestId=${encodeURIComponent(requestId)}&scene=generation-result`,
|
||||
success() {
|
||||
resolve(true);
|
||||
},
|
||||
fail() {
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
if (!navigated) {
|
||||
return false;
|
||||
}
|
||||
const result = await resultPromise;
|
||||
return Boolean(result);
|
||||
}
|
||||
Reference in New Issue
Block a user