417 lines
17 KiB
TypeScript
417 lines
17 KiB
TypeScript
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 };
|