Preserve partial creation replies on stream failure
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
380
src/components/square-hole-runtime/SquareHoleRuntimeShell.tsx
Normal file
380
src/components/square-hole-runtime/SquareHoleRuntimeShell.tsx
Normal 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;
|
||||
1
src/components/square-hole-runtime/index.ts
Normal file
1
src/components/square-hole-runtime/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SquareHoleRuntimeShell } from './SquareHoleRuntimeShell';
|
||||
Reference in New Issue
Block a user