feat: improve app ui and project metadata

This commit is contained in:
Yaojia Wang
2026-06-28 07:31:20 +02:00
parent 3bd464d400
commit 90a66437d7
86 changed files with 4892 additions and 1108 deletions

View File

@@ -1,8 +1,18 @@
"use client";
import { useState } from "react";
import { CheckCircle2, CircleDashed, Flag, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { suggestForeshadowCode } from "@/lib/foreshadow/board";
import { api } from "@/lib/api/client";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput";
import {
nextFreeForeshadowCode,
suggestForeshadowCode,
} from "@/lib/foreshadow/board";
import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
import type { ForeshadowSuggestion } from "@/lib/review/sse";
@@ -36,11 +46,54 @@ export function ForeshadowSuggestions({
const [registered, setRegistered] = useState<Set<number>>(new Set());
const [resolved, setResolved] = useState<Set<number>>(new Set());
const [pending, setPending] = useState<Set<number>>(new Set());
// 项目已存在的伏笔(用于隐藏「之前会话就已保存过」的建议):
// savedTitles = 已登记伏笔的标题savedClosedCodes = 已 CLOSED已回收的 code。
const [savedTitles, setSavedTitles] = useState<Set<string>>(new Set());
const [savedClosedCodes, setSavedClosedCodes] = useState<Set<string>>(
new Set(),
);
// 看板已占用的全部代号——预填登记代号时跳过它们,避免撞码 422。
const [savedCodes, setSavedCodes] = useState<Set<string>>(new Set());
useEffect(() => {
let active = true;
void (async () => {
const { data } = await api.GET("/projects/{project_id}/foreshadow", {
params: { path: { project_id: projectId } },
});
if (!active || !data?.foreshadow) return;
setSavedTitles(new Set(data.foreshadow.map((f) => f.title.trim())));
setSavedClosedCodes(
new Set(
data.foreshadow
.filter((f) => f.status === "CLOSED")
.map((f) => f.code),
),
);
setSavedCodes(
new Set(data.foreshadow.map((f) => f.code).filter((c) => c)),
);
})();
return () => {
active = false;
};
}, [projectId]);
// 该建议是否「已保存过」→ 从列表隐藏:
// - 本次会话已登记/回收registered/resolved
// - 新埋建议:标题已存在于看板(之前已登记)
// - 疑似回收建议:对应 code 已 CLOSED之前已回收
const isAlreadySaved = (s: ForeshadowSuggestion, i: number): boolean => {
if (s.kind === "resolved") {
return resolved.has(i) || (s.code ? savedClosedCodes.has(s.code) : false);
}
return registered.has(i) || savedTitles.has(s.title.trim());
};
const openRegister = (i: number, s: ForeshadowSuggestion): void => {
setOpenIdx(i);
setForm({
code: suggestForeshadowCode(s.code, i),
code: nextFreeForeshadowCode(suggestForeshadowCode(s.code, i), savedCodes),
title: s.title,
plantedAt: chapterNo,
content: s.note ?? s.where ?? "",
@@ -73,6 +126,9 @@ export function ForeshadowSuggestions({
markPending(i, false);
if (ok) {
setRegistered((prev) => new Set(prev).add(i));
// 并入已存集合:连续登记时下一条不再取到同一空闲码,且该标题立即隐藏。
setSavedCodes((prev) => new Set(prev).add(form.code));
setSavedTitles((prev) => new Set(prev).add(form.title.trim()));
closeForm();
}
};
@@ -84,36 +140,42 @@ export function ForeshadowSuggestions({
if (ok) setResolved((prev) => new Set(prev).add(i));
};
// 保留原始下标 iregistered/resolved/pending/openIdx 均按 i 索引),仅过滤已保存项。
const visible = suggestions
.map((s, i) => ({ s, i }))
.filter(({ s, i }) => !isAlreadySaved(s, i));
return (
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(foreshadow-analyst)
</h2>
{incomplete ? (
<p className="mt-1 text-xs text-overdue"> </p>
) : suggestions.length === 0 ? (
<Badge variant="warning" className="mt-1">
<CircleDashed className="h-3 w-3" aria-hidden="true" />
</Badge>
) : visible.length === 0 ? (
<p className="mt-1 text-xs text-ink-soft"></p>
) : (
<ul className="mt-2 space-y-2">
{suggestions.map((s, i) => {
{visible.map(({ s, i }) => {
const code = s.code;
const isResolved = s.kind === "resolved";
const done = isResolved ? resolved.has(i) : registered.has(i);
const busy = pending.has(i);
return (
<li
key={i}
className="rounded border border-line bg-panel p-2 text-xs"
>
<span
className={`mr-1 rounded px-1.5 py-0.5 ${
isResolved
? "bg-pass/15 text-pass"
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
}`}
>
{isResolved ? "⚑ 疑似回收" : " 新埋待确认"}
</span>
<Badge variant={isResolved ? "success" : "accent"}>
{isResolved ? (
<Flag className="h-3 w-3" aria-hidden="true" />
) : (
<Plus className="h-3 w-3" aria-hidden="true" />
)}
{isResolved ? "疑似回收" : "新埋待确认"}
</Badge>
{code ? (
<span className="font-mono text-ink-soft"> {code}</span>
) : null}
@@ -123,21 +185,19 @@ export function ForeshadowSuggestions({
) : null}
{s.note ? <p className="mt-0.5 text-ink-soft">{s.note}</p> : null}
{/* 作者确认动作区 */}
{done ? (
<p className="mt-1.5 text-pass">
{isResolved ? "已标记回收" : "已登记"}
</p>
) : isResolved ? (
{/* 作者确认动作区(已保存的建议已从列表过滤,故无需「已登记」态) */}
{isResolved ? (
code ? (
<button
type="button"
<Button
disabled={busy}
onClick={() => void markResolved(i, code)}
className="mt-1.5 rounded border border-pass px-2 py-0.5 text-pass hover:bg-pass/10 disabled:cursor-not-allowed disabled:opacity-40"
className="mt-1.5"
variant="secondary"
size="sm"
>
{busy ? "处理中…" : "✓ 标记回收"}
</button>
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
{busy ? "处理中…" : "标记回收"}
</Button>
) : (
<p className="mt-1.5 text-ink-soft">
@@ -152,13 +212,15 @@ export function ForeshadowSuggestions({
onCancel={closeForm}
/>
) : (
<button
type="button"
<Button
onClick={() => openRegister(i, s)}
className="mt-1.5 rounded border border-cinnabar px-2 py-0.5 text-cinnabar hover:bg-cinnabar/10"
className="mt-1.5"
variant="outline"
size="sm"
>
</button>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
)}
</li>
);
@@ -187,39 +249,28 @@ function RegisterFormInline({
}: RegisterFormInlineProps) {
const canSubmit =
!busy && form.code.trim().length > 0 && form.title.trim().length > 0;
const field =
"w-full rounded border border-line bg-bg px-2 py-1 text-xs text-ink focus:border-cinnabar focus:outline-none";
return (
<div className="mt-2 space-y-1.5 border-t border-line pt-2">
<div>
<label htmlFor="fs-reg-code" className="text-ink-soft">
</label>
<input
<Field label="编码" htmlFor="fs-reg-code">
<TextInput
id="fs-reg-code"
type="text"
value={form.code}
onChange={(e) => onChange({ ...form, code: e.target.value })}
className={field}
controlSize="sm"
/>
</div>
<div>
<label htmlFor="fs-reg-title" className="text-ink-soft">
</label>
<input
</Field>
<Field label="标题" htmlFor="fs-reg-title">
<TextInput
id="fs-reg-title"
type="text"
value={form.title}
onChange={(e) => onChange({ ...form, title: e.target.value })}
className={field}
controlSize="sm"
/>
</div>
<div>
<label htmlFor="fs-reg-planted" className="text-ink-soft">
</label>
<input
</Field>
<Field label="埋设章号" htmlFor="fs-reg-planted">
<TextInput
id="fs-reg-planted"
type="number"
min={1}
@@ -227,38 +278,36 @@ function RegisterFormInline({
onChange={(e) =>
onChange({ ...form, plantedAt: Number(e.target.value) || 1 })
}
className={field}
controlSize="sm"
/>
</div>
<div>
<label htmlFor="fs-reg-content" className="text-ink-soft">
线/
</label>
<textarea
</Field>
<Field label="线索/正文(可选)" htmlFor="fs-reg-content">
<TextArea
id="fs-reg-content"
value={form.content}
onChange={(e) => onChange({ ...form, content: e.target.value })}
rows={2}
className={`${field} resize-y`}
controlSize="sm"
/>
</div>
</Field>
<div className="flex gap-2 pt-0.5">
<button
type="button"
<Button
disabled={!canSubmit}
onClick={onSubmit}
className="rounded bg-cinnabar px-2 py-0.5 text-panel hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
variant="primary"
size="sm"
>
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
{busy ? "登记中…" : "确认登记"}
</button>
<button
type="button"
</Button>
<Button
disabled={busy}
onClick={onCancel}
className="rounded border border-line px-2 py-0.5 text-ink-soft hover:border-cinnabar hover:text-ink disabled:opacity-40"
variant="secondary"
size="sm"
>
</button>
</Button>
</div>
</div>
);