升级外部生成为正式任务列表
将 external_generation_job 扩展为含价格、确认时间、退款流水和事件审计的正式生成任务事实源 新增外部生成任务列表与确认 BFF,并让平台入口按后端任务补弹完成或失败提示 保护画布生成队列超时、快照回写和自动保存覆盖场景 将 worker 计费退款流水写入 externalGenerationJobId,并补充冷备份后 worker/controller 恢复守护
This commit is contained in:
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user