File size: 6,732 Bytes
68722e4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 | import { useState } from 'react';
import type { ProblemFramingPlan } from '../../../shared/types/api';
import { useI18n } from '../../../shared/i18n';
import { ProblemPlanEditor } from './ProblemPlanEditor';
interface ProblemFramingOverlayProps {
open: boolean;
status: 'loading' | 'ready' | 'error';
plan: ProblemFramingPlan | null;
error: string | null;
adjustment: string;
generating: boolean;
onAdjustmentChange: (value: string) => void;
onRetry: () => void;
onInitialRetry: () => void;
onPlanChange: (plan: ProblemFramingPlan) => void;
onGenerate: () => void;
onClose: () => void;
}
/** 等待时卡片右上角的三只猫爪:猫在整理结构。方案就绪后隐去,不抢位。*/
function PawStamp({ delay = 0 }: { delay?: number }) {
return (
<div className="problem-paw text-text-secondary/55" style={{ animationDelay: `${delay}s` }}>
<svg width="34" height="34" viewBox="0 0 24 24" fill="currentColor">
<ellipse cx="12" cy="15" rx="5.2" ry="4.1" />
<circle cx="7" cy="9.1" r="2.15" />
<circle cx="12" cy="6.8" r="2.15" />
<circle cx="17" cy="9.1" r="2.15" />
</svg>
</div>
);
}
function WaitingPaws() {
return (
<div aria-hidden="true" className="pointer-events-none absolute right-5 top-2.5 flex items-center gap-2 opacity-80">
<PawStamp />
<PawStamp delay={0.18} />
<PawStamp delay={0.36} />
</div>
);
}
export function ProblemFramingOverlay(props: ProblemFramingOverlayProps) {
return props.open ? <ProblemFramingOverlayContent {...props} /> : null;
}
function ProblemFramingOverlayContent({ status, plan, error, adjustment, generating,
onAdjustmentChange, onRetry, onInitialRetry, onPlanChange, onGenerate, onClose,
}: ProblemFramingOverlayProps) {
const { t } = useI18n();
const [confirmDiscard, setConfirmDiscard] = useState(false);
const busy = status === 'loading' || generating;
const statusKey = status === 'loading'
? plan ? 'problem.status.refining' : 'problem.status.loading'
: status === 'ready' ? 'problem.status.ready' : 'problem.status.error';
return <div className="fixed inset-0 z-[80] flex items-center justify-center bg-bg-primary/75 p-3 backdrop-blur-sm sm:p-8">
<section role="dialog" aria-modal="true" aria-label={t('problem.badge')} className="relative flex h-[min(88dvh,900px)] w-full max-w-4xl flex-col overflow-hidden rounded-[2rem] border border-bg-tertiary/40 bg-bg-secondary shadow-xl">
{status === 'loading' && <WaitingPaws />}
<header className={`flex shrink-0 items-center justify-between gap-4 px-5 py-4 sm:px-8 ${status === 'loading' ? 'pr-[10rem] sm:pr-[10.5rem]' : ''}`}>
<button type="button" onClick={() => setConfirmDiscard(true)} className="rounded-xl px-3 py-2 text-sm text-text-secondary hover:bg-bg-primary focus-visible:ring-2 focus-visible:ring-accent">← {t('problem.back')}</button>
<p role="status" className="text-sm text-text-secondary">{t(statusKey)}</p>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-3 sm:px-8" aria-busy={status === 'loading'}>
{plan ? <ProblemPlanEditor plan={plan} onChange={onPlanChange} disabled={busy} /> : status === 'loading' ? <div className="mx-auto max-w-3xl space-y-5" aria-hidden="true">
{/* 骨架也用 rail 说话:圆点、竖线、长短不一的灰条,避免等待态回到卡片墙 */}
{Array.from({ length: 4 }, (_, i) => <div key={i} className="relative pl-9">
<span className="absolute left-0 top-[6px] h-[9px] w-[9px] rounded-full bg-text-tertiary/25" />
{i < 3 && <span className="absolute left-1 top-[26px] bottom-[-20px] w-px bg-text-tertiary/15" />}
<div className="h-4 w-36 animate-pulse rounded bg-text-tertiary/20" style={{ animationDelay: `${i * 120}ms` }} />
<div className="mt-2.5 h-3 w-full max-w-lg animate-pulse rounded bg-text-tertiary/15" style={{ animationDelay: `${i * 120 + 60}ms` }} />
<div className="mt-2 h-3 w-2/3 animate-pulse rounded bg-text-tertiary/15" style={{ animationDelay: `${i * 120 + 120}ms` }} />
</div>)}
</div> : <div className="py-16 text-center">
<h2 className="text-lg text-text-primary">{t('problem.initialErrorTitle')}</h2>
<p role="alert" className="mt-3 text-sm text-red-700">{error || t('problem.initialErrorDescription')}</p>
<button type="button" onClick={onInitialRetry} className="mt-6 rounded-xl bg-accent px-5 py-3 text-white">{t('problem.retry')}</button>
</div>}
{plan && status === 'error' && <p role="alert" className="py-3 text-sm text-red-700">{error || t('generation.problemFramingFailed')}</p>}
</div>
{plan && <footer className="shrink-0 border-t border-bg-tertiary/30 px-5 py-4 sm:px-8">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<textarea aria-label={t('problem.adjustLabel')} value={adjustment} onChange={e => onAdjustmentChange(e.target.value)} rows={2} disabled={busy} placeholder={t('problem.adjustPlaceholder')} className="min-w-0 flex-1 resize-none rounded-2xl bg-bg-primary/60 px-4 py-3 text-sm text-text-primary outline-none focus:ring-2 focus:ring-accent/30" />
<div className="flex shrink-0 justify-end gap-3">
<button type="button" onClick={onRetry} disabled={busy || !adjustment.trim()} className="rounded-xl bg-bg-primary px-4 py-3 text-sm text-text-secondary disabled:opacity-45">{t('problem.adjustAction')}</button>
<button type="button" onClick={onGenerate} disabled={busy} className="rounded-full bg-accent px-5 py-3 text-sm text-white disabled:opacity-45">{generating ? t('form.submitting') : t('problem.start')}</button>
</div>
</div>
</footer>}
{confirmDiscard && <div className="absolute inset-0 z-10 flex items-center justify-center bg-bg-secondary/95 p-6">
<div role="alertdialog" aria-modal="true" aria-labelledby="discard-plan-title" className="w-full max-w-sm rounded-2xl border border-bg-tertiary/40 bg-bg-primary p-6">
<h2 id="discard-plan-title" className="text-text-primary">{t('problem.discardTitle')}</h2>
<p className="mt-3 text-sm text-text-secondary">{t('problem.discardDescription')}</p>
<div className="mt-6 flex justify-end gap-3">
<button type="button" autoFocus onClick={() => setConfirmDiscard(false)} className="rounded-xl px-4 py-2 text-text-secondary">{t('common.cancel')}</button>
<button type="button" onClick={onClose} className="rounded-xl bg-accent px-4 py-2 text-white">{t('common.confirm')}</button>
</div>
</div>
</div>}
</section>
</div>;
}
|