feat: improve app ui and project metadata
This commit is contained in:
@@ -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));
|
||||
};
|
||||
|
||||
// 保留原始下标 i(registered/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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user