Preserve partial creation replies on stream failure
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
kdletters
2026-05-05 11:31:50 +08:00
parent 100fee7e7a
commit 995661e7cc
299 changed files with 13805 additions and 1429 deletions

View File

@@ -0,0 +1,380 @@
import {
ArrowLeft,
CheckCircle2,
Clock3,
RotateCcw,
Shapes,
Sparkles,
XCircle,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type {
DropSquareHoleShapeRequest,
SquareHoleDropResponse,
SquareHoleHoleSnapshot,
SquareHoleRunSnapshot,
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
type SquareHoleRuntimeShellProps = {
run: SquareHoleRunSnapshot | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onRestart: () => void;
onDropShape: (
payload: DropSquareHoleShapeRequest,
) => Promise<SquareHoleDropResponse>;
onOptimisticRunChange?: (run: SquareHoleRunSnapshot) => void;
onTimeExpired?: () => void;
};
type PendingDrop = {
clientEventId: string;
holeId: string;
};
function isRunning(run: SquareHoleRunSnapshot) {
return run.status.toLowerCase() === 'running';
}
function formatTimer(value: number) {
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function buildClientEventId(runId: string, holeId: string) {
return `square-hole-drop-${runId}-${holeId}-${Date.now()}-${Math.round(
Math.random() * 1_000_000,
)}`;
}
function getHoleShapeClass(hole: SquareHoleHoleSnapshot) {
const kind = hole.holeKind.toLowerCase();
if (kind.includes('circle')) {
return 'rounded-full';
}
if (kind.includes('triangle')) {
return 'square-hole-runtime__hole-cut--triangle';
}
if (kind.includes('diamond')) {
return 'rotate-45 rounded-[0.75rem]';
}
if (kind.includes('star')) {
return 'square-hole-runtime__hole-cut--star';
}
if (kind.includes('arch')) {
return 'rounded-t-full rounded-b-[0.85rem]';
}
return 'rounded-[0.85rem]';
}
function getShapePreviewClass(shapeKind: string) {
const kind = shapeKind.toLowerCase();
if (kind.includes('circle')) {
return 'rounded-full';
}
if (kind.includes('triangle')) {
return 'square-hole-runtime__shape--triangle';
}
if (kind.includes('diamond')) {
return 'rotate-45 rounded-[0.8rem]';
}
if (kind.includes('star')) {
return 'square-hole-runtime__shape--star';
}
if (kind.includes('arch')) {
return 'rounded-t-full rounded-b-[0.9rem]';
}
return 'rounded-[0.9rem]';
}
function SquareHoleSettlement({
run,
onBack,
onRestart,
}: {
run: SquareHoleRunSnapshot;
onBack: () => void;
onRestart: () => void;
}) {
if (isRunning(run)) {
return null;
}
const won = run.status.toLowerCase() === 'won';
const stopped = run.status.toLowerCase() === 'stopped';
const title = won ? '挑战完成' : stopped ? '已停止' : '本轮失败';
return (
<div className="absolute inset-0 z-[80] flex items-center justify-center bg-slate-950/64 px-5 backdrop-blur-sm">
<section
className="w-full max-w-sm rounded-[1.5rem] border border-white/18 bg-white/94 p-5 text-slate-950 shadow-[0_26px_70px_rgba(15,23,42,0.34)]"
role="dialog"
aria-label={title}
>
<div className="mb-4 flex items-center gap-3">
<span
className={`flex h-11 w-11 items-center justify-center rounded-full ${
won
? 'bg-emerald-100 text-emerald-700'
: 'bg-rose-100 text-rose-700'
}`}
>
{won ? <CheckCircle2 size={24} /> : <XCircle size={24} />}
</span>
<div>
<h2 className="text-xl font-black">{title}</h2>
<p className="text-sm font-semibold text-slate-500">
{run.completedShapeCount}/{run.totalShapeCount} · {run.score}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-black text-slate-700"
onClick={onBack}
>
</button>
<button
type="button"
className="rounded-xl bg-slate-950 px-4 py-3 text-sm font-black text-white"
onClick={onRestart}
>
</button>
</div>
</section>
</div>
);
}
export function SquareHoleRuntimeShell({
run,
isBusy = false,
error = null,
onBack,
onRestart,
onDropShape,
onOptimisticRunChange,
onTimeExpired,
}: SquareHoleRuntimeShellProps) {
const [pendingDrop, setPendingDrop] = useState<PendingDrop | null>(null);
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
const [feedbackPulseId, setFeedbackPulseId] = useState<string | null>(null);
useEffect(() => {
setTimeLeftMs(run?.remainingMs ?? 0);
}, [run?.remainingMs, run?.snapshotVersion]);
useEffect(() => {
if (!run || !isRunning(run)) {
return undefined;
}
const timer = window.setInterval(() => {
setTimeLeftMs((current) => {
const next = Math.max(0, current - 1000);
if (next <= 0) {
onTimeExpired?.();
}
return next;
});
}, 1000);
return () => window.clearInterval(timer);
}, [onTimeExpired, run]);
useEffect(() => {
if (!feedbackPulseId) {
return undefined;
}
const timer = window.setTimeout(() => setFeedbackPulseId(null), 620);
return () => window.clearTimeout(timer);
}, [feedbackPulseId]);
const progressText = useMemo(() => {
if (!run) {
return '0/0';
}
return `${run.completedShapeCount}/${run.totalShapeCount}`;
}, [run]);
const dropToHole = async (holeId: string) => {
if (!run || !isRunning(run) || pendingDrop || isBusy) {
return;
}
const clientEventId = buildClientEventId(run.runId, holeId);
setPendingDrop({ clientEventId, holeId });
setFeedbackPulseId(null);
try {
const response = await onDropShape({
runId: run.runId,
holeId,
clientSnapshotVersion: run.snapshotVersion,
clientEventId,
droppedAtMs: Date.now(),
});
setFeedbackPulseId(clientEventId);
onOptimisticRunChange?.(response.run);
} finally {
setPendingDrop(null);
}
};
if (!run) {
return (
<div className="flex min-h-dvh items-center justify-center bg-slate-950 text-white">
{isBusy ? '载入中' : (error ?? '暂无运行态')}
</div>
);
}
const currentShape = run.currentShape;
const feedback = run.lastFeedback;
return (
<main className="relative flex min-h-dvh w-full justify-center overflow-hidden bg-[#101827] text-white">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_10%,rgba(125,211,252,0.32),transparent_27%),radial-gradient(circle_at_80%_80%,rgba(248,113,113,0.24),transparent_34%),linear-gradient(180deg,#1f3a5f_0%,#152238_48%,#111827_100%)]" />
<div
className="relative flex min-h-dvh min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]"
style={{
boxSizing: 'border-box',
maxWidth: '100vw',
width: 'min(100vw, 24rem)',
}}
>
<header className="flex items-center justify-between gap-2">
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/24 text-white backdrop-blur"
onClick={onBack}
aria-label="返回"
>
<ArrowLeft size={20} />
</button>
<div className="flex items-center gap-2 rounded-full border border-white/16 bg-black/26 px-3 py-2 text-sm font-black backdrop-blur">
<Clock3 size={16} />
<span>{formatTimer(timeLeftMs)}</span>
</div>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/24 text-white backdrop-blur"
onClick={onRestart}
aria-label="重新开始"
>
<RotateCcw size={18} />
</button>
</header>
<section className="mt-3 grid w-full min-w-0 grid-cols-3 gap-2 overflow-hidden text-center text-[0.72rem] font-black">
<div className="rounded-2xl border border-white/14 bg-black/20 px-2 py-2 backdrop-blur">
{progressText}
</div>
<div className="rounded-2xl border border-white/14 bg-black/20 px-2 py-2 backdrop-blur">
{run.score}
</div>
<div className="rounded-2xl border border-white/14 bg-black/20 px-2 py-2 backdrop-blur">
{run.combo}
</div>
</section>
<section className="mt-3 rounded-[1.5rem] border border-white/14 bg-black/18 p-3 shadow-[0_18px_42px_rgba(15,23,42,0.28)] backdrop-blur">
<div className="flex items-center justify-between gap-2 text-xs font-bold text-white/68">
<span>{run.ruleLabel}</span>
<span>v{run.snapshotVersion}</span>
</div>
<div className="mt-3 flex min-h-[12rem] items-center justify-center rounded-[1.35rem] border border-white/10 bg-white/10">
{currentShape ? (
<div className="flex flex-col items-center gap-3">
<div
className={`h-24 w-24 shadow-[0_18px_38px_rgba(15,23,42,0.34)] ${getShapePreviewClass(
currentShape.shapeKind,
)}`}
style={{
background:
currentShape.color ||
'linear-gradient(135deg,#f8fafc,#38bdf8)',
}}
/>
<div className="flex items-center gap-2 text-base font-black">
<Shapes size={18} />
<span>{currentShape.label}</span>
</div>
</div>
) : (
<div className="text-sm font-bold text-white/70"></div>
)}
</div>
</section>
<section className="mt-3 grid grid-cols-2 gap-2">
{run.holes.map((hole) => {
const isPending = pendingDrop?.holeId === hole.holeId;
return (
<button
key={hole.holeId}
type="button"
disabled={!isRunning(run) || Boolean(pendingDrop) || isBusy}
className={`min-h-[6.25rem] rounded-[1.35rem] border border-white/14 bg-black/22 p-2 text-white shadow-[0_12px_28px_rgba(15,23,42,0.24)] backdrop-blur transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-58 ${
isPending ? 'ring-2 ring-cyan-200/70' : ''
}`}
onClick={() => {
void dropToHole(hole.holeId);
}}
aria-label={`投入 ${hole.label}`}
>
<span className="mx-auto grid h-12 w-12 place-items-center rounded-2xl bg-white/88 p-2">
<span
className={`block h-full w-full bg-slate-950 ${getHoleShapeClass(
hole,
)}`}
/>
</span>
<span className="mt-2 block truncate text-sm font-black">
{hole.label}
</span>
</button>
);
})}
</section>
<section className="mt-auto min-h-[3.5rem] pt-3">
{feedback ? (
<div
className={`rounded-[1.2rem] border px-3 py-2 text-center text-sm font-black ${
feedback.accepted
? 'border-emerald-200/35 bg-emerald-400/18 text-emerald-50'
: 'border-rose-200/35 bg-rose-400/18 text-rose-50'
}`}
>
{feedback.message}
</div>
) : error ? (
<div className="rounded-[1.2rem] border border-rose-200/35 bg-rose-400/18 px-3 py-2 text-center text-sm font-black text-rose-50">
{error}
</div>
) : null}
</section>
</div>
{feedbackPulseId && feedback?.accepted ? (
<div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white/24 text-cyan-100 shadow-[0_0_42px_rgba(255,255,255,0.72)] backdrop-blur-sm">
<Sparkles size={42} />
</div>
</div>
) : null}
<SquareHoleSettlement run={run} onBack={onBack} onRestart={onRestart} />
</main>
);
}
export default SquareHoleRuntimeShell;