NavDrawer 汉堡按钮补 aria-controls 指向抽屉面板(Drawer 新增可选 id prop,默认 nav-drawer);ForeshadowCard 的 <dl> 裸 <div> 行改真 <dt>/<dd> 结构,可视文案不变,importance 用 sr-only 标签。行为不变。
155 lines
4.9 KiB
TypeScript
155 lines
4.9 KiB
TypeScript
"use client";
|
||
|
||
import { AlertTriangle } from "lucide-react";
|
||
import { useState } from "react";
|
||
|
||
import { Badge } from "@/components/ui/Badge";
|
||
import { Button } from "@/components/ui/Button";
|
||
import { TextInput } from "@/components/ui/TextInput";
|
||
import type { ForeshadowView } from "@/lib/api/types";
|
||
import { LANE_LABELS, type ForeshadowStatus } from "@/lib/foreshadow/board";
|
||
|
||
interface ForeshadowCardProps {
|
||
item: ForeshadowView;
|
||
overdue: boolean;
|
||
busy: boolean;
|
||
onTransition: (
|
||
code: string,
|
||
toStatus: ForeshadowStatus | null,
|
||
progressNote: string,
|
||
) => void;
|
||
}
|
||
|
||
const NEXT_STATUS: Record<string, ForeshadowStatus[]> = {
|
||
OPEN: ["PARTIAL", "CLOSED"],
|
||
PARTIAL: ["CLOSED"],
|
||
OVERDUE: ["PARTIAL", "CLOSED"],
|
||
CLOSED: [],
|
||
};
|
||
|
||
// 伏笔卡(UX §6.8):code/title/回收窗口/importance/progress 时间线 + 状态变更/加进展。
|
||
// OVERDUE 用琥珀强调(不单靠色:加 ⚠ 图标 + 文案,a11y §10)。
|
||
export function ForeshadowCard({
|
||
item,
|
||
overdue,
|
||
busy,
|
||
onTransition,
|
||
}: ForeshadowCardProps) {
|
||
const [note, setNote] = useState("");
|
||
const transitions = NEXT_STATUS[item.status] ?? [];
|
||
const windowText = recoveryWindow(item);
|
||
|
||
return (
|
||
<li
|
||
className={`rounded border bg-panel p-3 ${
|
||
overdue ? "border-overdue ring-1 ring-overdue/40" : "border-line"
|
||
}`}
|
||
>
|
||
<div className="flex items-baseline gap-2">
|
||
<span className="font-mono text-xs text-cinnabar">{item.code}</span>
|
||
{overdue ? (
|
||
<Badge variant="warning" aria-label="逾期">
|
||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||
逾期
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
<p className="mt-0.5 font-serif text-sm text-ink">{item.title}</p>
|
||
|
||
<dl className="mt-2 space-y-0.5 text-xs text-ink-soft">
|
||
{typeof item.planted_at === "number" ? (
|
||
<div className="flex gap-1">
|
||
<dt>埋于</dt>
|
||
<dd>{item.planted_at} 章</dd>
|
||
</div>
|
||
) : null}
|
||
{windowText ? (
|
||
<div className="flex gap-1">
|
||
<dt>窗口</dt>
|
||
<dd>{windowText}</dd>
|
||
</div>
|
||
) : null}
|
||
{item.importance ? (
|
||
<div className="flex gap-1">
|
||
<dt className="sr-only">重要度</dt>
|
||
<dd>{item.importance}</dd>
|
||
</div>
|
||
) : null}
|
||
</dl>
|
||
|
||
{item.progress && item.progress.length > 0 ? (
|
||
<ul className="mt-2 border-l-2 border-line pl-2 text-xs text-ink-soft">
|
||
{item.progress.map((p, i) => (
|
||
<li key={i}>· {progressLabel(p)}</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
|
||
{transitions.length > 0 || item.status !== "CLOSED" ? (
|
||
<div className="mt-3 space-y-2">
|
||
{transitions.length > 0 ? (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{transitions.map((to) => (
|
||
<Button
|
||
key={to}
|
||
disabled={busy}
|
||
onClick={() => onTransition(item.code, to, "")}
|
||
variant="secondary"
|
||
size="sm"
|
||
className="px-2 py-0.5"
|
||
>
|
||
→ {to === "CLOSED" ? "回收" : LANE_LABELS[to]}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{item.status !== "CLOSED" ? (
|
||
<div className="flex gap-1.5">
|
||
<label htmlFor={`prog-${item.code}`} className="sr-only">
|
||
{item.code} 进展
|
||
</label>
|
||
<TextInput
|
||
id={`prog-${item.code}`}
|
||
type="text"
|
||
value={note}
|
||
onChange={(e) => setNote(e.target.value)}
|
||
placeholder="加进展(如:23章发光)"
|
||
controlSize="sm"
|
||
className="min-w-0 flex-1 py-0.5"
|
||
/>
|
||
<Button
|
||
disabled={busy || note.trim().length === 0}
|
||
onClick={() => {
|
||
onTransition(item.code, null, note.trim());
|
||
setNote("");
|
||
}}
|
||
variant="secondary"
|
||
size="sm"
|
||
className="px-2 py-0.5"
|
||
>
|
||
记
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
function recoveryWindow(item: ForeshadowView): string | null {
|
||
const from = item.expected_close_from;
|
||
const to = item.expected_close_to;
|
||
if (typeof from === "number" && typeof to === "number") return `${from}-${to}`;
|
||
if (typeof to === "number") return `≤${to}`;
|
||
if (typeof from === "number") return `≥${from}`;
|
||
return null;
|
||
}
|
||
|
||
function progressLabel(entry: Record<string, unknown>): string {
|
||
const chapter = entry["chapter"];
|
||
const note = entry["note"];
|
||
const noteStr = typeof note === "string" ? note : JSON.stringify(entry);
|
||
return typeof chapter === "number" ? `${chapter}章:${noteStr}` : noteStr;
|
||
}
|