tcjs/ui/src/CalculatorPanel.tsx

417 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Alert, App, Button, Card, DatePicker, Input, InputNumber, Select, Table, Tag } from "antd";
import dayjs, { type Dayjs } from "dayjs";
import { ReportModal } from "./ReportView";
import {
conclusionText,
defaultCalibration,
formatNumber,
formatPercent,
materialOptions,
materialTiers,
validityText,
verdictDisplay,
type CalculationResult,
type LimitTier,
type MaterialType,
type SampleInput
} from "./types";
type MeasurementRow = {
key: number;
ra: number | null;
th: number | null;
k: number | null;
};
type CalibrationRow = { key: string; name: string } & (typeof defaultCalibration)["ra"];
type FocusableInput = { focus: () => void };
type Props = {
initialInput?: SampleInput;
onSaved: () => void;
};
function rowsFromInput(input?: SampleInput): MeasurementRow[] {
if (!input) return [{ key: 1, ra: 100, th: 110, k: 560 }];
const n = input.ra.measured_values.length;
return Array.from({ length: n }, (_, i) => ({
key: i + 1,
ra: input.ra.measured_values[i] ?? null,
th: input.th.measured_values[i] ?? null,
k: input.k.measured_values[i] ?? null
}));
}
function CalculatorPanel({ initialInput, onSaved }: Props) {
const { message } = App.useApp();
const [rows, setRows] = useState<MeasurementRow[]>(() => rowsFromInput(initialInput));
const [materialType, setMaterialType] = useState<MaterialType>(
initialInput?.material_type ?? "BuildingMainBody"
);
const [sampleId, setSampleId] = useState<string>(initialInput?.sample_id ?? "");
const [calcDate, setCalcDate] = useState<Dayjs | null>(
initialInput?.calculation_date ? dayjs(initialInput.calculation_date) : dayjs()
);
const [result, setResult] = useState<CalculationResult | null>(null);
const [lastInput, setLastInput] = useState<SampleInput | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const firstCellRefs = useRef<Record<number, FocusableInput | null>>({});
const dataSource = useMemo(() => rows, [rows]);
const updateRow = (key: number, field: keyof Omit<MeasurementRow, "key">, value: number | null) => {
setRows((current) => current.map((row) => (row.key === key ? { ...row, [field]: value } : row)));
};
const addRow = () => {
const key = Date.now();
setRows((current) => [...current, { key, ra: null, th: null, k: null }]);
window.setTimeout(() => firstCellRefs.current[key]?.focus(), 0);
};
const removeRow = (key: number) => {
setRows((current) => current.filter((row) => row.key !== key));
};
const buildInput = (): SampleInput => {
const toValues = (field: keyof Omit<MeasurementRow, "key">) =>
rows.map((row) => row[field]).filter((value): value is number => typeof value === "number");
return {
ra: { measured_values: toValues("ra"), calibration: defaultCalibration.ra },
th: { measured_values: toValues("th"), calibration: defaultCalibration.th },
k: { measured_values: toValues("k"), calibration: defaultCalibration.k },
material_type: materialType,
sample_id: sampleId.trim() ? sampleId.trim() : null,
calculation_date: calcDate ? calcDate.format("YYYY-MM-DD") : null
};
};
const calculate = async () => {
setLoading(true);
setError(null);
const input = buildInput();
try {
const response = await invoke<CalculationResult>("calculate", { input });
setResult(response);
setLastInput(input);
} catch (err) {
setResult(null);
setLastInput(null);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
// 复算:带入历史记录后自动计算一次。
useEffect(() => {
if (initialInput) void calculate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const save = async () => {
if (!result || !lastInput) return;
setSaving(true);
try {
await invoke<number>("save_record", {
args: { input: lastInput, result, created_at: new Date().toISOString() }
});
message.success("已保存到历史");
onSaved();
} catch (err) {
message.error(`保存失败:${String(err)}`);
} finally {
setSaving(false);
}
};
const tiers = materialTiers[materialType];
return (
<section className="workspace">
<div className="content-grid">
<Card title="重复测量值" className="panel measurements-panel">
<div className="measurement-table">
<Table<MeasurementRow>
pagination={false}
dataSource={dataSource}
rowKey="key"
size="small"
columns={[
{ title: "序号", key: "index", width: 56, align: "center", render: (_, _row, index) => index + 1 },
{
title: "Ra-226",
dataIndex: "ra",
render: (_, row) => (
<InputNumber
ref={(instance) => {
firstCellRefs.current[row.key] = instance;
}}
value={row.ra}
min={0}
onChange={(value) => updateRow(row.key, "ra", value)}
/>
)
},
{
title: "Th-232",
dataIndex: "th",
render: (_, row) => (
<InputNumber value={row.th} min={0} onChange={(value) => updateRow(row.key, "th", value)} />
)
},
{
title: "K-40",
dataIndex: "k",
render: (_, row) => (
<InputNumber value={row.k} min={0} onChange={(value) => updateRow(row.key, "k", value)} />
)
},
{
title: "",
key: "action",
width: 88,
render: (_, row) => (
<Button danger size="small" disabled={rows.length <= 1} onClick={() => removeRow(row.key)}>
</Button>
)
}
]}
/>
</div>
<div className="measurement-actions">
<Button size="small" block onClick={addRow}>
</Button>
<Button type="primary" size="small" block loading={loading} onClick={() => void calculate()}>
</Button>
<Button size="small" block loading={saving} disabled={!result} onClick={() => void save()}>
</Button>
<Button size="small" block disabled={!result} onClick={() => setReportOpen(true)}>
/ PDF
</Button>
</div>
</Card>
<Card title="样品信息 / 校准参数 / 限值" className="panel compact-panel">
<div className="sample-form">
<div className="sample-field">
<span className="sample-label"></span>
<Input value={sampleId} placeholder="可选" onChange={(event) => setSampleId(event.target.value)} />
</div>
<div className="sample-field">
<span className="sample-label"></span>
<DatePicker value={calcDate} onChange={setCalcDate} style={{ width: "100%" }} />
</div>
<div className="sample-field">
<span className="sample-label"></span>
<Select<MaterialType>
value={materialType}
options={materialOptions}
onChange={setMaterialType}
style={{ width: "100%" }}
/>
</div>
</div>
<div className="sample-tables">
<Table<CalibrationRow>
className="calibration-table"
pagination={false}
size="small"
rowKey="key"
dataSource={[
{ key: "ra", name: "Ra", ...defaultCalibration.ra },
{ key: "th", name: "Th", ...defaultCalibration.th },
{ key: "k", name: "K", ...defaultCalibration.k }
]}
columns={[
{ title: "核素", dataIndex: "name", align: "center" },
{ title: "a", dataIndex: "factor", align: "center" },
{
title: "U",
dataIndex: "expanded_uncertainty_percent",
align: "center",
render: (value: number) => `${value}%`
},
{ title: "k", dataIndex: "coverage_factor", align: "center" }
]}
/>
<Table<LimitTier>
className="limit-table"
pagination={false}
size="small"
rowKey="label"
dataSource={tiers}
columns={[
{ title: "级别", dataIndex: "label", align: "center" },
{ title: "IRa 限", dataIndex: "ira", align: "center", render: (value: number | null) => value ?? "—" },
{ title: "Ir 限", dataIndex: "ir", align: "center", render: (value: number | null) => value ?? "—" }
]}
/>
</div>
</Card>
</div>
{error ? <Alert type="error" message={error} showIcon /> : null}
<div className="results-area">
{result && lastInput ? (
<>
<Card
title="分析判定"
className="panel analysis-panel"
extra={
<span className="analysis-meta">
{sampleId.trim() || "未编号"} · {calcDate ? calcDate.format("YYYY-MM-DD") : "无日期"} ·{" "}
{result.measurement_count}
</span>
}
>
<div className="analysis-grid">
<div className="result-tile conclusion-tile">
<span></span>
<Tag color={validityText[result.analysis.validity].color}>
{validityText[result.analysis.validity].text}
</Tag>
<small> {formatNumber(result.analysis.total_calibrated_activity, 1)} Bq/kg 37</small>
</div>
<div className="result-tile conclusion-tile">
<span></span>
<Tag color={verdictDisplay(result.analysis.verdict).color}>
{verdictDisplay(result.analysis.verdict).text}
</Tag>
{result.analysis.verdict === "NeedMoreMeasurements" ? <small></small> : null}
</div>
<div className="result-tile">
<span>IRa </span>
<strong>{formatNumber(result.ira.value)}</strong>
<small>
[{formatNumber(result.ira.p2_5)}, {formatNumber(result.ira.p97_5)}] · k=2{" "}
{formatNumber(result.ira.relative_expanded_uncertainty_percent, 2)}%
</small>
</div>
<div className="result-tile">
<span>Ir </span>
<strong>{formatNumber(result.ir.value)}</strong>
<small>
[{formatNumber(result.ir.p2_5)}, {formatNumber(result.ir.p97_5)}] · k=2{" "}
{formatNumber(result.ir.relative_expanded_uncertainty_percent, 2)}%
</small>
</div>
</div>
</Card>
<div className="results-row">
<Card title="计算结果GUM 法)" className="panel">
<div className="result-grid">
<ResultTile title="IRa" value={result.ira.value} uncertainty={result.ira.relative_uncertainty_percent} />
<ResultTile title="Ir" value={result.ir.value} uncertainty={result.ir.relative_uncertainty_percent} />
<div className="result-tile conclusion-tile">
<span></span>
<Tag color={result.conclusion === "Ok" ? "success" : "warning"}>
{conclusionText[result.conclusion]}
</Tag>
</div>
</div>
<Table<{ name: string } & CalculationResult["ra"]>
pagination={false}
rowKey="name"
size="small"
dataSource={[
{ name: "Ra-226", ...result.ra },
{ name: "Th-232", ...result.th },
{ name: "K-40", ...result.k }
]}
columns={[
{ title: "核素", dataIndex: "name" },
{ title: "均值", dataIndex: "mean_measured", render: (value: number) => formatNumber(value) },
{ title: "校准活度", dataIndex: "mean_calibrated", render: (value: number) => formatNumber(value) },
{ title: "A 类", dataIndex: "type_a_uncertainty", render: (value: number) => formatNumber(value) },
{ title: "B 类相对", dataIndex: "type_b_relative", render: (value) => formatNumber(value * 100, 3) + "%" },
{ title: "合成", dataIndex: "combined_uncertainty", render: (value: number) => formatNumber(value) }
]}
/>
</Card>
<Card title={`蒙特卡洛仿真MCM${result.mcm.iterations} 次)`} className="panel">
<div className="result-grid">
<div className="result-tile">
<span></span>
<strong>{formatPercent(result.mcm.overall_pass_probability)}</strong>
<small>IRa Ir </small>
</div>
<div className="result-tile">
<span></span>
<strong>{formatPercent(result.mcm.overall_fail_probability)}</strong>
<small>95% </small>
</div>
<div className="result-tile conclusion-tile">
<span>仿</span>
<Tag color={result.mcm.overall_fail_probability < 0.05 ? "success" : "warning"}>
{result.mcm.overall_fail_probability < 0.05 ? "合格(不符合概率<5%" : "不合格风险"}
</Tag>
</div>
</div>
<Table<{ name: string } & CalculationResult["mcm"]["ira"]>
pagination={false}
rowKey="name"
size="small"
dataSource={[
{ name: "IRa", ...result.mcm.ira },
{ name: "Ir", ...result.mcm.ir }
]}
columns={[
{ title: "指数", dataIndex: "name" },
{ title: "平均值", dataIndex: "mean", render: (value: number) => formatNumber(value) },
{ title: "标准偏差", dataIndex: "std_dev", render: (value: number) => formatNumber(value) },
{ title: "P2.5", dataIndex: "p2_5", render: (value: number) => formatNumber(value) },
{ title: "P97.5", dataIndex: "p97_5", render: (value: number) => formatNumber(value) },
{ title: "标准值", dataIndex: "standard_value", render: (value: number) => formatNumber(value, 2) },
{
title: "合格概率",
dataIndex: "pass_probability",
render: (value: number) => <Tag color={value >= 0.95 ? "success" : "warning"}>{formatPercent(value)}</Tag>
}
]}
/>
</Card>
</div>
</>
) : (
<div className="results-placeholder"></div>
)}
</div>
<ReportModal
open={reportOpen}
onClose={() => setReportOpen(false)}
detail={result && lastInput ? { input: lastInput, result } : null}
/>
</section>
);
}
function ResultTile(props: { title: string; value: number; uncertainty: number }) {
return (
<div className="result-tile">
<span>{props.title}</span>
<strong>{formatNumber(props.value)}</strong>
<small> {formatNumber(props.uncertainty, 2)}%</small>
</div>
);
}
export { CalculatorPanel };