Files
mdpolish/reviewer/src/client/App.tsx
T

382 lines
14 KiB
TypeScript

import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type {
ChangeDetail,
CollectionSummaryResponse,
DocumentComparisonResponse,
ModifierStageResponse,
RunStatus,
} from "../shared/api.js";
import { fetchCollection, fetchDocument, fetchModifierStage } from "./api-client.js";
const DiffView = lazy(async () => {
const module = await import("./DiffView.js");
return { default: module.DiffView };
});
interface AsyncState<T> {
loading: boolean;
value: T | null;
error: string | null;
}
const emptyState = <T,>(): AsyncState<T> => ({ loading: true, value: null, error: null });
function statusLabel(status: RunStatus): string {
switch (status) {
case "success":
return "成功";
case "failed":
return "失败";
case "unstable":
return "不稳定";
}
}
function shortHash(hash: string): string {
return `${hash.slice(0, 8)}${hash.slice(-6)}`;
}
function ErrorPanel({ message }: { message: string }) {
return (
<div className="state-panel state-panel--error" role="alert">
<span className="eyebrow">无法显示</span>
<p>{message}</p>
</div>
);
}
function LoadingPanel() {
return (
<div className="state-panel" role="status">
<span className="loading-dot" />
<p>正在校验本地评审数据…</p>
</div>
);
}
function ChangeList({
changes,
onSelect,
canJump,
}: {
changes: ChangeDetail[];
onSelect: (change: ChangeDetail) => void;
canJump: boolean;
}) {
if (changes.length === 0) {
return <p className="quiet-message">这个 Modifier 运行过,但没有修改当前文档。</p>;
}
return (
<ol className="change-list">
{changes.map((change) => (
<li
key={`${change.modifier_position}-${change.proposal_index}-${change.edit_index}`}
>
<button type="button" onClick={() => onSelect(change)} disabled={!canJump}>
<span className="change-location">
{change.location.line} 行,第 {change.location.column} · 候选
{change.proposal_index + 1} / 编辑 {change.edit_index + 1}
</span>
<strong>{change.reason}</strong>
<span className="change-sample">
<del>{change.before || "∅"}</del>
<span aria-hidden="true"></span>
<ins>{change.after || "∅"}</ins>
</span>
</button>
</li>
))}
</ol>
);
}
export function App() {
const [collectionState, setCollectionState] =
useState<AsyncState<CollectionSummaryResponse>>(emptyState);
const [selectedDocument, setSelectedDocument] = useState<string | null>(null);
const [documentState, setDocumentState] = useState<AsyncState<DocumentComparisonResponse>>({
loading: false,
value: null,
error: null,
});
const [selectedModifier, setSelectedModifier] = useState<number | null>(null);
const [stageState, setStageState] = useState<AsyncState<ModifierStageResponse>>({
loading: false,
value: null,
error: null,
});
const [focusRange, setFocusRange] = useState<{ start: number; end: number } | null>(null);
useEffect(() => {
const controller = new AbortController();
fetchCollection(controller.signal)
.then((collection) => {
setCollectionState({ loading: false, value: collection, error: null });
setSelectedDocument(collection.documents[0]?.document_id ?? null);
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setCollectionState({
loading: false,
value: null,
error: error instanceof Error ? error.message : "无法读取评审集合摘要。",
});
}
});
return () => controller.abort();
}, []);
useEffect(() => {
setSelectedModifier(null);
setFocusRange(null);
if (selectedDocument === null) {
setDocumentState({ loading: false, value: null, error: null });
return undefined;
}
const controller = new AbortController();
setDocumentState(emptyState());
fetchDocument(selectedDocument, controller.signal)
.then((document) => setDocumentState({ loading: false, value: document, error: null }))
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setDocumentState({
loading: false,
value: null,
error: error instanceof Error ? error.message : "无法读取文档。",
});
}
});
return () => controller.abort();
}, [selectedDocument]);
useEffect(() => {
if (selectedDocument === null || selectedModifier === null) {
setStageState({ loading: false, value: null, error: null });
return undefined;
}
const controller = new AbortController();
setStageState(emptyState());
fetchModifierStage(selectedDocument, selectedModifier, controller.signal)
.then((stage) => setStageState({ loading: false, value: stage, error: null }))
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setStageState({
loading: false,
value: null,
error: error instanceof Error ? error.message : "无法读取 Modifier 阶段。",
});
}
});
return () => controller.abort();
}, [selectedDocument, selectedModifier]);
const visibleChanges = useMemo(() => {
const document = documentState.value;
if (document === null) {
return [];
}
if (selectedModifier === null) {
return document.changes;
}
return document.changes.filter((change) => change.modifier_position === selectedModifier);
}, [documentState.value, selectedModifier]);
const selectChange = (change: ChangeDetail): void => {
setSelectedModifier(change.modifier_position);
setFocusRange(change.editor_range);
};
if (collectionState.loading) {
return <LoadingPanel />;
}
if (collectionState.error !== null || collectionState.value === null) {
return <ErrorPanel message={collectionState.error ?? "评审集合摘要为空。"} />;
}
const collection = collectionState.value;
const document = documentState.value;
const selectedSummary = collection.documents.find(
(item) => item.document_id === selectedDocument,
);
const selectedStage = stageState.value;
const canCompare =
document?.document.status === "success" && document.current_markdown !== null;
const beforeText = selectedStage?.before_markdown ?? document?.input_markdown ?? "";
const afterText = selectedStage?.after_markdown ?? document?.current_markdown ?? "";
const beforeLabel =
selectedStage === null
? "清洗前"
: `Modifier ${selectedStage.modifier.modifier_position + 1} 执行前`;
const afterLabel =
selectedStage === null
? "清洗后"
: `Modifier ${selectedStage.modifier.modifier_position + 1} 执行后`;
return (
<div className="app-shell">
<header className="topbar">
<div>
<span className="brand-mark">md</span>
<div>
<p className="eyebrow">本地清洗评审器</p>
<h1>{collection.collection.label}</h1>
</div>
</div>
<div className="run-facts">
<span className={`status status--${collection.collection.status}`}>
{statusLabel(collection.collection.status)}
</span>
<span>{collection.summary.document_count} 份文档</span>
<span>{collection.summary.change_count} 条修改</span>
</div>
</header>
<div className="layout">
<aside className="sidebar" aria-label="评审导航">
<section>
<div className="section-heading">
<h2>文档</h2>
<span>{collection.documents.length}</span>
</div>
<nav className="document-list" aria-label="文档列表">
{collection.documents.map((item) => (
<button
type="button"
key={item.document_id}
className={item.document_id === selectedDocument ? "is-active" : ""}
onClick={() => setSelectedDocument(item.document_id)}
aria-current={item.document_id === selectedDocument ? "page" : undefined}
>
<span className={`status-dot status-dot--${item.status}`} />
<span>
<strong>{item.source_label}</strong>
<small>{item.change_count} 条修改</small>
</span>
</button>
))}
</nav>
</section>
<section className="modifier-section">
<div className="section-heading">
<h2>Modifier 时间线</h2>
<button
type="button"
className="text-button"
onClick={() => {
setSelectedModifier(null);
setFocusRange(null);
}}
disabled={selectedModifier === null}
>
查看总结果
</button>
</div>
<ol className="modifier-list">
{(document?.modifiers ?? []).map((modifier) => (
<li key={`${modifier.modifier_position}-${modifier.modifier_id}`}>
<button
type="button"
className={modifier.modifier_position === selectedModifier ? "is-active" : ""}
onClick={() => {
setSelectedModifier(modifier.modifier_position);
setFocusRange(null);
}}
disabled={!canCompare || !modifier.stage_available}
>
<span className="modifier-index">{modifier.modifier_position + 1}</span>
<span>
<strong>{modifier.modifier_id}</strong>
<small>
v{modifier.modifier_version} · {modifier.change_count}
</small>
</span>
</button>
</li>
))}
</ol>
</section>
</aside>
<main className="workspace">
<section className="document-header">
<div>
<p className="eyebrow">当前文档</p>
<h2>{selectedSummary?.source_label ?? "未选择"}</h2>
</div>
{selectedSummary === undefined ? null : (
<div className="document-meta">
<span className={`status status--${selectedSummary.status}`}>
{statusLabel(selectedSummary.status)}
</span>
<span title={selectedSummary.input_sha256}>
输入 {shortHash(selectedSummary.input_sha256)}
</span>
<span title={selectedSummary.current_sha256}>
当前 {shortHash(selectedSummary.current_sha256)}
</span>
</div>
)}
</section>
{documentState.loading || stageState.loading ? <LoadingPanel /> : null}
{documentState.error !== null ? <ErrorPanel message={documentState.error} /> : null}
{stageState.error !== null ? <ErrorPanel message={stageState.error} /> : null}
{!documentState.loading && document !== null && document.document.status !== "success" ? (
<div className="diagnostic-panel">
<p className="eyebrow">没有正式清洗结果</p>
<h3>{statusLabel(document.document.status)}文档只展示审计证据</h3>
<p>partial output 不会在这里命名为清洗结果。</p>
{document.errors.map((error) => (
<article key={`${error.modifier_position}-${error.stage}-${error.code}`}>
<strong>{error.diagnostic_type}</strong>
<span>{error.message}</span>
</article>
))}
{document.residual_proposals.map((proposal) => (
<article key={`${proposal.modifier_position}-${proposal.proposal_index}`}>
<strong>最终复查仍有 {proposal.edits.length} 项候选编辑</strong>
<span>{proposal.reason}</span>
</article>
))}
</div>
) : null}
{!documentState.loading && !stageState.loading && canCompare && stageState.error === null ? (
<Suspense fallback={<LoadingPanel />}>
<DiffView
before={beforeText}
after={afterText}
beforeLabel={beforeLabel}
afterLabel={afterLabel}
focusRange={focusRange}
/>
</Suspense>
) : null}
{document !== null ? (
<section className="changes-panel" aria-label="修改详情">
<div className="changes-heading">
<div>
<p className="eyebrow">实际修改</p>
<h3>
{selectedModifier === null
? `全部 Modifier · ${visibleChanges.length} 条`
: `${document.modifiers[selectedModifier]?.modifier_id ?? "Modifier"} · ${
visibleChanges.length
} 条`}
</h3>
</div>
{selectedStage === null ? null : <p>{selectedStage.modifier.applicability}</p>}
</div>
<ChangeList changes={visibleChanges} onSelect={selectChange} canJump={canCompare} />
</section>
) : null}
</main>
</div>
</div>
);
}