382 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|