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,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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

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>
);

View File

@@ -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>
)}

View File

@@ -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>
);

View 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>
);
}

View 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>
);
}