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:
parent
ef6c4f3f29
commit
fd2dfbdfb8
|
|
@ -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.4–2.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=1(uA=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=0,MCM 仅传播 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. 端到端手测三类材料的合格/不合格/分级/建议增加次数路径。
|
||||||
|
|
@ -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.1–6.3):
|
||||||
|
|
||||||
|
- 抬头:样品编号、计算日期、材料类型、限值表。
|
||||||
|
- 检测结果:IRa、Ir 最佳估计值;95% 真值区间 [P2.5, P97.5];k=1 / k=2 相对扩展不确定度。
|
||||||
|
- 有效性判定(总比活度 vs 37)、最终判定(合格/不合格/A·B·C/建议增加次数)。
|
||||||
|
- MCM:95% 置信概率下不符合概率。
|
||||||
|
- 各核素中间量明细表。
|
||||||
|
|
||||||
|
## 7. 界面变更
|
||||||
|
|
||||||
|
- 顶部加「保存到历史」按钮(计算成功后可用);保存成功后提示。
|
||||||
|
- 新增「历史」入口(抽屉或独立 Tab):列表(编号/日期/材料/IRa/Ir/判定)+ 过滤栏;行操作「查看 / 复算 / 导出 Excel / 导出 PDF / 删除」。
|
||||||
|
- 「查看」打开报告视图(即打印用组件)。
|
||||||
|
- 计算日期从已有的 DatePicker 复用,`created_at` 取前端当前时间戳。
|
||||||
|
|
||||||
|
## 8. 测试
|
||||||
|
|
||||||
|
- Rust:DB 用内存库(`: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. 端到端:算→存→列表过滤→查看→导出。
|
||||||
Binary file not shown.
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
/// 指数的包含因子 k(GUM 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 类不确定度为 0(PDF 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 =
|
||||||
|
|
|
||||||
135
src/domain.rs
135
src/domain.rs
|
|
@ -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.0(GB 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·c(Bq/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·k(k=2)。
|
||||||
|
pub expanded_uncertainty: f64,
|
||||||
|
/// 相对标准不确定度 u/value(k=1),百分数。
|
||||||
pub relative_uncertainty_percent: f64,
|
pub relative_uncertainty_percent: f64,
|
||||||
|
/// 相对扩展不确定度 U/value(k=2),百分数。
|
||||||
|
pub relative_expanded_uncertainty_percent: f64,
|
||||||
|
/// 95% 真值区间下限 = value − U(GUM 解析法)。
|
||||||
|
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)]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
461
ui/src/App.tsx
461
ui/src/App.tsx
|
|
@ -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 并强制重挂 CalculatorPanel(key 递增)。
|
||||||
|
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 };
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) + "%";
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue