feat: improve app ui and project metadata
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, CheckCircle2, ListTree } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import type { AcceptResponse } from "@/lib/api/types";
|
||||
import { buildAcceptPreview } from "@/lib/review/accept-preview";
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
interface AcceptPanelProps {
|
||||
projectId: string;
|
||||
@@ -42,40 +46,50 @@ export function AcceptPanel({
|
||||
if (result) {
|
||||
return (
|
||||
<div className="rounded border border-pass/50 bg-panel p-4">
|
||||
<h3 className="mb-2 text-sm font-semibold text-pass">本章已验收</h3>
|
||||
<ul className="space-y-1 text-xs text-ink-soft">
|
||||
<li>
|
||||
晋升版次:
|
||||
<span className="font-mono text-ink">v{result.accepted_version}</span>
|
||||
</li>
|
||||
<li>
|
||||
章节摘要:{result.digest_added ? "已新增一行" : "未变更"}
|
||||
</li>
|
||||
<li>
|
||||
裁决写回:
|
||||
<span className="font-mono text-ink">
|
||||
<StatusNote variant="success" title="本章已验收">
|
||||
<p className="text-sm leading-6">
|
||||
终稿已晋升为正式版本,章节摘要和裁决留痕已按验收事务写回。
|
||||
</p>
|
||||
</StatusNote>
|
||||
<dl className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded border border-line bg-bg/60 p-2">
|
||||
<dt className="text-[11px] text-ink-soft">版次</dt>
|
||||
<dd className="mt-1 font-mono text-sm text-ink">
|
||||
v{result.accepted_version}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded border border-line bg-bg/60 p-2">
|
||||
<dt className="text-[11px] text-ink-soft">摘要</dt>
|
||||
<dd className="mt-1 text-sm text-ink">
|
||||
{result.digest_added ? "已新增" : "未变更"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded border border-line bg-bg/60 p-2">
|
||||
<dt className="text-[11px] text-ink-soft">裁决</dt>
|
||||
<dd className="mt-1 font-mono text-sm text-ink">
|
||||
{result.decisions_recorded}
|
||||
</span>{" "}
|
||||
条
|
||||
</li>
|
||||
{result.review_id ? (
|
||||
<li className="truncate">
|
||||
留痕:<span className="font-mono">{result.review_id}</span>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{result.review_id ? (
|
||||
<p className="mt-2 truncate text-[11px] text-ink-soft">
|
||||
留痕:<span className="font-mono">{result.review_id}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{/* 验收闭环 → 写下一章入口(修「验收后无路可走」的断点)。 */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/projects/${projectId}/write?chapter=${chapterNo + 1}`}
|
||||
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
→ 写第 {chapterNo + 1} 章
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
写第 {chapterNo + 1} 章
|
||||
</Link>
|
||||
<Link
|
||||
href={`/projects/${projectId}/outline`}
|
||||
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
|
||||
className={buttonClass({ variant: "secondary" })}
|
||||
>
|
||||
<ListTree className="h-4 w-4" aria-hidden="true" />
|
||||
返回大纲
|
||||
</Link>
|
||||
</div>
|
||||
@@ -84,20 +98,24 @@ export function AcceptPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded border border-line bg-panel p-4">
|
||||
<div
|
||||
className={`rounded border bg-panel p-4 ${
|
||||
blocked ? "border-conflict/35" : "border-line"
|
||||
}`}
|
||||
>
|
||||
{blocked ? (
|
||||
<p className="mb-2 text-xs text-conflict" role="status">
|
||||
<StatusNote className="mb-3" variant="danger" role="status">
|
||||
尚有 {unresolvedCount} 项冲突未裁决,处理完才能验收。
|
||||
</p>
|
||||
</StatusNote>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 text-xs text-ink-soft">
|
||||
<StatusNote className="mb-3" variant={reviewed ? "success" : "info"}>
|
||||
{conflictCount === 0
|
||||
? reviewed
|
||||
? "无冲突,可直接验收。"
|
||||
: "尚未审稿——建议先点「重新审稿」确认本章无冲突,再验收。"
|
||||
: "全部冲突已裁决,可验收本章。"}
|
||||
</p>
|
||||
</StatusNote>
|
||||
{/* R3:验收前「本次将更新」清单(预期,落库口径以验收回执为准)。 */}
|
||||
<div className="mb-3 rounded border border-line/70 bg-bg/50 p-3">
|
||||
<h4 className="mb-1.5 text-xs font-semibold text-ink">
|
||||
@@ -122,19 +140,22 @@ export function AcceptPanel({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={onAccept}
|
||||
disabled={blocked || accepting}
|
||||
aria-disabled={blocked || accepting}
|
||||
className="w-full rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
>
|
||||
{accepting ? (
|
||||
<ThinkingIndicator label="验收中" className="justify-center" />
|
||||
) : (
|
||||
"全部处理完 → 验收本章"
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
全部处理完,验收本章
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,19 +16,21 @@ export function BeatMap({ beatMap }: BeatMapProps) {
|
||||
}
|
||||
const spark = toSparkline(beatMap);
|
||||
return (
|
||||
<div
|
||||
className="flex h-12 items-end gap-0.5"
|
||||
role="img"
|
||||
aria-label={`爽点节拍图 ${spark}`}
|
||||
>
|
||||
{cells.map((cell, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="min-w-[4px] flex-1 rounded-t bg-cinnabar/70"
|
||||
style={{ height: `${cell.heightPct}%` }}
|
||||
title={`第${i + 1}拍 强度${cell.value}`}
|
||||
/>
|
||||
))}
|
||||
<div className="max-w-full overflow-x-auto pb-1">
|
||||
<div
|
||||
className="flex h-12 min-w-full items-end gap-0.5"
|
||||
role="img"
|
||||
aria-label={`爽点节拍图 ${spark}`}
|
||||
>
|
||||
{cells.map((cell, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="min-w-[4px] flex-1 rounded-t bg-cinnabar/70"
|
||||
style={{ height: `${cell.heightPct}%` }}
|
||||
title={`第${i + 1}拍 强度${cell.value}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import type { ReviewConflict } from "@/lib/review/sse";
|
||||
import { AlertTriangle, CheckCircle2, LocateFixed, Sparkles } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
import type { DecisionDraft, Verdict } from "@/lib/review/decisions";
|
||||
import type { ReviewConflict } from "@/lib/review/sse";
|
||||
|
||||
interface ConflictCardProps {
|
||||
index: number;
|
||||
total: number;
|
||||
conflict: ReviewConflict;
|
||||
draft: DecisionDraft;
|
||||
missing: boolean;
|
||||
@@ -28,15 +34,16 @@ const VERDICT_OPTIONS: { value: Verdict; label: string; primary: boolean }[] = [
|
||||
|
||||
// 裁决按钮样式:激活=朱砂填充;未激活的主按钮=朱砂描边强调,次级=低对比描边。
|
||||
function verdictClass(active: boolean, primary: boolean): string {
|
||||
if (active) return "bg-cinnabar text-panel";
|
||||
if (primary) return "border border-cinnabar text-cinnabar hover:bg-cinnabar/10";
|
||||
return "border border-line text-ink-soft hover:border-cinnabar hover:text-ink";
|
||||
if (active) return buttonClass({ variant: "primary", size: "sm" });
|
||||
if (primary) return buttonClass({ variant: "outline", size: "sm" });
|
||||
return buttonClass({ variant: "secondary", size: "sm" });
|
||||
}
|
||||
|
||||
// 单个冲突报告卡(UX §6.4):五类徽标 + where + refs + suggestion + 裁决三态。
|
||||
// 冲突=赭红;未决/缺判时加图标+文案(不单靠色,a11y §10)。
|
||||
export function ConflictCard({
|
||||
index,
|
||||
total,
|
||||
conflict,
|
||||
draft,
|
||||
missing,
|
||||
@@ -60,13 +67,14 @@ export function ConflictCard({
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className="shrink-0 rounded bg-[var(--color-conflict)]/10 px-2 py-0.5 text-xs text-conflict"
|
||||
aria-hidden="true"
|
||||
>
|
||||
⚠ {conflict.type}
|
||||
</span>
|
||||
<p className="flex-1 text-sm text-ink">{conflict.suggestion}</p>
|
||||
<Badge variant="danger" className="shrink-0">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
冲突 {index + 1}/{total}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-conflict">{conflict.type}</p>
|
||||
<p className="mt-1 text-sm text-ink">{conflict.suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conflict.original && conflict.replacement ? (
|
||||
@@ -95,9 +103,10 @@ export function ConflictCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJump(index)}
|
||||
className="rounded border border-line px-2 py-0.5 hover:border-cinnabar hover:text-cinnabar"
|
||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
||||
>
|
||||
▸ 跳转 {conflict.where}
|
||||
<LocateFixed className="h-4 w-4" aria-hidden="true" />
|
||||
跳转 {conflict.where}
|
||||
</button>
|
||||
) : null}
|
||||
{conflict.refs.map((ref) => (
|
||||
@@ -121,23 +130,24 @@ export function ConflictCard({
|
||||
aria-pressed={active}
|
||||
disabled={rewriting}
|
||||
onClick={() => onVerdict(index, opt.value)}
|
||||
className={`rounded px-3 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ${verdictClass(
|
||||
active,
|
||||
opt.primary,
|
||||
)}`}
|
||||
className={verdictClass(active, opt.primary)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{rewriting ? (
|
||||
<span className="text-xs text-info">✦ AI 改写中…</span>
|
||||
<Badge variant="info">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
AI 改写中…
|
||||
</Badge>
|
||||
) : resolved ? (
|
||||
<span className="text-xs text-pass" aria-hidden="true">
|
||||
✓ 已裁决
|
||||
</span>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
已裁决
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-conflict">未裁决</span>
|
||||
<Badge variant="danger">未裁决</Badge>
|
||||
)}
|
||||
</div>
|
||||
{draft.verdict === "manual" ? (
|
||||
@@ -145,14 +155,16 @@ export function ConflictCard({
|
||||
<label htmlFor={`note-${index}`} className="sr-only">
|
||||
手改说明
|
||||
</label>
|
||||
<input
|
||||
<TextInput
|
||||
id={`note-${index}`}
|
||||
type="text"
|
||||
value={draft.note}
|
||||
onChange={(e) => onNote(index, e.target.value)}
|
||||
placeholder="手改说明(可选)"
|
||||
className="w-full rounded border border-line bg-bg px-3 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
选择手改后,说明会随验收裁决一起保存,正文请在左侧直接修改。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, CheckCircle2, CircleDashed } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { PaceReport } from "@/lib/review/sse";
|
||||
import { BeatMap } from "./BeatMap";
|
||||
|
||||
@@ -16,7 +19,10 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
|
||||
节奏 (pace-checker)
|
||||
</h2>
|
||||
{incomplete ? (
|
||||
<p className="mt-1 text-xs text-overdue">◌ 未完成</p>
|
||||
<Badge variant="warning" className="mt-1">
|
||||
<CircleDashed className="h-3 w-3" aria-hidden="true" />
|
||||
未完成
|
||||
</Badge>
|
||||
) : pace === null ? (
|
||||
<p className="mt-1 text-xs text-ink-soft">无节奏报告。</p>
|
||||
) : (
|
||||
@@ -29,15 +35,24 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
|
||||
<p>
|
||||
章末钩子:
|
||||
{pace.hook ? (
|
||||
<span className="text-pass">✓ 有</span>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
有
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-overdue">⚠ 无(建议补悬念)</span>
|
||||
<Badge variant="warning">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
无(建议补悬念)
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{pace.water.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-overdue">⚠ 注水段</p>
|
||||
<Badge variant="warning">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
注水段
|
||||
</Badge>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{pace.water.map((w, i) => (
|
||||
<li key={i} className="text-ink-soft">
|
||||
@@ -47,7 +62,10 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-pass">✓ 无明显注水</p>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
无明显注水
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
RotateCcw,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import {
|
||||
@@ -44,6 +52,8 @@ import { AnnotatedText } from "./AnnotatedText";
|
||||
import { ConflictCard } from "./ConflictCard";
|
||||
import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
|
||||
import { PacePanel } from "./PacePanel";
|
||||
import { ReviewSectionPanel } from "./ReviewSectionPanel";
|
||||
import { ReviewSummaryRail } from "./ReviewSummaryRail";
|
||||
import { StylePanel } from "@/components/style/StylePanel";
|
||||
import { RefineView } from "@/components/style/RefineView";
|
||||
|
||||
@@ -370,6 +380,13 @@ export function ReviewReport({
|
||||
const resolved = allResolved(drafts);
|
||||
const unresolved = unresolvedIndices(drafts).length;
|
||||
const reviewing = review.isReviewing;
|
||||
const hasReport =
|
||||
review.state.phase === "done" ||
|
||||
review.state.sections.length > 0 ||
|
||||
seededConflicts.length > 0 ||
|
||||
foreshadow.length > 0 ||
|
||||
pace !== null ||
|
||||
style !== null;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -378,33 +395,27 @@ export function ReviewReport({
|
||||
projectId={project.id}
|
||||
activeNav="review"
|
||||
>
|
||||
<div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[1fr_24rem]">
|
||||
<div className="flex min-h-[calc(100vh-var(--chrome,4rem))] flex-col lg:grid lg:h-[calc(100vh-var(--chrome,4rem))] lg:min-h-0 lg:grid-cols-[minmax(0,1fr)_24rem]">
|
||||
{/* 左:终稿正文 + 就地标注 */}
|
||||
<section className="flex min-w-0 flex-col bg-bg">
|
||||
<div className="flex items-center gap-3 border-b border-line bg-panel px-6 py-3">
|
||||
<h1 className="font-serif text-lg text-ink">
|
||||
<section className="flex min-h-[70vh] min-w-0 flex-col bg-bg lg:min-h-0">
|
||||
<div className="flex items-center gap-3 border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||
<h1 className="min-w-0 flex-1 truncate font-serif text-lg text-ink">
|
||||
第 {chapterNo} 章 审稿报告
|
||||
</h1>
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
<span className="hidden shrink-0 font-mono text-xs text-ink-soft sm:inline">
|
||||
{conflictCount} 冲突
|
||||
</span>
|
||||
<div className="ml-auto">
|
||||
<div className="shrink-0">
|
||||
{reviewing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={review.stop}
|
||||
className="rounded border border-conflict px-3 py-1.5 text-sm text-conflict"
|
||||
>
|
||||
<Button onClick={review.stop} variant="danger" size="sm">
|
||||
<Square className="h-4 w-4" aria-hidden="true" />
|
||||
停
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReReview}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
|
||||
>
|
||||
✦ 重新审稿
|
||||
</button>
|
||||
<Button onClick={onReReview} variant="primary" size="sm">
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
重新审稿
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,7 +424,7 @@ export function ReviewReport({
|
||||
<ReviewErrorNote error={review.state.error} />
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 py-6">
|
||||
<div className="flex-1 overflow-auto px-4 py-4 sm:px-6 sm:py-6">
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<p className="text-xs text-ink-soft">
|
||||
可直接在下方正文修改(改动自动保存草稿 — 摘要从终稿提炼);点冲突的「跳转」或顶部锚点定位到对应文字。
|
||||
@@ -438,101 +449,187 @@ export function ReviewReport({
|
||||
</section>
|
||||
|
||||
{/* 右:审稿分区 + 冲突裁决 + 验收 gate */}
|
||||
<aside className="flex flex-col overflow-auto border-l border-line bg-panel px-4 py-4">
|
||||
<section className="mb-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||
一致性 (continuity)
|
||||
</h2>
|
||||
<SectionStatusLine
|
||||
reviewing={reviewing}
|
||||
done={review.state.phase === "done"}
|
||||
conflictCount={conflictCount}
|
||||
sections={review.state.sections}
|
||||
/>
|
||||
</section>
|
||||
<aside
|
||||
className="flex flex-col border-t border-line bg-panel px-4 py-4 lg:overflow-auto lg:border-l lg:border-t-0"
|
||||
aria-label="审稿报告与验收"
|
||||
>
|
||||
<ReviewSummaryRail
|
||||
conflictCount={conflictCount}
|
||||
unresolvedCount={unresolved}
|
||||
reviewing={reviewing}
|
||||
hasReport={hasReport}
|
||||
onNextUnresolved={jumpToNextUnresolved}
|
||||
/>
|
||||
|
||||
<section>
|
||||
{conflicts.length === 0 ? (
|
||||
<p className="rounded border border-dashed border-line p-4 text-xs text-ink-soft">
|
||||
{review.state.phase === "done" || seededConflicts.length === 0
|
||||
? "未发现一致性冲突。"
|
||||
: "进页未带审稿留痕,点「重新审稿」开始。"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* R2:未裁决进度 + 跳到下一条未裁决 */}
|
||||
<div className="flex items-center justify-between text-xs text-ink-soft">
|
||||
<span>
|
||||
{unresolved > 0 ? (
|
||||
<span className="text-conflict">
|
||||
{unresolved}/{conflictCount} 待裁决
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-pass">✓ 全部已裁决</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={jumpToNextUnresolved}
|
||||
disabled={unresolved === 0}
|
||||
className="rounded border border-line px-2 py-0.5 hover:border-cinnabar hover:text-cinnabar disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
跳到下一条未裁决 ▾
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* B:讲清裁决保存模型——「采纳/裁决」仅本地暂存,验收时一并落库 */}
|
||||
<p className="rounded border border-dashed border-line/70 bg-bg/40 px-3 py-1.5 text-[11px] text-ink-soft">
|
||||
裁决先暂存在本地,点下方「验收本章」时与终稿一并保存落库(验收前刷新会丢失未保存的裁决)。
|
||||
<div className="space-y-3">
|
||||
<ReviewSectionPanel
|
||||
title="一致性"
|
||||
subtitle="continuity"
|
||||
statusLabel={
|
||||
reviewing
|
||||
? "审稿中"
|
||||
: conflictCount > 0
|
||||
? `${conflictCount} 冲突`
|
||||
: hasReport
|
||||
? "通过"
|
||||
: "待审"
|
||||
}
|
||||
statusVariant={
|
||||
reviewing
|
||||
? "info"
|
||||
: conflictCount > 0
|
||||
? "danger"
|
||||
: hasReport
|
||||
? "success"
|
||||
: "neutral"
|
||||
}
|
||||
defaultOpen={conflictCount > 0 || !hasReport}
|
||||
>
|
||||
<SectionStatusLine
|
||||
reviewing={reviewing}
|
||||
done={review.state.phase === "done"}
|
||||
conflictCount={conflictCount}
|
||||
sections={review.state.sections}
|
||||
/>
|
||||
{conflicts.length === 0 ? (
|
||||
<p className="mt-2 rounded border border-dashed border-line p-4 text-xs text-ink-soft">
|
||||
{review.state.phase === "done" || seededConflicts.length === 0
|
||||
? "未发现一致性冲突。"
|
||||
: "进页未带审稿留痕,点「重新审稿」开始。"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-4">
|
||||
<p className="rounded border border-dashed border-line/70 bg-bg/40 px-3 py-1.5 text-[11px] text-ink-soft">
|
||||
裁决先暂存在本地,点下方「验收本章」时与终稿一并保存落库。
|
||||
</p>
|
||||
|
||||
{groups.map((group) => (
|
||||
<div key={group.type}>
|
||||
<h3 className="mb-2 flex items-center gap-2 text-xs font-semibold text-ink">
|
||||
{group.type}
|
||||
<span className="rounded-full bg-[var(--color-conflict)]/10 px-2 py-0.5 font-mono text-conflict">
|
||||
{group.items.length}
|
||||
</span>
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{group.items.map(({ conflict: c, index: i }) => (
|
||||
<ConflictCard
|
||||
key={i}
|
||||
index={i}
|
||||
conflict={c}
|
||||
draft={drafts[i] ?? { verdict: null, note: "" }}
|
||||
missing={missing.has(i)}
|
||||
snippet={conflictSnippet(finalText, c.where)}
|
||||
locatable={locatable.has(i)}
|
||||
rewriting={rewriting.has(i)}
|
||||
onVerdict={onVerdict}
|
||||
onNote={onNote}
|
||||
onJump={focusRegion}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{groups.map((group) => {
|
||||
const unresolvedInGroup = group.items.filter(
|
||||
({ index }) => !drafts[index]?.verdict,
|
||||
).length;
|
||||
return (
|
||||
<details
|
||||
key={group.type}
|
||||
open={unresolvedInGroup > 0}
|
||||
className="rounded border border-line/70 bg-bg/35 p-2"
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 text-xs font-semibold text-ink">
|
||||
<span className="flex items-center gap-2">
|
||||
{group.type}
|
||||
<Badge
|
||||
variant={
|
||||
unresolvedInGroup > 0 ? "danger" : "success"
|
||||
}
|
||||
>
|
||||
{group.items.length}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-ink-soft">
|
||||
{unresolvedInGroup > 0
|
||||
? `${unresolvedInGroup} 未裁决`
|
||||
: "已处理"}
|
||||
</span>
|
||||
</summary>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{group.items.map(({ conflict: c, index: i }) => (
|
||||
<ConflictCard
|
||||
key={i}
|
||||
index={i}
|
||||
total={conflictCount}
|
||||
conflict={c}
|
||||
draft={drafts[i] ?? { verdict: null, note: "" }}
|
||||
missing={missing.has(i)}
|
||||
snippet={conflictSnippet(finalText, c.where)}
|
||||
locatable={locatable.has(i)}
|
||||
rewriting={rewriting.has(i)}
|
||||
onVerdict={onVerdict}
|
||||
onNote={onNote}
|
||||
onJump={focusRegion}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ReviewSectionPanel>
|
||||
|
||||
<div className="mt-4 border-t border-line pt-4">
|
||||
<ReviewSectionPanel
|
||||
title="伏笔"
|
||||
subtitle="foreshadow-analyst"
|
||||
statusLabel={
|
||||
sectionStatus("foreshadow") === "incomplete"
|
||||
? "未完成"
|
||||
: foreshadow.length > 0
|
||||
? `${foreshadow.length} 建议`
|
||||
: "无建议"
|
||||
}
|
||||
statusVariant={
|
||||
sectionStatus("foreshadow") === "incomplete"
|
||||
? "warning"
|
||||
: foreshadow.length > 0
|
||||
? "accent"
|
||||
: "success"
|
||||
}
|
||||
defaultOpen={foreshadow.length > 0}
|
||||
>
|
||||
<ForeshadowSuggestions
|
||||
suggestions={foreshadow}
|
||||
incomplete={sectionStatus("foreshadow") === "incomplete"}
|
||||
projectId={project.id}
|
||||
chapterNo={chapterNo}
|
||||
/>
|
||||
</ReviewSectionPanel>
|
||||
<ReviewSectionPanel
|
||||
title="节奏"
|
||||
subtitle="pace-checker"
|
||||
statusLabel={
|
||||
sectionStatus("pace") === "incomplete"
|
||||
? "未完成"
|
||||
: pace
|
||||
? "已完成"
|
||||
: "无报告"
|
||||
}
|
||||
statusVariant={
|
||||
sectionStatus("pace") === "incomplete"
|
||||
? "warning"
|
||||
: pace
|
||||
? "info"
|
||||
: "neutral"
|
||||
}
|
||||
defaultOpen={pace !== null}
|
||||
>
|
||||
<PacePanel
|
||||
pace={pace}
|
||||
incomplete={sectionStatus("pace") === "incomplete"}
|
||||
/>
|
||||
</ReviewSectionPanel>
|
||||
<ReviewSectionPanel
|
||||
title="文风"
|
||||
subtitle="style"
|
||||
statusLabel={
|
||||
sectionStatus("style") === "incomplete"
|
||||
? "未完成"
|
||||
: style
|
||||
? "已完成"
|
||||
: "无报告"
|
||||
}
|
||||
statusVariant={
|
||||
sectionStatus("style") === "incomplete"
|
||||
? "warning"
|
||||
: style
|
||||
? "info"
|
||||
: "neutral"
|
||||
}
|
||||
defaultOpen={style !== null}
|
||||
>
|
||||
<StylePanel
|
||||
style={style}
|
||||
incomplete={sectionStatus("style") === "incomplete"}
|
||||
onRefine={onRefineSegment}
|
||||
/>
|
||||
</ReviewSectionPanel>
|
||||
{refineText !== null ? (
|
||||
<RefineView
|
||||
projectId={project.id}
|
||||
@@ -613,9 +710,15 @@ function SectionStatusLine({
|
||||
return (
|
||||
<p className="mt-1 text-xs">
|
||||
{conflictCount > 0 ? (
|
||||
<span className="text-conflict">⚠ {conflictCount} 冲突</span>
|
||||
<Badge variant="danger">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
{conflictCount} 冲突
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-pass">✓ 通过</span>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
通过
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
|
||||
54
apps/web/components/review/ReviewSectionPanel.tsx
Normal file
54
apps/web/components/review/ReviewSectionPanel.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { cn, type BadgeVariant } from "@/lib/ui/variants";
|
||||
|
||||
interface ReviewSectionPanelProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
statusLabel: string;
|
||||
statusVariant: BadgeVariant;
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export function ReviewSectionPanel({
|
||||
title,
|
||||
subtitle,
|
||||
statusLabel,
|
||||
statusVariant,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
}: ReviewSectionPanelProps) {
|
||||
return (
|
||||
<details
|
||||
className="group rounded border border-line bg-bg/45"
|
||||
open={defaultOpen}
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2">
|
||||
<span className="min-w-0">
|
||||
<span className="block font-serif text-sm text-ink">{title}</span>
|
||||
{subtitle ? (
|
||||
<span className="block truncate text-xs text-ink-soft">
|
||||
{subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={statusVariant}>{statusLabel}</Badge>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-ink-soft transition-transform",
|
||||
"group-open:rotate-180",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="border-t border-line px-3 py-3">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
65
apps/web/components/review/ReviewSummaryRail.tsx
Normal file
65
apps/web/components/review/ReviewSummaryRail.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { AlertTriangle, CheckCircle2, CircleDashed } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
interface ReviewSummaryRailProps {
|
||||
conflictCount: number;
|
||||
unresolvedCount: number;
|
||||
reviewing: boolean;
|
||||
hasReport: boolean;
|
||||
onNextUnresolved: () => void;
|
||||
}
|
||||
|
||||
export function ReviewSummaryRail({
|
||||
conflictCount,
|
||||
unresolvedCount,
|
||||
reviewing,
|
||||
hasReport,
|
||||
onNextUnresolved,
|
||||
}: ReviewSummaryRailProps) {
|
||||
return (
|
||||
<section className="sticky top-0 z-10 -mx-4 mb-4 border-b border-line bg-panel px-4 pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-serif text-base text-ink">审稿摘要</h2>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
{reviewing
|
||||
? "四审正在更新"
|
||||
: hasReport
|
||||
? "先处理未裁决冲突,再验收本章"
|
||||
: "暂无审稿留痕,可先重新审稿"}
|
||||
</p>
|
||||
</div>
|
||||
{reviewing ? (
|
||||
<Badge variant="info">
|
||||
<CircleDashed className="h-3 w-3" aria-hidden="true" />
|
||||
审稿中
|
||||
</Badge>
|
||||
) : unresolvedCount > 0 ? (
|
||||
<Badge variant="danger">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
{unresolvedCount}/{conflictCount} 待裁决
|
||||
</Badge>
|
||||
) : conflictCount > 0 ? (
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
已裁决
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant={hasReport ? "success" : "neutral"}>
|
||||
{hasReport ? "无冲突" : "未审稿"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={onNextUnresolved}
|
||||
disabled={unresolvedCount === 0}
|
||||
variant="secondary"
|
||||
className="mt-3 w-full"
|
||||
>
|
||||
跳到下一条未裁决
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user