Add analysis judgment, history storage, and report/Excel export

Calculation (calculator/domain):
- Material types (main body / hollow / decorative) with tiered limits
- Validity judgment (37 Bq/kg exemption + Ur(IRa) k=2 threshold)
- Critical-value verdict via true-value interval vs limit (pass/fail/
  need-more), decorative A/B/C cascade
- GUM expanded uncertainty U and analytical interval P2.5/P97.5
- Single-measurement support (n=1, uA=0); fix Type-A term to include a

Persistence & export (src-tauri):
- SQLite history via rusqlite (save/list/get/delete, filtered list)
- Excel export via rust_xlsxwriter with native Save-As dialog
- Report view (A4) with print-to-PDF

UI:
- Tabs (calculate / history), save-to-history, report preview
- Logo-matched navy + gold theme, widened layout, density polish

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-11 15:55:55 +08:00
parent ef6c4f3f29
commit fd2dfbdfb8
19 changed files with 3138 additions and 531 deletions

View File

@ -0,0 +1,245 @@
# 判定逻辑补全设计(二期)
> 关联文档:一期设计 `2026-05-15-rust-tauri-mvp-design.md`;需求来源 `docs/建材放射性检测结果分析软件开发计算逻辑(1).pdf`
## 1. 目标
一期已完成算法骨架IRa/Ir、A/B 类不确定度、GUM 合成、MCM 仿真)。本期补齐 PDF 第 3、6 节缺失的**判定层与输出层**,使软件能给出最终交付给用户的"有效性 + 合格/不合格 + 材料分级"结论,而不只是中间量。
补齐范围:
- 1.3 样品信息:材料类型选择与对应限值集。
- 2.2.42.2.6:扩展不确定度 U、k=1/k=2 相对扩展不确定度、GUM 解析真值区间 P2.5/P97.5。
- 3.1 有效性判定37 Bq/kg 低活度豁免 + Ur(IRa) 阈值)。
- 3.2 临界值判定(真值区间是否跨越极限值 → 合格/不合格/建议增加次数)。
- 3.2.3 装饰装修材料 A/B/C 三级分类。
- 单次测量n=1主流程支持。
- 6.1 输出元信息(样品编号、计算日期)。
- 修正合成不确定度中 A 类项缺少校准因子 `a` 的问题。
非目标仍延后SQLite 历史、Excel/PDF 报告导出。本期只在内存结构和界面上给出完整结论,导出留三期。
## 2. 与现状的差距映射
| PDF 节 | 现状 | 本期动作 |
|---|---|---|
| 1.3 材料类型 | 无,仅手填 limit | 新增 `MaterialType`,按类型派生限值 |
| 2.2.4 扩展不确定度 | 无 | `IndexResult``expanded_uncertainty` |
| 2.2.5 k=1/k=2 相对 | 仅一套(k=1) | 增 `relative_expanded_uncertainty_percent` |
| 2.2.6 GUM 真值区间 | 仅 MCM 百分位 | `IndexResult``p2_5/p97_5 = value ± U` |
| 3.1 有效性 | 仅 Ur≤20% 双指数 | 实现 37 Bq/kg 豁免,仅看 Ur(IRa) |
| 3.2 临界值判定 | 无 | 新增 `Verdict` 判定函数 |
| 3.2.3 A/B/C | 无 | 装饰材料级联分类 |
| 单次测量 | `count<2` 报错 | n=1 时 uA=0 放行 |
| 6.1 元信息 | 无 | 输入/输出增 `sample_id`、`calculation_date` |
| 合成不确定度 A 类项 | 漏 `a` 因子 | 修正为 `a·uA` |
## 3. 领域模型变更(`domain.rs`
### 3.1 输入
```rust
pub struct SampleInput {
pub ra: NuclideMeasurements,
pub th: NuclideMeasurements,
pub k: NuclideMeasurements,
#[serde(default)]
pub material_type: MaterialType, // 新增,默认 BuildingMainBody
#[serde(default)]
pub sample_id: Option<String>, // 新增 6.1
#[serde(default)]
pub calculation_date: Option<String>, // 新增 6.1,前端传 ISO 字符串
}
#[derive(Default)]
pub enum MaterialType {
#[default]
BuildingMainBody, // 建筑主体材料 IRa≤1.0, Ir≤1.0
HollowBuildingMainBody, // 空心率>25%主体材料 IRa≤1.0, Ir≤1.3
DecorativeMaterial, // 装饰装修材料 A/B/C 分级
}
```
`AcceptanceLimits` 不再由前端手填,改为由 `MaterialType` 派生(保留结构体用于 MCM 比较)。装饰材料有多级限值,用一张分级表表达:
```rust
pub struct LimitTier { pub label: &'static str, pub ira_limit: Option<f64>, pub ir_limit: Option<f64> }
impl MaterialType {
/// 返回从严到宽的限值阶梯。主体材料/空心材料各 1 级;装饰材料 A/B/C 三级。
pub fn tiers(&self) -> &'static [LimitTier] { /* 见下表 */ }
}
```
限值表(来自 PDF 3.2
| 材料类型 | 级别 | IRa 限 | Ir 限 |
|---|---|---|---|
| 主体材料 | 合格 | 1.0 | 1.0 |
| 空心率>25% | 合格 | 1.0 | 1.3 |
| 装饰装修 | A | 1.0 | 1.3 |
| 装饰装修 | B | 1.3 | 1.9 |
| 装饰装修 | C | — | 2.8 |
> C 类只约束 Ir`ira_limit = None`)。
### 3.2 指数结果扩展
```rust
pub struct IndexResult {
pub value: f64,
pub standard_uncertainty: f64, // u, 已有
pub expanded_uncertainty: f64, // U = u·k (k=2),新增
pub relative_uncertainty_percent: f64, // k=1已有
pub relative_expanded_uncertainty_percent: f64, // k=2新增
pub p2_5: f64, // value - U新增GUM 解析区间)
pub p97_5: f64, // value + U新增
}
```
指数包含因子固定 `k = 2`PDF 2.2.4/2.2.6)。
### 3.3 判定结果
```rust
pub struct CalculationResult {
/* 既有字段 ... */
pub analysis: AnalysisResult, // 新增
}
pub struct AnalysisResult {
pub total_calibrated_activity: f64, // A1·a+A2·b+A3·c
pub validity: Validity,
pub verdict: Verdict,
}
pub enum Validity {
LowActivityExempt, // ≤37 Bq/kg直接有效
UncertaintyAcceptable, // >37 且 Ur(IRa)≤20%
Invalid, // >37 且 Ur(IRa)>20%
}
pub enum Verdict {
Qualified, // 主体/空心:合格
Unqualified, // 不合格
DecorativeClass(DecorClass), // 装饰A/B/C
NeedMoreMeasurements, // 真值区间跨越极限值,建议增加到 6 次
InvalidResult, // 有效性不成立
}
pub enum DecorClass { A, B, C, Unqualified }
```
`Conclusion` 枚举保留(向后兼容),但 UI 主结论改用 `Verdict`
## 4. 计算逻辑变更(`calculator.rs`
### 4.1 单次测量支持
`validate_input``count` 下限由 2 改为 1且三核素次数一致。`type_a_uncertainty``n == 1` 时返回 `0.0`PDF 2.2.1 uA=0`n>=6` 走标准差法,`2<=n<6` 走极差法`n` 其它非法值仍报错
### 4.2 合成不确定度修正
校准比活度 `C = mean·a`,对测量值 A 的灵敏系数是 `a`
```rust
// 修正前: combined = sqrt(uA² + (mean·uB)²)
// 修正后: combined = sqrt((a·uA)² + (mean·uB)²)
let combined = ((factor*type_a).powi(2) + (mean*type_b_uncertainty).powi(2)).sqrt();
```
n=1uA=0时与现状一致可与 PDF 单次算例对齐校验。
### 4.3 GUM 真值区间
每个 `IndexResult` 计算 `U = u·2``p2_5 = value - U``p97_5 = value + U`相对量两套u/value 与 U/value
### 4.4 有效性判定3.1
```rust
let total = ra.mean_calibrated + th.mean_calibrated + k.mean_calibrated; // A1·a+A2·b+A3·c
let validity = if total <= 37.0 {
Validity::LowActivityExempt
} else if ira.relative_expanded_uncertainty_percent <= 20.0 {
Validity::UncertaintyAcceptable
} else {
Validity::Invalid
};
```
> **待确认 1**3.1 的 `Ur(IRa)≤20%` 用 k=2 的相对扩展不确定度(与 2.2.5 命名一致)还是 k=1。本设计先取 k=2见第 7 节。
### 4.5 临界值判定3.2
核心是"真值区间 [P2.5,P97.5] 相对极限值 L 的位置"的三态函数:
```rust
enum TierCheck { Pass, Fail, Straddle } // 全在限下 / 全在限上 / 跨越
fn check(idx: &IndexResult, limit: Option<f64>) -> TierCheck {
let Some(l) = limit else { return TierCheck::Pass }; // 无约束(如 C 类的 IRa
if idx.p97_5 < l { TierCheck::Pass } // 区间不含 L 且值 < L
else if idx.p2_5 > l { TierCheck::Fail } // 区间不含 L 且值 > L
else { TierCheck::Straddle } // 区间含 L → 建议增加次数
}
```
组合规则:
- 若 `validity == Invalid``Verdict::InvalidResult`
- **主体/空心材料**单级IRa、Ir 两项 `check`
- 任一 `Straddle``NeedMoreMeasurements`
- 两项均 `Pass``Qualified`
- 否则(存在 `Fail`)→ `Unqualified`
- **装饰材料**A→B→C 级联):自严到宽逐级判断。
- 某级两项C 级仅 Ir`Pass` → 归该级(`DecorClass::A/B/C`)。
- 某级存在 `Straddle` 且尚未在更严级别通过 → `NeedMoreMeasurements`
- 全部级别都不通过且 C 级 `Fail``DecorClass::Unqualified`(不可用于建材)。
### 4.6 与 MCM 的衔接
GUM 路径给出确定性 `Verdict`;当 `Verdict::NeedMoreMeasurements`区间跨越极限值MCM 的 `overall_fail_probability` 即 PDF 6.3 的"95% 置信概率下不符合概率"作为补充量化结论展示。MCM 仍每次都算(用当前限值),无需新增接口。
> **待确认 2**6 次测量后若仍跨越极限值,最终合格判据用 MCM 不符合概率阈值(如 <5% 判合格还是仅展示概率由人判断现有代码用 `pass_probability>=0.95`本设计沿用并在第 7 节标注
## 5. 界面变更(`App.tsx`
- 顶部输入区:新增**材料类型**下拉(三选一)、**样品编号**输入、**计算日期**选择(默认今日)。移除手填 IRa/Ir 限值,改为根据材料类型只读展示当前限值表。
- 结果区新增"**分析判定**"卡片:
- 有效性标签(有效/低活度豁免/无效)+ 总比活度 `A1·a+A2·b+A3·c` 与 37 对比。
- 最终判定标签:合格 / 不合格 / A 类·B 类·C 类 / 建议增加至 6 次 / 结果无效。
- 各指数 GUM 真值区间 `[P2.5, P97.5]`、U、相对扩展不确定度k=1/k=2
- 报告头展示样品编号与计算日期,为三期导出做准备。
类型定义TS同步扩展 `MaterialType`、`AnalysisResult`、`Verdict`、`IndexResult` 新字段。
## 6. 测试(`tests/calculator_tests.rs`
新增:
- **单次测量对齐 PDF 算例**A1=83.439/a=0.916 等,断言 IRa≈0.38、Ir≈0.73、u(IRa)≈0.012、U(IRa)≈0.024、P2.5≈0.36、P97.5≈0.40、Ur(IRa,k=2)≈6.3%。
- **有效性**total≤37 → `LowActivityExempt`total>37 且 Ur 大 → `Invalid`
- **临界值三态**:构造区间全低/全高/跨越,断言 `Qualified`/`Unqualified`/`NeedMoreMeasurements`。
- **装饰材料级联**:分别命中 A、B、C 与 Unqualified。
- **合成不确定度修正**:多次测量下 A 类项含 `a` 因子(与手算对比)。
- 既有 6 次测量与 MCM 测试保持通过(注意修正后数值会有小幅变化,需更新期望值)。
## 7. 已确认决策
1. **3.1 的 Ur(IRa) 取 k=2 的相对扩展不确定度**`relative_expanded_uncertainty_percent`),与 2.2.5 命名一致。✅ 已定
2. **6 次测量后的最终合格判据**:用 MCM 不符合概率,`overall_fail_probability < 5%` `pass_probability ≥ 0.95`自动判合格/不合格。✅ 已定
3. **材料类型限值只读**:移除前端手填 IRa/Ir 限值,严格按材料类型派生。✅ 已定
仍按本设计默认处理、无需进一步确认:
4. **计算日期来源**:由前端生成 ISO 字符串传入本地时区可控Rust 侧不引入 `chrono`)。
5. **单次测量仍跑 MCM**n=1 时 uA=0MCM 仅传播 B 类不确定度,仍有意义,默认保留。
## 8. 实施顺序
1. `domain.rs``MaterialType`/限值表 + `IndexResult` 扩展 + `AnalysisResult/Verdict`
2. `calculator.rs`单次测量、合成不确定度修正、GUM 区间、有效性、临界值判定。
3. 补充/更新 Rust 测试至全绿(先对齐 PDF 单次算例)。
4. `lib.rs` 导出新类型;`main.rs` 命令签名无需变更(结构体透传)。
5. `App.tsx`:材料类型/样品信息输入 + 分析判定卡片。
6. 端到端手测三类材料的合格/不合格/分级/建议增加次数路径。

View File

@ -0,0 +1,152 @@
# 历史存储与报告导出设计(三期)
> 关联:一期 `2026-05-15-rust-tauri-mvp-design.md`、二期 `2026-06-11-judgment-logic-design.md`
> 对应 PDF 第 6 节「输出」样品编号、计算日期、最佳估计值、95% 置信区间、k=1 相对扩展不确定度、不符合概率。
## 1. 目标
一/二期已完成计算与判定。本期补齐"留痕与出报告"两块工程能力:
- **历史存储**:每次计算的输入与结果落库,可检索、回看、复算、删除。
- **报告导出**将一次完整结果导出为可交付文件PDF 检测报告 + Excel 数据表)。
非目标:多用户/权限、云同步、检测报告的官方排版模板(先满足内容完整、可打印)。
## 2. 范围
| 能力 | 内容 |
|---|---|
| 历史存储 | 保存/列表/详情/删除/复算一条记录 |
| Excel 导出 | 单条记录导出为 `.xlsx`(输入表 + 结果表 + 判定) |
| PDF 报告 | 单条记录导出为 A4 检测报告(中文) |
| 列表检索 | 按样品编号 / 日期范围 / 材料类型 / 判定结论过滤 |
## 3. 数据模型与存储
### 3.1 选型
SQLite经由 **`rusqlite`bundled 特性)** 直接在 `src-tauri` 内访问,不引入 `tauri-plugin-sql`。理由:计算结果是结构化 JSON存取简单bundled 模式免去系统 SQLite 依赖Windows 打包干净。
库文件位置:`tauri::api::path::app_data_dir()` 下的 `history.db`(随应用数据目录,卸载可清理)。
### 3.2 表结构
```sql
CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sample_id TEXT, -- 样品编号(可空)
material_type TEXT NOT NULL, -- MaterialType 序列化
calc_date TEXT, -- 计算日期 YYYY-MM-DD
created_at TEXT NOT NULL, -- 入库时间 RFC3339前端传入
ira_value REAL NOT NULL, -- 冗余列,供列表展示/排序/过滤
ir_value REAL NOT NULL,
validity TEXT NOT NULL, -- Validity 序列化
verdict TEXT NOT NULL, -- Verdict 序列化(用于列表标签与过滤)
input_json TEXT NOT NULL, -- 完整 SampleInput
result_json TEXT NOT NULL -- 完整 CalculationResult
);
CREATE INDEX IF NOT EXISTS idx_records_created ON records(created_at);
CREATE INDEX IF NOT EXISTS idx_records_sample ON records(sample_id);
```
设计要点:完整 `input_json` / `result_json` 保证回看与复算的保真;冗余标量列只为列表查询与过滤,避免反序列化全表。
### 3.3 复算保真
复算 = 读 `input_json``calculate_sample` 重新计算。因 MCM 用固定种子,结果可复现,可与存档 `result_json` 比对作为一致性自检(库内核版本升级时用于回归)。
## 4. 后端命令(`src-tauri`
新增 Tauri 命令,全部返回 `Result<_, String>`
```rust
save_record(input: SampleInput, result: CalculationResult, created_at: String) -> i64 // 返回 id
list_records(filter: RecordFilter) -> Vec<RecordSummary>
get_record(id: i64) -> RecordDetail // 含 input + result
delete_record(id: i64) -> ()
export_excel(id: i64, path: String) -> () // 写 .xlsx 到用户选定路径
export_report(id: i64, path: String) -> () // 见第 6 节
```
```rust
struct RecordFilter {
sample_id: Option<String>, // 模糊匹配
material_type: Option<MaterialType>,
date_from: Option<String>,
date_to: Option<String>,
verdict_kind: Option<String>, // "Qualified" / "Unqualified" / ...
}
struct RecordSummary {
id: i64, sample_id: Option<String>, material_type: MaterialType,
calc_date: Option<String>, created_at: String,
ira_value: f64, ir_value: f64, validity: Validity, verdict: Verdict,
}
```
数据库连接用 `tauri::State<Mutex<Connection>>``setup` 中初始化并建表。
新增 crate`src-tauri/Cargo.toml``rusqlite { features = ["bundled"] }`、`rust_xlsxwriter`。PDF 方案见第 6 节(可能 0 依赖)。
## 5. Excel 导出
`rust_xlsxwriter`(纯 Rust、原生 UTF-8无 CJK 字体问题)。生成单工作簿三段:
1. **样品信息**:编号、日期、材料类型、有效性、最终判定。
2. **测量与校准**:各次 Ra/Th/K 测量值;校准系数 a/U/k。
3. **结果**IRa/Ir 值、u、U、相对扩展不确定度(k=1/k=2)、真值区间;各核素均值/校准活度/A 类/B 类/合成MCM 平均值/标准偏差/区间/合格概率/不符合概率。
路径经 `@tauri-apps/plugin-dialog` 的保存对话框选定Rust 侧写文件。
## 6. PDF 报告
中文 PDF 字体内嵌是主要难点,给两条路线,**推荐路线 A**
- **路线 A推荐— Webview 打印**前端用一个独立的、A4 排版的「报告视图」组件渲染完整结果,调用 `window.print()`(或 Tauri 打印 API用户在打印对话框选「另存为 PDF」。零新依赖、零字体问题、排版用 CSS 即可,所见即所得。代价:依赖系统打印到 PDF非完全静默导出。
- **路线 B — Rust 生成**`printpdf` + 内嵌中文子集字体(如 Noto Sans SC。可完全静默、可定制页眉页脚但需打包字体、自行处理换行与分页工作量大。
建议本期落 A把"报告视图"做成可复用组件(同一组件既用于屏幕预览也用于打印),路线 B 留作后续真正"一键静默导出"需求时再做。
报告内容(对应 PDF 6.16.3
- 抬头:样品编号、计算日期、材料类型、限值表。
- 检测结果IRa、Ir 最佳估计值95% 真值区间 [P2.5, P97.5]k=1 / k=2 相对扩展不确定度。
- 有效性判定(总比活度 vs 37、最终判定合格/不合格/A·B·C/建议增加次数)。
- MCM95% 置信概率下不符合概率。
- 各核素中间量明细表。
## 7. 界面变更
- 顶部加「保存到历史」按钮(计算成功后可用);保存成功后提示。
- 新增「历史」入口(抽屉或独立 Tab列表编号/日期/材料/IRa/Ir/判定)+ 过滤栏;行操作「查看 / 复算 / 导出 Excel / 导出 PDF / 删除」。
- 「查看」打开报告视图(即打印用组件)。
- 计算日期从已有的 DatePicker 复用,`created_at` 取前端当前时间戳。
## 8. 测试
- RustDB 用内存库(`:memory:`)测 save→list→get→delete 往返;过滤条件命中;`export_excel` 生成非空且能被重新打开(校验首部魔数/可解析)。
- 复算一致性:存档 `result_json` 与重算结果逐字段相等(固定种子)。
- 前端:报告视图快照(关键字段渲染)、历史列表过滤交互。
## 9. 已确认决策
1. **PDF 路线**:路线 A —— 前端 A4 报告视图 + `window.print()` 另存为 PDF。✅
2. **历史入口形态**:顶部 Tab 切换(计算 / 历史两页)。✅
3. **保存时机**:仅用户点「保存到历史」时入库,避免调参脏数据。✅
4. **导出范围**PDF 报告 + Excel 数据表都做(实现独立,可分批落地)。✅
## 9.5 实现说明(落地后补记)
- **rusqlite 版本**:本地 Rust 为 1.93`libsqlite3-sys 0.38` 使用了未稳定的 `cfg_select!`,编译失败。已将 `rusqlite` 固定为 `0.37`(拉取 `libsqlite3-sys 0.35`)规避。升级工具链到 ≥1.94 后可放开。
- **Excel 落盘**`export_excel` 写入系统下载目录(取不到则回退文档目录),返回完整路径,前端以提示展示,无需对话框插件。
- **PDF**:前端 `ReportView` + `ReportModal`,「打印 / 导出 PDF」走 `window.print()`,打印 CSS 用 visibility 隔离 `.report-root`,仅输出报告本体(隐藏 Modal 外框与遮罩)。
- **命令入参**`save_record` 用 `SaveArgs` 结构体包裹 `input/result/created_at`,避免 JS↔Rust 多词参数名映射歧义。
- **f64 存储**:完整结果以 JSON 落库serde_json 解析可能有 ~1e-14 的末位 ULP 偏差,对指数无实际影响,测试以容差比较。
## 10. 实施顺序
1. `src-tauri`:加 `rusqlite``setup` 建表 + 连接 State。
2. 实现 save/list/get/delete 命令 + Rust 往返测试。
3. 前端历史列表 + 过滤 + 保存按钮。
4. 报告视图组件(屏幕预览)→ 接 `window.print()` 出 PDF。
5. `rust_xlsxwriter` 导出命令 + 保存对话框。
6. 端到端:算→存→列表过滤→查看→导出。

267
src-tauri/Cargo.lock generated
View File

@ -288,9 +288,13 @@ name = "ceramic-radioactivity-tauri"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ceramic-radioactivity", "ceramic-radioactivity",
"rusqlite",
"rust_xlsxwriter",
"serde", "serde",
"serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
] ]
[[package]] [[package]]
@ -736,6 +740,18 @@ dependencies = [
"typeid", "typeid",
] ]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.4.1"
@ -775,6 +791,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"miniz_oxide", "miniz_oxide",
"zlib-rs",
] ]
[[package]] [[package]]
@ -1216,6 +1233,15 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.5",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.4.1" version = "0.4.1"
@ -1695,6 +1721,17 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "libsqlite3-sys"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@ -1980,6 +2017,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@ -2454,6 +2492,53 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "rusqlite"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
dependencies = [
"bitflags 2.11.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rust_xlsxwriter"
version = "0.95.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f281b687352597d29efaad39701d1167d5c48aa76fb973e392bc13e9d44e7f36"
dependencies = [
"zip",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@ -2625,9 +2710,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.149" version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@ -3098,6 +3183,64 @@ dependencies = [
"tauri-utils", "tauri-utils",
] ]
[[package]]
name = "tauri-plugin"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.1" version = "2.11.1"
@ -3543,6 +3686,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]] [[package]]
name = "typeid" name = "typeid"
version = "1.0.3" version = "1.0.3"
@ -3663,6 +3812,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
@ -4145,6 +4300,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@ -4178,13 +4342,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6", "windows_x86_64_msvc 0.52.6",
] ]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]] [[package]]
name = "windows-threading" name = "windows-threading"
version = "0.1.0" version = "0.1.0"
@ -4215,6 +4396,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.42.2" version = "0.42.2"
@ -4227,6 +4414,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.42.2" version = "0.42.2"
@ -4239,12 +4432,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.42.2" version = "0.42.2"
@ -4257,6 +4462,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.42.2" version = "0.42.2"
@ -4269,6 +4480,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.42.2" version = "0.42.2"
@ -4281,6 +4498,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.42.2" version = "0.42.2"
@ -4293,6 +4516,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.5.40" version = "0.5.40"
@ -4569,8 +4798,40 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "zip"
version = "7.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0"
dependencies = [
"crc32fast",
"flate2",
"indexmap 2.14.0",
"memchr",
"typed-path",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]

View File

@ -8,5 +8,9 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
ceramic-radioactivity = { path = ".." } ceramic-radioactivity = { path = ".." }
rusqlite = { version = "0.37", features = ["bundled"] }
rust_xlsxwriter = "0.95.0"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1.0.150"
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2.7.1"

361
src-tauri/src/db.rs Normal file
View File

@ -0,0 +1,361 @@
//! 历史记录的 SQLite 存储。完整 input/result 以 JSON 落库,另冗余若干标量列供列表查询与过滤。
use std::sync::Mutex;
use ceramic_radioactivity::{CalculationResult, MaterialType, SampleInput, Validity, Verdict};
use rusqlite::{Connection, ToSql};
use serde::{Deserialize, Serialize};
/// 由 Tauri 托管的数据库连接。
pub struct Db(pub Mutex<Connection>);
const CREATE_SQL: &str = "
CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sample_id TEXT,
material_type TEXT NOT NULL,
calc_date TEXT,
created_at TEXT NOT NULL,
ira_value REAL NOT NULL,
ir_value REAL NOT NULL,
validity TEXT NOT NULL,
verdict_kind TEXT NOT NULL,
verdict_json TEXT NOT NULL,
input_json TEXT NOT NULL,
result_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_records_created ON records(created_at);
CREATE INDEX IF NOT EXISTS idx_records_sample ON records(sample_id);
";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordFilter {
pub sample_id: Option<String>,
pub material_type: Option<MaterialType>,
pub date_from: Option<String>,
pub date_to: Option<String>,
pub verdict_kind: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RecordSummary {
pub id: i64,
pub sample_id: Option<String>,
pub material_type: MaterialType,
pub calc_date: Option<String>,
pub created_at: String,
pub ira_value: f64,
pub ir_value: f64,
pub validity: Validity,
pub verdict: Verdict,
}
#[derive(Debug, Clone, Serialize)]
pub struct RecordDetail {
pub summary: RecordSummary,
pub input: SampleInput,
pub result: CalculationResult,
}
/// 建表(幂等)。
pub fn init(conn: &Connection) -> Result<(), String> {
conn.execute_batch(CREATE_SQL).map_err(|e| e.to_string())
}
/// 稳定的判定大类,用于列表过滤(装饰材料的 A/B/C 归为 DecorativeClass
pub fn verdict_kind(verdict: &Verdict) -> &'static str {
match verdict {
Verdict::Qualified => "Qualified",
Verdict::Unqualified => "Unqualified",
Verdict::NeedMoreMeasurements => "NeedMoreMeasurements",
Verdict::InvalidResult => "InvalidResult",
Verdict::DecorativeClass(_) => "DecorativeClass",
}
}
pub fn save(
conn: &Connection,
input: &SampleInput,
result: &CalculationResult,
created_at: &str,
) -> Result<i64, String> {
let material_type = to_json(&input.material_type)?;
let validity = to_json(&result.analysis.validity)?;
let verdict_json = to_json(&result.analysis.verdict)?;
let input_json = to_json(input)?;
let result_json = to_json(result)?;
conn.execute(
"INSERT INTO records (
sample_id, material_type, calc_date, created_at,
ira_value, ir_value, validity, verdict_kind, verdict_json,
input_json, result_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
input.sample_id,
material_type,
input.calculation_date,
created_at,
result.ira.value,
result.ir.value,
validity,
verdict_kind(&result.analysis.verdict),
verdict_json,
input_json,
result_json,
],
)
.map_err(|e| e.to_string())?;
Ok(conn.last_insert_rowid())
}
/// 列表查询行(标量 + 用于反序列化的 JSON 文本)。
struct RawSummary {
id: i64,
sample_id: Option<String>,
material_type: String,
calc_date: Option<String>,
created_at: String,
ira_value: f64,
ir_value: f64,
validity: String,
verdict_json: String,
}
pub fn list(conn: &Connection, filter: &RecordFilter) -> Result<Vec<RecordSummary>, String> {
let mut sql = String::from(
"SELECT id, sample_id, material_type, calc_date, created_at,
ira_value, ir_value, validity, verdict_json
FROM records WHERE 1 = 1",
);
let mut args: Vec<Box<dyn ToSql>> = Vec::new();
if let Some(sample_id) = filter.sample_id.as_ref().filter(|s| !s.is_empty()) {
sql.push_str(" AND sample_id LIKE ?");
args.push(Box::new(format!("%{sample_id}%")));
}
if let Some(material_type) = &filter.material_type {
sql.push_str(" AND material_type = ?");
args.push(Box::new(to_json(material_type)?));
}
if let Some(date_from) = filter.date_from.as_ref().filter(|s| !s.is_empty()) {
sql.push_str(" AND calc_date >= ?");
args.push(Box::new(date_from.clone()));
}
if let Some(date_to) = filter.date_to.as_ref().filter(|s| !s.is_empty()) {
sql.push_str(" AND calc_date <= ?");
args.push(Box::new(date_to.clone()));
}
if let Some(kind) = filter.verdict_kind.as_ref().filter(|s| !s.is_empty()) {
sql.push_str(" AND verdict_kind = ?");
args.push(Box::new(kind.clone()));
}
sql.push_str(" ORDER BY created_at DESC, id DESC");
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let params: Vec<&dyn ToSql> = args.iter().map(|b| b.as_ref()).collect();
let raw_rows = stmt
.query_map(params.as_slice(), |row| {
Ok(RawSummary {
id: row.get(0)?,
sample_id: row.get(1)?,
material_type: row.get(2)?,
calc_date: row.get(3)?,
created_at: row.get(4)?,
ira_value: row.get(5)?,
ir_value: row.get(6)?,
validity: row.get(7)?,
verdict_json: row.get(8)?,
})
})
.map_err(|e| e.to_string())?;
let mut summaries = Vec::new();
for raw in raw_rows {
let raw = raw.map_err(|e| e.to_string())?;
summaries.push(raw_to_summary(raw)?);
}
Ok(summaries)
}
pub fn get(conn: &Connection, id: i64) -> Result<RecordDetail, String> {
conn.query_row(
"SELECT id, sample_id, material_type, calc_date, created_at,
ira_value, ir_value, validity, verdict_json, input_json, result_json
FROM records WHERE id = ?1",
[id],
|row| {
Ok((
RawSummary {
id: row.get(0)?,
sample_id: row.get(1)?,
material_type: row.get(2)?,
calc_date: row.get(3)?,
created_at: row.get(4)?,
ira_value: row.get(5)?,
ir_value: row.get(6)?,
validity: row.get(7)?,
verdict_json: row.get(8)?,
},
row.get::<_, String>(9)?,
row.get::<_, String>(10)?,
))
},
)
.map_err(|e| e.to_string())
.and_then(|(raw, input_json, result_json)| {
Ok(RecordDetail {
summary: raw_to_summary(raw)?,
input: from_json(&input_json)?,
result: from_json(&result_json)?,
})
})
}
pub fn delete(conn: &Connection, id: i64) -> Result<(), String> {
conn.execute("DELETE FROM records WHERE id = ?1", [id])
.map_err(|e| e.to_string())?;
Ok(())
}
fn raw_to_summary(raw: RawSummary) -> Result<RecordSummary, String> {
Ok(RecordSummary {
id: raw.id,
sample_id: raw.sample_id,
material_type: from_json(&raw.material_type)?,
calc_date: raw.calc_date,
created_at: raw.created_at,
ira_value: raw.ira_value,
ir_value: raw.ir_value,
validity: from_json(&raw.validity)?,
verdict: from_json(&raw.verdict_json)?,
})
}
fn to_json<T: Serialize>(value: &T) -> Result<String, String> {
serde_json::to_string(value).map_err(|e| e.to_string())
}
fn from_json<T: for<'de> Deserialize<'de>>(text: &str) -> Result<T, String> {
serde_json::from_str(text).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use ceramic_radioactivity::{calculate_sample, CalibrationParams, NuclideMeasurements};
fn cal(factor: f64, u: f64) -> CalibrationParams {
CalibrationParams {
factor,
expanded_uncertainty_percent: u,
coverage_factor: 2.0,
}
}
fn sample() -> (SampleInput, CalculationResult) {
let input = SampleInput {
ra: NuclideMeasurements {
measured_values: vec![100.0],
calibration: cal(0.916, 6.3),
},
th: NuclideMeasurements {
measured_values: vec![110.0],
calibration: cal(0.884, 6.9),
},
k: NuclideMeasurements {
measured_values: vec![560.0],
calibration: cal(0.961, 6.7),
},
material_type: MaterialType::BuildingMainBody,
sample_id: Some("S-1".into()),
calculation_date: Some("2026-06-11".into()),
};
let result = calculate_sample(input.clone()).unwrap();
(input, result)
}
fn empty_filter() -> RecordFilter {
RecordFilter {
sample_id: None,
material_type: None,
date_from: None,
date_to: None,
verdict_kind: None,
}
}
#[test]
fn save_list_get_delete_roundtrip() {
let conn = Connection::open_in_memory().unwrap();
init(&conn).unwrap();
let (input, result) = sample();
let id = save(&conn, &input, &result, "2026-06-11T10:00:00Z").unwrap();
assert!(id > 0);
let all = list(&conn, &empty_filter()).unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].sample_id.as_deref(), Some("S-1"));
assert_eq!(all[0].verdict, result.analysis.verdict);
assert_eq!(all[0].material_type, MaterialType::BuildingMainBody);
let detail = get(&conn, id).unwrap();
// 离散字段精确比较数值字段用容差serde_json 的 f64 解析可能有末位 ULP 偏差,~1e-14可忽略
assert_eq!(detail.input.sample_id, input.sample_id);
assert_eq!(detail.input.material_type, input.material_type);
assert_eq!(detail.input.ra.measured_values, input.ra.measured_values);
assert_eq!(detail.result.measurement_count, result.measurement_count);
assert_eq!(detail.result.analysis.validity, result.analysis.validity);
assert_eq!(detail.result.analysis.verdict, result.analysis.verdict);
assert!((detail.result.ira.value - result.ira.value).abs() < 1e-9);
assert!((detail.result.ir.value - result.ir.value).abs() < 1e-9);
assert!(
(detail.result.mcm.overall_fail_probability - result.mcm.overall_fail_probability).abs()
< 1e-9
);
delete(&conn, id).unwrap();
assert!(list(&conn, &empty_filter()).unwrap().is_empty());
}
#[test]
fn filters_narrow_results() {
let conn = Connection::open_in_memory().unwrap();
init(&conn).unwrap();
let (input, result) = sample();
save(&conn, &input, &result, "2026-06-11T10:00:00Z").unwrap();
let hit = list(
&conn,
&RecordFilter {
verdict_kind: Some("Qualified".into()),
sample_id: Some("S".into()),
..empty_filter()
},
)
.unwrap();
assert_eq!(hit.len(), 1);
let miss = list(
&conn,
&RecordFilter {
verdict_kind: Some("Unqualified".into()),
..empty_filter()
},
)
.unwrap();
assert!(miss.is_empty());
let wrong_date = list(
&conn,
&RecordFilter {
date_from: Some("2026-07-01".into()),
..empty_filter()
},
)
.unwrap();
assert!(wrong_date.is_empty());
}
}

217
src-tauri/src/excel.rs Normal file
View File

@ -0,0 +1,217 @@
//! 将一条历史记录导出为 .xlsx。rust_xlsxwriter 原生 UTF-8无需处理中文字体。
use std::path::Path;
use ceramic_radioactivity::{MaterialType, NuclideResult, Validity, Verdict};
use rust_xlsxwriter::{Format, Workbook};
use crate::db::RecordDetail;
/// 「另存为」对话框的默认文件名。
pub fn default_file_name(detail: &RecordDetail) -> String {
let sample = detail
.summary
.sample_id
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("未编号");
let safe: String = sample
.chars()
.map(|c| if r#"\/:*?"<>|"#.contains(c) { '_' } else { c })
.collect();
format!("建材放射性检测_{safe}_{}.xlsx", detail.summary.id)
}
/// 将 xlsx 写到用户选定的路径,返回完整路径。
pub fn write_to_path(path: &Path, detail: &RecordDetail) -> Result<String, String> {
let mut workbook = Workbook::new();
let bold = Format::new().set_bold();
write_summary(&mut workbook, detail, &bold)?;
write_measurements(&mut workbook, detail, &bold)?;
write_results(&mut workbook, detail, &bold)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
workbook
.save(path)
.map_err(|e| e.to_string())
.map(|_| path.to_string_lossy().into_owned())
}
fn write_summary(
workbook: &mut Workbook,
detail: &RecordDetail,
bold: &Format,
) -> Result<(), String> {
let sheet = workbook.add_worksheet();
sheet.set_name("概要").map_err(|e| e.to_string())?;
let s = &detail.summary;
let rows: [(&str, String); 7] = [
("样品编号", s.sample_id.clone().unwrap_or_else(|| "未编号".into())),
("计算日期", s.calc_date.clone().unwrap_or_default()),
("入库时间", s.created_at.clone()),
("材料类型", material_text(&s.material_type).into()),
("测量次数", detail.result.measurement_count.to_string()),
("有效性", validity_text(&s.validity).into()),
("最终判定", verdict_text(&s.verdict)),
];
for (i, (label, value)) in rows.iter().enumerate() {
let row = i as u32;
sheet.write_with_format(row, 0, *label, bold).map_err(|e| e.to_string())?;
sheet.write(row, 1, value.as_str()).map_err(|e| e.to_string())?;
}
sheet.set_column_width(0, 14).ok();
sheet.set_column_width(1, 28).ok();
Ok(())
}
fn write_measurements(
workbook: &mut Workbook,
detail: &RecordDetail,
bold: &Format,
) -> Result<(), String> {
let sheet = workbook.add_worksheet();
sheet.set_name("测量与校准").map_err(|e| e.to_string())?;
let input = &detail.input;
for (col, header) in ["序号", "Ra-226", "Th-232", "K-40"].iter().enumerate() {
sheet.write_with_format(0, col as u16, *header, bold).map_err(|e| e.to_string())?;
}
let n = input.ra.measured_values.len();
for i in 0..n {
let row = (i + 1) as u32;
sheet.write(row, 0, (i + 1) as f64).map_err(|e| e.to_string())?;
sheet.write(row, 1, input.ra.measured_values[i]).map_err(|e| e.to_string())?;
sheet.write(row, 2, input.th.measured_values[i]).map_err(|e| e.to_string())?;
sheet.write(row, 3, input.k.measured_values[i]).map_err(|e| e.to_string())?;
}
let base = (n + 2) as u32;
sheet.write_with_format(base, 0, "校准参数", bold).map_err(|e| e.to_string())?;
for (col, header) in ["核素", "校准系数 a", "U(%)", "k"].iter().enumerate() {
sheet.write_with_format(base + 1, col as u16, *header, bold).map_err(|e| e.to_string())?;
}
let cals = [
("Ra", input.ra.calibration),
("Th", input.th.calibration),
("K", input.k.calibration),
];
for (i, (name, cal)) in cals.iter().enumerate() {
let row = base + 2 + i as u32;
sheet.write(row, 0, *name).map_err(|e| e.to_string())?;
sheet.write(row, 1, cal.factor).map_err(|e| e.to_string())?;
sheet.write(row, 2, cal.expanded_uncertainty_percent).map_err(|e| e.to_string())?;
sheet.write(row, 3, cal.coverage_factor).map_err(|e| e.to_string())?;
}
sheet.set_column_width(0, 10).ok();
Ok(())
}
fn write_results(
workbook: &mut Workbook,
detail: &RecordDetail,
bold: &Format,
) -> Result<(), String> {
let sheet = workbook.add_worksheet();
sheet.set_name("结果与MCM").map_err(|e| e.to_string())?;
let r = &detail.result;
// 指数结果。
for (col, header) in ["指数", "", "U(k=2)", "相对(k=2)%", "P2.5", "P97.5"]
.iter()
.enumerate()
{
sheet.write_with_format(0, col as u16, *header, bold).map_err(|e| e.to_string())?;
}
let indices = [("IRa", &r.ira), ("Ir", &r.ir)];
for (i, (name, idx)) in indices.iter().enumerate() {
let row = (i + 1) as u32;
sheet.write(row, 0, *name).map_err(|e| e.to_string())?;
sheet.write(row, 1, idx.value).map_err(|e| e.to_string())?;
sheet.write(row, 2, idx.expanded_uncertainty).map_err(|e| e.to_string())?;
sheet.write(row, 3, idx.relative_expanded_uncertainty_percent).map_err(|e| e.to_string())?;
sheet.write(row, 4, idx.p2_5).map_err(|e| e.to_string())?;
sheet.write(row, 5, idx.p97_5).map_err(|e| e.to_string())?;
}
// 核素中间量。
let base = 4u32;
for (col, header) in ["核素", "均值", "校准活度", "A类", "B类相对", "合成不确定度"]
.iter()
.enumerate()
{
sheet.write_with_format(base, col as u16, *header, bold).map_err(|e| e.to_string())?;
}
let nuclides: [(&str, &NuclideResult); 3] =
[("Ra-226", &r.ra), ("Th-232", &r.th), ("K-40", &r.k)];
for (i, (name, nr)) in nuclides.iter().enumerate() {
let row = base + 1 + i as u32;
sheet.write(row, 0, *name).map_err(|e| e.to_string())?;
sheet.write(row, 1, nr.mean_measured).map_err(|e| e.to_string())?;
sheet.write(row, 2, nr.mean_calibrated).map_err(|e| e.to_string())?;
sheet.write(row, 3, nr.type_a_uncertainty).map_err(|e| e.to_string())?;
sheet.write(row, 4, nr.type_b_relative).map_err(|e| e.to_string())?;
sheet.write(row, 5, nr.combined_uncertainty).map_err(|e| e.to_string())?;
}
// MCM。
let mbase = base + 5;
sheet.write_with_format(mbase, 0, format!("蒙特卡洛仿真({} 次)", r.mcm.iterations), bold)
.map_err(|e| e.to_string())?;
for (col, header) in ["指数", "平均值", "标准偏差", "P2.5", "P97.5", "标准值", "合格概率"]
.iter()
.enumerate()
{
sheet.write_with_format(mbase + 1, col as u16, *header, bold).map_err(|e| e.to_string())?;
}
let mcm = [("IRa", &r.mcm.ira), ("Ir", &r.mcm.ir)];
for (i, (name, stats)) in mcm.iter().enumerate() {
let row = mbase + 2 + i as u32;
sheet.write(row, 0, *name).map_err(|e| e.to_string())?;
sheet.write(row, 1, stats.mean).map_err(|e| e.to_string())?;
sheet.write(row, 2, stats.std_dev).map_err(|e| e.to_string())?;
sheet.write(row, 3, stats.p2_5).map_err(|e| e.to_string())?;
sheet.write(row, 4, stats.p97_5).map_err(|e| e.to_string())?;
sheet.write(row, 5, stats.standard_value).map_err(|e| e.to_string())?;
sheet.write(row, 6, stats.pass_probability).map_err(|e| e.to_string())?;
}
let frow = mbase + 4;
sheet.write_with_format(frow, 0, "综合不符合概率", bold).map_err(|e| e.to_string())?;
sheet.write(frow, 1, r.mcm.overall_fail_probability).map_err(|e| e.to_string())?;
sheet.set_column_width(0, 12).ok();
Ok(())
}
fn material_text(material: &MaterialType) -> &'static str {
match material {
MaterialType::BuildingMainBody => "建筑主体材料",
MaterialType::HollowBuildingMainBody => "空心率>25% 主体材料",
MaterialType::DecorativeMaterial => "装饰装修材料",
}
}
fn validity_text(validity: &Validity) -> &'static str {
match validity {
Validity::LowActivityExempt => "有效(低活度豁免 ≤37 Bq/kg",
Validity::UncertaintyAcceptable => "有效Ur(IRa) ≤ 20%",
Validity::Invalid => "无效Ur(IRa) > 20%",
}
}
fn verdict_text(verdict: &Verdict) -> String {
match verdict {
Verdict::Qualified => "合格".into(),
Verdict::Unqualified => "不合格".into(),
Verdict::NeedMoreMeasurements => "建议增加至 6 次测量".into(),
Verdict::InvalidResult => "结果无效".into(),
Verdict::DecorativeClass(class) => match class {
ceramic_radioactivity::DecorClass::A => "装饰装修 A 类".into(),
ceramic_radioactivity::DecorClass::B => "装饰装修 B 类".into(),
ceramic_radioactivity::DecorClass::C => "装饰装修 C 类".into(),
ceramic_radioactivity::DecorClass::Unqualified => "不合格(不可用于建材)".into(),
},
}
}

View File

@ -1,15 +1,102 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod db;
mod excel;
use std::sync::Mutex;
use ceramic_radioactivity::{calculate_sample, CalculationResult, SampleInput}; use ceramic_radioactivity::{calculate_sample, CalculationResult, SampleInput};
use db::{Db, RecordDetail, RecordFilter, RecordSummary};
use rusqlite::Connection;
use tauri::{Manager, State};
use tauri_plugin_dialog::DialogExt;
#[tauri::command] #[tauri::command]
fn calculate(input: SampleInput) -> Result<CalculationResult, String> { fn calculate(input: SampleInput) -> Result<CalculationResult, String> {
calculate_sample(input).map_err(|error| error.to_string()) calculate_sample(input).map_err(|error| error.to_string())
} }
#[derive(serde::Deserialize)]
struct SaveArgs {
input: SampleInput,
result: CalculationResult,
created_at: String,
}
#[tauri::command]
fn save_record(db: State<Db>, args: SaveArgs) -> Result<i64, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
db::save(&conn, &args.input, &args.result, &args.created_at)
}
#[tauri::command]
fn list_records(db: State<Db>, filter: RecordFilter) -> Result<Vec<RecordSummary>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
db::list(&conn, &filter)
}
#[tauri::command]
fn get_record(db: State<Db>, id: i64) -> Result<RecordDetail, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
db::get(&conn, id)
}
#[tauri::command]
fn delete_record(db: State<Db>, id: i64) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
db::delete(&conn, id)
}
/// 弹出「另存为」对话框,用户选定路径后写出 xlsx。取消则返回 `None`。
#[tauri::command]
fn export_excel(app: tauri::AppHandle, db: State<Db>, id: i64) -> Result<Option<String>, String> {
let detail = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
db::get(&conn, id)?
};
let start_dir = app
.path()
.download_dir()
.or_else(|_| app.path().document_dir())
.ok();
let mut builder = app
.dialog()
.file()
.set_title("导出 Excel")
.set_file_name(excel::default_file_name(&detail))
.add_filter("Excel 工作簿", &["xlsx"]);
if let Some(dir) = start_dir {
builder = builder.set_directory(dir);
}
let Some(file_path) = builder.blocking_save_file() else {
return Ok(None); // 用户取消
};
let path = file_path.into_path().map_err(|e| e.to_string())?;
excel::write_to_path(&path, &detail).map(Some)
}
fn main() { fn main() {
tauri::Builder::default() tauri::Builder::default()
.invoke_handler(tauri::generate_handler![calculate]) .plugin(tauri_plugin_dialog::init())
.setup(|app| {
let dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&dir)?;
let conn = Connection::open(dir.join("history.db"))?;
db::init(&conn).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
app.manage(Db(Mutex::new(conn)));
Ok(())
})
.invoke_handler(tauri::generate_handler![
calculate,
save_record,
list_records,
get_record,
delete_record,
export_excel
])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("failed to run Tauri application"); .expect("failed to run Tauri application");
} }

View File

@ -1,18 +1,23 @@
use crate::domain::{ use crate::domain::{
CalculationError, CalculationResult, Conclusion, IndexResult, NuclideMeasurements, AnalysisResult, CalculationError, CalculationResult, Conclusion, DecorClass, IndexResult,
NuclideResult, SampleInput, MaterialType, NuclideMeasurements, NuclideResult, SampleInput, Validity, Verdict,
}; };
use crate::mcm::run_monte_carlo; use crate::mcm::run_monte_carlo;
/// 相对(扩展)不确定度可接受上限,用于 3.1 有效性判定与 legacy conclusion。
const ACCEPTANCE_LIMIT_PERCENT: f64 = 20.0; const ACCEPTANCE_LIMIT_PERCENT: f64 = 20.0;
/// 3.1 低活度豁免阈值:总比活度 ≤ 37 Bq/kg 时结果直接有效。
const TOTAL_ACTIVITY_EXEMPT: f64 = 37.0;
/// 指数的包含因子 kGUM 2.2.4 / 2.2.6)。
const COVERAGE_FACTOR: f64 = 2.0;
pub fn calculate_sample(input: SampleInput) -> Result<CalculationResult, CalculationError> { pub fn calculate_sample(input: SampleInput) -> Result<CalculationResult, CalculationError> {
validate_input(&input)?; validate_input(&input)?;
let n = input.ra.measured_values.len(); let n = input.ra.measured_values.len();
let ra = calculate_nuclide("Ra", &input.ra)?; let ra = calculate_nuclide(&input.ra)?;
let th = calculate_nuclide("Th", &input.th)?; let th = calculate_nuclide(&input.th)?;
let k = calculate_nuclide("K", &input.k)?; let k = calculate_nuclide(&input.k)?;
let ira = calculate_ira(&ra); let ira = calculate_ira(&ra);
let ir = calculate_ir(&ra, &th, &k); let ir = calculate_ir(&ra, &th, &k);
@ -26,7 +31,8 @@ pub fn calculate_sample(input: SampleInput) -> Result<CalculationResult, Calcula
Conclusion::RecalibrateInstrument Conclusion::RecalibrateInstrument
}; };
let mcm = run_monte_carlo(&ra, &th, &k, &input.limits); let analysis = analyze(input.material_type, &ra, &th, &k, &ira, &ir);
let mcm = run_monte_carlo(&ra, &th, &k, &input.material_type.primary_limits());
Ok(CalculationResult { Ok(CalculationResult {
measurement_count: n, measurement_count: n,
@ -36,6 +42,7 @@ pub fn calculate_sample(input: SampleInput) -> Result<CalculationResult, Calcula
ira, ira,
ir, ir,
conclusion, conclusion,
analysis,
mcm, mcm,
}) })
} }
@ -48,7 +55,7 @@ fn validate_input(input: &SampleInput) -> Result<(), CalculationError> {
]; ];
for (nuclide, count) in counts { for (nuclide, count) in counts {
if count < 2 { if count < 1 {
return Err(CalculationError::TooFewMeasurements { nuclide, count }); return Err(CalculationError::TooFewMeasurements { nuclide, count });
} }
} }
@ -90,27 +97,21 @@ fn validate_nuclide(
Ok(()) Ok(())
} }
fn calculate_nuclide( fn calculate_nuclide(measurements: &NuclideMeasurements) -> Result<NuclideResult, CalculationError> {
nuclide: &'static str, let factor = measurements.calibration.factor;
measurements: &NuclideMeasurements,
) -> Result<NuclideResult, CalculationError> {
let n = measurements.measured_values.len();
let mean_measured = mean(&measurements.measured_values); let mean_measured = mean(&measurements.measured_values);
let mean_calibrated = mean_measured * measurements.calibration.factor; let mean_calibrated = mean_measured * factor;
let type_a_uncertainty = type_a_uncertainty(&measurements.measured_values)?; let type_a_uncertainty = type_a_uncertainty(&measurements.measured_values)?;
let type_b_relative = let type_b_relative = measurements.calibration.expanded_uncertainty_percent
measurements.calibration.expanded_uncertainty_percent / 100.0 / measurements.calibration.coverage_factor; / 100.0
let type_b_uncertainty = measurements.calibration.factor * type_b_relative; / measurements.calibration.coverage_factor;
let type_b_uncertainty = factor * type_b_relative;
let sensitivity_coefficient = mean_measured; let sensitivity_coefficient = mean_measured;
let combined_uncertainty = (type_a_uncertainty.powi(2) // 校准比活度 C = mean·a对测量值 A 的灵敏系数为 a故 A 类项为 a·uA。
let combined_uncertainty = ((factor * type_a_uncertainty).powi(2)
+ (sensitivity_coefficient * type_b_uncertainty).powi(2)) + (sensitivity_coefficient * type_b_uncertainty).powi(2))
.sqrt(); .sqrt();
if n < 6 && range_coefficient(n).is_none() {
return Err(CalculationError::UnsupportedRangeMethodCount { count: n });
}
let _ = nuclide;
Ok(NuclideResult { Ok(NuclideResult {
mean_measured, mean_measured,
mean_calibrated, mean_calibrated,
@ -125,11 +126,7 @@ fn calculate_nuclide(
fn calculate_ira(ra: &NuclideResult) -> IndexResult { fn calculate_ira(ra: &NuclideResult) -> IndexResult {
let value = ra.mean_calibrated / 200.0; let value = ra.mean_calibrated / 200.0;
let standard_uncertainty = ra.combined_uncertainty / 200.0; let standard_uncertainty = ra.combined_uncertainty / 200.0;
IndexResult { make_index(value, standard_uncertainty)
value,
standard_uncertainty,
relative_uncertainty_percent: relative_percent(standard_uncertainty, value),
}
} }
fn calculate_ir(ra: &NuclideResult, th: &NuclideResult, k: &NuclideResult) -> IndexResult { fn calculate_ir(ra: &NuclideResult, th: &NuclideResult, k: &NuclideResult) -> IndexResult {
@ -138,16 +135,134 @@ fn calculate_ir(ra: &NuclideResult, th: &NuclideResult, k: &NuclideResult) -> In
+ (th.combined_uncertainty / 260.0).powi(2) + (th.combined_uncertainty / 260.0).powi(2)
+ (k.combined_uncertainty / 4200.0).powi(2)) + (k.combined_uncertainty / 4200.0).powi(2))
.sqrt(); .sqrt();
make_index(value, standard_uncertainty)
}
/// 由指数值与标准不确定度构造完整的 `IndexResult`(含扩展不确定度与 GUM 真值区间)。
fn make_index(value: f64, standard_uncertainty: f64) -> IndexResult {
let expanded_uncertainty = standard_uncertainty * COVERAGE_FACTOR;
IndexResult { IndexResult {
value, value,
standard_uncertainty, standard_uncertainty,
expanded_uncertainty,
relative_uncertainty_percent: relative_percent(standard_uncertainty, value), relative_uncertainty_percent: relative_percent(standard_uncertainty, value),
relative_expanded_uncertainty_percent: relative_percent(expanded_uncertainty, value),
p2_5: value - expanded_uncertainty,
p97_5: value + expanded_uncertainty,
} }
} }
/// 3.1 有效性 + 3.2 临界值判定。
fn analyze(
material: MaterialType,
ra: &NuclideResult,
th: &NuclideResult,
k: &NuclideResult,
ira: &IndexResult,
ir: &IndexResult,
) -> AnalysisResult {
let total_calibrated_activity = ra.mean_calibrated + th.mean_calibrated + k.mean_calibrated;
// 3.1 有效性:低活度豁免,否则看 IRa 的相对扩展不确定度k=2
let validity = if total_calibrated_activity <= TOTAL_ACTIVITY_EXEMPT {
Validity::LowActivityExempt
} else if ira.relative_expanded_uncertainty_percent <= ACCEPTANCE_LIMIT_PERCENT {
Validity::UncertaintyAcceptable
} else {
Validity::Invalid
};
// 3.2 临界值判定。
let verdict = if validity == Validity::Invalid {
Verdict::InvalidResult
} else {
match material {
MaterialType::DecorativeMaterial => judge_decorative(ira, ir),
single_tier => judge_single_tier(single_tier, ira, ir),
}
};
AnalysisResult {
total_calibrated_activity,
validity,
verdict,
}
}
/// 单个指数真值区间相对极限值的三态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TierCheck {
/// 区间整体在限值之下:合格。
Pass,
/// 区间整体在限值之上:超标。
Fail,
/// 区间跨越限值:需增加测量次数。
Straddle,
}
/// 判断指数真值区间相对极限值的位置;`None` 表示该指数无约束。
fn check_index(index: &IndexResult, limit: Option<f64>) -> TierCheck {
match limit {
None => TierCheck::Pass,
Some(limit) => {
if index.p97_5 < limit {
TierCheck::Pass
} else if index.p2_5 > limit {
TierCheck::Fail
} else {
TierCheck::Straddle
}
}
}
}
/// 合并同一级别多个指数的判定:超标优先,其次跨越,全部合格才合格。
fn combine(checks: &[TierCheck]) -> TierCheck {
if checks.contains(&TierCheck::Fail) {
TierCheck::Fail
} else if checks.contains(&TierCheck::Straddle) {
TierCheck::Straddle
} else {
TierCheck::Pass
}
}
/// 主体/空心材料:单级判定。
fn judge_single_tier(material: MaterialType, ira: &IndexResult, ir: &IndexResult) -> Verdict {
let tier = &material.tiers()[0];
match combine(&[
check_index(ira, tier.ira_limit),
check_index(ir, tier.ir_limit),
]) {
TierCheck::Pass => Verdict::Qualified,
TierCheck::Straddle => Verdict::NeedMoreMeasurements,
TierCheck::Fail => Verdict::Unqualified,
}
}
/// 装饰装修材料A→B→C 级联分类。
fn judge_decorative(ira: &IndexResult, ir: &IndexResult) -> Verdict {
let tiers = MaterialType::DecorativeMaterial.tiers();
let classes = [DecorClass::A, DecorClass::B, DecorClass::C];
for (tier, class) in tiers.iter().zip(classes) {
match combine(&[
check_index(ira, tier.ira_limit),
check_index(ir, tier.ir_limit),
]) {
TierCheck::Pass => return Verdict::DecorativeClass(class),
TierCheck::Straddle => return Verdict::NeedMoreMeasurements,
TierCheck::Fail => continue,
}
}
Verdict::DecorativeClass(DecorClass::Unqualified)
}
fn type_a_uncertainty(values: &[f64]) -> Result<f64, CalculationError> { fn type_a_uncertainty(values: &[f64]) -> Result<f64, CalculationError> {
let n = values.len(); let n = values.len();
if n >= 6 { if n <= 1 {
// 单次测量A 类不确定度为 0PDF 2.2.1)。
Ok(0.0)
} else if n >= 6 {
Ok(sample_standard_deviation(values) / (n as f64).sqrt()) Ok(sample_standard_deviation(values) / (n as f64).sqrt())
} else { } else {
let coefficient = let coefficient =

View File

@ -8,12 +8,82 @@ pub struct SampleInput {
pub ra: NuclideMeasurements, pub ra: NuclideMeasurements,
pub th: NuclideMeasurements, pub th: NuclideMeasurements,
pub k: NuclideMeasurements, pub k: NuclideMeasurements,
/// 合格判定标准值(限值)。前端可省略,默认 IRa ≤ 1.0、Ir ≤ 1.0GB 6566 主体材料) /// 样品材料类型决定限值集GB 6566。默认建筑主体材料
#[serde(default)] #[serde(default)]
pub limits: AcceptanceLimits, pub material_type: MaterialType,
/// 样品编号输出元信息6.1)。
#[serde(default)]
pub sample_id: Option<String>,
/// 计算日期(前端传入的 ISO 字符串6.1)。
#[serde(default)]
pub calculation_date: Option<String>,
} }
/// 内照射指数 IRa 与外照射指数 Ir 的合格判定标准值(限值)。 /// 样品材料类型。每种类型对应一组(或多级)限值。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum MaterialType {
/// 建筑主体材料IRa ≤ 1.0、Ir ≤ 1.0。
#[default]
BuildingMainBody,
/// 空心率大于 25% 的建筑主体材料IRa ≤ 1.0、Ir ≤ 1.3。
HollowBuildingMainBody,
/// 装饰装修材料:按 A / B / C 三级分类。
DecorativeMaterial,
}
/// 一级限值(装饰材料的 C 类无 IRa 约束,故用 `Option`)。
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct LimitTier {
pub label: &'static str,
pub ira_limit: Option<f64>,
pub ir_limit: Option<f64>,
}
impl MaterialType {
/// 从严到宽的限值阶梯。主体/空心材料各 1 级;装饰材料 A/B/C 三级。
pub fn tiers(&self) -> &'static [LimitTier] {
match self {
MaterialType::BuildingMainBody => &[LimitTier {
label: "合格",
ira_limit: Some(1.0),
ir_limit: Some(1.0),
}],
MaterialType::HollowBuildingMainBody => &[LimitTier {
label: "合格",
ira_limit: Some(1.0),
ir_limit: Some(1.3),
}],
MaterialType::DecorativeMaterial => &[
LimitTier {
label: "A",
ira_limit: Some(1.0),
ir_limit: Some(1.3),
},
LimitTier {
label: "B",
ira_limit: Some(1.3),
ir_limit: Some(1.9),
},
LimitTier {
label: "C",
ira_limit: None,
ir_limit: Some(2.8),
},
],
}
}
/// MCM 比较所用的主限值(取最严一级,无约束项以 +∞ 表示)。
pub fn primary_limits(&self) -> AcceptanceLimits {
let tier = &self.tiers()[0];
AcceptanceLimits {
ira_limit: tier.ira_limit.unwrap_or(f64::INFINITY),
ir_limit: tier.ir_limit.unwrap_or(f64::INFINITY),
}
}
}
/// 内照射指数 IRa 与外照射指数 Ir 的合格判定标准值限值。MCM 仿真比较使用。
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct AcceptanceLimits { pub struct AcceptanceLimits {
pub ira_limit: f64, pub ira_limit: f64,
@ -51,10 +121,59 @@ pub struct CalculationResult {
pub ira: IndexResult, pub ira: IndexResult,
pub ir: IndexResult, pub ir: IndexResult,
pub conclusion: Conclusion, pub conclusion: Conclusion,
/// 分析判定结果(有效性 + 合格/不合格/分级)。
pub analysis: AnalysisResult,
/// 蒙特卡洛法MCM仿真结果。 /// 蒙特卡洛法MCM仿真结果。
pub mcm: McmResult, pub mcm: McmResult,
} }
/// 分析判定结果PDF 第 3 节)。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnalysisResult {
/// 总比活度 A1·a + A2·b + A3·cBq/kg用于 3.1 有效性判定。
pub total_calibrated_activity: f64,
/// 有效性判定结果。
pub validity: Validity,
/// 最终判定结论。
pub verdict: Verdict,
}
/// 3.1 有效性判定结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Validity {
/// 总比活度 ≤ 37 Bq/kg直接有效。
LowActivityExempt,
/// 总比活度 > 37 Bq/kg 且 Ur(IRa) ≤ 20%k=2有效。
UncertaintyAcceptable,
/// 总比活度 > 37 Bq/kg 且 Ur(IRa) > 20%,无效。
Invalid,
}
/// 3.2 临界值判定的最终结论。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Verdict {
/// 主体/空心材料:合格。
Qualified,
/// 不合格。
Unqualified,
/// 装饰装修材料A/B/C 分级结果。
DecorativeClass(DecorClass),
/// 真值区间跨越极限值,建议增加测量次数至 6 次。
NeedMoreMeasurements,
/// 有效性不成立,结果无效。
InvalidResult,
}
/// 装饰装修材料分级。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DecorClass {
A,
B,
C,
/// 不满足 C 类,不可用于建筑材料。
Unqualified,
}
/// 蒙特卡洛法MCM整体仿真结果。 /// 蒙特卡洛法MCM整体仿真结果。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct McmResult { pub struct McmResult {
@ -103,8 +222,18 @@ pub struct NuclideResult {
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct IndexResult { pub struct IndexResult {
pub value: f64, pub value: f64,
/// 标准(合成)不确定度 u。
pub standard_uncertainty: f64, pub standard_uncertainty: f64,
/// 扩展不确定度 U = u·kk=2
pub expanded_uncertainty: f64,
/// 相对标准不确定度 u/valuek=1百分数。
pub relative_uncertainty_percent: f64, pub relative_uncertainty_percent: f64,
/// 相对扩展不确定度 U/valuek=2百分数。
pub relative_expanded_uncertainty_percent: f64,
/// 95% 真值区间下限 = value UGUM 解析法)。
pub p2_5: f64,
/// 95% 真值区间上限 = value + U。
pub p97_5: f64,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]

View File

@ -4,6 +4,7 @@ mod mcm;
pub use calculator::calculate_sample; pub use calculator::calculate_sample;
pub use domain::{ pub use domain::{
AcceptanceLimits, CalculationError, CalculationResult, CalibrationParams, Conclusion, AcceptanceLimits, AnalysisResult, CalculationError, CalculationResult, CalibrationParams,
IndexResult, McmIndexStats, McmResult, NuclideMeasurements, NuclideResult, SampleInput, Conclusion, DecorClass, IndexResult, LimitTier, MaterialType, McmIndexStats, McmResult,
NuclideMeasurements, NuclideResult, SampleInput, Validity, Verdict,
}; };

View File

@ -1,41 +1,45 @@
use ceramic_radioactivity::{ use ceramic_radioactivity::{
calculate_sample, AcceptanceLimits, CalibrationParams, Conclusion, NuclideMeasurements, calculate_sample, CalibrationParams, Conclusion, DecorClass, MaterialType, NuclideMeasurements,
SampleInput, SampleInput, Validity, Verdict,
}; };
fn calibration(factor: f64, expanded_uncertainty_percent: f64) -> CalibrationParams {
CalibrationParams {
factor,
expanded_uncertainty_percent,
coverage_factor: 2.0,
}
}
fn default_input() -> SampleInput { fn default_input() -> SampleInput {
SampleInput { SampleInput {
ra: NuclideMeasurements { ra: NuclideMeasurements {
measured_values: vec![100.0, 102.0, 98.0, 101.0, 99.0, 100.0], measured_values: vec![100.0, 102.0, 98.0, 101.0, 99.0, 100.0],
calibration: CalibrationParams { calibration: calibration(0.916, 6.3),
factor: 0.916,
expanded_uncertainty_percent: 6.3,
coverage_factor: 2.0,
},
}, },
th: NuclideMeasurements { th: NuclideMeasurements {
measured_values: vec![110.0, 111.0, 109.0, 110.0, 112.0, 108.0], measured_values: vec![110.0, 111.0, 109.0, 110.0, 112.0, 108.0],
calibration: CalibrationParams { calibration: calibration(0.884, 6.9),
factor: 0.884,
expanded_uncertainty_percent: 6.9,
coverage_factor: 2.0,
},
}, },
k: NuclideMeasurements { k: NuclideMeasurements {
measured_values: vec![560.0, 565.0, 555.0, 562.0, 558.0, 561.0], measured_values: vec![560.0, 565.0, 555.0, 562.0, 558.0, 561.0],
calibration: CalibrationParams { calibration: calibration(0.961, 6.7),
factor: 0.961,
expanded_uncertainty_percent: 6.7,
coverage_factor: 2.0,
},
},
limits: AcceptanceLimits {
ira_limit: 1.0,
ir_limit: 1.0,
}, },
material_type: MaterialType::BuildingMainBody,
sample_id: None,
calculation_date: None,
} }
} }
/// 由目标校准比活度构造 n=6 的等值输入A 类不确定度为 0仅保留 B 类)。
fn from_calibrated(ra_cal: f64, th_cal: f64, k_cal: f64) -> SampleInput {
let mut input = default_input();
input.ra.measured_values = vec![ra_cal / 0.916; 6];
input.th.measured_values = vec![th_cal / 0.884; 6];
input.k.measured_values = vec![k_cal / 0.961; 6];
input
}
#[test] #[test]
fn calculates_indices_and_ok_conclusion_for_six_measurements() { fn calculates_indices_and_ok_conclusion_for_six_measurements() {
let result = calculate_sample(default_input()).expect("valid sample should calculate"); let result = calculate_sample(default_input()).expect("valid sample should calculate");
@ -47,6 +51,146 @@ fn calculates_indices_and_ok_conclusion_for_six_measurements() {
assert_close(result.ira.value, 0.458, 1e-9); assert_close(result.ira.value, 0.458, 1e-9);
assert_close(result.ir.value, 0.749_739_035_821_535_9, 1e-9); assert_close(result.ir.value, 0.749_739_035_821_535_9, 1e-9);
assert_eq!(result.conclusion, Conclusion::Ok); assert_eq!(result.conclusion, Conclusion::Ok);
assert_eq!(result.analysis.verdict, Verdict::Qualified);
}
/// 对齐 PDF 单次测量算例A1=83.439, A2=116.995, A3=554.268)。
#[test]
fn single_measurement_matches_pdf_example() {
let input = SampleInput {
ra: NuclideMeasurements {
measured_values: vec![83.439],
calibration: calibration(0.916, 6.3),
},
th: NuclideMeasurements {
measured_values: vec![116.995],
calibration: calibration(0.884, 6.9),
},
k: NuclideMeasurements {
measured_values: vec![554.268],
calibration: calibration(0.961, 6.7),
},
material_type: MaterialType::BuildingMainBody,
sample_id: Some("PDF-EX".to_string()),
calculation_date: Some("2026-06-11".to_string()),
};
let result = calculate_sample(input).expect("single measurement should calculate");
assert_eq!(result.measurement_count, 1);
// A 类不确定度为 0。
assert_close(result.ra.type_a_uncertainty, 0.0, 1e-12);
// 2.1 检测结果。
assert_close(result.ira.value, 0.38, 5e-3);
assert_close(result.ir.value, 0.73, 5e-3);
// 2.2.3 标准不确定度、2.2.4 扩展不确定度、2.2.6 真值区间。
assert_close(result.ira.standard_uncertainty, 0.012, 5e-4);
assert_close(result.ira.expanded_uncertainty, 0.024, 1e-3);
// PDF 区间用已四舍五入的 0.38±0.024 得 0.36/0.40;此处用未舍入值,放宽容差。
assert_close(result.ira.p2_5, 0.36, 1e-2);
assert_close(result.ira.p97_5, 0.40, 1e-2);
assert_close(result.ir.standard_uncertainty, 0.016, 5e-4);
assert_close(result.ir.expanded_uncertainty, 0.032, 1e-3);
// 2.2.5 相对扩展不确定度 k=2。
assert_close(result.ira.relative_expanded_uncertainty_percent, 6.3, 0.2);
assert_close(result.ir.relative_expanded_uncertainty_percent, 4.4, 0.2);
// 3.1 有效性 + 3.2 判定。
assert_close(result.analysis.total_calibrated_activity, 712.5, 1.0);
assert_eq!(result.analysis.validity, Validity::UncertaintyAcceptable);
assert_eq!(result.analysis.verdict, Verdict::Qualified);
}
#[test]
fn low_activity_sample_is_exempt_and_valid() {
let input = SampleInput {
ra: NuclideMeasurements {
measured_values: vec![2.0],
calibration: calibration(0.916, 6.3),
},
th: NuclideMeasurements {
measured_values: vec![2.0],
calibration: calibration(0.884, 6.9),
},
k: NuclideMeasurements {
measured_values: vec![2.0],
calibration: calibration(0.961, 6.7),
},
material_type: MaterialType::BuildingMainBody,
sample_id: None,
calculation_date: None,
};
let result = calculate_sample(input).expect("low activity sample should calculate");
assert!(result.analysis.total_calibrated_activity <= 37.0);
assert_eq!(result.analysis.validity, Validity::LowActivityExempt);
assert_eq!(result.analysis.verdict, Verdict::Qualified);
}
#[test]
fn high_uncertainty_above_37_is_invalid() {
let mut input = default_input();
input.ra.measured_values = vec![10.0, 200.0, 400.0];
input.th.measured_values = vec![10.0, 200.0, 400.0];
input.k.measured_values = vec![10.0, 200.0, 400.0];
let result = calculate_sample(input).expect("valid sample should calculate");
assert!(result.analysis.total_calibrated_activity > 37.0);
assert_eq!(result.analysis.validity, Validity::Invalid);
assert_eq!(result.analysis.verdict, Verdict::InvalidResult);
}
#[test]
fn main_body_unqualified_when_interval_above_limit() {
// IRa ≈ 1.5,区间整体高于 1.0。
let result =
calculate_sample(from_calibrated(300.0, 50.0, 50.0)).expect("valid sample should calculate");
assert!(result.ira.p2_5 > 1.0);
assert_eq!(result.analysis.verdict, Verdict::Unqualified);
}
#[test]
fn main_body_needs_more_measurements_when_interval_straddles_limit() {
// IRa = 1.0,区间跨越 1.0。
let result =
calculate_sample(from_calibrated(200.0, 50.0, 50.0)).expect("valid sample should calculate");
assert!(result.ira.p2_5 < 1.0 && result.ira.p97_5 > 1.0);
assert_eq!(result.analysis.verdict, Verdict::NeedMoreMeasurements);
}
#[test]
fn decorative_material_classifies_into_tiers() {
// A 类IRa、Ir 均低。
let a = calculate_sample(decorative(100.0, 100.0, 100.0)).expect("valid");
assert_eq!(a.analysis.verdict, Verdict::DecorativeClass(DecorClass::A));
// B 类Ir 超 A 限(1.3) 但在 B 限(1.9) 内IRa 低。
let b = calculate_sample(decorative(100.0, 317.0, 42.0)).expect("valid");
assert_eq!(b.analysis.verdict, Verdict::DecorativeClass(DecorClass::B));
// C 类Ir 超 B 限(1.9) 但在 C 限(2.8) 内。
let c = calculate_sample(decorative(100.0, 520.0, 100.0)).expect("valid");
assert_eq!(c.analysis.verdict, Verdict::DecorativeClass(DecorClass::C));
// 不合格Ir 超 C 限(2.8)。
let fail = calculate_sample(decorative(100.0, 900.0, 100.0)).expect("valid");
assert_eq!(
fail.analysis.verdict,
Verdict::DecorativeClass(DecorClass::Unqualified)
);
}
fn decorative(ra_cal: f64, th_cal: f64, k_cal: f64) -> SampleInput {
let mut input = from_calibrated(ra_cal, th_cal, k_cal);
input.material_type = MaterialType::DecorativeMaterial;
input
} }
#[test] #[test]
@ -125,14 +269,12 @@ fn monte_carlo_is_deterministic_for_same_input() {
} }
#[test] #[test]
fn monte_carlo_gives_about_half_pass_probability_when_limit_equals_mean() { fn monte_carlo_gives_about_half_pass_probability_when_index_equals_limit() {
let mut input = default_input(); // 主体材料 IRa 标准值为 1.0;构造 IRa=1.0 的样本,合格概率应接近 0.5。
let analytical = calculate_sample(input.clone()).expect("valid sample should calculate"); let result =
calculate_sample(from_calibrated(200.0, 50.0, 50.0)).expect("valid sample should calculate");
// 将 IRa 标准值设为其均值,合格概率应接近 0.5。
input.limits.ira_limit = analytical.ira.value;
let result = calculate_sample(input).expect("valid sample should calculate");
assert_close(result.ira.value, 1.0, 1e-9);
assert_close(result.mcm.ira.pass_probability, 0.5, 0.03); assert_close(result.mcm.ira.pass_probability, 0.5, 0.03);
assert_close( assert_close(
result.mcm.ira.fail_probability, result.mcm.ira.fail_probability,

BIN
ui/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -1,399 +1,78 @@
import { useMemo, useRef, useState } from "react"; import { useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { App as AntApp, ConfigProvider, Tabs } from "antd";
import { Alert, Button, Card, ConfigProvider, InputNumber, Table, Tag } from "antd";
import zhCN from "antd/locale/zh_CN"; import zhCN from "antd/locale/zh_CN";
import { CalculatorPanel } from "./CalculatorPanel";
import { HistoryTab } from "./HistoryTab";
import type { SampleInput } from "./types";
type Conclusion = "Ok" | "IncreaseMeasurementsToSix" | "RecalibrateInstrument"; function Brand() {
type CalibrationParams = {
factor: number;
expanded_uncertainty_percent: number;
coverage_factor: number;
};
type NuclideMeasurements = {
measured_values: number[];
calibration: CalibrationParams;
};
type AcceptanceLimits = {
ira_limit: number;
ir_limit: number;
};
type SampleInput = {
ra: NuclideMeasurements;
th: NuclideMeasurements;
k: NuclideMeasurements;
limits: AcceptanceLimits;
};
type McmIndexStats = {
mean: number;
std_dev: number;
p2_5: number;
p97_5: number;
standard_value: number;
pass_probability: number;
fail_probability: number;
};
type McmResult = {
iterations: number;
ira: McmIndexStats;
ir: McmIndexStats;
overall_pass_probability: number;
overall_fail_probability: number;
};
type NuclideResult = {
mean_measured: number;
mean_calibrated: number;
type_a_uncertainty: number;
type_b_relative: number;
type_b_uncertainty: number;
sensitivity_coefficient: number;
combined_uncertainty: number;
};
type IndexResult = {
value: number;
standard_uncertainty: number;
relative_uncertainty_percent: number;
};
type CalculationResult = {
measurement_count: number;
ra: NuclideResult;
th: NuclideResult;
k: NuclideResult;
ira: IndexResult;
ir: IndexResult;
conclusion: Conclusion;
mcm: McmResult;
};
type MeasurementRow = {
key: number;
ra: number | null;
th: number | null;
k: number | null;
};
type ResultRow = { name: string } & NuclideResult;
type McmRow = { name: string } & McmIndexStats;
type CalibrationRow = { key: string; name: string } & CalibrationParams;
type FocusableInput = {
focus: () => void;
};
const defaultCalibration = {
ra: { factor: 0.916, expanded_uncertainty_percent: 6.3, coverage_factor: 2 },
th: { factor: 0.884, expanded_uncertainty_percent: 6.9, coverage_factor: 2 },
k: { factor: 0.961, expanded_uncertainty_percent: 6.7, coverage_factor: 2 }
};
const defaultLimits: AcceptanceLimits = { ira_limit: 1.0, ir_limit: 1.0 };
const initialRows: MeasurementRow[] = [
{ key: 1, ra: 100, th: 110, k: 560 },
{ key: 2, ra: 102, th: 111, k: 565 },
{ key: 3, ra: 98, th: 109, k: 555 },
{ key: 4, ra: 101, th: 110, k: 562 },
{ key: 5, ra: 99, th: 112, k: 558 },
{ key: 6, ra: 100, th: 108, k: 561 }
];
const conclusionText: Record<Conclusion, string> = {
Ok: "OK",
IncreaseMeasurementsToSix: "请增加试验次数至 6 次",
RecalibrateInstrument: "校准仪器后重新测量"
};
function formatNumber(value: number, digits = 4) {
if (!Number.isFinite(value)) return "-";
return value.toFixed(digits);
}
function formatPercent(value: number, digits = 2) {
if (!Number.isFinite(value)) return "-";
return (value * 100).toFixed(digits) + "%";
}
function App() {
const [rows, setRows] = useState<MeasurementRow[]>(initialRows);
const [limits, setLimits] = useState<AcceptanceLimits>(defaultLimits);
const [result, setResult] = useState<CalculationResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = 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 },
limits
};
};
const calculate = async () => {
setLoading(true);
setError(null);
try {
const response = await invoke<CalculationResult>("calculate", { input: buildInput() });
setResult(response);
} catch (err) {
setResult(null);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
return ( return (
<ConfigProvider locale={zhCN}> <div className="brand">
<main className="app-shell"> <img className="brand-logo" src="/logo.png" alt="" />
<section className="workspace"> <span className="brand-name"></span>
<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 <= 2} 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={calculate}>
</Button>
</div>
</Card>
<Card title="校准参数 / 判定标准值" className="panel compact-panel">
<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" }
]}
/>
<div className="limits-form">
<div className="limit-field">
<span className="limit-label">IRa </span>
<InputNumber
value={limits.ira_limit}
min={0}
step={0.1}
onChange={(value) => setLimits((current) => ({ ...current, ira_limit: value ?? 0 }))}
/>
</div>
<div className="limit-field">
<span className="limit-label">Ir </span>
<InputNumber
value={limits.ir_limit}
min={0}
step={0.1}
onChange={(value) => setLimits((current) => ({ ...current, ir_limit: value ?? 0 }))}
/>
</div>
</div>
</Card>
</div>
{error ? <Alert type="error" message={error} showIcon /> : null}
<div className="results-area">
{result ? (
<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<ResultRow>
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></small>
</div>
<div className="result-tile conclusion-tile">
<span>仿</span>
<Tag color={result.mcm.overall_pass_probability >= 0.95 ? "success" : "warning"}>
{result.mcm.overall_pass_probability >= 0.95 ? "合格" : "不合格风险"}
</Tag>
</div>
</div>
<Table<McmRow>
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>
</section>
</main>
</ConfigProvider>
);
}
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> </div>
); );
} }
function App() {
const [activeTab, setActiveTab] = useState("calc");
const [reloadSignal, setReloadSignal] = useState(0);
// 复算:带入历史 input 并强制重挂 CalculatorPanelkey 递增)。
const [loadedInput, setLoadedInput] = useState<SampleInput | undefined>(undefined);
const [calcKey, setCalcKey] = useState(0);
const recompute = (input: SampleInput) => {
setLoadedInput(input);
setCalcKey((k) => k + 1);
setActiveTab("calc");
};
return (
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: "#243150",
colorInfo: "#243150",
colorLink: "#243150",
colorLinkHover: "#3a4d78",
borderRadius: 8,
fontSize: 14
}
}}
>
<AntApp>
<main className="app-shell">
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
className="main-tabs"
tabBarExtraContent={{ left: <Brand /> }}
items={[
{
key: "calc",
label: "计算",
children: (
<CalculatorPanel
key={calcKey}
initialInput={loadedInput}
onSaved={() => setReloadSignal((s) => s + 1)}
/>
)
},
{
key: "history",
label: "历史记录",
children: (
<HistoryTab active={activeTab === "history"} reloadSignal={reloadSignal} onRecompute={recompute} />
)
}
]}
/>
</main>
</AntApp>
</ConfigProvider>
);
}
export { App }; export { App };

416
ui/src/CalculatorPanel.tsx Normal file
View File

@ -0,0 +1,416 @@
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 };

202
ui/src/HistoryTab.tsx Normal file
View File

@ -0,0 +1,202 @@
import { useCallback, useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { App, Button, DatePicker, Input, Popconfirm, Select, Space, Table, Tag } from "antd";
import dayjs, { type Dayjs } from "dayjs";
import { ReportModal } from "./ReportView";
import {
formatNumber,
materialOptions,
materialText,
verdictDisplay,
verdictKindOptions,
type MaterialType,
type RecordDetail,
type RecordFilter,
type RecordSummary,
type SampleInput
} from "./types";
const { RangePicker } = DatePicker;
type Props = {
active: boolean;
reloadSignal: number;
onRecompute: (input: SampleInput) => void;
};
function HistoryTab({ active, reloadSignal, onRecompute }: Props) {
const { message } = App.useApp();
const [rows, setRows] = useState<RecordSummary[]>([]);
const [loading, setLoading] = useState(false);
const [sampleId, setSampleId] = useState("");
const [materialType, setMaterialType] = useState<MaterialType | null>(null);
const [verdictKind, setVerdictKind] = useState<string | null>(null);
const [range, setRange] = useState<[Dayjs | null, Dayjs | null] | null>(null);
const [report, setReport] = useState<RecordDetail | null>(null);
const [reportOpen, setReportOpen] = useState(false);
const fetchList = useCallback(async () => {
setLoading(true);
try {
const filter: RecordFilter = {
sample_id: sampleId.trim() || null,
material_type: materialType,
date_from: range?.[0] ? range[0]!.format("YYYY-MM-DD") : null,
date_to: range?.[1] ? range[1]!.format("YYYY-MM-DD") : null,
verdict_kind: verdictKind
};
const result = await invoke<RecordSummary[]>("list_records", { filter });
setRows(result);
} catch (err) {
message.error(`加载历史失败:${String(err)}`);
} finally {
setLoading(false);
}
}, [sampleId, materialType, verdictKind, range]);
useEffect(() => {
if (active) void fetchList();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, reloadSignal]);
const openReport = async (id: number) => {
try {
const detail = await invoke<RecordDetail>("get_record", { id });
setReport(detail);
setReportOpen(true);
} catch (err) {
message.error(`读取记录失败:${String(err)}`);
}
};
const recompute = async (id: number) => {
try {
const detail = await invoke<RecordDetail>("get_record", { id });
onRecompute(detail.input);
} catch (err) {
message.error(`复算失败:${String(err)}`);
}
};
const exportExcel = async (id: number) => {
try {
const path = await invoke<string | null>("export_excel", { id });
if (path) message.success(`已导出 Excel${path}`);
else message.info("已取消导出");
} catch (err) {
message.error(`导出失败:${String(err)}`);
}
};
const remove = async (id: number) => {
try {
await invoke("delete_record", { id });
message.success("已删除");
void fetchList();
} catch (err) {
message.error(`删除失败:${String(err)}`);
}
};
return (
<div className="history-tab">
<Space wrap className="history-filters">
<Input
allowClear
placeholder="样品编号"
style={{ width: 160 }}
value={sampleId}
onChange={(e) => setSampleId(e.target.value)}
/>
<Select<MaterialType>
allowClear
placeholder="材料类型"
style={{ width: 180 }}
options={materialOptions}
value={materialType ?? undefined}
onChange={(v) => setMaterialType(v ?? null)}
/>
<Select
allowClear
placeholder="判定"
style={{ width: 160 }}
options={verdictKindOptions}
value={verdictKind ?? undefined}
onChange={(v) => setVerdictKind(v ?? null)}
/>
<RangePicker
value={range as never}
onChange={(v) => setRange(v as [Dayjs | null, Dayjs | null] | null)}
/>
<Button type="primary" onClick={() => void fetchList()}>
</Button>
</Space>
<Table<RecordSummary>
className="history-table"
rowKey="id"
size="small"
loading={loading}
dataSource={rows}
pagination={{ pageSize: 12, hideOnSinglePage: true }}
columns={[
{
title: "入库时间",
dataIndex: "created_at",
width: 150,
render: (v: string) => (dayjs(v).isValid() ? dayjs(v).format("YYYY-MM-DD HH:mm") : v)
},
{
title: "样品编号",
dataIndex: "sample_id",
render: (v: string | null) => v || "未编号"
},
{
title: "材料",
dataIndex: "material_type",
render: (v: MaterialType) => materialText(v)
},
{ title: "IRa", dataIndex: "ira_value", width: 76, render: (v: number) => formatNumber(v, 3) },
{ title: "Ir", dataIndex: "ir_value", width: 76, render: (v: number) => formatNumber(v, 3) },
{
title: "判定",
dataIndex: "verdict",
width: 140,
render: (_, row) => {
const d = verdictDisplay(row.verdict);
return <Tag color={d.color}>{d.text}</Tag>;
}
},
{
title: "操作",
key: "action",
width: 250,
render: (_, row) => (
<Space size={4} wrap>
<Button size="small" type="link" onClick={() => void openReport(row.id)}>
</Button>
<Button size="small" type="link" onClick={() => void recompute(row.id)}>
</Button>
<Button size="small" type="link" onClick={() => void exportExcel(row.id)}>
Excel
</Button>
<Popconfirm title="确认删除该记录?" onConfirm={() => void remove(row.id)}>
<Button size="small" type="link" danger>
</Button>
</Popconfirm>
</Space>
)
}
]}
/>
<ReportModal open={reportOpen} onClose={() => setReportOpen(false)} detail={report} />
</div>
);
}
export { HistoryTab };

189
ui/src/ReportView.tsx Normal file
View File

@ -0,0 +1,189 @@
import { Button, Modal } from "antd";
import {
formatNumber,
formatPercent,
materialText,
validityText,
verdictDisplay,
type CalculationResult,
type SampleInput
} from "./types";
type Props = {
input: SampleInput;
result: CalculationResult;
};
function Row(props: { label: string; value: React.ReactNode }) {
return (
<tr>
<th>{props.label}</th>
<td>{props.value}</td>
</tr>
);
}
/** A4 检测报告视图。屏幕预览与打印(导出 PDF共用同一组件。 */
function ReportView({ input, result }: Props) {
const n = input.ra.measured_values.length;
const validity = validityText[result.analysis.validity];
const verdict = verdictDisplay(result.analysis.verdict);
return (
<div className="report-root">
<h1 className="report-title"></h1>
<table className="report-meta">
<tbody>
<Row label="样品编号" value={input.sample_id?.trim() || "未编号"} />
<Row label="计算日期" value={input.calculation_date || "—"} />
<Row label="材料类型" value={materialText(input.material_type)} />
<Row label="测量次数" value={`${n}`} />
</tbody>
</table>
<h2 className="report-h2"></h2>
<table className="report-table">
<thead>
<tr>
<th></th>
<th></th>
<th> U (k=2)</th>
<th> (k=2)</th>
<th>95% </th>
</tr>
</thead>
<tbody>
{[
{ name: "内照射指数 IRa", idx: result.ira },
{ name: "外照射指数 Ir", idx: result.ir }
].map(({ name, idx }) => (
<tr key={name}>
<td>{name}</td>
<td>{formatNumber(idx.value)}</td>
<td>{formatNumber(idx.expanded_uncertainty)}</td>
<td>{formatNumber(idx.relative_expanded_uncertainty_percent, 2)}%</td>
<td>
[{formatNumber(idx.p2_5)}, {formatNumber(idx.p97_5)}]
</td>
</tr>
))}
</tbody>
</table>
<h2 className="report-h2"></h2>
<table className="report-table">
<thead>
<tr>
<th></th>
<th> (Bq/kg)</th>
<th></th>
<th>A </th>
<th>B </th>
<th></th>
</tr>
</thead>
<tbody>
{[
{ name: "Ra-226", nr: result.ra },
{ name: "Th-232", nr: result.th },
{ name: "K-40", nr: result.k }
].map(({ name, nr }) => (
<tr key={name}>
<td>{name}</td>
<td>{formatNumber(nr.mean_measured)}</td>
<td>{formatNumber(nr.mean_calibrated)}</td>
<td>{formatNumber(nr.type_a_uncertainty)}</td>
<td>{formatNumber(nr.type_b_relative * 100, 3)}%</td>
<td>{formatNumber(nr.combined_uncertainty)}</td>
</tr>
))}
</tbody>
</table>
<h2 className="report-h2">仿{result.mcm.iterations} </h2>
<table className="report-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>95% </th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{[
{ name: "IRa", s: result.mcm.ira },
{ name: "Ir", s: result.mcm.ir }
].map(({ name, s }) => (
<tr key={name}>
<td>{name}</td>
<td>{formatNumber(s.mean)}</td>
<td>{formatNumber(s.std_dev)}</td>
<td>
[{formatNumber(s.p2_5)}, {formatNumber(s.p97_5)}]
</td>
<td>{formatNumber(s.standard_value, 2)}</td>
<td>{formatPercent(s.pass_probability)}</td>
</tr>
))}
</tbody>
</table>
<p className="report-note">
95% <strong>{formatPercent(result.mcm.overall_fail_probability)}</strong>
</p>
<h2 className="report-h2"></h2>
<table className="report-meta">
<tbody>
<Row
label="有效性"
value={`${validity.text}(总比活度 ${formatNumber(
result.analysis.total_calibrated_activity,
1
)} Bq/kg 37`}
/>
<Row label="最终判定" value={<strong>{verdict.text}</strong>} />
</tbody>
</table>
<div className="report-sign">
<span>____________</span>
<span>____________</span>
<span>____________</span>
</div>
</div>
);
}
type ModalProps = {
open: boolean;
onClose: () => void;
detail: { input: SampleInput; result: CalculationResult } | null;
};
/** 报告预览弹窗,内含「打印 / 导出 PDF」按钮走 window.print由打印对话框另存为 PDF。 */
function ReportModal({ open, onClose, detail }: ModalProps) {
return (
<Modal
className="report-modal"
open={open}
onCancel={onClose}
width={920}
footer={[
<Button key="close" onClick={onClose}>
</Button>,
<Button key="print" type="primary" onClick={() => window.print()}>
/ PDF
</Button>
]}
>
{detail ? <ReportView input={detail.input} result={detail.result} /> : null}
</Modal>
);
}
export { ReportView, ReportModal };

View File

@ -4,49 +4,115 @@
body { body {
margin: 0; margin: 0;
color: #202124; color: #1f2933;
background: #f4f6f8; background: linear-gradient(180deg, #eef2f6 0%, #f4f6f8 240px);
font-family: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; font-family: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
} }
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
height: 100vh; padding: 8px clamp(12px, 1.4vw, 24px) clamp(12px, 1.4vw, 22px);
padding: clamp(12px, 1.4vw, 28px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.main-tabs {
width: 100%;
max-width: 2600px;
margin: 0 auto;
}
/* 深藏蓝顶栏:呼应应用图标(深底 + 金色 logo + 浅字) */
.main-tabs > .ant-tabs-nav {
margin: 0 0 14px;
padding: 7px 18px;
background: linear-gradient(135deg, #1b2436 0%, #2a3958 100%);
border-radius: 12px;
box-shadow: 0 3px 12px rgba(27, 36, 54, 0.22);
}
.main-tabs > .ant-tabs-nav::before {
border-bottom: none !important;
}
.main-tabs .ant-tabs-tab {
font-size: 15px;
padding: 8px 4px;
}
.main-tabs .ant-tabs-tab .ant-tabs-tab-btn {
color: #aeb8cb;
}
.main-tabs .ant-tabs-tab:hover .ant-tabs-tab-btn {
color: #ffffff;
}
.main-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: #ffffff !important;
font-weight: 600;
}
/* 选中下划线用 logo 的金色 */
.main-tabs .ant-tabs-ink-bar {
background: #f2b50c;
height: 3px !important;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding-right: 18px;
margin-right: 10px;
border-right: 1px solid rgba(255, 255, 255, 0.16);
}
.brand-logo {
width: 30px;
height: 30px;
border-radius: 7px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.brand-name {
font-size: 17px;
font-weight: 700;
letter-spacing: 0.5px;
color: #ffffff;
white-space: nowrap;
}
.results-toolbar {
display: flex;
gap: 8px;
}
.history-tab {
display: flex;
flex-direction: column;
gap: 12px;
}
.workspace { .workspace {
width: 100%; width: 100%;
max-width: 2200px; margin: 0;
margin: 0 auto;
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; gap: clamp(8px, 0.7vw, 14px);
} }
.content-grid { .content-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) clamp(240px, 18vw, 360px); grid-template-columns: minmax(0, 1fr) clamp(330px, 26vw, 480px);
grid-auto-rows: clamp(380px, 42vh, 620px);
align-items: stretch; align-items: stretch;
gap: clamp(10px, 0.8vw, 18px); gap: clamp(8px, 0.7vw, 14px);
margin-bottom: clamp(10px, 0.8vw, 18px);
flex: 0 0 auto;
} }
.results-area { .results-area {
flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} gap: clamp(10px, 0.8vw, 18px);
.results-row {
flex: 1;
min-height: 0;
} }
.results-row > .panel { .results-row > .panel {
@ -54,14 +120,8 @@ body {
flex-direction: column; flex-direction: column;
} }
.results-row > .panel > .ant-card-body {
flex: 1;
min-height: 0;
overflow: auto;
}
.results-placeholder { .results-placeholder {
flex: 1; min-height: 200px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -73,21 +133,35 @@ body {
} }
.panel { .panel {
border-radius: 8px; border-radius: 10px;
} border: 1px solid #e7ecf1;
box-shadow: 0 1px 2px rgba(16, 40, 60, 0.04), 0 2px 8px rgba(16, 40, 60, 0.05);
.content-grid > .panel {
height: 100%;
} }
.panel .ant-card-head { .panel .ant-card-head {
min-height: 38px; min-height: 36px;
padding: 0 12px; padding: 0 12px;
background: #f7f9fb;
border-radius: 10px 10px 0 0;
} }
.panel .ant-card-head-title { .panel .ant-card-head-title {
padding: 9px 0; padding: 9px 0;
font-size: 14px; font-size: 15px;
font-weight: 600;
color: #243150;
}
/* 卡片标题左侧的强调竖条logo 金色) */
.panel .ant-card-head-title::before {
content: "";
display: inline-block;
width: 3px;
height: 14px;
margin-right: 8px;
vertical-align: -2px;
border-radius: 2px;
background: #e3a008;
} }
.panel .ant-card-body { .panel .ant-card-body {
@ -99,6 +173,10 @@ body {
flex-direction: column; flex-direction: column;
} }
.measurements-panel {
height: 100%;
}
.measurements-panel .ant-card-body { .measurements-panel .ant-card-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -109,40 +187,50 @@ body {
.measurement-table { .measurement-table {
flex: 1; flex: 1;
min-height: 0; min-height: 96px;
overflow: auto; overflow: auto;
} }
.compact-panel .ant-card-body { .compact-panel .ant-card-body {
height: auto;
overflow: visible;
padding: 10px; padding: 10px;
} }
.calibration-table { /* 核素(校准) 与 级别(限值) 两表并排,压缩样品信息栏高度 */
margin-bottom: 12px; .sample-tables {
display: grid;
grid-template-columns: 1fr minmax(120px, 0.78fr);
gap: 10px;
align-items: start;
} }
.limits-form { .calibration-table,
.limit-table {
margin: 0;
}
.sample-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
margin-bottom: 12px;
} }
.limit-field { .sample-field {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.limit-label { .sample-label {
flex-shrink: 0; flex-shrink: 0;
width: 64px;
white-space: nowrap; white-space: nowrap;
color: #5f6368; color: #5f6368;
font-size: 13px; font-size: 13px;
} }
.limit-field .ant-input-number { .sample-field .ant-input,
.sample-field .ant-input-number {
flex: 1; flex: 1;
} }
@ -152,7 +240,7 @@ body {
.measurement-actions { .measurement-actions {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
flex-shrink: 0; flex-shrink: 0;
gap: 8px; gap: 8px;
margin-top: 8px; margin-top: 8px;
@ -176,51 +264,81 @@ body {
margin-bottom: 10px; margin-bottom: 10px;
} }
.result-tile { .analysis-grid {
min-height: 58px; display: grid;
padding: 7px 10px; grid-template-columns: repeat(4, minmax(0, 1fr));
border: 1px solid #e5e7eb; gap: 8px;
border-radius: 8px;
background: #ffffff;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
} }
.result-tile span { .analysis-meta {
color: #5f6368; color: #5f6368;
font-size: 13px; font-size: 13px;
} }
.result-tile {
min-height: 54px;
padding: 8px 11px;
border: 1px solid #e9edf1;
border-radius: 8px;
background: linear-gradient(180deg, #fbfcfd 0%, #f6f8fa 100%);
display: flex;
flex-direction: column;
justify-content: center;
gap: 3px;
}
.result-tile span {
color: #69727c;
font-size: 13px;
letter-spacing: 0.2px;
}
.result-tile strong { .result-tile strong {
font-size: 18px; font-size: 22px;
line-height: 1.2; line-height: 1.15;
color: #1b2436;
font-variant-numeric: tabular-nums;
} }
.result-tile small { .result-tile small {
color: #5f6368; color: #79838d;
font-size: 12px; font-size: 12.5px;
font-variant-numeric: tabular-nums;
} }
.conclusion-tile .ant-tag { .conclusion-tile .ant-tag {
width: fit-content; width: fit-content;
font-size: 14px; font-size: 14px;
padding: 2px 8px; padding: 3px 10px;
border-radius: 6px;
}
/* 分析判定中的「最终判定」磁贴更突出logo 金色调) */
.analysis-grid .conclusion-tile:nth-child(2) {
background: linear-gradient(180deg, #fdf8ec 0%, #fbf1d6 100%);
border-color: #f0d894;
}
.analysis-grid .conclusion-tile:nth-child(2) .ant-tag {
font-size: 15px;
font-weight: 600;
padding: 4px 12px;
} }
.ant-table-small .ant-table-thead > tr > th, .ant-table-small .ant-table-thead > tr > th,
.ant-table-small .ant-table-tbody > tr > td { .ant-table-small .ant-table-tbody > tr > td {
padding: 3px 8px; padding: 5px 9px;
font-size: 13.5px;
} }
.ant-input-number { .ant-input-number {
height: 24px; height: 30px;
} }
.ant-input-number .ant-input-number-input { .ant-input-number .ant-input-number-input {
height: 22px; height: 28px;
padding: 0 7px; padding: 0 9px;
font-size: 14px;
} }
/* Large screens / maximized window: scale content up so it stays comfortable. */ /* Large screens / maximized window: scale content up so it stays comfortable. */
@ -257,7 +375,7 @@ body {
font-size: 22px; font-size: 22px;
} }
.limit-label { .sample-label {
font-size: 14px; font-size: 14px;
} }
} }
@ -285,15 +403,109 @@ body {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.content-grid { .analysis-grid,
grid-auto-rows: auto; .measurement-actions {
} grid-template-columns: repeat(2, minmax(0, 1fr));
}
.content-grid > .panel { }
height: auto;
} /* ---- 检测报告 ---- */
.report-root {
.measurements-panel { color: #000;
height: 500px; background: #fff;
font-size: 13px;
}
.report-title {
text-align: center;
font-size: 20px;
margin: 0 0 16px;
}
.report-h2 {
font-size: 15px;
margin: 16px 0 8px;
padding-left: 8px;
border-left: 4px solid #1677ff;
}
.report-table,
.report-meta {
width: 100%;
border-collapse: collapse;
margin-bottom: 8px;
}
.report-table th,
.report-table td,
.report-meta th,
.report-meta td {
border: 1px solid #999;
padding: 4px 8px;
font-size: 13px;
text-align: center;
}
.report-table thead th {
background: #f0f3f7;
}
.report-meta th {
width: 92px;
text-align: left;
background: #f0f3f7;
}
.report-meta td {
text-align: left;
}
.report-note {
margin: 8px 0;
}
.report-sign {
display: flex;
gap: 24px;
margin-top: 36px;
}
/* ---- 打印 / 导出 PDF仅输出报告本体 ---- */
@page {
size: A4;
margin: 16mm;
}
@media print {
body * {
visibility: hidden !important;
}
.report-root,
.report-root * {
visibility: visible !important;
}
.report-root {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
.ant-modal-mask,
.ant-modal-footer,
.ant-modal-close {
display: none !important;
}
.ant-modal,
.ant-modal-wrap,
.ant-modal-content,
.ant-modal-body {
position: static !important;
box-shadow: none !important;
padding: 0 !important;
background: transparent !important;
} }
} }

195
ui/src/types.ts Normal file
View File

@ -0,0 +1,195 @@
// 与 Rust 端 (domain.rs / db.rs) 对应的共享类型与展示辅助。
export type Conclusion = "Ok" | "IncreaseMeasurementsToSix" | "RecalibrateInstrument";
export type MaterialType = "BuildingMainBody" | "HollowBuildingMainBody" | "DecorativeMaterial";
export type Validity = "LowActivityExempt" | "UncertaintyAcceptable" | "Invalid";
export type DecorClass = "A" | "B" | "C" | "Unqualified";
export type Verdict =
| "Qualified"
| "Unqualified"
| "NeedMoreMeasurements"
| "InvalidResult"
| { DecorativeClass: DecorClass };
export type CalibrationParams = {
factor: number;
expanded_uncertainty_percent: number;
coverage_factor: number;
};
export type NuclideMeasurements = {
measured_values: number[];
calibration: CalibrationParams;
};
export type SampleInput = {
ra: NuclideMeasurements;
th: NuclideMeasurements;
k: NuclideMeasurements;
material_type: MaterialType;
sample_id: string | null;
calculation_date: string | null;
};
export type McmIndexStats = {
mean: number;
std_dev: number;
p2_5: number;
p97_5: number;
standard_value: number;
pass_probability: number;
fail_probability: number;
};
export type McmResult = {
iterations: number;
ira: McmIndexStats;
ir: McmIndexStats;
overall_pass_probability: number;
overall_fail_probability: number;
};
export type NuclideResult = {
mean_measured: number;
mean_calibrated: number;
type_a_uncertainty: number;
type_b_relative: number;
type_b_uncertainty: number;
sensitivity_coefficient: number;
combined_uncertainty: number;
};
export type IndexResult = {
value: number;
standard_uncertainty: number;
expanded_uncertainty: number;
relative_uncertainty_percent: number;
relative_expanded_uncertainty_percent: number;
p2_5: number;
p97_5: number;
};
export type AnalysisResult = {
total_calibrated_activity: number;
validity: Validity;
verdict: Verdict;
};
export type CalculationResult = {
measurement_count: number;
ra: NuclideResult;
th: NuclideResult;
k: NuclideResult;
ira: IndexResult;
ir: IndexResult;
conclusion: Conclusion;
analysis: AnalysisResult;
mcm: McmResult;
};
export type RecordSummary = {
id: number;
sample_id: string | null;
material_type: MaterialType;
calc_date: string | null;
created_at: string;
ira_value: number;
ir_value: number;
validity: Validity;
verdict: Verdict;
};
export type RecordDetail = {
summary: RecordSummary;
input: SampleInput;
result: CalculationResult;
};
export type RecordFilter = {
sample_id: string | null;
material_type: MaterialType | null;
date_from: string | null;
date_to: string | null;
verdict_kind: string | null;
};
export const defaultCalibration = {
ra: { factor: 0.916, expanded_uncertainty_percent: 6.3, coverage_factor: 2 },
th: { factor: 0.884, expanded_uncertainty_percent: 6.9, coverage_factor: 2 },
k: { factor: 0.961, expanded_uncertainty_percent: 6.7, coverage_factor: 2 }
};
export const materialOptions: { value: MaterialType; label: string }[] = [
{ value: "BuildingMainBody", label: "建筑主体材料" },
{ value: "HollowBuildingMainBody", label: "空心率>25% 主体材料" },
{ value: "DecorativeMaterial", label: "装饰装修材料" }
];
export type LimitTier = { label: string; ira: number | null; ir: number | null };
export const materialTiers: Record<MaterialType, LimitTier[]> = {
BuildingMainBody: [{ label: "合格", ira: 1.0, ir: 1.0 }],
HollowBuildingMainBody: [{ label: "合格", ira: 1.0, ir: 1.3 }],
DecorativeMaterial: [
{ label: "A 类", ira: 1.0, ir: 1.3 },
{ label: "B 类", ira: 1.3, ir: 1.9 },
{ label: "C 类", ira: null, ir: 2.8 }
]
};
export const verdictKindOptions: { value: string; label: string }[] = [
{ value: "Qualified", label: "合格" },
{ value: "Unqualified", label: "不合格" },
{ value: "DecorativeClass", label: "装饰分级 (A/B/C)" },
{ value: "NeedMoreMeasurements", label: "建议增加次数" },
{ value: "InvalidResult", label: "结果无效" }
];
export const validityText: Record<Validity, { text: string; color: string }> = {
LowActivityExempt: { text: "有效(低活度豁免 ≤37 Bq/kg", color: "success" },
UncertaintyAcceptable: { text: "有效Ur(IRa) ≤ 20%", color: "success" },
Invalid: { text: "无效Ur(IRa) > 20%", color: "error" }
};
export const conclusionText: Record<Conclusion, string> = {
Ok: "OK",
IncreaseMeasurementsToSix: "请增加试验次数至 6 次",
RecalibrateInstrument: "校准仪器后重新测量"
};
export function materialText(material: MaterialType): string {
return materialOptions.find((o) => o.value === material)?.label ?? material;
}
export function verdictDisplay(verdict: Verdict): { text: string; color: string } {
if (typeof verdict === "object") {
const cls = verdict.DecorativeClass;
if (cls === "Unqualified") return { text: "不合格(不可用于建材)", color: "error" };
const color = cls === "A" ? "success" : cls === "B" ? "processing" : "warning";
return { text: `装饰装修 ${cls}`, color };
}
switch (verdict) {
case "Qualified":
return { text: "合格", color: "success" };
case "Unqualified":
return { text: "不合格", color: "error" };
case "NeedMoreMeasurements":
return { text: "建议增加至 6 次测量", color: "warning" };
case "InvalidResult":
return { text: "结果无效", color: "default" };
}
}
export function formatNumber(value: number, digits = 4): string {
if (!Number.isFinite(value)) return "-";
return value.toFixed(digits);
}
export function formatPercent(value: number, digits = 2): string {
if (!Number.isFinite(value)) return "-";
return (value * 100).toFixed(digits) + "%";
}