Merge remote-tracking branch 'origin/master' into feat/video-BGfilter
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -23,7 +23,10 @@ import type {
|
||||
import { createQuickEditPanelDraft } from './ImageCanvasGenerationDialogModel';
|
||||
import type { UiAssetExtractionState } from './ImageCanvasUiAssetExtractionModel';
|
||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGenerationSubmissionWorkflow';
|
||||
import {
|
||||
applyQueuedEditorGenerationProject,
|
||||
useImageCanvasGenerationSubmissionWorkflow,
|
||||
} from './useImageCanvasGenerationSubmissionWorkflow';
|
||||
|
||||
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
|
||||
const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() =>
|
||||
@@ -1455,6 +1458,82 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reloads a queued project snapshot again when the completion dialog is still unresolved', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
const refreshTaskList = vi.fn();
|
||||
const refreshWalletBalance = vi.fn();
|
||||
const unresolvedProject = {
|
||||
projectId: 'editor-project-1',
|
||||
title: '队列项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'generation-dialog-background-removal',
|
||||
resourceId: 'generation-dialog-background-removal',
|
||||
itemType: 'generation-dialog',
|
||||
dialog: {
|
||||
id: 'dialog-background-removal',
|
||||
status: 'generating',
|
||||
},
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-23T08:00:00.000Z',
|
||||
};
|
||||
const completedProject = {
|
||||
...unresolvedProject,
|
||||
layers: [
|
||||
{
|
||||
layerId: 'generation-dialog-background-removal',
|
||||
resourceId: 'generation-dialog-background-removal',
|
||||
itemType: 'generation-dialog',
|
||||
dialog: {
|
||||
id: 'dialog-background-removal',
|
||||
status: 'idle',
|
||||
generatedLayerId: 'layer-background-removal-result',
|
||||
},
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-23T08:00:01.000Z',
|
||||
};
|
||||
loadEditorProjectMock
|
||||
.mockResolvedValueOnce(unresolvedProject)
|
||||
.mockResolvedValueOnce(completedProject);
|
||||
|
||||
const applyPromise = applyQueuedEditorGenerationProject(
|
||||
{ queueState: createQueueState() },
|
||||
'editor-project-1',
|
||||
applyProjectSnapshot,
|
||||
refreshTaskList,
|
||||
refreshWalletBalance,
|
||||
'dialog-background-removal',
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(refreshTaskList).toHaveBeenCalledTimes(1);
|
||||
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
|
||||
expect(loadEditorProjectMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(650);
|
||||
await applyPromise;
|
||||
|
||||
expect(loadEditorProjectMock).toHaveBeenCalledTimes(2);
|
||||
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
unresolvedProject,
|
||||
);
|
||||
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
completedProject,
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a queued generation pending when the frontend queue wait expires', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -101,6 +101,7 @@ type UiDesignAssetExtractionOptions = {
|
||||
|
||||
const EDITOR_GENERATION_QUEUE_POLL_INTERVAL_MS = 1600;
|
||||
const EDITOR_GENERATION_QUEUE_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const EDITOR_GENERATION_QUEUE_PROJECT_REFRESH_RETRY_MS = 650;
|
||||
|
||||
type GenerationSubmissionWorkflowOptions = {
|
||||
layers: CanvasLayer[];
|
||||
@@ -176,6 +177,31 @@ function queuedStateFromResponse(
|
||||
return response?.queueState ?? null;
|
||||
}
|
||||
|
||||
function projectHasUnresolvedGenerationDialog(
|
||||
project: EditorProjectSnapshot,
|
||||
dialogId: string | null | undefined,
|
||||
) {
|
||||
const normalizedDialogId = dialogId?.trim();
|
||||
if (!normalizedDialogId) {
|
||||
return false;
|
||||
}
|
||||
return project.layers.some((item) => {
|
||||
if (item.itemType !== 'generation-dialog') {
|
||||
return false;
|
||||
}
|
||||
const dialog =
|
||||
item.dialog && typeof item.dialog === 'object'
|
||||
? (item.dialog as Record<string, unknown>)
|
||||
: null;
|
||||
return (
|
||||
dialog?.id === normalizedDialogId &&
|
||||
(dialog.status === 'generating' ||
|
||||
typeof dialog.generatedLayerId !== 'string' ||
|
||||
dialog.generatedLayerId.trim() === '')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function notifyWalletBalanceMayHaveChanged(callback?: () => void) {
|
||||
callback?.();
|
||||
}
|
||||
@@ -224,6 +250,8 @@ export async function applyQueuedEditorGenerationProject(
|
||||
applyProjectSnapshot: ((project: EditorProjectSnapshot) => void) | undefined,
|
||||
onQueuedGenerationTask?: () => void,
|
||||
onWalletBalanceMayHaveChanged?: () => void,
|
||||
completionDialogId?: string | null,
|
||||
transformProjectSnapshot?: (project: EditorProjectSnapshot) => EditorProjectSnapshot,
|
||||
) {
|
||||
const queueState = queuedStateFromResponse(response);
|
||||
if (!queueState) {
|
||||
@@ -240,7 +268,15 @@ export async function applyQueuedEditorGenerationProject(
|
||||
return true;
|
||||
}
|
||||
if (projectId && applyProjectSnapshot) {
|
||||
applyProjectSnapshot(await loadEditorProject(projectId));
|
||||
const applyLoadedProject = (project: EditorProjectSnapshot) => {
|
||||
applyProjectSnapshot(transformProjectSnapshot?.(project) ?? project);
|
||||
};
|
||||
const project = await loadEditorProject(projectId);
|
||||
applyLoadedProject(project);
|
||||
if (projectHasUnresolvedGenerationDialog(project, completionDialogId)) {
|
||||
await delay(EDITOR_GENERATION_QUEUE_PROJECT_REFRESH_RETRY_MS);
|
||||
applyLoadedProject(await loadEditorProject(projectId));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
|
||||
const generateEditorSoundEffectMock = vi.hoisted(() => vi.fn());
|
||||
const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn());
|
||||
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
const renderCropExpandImageMock = vi.hoisted(() => vi.fn());
|
||||
const removeImageBackgroundMock = vi.hoisted(() => vi.fn());
|
||||
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
|
||||
@@ -46,6 +48,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
return {
|
||||
...actual,
|
||||
editEditorImage: editEditorImageMock,
|
||||
createEditorProjectResource: createEditorProjectResourceMock,
|
||||
generateEditorBackgroundMusic: generateEditorBackgroundMusicMock,
|
||||
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
|
||||
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
|
||||
@@ -54,6 +57,10 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||||
}));
|
||||
|
||||
vi.mock('./ImageCanvasRasterEditModel', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('./ImageCanvasRasterEditModel')
|
||||
@@ -190,6 +197,14 @@ function GenerationWorkflowHarness({
|
||||
)
|
||||
.join('|')}
|
||||
</span>
|
||||
<span data-testid="layer-resources">
|
||||
{layers
|
||||
.map(
|
||||
(layer) =>
|
||||
`${layer.id}:${layer.resourceId}:${layer.objectKey ?? '-'}:${layer.assetObjectId ?? '-'}`,
|
||||
)
|
||||
.join('|')}
|
||||
</span>
|
||||
<span data-testid="dialog">
|
||||
{activeDialog
|
||||
? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}`
|
||||
@@ -823,6 +838,27 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
resolveEditorImageReferenceDataUrlMock.mockImplementation(
|
||||
async (src: string) => src,
|
||||
);
|
||||
uploadEditorMediaAssetFileMock.mockResolvedValue({
|
||||
src: 'https://signed.example.test/crop-expand.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
assetObjectId: 'asset-object-crop-expand',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
});
|
||||
createEditorProjectResourceMock.mockResolvedValue({
|
||||
resourceId: 'resource-crop-expand',
|
||||
projectId: 'project-1',
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
assetObjectId: 'asset-object-crop-expand',
|
||||
width: 380,
|
||||
height: 280,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-source',
|
||||
});
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
@@ -1662,6 +1698,63 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
expect(screen.getByTestId('fit-count').textContent).toBe('1');
|
||||
});
|
||||
|
||||
it('uploads a project crop-expand result before adding it to the canvas', async () => {
|
||||
renderCropExpandImageMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,Y3JvcC1leHBhbmRlZA==',
|
||||
width: 380,
|
||||
height: 280,
|
||||
});
|
||||
render(<GenerationWorkflowHarness projectId="project-1" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开裁扩' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '拖拽裁扩边界' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '完成裁扩' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
'image',
|
||||
expect.objectContaining({
|
||||
assetKind: 'editor_crop_expand_image',
|
||||
entityId: 'project-1',
|
||||
metadata: {
|
||||
editor_project_id: 'project-1',
|
||||
source_resource_id: 'resource-source',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
const uploadCallOrder =
|
||||
uploadEditorMediaAssetFileMock.mock.invocationCallOrder[0];
|
||||
const resourceCreateCallOrder =
|
||||
createEditorProjectResourceMock.mock.invocationCallOrder[0];
|
||||
expect(uploadCallOrder).toBeDefined();
|
||||
expect(resourceCreateCallOrder).toBeDefined();
|
||||
expect(uploadCallOrder!).toBeLessThan(resourceCreateCallOrder!);
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
||||
'project-1',
|
||||
expect.objectContaining({
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
assetObjectId: 'asset-object-crop-expand',
|
||||
width: 380,
|
||||
height: 280,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-source',
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layer-resources').textContent).toContain(
|
||||
'layer-crop-expand-1:resource-crop-expand:generated-character-drafts/editor/crop-expand/project-1/result.png:asset-object-crop-expand',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||||
'layer-crop-expand-1:源图 裁扩:resource-source:-:data:image',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves private character image before removing background', async () => {
|
||||
resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce(
|
||||
'data:image/png;base64,resolved-character',
|
||||
@@ -1775,6 +1868,115 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a just-created crop layer when applying a background removal project snapshot', async () => {
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
let resolveBackgroundRemoval: ((value: unknown) => void) | null = null;
|
||||
removeImageBackgroundMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveBackgroundRemoval = resolve;
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
id: 'layer-crop-expand-1',
|
||||
resourceId: 'resource-crop-expand',
|
||||
title: '源图 裁扩',
|
||||
src: '/generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
assetObjectId: 'asset-object-crop-expand',
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-source',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '去除背景' }));
|
||||
|
||||
await waitFor(() => expect(removeImageBackgroundMock).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
resolveBackgroundRemoval?.({
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/background-removal/project-result.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/background-removal/project-result.png',
|
||||
assetObjectId: 'asset-object-background-removal',
|
||||
width: 512,
|
||||
height: 768,
|
||||
taskId: 'background-removal-project-task',
|
||||
elapsedMs: 1234,
|
||||
provider: 'BiRefNet',
|
||||
project: {
|
||||
projectId: 'project-1',
|
||||
title: '队列项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-background-removal-result',
|
||||
resourceId: 'resource-background-removal',
|
||||
title: '源图 裁扩 去背景',
|
||||
x: 480,
|
||||
y: 140,
|
||||
width: 512,
|
||||
height: 768,
|
||||
originalWidth: 512,
|
||||
originalHeight: 768,
|
||||
zIndex: 9,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-background-removal',
|
||||
projectId: 'project-1',
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/background-removal/project-result.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/background-removal/project-result.png',
|
||||
assetObjectId: 'asset-object-background-removal',
|
||||
width: 512,
|
||||
height: 768,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-crop-expand',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-23T08:00:01.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const appliedProject = applyProjectSnapshot.mock.calls[0]?.[0];
|
||||
expect(appliedProject?.layers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-crop-expand-1',
|
||||
resourceId: 'resource-crop-expand',
|
||||
title: '源图 裁扩',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-background-removal-result',
|
||||
resourceId: 'resource-background-removal',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(appliedProject?.resources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-crop-expand',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
assetObjectId: 'asset-object-crop-expand',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens UI design extraction as a mark selection state before submitting', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
EditorProjectSnapshot,
|
||||
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
|
||||
import {
|
||||
createEditorProjectResource,
|
||||
type EditorAssetSnapshot,
|
||||
type EditorProjectLayerSnapshot,
|
||||
type EditorProjectResourceSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
@@ -33,6 +36,7 @@ import type {
|
||||
SpecFormValues,
|
||||
SpecGenerationType,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
|
||||
import {
|
||||
appendCharacterReference,
|
||||
appendGenerationReference,
|
||||
@@ -125,6 +129,103 @@ type RememberedImageGenerationOptions = {
|
||||
imageSize: string;
|
||||
};
|
||||
|
||||
function dataUrlToImageFile(dataUrl: string, fileName: string) {
|
||||
const [header = '', payload = ''] = dataUrl.split(',');
|
||||
const mimeMatch = /^data:([^;]+)(;base64)?$/iu.exec(header);
|
||||
const type = mimeMatch?.[1] ?? 'image/png';
|
||||
const isBase64 = Boolean(mimeMatch?.[2]);
|
||||
const binary = isBase64
|
||||
? typeof atob === 'function'
|
||||
? atob(payload)
|
||||
: Buffer.from(payload, 'base64').toString('binary')
|
||||
: decodeURIComponent(payload);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return new File([bytes], fileName, { type });
|
||||
}
|
||||
|
||||
function createProjectResourceSnapshotFromLayer(
|
||||
projectId: string,
|
||||
layer: CanvasLayer,
|
||||
): EditorProjectResourceSnapshot | null {
|
||||
const imageSrc = layer.objectKey?.trim()
|
||||
? `/${layer.objectKey.trim().replace(/^\/+/u, '')}`
|
||||
: layer.src.trim();
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
resourceId: layer.resourceId,
|
||||
projectId,
|
||||
label: layer.title,
|
||||
imageSrc,
|
||||
objectKey: layer.objectKey,
|
||||
assetObjectId: layer.assetObjectId,
|
||||
width: layer.originalWidth,
|
||||
height: layer.originalHeight,
|
||||
sourceType: layer.sourceType,
|
||||
prompt: layer.prompt,
|
||||
actualPrompt: layer.actualPrompt,
|
||||
model: layer.model,
|
||||
provider: layer.provider,
|
||||
taskId: layer.taskId,
|
||||
durationSeconds: layer.durationSeconds,
|
||||
sourceResourceId: layer.sourceResourceId,
|
||||
assetKind: layer.assetKind,
|
||||
generationInputs: layer.generationInputs,
|
||||
};
|
||||
}
|
||||
|
||||
function createProjectLayerSnapshotFromLayer(
|
||||
layer: CanvasLayer,
|
||||
): EditorProjectLayerSnapshot {
|
||||
return {
|
||||
layerId: layer.id,
|
||||
resourceId: layer.resourceId,
|
||||
title: layer.title,
|
||||
x: layer.x,
|
||||
y: layer.y,
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
originalWidth: layer.originalWidth,
|
||||
originalHeight: layer.originalHeight,
|
||||
zIndex: layer.zIndex,
|
||||
sourceType: layer.sourceType,
|
||||
sourceResourceId: layer.sourceResourceId,
|
||||
assetKind: layer.assetKind,
|
||||
mediaType: layer.mediaType,
|
||||
objectKey: layer.objectKey,
|
||||
assetObjectId: layer.assetObjectId,
|
||||
durationSeconds: layer.durationSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function preserveSourceLayerInProjectSnapshot(
|
||||
project: EditorProjectSnapshot,
|
||||
sourceLayer: CanvasLayer,
|
||||
): EditorProjectSnapshot {
|
||||
if (
|
||||
project.layers.some((layer) => layer.layerId === sourceLayer.id) ||
|
||||
sourceLayer.resourceId.startsWith('generation-dialog:')
|
||||
) {
|
||||
return project;
|
||||
}
|
||||
const sourceResource = project.resources.some(
|
||||
(resource) => resource.resourceId === sourceLayer.resourceId,
|
||||
)
|
||||
? null
|
||||
: createProjectResourceSnapshotFromLayer(project.projectId, sourceLayer);
|
||||
return {
|
||||
...project,
|
||||
resources: sourceResource
|
||||
? [...project.resources, sourceResource]
|
||||
: project.resources,
|
||||
layers: [createProjectLayerSnapshotFromLayer(sourceLayer), ...project.layers],
|
||||
};
|
||||
}
|
||||
|
||||
function findSourceGenerationDialog(
|
||||
dialogs: CanvasGenerationDialogState[],
|
||||
sourceLayer: CanvasLayer,
|
||||
@@ -1323,12 +1424,77 @@ export function useImageCanvasGenerationWorkflow({
|
||||
});
|
||||
layerCounterRef.current += 1;
|
||||
const cropExpandIndex = layerCounterRef.current;
|
||||
const cropExpandTitle = `${cropExpandSourceLayer.title} 裁扩`;
|
||||
const cropExpandProjectId = projectId?.trim();
|
||||
let cropExpandResourceId = `local-resource-crop-expand-${cropExpandIndex}`;
|
||||
let cropExpandImageSrc = result.imageSrc;
|
||||
let cropExpandObjectKey: string | null = null;
|
||||
let cropExpandAssetObjectId: string | null = null;
|
||||
let cropExpandSourceResourceId: string | null =
|
||||
cropExpandSourceLayer.resourceId;
|
||||
let cropExpandAssetKind = cropExpandSourceLayer.assetKind;
|
||||
if (cropExpandProjectId) {
|
||||
const cropExpandFile = dataUrlToImageFile(
|
||||
result.imageSrc,
|
||||
`crop-expand-${cropExpandIndex}.png`,
|
||||
);
|
||||
const uploadedCropExpand = await uploadEditorMediaAssetFile(
|
||||
cropExpandFile,
|
||||
'image',
|
||||
{
|
||||
assetKind: 'editor_crop_expand_image',
|
||||
pathSegments: [
|
||||
'editor',
|
||||
'crop-expand',
|
||||
cropExpandProjectId,
|
||||
`${Date.now()}`,
|
||||
],
|
||||
entityId: cropExpandProjectId,
|
||||
metadata: {
|
||||
editor_project_id: cropExpandProjectId,
|
||||
source_resource_id: cropExpandSourceLayer.resourceId,
|
||||
},
|
||||
},
|
||||
);
|
||||
const cropExpandResource = await createEditorProjectResource(
|
||||
cropExpandProjectId,
|
||||
{
|
||||
imageSrc: uploadedCropExpand.legacyPublicPath,
|
||||
objectKey: uploadedCropExpand.objectKey,
|
||||
assetObjectId: uploadedCropExpand.assetObjectId,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
sourceType: 'generated',
|
||||
prompt: cropExpandSourceLayer.prompt,
|
||||
actualPrompt: cropExpandSourceLayer.actualPrompt,
|
||||
model: cropExpandSourceLayer.model,
|
||||
provider: cropExpandSourceLayer.provider,
|
||||
taskId: cropExpandSourceLayer.taskId,
|
||||
sourceResourceId: cropExpandSourceLayer.resourceId,
|
||||
assetKind: cropExpandSourceLayer.assetKind,
|
||||
generationInputs: cropExpandSourceLayer.generationInputs,
|
||||
},
|
||||
);
|
||||
cropExpandResourceId = cropExpandResource.resourceId;
|
||||
cropExpandImageSrc = cropExpandResource.imageSrc;
|
||||
cropExpandObjectKey =
|
||||
cropExpandResource.objectKey ?? uploadedCropExpand.objectKey;
|
||||
cropExpandAssetObjectId =
|
||||
cropExpandResource.assetObjectId ??
|
||||
uploadedCropExpand.assetObjectId;
|
||||
cropExpandSourceResourceId =
|
||||
cropExpandResource.sourceResourceId ??
|
||||
cropExpandSourceLayer.resourceId;
|
||||
cropExpandAssetKind =
|
||||
(cropExpandResource.assetKind as CanvasLayer['assetKind']) ??
|
||||
cropExpandSourceLayer.assetKind;
|
||||
}
|
||||
const nextLayer: CanvasLayer = {
|
||||
...cropExpandSourceLayer,
|
||||
id: `layer-crop-expand-${cropExpandIndex}`,
|
||||
resourceId: `local-resource-crop-expand-${cropExpandIndex}`,
|
||||
title: `${cropExpandSourceLayer.title} 裁扩`,
|
||||
src: result.imageSrc,
|
||||
resourceId: cropExpandResourceId,
|
||||
title: cropExpandTitle,
|
||||
src: cropExpandImageSrc,
|
||||
x: cropExpandSourceLayer.x + cropExpandSourceLayer.width + 32,
|
||||
y: cropExpandSourceLayer.y,
|
||||
width: result.width,
|
||||
@@ -1337,10 +1503,11 @@ export function useImageCanvasGenerationWorkflow({
|
||||
originalHeight: result.height,
|
||||
zIndex: cropExpandIndex + 10,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: cropExpandSourceLayer.resourceId,
|
||||
objectKey: null,
|
||||
assetObjectId: null,
|
||||
sourceResourceId: cropExpandSourceResourceId,
|
||||
objectKey: cropExpandObjectKey,
|
||||
assetObjectId: cropExpandAssetObjectId,
|
||||
sourceAssetId: null,
|
||||
assetKind: cropExpandAssetKind,
|
||||
};
|
||||
captureCanvasHistory();
|
||||
appendCanvasLayersWithResources([nextLayer]);
|
||||
@@ -1367,6 +1534,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
fitLayers,
|
||||
layerCounterRef,
|
||||
persistGeneratedAsset,
|
||||
projectId,
|
||||
selectSingleLayer,
|
||||
setActiveSidebarPanel,
|
||||
]);
|
||||
@@ -1397,6 +1565,14 @@ export function useImageCanvasGenerationWorkflow({
|
||||
})
|
||||
: undefined;
|
||||
const backgroundRemovalDialogId = backgroundRemovalPlacement?.dialogId;
|
||||
const applyBackgroundRemovalProjectSnapshot =
|
||||
projectId && applyProjectSnapshot
|
||||
? (project: EditorProjectSnapshot) => {
|
||||
applyProjectSnapshot(
|
||||
preserveSourceLayerInProjectSnapshot(project, sourceLayer),
|
||||
);
|
||||
}
|
||||
: undefined;
|
||||
try {
|
||||
const sourceObjectKey = sourceLayer.objectKey?.trim();
|
||||
const sourceImageSrc = sourceObjectKey
|
||||
@@ -1428,12 +1604,17 @@ export function useImageCanvasGenerationWorkflow({
|
||||
applyProjectSnapshot,
|
||||
refreshTaskListForQueuedGeneration,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
backgroundRemovalDialogId,
|
||||
applyBackgroundRemovalProjectSnapshot
|
||||
? (project) =>
|
||||
preserveSourceLayerInProjectSnapshot(project, sourceLayer)
|
||||
: undefined,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (result.project && applyProjectSnapshot) {
|
||||
applyProjectSnapshot(result.project);
|
||||
if (result.project && applyBackgroundRemovalProjectSnapshot) {
|
||||
applyBackgroundRemovalProjectSnapshot(result.project);
|
||||
return;
|
||||
}
|
||||
if (backgroundRemovalPlacement?.placeholder && applyProjectSnapshot) {
|
||||
|
||||
@@ -421,6 +421,29 @@ function StageInteractionsHarness({
|
||||
>
|
||||
直接移动图层
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
interaction.setShiftPressed(false);
|
||||
interaction.handleLayerPointerDown(
|
||||
createPointerEvent(event.currentTarget, {
|
||||
pointerId: 10,
|
||||
clientX: 220,
|
||||
clientY: 60,
|
||||
}),
|
||||
secondLayer,
|
||||
);
|
||||
interaction.finishDrag(
|
||||
createPointerEvent(getViewportElement(), {
|
||||
pointerId: 10,
|
||||
clientX: 220,
|
||||
clientY: 60,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
直接点击第二层
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
@@ -508,7 +531,7 @@ function StageInteractionsHarness({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
onClick={() => {
|
||||
const frameElement = document.createElement('div');
|
||||
const dialog = asCanvasGenerationDialog(generateDialog);
|
||||
if (!dialog) {
|
||||
@@ -581,6 +604,34 @@ function StageInteractionsHarness({
|
||||
>
|
||||
直接追加生成器
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
const frameElement = document.createElement('div');
|
||||
const dialog = asCanvasGenerationDialog(generateDialog);
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
interaction.setShiftPressed(false);
|
||||
interaction.handleGenerationFramePointerDown(
|
||||
createPointerEvent(frameElement, {
|
||||
pointerId: 11,
|
||||
clientX: 300,
|
||||
clientY: 200,
|
||||
}),
|
||||
dialog,
|
||||
);
|
||||
interaction.finishDrag(
|
||||
createPointerEvent(getViewportElement(), {
|
||||
pointerId: 11,
|
||||
clientX: 300,
|
||||
clientY: 200,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
直接点击生成器
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
@@ -674,6 +725,26 @@ describe('useImageCanvasStageInteractions', () => {
|
||||
expect(screen.getByTestId('snap').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('collapses an unmoved layer pointer gesture to a single selection', () => {
|
||||
render(<StageInteractionsHarness />);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接选第一层' }).click();
|
||||
});
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接追加第二层' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('selection').textContent).toBe(
|
||||
'second:first,second',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接点击第二层' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('selection').textContent).toBe('second:second');
|
||||
});
|
||||
|
||||
it('handles marquee, temporary hand mode, and generation frame dragging', () => {
|
||||
const activateCanvasGenerationDialog = vi.fn();
|
||||
render(
|
||||
@@ -764,6 +835,30 @@ describe('useImageCanvasStageInteractions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses an unmoved generation frame pointer gesture to that frame', () => {
|
||||
render(<StageInteractionsHarness />);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接选第一层' }).click();
|
||||
});
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接追加生成器' }).click();
|
||||
});
|
||||
const generationSelectionId =
|
||||
screen.getByTestId('dialog-selection-id').textContent;
|
||||
expect(screen.getByTestId('selection').textContent).toBe(
|
||||
`first:first,${generationSelectionId}`,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接点击生成器' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('selection').textContent).toBe(
|
||||
`-:${generationSelectionId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('distinguishes minimap click and drag movement', () => {
|
||||
const moveViewportFromMinimapPointer = vi.fn();
|
||||
const updateViewportFromMinimapDrag = vi.fn();
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
moveViewportFromPan,
|
||||
selectCanvasObjectsInsideMarquee,
|
||||
} from './ImageCanvasInteractionModel';
|
||||
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
|
||||
import {
|
||||
createCanvasMarqueeState,
|
||||
createGenerationFrameSelectionStart,
|
||||
@@ -42,6 +43,26 @@ import type {
|
||||
SnapGuide,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
const CLICK_COLLAPSE_MOVEMENT_THRESHOLD_PX = 3;
|
||||
|
||||
type PendingClickCollapse =
|
||||
| {
|
||||
kind: 'layer';
|
||||
pointerId: number;
|
||||
targetId: string;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
isMultiSelectGesture: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'generation-frame';
|
||||
pointerId: number;
|
||||
targetId: string;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
isMultiSelectGesture: boolean;
|
||||
};
|
||||
|
||||
type UseImageCanvasStageInteractionsOptions = {
|
||||
canvasViewportRef: RefObject<HTMLDivElement | null>;
|
||||
activeTool: CanvasTool;
|
||||
@@ -97,6 +118,21 @@ function focusCanvasInteractionTarget(target: HTMLElement) {
|
||||
target.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function shouldCollapsePointerSelection(
|
||||
pending: PendingClickCollapse,
|
||||
pointer: { x: number; y: number },
|
||||
) {
|
||||
if (pending.isMultiSelectGesture) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
Math.hypot(
|
||||
pointer.x - pending.startClientX,
|
||||
pointer.y - pending.startClientY,
|
||||
) < CLICK_COLLAPSE_MOVEMENT_THRESHOLD_PX
|
||||
);
|
||||
}
|
||||
|
||||
export function useImageCanvasStageInteractions({
|
||||
canvasViewportRef,
|
||||
activeTool,
|
||||
@@ -137,6 +173,7 @@ export function useImageCanvasStageInteractions({
|
||||
onCloseImageContextMenu,
|
||||
}: UseImageCanvasStageInteractionsOptions) {
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
const pendingClickCollapseRef = useRef<PendingClickCollapse | null>(null);
|
||||
const isShiftPressedRef = useRef(false);
|
||||
const suppressNextLayerClickRef = useRef(false);
|
||||
const isViewportInteractionActiveRef = useRef(false);
|
||||
@@ -170,6 +207,7 @@ export function useImageCanvasStageInteractions({
|
||||
flushMinimapViewportDrag();
|
||||
}
|
||||
dragStateRef.current = null;
|
||||
pendingClickCollapseRef.current = null;
|
||||
setCanvasMarquee(null);
|
||||
setIsPanning(false);
|
||||
setSnapGuide(null);
|
||||
@@ -342,6 +380,14 @@ export function useImageCanvasStageInteractions({
|
||||
updateGenerateDialogForLayerPointerDown(currentDialog, layer.id),
|
||||
);
|
||||
dragStateRef.current = layerDragStart.dragState;
|
||||
pendingClickCollapseRef.current = {
|
||||
kind: 'layer',
|
||||
pointerId: getPointerId(event),
|
||||
targetId: layer.id,
|
||||
startClientX: pointer.x,
|
||||
startClientY: pointer.y,
|
||||
isMultiSelectGesture,
|
||||
};
|
||||
},
|
||||
[
|
||||
canvasViewportRef,
|
||||
@@ -465,6 +511,14 @@ export function useImageCanvasStageInteractions({
|
||||
setSelectedLayerId(frameDragStart.selectedLayerId);
|
||||
setSelectedLayerIds(frameDragStart.selectedLayerIds);
|
||||
dragStateRef.current = frameDragStart.dragState;
|
||||
pendingClickCollapseRef.current = {
|
||||
kind: 'generation-frame',
|
||||
pointerId: getPointerId(event),
|
||||
targetId: dialog.id,
|
||||
startClientX: pointer.x,
|
||||
startClientY: pointer.y,
|
||||
isMultiSelectGesture,
|
||||
};
|
||||
},
|
||||
[
|
||||
activateCanvasGenerationDialog,
|
||||
@@ -650,6 +704,26 @@ export function useImageCanvasStageInteractions({
|
||||
const pointer = getPointerClient(event);
|
||||
moveViewportFromMinimapPointer(pointer.x, pointer.y);
|
||||
}
|
||||
const pendingClickCollapse = pendingClickCollapseRef.current;
|
||||
const pointer = getPointerClient(event);
|
||||
if (
|
||||
pendingClickCollapse &&
|
||||
(pendingClickCollapse.pointerId < 0 ||
|
||||
pointerId < 0 ||
|
||||
pendingClickCollapse.pointerId === pointerId) &&
|
||||
shouldCollapsePointerSelection(pendingClickCollapse, pointer)
|
||||
) {
|
||||
if (pendingClickCollapse.kind === 'layer') {
|
||||
setSelectedLayerId(pendingClickCollapse.targetId);
|
||||
setSelectedLayerIds([pendingClickCollapse.targetId]);
|
||||
} else {
|
||||
setSelectedLayerId(null);
|
||||
setSelectedLayerIds([
|
||||
getCanvasGenerationSelectionId(pendingClickCollapse.targetId),
|
||||
]);
|
||||
}
|
||||
}
|
||||
pendingClickCollapseRef.current = null;
|
||||
if (dragState.kind === 'minimap') {
|
||||
flushMinimapViewportDrag();
|
||||
}
|
||||
@@ -670,6 +744,8 @@ export function useImageCanvasStageInteractions({
|
||||
finishViewportInteraction,
|
||||
flushMinimapViewportDrag,
|
||||
moveViewportFromMinimapPointer,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -15734,7 +15734,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="creation-home-stage flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
|
||||
className="creation-home-stage creation-home-stage--landing flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
|
||||
>
|
||||
{platformHomeView}
|
||||
</motion.div>
|
||||
|
||||
@@ -227,4 +227,54 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
);
|
||||
expect(onCloseNativePayment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('shows expired native payment state when qr countdown ends', async () => {
|
||||
const onConfirmNativePayment = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 12,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
}}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
submittingProductId={null}
|
||||
nativePayment={{
|
||||
orderId: 'rcg_native_expired',
|
||||
productTitle: '100泥点',
|
||||
amountCents: 1000,
|
||||
codeUrl: 'weixin://wxpay/bizpayurl?pr=expired',
|
||||
expiresAt: '2000-01-01T00:00:00Z',
|
||||
isConfirming: false,
|
||||
}}
|
||||
activeTab="points"
|
||||
onTabChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={vi.fn()}
|
||||
onConfirmNativePayment={onConfirmNativePayment}
|
||||
onCloseNativePayment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const paymentDialog = screen.getByRole('dialog', {
|
||||
name: '微信扫码支付',
|
||||
});
|
||||
const confirmButton = within(paymentDialog).getByRole('button', {
|
||||
name: '已过期',
|
||||
});
|
||||
|
||||
expect(
|
||||
await within(paymentDialog).findByAltText('微信 Native 支付二维码'),
|
||||
).toBeTruthy();
|
||||
expect(within(paymentDialog).getAllByText('已过期')).toHaveLength(2);
|
||||
expect(confirmButton).toHaveProperty('disabled', true);
|
||||
expect(onConfirmNativePayment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -229,7 +229,11 @@ function PlatformProfileWechatNativePaymentModal({
|
||||
onClick={onConfirm}
|
||||
disabled={nativePayment.isConfirming || remaining.isExpired}
|
||||
>
|
||||
{nativePayment.isConfirming ? '确认中' : '我已支付'}
|
||||
{nativePayment.isConfirming
|
||||
? '确认中'
|
||||
: remaining.isExpired
|
||||
? '已过期'
|
||||
: '我已支付'}
|
||||
</PlatformActionButton>
|
||||
{nativePayment.confirmMessage ? (
|
||||
<div className="mt-3 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
|
||||
@@ -17,7 +17,7 @@ export function PlatformRechargePaymentResultDialog({
|
||||
? 'success'
|
||||
: result.kind === 'pending'
|
||||
? 'loading'
|
||||
: result.kind === 'cancel'
|
||||
: result.kind === 'cancel' || result.kind === 'expired'
|
||||
? 'cancel'
|
||||
: 'error';
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type ConfirmWechatProfileRechargeOrderResponse,
|
||||
type ProfileRechargeCenterResponse,
|
||||
type ProfileRechargeOrder,
|
||||
type ProfileRechargeProduct,
|
||||
type ProfileReferralInviteCenterResponse,
|
||||
type ProfileTaskCenterResponse,
|
||||
@@ -68,7 +69,12 @@ type WechatPayResult = {
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
type RechargePaymentResultKind = 'success' | 'pending' | 'cancel' | 'failed';
|
||||
type RechargePaymentResultKind =
|
||||
| 'success'
|
||||
| 'pending'
|
||||
| 'cancel'
|
||||
| 'failed'
|
||||
| 'expired';
|
||||
|
||||
export type RechargePaymentResult = {
|
||||
kind: RechargePaymentResultKind;
|
||||
@@ -200,11 +206,25 @@ function waitWechatPayConfirmDelay(delayMs: number) {
|
||||
});
|
||||
}
|
||||
|
||||
function isWechatRechargeOrderTerminalForConfirmation(
|
||||
order: Pick<ProfileRechargeOrder, 'status' | 'expirationCheckedAt'>,
|
||||
) {
|
||||
if (order.status === 'pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (order.status === 'expired' && !order.expirationCheckedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function confirmWechatRechargeOrderUntilSettled(
|
||||
orderId: string,
|
||||
): Promise<ConfirmWechatProfileRechargeOrderResponse> {
|
||||
let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
|
||||
if (latestResponse.order.status !== 'pending') {
|
||||
if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) {
|
||||
return latestResponse;
|
||||
}
|
||||
|
||||
@@ -212,7 +232,7 @@ async function confirmWechatRechargeOrderUntilSettled(
|
||||
await waitWechatPayConfirmDelay(delayMs);
|
||||
|
||||
latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
|
||||
if (latestResponse.order.status !== 'pending') {
|
||||
if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) {
|
||||
return latestResponse;
|
||||
}
|
||||
}
|
||||
@@ -229,7 +249,7 @@ async function confirmWechatRechargeOrderQuickly(
|
||||
orderId: string,
|
||||
): Promise<ConfirmWechatProfileRechargeOrderResponse> {
|
||||
let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
|
||||
if (latestResponse.order.status !== 'pending') {
|
||||
if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) {
|
||||
return latestResponse;
|
||||
}
|
||||
|
||||
@@ -237,7 +257,7 @@ async function confirmWechatRechargeOrderQuickly(
|
||||
await waitWechatPayConfirmDelay(delayMs);
|
||||
|
||||
latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
|
||||
if (latestResponse.order.status !== 'pending') {
|
||||
if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) {
|
||||
return latestResponse;
|
||||
}
|
||||
}
|
||||
@@ -245,6 +265,52 @@ async function confirmWechatRechargeOrderQuickly(
|
||||
return latestResponse;
|
||||
}
|
||||
|
||||
function buildRechargePaymentResultForOrder(
|
||||
order: Pick<ProfileRechargeOrder, 'status' | 'expirationCheckedAt'>,
|
||||
): RechargePaymentResult {
|
||||
switch (order.status) {
|
||||
case 'paid':
|
||||
return {
|
||||
kind: 'success',
|
||||
title: '支付成功',
|
||||
message: '已到账,账户状态已刷新。',
|
||||
};
|
||||
case 'expired':
|
||||
if (!order.expirationCheckedAt) {
|
||||
return {
|
||||
kind: 'pending',
|
||||
title: '支付处理中',
|
||||
message: '正在等待到账状态确认,请稍后查看余额或会员状态。',
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'expired',
|
||||
title: '支付已过期',
|
||||
message: '订单已超过支付时限,本次没有入账。',
|
||||
};
|
||||
case 'closed':
|
||||
return {
|
||||
kind: 'cancel',
|
||||
title: '支付未完成',
|
||||
message: '本次没有扣款,账户状态未发生变化。',
|
||||
};
|
||||
case 'failed':
|
||||
case 'refunded':
|
||||
return {
|
||||
kind: 'failed',
|
||||
title: '支付未完成',
|
||||
message: '微信支付没有完成,本次不会入账。',
|
||||
};
|
||||
case 'pending':
|
||||
default:
|
||||
return {
|
||||
kind: 'pending',
|
||||
title: '支付处理中',
|
||||
message: '正在等待到账状态确认,请稍后查看余额或会员状态。',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function usePlatformProfileCenterController({
|
||||
activeTab,
|
||||
isAuthenticated,
|
||||
@@ -417,24 +483,13 @@ export function usePlatformProfileCenterController({
|
||||
setRechargePaymentResult(null);
|
||||
void confirmWechatRechargeOrderUntilSettled(orderId)
|
||||
.then((response) => {
|
||||
const isPaid = response.order.status === 'paid';
|
||||
const result = buildRechargePaymentResultForOrder(response.order);
|
||||
const isPaid = result.kind === 'success';
|
||||
setRechargeCenter(response.center);
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
confirmingWechatRechargeOrderIdRef.current = null;
|
||||
setWechatRechargeOrderConfirmationState(null);
|
||||
setRechargePaymentResult(
|
||||
isPaid
|
||||
? {
|
||||
kind: 'success',
|
||||
title: '支付成功',
|
||||
message: '已到账,账户状态已刷新。',
|
||||
}
|
||||
: {
|
||||
kind: 'pending',
|
||||
title: '支付处理中',
|
||||
message: '正在等待到账状态确认,请稍后查看余额或会员状态。',
|
||||
},
|
||||
);
|
||||
setRechargePaymentResult(result);
|
||||
if (isPaid) {
|
||||
void onRechargeSuccess?.();
|
||||
}
|
||||
@@ -495,25 +550,14 @@ export function usePlatformProfileCenterController({
|
||||
setRechargePaymentResult(null);
|
||||
void confirmWechatRechargeOrderUntilSettled(orderId)
|
||||
.then((response) => {
|
||||
const isPaid = response.order.status === 'paid';
|
||||
const result = buildRechargePaymentResultForOrder(response.order);
|
||||
const isPaid = result.kind === 'success';
|
||||
setRechargeCenter(response.center);
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
confirmingWechatRechargeOrderIdRef.current = null;
|
||||
setWechatRechargeOrderConfirmationState(null);
|
||||
setSubmittingRechargeProductId(null);
|
||||
setRechargePaymentResult(
|
||||
isPaid
|
||||
? {
|
||||
kind: 'success',
|
||||
title: '支付成功',
|
||||
message: '已到账,账户状态已刷新。',
|
||||
}
|
||||
: {
|
||||
kind: 'pending',
|
||||
title: '支付处理中',
|
||||
message: '正在等待到账状态确认,请稍后查看余额或会员状态。',
|
||||
},
|
||||
);
|
||||
setRechargePaymentResult(result);
|
||||
if (isPaid) {
|
||||
void onRechargeSuccess?.();
|
||||
}
|
||||
@@ -612,23 +656,16 @@ export function usePlatformProfileCenterController({
|
||||
});
|
||||
void confirmWechatRechargeOrderUntilSettled(response.order.orderId)
|
||||
.then((confirmResponse) => {
|
||||
const isPaid = confirmResponse.order.status === 'paid';
|
||||
setRechargeCenter(confirmResponse.center);
|
||||
setRechargePaymentResult(
|
||||
isPaid
|
||||
? {
|
||||
kind: 'success',
|
||||
title: '支付成功',
|
||||
message: '已到账,账户状态已刷新。',
|
||||
}
|
||||
: {
|
||||
kind: 'pending',
|
||||
title: '等待微信确认',
|
||||
message: '暂时没能确认到账状态,请稍后再试。',
|
||||
},
|
||||
const result = buildRechargePaymentResultForOrder(
|
||||
confirmResponse.order,
|
||||
);
|
||||
if (isPaid) {
|
||||
const isPaid = result.kind === 'success';
|
||||
setRechargeCenter(confirmResponse.center);
|
||||
setRechargePaymentResult(result);
|
||||
if (result.kind !== 'pending') {
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
}
|
||||
if (isPaid) {
|
||||
void onRechargeSuccess?.();
|
||||
}
|
||||
})
|
||||
@@ -745,17 +782,16 @@ export function usePlatformProfileCenterController({
|
||||
);
|
||||
void confirmWechatRechargeOrderQuickly(nativeWechatPayment.orderId)
|
||||
.then((response) => {
|
||||
const isPaid = response.order.status === 'paid';
|
||||
const result = buildRechargePaymentResultForOrder(response.order);
|
||||
const isPaid = result.kind === 'success';
|
||||
setRechargeCenter(response.center);
|
||||
if (isPaid) {
|
||||
if (result.kind !== 'pending') {
|
||||
setNativeWechatPayment(null);
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
setRechargePaymentResult({
|
||||
kind: 'success',
|
||||
title: '支付成功',
|
||||
message: '已到账,账户状态已刷新。',
|
||||
});
|
||||
void onRechargeSuccess?.();
|
||||
setRechargePaymentResult(result);
|
||||
if (isPaid) {
|
||||
void onRechargeSuccess?.();
|
||||
}
|
||||
} else {
|
||||
setNativeWechatPayment((current) =>
|
||||
current && current.orderId === nativeWechatPayment.orderId
|
||||
|
||||
@@ -1475,18 +1475,18 @@ test('profile recharge modal shows native qr code on desktop web by default', as
|
||||
expect(screen.queryByRole('dialog', { name: '支付成功' })).toBeNull();
|
||||
});
|
||||
|
||||
test('profile recharge modal uses native payment inside wechat browser before open platform is connected', async () => {
|
||||
test('profile recharge modal uses h5 payment inside wechat browser before open platform is connected', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockWechatMobileLayout();
|
||||
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||
order: {
|
||||
orderId: 'order-wechat-native-1',
|
||||
orderId: 'order-wechat-h5-1',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'pending' as const,
|
||||
paymentChannel: 'wechat_native',
|
||||
paymentChannel: 'wechat_h5',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
paidAt: null,
|
||||
providerTransactionId: null,
|
||||
@@ -1502,9 +1502,8 @@ test('profile recharge modal uses native payment inside wechat browser before op
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
},
|
||||
wechatNativePayment: {
|
||||
codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=wechat-native',
|
||||
expiresAt: '2099-01-01T00:05:00Z',
|
||||
wechatH5Payment: {
|
||||
h5Url: 'https://pay.example.test/wechat-h5',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1518,27 +1517,29 @@ test('profile recharge modal uses native payment inside wechat browser before op
|
||||
await waitFor(() => {
|
||||
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||
'points_60',
|
||||
'wechat_native',
|
||||
'wechat_h5',
|
||||
);
|
||||
});
|
||||
expect(mockRedirectToPaymentUrl).not.toHaveBeenCalled();
|
||||
expect(mockRedirectToPaymentUrl).toHaveBeenCalledWith(
|
||||
'https://pay.example.test/wechat-h5',
|
||||
);
|
||||
expect(mockRequestWechatJsapiPayment).not.toHaveBeenCalled();
|
||||
expect(mockStartWechatBind).not.toHaveBeenCalled();
|
||||
expect(screen.getByAltText('微信 Native 支付二维码')).toBeTruthy();
|
||||
expect(screen.queryByAltText('微信 Native 支付二维码')).toBeNull();
|
||||
});
|
||||
|
||||
test('profile recharge does not start wechat oauth inside wechat browser while native fallback is active', async () => {
|
||||
test('profile recharge does not start wechat oauth inside wechat browser while h5 payment is active', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockWechatMobileLayout();
|
||||
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||
order: {
|
||||
orderId: 'order-wechat-native-oauth-free',
|
||||
orderId: 'order-wechat-h5-oauth-free',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'pending' as const,
|
||||
paymentChannel: 'wechat_native',
|
||||
paymentChannel: 'wechat_h5',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
paidAt: null,
|
||||
providerTransactionId: null,
|
||||
@@ -1554,10 +1555,8 @@ test('profile recharge does not start wechat oauth inside wechat browser while n
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
},
|
||||
wechatNativePayment: {
|
||||
codeUrl:
|
||||
'weixin://pay.weixin.qq.com/bizpayurl/up?pr=wechat-native-oauth-free',
|
||||
expiresAt: '2099-01-01T00:05:00Z',
|
||||
wechatH5Payment: {
|
||||
h5Url: 'https://pay.example.test/wechat-h5-oauth-free',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1579,27 +1578,29 @@ test('profile recharge does not start wechat oauth inside wechat browser while n
|
||||
await waitFor(() => {
|
||||
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||
'points_60',
|
||||
'wechat_native',
|
||||
'wechat_h5',
|
||||
);
|
||||
});
|
||||
expect(mockRedirectToPaymentUrl).not.toHaveBeenCalled();
|
||||
expect(mockRedirectToPaymentUrl).toHaveBeenCalledWith(
|
||||
'https://pay.example.test/wechat-h5-oauth-free',
|
||||
);
|
||||
expect(mockStartWechatBind).not.toHaveBeenCalled();
|
||||
expect(mockRequestWechatJsapiPayment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('profile native payment keeps qr dialog on mobile web without opening native link', async () => {
|
||||
test('profile recharge redirects to h5 payment on mobile web', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
mockNarrowMobileLayout();
|
||||
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||
order: {
|
||||
orderId: 'order-native-resume',
|
||||
orderId: 'order-h5-mobile-resume',
|
||||
productId: 'points_60',
|
||||
productTitle: 'points 60',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'pending' as const,
|
||||
paymentChannel: 'wechat_native',
|
||||
paymentChannel: 'wechat_h5',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
paidAt: null,
|
||||
providerTransactionId: null,
|
||||
@@ -1615,24 +1616,25 @@ test('profile native payment keeps qr dialog on mobile web without opening nativ
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
},
|
||||
wechatNativePayment: {
|
||||
codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-resume',
|
||||
expiresAt: '2099-01-01T00:05:00Z',
|
||||
wechatH5Payment: {
|
||||
h5Url: 'https://pay.example.test/mobile-h5',
|
||||
},
|
||||
});
|
||||
renderProfileView(onRechargeSuccess);
|
||||
await openRechargeModal(user);
|
||||
await user.click(await screen.findByRole('button', { name: /60/u }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('dialog').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByAltText('微信 Native 支付二维码')).toBeTruthy();
|
||||
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||
'points_60',
|
||||
'wechat_h5',
|
||||
);
|
||||
});
|
||||
expect(mockQrCodeToDataUrl).toHaveBeenCalledWith(
|
||||
'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-resume',
|
||||
expect.objectContaining({ width: 180 }),
|
||||
expect(mockRedirectToPaymentUrl).toHaveBeenCalledWith(
|
||||
'https://pay.example.test/mobile-h5',
|
||||
);
|
||||
expect(mockRedirectToPaymentUrl).not.toHaveBeenCalled();
|
||||
expect(mockQrCodeToDataUrl).not.toHaveBeenCalled();
|
||||
expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled();
|
||||
expect(screen.queryByAltText('微信 Native 支付二维码')).toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: '支付成功' })).toBeNull();
|
||||
expect(onRechargeSuccess).not.toHaveBeenCalled();
|
||||
expect(mockWatchWechatRpgProfileRechargeOrder).not.toHaveBeenCalled();
|
||||
@@ -2682,6 +2684,181 @@ test('profile native qr confirmation refreshes only after server reports paid',
|
||||
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('profile native qr confirmation closes qr dialog when order is expired', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
mockDesktopLayout();
|
||||
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||
order: {
|
||||
orderId: 'order-native-expired',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'pending' as const,
|
||||
paymentChannel: 'wechat_native',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
paidAt: null,
|
||||
providerTransactionId: null,
|
||||
pointsDelta: 0,
|
||||
membershipExpiresAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 0,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
},
|
||||
wechatNativePayment: {
|
||||
codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-expired',
|
||||
expiresAt: '2099-01-01T00:05:00Z',
|
||||
},
|
||||
});
|
||||
mockConfirmWechatRpgProfileRechargeOrder.mockResolvedValue({
|
||||
order: {
|
||||
orderId: 'order-native-expired',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'expired' as const,
|
||||
paymentChannel: 'wechat_native',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
paidAt: null,
|
||||
providerTransactionId: null,
|
||||
pointsDelta: 0,
|
||||
membershipExpiresAt: null,
|
||||
expiredAt: '2026-04-25T10:05:00Z',
|
||||
expirationCheckedAt: '2026-04-25T10:05:02Z',
|
||||
expirationProviderState: 'NOTPAY',
|
||||
},
|
||||
center: {
|
||||
walletBalance: 0,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
},
|
||||
});
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
await user.click(
|
||||
within(shortcutRegion).getByRole('button', { name: /充值/u }),
|
||||
);
|
||||
await user.click(await screen.findByRole('button', { name: /60泥点/u }));
|
||||
expect(await screen.findByAltText('微信 Native 支付二维码')).toBeTruthy();
|
||||
await user.click(await screen.findByRole('button', { name: '我已支付' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||
'order-native-expired',
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText('支付已过期')).toBeTruthy();
|
||||
expect(screen.queryByAltText('微信 Native 支付二维码')).toBeNull();
|
||||
expect(screen.getByText('订单已超过支付时限,本次没有入账。')).toBeTruthy();
|
||||
expect(onRechargeSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('profile native qr confirmation keeps qr dialog while expired order is awaiting compensation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
mockDesktopLayout();
|
||||
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||
order: {
|
||||
orderId: 'order-native-expired-unchecked',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'pending' as const,
|
||||
paymentChannel: 'wechat_native',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
paidAt: null,
|
||||
providerTransactionId: null,
|
||||
pointsDelta: 0,
|
||||
membershipExpiresAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 0,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
},
|
||||
wechatNativePayment: {
|
||||
codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-expired-unchecked',
|
||||
expiresAt: '2099-01-01T00:05:00Z',
|
||||
},
|
||||
});
|
||||
mockConfirmWechatRpgProfileRechargeOrder.mockResolvedValue({
|
||||
order: {
|
||||
orderId: 'order-native-expired-unchecked',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'expired' as const,
|
||||
paymentChannel: 'wechat_native',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
paidAt: null,
|
||||
providerTransactionId: null,
|
||||
pointsDelta: 0,
|
||||
membershipExpiresAt: null,
|
||||
expiredAt: '2026-04-25T10:05:00Z',
|
||||
expirationCheckedAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 0,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
},
|
||||
});
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
await user.click(
|
||||
within(shortcutRegion).getByRole('button', { name: /充值/u }),
|
||||
);
|
||||
await user.click(await screen.findByRole('button', { name: /60泥点/u }));
|
||||
await user.click(await screen.findByRole('button', { name: '我已支付' }));
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(3);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'暂未确认到账,请确认付款完成后再点一次。',
|
||||
undefined,
|
||||
{
|
||||
timeout: 5000,
|
||||
},
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('dialog', {
|
||||
name: '微信扫码支付',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('dialog', { name: '支付已过期' })).toBeNull();
|
||||
expect(onRechargeSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('profile native qr confirmation keeps qr dialog when payment is still pending', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
|
||||
@@ -13022,6 +13022,34 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.creation-home-stage--landing
|
||||
.platform-desktop-shell--workbench
|
||||
#platform-tab-panel-create {
|
||||
-ms-overflow-style: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.creation-home-stage--landing
|
||||
.platform-desktop-shell--workbench
|
||||
#platform-tab-panel-create::-webkit-scrollbar {
|
||||
display: block;
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
}
|
||||
|
||||
.creation-home-stage--landing
|
||||
.platform-desktop-shell--workbench
|
||||
#platform-tab-panel-create::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgba(145, 67, 21, 0.34);
|
||||
}
|
||||
|
||||
.creation-home-stage--landing
|
||||
.platform-desktop-shell--workbench
|
||||
#platform-tab-panel-create::-webkit-scrollbar-track {
|
||||
background: rgba(255, 248, 240, 0.72);
|
||||
}
|
||||
|
||||
.platform-desktop-trending-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
+7
-2
@@ -128,10 +128,10 @@ describe('index stylesheet unread dots', () => {
|
||||
'.platform-desktop-shell--workbench .platform-desktop-rail__button--primary .platform-desktop-rail__label,\n.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__label',
|
||||
);
|
||||
expect(css).toContain(
|
||||
'.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell',
|
||||
'.platform-desktop-rail__button--active .platform-desktop-rail__icon-shell',
|
||||
);
|
||||
expect(css).toContain(
|
||||
'.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__label',
|
||||
'.platform-desktop-rail__button--active .platform-desktop-rail__label',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -158,6 +158,11 @@ describe('index stylesheet unread dots', () => {
|
||||
expect(css).toContain('.platform-page-stage::-webkit-scrollbar');
|
||||
expect(css).toContain('.unified-creation-page::-webkit-scrollbar');
|
||||
expect(css).toContain('.platform-work-detail__scroll::-webkit-scrollbar');
|
||||
expect(css).toContain('.creation-home-stage--landing');
|
||||
expect(css).toContain('scrollbar-width: thin;');
|
||||
expect(css).toContain(
|
||||
'.creation-home-stage--landing\n .platform-desktop-shell--workbench\n #platform-tab-panel-create::-webkit-scrollbar',
|
||||
);
|
||||
|
||||
expect(css).toContain('.scrollbar-hide');
|
||||
expect(css).toContain('::-webkit-scrollbar-thumb');
|
||||
|
||||
@@ -34,6 +34,7 @@ describe('frontendRuntimeConfigService', () => {
|
||||
'读取前端运行时配置失败',
|
||||
{
|
||||
authImpact: 'local',
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
|
||||
@@ -13,6 +13,7 @@ export async function loadFrontendRuntimeConfig() {
|
||||
'读取前端运行时配置失败',
|
||||
{
|
||||
authImpact: 'local',
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
resolveProfileRechargePaymentChannel,
|
||||
resolveProfileRechargeProductPaymentChannel,
|
||||
shouldShowRechargeEntry,
|
||||
WECHAT_H5_PAYMENT_CHANNEL,
|
||||
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL,
|
||||
WECHAT_NATIVE_PAYMENT_CHANNEL,
|
||||
} from './paymentPlatform';
|
||||
@@ -53,7 +54,7 @@ describe('resolveProfileRechargePaymentChannel', () => {
|
||||
).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL);
|
||||
});
|
||||
|
||||
test('移动网页也选择 wechat_native', () => {
|
||||
test('移动网页选择 wechat_h5', () => {
|
||||
expect(
|
||||
resolveProfileRechargePaymentChannel({
|
||||
location: { search: '' },
|
||||
@@ -62,10 +63,10 @@ describe('resolveProfileRechargePaymentChannel', () => {
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile',
|
||||
},
|
||||
}),
|
||||
).toBe(WECHAT_NATIVE_PAYMENT_CHANNEL);
|
||||
).toBe(WECHAT_H5_PAYMENT_CHANNEL);
|
||||
});
|
||||
|
||||
test('微信内 H5 在开放平台未接入前选择 wechat_native', () => {
|
||||
test('微信内 H5 在开放平台未接入前选择 wechat_h5', () => {
|
||||
expect(
|
||||
resolveProfileRechargePaymentChannel({
|
||||
location: { search: '' },
|
||||
@@ -74,7 +75,7 @@ describe('resolveProfileRechargePaymentChannel', () => {
|
||||
'Mozilla/5.0 (Linux; Android 14) AppleWebKit MicroMessenger/8.0 Mobile',
|
||||
},
|
||||
}),
|
||||
).toBe(WECHAT_NATIVE_PAYMENT_CHANNEL);
|
||||
).toBe(WECHAT_H5_PAYMENT_CHANNEL);
|
||||
});
|
||||
|
||||
test('桌面网页选择 wechat_native', () => {
|
||||
|
||||
@@ -43,6 +43,10 @@ export function resolveProfileRechargePaymentChannel(
|
||||
return WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL;
|
||||
}
|
||||
|
||||
if (isMobilePaymentRuntime(context, navigatorLike)) {
|
||||
return WECHAT_H5_PAYMENT_CHANNEL;
|
||||
}
|
||||
|
||||
return WECHAT_NATIVE_PAYMENT_CHANNEL;
|
||||
}
|
||||
|
||||
@@ -74,3 +78,35 @@ export function isWechatMiniProgramRuntime(
|
||||
}).kind === 'wechat_mini_program'
|
||||
);
|
||||
}
|
||||
|
||||
function isMobilePaymentRuntime(
|
||||
context: PaymentPlatformContext,
|
||||
navigatorLike: Partial<PaymentPlatformNavigator> | null,
|
||||
) {
|
||||
const userAgent = navigatorLike?.userAgent ?? '';
|
||||
if (
|
||||
/\b(Android|iPhone|iPad|iPod|Mobile|Windows Phone|IEMobile|BlackBerry|webOS)\b/i.test(
|
||||
userAgent,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const maxTouchPoints = navigatorLike?.maxTouchPoints ?? 0;
|
||||
if (maxTouchPoints <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const matchMedia =
|
||||
context.matchMedia ??
|
||||
(typeof window !== 'undefined' ? window.matchMedia.bind(window) : null);
|
||||
if (!matchMedia) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return matchMedia('(pointer: coarse)').matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,4 +397,143 @@ describe('rpgProfileClient recharge order events', () => {
|
||||
expect(result.order.status).toBe('paid');
|
||||
expect(result.center.walletBalance).toBe(120);
|
||||
});
|
||||
|
||||
it('waits past unchecked expired order events until compensation publishes paid', async () => {
|
||||
const pendingOrder = {
|
||||
orderId: 'order-wechat-sse-expired-then-paid',
|
||||
productId: 'points_60',
|
||||
productTitle: '60娉ョ偣',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'pending',
|
||||
paymentChannel: 'wechat_native',
|
||||
paidAt: null,
|
||||
providerTransactionId: null,
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
pointsDelta: 0,
|
||||
membershipExpiresAt: null,
|
||||
};
|
||||
const center = {
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
};
|
||||
const uncheckedExpiredOrder = {
|
||||
...pendingOrder,
|
||||
status: 'expired',
|
||||
expiredAt: '2026-04-25T10:05:00Z',
|
||||
expirationCheckedAt: null,
|
||||
};
|
||||
const paidOrder = {
|
||||
...pendingOrder,
|
||||
status: 'paid',
|
||||
paidAt: '2026-04-25T10:05:03Z',
|
||||
providerTransactionId: 'wx-expired-compensated-1',
|
||||
pointsDelta: 120,
|
||||
};
|
||||
fetchWithApiAuthMock.mockResolvedValueOnce(
|
||||
createSseResponse(
|
||||
[
|
||||
'event: order',
|
||||
`data: ${JSON.stringify({ order: pendingOrder, center })}`,
|
||||
'',
|
||||
'event: order',
|
||||
`data: ${JSON.stringify({ order: uncheckedExpiredOrder, center })}`,
|
||||
'',
|
||||
'event: order',
|
||||
`data: ${JSON.stringify({
|
||||
order: paidOrder,
|
||||
center: {
|
||||
...center,
|
||||
walletBalance: 120,
|
||||
hasPointsRecharged: true,
|
||||
},
|
||||
})}`,
|
||||
'',
|
||||
'event: done',
|
||||
'data: {"orderId":"order-wechat-sse-expired-then-paid","status":"paid"}',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await watchWechatRpgProfileRechargeOrder(
|
||||
'order-wechat-sse-expired-then-paid',
|
||||
);
|
||||
|
||||
expect(result.order.status).toBe('paid');
|
||||
expect(result.center.walletBalance).toBe(120);
|
||||
});
|
||||
|
||||
it('treats a checked expired order event as a settled SSE result', async () => {
|
||||
const pendingOrder = {
|
||||
orderId: 'order-wechat-sse-expired',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'pending',
|
||||
paymentChannel: 'wechat_native',
|
||||
paidAt: null,
|
||||
providerTransactionId: null,
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
pointsDelta: 0,
|
||||
membershipExpiresAt: null,
|
||||
};
|
||||
const center = {
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
};
|
||||
const expiredOrder = {
|
||||
...pendingOrder,
|
||||
status: 'expired',
|
||||
expiredAt: '2026-04-25T10:05:00Z',
|
||||
expirationCheckedAt: '2026-04-25T10:05:02Z',
|
||||
expirationProviderState: 'NOTPAY',
|
||||
};
|
||||
fetchWithApiAuthMock.mockResolvedValueOnce(
|
||||
createSseResponse(
|
||||
[
|
||||
'event: order',
|
||||
`data: ${JSON.stringify({ order: pendingOrder, center })}`,
|
||||
'',
|
||||
'event: order',
|
||||
`data: ${JSON.stringify({ order: expiredOrder, center })}`,
|
||||
'',
|
||||
'event: done',
|
||||
'data: {"orderId":"order-wechat-sse-expired","status":"expired"}',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await watchWechatRpgProfileRechargeOrder(
|
||||
'order-wechat-sse-expired',
|
||||
);
|
||||
|
||||
expect(result.order.status).toBe('expired');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileRechargeOrder,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileSaveArchiveListResponse,
|
||||
@@ -212,6 +213,20 @@ function normalizeRechargeOrderSseEvent(
|
||||
return null;
|
||||
}
|
||||
|
||||
function isWechatRechargeOrderTerminalForConfirmation(
|
||||
order: Pick<ProfileRechargeOrder, 'status' | 'expirationCheckedAt'>,
|
||||
) {
|
||||
if (order.status === 'pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (order.status === 'expired' && !order.expirationCheckedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function watchWechatRpgProfileRechargeOrder(
|
||||
orderId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
@@ -259,7 +274,9 @@ export async function watchWechatRpgProfileRechargeOrder(
|
||||
|
||||
if (normalized.type === 'order') {
|
||||
lastResponse = normalized.payload;
|
||||
if (normalized.payload.order.status !== 'pending') {
|
||||
if (
|
||||
isWechatRechargeOrderTerminalForConfirmation(normalized.payload.order)
|
||||
) {
|
||||
finalResponse = normalized.payload;
|
||||
return false;
|
||||
}
|
||||
@@ -267,7 +284,11 @@ export async function watchWechatRpgProfileRechargeOrder(
|
||||
}
|
||||
|
||||
if (normalized.type === 'done') {
|
||||
if (!finalResponse && lastResponse) {
|
||||
if (
|
||||
!finalResponse &&
|
||||
lastResponse &&
|
||||
isWechatRechargeOrderTerminalForConfirmation(lastResponse.order)
|
||||
) {
|
||||
finalResponse = lastResponse;
|
||||
}
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user