feat: 增加项目无关的本地清洗评审器
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user