再次合并 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

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

@@ -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 '历史素材';
}

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

View 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);
}