升级外部生成为正式任务列表
将 external_generation_job 扩展为含价格、确认时间、退款流水和事件审计的正式生成任务事实源 新增外部生成任务列表与确认 BFF,并让平台入口按后端任务补弹完成或失败提示 保护画布生成队列超时、快照回写和自动保存覆盖场景 将 worker 计费退款流水写入 externalGenerationJobId,并补充冷备份后 worker/controller 恢复守护
This commit is contained in:
@@ -1120,6 +1120,79 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a queued generation pending when the frontend queue wait expires', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const refreshWalletBalance = vi.fn();
|
||||
const runningState = createQueueState({
|
||||
status: 'running',
|
||||
phaseLabel: '生成中',
|
||||
phaseDetail: '后端任务继续执行中',
|
||||
progress: 0.35,
|
||||
});
|
||||
generateEditorImageMock.mockResolvedValueOnce({
|
||||
...createGenerated({
|
||||
imageSrc: '/generated-editor-images/project/image.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
prompt: '队列生成',
|
||||
}),
|
||||
queueState: runningState,
|
||||
});
|
||||
getExternalGenerationJobStatusMock.mockResolvedValue({
|
||||
job: runningState,
|
||||
});
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
projectId="editor-project-1"
|
||||
onWalletBalanceMayHaveChanged={refreshWalletBalance}
|
||||
initialDialog={{
|
||||
id: 'dialog-generate',
|
||||
mode: 'generate',
|
||||
prompt: '队列生成',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
imageModel: 'gpt-image-2',
|
||||
placeholder: {
|
||||
x: 200,
|
||||
y: 160,
|
||||
width: 420,
|
||||
height: 420,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1024,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20 * 60 * 1000 + 1600);
|
||||
});
|
||||
|
||||
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
|
||||
expect(loadEditorProjectMock).not.toHaveBeenCalled();
|
||||
expect(getExternalGenerationJobStatusMock).toHaveBeenCalled();
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'generate:generating:closed:-:placeholder:-',
|
||||
);
|
||||
expect(screen.getByTestId('dialog').textContent).not.toContain(
|
||||
'生成任务仍在队列中',
|
||||
);
|
||||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||||
'layer-generated-1',
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not add a local layer when project generation returns without a backend snapshot', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({
|
||||
|
||||
@@ -199,12 +199,12 @@ async function runEditorGenerationWithWalletRefresh<T>(
|
||||
|
||||
async function waitForEditorGenerationQueue(
|
||||
queueState: ExternalGenerationJobStatusRecord,
|
||||
) {
|
||||
): Promise<ExternalGenerationJobStatusRecord | null> {
|
||||
const startedAt = Date.now();
|
||||
let current = queueState;
|
||||
while (current.status === 'queued' || current.status === 'running') {
|
||||
if (Date.now() - startedAt > EDITOR_GENERATION_QUEUE_TIMEOUT_MS) {
|
||||
throw new Error('生成任务仍在队列中,请稍后刷新画布查看结果');
|
||||
return null;
|
||||
}
|
||||
await delay(EDITOR_GENERATION_QUEUE_POLL_INTERVAL_MS);
|
||||
current = (
|
||||
@@ -227,11 +227,15 @@ async function applyQueuedEditorGenerationProject(
|
||||
if (!queueState) {
|
||||
return false;
|
||||
}
|
||||
let terminalState: ExternalGenerationJobStatusRecord | null = null;
|
||||
try {
|
||||
await waitForEditorGenerationQueue(queueState);
|
||||
terminalState = await waitForEditorGenerationQueue(queueState);
|
||||
} finally {
|
||||
notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged);
|
||||
}
|
||||
if (!terminalState) {
|
||||
return true;
|
||||
}
|
||||
if (projectId && applyProjectSnapshot) {
|
||||
applyProjectSnapshot(await loadEditorProject(projectId));
|
||||
}
|
||||
|
||||
@@ -274,6 +274,67 @@ function ProjectPersistenceHarness({
|
||||
>
|
||||
move generation again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
persistence.applyProjectSnapshot({
|
||||
projectId: 'editor-project-default',
|
||||
title: '空画布项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-generated',
|
||||
resourceId: 'resource-generated',
|
||||
title: '生成结果',
|
||||
x: 42,
|
||||
y: 56,
|
||||
width: 420,
|
||||
height: 420,
|
||||
originalWidth: 420,
|
||||
originalHeight: 420,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
{
|
||||
itemType: 'generation-dialog',
|
||||
layerId: 'generation-dialog:generation-dialog-1',
|
||||
resourceId: 'generation-dialog:generation-dialog-1',
|
||||
dialog: {
|
||||
id: 'generation-dialog-1',
|
||||
mode: 'generate',
|
||||
prompt: '后端完成生成器',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generatedLayerId: 'layer-generated',
|
||||
placeholder: {
|
||||
x: 42,
|
||||
y: 56,
|
||||
width: 420,
|
||||
height: 420,
|
||||
originalWidth: 420,
|
||||
originalHeight: 420,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-generated',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/generated/result.png',
|
||||
objectKey: 'generated/result.png',
|
||||
assetObjectId: 'asset-object-result',
|
||||
width: 420,
|
||||
height: 420,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-12T00:00:01.000Z',
|
||||
});
|
||||
}}
|
||||
>
|
||||
apply completed project
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -731,6 +792,62 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('drops stale pending generating saves when a backend completion snapshot is applied', async () => {
|
||||
const savedDialog: CanvasGenerationDialogState = {
|
||||
id: 'generation-dialog-1',
|
||||
mode: 'generate',
|
||||
prompt: '刷新后继续生成',
|
||||
status: 'generating',
|
||||
composerOpen: false,
|
||||
placeholder: {
|
||||
x: 42,
|
||||
y: 56,
|
||||
width: 420,
|
||||
height: 420,
|
||||
originalWidth: 2048,
|
||||
originalHeight: 2048,
|
||||
},
|
||||
};
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
title: '空画布项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
itemType: 'generation-dialog',
|
||||
layerId: 'generation-dialog:generation-dialog-1',
|
||||
resourceId: 'generation-dialog:generation-dialog-1',
|
||||
dialog: savedDialog,
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
vi.useFakeTimers();
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'move generation' }).click();
|
||||
});
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'apply completed project' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
|
||||
'generation-dialog-1:后端完成生成器:42',
|
||||
);
|
||||
expect(screen.getByTestId('layers').textContent).toBe(
|
||||
'layer-generated:resource-generated:-:-:generated/result.png:asset-object-result',
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(451);
|
||||
});
|
||||
|
||||
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('saves generated layers together with their generator snapshots', async () => {
|
||||
const savedDialog: CanvasGenerationDialogState = {
|
||||
id: 'generation-dialog-1',
|
||||
|
||||
@@ -260,6 +260,7 @@ export function useImageCanvasProjectPersistence({
|
||||
const pendingProjectLayoutSaveRef =
|
||||
useRef<PendingProjectLayoutSave | null>(null);
|
||||
const isProjectLayoutSaveRunningRef = useRef(false);
|
||||
const skipNextProjectLayoutSaveRef = useRef(false);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
const projectTitleRef = useRef('未命名画布');
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
@@ -274,6 +275,14 @@ export function useImageCanvasProjectPersistence({
|
||||
restoreCanvasGenerationDialogs,
|
||||
} = setters;
|
||||
|
||||
const clearPendingProjectLayoutSave = useCallback(() => {
|
||||
pendingProjectLayoutSaveRef.current = null;
|
||||
if (saveTimerRef.current) {
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runPendingProjectLayoutSave = useCallback(
|
||||
function runPendingProjectLayoutSave() {
|
||||
if (isProjectLayoutSaveRunningRef.current) {
|
||||
@@ -422,6 +431,8 @@ export function useImageCanvasProjectPersistence({
|
||||
|
||||
const applyProjectSnapshot = useCallback(
|
||||
(project: EditorProjectSnapshot) => {
|
||||
clearPendingProjectLayoutSave();
|
||||
skipNextProjectLayoutSaveRef.current = true;
|
||||
projectIdRef.current = project.projectId;
|
||||
setProjectId(project.projectId);
|
||||
const nextProjectTitle = project.title?.trim() || '未命名画布';
|
||||
@@ -471,6 +482,7 @@ export function useImageCanvasProjectPersistence({
|
||||
restoreCanvasGenerationDialogs(generationDialogs);
|
||||
},
|
||||
[
|
||||
clearPendingProjectLayoutSave,
|
||||
refs,
|
||||
restoreCanvasGenerationDialogs,
|
||||
selectSingleLayer,
|
||||
@@ -558,6 +570,10 @@ export function useImageCanvasProjectPersistence({
|
||||
if (!projectId || !isProjectReady) {
|
||||
return undefined;
|
||||
}
|
||||
if (skipNextProjectLayoutSaveRef.current) {
|
||||
skipNextProjectLayoutSaveRef.current = false;
|
||||
return undefined;
|
||||
}
|
||||
queueProjectLayoutSave(
|
||||
projectId,
|
||||
{
|
||||
|
||||
@@ -69,6 +69,7 @@ describe('buildExternalGenerationQueueStatus', () => {
|
||||
{
|
||||
pendingCount: 7,
|
||||
runningCount: 3,
|
||||
unacknowledgedTerminalCount: 1,
|
||||
updatedAtMicros: 1_781_222_400_000_000,
|
||||
},
|
||||
{
|
||||
@@ -85,6 +86,8 @@ describe('buildExternalGenerationQueueStatus', () => {
|
||||
currentProgress: 35,
|
||||
pendingCount: 7,
|
||||
runningCount: 3,
|
||||
unacknowledgedTerminalCount: 1,
|
||||
tasks: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +102,7 @@ describe('buildExternalGenerationQueueStatus', () => {
|
||||
currentProgress: 42.4,
|
||||
pendingCount: 2,
|
||||
runningCount: 1,
|
||||
unacknowledgedTerminalCount: 0,
|
||||
}),
|
||||
).toEqual({
|
||||
statusLabel: '生成中',
|
||||
@@ -107,7 +111,9 @@ describe('buildExternalGenerationQueueStatus', () => {
|
||||
runningLabel: '1',
|
||||
pendingCount: 2,
|
||||
runningCount: 1,
|
||||
unacknowledgedTerminalCount: 0,
|
||||
progress: 42,
|
||||
tasks: [],
|
||||
shouldShow: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type {
|
||||
ExternalGenerationJobStatusRecord,
|
||||
ExternalGenerationQueueOverview,
|
||||
ExternalGenerationTaskRecord,
|
||||
} from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import type {
|
||||
JumpHopJumpRequest,
|
||||
@@ -193,7 +193,10 @@ import {
|
||||
regenerateBabyObjectMatchDraftAssets,
|
||||
saveBabyObjectMatchDraft,
|
||||
} from '../../services/edutainment-baby-object';
|
||||
import { getExternalGenerationQueueOverview } from '../../services/external-generation';
|
||||
import {
|
||||
acknowledgeExternalGenerationTasks,
|
||||
listExternalGenerationTasks,
|
||||
} from '../../services/external-generation';
|
||||
import {
|
||||
jumpHopClient,
|
||||
type JumpHopGalleryCardResponse,
|
||||
@@ -720,6 +723,54 @@ function isExternalGenerationQueueStage(selectionStage: SelectionStage) {
|
||||
);
|
||||
}
|
||||
|
||||
function isUnacknowledgedExternalGenerationTerminalTask(
|
||||
task: ExternalGenerationTaskRecord,
|
||||
) {
|
||||
return (
|
||||
(task.status === 'completed' || task.status === 'failed') &&
|
||||
!task.notificationAcknowledgedAt
|
||||
);
|
||||
}
|
||||
|
||||
function resolveExternalGenerationTaskCompletionDialog(
|
||||
tasks: readonly ExternalGenerationTaskRecord[],
|
||||
): PlatformTaskCompletionDialogState | null {
|
||||
const terminalTasks = tasks.filter(
|
||||
isUnacknowledgedExternalGenerationTerminalTask,
|
||||
);
|
||||
if (terminalTasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const jobIds = terminalTasks.map((task) => task.jobId);
|
||||
const latestUpdatedAtMicros = terminalTasks.reduce(
|
||||
(latest, task) => Math.max(latest, task.updatedAtMicros),
|
||||
0,
|
||||
);
|
||||
const completedAtMs =
|
||||
latestUpdatedAtMicros > 0
|
||||
? Math.floor(latestUpdatedAtMicros / 1000)
|
||||
: Date.now();
|
||||
|
||||
return {
|
||||
key: `external-generation:${jobIds.join('|')}:${latestUpdatedAtMicros}`,
|
||||
source: '生成任务',
|
||||
message:
|
||||
terminalTasks.length > 1
|
||||
? `${terminalTasks.length} 个生成任务已结束。`
|
||||
: '生成任务已结束。',
|
||||
completedAtMs,
|
||||
taskJobIds: jobIds,
|
||||
items: terminalTasks.map((task) => ({
|
||||
source: task.requestLabel.trim() || task.jobKind || task.jobId,
|
||||
message:
|
||||
task.status === 'failed'
|
||||
? (task.error?.trim() || '生成失败。')
|
||||
: '生成已完成。',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
type PuzzleDetailReturnTarget = {
|
||||
tab: PlatformHomeTab;
|
||||
};
|
||||
@@ -1839,8 +1890,16 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [puzzleOperation, setPuzzleOperation] = useState<PuzzleAgentOperationRecord | null>(
|
||||
null,
|
||||
);
|
||||
const [externalGenerationQueueOverview, setExternalGenerationQueueOverview] =
|
||||
useState<ExternalGenerationQueueOverview | null>(null);
|
||||
const [externalGenerationTasks, setExternalGenerationTasks] = useState<
|
||||
ExternalGenerationTaskRecord[]
|
||||
>([]);
|
||||
const [externalGenerationTaskOverview, setExternalGenerationTaskOverview] =
|
||||
useState<{
|
||||
pendingCount: number;
|
||||
runningCount: number;
|
||||
unacknowledgedTerminalCount: number;
|
||||
updatedAtMicros: number;
|
||||
} | null>(null);
|
||||
const [jumpHopQueueState, setJumpHopQueueState] =
|
||||
useState<ExternalGenerationJobStatusRecord | null>(null);
|
||||
const [puzzleClearQueueState, setPuzzleClearQueueState] =
|
||||
@@ -4922,18 +4981,21 @@ export function PlatformEntryFlowShellImpl({
|
||||
platformBootstrap.platformTab === 'profile' &&
|
||||
platformBootstrap.canReadProtectedData);
|
||||
const isHostNetworkOnline = useHostNetworkOnline();
|
||||
const shouldPollExternalGenerationTasks =
|
||||
platformBootstrap.canReadProtectedData && Boolean(authUi?.user?.id);
|
||||
const externalGenerationQueueOwnerKey =
|
||||
shouldShowExternalGenerationQueueStatus
|
||||
shouldPollExternalGenerationTasks
|
||||
? (authUi?.user?.id ?? 'anonymous')
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
setExternalGenerationQueueOverview(null);
|
||||
setExternalGenerationTasks([]);
|
||||
setExternalGenerationTaskOverview(null);
|
||||
}, [externalGenerationQueueOwnerKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldShowExternalGenerationQueueStatus ||
|
||||
!shouldPollExternalGenerationTasks ||
|
||||
!isHostNetworkOnline
|
||||
) {
|
||||
return;
|
||||
@@ -4942,24 +5004,30 @@ export function PlatformEntryFlowShellImpl({
|
||||
let disposed = false;
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
const refreshQueueOverview = () => {
|
||||
const refreshTaskList = () => {
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
getExternalGenerationQueueOverview(controller.signal)
|
||||
listExternalGenerationTasks({
|
||||
limit: 20,
|
||||
includeAcknowledgedTerminal: false,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((response) => {
|
||||
if (!disposed) {
|
||||
setExternalGenerationQueueOverview(response.overview);
|
||||
setExternalGenerationTasks(response.tasks);
|
||||
setExternalGenerationTaskOverview(response.overview);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed) {
|
||||
setExternalGenerationQueueOverview(null);
|
||||
setExternalGenerationTasks([]);
|
||||
setExternalGenerationTaskOverview(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
refreshQueueOverview();
|
||||
const intervalId = window.setInterval(refreshQueueOverview, 4000);
|
||||
refreshTaskList();
|
||||
const intervalId = window.setInterval(refreshTaskList, 4000);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
@@ -4969,7 +5037,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [
|
||||
externalGenerationQueueOwnerKey,
|
||||
isHostNetworkOnline,
|
||||
shouldShowExternalGenerationQueueStatus,
|
||||
shouldPollExternalGenerationTasks,
|
||||
]);
|
||||
const activeExternalGenerationJobState = useMemo(() => {
|
||||
const candidates = [
|
||||
@@ -4998,10 +5066,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
const externalGenerationQueueStatus = useMemo(
|
||||
() =>
|
||||
buildExternalGenerationQueueStatus(
|
||||
externalGenerationQueueOverview,
|
||||
externalGenerationTaskOverview,
|
||||
activeExternalGenerationJobState,
|
||||
externalGenerationTasks,
|
||||
),
|
||||
[activeExternalGenerationJobState, externalGenerationQueueOverview],
|
||||
[
|
||||
activeExternalGenerationJobState,
|
||||
externalGenerationTaskOverview,
|
||||
externalGenerationTasks,
|
||||
],
|
||||
);
|
||||
const profileExternalGenerationQueueStatus =
|
||||
platformBootstrap.canReadProtectedData &&
|
||||
@@ -5235,13 +5308,24 @@ export function PlatformEntryFlowShellImpl({
|
||||
woodenFishRun?.runId,
|
||||
woodenFishSession?.sessionId,
|
||||
]);
|
||||
const externalGenerationTaskCompletionDialog =
|
||||
useMemo<PlatformTaskCompletionDialogState | null>(
|
||||
() => resolveExternalGenerationTaskCompletionDialog(externalGenerationTasks),
|
||||
[externalGenerationTasks],
|
||||
);
|
||||
const currentPlatformTaskCompletionDialog =
|
||||
useMemo<PlatformTaskCompletionDialogState | null>(
|
||||
() => pendingPlatformTaskCompletionDialog,
|
||||
[pendingPlatformTaskCompletionDialog],
|
||||
() =>
|
||||
pendingPlatformTaskCompletionDialog ??
|
||||
externalGenerationTaskCompletionDialog,
|
||||
[
|
||||
externalGenerationTaskCompletionDialog,
|
||||
pendingPlatformTaskCompletionDialog,
|
||||
],
|
||||
);
|
||||
const activePlatformTaskCompletionDialog =
|
||||
selectionStage === 'image-editor'
|
||||
selectionStage === 'image-editor' &&
|
||||
!(currentPlatformTaskCompletionDialog?.taskJobIds?.length ?? 0)
|
||||
? null
|
||||
: resolveActivePlatformDialog(
|
||||
currentPlatformTaskCompletionDialog,
|
||||
@@ -5374,6 +5458,36 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (dismissKey) {
|
||||
setDismissedPlatformTaskCompletionDialogKey(dismissKey);
|
||||
}
|
||||
const taskJobIds = currentPlatformTaskCompletionDialog.taskJobIds ?? [];
|
||||
if (taskJobIds.length > 0) {
|
||||
const acknowledgedJobIds = new Set(taskJobIds);
|
||||
setExternalGenerationTasks((current) =>
|
||||
current.filter((task) => !acknowledgedJobIds.has(task.jobId)),
|
||||
);
|
||||
setExternalGenerationTaskOverview((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
unacknowledgedTerminalCount: Math.max(
|
||||
0,
|
||||
current.unacknowledgedTerminalCount - acknowledgedJobIds.size,
|
||||
),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
void acknowledgeExternalGenerationTasks({ jobIds: taskJobIds })
|
||||
.then((response) => {
|
||||
setExternalGenerationTasks((current) =>
|
||||
current.filter(
|
||||
(task) =>
|
||||
!response.acknowledgedTasks.some(
|
||||
(acknowledgedTask) => acknowledgedTask.jobId === task.jobId,
|
||||
),
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
setPendingPlatformTaskCompletionDialog(null);
|
||||
}, [currentPlatformTaskCompletionDialog]);
|
||||
const shouldPollPuzzleGenerationSession =
|
||||
|
||||
@@ -69,7 +69,37 @@ export function PlatformProfileGenerationQueueCard({
|
||||
fillClassName="bg-[linear-gradient(90deg,#e47631,#ce5f2a)]"
|
||||
/>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{presentation.tasks.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{presentation.tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="rounded-[8px] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate text-[12px] font-black text-[var(--platform-text-strong)]">
|
||||
{task.title}
|
||||
</span>
|
||||
<PlatformPillBadge
|
||||
tone={task.tone}
|
||||
size="xxs"
|
||||
className="shrink-0"
|
||||
>
|
||||
{task.statusLabel}
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center justify-between gap-2 text-[11px] font-medium text-[var(--platform-text-base)]">
|
||||
<span className="min-w-0 truncate">{task.detail}</span>
|
||||
<span className="shrink-0 font-black text-[var(--platform-text-strong)]">
|
||||
{task.priceLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
<div className="platform-profile-generation-queue-card__metric">
|
||||
<span>排队</span>
|
||||
<strong>{presentation.pendingLabel}</strong>
|
||||
@@ -78,6 +108,10 @@ export function PlatformProfileGenerationQueueCard({
|
||||
<span>生成</span>
|
||||
<strong>{presentation.runningLabel}</strong>
|
||||
</div>
|
||||
<div className="platform-profile-generation-queue-card__metric">
|
||||
<span>待提示</span>
|
||||
<strong>{presentation.unacknowledgedTerminalCount}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,10 @@ import { PlatformReportDialog } from '../common/PlatformReportDialog';
|
||||
export type PlatformTaskCompletionDialogPayload = {
|
||||
source: string;
|
||||
message: string;
|
||||
items?: Array<{
|
||||
source: string;
|
||||
message: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type PlatformTaskCompletionDialogProps = {
|
||||
@@ -26,17 +30,23 @@ export function PlatformTaskCompletionDialog({
|
||||
copyIdleLabel="复制内容"
|
||||
fields={
|
||||
completion
|
||||
? [
|
||||
{
|
||||
label: '来源',
|
||||
value: completion.source,
|
||||
},
|
||||
{
|
||||
label: '状态',
|
||||
value: completion.message,
|
||||
? completion.items?.length
|
||||
? completion.items.map((item, index) => ({
|
||||
label: item.source || `任务 ${index + 1}`,
|
||||
value: item.message,
|
||||
multiline: true,
|
||||
},
|
||||
]
|
||||
}))
|
||||
: [
|
||||
{
|
||||
label: '来源',
|
||||
value: completion.source,
|
||||
},
|
||||
{
|
||||
label: '状态',
|
||||
value: completion.message,
|
||||
multiline: true,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
overlayClassName={overlayClassName}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type PlatformTaskCompletionDialogState =
|
||||
PlatformTaskCompletionDialogPayload & {
|
||||
key: string;
|
||||
completedAtMs: number | null;
|
||||
taskJobIds?: string[];
|
||||
};
|
||||
|
||||
export type PlatformDialogCandidate = {
|
||||
@@ -68,7 +69,7 @@ export function buildPlatformTaskCompletionDialogDismissKey(
|
||||
completion: PlatformTaskCompletionDialogState | null,
|
||||
) {
|
||||
return completion
|
||||
? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}`
|
||||
? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}:${completion.taskJobIds?.join(',') ?? ''}`
|
||||
: null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ExternalGenerationJobStatusRecord,
|
||||
ExternalGenerationQueueOverview,
|
||||
ExternalGenerationTaskRecord,
|
||||
} from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
|
||||
export type ExternalGenerationQueueStatus = {
|
||||
@@ -8,6 +9,17 @@ export type ExternalGenerationQueueStatus = {
|
||||
currentProgress?: number | null;
|
||||
pendingCount?: number | null;
|
||||
runningCount?: number | null;
|
||||
tasks?: ExternalGenerationTaskRecord[] | null;
|
||||
unacknowledgedTerminalCount?: number | null;
|
||||
};
|
||||
|
||||
export type ExternalGenerationQueueTaskPresentation = {
|
||||
id: string;
|
||||
title: string;
|
||||
statusLabel: string;
|
||||
priceLabel: string;
|
||||
detail: string;
|
||||
tone: 'profile' | 'danger' | 'success';
|
||||
};
|
||||
|
||||
export type ExternalGenerationQueuePresentation = {
|
||||
@@ -17,15 +29,18 @@ export type ExternalGenerationQueuePresentation = {
|
||||
runningLabel: string;
|
||||
pendingCount: number;
|
||||
runningCount: number;
|
||||
unacknowledgedTerminalCount: number;
|
||||
progress: number | null;
|
||||
tasks: ExternalGenerationQueueTaskPresentation[];
|
||||
shouldShow: boolean;
|
||||
};
|
||||
|
||||
export function buildExternalGenerationQueueStatus(
|
||||
overview: ExternalGenerationQueueOverview | null,
|
||||
job: ExternalGenerationJobStatusRecord | null,
|
||||
tasks: ExternalGenerationTaskRecord[] | null = null,
|
||||
): ExternalGenerationQueueStatus | null {
|
||||
if (!overview && !job) {
|
||||
if (!overview && !job && !tasks?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -34,6 +49,9 @@ export function buildExternalGenerationQueueStatus(
|
||||
currentProgress: job?.progress ?? null,
|
||||
pendingCount: overview?.pendingCount ?? null,
|
||||
runningCount: overview?.runningCount ?? null,
|
||||
unacknowledgedTerminalCount:
|
||||
overview?.unacknowledgedTerminalCount ?? null,
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,6 +112,26 @@ export function normalizeExternalGenerationQueueProgress(
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
export function buildExternalGenerationTaskPresentation(
|
||||
task: ExternalGenerationTaskRecord,
|
||||
): ExternalGenerationQueueTaskPresentation {
|
||||
const statusLabel = resolveExternalGenerationQueueStatusLabel(task.status) ?? '待处理';
|
||||
return {
|
||||
id: task.jobId,
|
||||
title: task.requestLabel.trim() || task.jobKind,
|
||||
statusLabel,
|
||||
priceLabel:
|
||||
task.priceMudPoints > 0 ? `${task.priceMudPoints}泥点` : '未计费',
|
||||
detail: task.error?.trim() || task.phaseDetail || task.updatedAt,
|
||||
tone:
|
||||
task.status === 'failed'
|
||||
? 'danger'
|
||||
: task.status === 'completed'
|
||||
? 'success'
|
||||
: 'profile',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildExternalGenerationQueuePresentation(
|
||||
status: ExternalGenerationQueueStatus | null | undefined,
|
||||
): ExternalGenerationQueuePresentation {
|
||||
@@ -103,6 +141,9 @@ export function buildExternalGenerationQueuePresentation(
|
||||
const runningCount = normalizeExternalGenerationQueueCount(
|
||||
status?.runningCount,
|
||||
);
|
||||
const unacknowledgedTerminalCount = normalizeExternalGenerationQueueCount(
|
||||
status?.unacknowledgedTerminalCount,
|
||||
);
|
||||
const progress = normalizeExternalGenerationQueueProgress(
|
||||
status?.currentProgress,
|
||||
);
|
||||
@@ -126,7 +167,17 @@ export function buildExternalGenerationQueuePresentation(
|
||||
runningLabel: runningCount.toString(),
|
||||
pendingCount,
|
||||
runningCount,
|
||||
unacknowledgedTerminalCount,
|
||||
progress: progressValue,
|
||||
shouldShow: Boolean(statusLabel || pendingCount > 0 || runningCount > 0),
|
||||
tasks: (status?.tasks ?? [])
|
||||
.slice(0, 5)
|
||||
.map(buildExternalGenerationTaskPresentation),
|
||||
shouldShow: Boolean(
|
||||
statusLabel ||
|
||||
pendingCount > 0 ||
|
||||
runningCount > 0 ||
|
||||
unacknowledgedTerminalCount > 0 ||
|
||||
(status?.tasks?.length ?? 0) > 0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type {
|
||||
ExternalGenerationJobStatusResponse,
|
||||
ExternalGenerationQueueOverviewResponse,
|
||||
ExternalGenerationTaskAcknowledgeRequest,
|
||||
ExternalGenerationTaskAcknowledgeResponse,
|
||||
ExternalGenerationTaskListResponse,
|
||||
} from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { BACKGROUND_AUTH_REQUEST_OPTIONS, requestJson } from '../apiClient';
|
||||
|
||||
@@ -43,7 +46,54 @@ export async function getExternalGenerationJobStatus(
|
||||
);
|
||||
}
|
||||
|
||||
export async function listExternalGenerationTasks(
|
||||
options: {
|
||||
limit?: number;
|
||||
includeAcknowledgedTerminal?: boolean;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (typeof options.limit === 'number') {
|
||||
params.set('limit', String(options.limit));
|
||||
}
|
||||
if (typeof options.includeAcknowledgedTerminal === 'boolean') {
|
||||
params.set(
|
||||
'includeAcknowledgedTerminal',
|
||||
options.includeAcknowledgedTerminal ? 'true' : 'false',
|
||||
);
|
||||
}
|
||||
const query = params.toString();
|
||||
return requestJson<ExternalGenerationTaskListResponse>(
|
||||
`${EXTERNAL_GENERATION_API_BASE}/jobs${query ? `?${query}` : ''}`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal: options.signal,
|
||||
},
|
||||
'读取生成任务列表失败',
|
||||
EXTERNAL_GENERATION_READ_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function acknowledgeExternalGenerationTasks(
|
||||
payload: ExternalGenerationTaskAcknowledgeRequest,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return requestJson<ExternalGenerationTaskAcknowledgeResponse>(
|
||||
`${EXTERNAL_GENERATION_API_BASE}/jobs/acknowledge`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
},
|
||||
'确认生成任务通知失败',
|
||||
EXTERNAL_GENERATION_READ_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
export const externalGenerationClient = {
|
||||
getQueueOverview: getExternalGenerationQueueOverview,
|
||||
getJobStatus: getExternalGenerationJobStatus,
|
||||
listTasks: listExternalGenerationTasks,
|
||||
acknowledgeTasks: acknowledgeExternalGenerationTasks,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export {
|
||||
acknowledgeExternalGenerationTasks,
|
||||
externalGenerationClient,
|
||||
getExternalGenerationJobStatus,
|
||||
getExternalGenerationQueueOverview,
|
||||
listExternalGenerationTasks,
|
||||
} from './externalGenerationClient';
|
||||
|
||||
Reference in New Issue
Block a user