迭代加氢站 H5/Web:订单拍照水印与车机里程、站点加氢机品牌管理,以及对账单仅纳入已核对明细;同步加氢记录日统计与导航。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
150
src/common/h2DispenserBrandStore.js
Normal file
150
src/common/h2DispenserBrandStore.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* 加氢机品牌 / 型号维护(原型本地 Store)
|
||||||
|
* 供「站点信息」维护、「加氢订单 H5」OCR 读取品牌型号线索。
|
||||||
|
*/
|
||||||
|
(function initH2DispenserBrandStore(global) {
|
||||||
|
var STORAGE_KEY = 'oneos.h2.dispenserBrands.v1';
|
||||||
|
|
||||||
|
var SEED = [
|
||||||
|
{ id: 'db-1', brand: '海德利森', model: 'H2-D50', remark: '嘉兴站常用', updatedAt: '2026-07-10 09:00:00' },
|
||||||
|
{ id: 'db-2', brand: '海德利森', model: 'H2-D80', remark: '', updatedAt: '2026-07-10 09:00:00' },
|
||||||
|
{ id: 'db-3', brand: '舜华新能源', model: 'SH-H35', remark: '面板 OCR 模板 A', updatedAt: '2026-07-12 14:20:00' },
|
||||||
|
{ id: 'db-4', brand: '厚普股份', model: 'HP-H70', remark: '', updatedAt: '2026-07-14 11:05:00' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function clone(list) {
|
||||||
|
return (list || []).map(function (row) {
|
||||||
|
return Object.assign({}, row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRaw() {
|
||||||
|
try {
|
||||||
|
var raw = global.localStorage && global.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return clone(SEED);
|
||||||
|
var parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed) || !parsed.length) return clone(SEED);
|
||||||
|
return parsed.map(function (row) {
|
||||||
|
return {
|
||||||
|
id: String(row.id || ''),
|
||||||
|
brand: String(row.brand || '').trim(),
|
||||||
|
model: String(row.model || '').trim(),
|
||||||
|
remark: String(row.remark || '').trim(),
|
||||||
|
updatedAt: String(row.updatedAt || ''),
|
||||||
|
};
|
||||||
|
}).filter(function (row) {
|
||||||
|
return row.id && row.brand && row.model;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return clone(SEED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRaw(list) {
|
||||||
|
try {
|
||||||
|
if (global.localStorage) {
|
||||||
|
global.localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore quota */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowText() {
|
||||||
|
var d = new Date();
|
||||||
|
var pad = function (n) {
|
||||||
|
return n < 10 ? '0' + n : String(n);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
d.getFullYear() +
|
||||||
|
'-' +
|
||||||
|
pad(d.getMonth() + 1) +
|
||||||
|
'-' +
|
||||||
|
pad(d.getDate()) +
|
||||||
|
' ' +
|
||||||
|
pad(d.getHours()) +
|
||||||
|
':' +
|
||||||
|
pad(d.getMinutes()) +
|
||||||
|
':' +
|
||||||
|
pad(d.getSeconds())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listBrands() {
|
||||||
|
return clone(readRaw()).sort(function (a, b) {
|
||||||
|
var brandCmp = a.brand.localeCompare(b.brand, 'zh');
|
||||||
|
if (brandCmp !== 0) return brandCmp;
|
||||||
|
return a.model.localeCompare(b.model, 'zh');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertBrand(payload) {
|
||||||
|
var brand = String(payload.brand || '').trim();
|
||||||
|
var model = String(payload.model || '').trim();
|
||||||
|
var remark = String(payload.remark || '').trim();
|
||||||
|
if (!brand || !model) {
|
||||||
|
return { ok: false, message: '品牌与型号均为必填' };
|
||||||
|
}
|
||||||
|
var list = readRaw();
|
||||||
|
var id = String(payload.id || '').trim();
|
||||||
|
var dup = list.some(function (row) {
|
||||||
|
return (
|
||||||
|
row.brand === brand &&
|
||||||
|
row.model === model &&
|
||||||
|
row.id !== id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (dup) {
|
||||||
|
return { ok: false, message: '已存在相同品牌与型号' };
|
||||||
|
}
|
||||||
|
if (id) {
|
||||||
|
var found = false;
|
||||||
|
list = list.map(function (row) {
|
||||||
|
if (row.id !== id) return row;
|
||||||
|
found = true;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
brand: brand,
|
||||||
|
model: model,
|
||||||
|
remark: remark,
|
||||||
|
updatedAt: nowText(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!found) {
|
||||||
|
return { ok: false, message: '记录不存在' };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
list.push({
|
||||||
|
id: 'db-' + Date.now() + '-' + Math.floor(Math.random() * 1000),
|
||||||
|
brand: brand,
|
||||||
|
model: model,
|
||||||
|
remark: remark,
|
||||||
|
updatedAt: nowText(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
writeRaw(list);
|
||||||
|
return { ok: true, list: clone(list) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBrand(id) {
|
||||||
|
var next = readRaw().filter(function (row) {
|
||||||
|
return row.id !== id;
|
||||||
|
});
|
||||||
|
writeRaw(next);
|
||||||
|
return { ok: true, list: clone(next) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function brandModelLabels() {
|
||||||
|
return listBrands().map(function (row) {
|
||||||
|
return row.brand + ' · ' + row.model;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
global.H2DispenserBrandStore = {
|
||||||
|
list: listBrands,
|
||||||
|
upsert: upsertBrand,
|
||||||
|
remove: removeBrand,
|
||||||
|
labels: brandModelLabels,
|
||||||
|
SEED: clone(SEED),
|
||||||
|
};
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
@@ -17,16 +17,17 @@
|
|||||||
|
|
||||||
| 字段 | 规则 |
|
| 字段 | 规则 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 拍摄区 | 提示「请拍摄车牌」;相册/相机;OCR 模拟回填车牌 |
|
| 拍摄区 | 提示「请拍摄车牌」;相册/相机;选图后自动叠加**时间、地点**水印;OCR 模拟回填车牌 |
|
||||||
| 车牌号 * | 占位「请拍摄或输入车牌号」;点击弹出车牌键盘 |
|
| 车牌号 * | 占位「请拍摄或输入车牌号」;点击弹出车牌键盘 |
|
||||||
| 车辆标签 | 有车牌后:系统车辆列表命中→「羚牛车辆」,否则→「非羚牛车辆」 |
|
| 车辆标签 | 有车牌后:系统车辆列表命中→「羚牛车辆」,否则→「非羚牛车辆」 |
|
||||||
| 里程(km) | 选填 |
|
| 里程(km) | 选填;支持**从车机获取**(原型 Mock);识别车牌后可自动回填 |
|
||||||
|
|
||||||
### 3. 加氢机面板
|
### 3. 加氢机面板
|
||||||
|
|
||||||
| 字段 | 规则 |
|
| 字段 | 规则 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 拍摄区 | 提示「请拍摄清晰的加氢机照片」;OCR 模拟回填单价、量、总额 |
|
| 拍摄区 | 提示「请拍摄清晰的加氢机照片」;选图后叠加**时间、地点**水印;OCR 模拟回填单价、量、总额 |
|
||||||
|
| OCR 参考 | 展示站点信息维护的加氢机品牌/型号线索(见站点信息「加氢机品牌管理」) |
|
||||||
| 氢气单价 * | 可改;变更后重算总额 |
|
| 氢气单价 * | 可改;变更后重算总额 |
|
||||||
| 加氢量 * | 可改;变更后重算总额 |
|
| 加氢量 * | 可改;变更后重算总额 |
|
||||||
| 加氢总额(元) | 只读;= 单价 × 加氢量(两位小数);界面不展示公式文案 |
|
| 加氢总额(元) | 只读;= 单价 × 加氢量(两位小数);界面不展示公式文案 |
|
||||||
@@ -42,5 +43,7 @@
|
|||||||
|
|
||||||
1. 时间默认正确且可选到分
|
1. 时间默认正确且可选到分
|
||||||
2. 车牌键盘可用;标签随车牌变化
|
2. 车牌键盘可用;标签随车牌变化
|
||||||
3. 改单价或量后总额自动更新且不可手改
|
3. 车牌/面板照片右下角可见时间与地点水印
|
||||||
4. 空表单可直接返回;有内容返回需确认
|
4. 可从车机获取里程(有车牌时)
|
||||||
|
5. 改单价或量后总额自动更新且不可手改
|
||||||
|
6. 空表单可直接返回;有内容返回需确认
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
- 顶部:车牌、加氢时间、核对标签、对账标签
|
- 顶部:车牌、加氢时间、核对标签、对账标签
|
||||||
- 指标条:加氢总额、加氢量
|
- 指标条:加氢总额、加氢量
|
||||||
|
- **识别原图**:车牌照片、加氢机面板照片(均为拍摄时叠加时间/地点水印后的原图;种子数据无实拍时用演示水印图)
|
||||||
- 明细行:站点、车牌、里程、单价、核对/对账状态与时间
|
- 明细行:站点、车牌、里程、单价、核对/对账状态与时间
|
||||||
|
|
||||||
## 锁定规则
|
## 锁定规则
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
|
|
||||||
## 编辑
|
## 编辑
|
||||||
|
|
||||||
进入与「新增」同一套表单,预填当前值;提交走更新逻辑(仍校验重复键)。
|
进入与「新增」同一套表单,预填当前值(含已存水印照片);提交走更新逻辑(仍校验重复键)。
|
||||||
|
|
||||||
## 删除
|
## 删除
|
||||||
|
|
||||||
@@ -33,3 +34,4 @@
|
|||||||
1. 未核对未对账:底部有删除/编辑
|
1. 未核对未对账:底部有删除/编辑
|
||||||
2. 已核对或已对账:无操作栏,有锁定说明
|
2. 已核对或已对账:无操作栏,有锁定说明
|
||||||
3. 详情含对账信息;列表卡片不含对账
|
3. 详情含对账信息;列表卡片不含对账
|
||||||
|
4. 详情展示车牌与面板带水印原图(新上报提交后为实拍;种子行为演示图)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
| 项 | 内容 |
|
| 项 | 内容 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 文档版本 | v1.3 |
|
| 文档版本 | v1.5 |
|
||||||
| 模块名称 | 加氢站管理 → 加氢订单 |
|
| 模块名称 | 加氢站管理 → 加氢订单 |
|
||||||
| 所属系统 | ONE-OS(加氢站手机浏览器) |
|
| 所属系统 | ONE-OS(加氢站手机浏览器) |
|
||||||
| 交互原型 | `/prototypes/oneos-h5-h2-order` |
|
| 交互原型 | `/prototypes/oneos-h5-h2-order` |
|
||||||
@@ -51,6 +51,9 @@
|
|||||||
5. 能源部完成核对后同步「已核对」;站点对账单提交后同步「已对账」+ 对账时间
|
5. 能源部完成核对后同步「已核对」;站点对账单提交后同步「已对账」+ 对账时间
|
||||||
6. 同站同车牌同加氢时间重复保存被拦截
|
6. 同站同车牌同加氢时间重复保存被拦截
|
||||||
7. Web「加氢记录」与 H5「加氢订单」手工台账逻辑一致(同 Store)
|
7. Web「加氢记录」与 H5「加氢订单」手工台账逻辑一致(同 Store)
|
||||||
|
8. 车牌/面板拍摄预览含水印(时间、地点=本站名称);里程可从车机获取(Mock)
|
||||||
|
9. 面板 OCR 可展示站点信息维护的加氢机品牌型号线索
|
||||||
|
10. 详情页展示车牌与面板带水印原图
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
"version": 2,
|
"version": 2,
|
||||||
"prototypeName": "oneos-h5-h2-order",
|
"prototypeName": "oneos-h5-h2-order",
|
||||||
"pageId": "list",
|
"pageId": "list",
|
||||||
"updatedAt": 1784260628220,
|
"updatedAt": 1784280448632,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"images": [],
|
"images": [],
|
||||||
"controls": [],
|
"controls": [],
|
||||||
"createdAt": 1784260628220,
|
"createdAt": 1784280448632,
|
||||||
"updatedAt": 1784260628220,
|
"updatedAt": 1784280448632,
|
||||||
"hasMarkdown": true,
|
"hasMarkdown": true,
|
||||||
"annotationText": "",
|
"annotationText": "",
|
||||||
"id": "h5-hero",
|
"id": "h5-hero",
|
||||||
@@ -31,8 +31,8 @@
|
|||||||
{
|
{
|
||||||
"images": [],
|
"images": [],
|
||||||
"controls": [],
|
"controls": [],
|
||||||
"createdAt": 1784260628220,
|
"createdAt": 1784280448632,
|
||||||
"updatedAt": 1784260628220,
|
"updatedAt": 1784280448632,
|
||||||
"hasMarkdown": true,
|
"hasMarkdown": true,
|
||||||
"annotationText": "",
|
"annotationText": "",
|
||||||
"id": "h5-summary",
|
"id": "h5-summary",
|
||||||
@@ -52,8 +52,8 @@
|
|||||||
{
|
{
|
||||||
"images": [],
|
"images": [],
|
||||||
"controls": [],
|
"controls": [],
|
||||||
"createdAt": 1784260628220,
|
"createdAt": 1784280448632,
|
||||||
"updatedAt": 1784260628220,
|
"updatedAt": 1784280448632,
|
||||||
"hasMarkdown": true,
|
"hasMarkdown": true,
|
||||||
"annotationText": "",
|
"annotationText": "",
|
||||||
"id": "h5-card",
|
"id": "h5-card",
|
||||||
@@ -73,8 +73,8 @@
|
|||||||
{
|
{
|
||||||
"images": [],
|
"images": [],
|
||||||
"controls": [],
|
"controls": [],
|
||||||
"createdAt": 1784260628220,
|
"createdAt": 1784280448632,
|
||||||
"updatedAt": 1784260628220,
|
"updatedAt": 1784280448632,
|
||||||
"hasMarkdown": true,
|
"hasMarkdown": true,
|
||||||
"annotationText": "",
|
"annotationText": "",
|
||||||
"id": "h5-fab",
|
"id": "h5-fab",
|
||||||
@@ -94,8 +94,8 @@
|
|||||||
{
|
{
|
||||||
"images": [],
|
"images": [],
|
||||||
"controls": [],
|
"controls": [],
|
||||||
"createdAt": 1784260628220,
|
"createdAt": 1784280448632,
|
||||||
"updatedAt": 1784260628220,
|
"updatedAt": 1784280448632,
|
||||||
"hasMarkdown": true,
|
"hasMarkdown": true,
|
||||||
"annotationText": "",
|
"annotationText": "",
|
||||||
"id": "h5-time",
|
"id": "h5-time",
|
||||||
@@ -115,8 +115,8 @@
|
|||||||
{
|
{
|
||||||
"images": [],
|
"images": [],
|
||||||
"controls": [],
|
"controls": [],
|
||||||
"createdAt": 1784260628220,
|
"createdAt": 1784280448632,
|
||||||
"updatedAt": 1784260628220,
|
"updatedAt": 1784280448632,
|
||||||
"hasMarkdown": true,
|
"hasMarkdown": true,
|
||||||
"annotationText": "",
|
"annotationText": "",
|
||||||
"id": "h5-station",
|
"id": "h5-station",
|
||||||
@@ -136,8 +136,8 @@
|
|||||||
{
|
{
|
||||||
"images": [],
|
"images": [],
|
||||||
"controls": [],
|
"controls": [],
|
||||||
"createdAt": 1784260628220,
|
"createdAt": 1784280448632,
|
||||||
"updatedAt": 1784260628220,
|
"updatedAt": 1784280448632,
|
||||||
"hasMarkdown": true,
|
"hasMarkdown": true,
|
||||||
"annotationText": "",
|
"annotationText": "",
|
||||||
"id": "h5-plate",
|
"id": "h5-plate",
|
||||||
@@ -157,12 +157,54 @@
|
|||||||
{
|
{
|
||||||
"images": [],
|
"images": [],
|
||||||
"controls": [],
|
"controls": [],
|
||||||
"createdAt": 1784260628220,
|
"createdAt": 1784280448632,
|
||||||
"updatedAt": 1784260628220,
|
"updatedAt": 1784280448632,
|
||||||
|
"hasMarkdown": true,
|
||||||
|
"annotationText": "",
|
||||||
|
"id": "h5-mileage",
|
||||||
|
"index": 8,
|
||||||
|
"title": "里程",
|
||||||
|
"pageId": "create",
|
||||||
|
"color": "#0E8A7B",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [
|
||||||
|
"[data-annotation-id='h5-mileage']",
|
||||||
|
"#h5-mileage"
|
||||||
|
],
|
||||||
|
"fingerprint": "field|h5-mileage",
|
||||||
|
"path": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"images": [],
|
||||||
|
"controls": [],
|
||||||
|
"createdAt": 1784280448632,
|
||||||
|
"updatedAt": 1784280448632,
|
||||||
|
"hasMarkdown": true,
|
||||||
|
"annotationText": "",
|
||||||
|
"id": "h5-dispenser-brands",
|
||||||
|
"index": 9,
|
||||||
|
"title": "加氢机品牌参考",
|
||||||
|
"pageId": "create",
|
||||||
|
"color": "#7C3AED",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [
|
||||||
|
"[data-annotation-id='h5-dispenser-brands']",
|
||||||
|
".h5-brand-hint"
|
||||||
|
],
|
||||||
|
"fingerprint": "hint|dispenser-brands",
|
||||||
|
"path": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"images": [],
|
||||||
|
"controls": [],
|
||||||
|
"createdAt": 1784280448632,
|
||||||
|
"updatedAt": 1784280448632,
|
||||||
"hasMarkdown": true,
|
"hasMarkdown": true,
|
||||||
"annotationText": "",
|
"annotationText": "",
|
||||||
"id": "h5-price",
|
"id": "h5-price",
|
||||||
"index": 8,
|
"index": 10,
|
||||||
"title": "氢气单价",
|
"title": "氢气单价",
|
||||||
"pageId": "create",
|
"pageId": "create",
|
||||||
"color": "#7AB929",
|
"color": "#7AB929",
|
||||||
@@ -178,12 +220,12 @@
|
|||||||
{
|
{
|
||||||
"images": [],
|
"images": [],
|
||||||
"controls": [],
|
"controls": [],
|
||||||
"createdAt": 1784260628220,
|
"createdAt": 1784280448632,
|
||||||
"updatedAt": 1784260628220,
|
"updatedAt": 1784280448632,
|
||||||
"hasMarkdown": true,
|
"hasMarkdown": true,
|
||||||
"annotationText": "",
|
"annotationText": "",
|
||||||
"id": "h5-kg",
|
"id": "h5-kg",
|
||||||
"index": 9,
|
"index": 11,
|
||||||
"title": "加氢量",
|
"title": "加氢量",
|
||||||
"pageId": "create",
|
"pageId": "create",
|
||||||
"color": "#7AB929",
|
"color": "#7AB929",
|
||||||
@@ -199,12 +241,12 @@
|
|||||||
{
|
{
|
||||||
"images": [],
|
"images": [],
|
||||||
"controls": [],
|
"controls": [],
|
||||||
"createdAt": 1784260628220,
|
"createdAt": 1784280448632,
|
||||||
"updatedAt": 1784260628220,
|
"updatedAt": 1784280448632,
|
||||||
"hasMarkdown": true,
|
"hasMarkdown": true,
|
||||||
"annotationText": "",
|
"annotationText": "",
|
||||||
"id": "h5-total-label",
|
"id": "h5-total-label",
|
||||||
"index": 10,
|
"index": 12,
|
||||||
"title": "加氢总额",
|
"title": "加氢总额",
|
||||||
"pageId": "create",
|
"pageId": "create",
|
||||||
"color": "#0E8A7B",
|
"color": "#0E8A7B",
|
||||||
@@ -220,12 +262,12 @@
|
|||||||
{
|
{
|
||||||
"images": [],
|
"images": [],
|
||||||
"controls": [],
|
"controls": [],
|
||||||
"createdAt": 1784260628220,
|
"createdAt": 1784280448632,
|
||||||
"updatedAt": 1784260628220,
|
"updatedAt": 1784280448632,
|
||||||
"hasMarkdown": true,
|
"hasMarkdown": true,
|
||||||
"annotationText": "",
|
"annotationText": "",
|
||||||
"id": "h5-detail",
|
"id": "h5-detail",
|
||||||
"index": 11,
|
"index": 13,
|
||||||
"title": "记录详情",
|
"title": "记录详情",
|
||||||
"pageId": "detail",
|
"pageId": "detail",
|
||||||
"color": "#FF7D00",
|
"color": "#FF7D00",
|
||||||
@@ -241,12 +283,33 @@
|
|||||||
{
|
{
|
||||||
"images": [],
|
"images": [],
|
||||||
"controls": [],
|
"controls": [],
|
||||||
"createdAt": 1784260628220,
|
"createdAt": 1784280448632,
|
||||||
"updatedAt": 1784260628220,
|
"updatedAt": 1784280448632,
|
||||||
|
"hasMarkdown": true,
|
||||||
|
"annotationText": "",
|
||||||
|
"id": "h5-detail-photos",
|
||||||
|
"index": 14,
|
||||||
|
"title": "识别原图",
|
||||||
|
"pageId": "detail",
|
||||||
|
"color": "#0E8A7B",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [
|
||||||
|
"[data-annotation-id='h5-detail-photos']",
|
||||||
|
".h5-detail-photos"
|
||||||
|
],
|
||||||
|
"fingerprint": "detail|photos",
|
||||||
|
"path": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"images": [],
|
||||||
|
"controls": [],
|
||||||
|
"createdAt": 1784280448632,
|
||||||
|
"updatedAt": 1784280448632,
|
||||||
"hasMarkdown": true,
|
"hasMarkdown": true,
|
||||||
"annotationText": "",
|
"annotationText": "",
|
||||||
"id": "h5-manual",
|
"id": "h5-manual",
|
||||||
"index": 12,
|
"index": 15,
|
||||||
"title": "手工台账",
|
"title": "手工台账",
|
||||||
"pageId": "manual",
|
"pageId": "manual",
|
||||||
"color": "#7AB929",
|
"color": "#7AB929",
|
||||||
@@ -262,12 +325,12 @@
|
|||||||
{
|
{
|
||||||
"images": [],
|
"images": [],
|
||||||
"controls": [],
|
"controls": [],
|
||||||
"createdAt": 1784260628220,
|
"createdAt": 1784280448632,
|
||||||
"updatedAt": 1784260628220,
|
"updatedAt": 1784280448632,
|
||||||
"hasMarkdown": true,
|
"hasMarkdown": true,
|
||||||
"annotationText": "",
|
"annotationText": "",
|
||||||
"id": "h5-manual-calendar",
|
"id": "h5-manual-calendar",
|
||||||
"index": 13,
|
"index": 16,
|
||||||
"title": "台账日历",
|
"title": "台账日历",
|
||||||
"pageId": "manual",
|
"pageId": "manual",
|
||||||
"color": "#0E8A7B",
|
"color": "#0E8A7B",
|
||||||
@@ -290,10 +353,13 @@
|
|||||||
"h5-time": "## 加氢时间(必填)\n\n默认=点击「新增」时的当前时间;点击后底部弹层选择日期 + 时 + 分。",
|
"h5-time": "## 加氢时间(必填)\n\n默认=点击「新增」时的当前时间;点击后底部弹层选择日期 + 时 + 分。",
|
||||||
"h5-station": "## 加氢站名称\n\n只读。默认显示当前登录账号对应加氢站名称,不可切换。",
|
"h5-station": "## 加氢站名称\n\n只读。默认显示当前登录账号对应加氢站名称,不可切换。",
|
||||||
"h5-plate": "# 车牌「羚牛车辆 / 非羚牛车辆」标签\n\n原型本地判定,未接真实车辆管理 API。\n\n## 判定顺序\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 车牌为空 | 不展示标签 |\n| 2 | 规范化后车牌 ∈ 本地系统车辆集合 | **羚牛车辆** |\n| 3 | 有车牌但不在集合中 | **非羚牛车辆** |\n\n## 前置条件\n\n- 识别(OCR)或键盘输入得到车牌后才展示。\n- 比较前去掉尾缀 `F`,统一大写。\n\n## 数据源\n\n- `utils/fleet-plates.ts` 内本地种子车牌集合(含加氢 Bridge 演示车牌与部分车辆管理样例)。\n- 正式环境应改为查询车辆管理 / 系统车辆列表接口。\n\n## 用户可见结果\n\n- 绿色标签「羚牛车辆」或橙色标签「非羚牛车辆」。\n- 不拦截提交(仅提示归属)。\n\n## 代码路径\n\n- `getVehicleTag` / `isLingniuVehicle` → `CreateWizard` 车牌行右侧标签。\n",
|
"h5-plate": "# 车牌「羚牛车辆 / 非羚牛车辆」标签\n\n原型本地判定,未接真实车辆管理 API。\n\n## 判定顺序\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 车牌为空 | 不展示标签 |\n| 2 | 规范化后车牌 ∈ 本地系统车辆集合 | **羚牛车辆** |\n| 3 | 有车牌但不在集合中 | **非羚牛车辆** |\n\n## 前置条件\n\n- 识别(OCR)或键盘输入得到车牌后才展示。\n- 比较前去掉尾缀 `F`,统一大写。\n\n## 数据源\n\n- `utils/fleet-plates.ts` 内本地种子车牌集合(含加氢 Bridge 演示车牌与部分车辆管理样例)。\n- 正式环境应改为查询车辆管理 / 系统车辆列表接口。\n\n## 用户可见结果\n\n- 绿色标签「羚牛车辆」或橙色标签「非羚牛车辆」。\n- 不拦截提交(仅提示归属)。\n\n## 代码路径\n\n- `getVehicleTag` / `isLingniuVehicle` → `CreateWizard` 车牌行右侧标签。\n",
|
||||||
|
"h5-mileage": "## 里程(km)\n\n选填。支持「从车机获取」(原型 Mock);识别车牌后可自动回填。正式环境对接车机 / T-Box。\n\n详见 [新增加氢记录](feature-create)。",
|
||||||
|
"h5-dispenser-brands": "## 加氢机品牌参考\n\n展示站点信息「加氢机品牌管理」维护的品牌·型号,供面板 OCR 模型判断参考。数据源:`h2DispenserBrandStore`。",
|
||||||
"h5-price": "## 氢气单价(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额(单价×加氢量)。",
|
"h5-price": "## 氢气单价(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额(单价×加氢量)。",
|
||||||
"h5-kg": "## 加氢量(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额。",
|
"h5-kg": "## 加氢量(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额。",
|
||||||
"h5-total-label": "## 加氢总额(元)\n\n只读展示;由单价×加氢量自动计算(两位小数)。界面标签不展示公式文案。",
|
"h5-total-label": "## 加氢总额(元)\n\n只读展示;由单价×加氢量自动计算(两位小数)。界面标签不展示公式文案。",
|
||||||
"h5-detail": "# 功能:详情、编辑与删除\n\n## 目标\n\n查看单笔加氢记录完整字段;在「未核对且未对账」时可编辑或删除。\n\n## 详情展示\n\n- 顶部:车牌、加氢时间、核对标签、对账标签 \n- 指标条:加氢总额、加氢量 \n- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 \n\n## 锁定规则\n\n| 条件 | 结果 |\n|------|------|\n| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |\n| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |\n| 未核对且未对账 | 可编辑、可删除 |\n\n判定函数:`isOrderLocked`(核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单,预填当前值;提交走更新逻辑(仍校验重复键)。\n\n## 删除\n\n二次确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 未核对未对账:底部有删除/编辑 \n2. 已核对或已对账:无操作栏,有锁定说明 \n3. 详情含对账信息;列表卡片不含对账 \n",
|
"h5-detail": "# 功能:详情、编辑与删除\n\n## 目标\n\n查看单笔加氢记录完整字段;在「未核对且未对账」时可编辑或删除。\n\n## 详情展示\n\n- 顶部:车牌、加氢时间、核对标签、对账标签 \n- 指标条:加氢总额、加氢量 \n- **识别原图**:车牌照片、加氢机面板照片(均为拍摄时叠加时间/地点水印后的原图;种子数据无实拍时用演示水印图) \n- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 \n\n## 锁定规则\n\n| 条件 | 结果 |\n|------|------|\n| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |\n| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |\n| 未核对且未对账 | 可编辑、可删除 |\n\n判定函数:`isOrderLocked`(核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单,预填当前值(含已存水印照片);提交走更新逻辑(仍校验重复键)。\n\n## 删除\n\n二次确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 未核对未对账:底部有删除/编辑 \n2. 已核对或已对账:无操作栏,有锁定说明 \n3. 详情含对账信息;列表卡片不含对账 \n4. 详情展示车牌与面板带水印原图(新上报提交后为实拍;种子行为演示图) \n",
|
||||||
|
"h5-detail-photos": "## 识别原图(含水印)\n\n详情展示车牌识别、加氢机面板的原始照片;照片在拍摄/选图时已叠加时间与地点水印并随记录保存。种子数据无实拍时用演示水印图。\n\n详见 [详情编辑删除](feature-detail)。",
|
||||||
"h5-manual": "# 功能:手工台账上传\n\n## 目标\n\n站端每日上传纸质**手工台账照片**,作为系统电子加氢记录的核对依据。多日未操作时,可通过日历查看缺口并补传过往日期。\n\n## 产品口径\n\n| 项 | 规则 |\n|---|---|\n| 入口 | H5:底部 Tab「手工台账」;Web:顶栏「手工台账」 |\n| 上传形态 | 仅图片(拍照/相册),可多张 |\n| 维度 | **加氢站 + 自然日(本地日历日)** 一份 |\n| 日历 | 月历自本站**首笔加氢记录日**起算:绿=已传、橙=未传;首笔之前无需补传;禁选未来日 |\n| 补传 | 允许为过往未传日期补传;已传日期仅可**追加**新图 |\n| 归档 | **确认上传后的图片进入已归档状态,不可删除**;未确认的草稿图仍可删除 |\n| 拦截 | **未上传今日手工台账 → 禁止新增加氢记录**(编辑已有记录不拦;补传历史不替代今日校验) |\n| 数据 | 共享 `H2VehicleLedgerBridge` 内存 Store(未接真实 API) |\n\n## 判定顺序(新增拦截)\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 操作=编辑已有记录 | 不校验手工台账 |\n| 2 | 操作=新增,且目标站今日已有 ≥1 张台账图 | 允许进入新增 |\n| 3 | 操作=新增,今日未上传 | 提示并跳转手工台账 Tab |\n\n## 日历与补传\n\n| 规则 | 说明 |\n|------|------|\n| 数据源 | `listManualLedgersByStation`;当日有 ≥1 张图视为已上传 |\n| 业务起点 | 取本站 Bridge 加氢记录中最早 `hydrogenTime` 自然日;无记录则历史日均不要求补传 |\n| 未传计数 | 本月内 `max(月初, 首笔日)`~min(月末, 今日) 中未上传的天数 |\n| 点选 | 仅 `dateKey ≤ 今日`;选中后右侧/下方展示该日草稿图与上传按钮 |\n| 归档不可删 | Store 已有图强制保留;`upsertManualLedger` 仅追加新图,忽略删除已有图的请求 |\n| 与拦截关系 | 仅 **今日** 影响「禁止新增」;历史补传解决核对缺口,不放宽今日门禁 |\n\n## 用户可见\n\n- 今日未上传:橙/黄提示条 + Tab 红点 \n- 日历:自首笔加氢日起绿/橙标记;首笔之前无橙点(无需补传);本月未传天数摘要 \n- 选中日:上传区标题为该日期;已归档图显示「已归档」无删除;仅未确认草稿可删;已有台账时主按钮为「确认补传」\n\n## 代码路径\n\n- Bridge:`src/common/h2VehicleLedgerBridge.js`(`hasManualLedgerForDate` / `upsertManualLedger` / `listManualLedgersByStation`) \n- H5:`oneos-h5-h2-order` · `ManualLedgerPanel` + `utils/manual-ledger.ts`(`buildManualMonthCells` / `getFirstHydrogenDateKey`) \n- Web:`oneos-web-h2-station/pages/02-加氢记录.jsx`(手工台账 Tab 月历) \n\n## 验收\n\n1. 今日未上传时,H5/Web 点「新增」均被拦截并引导上传 \n2. 上传至少一张图并确认后,可新增电子记录 \n3. 日历自首笔加氢日起区分已传/未传;首笔之前不标未传、不计入补传;可点选范围内未传日补传 \n4. 补传历史日后,该日日历变为已传;今日仍未传时新增仍被拦截 \n5. 确认上传后的图显示已归档且不可删除;仅可追加补传 \n6. H5 与 Web 共用同一 Store,一端上传另一端可见(同会话) \n",
|
"h5-manual": "# 功能:手工台账上传\n\n## 目标\n\n站端每日上传纸质**手工台账照片**,作为系统电子加氢记录的核对依据。多日未操作时,可通过日历查看缺口并补传过往日期。\n\n## 产品口径\n\n| 项 | 规则 |\n|---|---|\n| 入口 | H5:底部 Tab「手工台账」;Web:顶栏「手工台账」 |\n| 上传形态 | 仅图片(拍照/相册),可多张 |\n| 维度 | **加氢站 + 自然日(本地日历日)** 一份 |\n| 日历 | 月历自本站**首笔加氢记录日**起算:绿=已传、橙=未传;首笔之前无需补传;禁选未来日 |\n| 补传 | 允许为过往未传日期补传;已传日期仅可**追加**新图 |\n| 归档 | **确认上传后的图片进入已归档状态,不可删除**;未确认的草稿图仍可删除 |\n| 拦截 | **未上传今日手工台账 → 禁止新增加氢记录**(编辑已有记录不拦;补传历史不替代今日校验) |\n| 数据 | 共享 `H2VehicleLedgerBridge` 内存 Store(未接真实 API) |\n\n## 判定顺序(新增拦截)\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 操作=编辑已有记录 | 不校验手工台账 |\n| 2 | 操作=新增,且目标站今日已有 ≥1 张台账图 | 允许进入新增 |\n| 3 | 操作=新增,今日未上传 | 提示并跳转手工台账 Tab |\n\n## 日历与补传\n\n| 规则 | 说明 |\n|------|------|\n| 数据源 | `listManualLedgersByStation`;当日有 ≥1 张图视为已上传 |\n| 业务起点 | 取本站 Bridge 加氢记录中最早 `hydrogenTime` 自然日;无记录则历史日均不要求补传 |\n| 未传计数 | 本月内 `max(月初, 首笔日)`~min(月末, 今日) 中未上传的天数 |\n| 点选 | 仅 `dateKey ≤ 今日`;选中后右侧/下方展示该日草稿图与上传按钮 |\n| 归档不可删 | Store 已有图强制保留;`upsertManualLedger` 仅追加新图,忽略删除已有图的请求 |\n| 与拦截关系 | 仅 **今日** 影响「禁止新增」;历史补传解决核对缺口,不放宽今日门禁 |\n\n## 用户可见\n\n- 今日未上传:橙/黄提示条 + Tab 红点 \n- 日历:自首笔加氢日起绿/橙标记;首笔之前无橙点(无需补传);本月未传天数摘要 \n- 选中日:上传区标题为该日期;已归档图显示「已归档」无删除;仅未确认草稿可删;已有台账时主按钮为「确认补传」\n\n## 代码路径\n\n- Bridge:`src/common/h2VehicleLedgerBridge.js`(`hasManualLedgerForDate` / `upsertManualLedger` / `listManualLedgersByStation`) \n- H5:`oneos-h5-h2-order` · `ManualLedgerPanel` + `utils/manual-ledger.ts`(`buildManualMonthCells` / `getFirstHydrogenDateKey`) \n- Web:`oneos-web-h2-station/pages/02-加氢记录.jsx`(手工台账 Tab 月历) \n\n## 验收\n\n1. 今日未上传时,H5/Web 点「新增」均被拦截并引导上传 \n2. 上传至少一张图并确认后,可新增电子记录 \n3. 日历自首笔加氢日起区分已传/未传;首笔之前不标未传、不计入补传;可点选范围内未传日补传 \n4. 补传历史日后,该日日历变为已传;今日仍未传时新增仍被拦截 \n5. 确认上传后的图显示已归档且不可删除;仅可追加补传 \n6. H5 与 Web 共用同一 Store,一端上传另一端可见(同会话) \n",
|
||||||
"h5-manual-calendar": "## 台账日历\n\n自本站首笔加氢记录日起:绿=已上传,橙=未上传;首笔之前无需补传。禁选未来日。点选范围内未传日可补传。仅「今日」是否上传影响新增加氢记录。\n\n详见 [手工台账上传](feature-manual-ledger)。"
|
"h5-manual-calendar": "## 台账日历\n\n自本站首笔加氢记录日起:绿=已上传,橙=未上传;首笔之前无需补传。禁选未来日。点选范围内未传日可补传。仅「今日」是否上传影响新增加氢记录。\n\n详见 [手工台账上传](feature-manual-ledger)。"
|
||||||
},
|
},
|
||||||
@@ -310,7 +376,7 @@
|
|||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
"id": "h5-doc-prd",
|
"id": "h5-doc-prd",
|
||||||
"title": "PRD 全文",
|
"title": "PRD 全文",
|
||||||
"markdown": "# 加氢订单(H5)— 产品需求文档(PRD)\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.3 |\n| 模块名称 | 加氢站管理 → 加氢订单 |\n| 所属系统 | ONE-OS(加氢站手机浏览器) |\n| 交互原型 | `/prototypes/oneos-h5-h2-order` |\n| 设计基底 | 小羚羚 `xll-miniapp/DESIGN.md` |\n| 关联模块 | [站点信息](/prototypes/oneos-web-h2-station-site)、[加氢记录 Web](/prototypes/oneos-web-h2-station)、[车辆氢费明细](/prototypes/vehicle-h2-fee-ledger) |\n\n---\n\n## 1. 背景与目标\n\n采购创建加氢站并绑定供应商、开放系统账号后,加氢站人员通过手机浏览器登录 OneOS,在「加氢订单」模块上报加氢记录。Web「加氢记录」为同一业务的 PC 入口(共享 Bridge 数据;PC 另有导出与预约加氢)。\n\n每日须上传**手工台账**照片,作为电子记录的核对依据;未上传当日手工台账时不可新增加氢记录。手工台账页提供月历,标出已传/未传日期,支持补传过往缺口。\n\n### 1.1 目标用户\n\n加氢站操作人员(户外 / 站场手机使用)。\n\n### 1.2 功能清单\n\n| # | 功能 | 说明文档 |\n|---|---|---|\n| 1 | 本站列表与 KPI | [feature-list.md](./feature-list.md) |\n| 2 | 新增加氢记录 | [feature-create.md](./feature-create.md) |\n| 3 | 详情 / 编辑 / 删除 | [feature-detail.md](./feature-detail.md) |\n| 4 | **手工台账上传** | [feature-manual-ledger.md](./feature-manual-ledger.md) |\n| 5 | 羚牛车辆标签 | [fleet-plate-tag.md](./fleet-plate-tag.md) |\n| 6 | 核对与对账联动 | [reconcile-linkage.md](./reconcile-linkage.md) |\n\n### 1.3 列表字段\n\n加氢时间、车牌号、氢气单价、加氢量、加氢总额、核对状态;已核对时展示核对时间。里程在新增/详情展示(选填),列表不展示。列表不展示对账状态。\n\n### 1.4 不做\n\n真登录、真 OCR SDK、PLC、原生 App、后端联调。\n\n---\n\n## 2. 验收项\n\n1. 菜单位于 OneOS → 加氢站管理 → 加氢订单 \n2. 默认已登录本站;列表仅本站(无切站) \n3. 底栏 Tab:加氢订单 / 手工台账;月历可区分已传/未传并补传过往日;未上传今日手工台账不可新增;上传后可新增;时间默认点击新增时刻(到分) \n4. 已核对或已对账不可编辑/删除;未核对未对账可 \n5. 能源部完成核对后同步「已核对」;站点对账单提交后同步「已对账」+ 对账时间 \n6. 同站同车牌同加氢时间重复保存被拦截 \n7. Web「加氢记录」与 H5「加氢订单」手工台账逻辑一致(同 Store) \n\n---\n\n## 3. 复杂逻辑摘要\n\n| 主题 | 摘要 | 全文 |\n|---|---|---|\n| 对账 / 核对 | 核对≠对账;触发方不同 | [reconcile-linkage.md](./reconcile-linkage.md)、[车辆氢费 verify-reconcile](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) |\n| 重复键 | 与 Web 共用 Bridge | [Web 数据模型](../oneos-web-h2-station/.spec/record-data-model.md) |\n| 羚牛车辆标签 | 本地系统车辆集合判定 | [fleet-plate-tag.md](./fleet-plate-tag.md) |\n| 总额 | 单价×加氢量自动算,界面不展示公式 | [feature-create.md](./feature-create.md) |\n| 手工台账 | 按站+自然日上传图片;月历标已传/未传可补传;未上传今日禁新增 | [feature-manual-ledger.md](./feature-manual-ledger.md) |\n\n---\n\n## 4. 页面与标注对照\n\n| 页面 pageId | 主要能力 |\n|---|---|\n| `list` | 站点头、KPI、订单卡片、底栏新增 |\n| `create` | 时间选择、站名、车牌键盘、面板 OCR、金额字段 |\n| `detail` | 详情字段、锁定说明、编辑/删除 |\n\n标注数据:`annotation-source.json`(由 `scripts/build-annotation-source.mjs` 从本目录 Markdown 生成)。\n",
|
"markdown": "# 加氢订单(H5)— 产品需求文档(PRD)\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.5 |\n| 模块名称 | 加氢站管理 → 加氢订单 |\n| 所属系统 | ONE-OS(加氢站手机浏览器) |\n| 交互原型 | `/prototypes/oneos-h5-h2-order` |\n| 设计基底 | 小羚羚 `xll-miniapp/DESIGN.md` |\n| 关联模块 | [站点信息](/prototypes/oneos-web-h2-station-site)、[加氢记录 Web](/prototypes/oneos-web-h2-station)、[车辆氢费明细](/prototypes/vehicle-h2-fee-ledger) |\n\n---\n\n## 1. 背景与目标\n\n采购创建加氢站并绑定供应商、开放系统账号后,加氢站人员通过手机浏览器登录 OneOS,在「加氢订单」模块上报加氢记录。Web「加氢记录」为同一业务的 PC 入口(共享 Bridge 数据;PC 另有导出与预约加氢)。\n\n每日须上传**手工台账**照片,作为电子记录的核对依据;未上传当日手工台账时不可新增加氢记录。手工台账页提供月历,标出已传/未传日期,支持补传过往缺口。\n\n### 1.1 目标用户\n\n加氢站操作人员(户外 / 站场手机使用)。\n\n### 1.2 功能清单\n\n| # | 功能 | 说明文档 |\n|---|---|---|\n| 1 | 本站列表与 KPI | [feature-list.md](./feature-list.md) |\n| 2 | 新增加氢记录 | [feature-create.md](./feature-create.md) |\n| 3 | 详情 / 编辑 / 删除 | [feature-detail.md](./feature-detail.md) |\n| 4 | **手工台账上传** | [feature-manual-ledger.md](./feature-manual-ledger.md) |\n| 5 | 羚牛车辆标签 | [fleet-plate-tag.md](./fleet-plate-tag.md) |\n| 6 | 核对与对账联动 | [reconcile-linkage.md](./reconcile-linkage.md) |\n\n### 1.3 列表字段\n\n加氢时间、车牌号、氢气单价、加氢量、加氢总额、核对状态;已核对时展示核对时间。里程在新增/详情展示(选填),列表不展示。列表不展示对账状态。\n\n### 1.4 不做\n\n真登录、真 OCR SDK、PLC、原生 App、后端联调。\n\n---\n\n## 2. 验收项\n\n1. 菜单位于 OneOS → 加氢站管理 → 加氢订单 \n2. 默认已登录本站;列表仅本站(无切站) \n3. 底栏 Tab:加氢订单 / 手工台账;月历可区分已传/未传并补传过往日;未上传今日手工台账不可新增;上传后可新增;时间默认点击新增时刻(到分) \n4. 已核对或已对账不可编辑/删除;未核对未对账可 \n5. 能源部完成核对后同步「已核对」;站点对账单提交后同步「已对账」+ 对账时间 \n6. 同站同车牌同加氢时间重复保存被拦截 \n7. Web「加氢记录」与 H5「加氢订单」手工台账逻辑一致(同 Store) \n8. 车牌/面板拍摄预览含水印(时间、地点=本站名称);里程可从车机获取(Mock) \n9. 面板 OCR 可展示站点信息维护的加氢机品牌型号线索 \n10. 详情页展示车牌与面板带水印原图 \n\n---\n\n## 3. 复杂逻辑摘要\n\n| 主题 | 摘要 | 全文 |\n|---|---|---|\n| 对账 / 核对 | 核对≠对账;触发方不同 | [reconcile-linkage.md](./reconcile-linkage.md)、[车辆氢费 verify-reconcile](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) |\n| 重复键 | 与 Web 共用 Bridge | [Web 数据模型](../oneos-web-h2-station/.spec/record-data-model.md) |\n| 羚牛车辆标签 | 本地系统车辆集合判定 | [fleet-plate-tag.md](./fleet-plate-tag.md) |\n| 总额 | 单价×加氢量自动算,界面不展示公式 | [feature-create.md](./feature-create.md) |\n| 手工台账 | 按站+自然日上传图片;月历标已传/未传可补传;未上传今日禁新增 | [feature-manual-ledger.md](./feature-manual-ledger.md) |\n\n---\n\n## 4. 页面与标注对照\n\n| 页面 pageId | 主要能力 |\n|---|---|\n| `list` | 站点头、KPI、订单卡片、底栏新增 |\n| `create` | 时间选择、站名、车牌键盘、面板 OCR、金额字段 |\n| `detail` | 详情字段、锁定说明、编辑/删除 |\n\n标注数据:`annotation-source.json`(由 `scripts/build-annotation-source.mjs` 从本目录 Markdown 生成)。\n",
|
||||||
"markdownPath": ".spec/requirements-prd.md"
|
"markdownPath": ".spec/requirements-prd.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -330,7 +396,7 @@
|
|||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
"id": "h5-doc-create",
|
"id": "h5-doc-create",
|
||||||
"title": "新增加氢记录",
|
"title": "新增加氢记录",
|
||||||
"markdown": "# 功能:新增加氢记录\n\n## 目标\n\n站端通过手机浏览器上报一笔加氢记录:拍照识别 + 可校正,提交写入共享氢费 Store。\n\n## 表单分区\n\n### 1. 基础信息\n\n| 字段 | 规则 |\n|------|------|\n| 加氢时间 * | 默认=点击「新增」时的当前时间;点击底部弹层选日期+时+分 |\n| 加氢站名称 | 只读,登录本站名称 |\n\n### 2. 车牌识别\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄车牌」;相册/相机;OCR 模拟回填车牌 |\n| 车牌号 * | 占位「请拍摄或输入车牌号」;点击弹出车牌键盘 |\n| 车辆标签 | 有车牌后:系统车辆列表命中→「羚牛车辆」,否则→「非羚牛车辆」 |\n| 里程(km) | 选填 |\n\n### 3. 加氢机面板\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄清晰的加氢机照片」;OCR 模拟回填单价、量、总额 |\n| 氢气单价 * | 可改;变更后重算总额 |\n| 加氢量 * | 可改;变更后重算总额 |\n| 加氢总额(元) | 只读;= 单价 × 加氢量(两位小数);界面不展示公式文案 |\n\n## 提交与拦截\n\n- 必填齐全方可提交 \n- 同站 + 同车牌 + 同加氢时间 → 拦截重复 \n- 有填写内容点返回 → 二次确认防丢失 \n- 已核对或已对账记录不可从此入口编辑(列表进详情后按钮隐藏) \n\n## 验收\n\n1. 时间默认正确且可选到分 \n2. 车牌键盘可用;标签随车牌变化 \n3. 改单价或量后总额自动更新且不可手改 \n4. 空表单可直接返回;有内容返回需确认 \n",
|
"markdown": "# 功能:新增加氢记录\n\n## 目标\n\n站端通过手机浏览器上报一笔加氢记录:拍照识别 + 可校正,提交写入共享氢费 Store。\n\n## 表单分区\n\n### 1. 基础信息\n\n| 字段 | 规则 |\n|------|------|\n| 加氢时间 * | 默认=点击「新增」时的当前时间;点击底部弹层选日期+时+分 |\n| 加氢站名称 | 只读,登录本站名称 |\n\n### 2. 车牌识别\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄车牌」;相册/相机;选图后自动叠加**时间、地点**水印;OCR 模拟回填车牌 |\n| 车牌号 * | 占位「请拍摄或输入车牌号」;点击弹出车牌键盘 |\n| 车辆标签 | 有车牌后:系统车辆列表命中→「羚牛车辆」,否则→「非羚牛车辆」 |\n| 里程(km) | 选填;支持**从车机获取**(原型 Mock);识别车牌后可自动回填 |\n\n### 3. 加氢机面板\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄清晰的加氢机照片」;选图后叠加**时间、地点**水印;OCR 模拟回填单价、量、总额 |\n| OCR 参考 | 展示站点信息维护的加氢机品牌/型号线索(见站点信息「加氢机品牌管理」) |\n| 氢气单价 * | 可改;变更后重算总额 |\n| 加氢量 * | 可改;变更后重算总额 |\n| 加氢总额(元) | 只读;= 单价 × 加氢量(两位小数);界面不展示公式文案 |\n\n## 提交与拦截\n\n- 必填齐全方可提交 \n- 同站 + 同车牌 + 同加氢时间 → 拦截重复 \n- 有填写内容点返回 → 二次确认防丢失 \n- 已核对或已对账记录不可从此入口编辑(列表进详情后按钮隐藏) \n\n## 验收\n\n1. 时间默认正确且可选到分 \n2. 车牌键盘可用;标签随车牌变化 \n3. 车牌/面板照片右下角可见时间与地点水印 \n4. 可从车机获取里程(有车牌时) \n5. 改单价或量后总额自动更新且不可手改 \n6. 空表单可直接返回;有内容返回需确认 \n",
|
||||||
"markdownPath": ".spec/feature-create.md"
|
"markdownPath": ".spec/feature-create.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -344,7 +410,7 @@
|
|||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
"id": "h5-doc-detail",
|
"id": "h5-doc-detail",
|
||||||
"title": "详情编辑删除",
|
"title": "详情编辑删除",
|
||||||
"markdown": "# 功能:详情、编辑与删除\n\n## 目标\n\n查看单笔加氢记录完整字段;在「未核对且未对账」时可编辑或删除。\n\n## 详情展示\n\n- 顶部:车牌、加氢时间、核对标签、对账标签 \n- 指标条:加氢总额、加氢量 \n- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 \n\n## 锁定规则\n\n| 条件 | 结果 |\n|------|------|\n| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |\n| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |\n| 未核对且未对账 | 可编辑、可删除 |\n\n判定函数:`isOrderLocked`(核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单,预填当前值;提交走更新逻辑(仍校验重复键)。\n\n## 删除\n\n二次确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 未核对未对账:底部有删除/编辑 \n2. 已核对或已对账:无操作栏,有锁定说明 \n3. 详情含对账信息;列表卡片不含对账 \n",
|
"markdown": "# 功能:详情、编辑与删除\n\n## 目标\n\n查看单笔加氢记录完整字段;在「未核对且未对账」时可编辑或删除。\n\n## 详情展示\n\n- 顶部:车牌、加氢时间、核对标签、对账标签 \n- 指标条:加氢总额、加氢量 \n- **识别原图**:车牌照片、加氢机面板照片(均为拍摄时叠加时间/地点水印后的原图;种子数据无实拍时用演示水印图) \n- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 \n\n## 锁定规则\n\n| 条件 | 结果 |\n|------|------|\n| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |\n| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |\n| 未核对且未对账 | 可编辑、可删除 |\n\n判定函数:`isOrderLocked`(核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单,预填当前值(含已存水印照片);提交走更新逻辑(仍校验重复键)。\n\n## 删除\n\n二次确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 未核对未对账:底部有删除/编辑 \n2. 已核对或已对账:无操作栏,有锁定说明 \n3. 详情含对账信息;列表卡片不含对账 \n4. 详情展示车牌与面板带水印原图(新上报提交后为实拍;种子行为演示图) \n",
|
||||||
"markdownPath": ".spec/feature-detail.md"
|
"markdownPath": ".spec/feature-detail.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -411,7 +477,7 @@
|
|||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
"id": "h5-page-create-md",
|
"id": "h5-page-create-md",
|
||||||
"title": "新增页要点",
|
"title": "新增页要点",
|
||||||
"markdown": "# 功能:新增加氢记录\n\n## 目标\n\n站端通过手机浏览器上报一笔加氢记录:拍照识别 + 可校正,提交写入共享氢费 Store。\n\n## 表单分区\n\n### 1. 基础信息\n\n| 字段 | 规则 |\n|------|------|\n| 加氢时间 * | 默认=点击「新增」时的当前时间;点击底部弹层选日期+时+分 |\n| 加氢站名称 | 只读,登录本站名称 |\n\n### 2. 车牌识别\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄车牌」;相册/相机;OCR 模拟回填车牌 |\n| 车牌号 * | 占位「请拍摄或输入车牌号」;点击弹出车牌键盘 |\n| 车辆标签 | 有车牌后:系统车辆列表命中→「羚牛车辆」,否则→「非羚牛车辆」 |\n| 里程(km) | 选填 |\n\n### 3. 加氢机面板\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄清晰的加氢机照片」;OCR 模拟回填单价、量、总额 |\n| 氢气单价 * | 可改;变更后重算总额 |\n| 加氢量 * | 可改;变更后重算总额 |\n| 加氢总额(元) | 只读;= 单价 × 加氢量(两位小数);界面不展示公式文案 |\n\n## 提交与拦截\n\n- 必填齐全方可提交 \n- 同站 + 同车牌 + 同加氢时间 → 拦截重复 \n- 有填写内容点返回 → 二次确认防丢失 \n- 已核对或已对账记录不可从此入口编辑(列表进详情后按钮隐藏) \n\n## 验收\n\n1. 时间默认正确且可选到分 \n2. 车牌键盘可用;标签随车牌变化 \n3. 改单价或量后总额自动更新且不可手改 \n4. 空表单可直接返回;有内容返回需确认 \n"
|
"markdown": "# 功能:新增加氢记录\n\n## 目标\n\n站端通过手机浏览器上报一笔加氢记录:拍照识别 + 可校正,提交写入共享氢费 Store。\n\n## 表单分区\n\n### 1. 基础信息\n\n| 字段 | 规则 |\n|------|------|\n| 加氢时间 * | 默认=点击「新增」时的当前时间;点击底部弹层选日期+时+分 |\n| 加氢站名称 | 只读,登录本站名称 |\n\n### 2. 车牌识别\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄车牌」;相册/相机;选图后自动叠加**时间、地点**水印;OCR 模拟回填车牌 |\n| 车牌号 * | 占位「请拍摄或输入车牌号」;点击弹出车牌键盘 |\n| 车辆标签 | 有车牌后:系统车辆列表命中→「羚牛车辆」,否则→「非羚牛车辆」 |\n| 里程(km) | 选填;支持**从车机获取**(原型 Mock);识别车牌后可自动回填 |\n\n### 3. 加氢机面板\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄清晰的加氢机照片」;选图后叠加**时间、地点**水印;OCR 模拟回填单价、量、总额 |\n| OCR 参考 | 展示站点信息维护的加氢机品牌/型号线索(见站点信息「加氢机品牌管理」) |\n| 氢气单价 * | 可改;变更后重算总额 |\n| 加氢量 * | 可改;变更后重算总额 |\n| 加氢总额(元) | 只读;= 单价 × 加氢量(两位小数);界面不展示公式文案 |\n\n## 提交与拦截\n\n- 必填齐全方可提交 \n- 同站 + 同车牌 + 同加氢时间 → 拦截重复 \n- 有填写内容点返回 → 二次确认防丢失 \n- 已核对或已对账记录不可从此入口编辑(列表进详情后按钮隐藏) \n\n## 验收\n\n1. 时间默认正确且可选到分 \n2. 车牌键盘可用;标签随车牌变化 \n3. 车牌/面板照片右下角可见时间与地点水印 \n4. 可从车机获取里程(有车牌时) \n5. 改单价或量后总额自动更新且不可手改 \n6. 空表单可直接返回;有内容返回需确认 \n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -434,7 +500,7 @@
|
|||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
"id": "h5-page-detail-md",
|
"id": "h5-page-detail-md",
|
||||||
"title": "详情页要点",
|
"title": "详情页要点",
|
||||||
"markdown": "# 功能:详情、编辑与删除\n\n## 目标\n\n查看单笔加氢记录完整字段;在「未核对且未对账」时可编辑或删除。\n\n## 详情展示\n\n- 顶部:车牌、加氢时间、核对标签、对账标签 \n- 指标条:加氢总额、加氢量 \n- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 \n\n## 锁定规则\n\n| 条件 | 结果 |\n|------|------|\n| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |\n| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |\n| 未核对且未对账 | 可编辑、可删除 |\n\n判定函数:`isOrderLocked`(核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单,预填当前值;提交走更新逻辑(仍校验重复键)。\n\n## 删除\n\n二次确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 未核对未对账:底部有删除/编辑 \n2. 已核对或已对账:无操作栏,有锁定说明 \n3. 详情含对账信息;列表卡片不含对账 \n"
|
"markdown": "# 功能:详情、编辑与删除\n\n## 目标\n\n查看单笔加氢记录完整字段;在「未核对且未对账」时可编辑或删除。\n\n## 详情展示\n\n- 顶部:车牌、加氢时间、核对标签、对账标签 \n- 指标条:加氢总额、加氢量 \n- **识别原图**:车牌照片、加氢机面板照片(均为拍摄时叠加时间/地点水印后的原图;种子数据无实拍时用演示水印图) \n- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 \n\n## 锁定规则\n\n| 条件 | 结果 |\n|------|------|\n| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |\n| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |\n| 未核对且未对账 | 可编辑、可删除 |\n\n判定函数:`isOrderLocked`(核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单,预填当前值(含已存水印照片);提交走更新逻辑(仍校验重复键)。\n\n## 删除\n\n二次确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 未核对未对账:底部有删除/编辑 \n2. 已核对或已对账:无操作栏,有锁定说明 \n3. 详情含对账信息;列表卡片不含对账 \n4. 详情展示车牌与面板带水印原图(新上报提交后为实拍;种子行为演示图) \n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React, { useMemo, useRef, useState } from 'react';
|
|||||||
import type { CreateDraft } from '../types';
|
import type { CreateDraft } from '../types';
|
||||||
import { getVehicleTag } from '../utils/fleet-plates';
|
import { getVehicleTag } from '../utils/fleet-plates';
|
||||||
import { delay, mockPanelOcr, mockPlateOcr, readFileAsDataUrl } from '../utils/ocr-mock';
|
import { delay, mockPanelOcr, mockPlateOcr, readFileAsDataUrl } from '../utils/ocr-mock';
|
||||||
|
import { applyPhotoWatermark, formatWatermarkTime } from '../utils/photo-watermark';
|
||||||
|
import { fetchMileageFromVehicle } from '../utils/vehicle-mileage';
|
||||||
import { DateTimePickerSheet } from './DateTimePickerSheet';
|
import { DateTimePickerSheet } from './DateTimePickerSheet';
|
||||||
import {
|
import {
|
||||||
IconCamera,
|
IconCamera,
|
||||||
@@ -14,6 +16,17 @@ import {
|
|||||||
import { PhoneShell } from './PhoneShell';
|
import { PhoneShell } from './PhoneShell';
|
||||||
import { PlateKeyboardSheet } from './PlateKeyboardSheet';
|
import { PlateKeyboardSheet } from './PlateKeyboardSheet';
|
||||||
|
|
||||||
|
function listDispenserBrandHints(): string[] {
|
||||||
|
const store = (window as unknown as { H2DispenserBrandStore?: { labels?: () => string[] } })
|
||||||
|
.H2DispenserBrandStore;
|
||||||
|
if (!store || typeof store.labels !== 'function') return [];
|
||||||
|
try {
|
||||||
|
return store.labels().slice(0, 4);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
draft: CreateDraft;
|
draft: CreateDraft;
|
||||||
@@ -88,10 +101,13 @@ export function CreateWizard({
|
|||||||
const panelInputRef = useRef<HTMLInputElement>(null);
|
const panelInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [recognizing, setRecognizing] = useState<'plate' | 'panel' | null>(null);
|
const [recognizing, setRecognizing] = useState<'plate' | 'panel' | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [fetchingMileage, setFetchingMileage] = useState(false);
|
||||||
|
const [mileageHint, setMileageHint] = useState('');
|
||||||
const [timeOpen, setTimeOpen] = useState(false);
|
const [timeOpen, setTimeOpen] = useState(false);
|
||||||
const [plateOpen, setPlateOpen] = useState(false);
|
const [plateOpen, setPlateOpen] = useState(false);
|
||||||
|
|
||||||
const vehicleTag = useMemo(() => getVehicleTag(draft.plateNo), [draft.plateNo]);
|
const vehicleTag = useMemo(() => getVehicleTag(draft.plateNo), [draft.plateNo]);
|
||||||
|
const brandHints = listDispenserBrandHints();
|
||||||
|
|
||||||
function patchDraft(patch: Partial<CreateDraft>) {
|
function patchDraft(patch: Partial<CreateDraft>) {
|
||||||
const nextPrice = patch.unitPrice !== undefined ? patch.unitPrice : draft.unitPrice;
|
const nextPrice = patch.unitPrice !== undefined ? patch.unitPrice : draft.unitPrice;
|
||||||
@@ -102,14 +118,42 @@ export function CreateWizard({
|
|||||||
onChangeDraft(patch);
|
onChangeDraft(patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function watermarkPreview(rawUrl: string): Promise<string> {
|
||||||
|
return applyPhotoWatermark(rawUrl, {
|
||||||
|
timeText: formatWatermarkTime(),
|
||||||
|
locationText: stationName || '加氢站',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullMileageFromVehicle(plateNo: string, autoFill: boolean) {
|
||||||
|
setFetchingMileage(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchMileageFromVehicle(plateNo);
|
||||||
|
setMileageHint(result.message);
|
||||||
|
if (result.ok && (autoFill || !draft.mileageKm.trim())) {
|
||||||
|
onChangeDraft({ mileageKm: result.mileageKm });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
setFetchingMileage(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePlateFile(file: File | null) {
|
async function handlePlateFile(file: File | null) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setRecognizing('plate');
|
setRecognizing('plate');
|
||||||
try {
|
try {
|
||||||
const url = await readFileAsDataUrl(file);
|
const rawUrl = await readFileAsDataUrl(file);
|
||||||
|
const url = await watermarkPreview(rawUrl);
|
||||||
await delay(900);
|
await delay(900);
|
||||||
const ocr = mockPlateOcr();
|
const ocr = mockPlateOcr();
|
||||||
onChangeDraft({ platePreviewUrl: url, plateNo: ocr.plateNo });
|
const mileage = await fetchMileageFromVehicle(ocr.plateNo);
|
||||||
|
setMileageHint(mileage.message);
|
||||||
|
onChangeDraft({
|
||||||
|
platePreviewUrl: url,
|
||||||
|
plateNo: ocr.plateNo,
|
||||||
|
...(mileage.ok ? { mileageKm: mileage.mileageKm } : {}),
|
||||||
|
});
|
||||||
if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate(10);
|
if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate(10);
|
||||||
} finally {
|
} finally {
|
||||||
setRecognizing(null);
|
setRecognizing(null);
|
||||||
@@ -120,7 +164,8 @@ export function CreateWizard({
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
setRecognizing('panel');
|
setRecognizing('panel');
|
||||||
try {
|
try {
|
||||||
const url = await readFileAsDataUrl(file);
|
const rawUrl = await readFileAsDataUrl(file);
|
||||||
|
const url = await watermarkPreview(rawUrl);
|
||||||
await delay(900);
|
await delay(900);
|
||||||
const ocr = mockPanelOcr();
|
const ocr = mockPanelOcr();
|
||||||
onChangeDraft({
|
onChangeDraft({
|
||||||
@@ -290,16 +335,38 @@ export function CreateWizard({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h5-field">
|
<div className="h5-field" data-annotation-id="h5-mileage">
|
||||||
<label htmlFor="h5-mileage">里程(km)</label>
|
<label htmlFor="h5-mileage">里程(km)</label>
|
||||||
<input
|
<div className="h5-mileage-row">
|
||||||
id="h5-mileage"
|
<input
|
||||||
inputMode="numeric"
|
id="h5-mileage"
|
||||||
value={draft.mileageKm}
|
inputMode="numeric"
|
||||||
onChange={(e) => onChangeDraft({ mileageKm: e.target.value })}
|
value={draft.mileageKm}
|
||||||
placeholder="请输入里程"
|
onChange={(e) => {
|
||||||
autoComplete="off"
|
setMileageHint('');
|
||||||
/>
|
onChangeDraft({ mileageKm: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="可从车机获取或手工填写"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h5-btn h5-btn--ghost h5-mileage-fetch"
|
||||||
|
disabled={fetchingMileage || recognizing === 'plate'}
|
||||||
|
onClick={() => void pullMileageFromVehicle(draft.plateNo, true)}
|
||||||
|
aria-label="从车机获取里程"
|
||||||
|
>
|
||||||
|
{fetchingMileage ? <IconSpinner size={16} /> : null}
|
||||||
|
{fetchingMileage ? '获取中…' : '从车机获取'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{mileageHint ? (
|
||||||
|
<p className="h5-field-hint" role="status">
|
||||||
|
{mileageHint}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="h5-field-hint">识别车牌后可自动取数;也可点「从车机获取」(原型 Mock)。</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -313,6 +380,12 @@ export function CreateWizard({
|
|||||||
<p>请拍摄清晰的加氢机照片</p>
|
<p>请拍摄清晰的加氢机照片</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{brandHints.length ? (
|
||||||
|
<p className="h5-brand-hint" data-annotation-id="h5-dispenser-brands">
|
||||||
|
OCR 参考品牌型号(站点信息维护):{brandHints.join('、')}
|
||||||
|
{brandHints.length >= 4 ? '…' : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div
|
<div
|
||||||
className={`h5-shot h5-shot--compact${draft.panelPreviewUrl ? ' h5-shot--filled' : ''}`}
|
className={`h5-shot h5-shot--compact${draft.panelPreviewUrl ? ' h5-shot--filled' : ''}`}
|
||||||
role="button"
|
role="button"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import type { H5OrderRecord } from '../types';
|
import type { H5OrderRecord } from '../types';
|
||||||
|
import { buildDemoWatermarkedPhoto } from '../utils/demo-photo';
|
||||||
import { formatDateTimeMinute, formatKg, formatMileage, formatMoney, formatPlateDisplay } from '../utils/format';
|
import { formatDateTimeMinute, formatKg, formatMileage, formatMoney, formatPlateDisplay } from '../utils/format';
|
||||||
import { IconCheck, IconChevronLeft, IconClock } from './Icons';
|
import { IconCheck, IconChevronLeft, IconClock } from './Icons';
|
||||||
import { PhoneShell } from './PhoneShell';
|
import { PhoneShell } from './PhoneShell';
|
||||||
@@ -16,6 +17,50 @@ export function OrderDetail({ order, onBack, onEdit, onDelete }: Props) {
|
|||||||
const reconciled = order.reconcileStatus === 'reconciled';
|
const reconciled = order.reconcileStatus === 'reconciled';
|
||||||
const verified = order.verifyStatus === 'verified';
|
const verified = order.verifyStatus === 'verified';
|
||||||
const locked = isOrderLocked(order);
|
const locked = isOrderLocked(order);
|
||||||
|
const [plateSrc, setPlateSrc] = useState(order.platePhotoUrl || '');
|
||||||
|
const [panelSrc, setPanelSrc] = useState(order.panelPhotoUrl || '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setPlateSrc(order.platePhotoUrl || '');
|
||||||
|
setPanelSrc(order.panelPhotoUrl || '');
|
||||||
|
|
||||||
|
async function ensureDemoPhotos() {
|
||||||
|
if (!order.platePhotoUrl) {
|
||||||
|
const url = await buildDemoWatermarkedPhoto({
|
||||||
|
kind: 'plate',
|
||||||
|
plateNo: order.plateNo,
|
||||||
|
stationName: order.stationName,
|
||||||
|
hydrogenTime: order.hydrogenTime,
|
||||||
|
});
|
||||||
|
if (!cancelled && url) setPlateSrc(url);
|
||||||
|
}
|
||||||
|
if (!order.panelPhotoUrl) {
|
||||||
|
const url = await buildDemoWatermarkedPhoto({
|
||||||
|
kind: 'panel',
|
||||||
|
plateNo: order.plateNo,
|
||||||
|
stationName: order.stationName,
|
||||||
|
hydrogenTime: order.hydrogenTime,
|
||||||
|
});
|
||||||
|
if (!cancelled && url) setPanelSrc(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order.platePhotoUrl || !order.panelPhotoUrl) {
|
||||||
|
void ensureDemoPhotos();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
order.id,
|
||||||
|
order.platePhotoUrl,
|
||||||
|
order.panelPhotoUrl,
|
||||||
|
order.plateNo,
|
||||||
|
order.stationName,
|
||||||
|
order.hydrogenTime,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PhoneShell
|
<PhoneShell
|
||||||
@@ -71,6 +116,30 @@ export function OrderDetail({ order, onBack, onEdit, onDelete }: Props) {
|
|||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="h5-detail-photos" data-annotation-id="h5-detail-photos">
|
||||||
|
<div className="h5-detail-photo">
|
||||||
|
<div className="h5-detail-photo__label">车牌识别原图(含水印)</div>
|
||||||
|
{plateSrc ? (
|
||||||
|
<img src={plateSrc} alt="车牌识别原图(含水印)" />
|
||||||
|
) : (
|
||||||
|
<div className="h5-detail-photo__empty">暂无车牌照片</div>
|
||||||
|
)}
|
||||||
|
{!order.platePhotoUrl && plateSrc ? (
|
||||||
|
<p className="h5-detail-photo__hint">种子演示图;新上报提交后展示实拍水印照</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="h5-detail-photo">
|
||||||
|
<div className="h5-detail-photo__label">加氢机面板原图(含水印)</div>
|
||||||
|
{panelSrc ? (
|
||||||
|
<img src={panelSrc} alt="加氢机面板原图(含水印)" />
|
||||||
|
) : (
|
||||||
|
<div className="h5-detail-photo__empty">暂无面板照片</div>
|
||||||
|
)}
|
||||||
|
{!order.panelPhotoUrl && panelSrc ? (
|
||||||
|
<p className="h5-detail-photo__hint">种子演示图;新上报提交后展示实拍水印照</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="h5-detail-row">
|
<div className="h5-detail-row">
|
||||||
<span>加氢站名称</span>
|
<span>加氢站名称</span>
|
||||||
<strong>{order.stationName}</strong>
|
<strong>{order.stationName}</strong>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* 加氢站手机浏览器 H5:本站加氢记录列表、OCR 新增、与站点对账单联动
|
* 加氢站手机浏览器 H5:本站加氢记录列表、OCR 新增、与站点对账单联动
|
||||||
*/
|
*/
|
||||||
import '../../common/h2VehicleLedgerBridge.js';
|
import '../../common/h2VehicleLedgerBridge.js';
|
||||||
|
import '../../common/h2DispenserBrandStore.js';
|
||||||
import './styles/index.css';
|
import './styles/index.css';
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
@@ -118,6 +119,8 @@ export default function OneosH5H2Order() {
|
|||||||
unitPrice: String(order.unitPrice),
|
unitPrice: String(order.unitPrice),
|
||||||
hydrogenKg: String(order.hydrogenKg),
|
hydrogenKg: String(order.hydrogenKg),
|
||||||
totalAmount: String(order.totalAmount),
|
totalAmount: String(order.totalAmount),
|
||||||
|
platePreviewUrl: order.platePhotoUrl || '',
|
||||||
|
panelPreviewUrl: order.panelPhotoUrl || '',
|
||||||
});
|
});
|
||||||
setView({
|
setView({
|
||||||
name: 'create',
|
name: 'create',
|
||||||
@@ -175,6 +178,8 @@ export default function OneosH5H2Order() {
|
|||||||
unitPrice,
|
unitPrice,
|
||||||
hydrogenKg,
|
hydrogenKg,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
|
platePhotoUrl: draft.platePreviewUrl || '',
|
||||||
|
panelPhotoUrl: draft.panelPreviewUrl || '',
|
||||||
});
|
});
|
||||||
if (!saved) {
|
if (!saved) {
|
||||||
showToast('保存失败,请刷新后重试');
|
showToast('保存失败,请刷新后重试');
|
||||||
|
|||||||
@@ -130,8 +130,32 @@ const source = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
node({
|
node({
|
||||||
id: 'h5-price',
|
id: 'h5-mileage',
|
||||||
index: 8,
|
index: 8,
|
||||||
|
title: '里程',
|
||||||
|
pageId: 'create',
|
||||||
|
color: '#0E8A7B',
|
||||||
|
locator: {
|
||||||
|
selectors: ["[data-annotation-id='h5-mileage']", '#h5-mileage'],
|
||||||
|
fingerprint: 'field|h5-mileage',
|
||||||
|
path: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
node({
|
||||||
|
id: 'h5-dispenser-brands',
|
||||||
|
index: 9,
|
||||||
|
title: '加氢机品牌参考',
|
||||||
|
pageId: 'create',
|
||||||
|
color: '#7C3AED',
|
||||||
|
locator: {
|
||||||
|
selectors: ["[data-annotation-id='h5-dispenser-brands']", '.h5-brand-hint'],
|
||||||
|
fingerprint: 'hint|dispenser-brands',
|
||||||
|
path: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
node({
|
||||||
|
id: 'h5-price',
|
||||||
|
index: 10,
|
||||||
title: '氢气单价',
|
title: '氢气单价',
|
||||||
pageId: 'create',
|
pageId: 'create',
|
||||||
color: '#7AB929',
|
color: '#7AB929',
|
||||||
@@ -143,7 +167,7 @@ const source = {
|
|||||||
}),
|
}),
|
||||||
node({
|
node({
|
||||||
id: 'h5-kg',
|
id: 'h5-kg',
|
||||||
index: 9,
|
index: 11,
|
||||||
title: '加氢量',
|
title: '加氢量',
|
||||||
pageId: 'create',
|
pageId: 'create',
|
||||||
color: '#7AB929',
|
color: '#7AB929',
|
||||||
@@ -155,7 +179,7 @@ const source = {
|
|||||||
}),
|
}),
|
||||||
node({
|
node({
|
||||||
id: 'h5-total-label',
|
id: 'h5-total-label',
|
||||||
index: 10,
|
index: 12,
|
||||||
title: '加氢总额',
|
title: '加氢总额',
|
||||||
pageId: 'create',
|
pageId: 'create',
|
||||||
color: '#0E8A7B',
|
color: '#0E8A7B',
|
||||||
@@ -167,7 +191,7 @@ const source = {
|
|||||||
}),
|
}),
|
||||||
node({
|
node({
|
||||||
id: 'h5-detail',
|
id: 'h5-detail',
|
||||||
index: 11,
|
index: 13,
|
||||||
title: '记录详情',
|
title: '记录详情',
|
||||||
pageId: 'detail',
|
pageId: 'detail',
|
||||||
color: '#FF7D00',
|
color: '#FF7D00',
|
||||||
@@ -177,9 +201,21 @@ const source = {
|
|||||||
path: [],
|
path: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
node({
|
||||||
|
id: 'h5-detail-photos',
|
||||||
|
index: 14,
|
||||||
|
title: '识别原图',
|
||||||
|
pageId: 'detail',
|
||||||
|
color: '#0E8A7B',
|
||||||
|
locator: {
|
||||||
|
selectors: ["[data-annotation-id='h5-detail-photos']", '.h5-detail-photos'],
|
||||||
|
fingerprint: 'detail|photos',
|
||||||
|
path: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
node({
|
node({
|
||||||
id: 'h5-manual',
|
id: 'h5-manual',
|
||||||
index: 12,
|
index: 15,
|
||||||
title: '手工台账',
|
title: '手工台账',
|
||||||
pageId: 'manual',
|
pageId: 'manual',
|
||||||
color: '#7AB929',
|
color: '#7AB929',
|
||||||
@@ -191,7 +227,7 @@ const source = {
|
|||||||
}),
|
}),
|
||||||
node({
|
node({
|
||||||
id: 'h5-manual-calendar',
|
id: 'h5-manual-calendar',
|
||||||
index: 13,
|
index: 16,
|
||||||
title: '台账日历',
|
title: '台账日历',
|
||||||
pageId: 'manual',
|
pageId: 'manual',
|
||||||
color: '#0E8A7B',
|
color: '#0E8A7B',
|
||||||
@@ -216,6 +252,10 @@ const source = {
|
|||||||
'h5-station':
|
'h5-station':
|
||||||
'## 加氢站名称\n\n只读。默认显示当前登录账号对应加氢站名称,不可切换。',
|
'## 加氢站名称\n\n只读。默认显示当前登录账号对应加氢站名称,不可切换。',
|
||||||
'h5-plate': mdFleet,
|
'h5-plate': mdFleet,
|
||||||
|
'h5-mileage':
|
||||||
|
'## 里程(km)\n\n选填。支持「从车机获取」(原型 Mock);识别车牌后可自动回填。正式环境对接车机 / T-Box。\n\n详见 [新增加氢记录](feature-create)。',
|
||||||
|
'h5-dispenser-brands':
|
||||||
|
'## 加氢机品牌参考\n\n展示站点信息「加氢机品牌管理」维护的品牌·型号,供面板 OCR 模型判断参考。数据源:`h2DispenserBrandStore`。',
|
||||||
'h5-price':
|
'h5-price':
|
||||||
'## 氢气单价(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额(单价×加氢量)。',
|
'## 氢气单价(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额(单价×加氢量)。',
|
||||||
'h5-kg':
|
'h5-kg':
|
||||||
@@ -223,6 +263,8 @@ const source = {
|
|||||||
'h5-total-label':
|
'h5-total-label':
|
||||||
'## 加氢总额(元)\n\n只读展示;由单价×加氢量自动计算(两位小数)。界面标签不展示公式文案。',
|
'## 加氢总额(元)\n\n只读展示;由单价×加氢量自动计算(两位小数)。界面标签不展示公式文案。',
|
||||||
'h5-detail': mdDetail,
|
'h5-detail': mdDetail,
|
||||||
|
'h5-detail-photos':
|
||||||
|
'## 识别原图(含水印)\n\n详情展示车牌识别、加氢机面板的原始照片;照片在拍摄/选图时已叠加时间与地点水印并随记录保存。种子数据无实拍时用演示水印图。\n\n详见 [详情编辑删除](feature-detail)。',
|
||||||
'h5-manual': mdManual,
|
'h5-manual': mdManual,
|
||||||
'h5-manual-calendar':
|
'h5-manual-calendar':
|
||||||
'## 台账日历\n\n自本站首笔加氢记录日起:绿=已上传,橙=未上传;首笔之前无需补传。禁选未来日。点选范围内未传日可补传。仅「今日」是否上传影响新增加氢记录。\n\n详见 [手工台账上传](feature-manual-ledger)。',
|
'## 台账日历\n\n自本站首笔加氢记录日起:绿=已上传,橙=未上传;首笔之前无需补传。禁选未来日。点选范围内未传日可补传。仅「今日」是否上传影响新增加氢记录。\n\n详见 [手工台账上传](feature-manual-ledger)。',
|
||||||
|
|||||||
@@ -1019,6 +1019,59 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h5-mileage-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-mileage-row input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-mileage-fetch {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 108px;
|
||||||
|
height: auto;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(122, 185, 41, 0.45);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--xll-green-deep);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-mileage-fetch:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-field-hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-text-sec);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-brand-hint {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
color: #166534;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.h5-field {
|
.h5-field {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
@@ -1330,6 +1383,54 @@
|
|||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h5-detail-photos {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 4px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-detail-photo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-detail-photo__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-sec);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-detail-photo img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-detail-photo__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 120px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-detail-photo__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
.h5-loading {
|
.h5-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export type H5OrderRecord = {
|
|||||||
verifiedAt: string;
|
verifiedAt: string;
|
||||||
reconcileStatus: ReconcileStatus;
|
reconcileStatus: ReconcileStatus;
|
||||||
reconcileTime: string;
|
reconcileTime: string;
|
||||||
|
/** 车牌识别原图(含水印),无则为空 */
|
||||||
|
platePhotoUrl: string;
|
||||||
|
/** 加氢机面板原图(含水印),无则为空 */
|
||||||
|
panelPhotoUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateDraft = {
|
export type CreateDraft = {
|
||||||
|
|||||||
48
src/prototypes/oneos-h5-h2-order/utils/demo-photo.ts
Normal file
48
src/prototypes/oneos-h5-h2-order/utils/demo-photo.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/** 种子记录无实拍时,生成带水印的演示占位图(原型用) */
|
||||||
|
|
||||||
|
import { applyPhotoWatermark, formatWatermarkTime } from './photo-watermark';
|
||||||
|
|
||||||
|
function makeBaseCanvas(label: string, tone: 'plate' | 'panel'): string {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 720;
|
||||||
|
canvas.height = 405;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return '';
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 720, 405);
|
||||||
|
if (tone === 'plate') {
|
||||||
|
gradient.addColorStop(0, '#1e293b');
|
||||||
|
gradient.addColorStop(1, '#334155');
|
||||||
|
} else {
|
||||||
|
gradient.addColorStop(0, '#064e3b');
|
||||||
|
gradient.addColorStop(1, '#0f766e');
|
||||||
|
}
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, 720, 405);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.92)';
|
||||||
|
ctx.font = '700 36px ui-sans-serif, system-ui, "PingFang SC", sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(label, 360, 190);
|
||||||
|
ctx.font = '500 18px ui-sans-serif, system-ui, "PingFang SC", sans-serif';
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.72)';
|
||||||
|
ctx.fillText('演示原图(含水印)', 360, 232);
|
||||||
|
return canvas.toDataURL('image/jpeg', 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildDemoWatermarkedPhoto(options: {
|
||||||
|
kind: 'plate' | 'panel';
|
||||||
|
plateNo: string;
|
||||||
|
stationName: string;
|
||||||
|
hydrogenTime: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const label =
|
||||||
|
options.kind === 'plate'
|
||||||
|
? `车牌 ${String(options.plateNo || '').replace(/F$/u, '') || '—'}`
|
||||||
|
: '加氢机面板';
|
||||||
|
const base = makeBaseCanvas(label, options.kind);
|
||||||
|
if (!base) return '';
|
||||||
|
const timeText = String(options.hydrogenTime || formatWatermarkTime()).slice(0, 19);
|
||||||
|
return applyPhotoWatermark(base, {
|
||||||
|
timeText,
|
||||||
|
locationText: options.stationName || '加氢站',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -79,6 +79,8 @@ export function mapRowToOrder(row: Record<string, unknown>): H5OrderRecord {
|
|||||||
verifiedAt,
|
verifiedAt,
|
||||||
reconcileStatus,
|
reconcileStatus,
|
||||||
reconcileTime: reconcileStatus === 'reconciled' ? reconcileTime : '',
|
reconcileTime: reconcileStatus === 'reconciled' ? reconcileTime : '',
|
||||||
|
platePhotoUrl: asString(row.platePhotoUrl),
|
||||||
|
panelPhotoUrl: asString(row.panelPhotoUrl),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +129,8 @@ export function upsertStationOrder(input: {
|
|||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
hydrogenKg: number;
|
hydrogenKg: number;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
|
platePhotoUrl?: string;
|
||||||
|
panelPhotoUrl?: string;
|
||||||
}): H5OrderRecord | null {
|
}): H5OrderRecord | null {
|
||||||
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||||
if (!bridge?.upsertRow) return null;
|
if (!bridge?.upsertRow) return null;
|
||||||
@@ -152,6 +156,8 @@ export function upsertStationOrder(input: {
|
|||||||
customerAmount: input.totalAmount,
|
customerAmount: input.totalAmount,
|
||||||
settlementStatus: 'customer',
|
settlementStatus: 'customer',
|
||||||
mileageKm,
|
mileageKm,
|
||||||
|
platePhotoUrl: String(input.platePhotoUrl || ''),
|
||||||
|
panelPhotoUrl: String(input.panelPhotoUrl || ''),
|
||||||
creatorName: '站端账号',
|
creatorName: '站端账号',
|
||||||
reconcileStatus: 'pending',
|
reconcileStatus: 'pending',
|
||||||
verifyStatus: 'unverified',
|
verifyStatus: 'unverified',
|
||||||
|
|||||||
68
src/prototypes/oneos-h5-h2-order/utils/photo-watermark.ts
Normal file
68
src/prototypes/oneos-h5-h2-order/utils/photo-watermark.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/** 拍摄预览加水印:时间 + 地点(原型本地 canvas,未接真相机 SDK) */
|
||||||
|
|
||||||
|
function pad2(n: number): string {
|
||||||
|
return n < 10 ? `0${n}` : String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWatermarkTime(date = new Date()): string {
|
||||||
|
return (
|
||||||
|
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +
|
||||||
|
`${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在图片右下角绘制半透明水印条:第一行时间,第二行地点。
|
||||||
|
*/
|
||||||
|
export function applyPhotoWatermark(
|
||||||
|
dataUrl: string,
|
||||||
|
options: { timeText: string; locationText: string },
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const w = img.naturalWidth || img.width || 1;
|
||||||
|
const h = img.naturalHeight || img.height || 1;
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
resolve(dataUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
|
||||||
|
const fontSize = Math.max(14, Math.round(Math.min(w, h) * 0.032));
|
||||||
|
const lineGap = Math.round(fontSize * 1.35);
|
||||||
|
const padX = Math.round(fontSize * 0.7);
|
||||||
|
const padY = Math.round(fontSize * 0.55);
|
||||||
|
const lines = [
|
||||||
|
`时间 ${options.timeText}`,
|
||||||
|
`地点 ${options.locationText || '位置未知'}`,
|
||||||
|
];
|
||||||
|
ctx.font = `600 ${fontSize}px ui-sans-serif, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif`;
|
||||||
|
const textWidth = Math.max(...lines.map((line) => ctx.measureText(line).width));
|
||||||
|
const boxW = textWidth + padX * 2;
|
||||||
|
const boxH = lineGap * lines.length + padY * 2;
|
||||||
|
const boxX = Math.max(8, w - boxW - Math.round(fontSize * 0.6));
|
||||||
|
const boxY = Math.max(8, h - boxH - Math.round(fontSize * 0.6));
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(15, 23, 42, 0.58)';
|
||||||
|
ctx.fillRect(boxX, boxY, boxW, boxH);
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
ctx.fillText(line, boxX + padX, boxY + padY + index * lineGap);
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(canvas.toDataURL('image/jpeg', 0.92));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => reject(new Error('水印图片加载失败'));
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
61
src/prototypes/oneos-h5-h2-order/utils/vehicle-mileage.ts
Normal file
61
src/prototypes/oneos-h5-h2-order/utils/vehicle-mileage.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 车机里程(原型 Mock)
|
||||||
|
* 正式环境应改为车机 / T-Box / 车辆管理接口;此处按车牌返回演示里程。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MILEAGE_BY_PLATE: Record<string, number> = {
|
||||||
|
浙A88H201: 128560,
|
||||||
|
浙A12345: 132000,
|
||||||
|
浙B23456: 98640,
|
||||||
|
沪ADB9161: 75420,
|
||||||
|
苏E33333: 110280,
|
||||||
|
浙A99880: 87210,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePlate(plateNo: string): string {
|
||||||
|
return String(plateNo || '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/F$/u, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VehicleMileageResult = {
|
||||||
|
ok: boolean;
|
||||||
|
mileageKm: string;
|
||||||
|
source: 'vehicle' | 'fallback';
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 模拟从车机拉取当前里程;无命中时给可演示的回退值 */
|
||||||
|
export async function fetchMileageFromVehicle(plateNo: string): Promise<VehicleMileageResult> {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
window.setTimeout(resolve, 450);
|
||||||
|
});
|
||||||
|
const key = normalizePlate(plateNo);
|
||||||
|
if (!key) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
mileageKm: '',
|
||||||
|
source: 'fallback',
|
||||||
|
message: '请先填写或识别车牌号后再取里程',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (MILEAGE_BY_PLATE[key] != null) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
mileageKm: String(MILEAGE_BY_PLATE[key]),
|
||||||
|
source: 'vehicle',
|
||||||
|
message: '已从车机获取当前里程',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 非种子车牌:用稳定伪值,便于演示「可取数」
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < key.length; i += 1) hash = (hash * 31 + key.charCodeAt(i)) % 100000;
|
||||||
|
const mileage = 60000 + hash;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
mileageKm: String(mileage),
|
||||||
|
source: 'fallback',
|
||||||
|
message: '车机未直连,已按演示规则回填里程',
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"updatedAt": "2026-07-17T03:52:22.620Z",
|
"updatedAt": "2026-07-17T09:37:56.294Z",
|
||||||
"title": "小羚羚",
|
"title": "小羚羚",
|
||||||
"sectionId": "folder-prototypes-xll-miniapp",
|
"sectionId": "folder-prototypes-xll-miniapp",
|
||||||
"description": "氢能车辆运营移动端原型;菜单与小羚羚「小程序」项目目录同步。",
|
"description": "氢能车辆运营移动端原型;菜单与小羚羚「小程序」项目目录同步。",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 加氢机品牌管理
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 模块 | 加氢站管理 → 站点信息 |
|
||||||
|
| 入口 | 列表工具栏 → **加氢机品牌管理** |
|
||||||
|
| 关联 | H5「加氢订单」面板 OCR |
|
||||||
|
| 数据 | `src/common/h2DispenserBrandStore.js`(localStorage 演示,未接真实 API) |
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
维护加氢机**品牌 + 型号**清单,供站端拍照 OCR 时作为识别模型 / 模板判断参考(原型展示为 H5 面板区提示文案)。
|
||||||
|
|
||||||
|
## 字段
|
||||||
|
|
||||||
|
| 字段 | 规则 |
|
||||||
|
|---|---|
|
||||||
|
| 品牌 * | 必填,如「海德利森」 |
|
||||||
|
| 型号 * | 必填,如「H2-D50」;同品牌+型号不可重复 |
|
||||||
|
| 备注 | 选填,如 OCR 模板说明 |
|
||||||
|
| 更新时间 | 系统写入 |
|
||||||
|
|
||||||
|
## 交互
|
||||||
|
|
||||||
|
1. 打开弹窗加载当前清单(含种子数据)。
|
||||||
|
2. 填写品牌/型号/备注 → **新增** 或编辑后 **保存修改**。
|
||||||
|
3. 行操作:编辑、删除(二次确认)。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
1. 站点信息工具栏可见「加氢机品牌管理」。
|
||||||
|
2. 可增删改品牌型号,刷新后仍保留(同浏览器)。
|
||||||
|
3. H5 新增页「加氢机面板识别」展示已维护品牌型号提示(若 Store 已加载)。
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
1. **筛选区** — 名称、签约、地区、营业状态
|
1. **筛选区** — 名称、签约、地区、营业状态
|
||||||
2. **KPI 分类** — 全部 / 签约·普通 / 预付余额预警 / 已欠费 / 无加氢
|
2. **KPI 分类** — 全部 / 签约·普通 / 预付余额预警 / 已欠费 / 无加氢
|
||||||
3. **工具栏** — 全站余额提醒、批量导入、发起充值单、新建站点
|
3. **工具栏** — 全站余额提醒、批量导入、加氢机品牌管理、发起充值单、新建站点
|
||||||
4. **站点列表** — 主数据 + 运营指标 + 操作列
|
4. **站点列表** — 主数据 + 运营指标 + 操作列
|
||||||
5. **分页栏** — 每页 5/10/20/50 条
|
5. **分页栏** — 每页 5/10/20/50 条
|
||||||
|
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 全站余额提醒设置 | 配置全局预付余额预警阈值 |
|
| 全站余额提醒设置 | 配置全局预付余额预警阈值 |
|
||||||
| 批量导入 | 下载模板 → 上传 CSV/Excel → 预览确认导入 |
|
| 批量导入 | 下载模板 → 上传 CSV/Excel → 预览确认导入 |
|
||||||
|
| 加氢机品牌管理 | 维护品牌与型号,供 H5 面板 OCR 参考(见 `.spec/dispenser-brand.md`) |
|
||||||
| 发起充值单 | 为站点发起预付充值流程(原型演示) |
|
| 发起充值单 | 为站点发起预付充值流程(原型演示) |
|
||||||
| 新建站点 | 进入同页内嵌新建视图 |
|
| 新建站点 | 进入同页内嵌新建视图 |
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,51 @@
|
|||||||
# 加氢站管理 · 站点信息 — 对账与结算 PRD
|
# 加氢站管理 · 站点信息 — 对账与结算 PRD
|
||||||
|
|
||||||
> 本模块与「车辆氢费明细」配合完成**站点侧氢费结算闭环**。
|
> 本模块与「车辆氢费明细」配合完成**站点侧氢费结算闭环**。
|
||||||
|
> **重要**:生成对账单时,账期筛选出的明细**必须来自车辆氢费明细中「已核对」的记录**;未核对明细不得进入对账单。完整判定见 [statement-verified-filter.md](./statement-verified-filter.md);台账双状态见车辆氢费明细 `.spec/verify-reconcile.md`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 数据来源
|
## 1. 数据来源
|
||||||
|
|
||||||
对账单明细取自 **车辆氢费明细** 中同时满足以下条件的记录:
|
对账单明细取自 **车辆氢费明细**(共享 Store)中**同时满足**以下条件的记录:
|
||||||
|
|
||||||
1. 对账状态 = **已对账**(台账已完成「完成对账」)
|
| 优先级 | 条件 | 说明 |
|
||||||
2. **尚未**被本站点历史对账单结算过(未绑定 `statementRecordId`)
|
|---|---|---|
|
||||||
3. 加氢时间落在用户选择的 **[账单开始日期, 账单结束日期]** 内
|
| 1 | 未绑定 `statementRecordId` | 尚未被历史对账单结算 |
|
||||||
4. 加氢站名称与当前操作站点一致
|
| 2 | 核对状态 = **已核对**(`verifyStatus = verified`) | **硬约束**:未核对一律不纳入 |
|
||||||
|
| 3 | 对账状态 = **未对账**(`reconcileStatus = pending`) | 已对账由对账单提交产生,不再进入新账单 |
|
||||||
|
| 4 | 加氢站名称 = 当前操作站点 | 仅本站 |
|
||||||
|
| 5 | 加氢时间落在 **[账单开始日期, 账单结束日期]** | 用户所选账期(按日闭区间) |
|
||||||
|
|
||||||
> 对账单明细**仅展示成本字段**(加氢量、成本单价、成本总价等),不展示加氢单价、加氢总价。
|
> 对账单明细**仅展示成本字段**(加氢量、成本单价、成本总价等),不展示加氢单价、加氢总价。
|
||||||
|
|
||||||
|
### 1.1 为何必须「已核对」
|
||||||
|
|
||||||
|
能源部在车辆氢费明细点击「完成核对」后,明细才视为业务已确认。站点侧生成对账单是结算动作,只能拉取已确认数据;未核对记录即使时间落在账期内,也**静默排除**,不出现在对账单明细与统计中。
|
||||||
|
|
||||||
|
```text
|
||||||
|
未核对 ──✕──→ 不可进对账单
|
||||||
|
已核对 + 未对账 ──✓──→ 可进对账单(提交后变为已对账)
|
||||||
|
已核对 + 已对账 ──✕──→ 不可再进下一张对账单
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 两阶段交互
|
## 2. 两阶段交互
|
||||||
|
|
||||||
#### 生成对账单 · 业务逻辑
|
#### 生成对账单 · 业务逻辑
|
||||||
|
|
||||||
1. 选择**账单开始日期**与**账单结束日期**后,从**车辆氢费明细**获取该时间段内、对应当前站点的加氢记录(已对账且尚未结算)。
|
1. 选择**账单开始日期**与**账单结束日期**后,从**车辆氢费明细**获取该时间段内、对应当前站点、且为**已核对且尚未结算(未对账)**的加氢记录。
|
||||||
2. 点击「**生成对账单**」时弹出**二次确认**,提示:生成对账单后,车辆氢费明细中对应记录将自动标记为已对账且无法修改。
|
2. 点击「**生成对账单**」时弹出**二次确认**,提示:生成并提交后,车辆氢费明细中对应记录将标记为**已对账**且无法修改。
|
||||||
3. 确认后进入结算阶段,填写**收票日期**、**收票金额**及**发票附件**(对账日期在提交时由系统自动回写)。
|
3. 确认后进入结算阶段,填写**收票日期**、**收票金额**及**发票附件**(对账日期在提交时由系统自动回写)。
|
||||||
4. 点击「**提交对账单**」后,本批明细在车辆氢费明细中锁定为已对账/已结算状态,不可再修改。
|
4. 点击「**提交对账单**」后,本批明细在车辆氢费明细中锁定为**已对账**并绑定本对账单,不可再进入后续对账单。
|
||||||
|
|
||||||
**阶段一 · 选期生成**
|
**阶段一 · 选期生成**
|
||||||
|
|
||||||
- 日期区下方展示:**上次对账单结束时间**(无历史则「暂无」)
|
- 日期区下方展示:**上次对账单结束时间**(无历史则「暂无」)
|
||||||
- 默认开始日期建议 = 上次结束日 + 1 天
|
- 默认开始日期建议 = 上次结束日 + 1 天
|
||||||
- 点击「生成对账单」时弹出**二次确认**:提示生成后车辆氢费明细对应记录将标记为已对账且无法修改;确认后进入阶段二,日期锁定不可改
|
- 点击「生成对账单」时弹出**二次确认**;确认后进入阶段二,日期锁定不可改
|
||||||
- 无符合条件记录时提示调整日期
|
- 无符合条件记录时提示调整日期(含:账期内仅有未核对、或均已结算等情况)
|
||||||
|
|
||||||
**阶段二 · 结算提交**
|
**阶段二 · 结算提交**
|
||||||
|
|
||||||
@@ -39,7 +53,7 @@
|
|||||||
|
|
||||||
| 指标 | 说明 |
|
| 指标 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 加氢次数 | 本账单包含的已对账记录笔数 |
|
| 加氢次数 | 本账单包含的**已核对**记录笔数 |
|
||||||
| 加氢总量 | kg 合计 |
|
| 加氢总量 | kg 合计 |
|
||||||
| 成本总金额 | 成本总价合计 |
|
| 成本总金额 | 成本总价合计 |
|
||||||
|
|
||||||
@@ -65,12 +79,15 @@
|
|||||||
|
|
||||||
| 车辆氢费明细字段 | 回写值 |
|
| 车辆氢费明细字段 | 回写值 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| 对账状态 | **已对账**(`reconcileStatus = reconciled`) |
|
||||||
| 对账日期 | 操作完成时间 |
|
| 对账日期 | 操作完成时间 |
|
||||||
| 收票日期 | 操作完成日期 |
|
| 收票日期 | 操作完成日期 |
|
||||||
| 加氢站付款状态 | 已付款 |
|
| 加氢站付款状态 | 已付款 |
|
||||||
|
|
||||||
原型通过 `H2_STATION_STATEMENT_LEDGER_UPDATES` / `h2VehicleLedgerBridge` 模拟跨页回写。
|
原型通过 `H2_STATION_STATEMENT_LEDGER_UPDATES` / `h2VehicleLedgerBridge` 模拟跨页回写。
|
||||||
|
|
||||||
|
> 「完成核对」只改变核对状态,**不会**把记录写成已对账;已对账仅由本页对账单提交产生。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 查看对账记录
|
## 4. 查看对账记录
|
||||||
@@ -88,7 +105,8 @@
|
|||||||
| 场景 | 处理 |
|
| 场景 | 处理 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 日期未填 / 开始晚于结束 | 阻止生成 |
|
| 日期未填 / 开始晚于结束 | 阻止生成 |
|
||||||
| 区间内无已对账未结算记录 | 阻止生成,提示调整日期 |
|
| 区间内无「已核对且未对账未结算」记录 | 阻止生成,提示调整日期(或先完成核对) |
|
||||||
|
| 区间内存在未核对记录 | **不纳入**对账单;不单独弹窗枚举(静默排除) |
|
||||||
| 点击「生成对账单」未确认 | 不进入结算阶段 |
|
| 点击「生成对账单」未确认 | 不进入结算阶段 |
|
||||||
| 收票日期/金额/附件未填 | 阻止提交 |
|
| 收票日期/金额/附件未填 | 阻止提交 |
|
||||||
| 收票金额 ≤ 0 | 阻止提交 |
|
| 收票金额 ≤ 0 | 阻止提交 |
|
||||||
@@ -98,8 +116,10 @@
|
|||||||
|
|
||||||
## 6. 研发要点
|
## 6. 研发要点
|
||||||
|
|
||||||
1. **对账单幂等**:同一笔已对账记录只能进入一次成功提交的对账单
|
1. **准入**:`verifyStatus === verified` 为硬条件;勿再用「已对账」作为生成筛选条件
|
||||||
2. **上次结束时间**:取该站点已成功提交对账单的最大 `endDate`
|
2. **对账单幂等**:同一笔明细只能进入一次成功提交的对账单(`statementRecordId` 约束)
|
||||||
3. **余额计算**:`balanceAfter = prepaidBalance - sum(costTotal)`
|
3. **上次结束时间**:取该站点已成功提交对账单的最大 `endDate`
|
||||||
4. **跨模块同步**:生产对接统一台账;原型使用 Bridge Store
|
4. **余额计算**:`balanceAfter = prepaidBalance - sum(costTotal)`
|
||||||
5. **明细字段范围**:对账弹窗不展示客户侧加氢单价/总价
|
5. **跨模块同步**:生产对接统一台账;原型使用 Bridge Store
|
||||||
|
6. **明细字段范围**:对账弹窗不展示客户侧加氢单价/总价
|
||||||
|
7. **规格全文**:[statement-verified-filter.md](./statement-verified-filter.md)
|
||||||
|
|||||||
@@ -138,13 +138,17 @@
|
|||||||
|
|
||||||
## 6. 对账与结算(重点)
|
## 6. 对账与结算(重点)
|
||||||
|
|
||||||
> 详见 `.spec/requirements-prd-statement.md`
|
> 详见 [requirements-prd-statement.md](./requirements-prd-statement.md);纳入规则全文见 [statement-verified-filter.md](./statement-verified-filter.md)
|
||||||
|
|
||||||
与「车辆氢费明细」配合完成站点侧氢费结算闭环:
|
与「车辆氢费明细」配合完成站点侧氢费结算闭环:
|
||||||
|
|
||||||
1. 拉取已对账且未结算的记录
|
| 步骤 | 说明 |
|
||||||
2. 两阶段:选期生成 → 填收票提交
|
|---|---|
|
||||||
3. 提交后更新预付余额、写对账历史、回写台账(对账日期、收票日期、已付款)
|
| 1. 拉取明细 | 按账期从车辆氢费明细取 **已核对** ∧ **未对账** ∧ 未结算 ∧ 本站 的记录;**未核对不纳入** |
|
||||||
|
| 2. 两阶段 | 选期生成 → 填收票提交 |
|
||||||
|
| 3. 提交回写 | 更新预付余额、写对账历史;台账变为 **已对账**(对账日期、收票日期、已付款) |
|
||||||
|
|
||||||
|
> **核对 ≠ 对账**:核对由车辆氢费明细「完成核对」产生;对账仅由本页「提交对账单」回写。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -170,9 +174,9 @@
|
|||||||
|
|
||||||
### 8.2 对账流程
|
### 8.2 对账流程
|
||||||
|
|
||||||
- [ ] 仅已对账未结算记录可进入对账单
|
- [ ] 仅 **已核对** 且未对账、未结算的记录可进入对账单;未核对明细即使在账期内也不出现
|
||||||
- [ ] 两阶段交互与必填校验完整
|
- [ ] 两阶段交互与必填校验完整
|
||||||
- [ ] 提交后回写车辆氢费明细可跨页验收
|
- [ ] 提交后回写车辆氢费明细为 **已对账**,可跨页验收
|
||||||
|
|
||||||
### 8.3 期末余额设置
|
### 8.3 期末余额设置
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# 站点信息 · 生成对账单 — 明细纳入规则(已核对)
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 模块 | 加氢站管理 → 站点信息 → 生成对账单 |
|
||||||
|
| 交互原型 | `/prototypes/oneos-web-h2-station-site` |
|
||||||
|
| 对照台账 | [车辆氢费明细 · 核对 vs 对账](/prototypes/vehicle-h2-fee-ledger)(`.spec/verify-reconcile.md`) |
|
||||||
|
| 实现 | `h2GetAvailableStatementLedgerRows`(`pages/03-站点信息.jsx`) |
|
||||||
|
| 数据源 | `src/common/h2VehicleLedgerBridge.js` 共享 Store(原型本地种子,**未接真实 API**) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 业务目的
|
||||||
|
|
||||||
|
生成对账单时,按账单起止日期从**车辆氢费明细**筛选本站加氢记录。
|
||||||
|
**只纳入已完成「核对」的明细**;**未核对**记录即使加氢时间落在账期内,也**不得**出现在对账单中。
|
||||||
|
|
||||||
|
> 这是跨模块硬约束:未核对数据尚未经能源部确认,不能进入站点结算。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核对 vs 对账(不得混用)
|
||||||
|
|
||||||
|
| 概念 | 字段 | 用户可见 | 谁触发 | 与对账单关系 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **核对** | `verifyStatus` = `verified` / `unverified` | 已核对 / 未核对 | 车辆氢费明细 · **完成核对** | **生成对账单的准入条件** |
|
||||||
|
| **对账** | `reconcileStatus` = `pending` / `reconciled` | 未对账 / 已对账 | 本页 · **提交对账单** 回写 | 提交后变为已对账;已对账不再进入下一张 |
|
||||||
|
|
||||||
|
```text
|
||||||
|
加氢记录上报 → 未核对 + 未对账
|
||||||
|
↓ 车辆氢费明细「完成核对」
|
||||||
|
已核对 + 未对账 ←── 仅此状态可被「生成对账单」拉取
|
||||||
|
↓ 站点「提交对账单」
|
||||||
|
已核对 + 已对账(绑定 statementRecordId)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 判定顺序(须全部满足,命中否即排除)
|
||||||
|
|
||||||
|
按下列优先级过滤;任一不满足则**不纳入**对账单:
|
||||||
|
|
||||||
|
| 优先级 | 条件 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 无 `statementRecordId` | 尚未被历史对账单结算绑定 |
|
||||||
|
| 2 | `verifyStatus === 'verified'`(已核对) | **核心**:未核对一律排除 |
|
||||||
|
| 3 | `reconcileStatus === 'pending'`(未对账) | 已对账不再进入新账单 |
|
||||||
|
| 4 | `stationName` = 当前操作站点 | 仅本站明细 |
|
||||||
|
| 5 | 加氢时间 ∈ **[账单开始日期, 账单结束日期]**(按日闭区间) | 用户所选账期 |
|
||||||
|
|
||||||
|
**前置条件(不进入本筛选):**
|
||||||
|
|
||||||
|
- 未保存草稿(台账 `reconcileStatus = draft`)
|
||||||
|
- 账单起止日期未填或开始晚于结束(交互层先拦截,不调用本筛选)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 用户可见结果
|
||||||
|
|
||||||
|
| 场景 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 账期内仅有未核对明细 | 无可用记录;提示调整日期或先去车辆氢费明细完成核对 |
|
||||||
|
| 账期内有已核对未对账明细 | 进入对账单明细表与统计(次数 / 加氢量 / 成本总金额) |
|
||||||
|
| 同账期内混有已核对与未核对 | **只展示已核对**;未核对静默排除,不计入统计 |
|
||||||
|
| 已核对但已对账 / 已绑定对账单 | 排除 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 与代码映射
|
||||||
|
|
||||||
|
| 规则 | 代码 |
|
||||||
|
|---|---|
|
||||||
|
| 筛选入口 | `h2GetAvailableStatementLedgerRows(store, stationName, startDate, endDate)` |
|
||||||
|
| 已核对 | `verifyStatus === 'verified'`(缺省按 `unverified`) |
|
||||||
|
| 未对账 | `reconcileStatus === 'pending'`(且 ≠ `reconciled`) |
|
||||||
|
| 账期 | `h2ParseDateTimeMs(hydrogenTime)` 落在起止日闭区间 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 验收要点
|
||||||
|
|
||||||
|
- [ ] 未核对明细不会出现在对账单明细表与统计中
|
||||||
|
- [ ] 仅「已核对 ∧ 未对账 ∧ 未结算 ∧ 本站 ∧ 账期内」可生成
|
||||||
|
- [ ] 文案与 PRD 使用「已核对 / 未核对」,不与「已对账 / 未对账」混称
|
||||||
File diff suppressed because one or more lines are too long
@@ -4,9 +4,13 @@
|
|||||||
*/
|
*/
|
||||||
import '../../common/oneosWebLegacy/legacyGlobals';
|
import '../../common/oneosWebLegacy/legacyGlobals';
|
||||||
import '../../common/h2VehicleLedgerBridge.js';
|
import '../../common/h2VehicleLedgerBridge.js';
|
||||||
|
import '../../common/h2DispenserBrandStore.js';
|
||||||
import React, { useEffect, useMemo } from 'react';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
import * as antd from 'antd';
|
import * as antd from 'antd';
|
||||||
import { ConfigProvider } from 'antd';
|
import { ConfigProvider } from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
import 'antd/dist/reset.css';
|
import 'antd/dist/reset.css';
|
||||||
import '../vehicle-management/style.css';
|
import '../vehicle-management/style.css';
|
||||||
import '../lease-contract-management/styles/lease-contract.css';
|
import '../lease-contract-management/styles/lease-contract.css';
|
||||||
@@ -20,6 +24,19 @@ import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
|||||||
import SiteInfoPage from './pages/03-站点信息.jsx';
|
import SiteInfoPage from './pages/03-站点信息.jsx';
|
||||||
import annotationSourceDocument from './annotation-source.json';
|
import annotationSourceDocument from './annotation-source.json';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
React: typeof React;
|
||||||
|
antd: typeof antd;
|
||||||
|
dayjs: typeof dayjs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
window.React = React;
|
||||||
|
window.antd = antd;
|
||||||
|
window.dayjs = dayjs;
|
||||||
|
|
||||||
/** 与租赁合同管理 / vehicle-management 主题一致 */
|
/** 与租赁合同管理 / vehicle-management 主题一致 */
|
||||||
const vmTheme = {
|
const vmTheme = {
|
||||||
token: {
|
token: {
|
||||||
@@ -50,12 +67,16 @@ const vmTheme = {
|
|||||||
InputNumber: {
|
InputNumber: {
|
||||||
handleVisible: true,
|
handleVisible: true,
|
||||||
},
|
},
|
||||||
|
DatePicker: {
|
||||||
|
cellActiveWithRangeBg: 'rgba(50, 160, 110, 0.12)',
|
||||||
|
cellHoverWithRangeBg: 'rgba(50, 160, 110, 0.08)',
|
||||||
|
cellRangeBorderColor: '#32a06e',
|
||||||
|
activeBorderColor: '#32a06e',
|
||||||
|
hoverBorderColor: '#3fb87c',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
window.React = React;
|
|
||||||
window.antd = antd;
|
|
||||||
|
|
||||||
export default function OneosWebH2StationSite() {
|
export default function OneosWebH2StationSite() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearHostPrototypeRouteInfo();
|
clearHostPrototypeRouteInfo();
|
||||||
@@ -74,7 +95,7 @@ export default function OneosWebH2StationSite() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={vmTheme}>
|
<ConfigProvider locale={zhCN} theme={vmTheme}>
|
||||||
<SiteInfoPage />
|
<SiteInfoPage />
|
||||||
<PrototypeAnnotationHost
|
<PrototypeAnnotationHost
|
||||||
source={annotationSourceDocument as AnnotationSourceDocument}
|
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||||
|
|||||||
@@ -493,6 +493,11 @@ var H2_PAGE_STYLE = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([
|
|||||||
'.h2-statement-modal .h2-statement-receipt-amount-input .ant-input { font-variant-numeric: tabular-nums; }',
|
'.h2-statement-modal .h2-statement-receipt-amount-input .ant-input { font-variant-numeric: tabular-nums; }',
|
||||||
'.h2-statement-modal .h2-statement-receipt-amount-input .ant-input-prefix { color: #64748b; font-weight: 600; margin-inline-end: 4px; }',
|
'.h2-statement-modal .h2-statement-receipt-amount-input .ant-input-prefix { color: #64748b; font-weight: 600; margin-inline-end: 4px; }',
|
||||||
'.h2-statement-modal .h2-statement-settlement-wrap .ant-picker { width: 100% !important; border-radius: 8px !important; }',
|
'.h2-statement-modal .h2-statement-settlement-wrap .ant-picker { width: 100% !important; border-radius: 8px !important; }',
|
||||||
|
'.h2-period-end-modal .ant-picker.h2-period-end-datetime-picker { width: 100% !important; height: 32px !important; min-height: 32px !important; border-radius: 8px !important; border: 1px solid #e2e8f0 !important; background: #fff !important; }',
|
||||||
|
'.h2-period-end-modal .ant-picker.h2-period-end-datetime-picker:hover { border-color: #cbd5e1 !important; }',
|
||||||
|
'.h2-period-end-modal .ant-picker.h2-period-end-datetime-picker.ant-picker-focused { border-color: #32a06e !important; box-shadow: 0 0 0 2px rgba(50, 160, 110, 0.2) !important; }',
|
||||||
|
'.h2-period-end-modal .ant-picker.h2-period-end-datetime-picker .ant-picker-input > input { font-size: 13px !important; color: #0f172a !important; }',
|
||||||
|
'.h2-period-end-modal .ant-form-item-label > label { font-size: 13px; font-weight: 600; color: #334155; }',
|
||||||
'.h2-statement-modal .h2-statement-invoice-upload .h2-contract-upload-actions { gap: 0; }',
|
'.h2-statement-modal .h2-statement-invoice-upload .h2-contract-upload-actions { gap: 0; }',
|
||||||
'.h2-statement-modal .h2-statement-invoice-upload .h2-statement-upload-btn.ant-btn { display: inline-flex !important; align-items: center; gap: 6px; height: 32px !important; padding: 0 14px !important; border-radius: 8px !important; font-size: 13px !important; font-weight: 600 !important; border: 1px solid #e2e8f0 !important; color: #475569 !important; background: #fff !important; box-shadow: none !important; }',
|
'.h2-statement-modal .h2-statement-invoice-upload .h2-statement-upload-btn.ant-btn { display: inline-flex !important; align-items: center; gap: 6px; height: 32px !important; padding: 0 14px !important; border-radius: 8px !important; font-size: 13px !important; font-weight: 600 !important; border: 1px solid #e2e8f0 !important; color: #475569 !important; background: #fff !important; box-shadow: none !important; }',
|
||||||
'.h2-statement-modal .h2-statement-invoice-upload .h2-statement-upload-btn.ant-btn:hover { border-color: #10b981 !important; color: #059669 !important; background: #f0fdf4 !important; }',
|
'.h2-statement-modal .h2-statement-invoice-upload .h2-statement-upload-btn.ant-btn:hover { border-color: #10b981 !important; color: #059669 !important; background: #f0fdf4 !important; }',
|
||||||
@@ -4120,7 +4125,7 @@ mindmap
|
|||||||
|------|----------|----------|
|
|------|----------|----------|
|
||||||
| **平台管理员(admin)** | 新建、编辑、批量导入、删除、绑定系统账号 | 系统账号支持多选绑定 |
|
| **平台管理员(admin)** | 新建、编辑、批量导入、删除、绑定系统账号 | 系统账号支持多选绑定 |
|
||||||
| **加氢站运营账号** | 编辑本站资料、营业状态、价格配置 | 编辑时**不能改**供应商与系统账号 |
|
| **加氢站运营账号** | 编辑本站资料、营业状态、价格配置 | 编辑时**不能改**供应商与系统账号 |
|
||||||
| **财务 / 结算人员** | 生成对账单、登记收票、查看对账记录 | 需先在台账完成「已对账」 |
|
| **财务 / 结算人员** | 生成对账单、登记收票、查看对账记录 | 需先在台账完成「已核对」 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -4393,12 +4398,12 @@ flowchart LR
|
|||||||
|
|
||||||
生成对账单前,台账中须已有满足以下**全部**条件的记录:
|
生成对账单前,台账中须已有满足以下**全部**条件的记录:
|
||||||
|
|
||||||
1. 对账状态 = **已对账**(业务员已在台账点击「完成对账」)
|
1. 核对状态 = **已核对**(能源部已在车辆氢费明细点击「完成核对」)
|
||||||
2. **尚未**被本站点历史对账单结算过
|
2. 对账状态 = **未对账**,且**尚未**被本站点历史对账单结算过
|
||||||
3. 加氢时间在所选账单日期区间内
|
3. 加氢时间在所选账单日期区间内
|
||||||
4. 加氢站名称与当前站点一致
|
4. 加氢站名称与当前站点一致
|
||||||
|
|
||||||
> 对账单只展示**成本侧**字段(加氢量、成本单价、成本总价),不展示客户加氢价。
|
> **未核对**明细即使落在账期内也**不会**进入对账单。对账单只展示**成本侧**字段(加氢量、成本单价、成本总价),不展示客户加氢价。
|
||||||
|
|
||||||
### 7.2 生成对账单 — 两阶段操作
|
### 7.2 生成对账单 — 两阶段操作
|
||||||
|
|
||||||
@@ -4461,12 +4466,12 @@ sequenceDiagram
|
|||||||
participant 站点 as 站点信息
|
participant 站点 as 站点信息
|
||||||
participant 台账 as 车辆氢费明细
|
participant 台账 as 车辆氢费明细
|
||||||
|
|
||||||
业务->>台账: 完成对账 → 已对账
|
业务->>台账: 完成核对 → 已核对
|
||||||
财务->>站点: 更多 → 生成对账单
|
财务->>站点: 更多 → 生成对账单
|
||||||
站点->>台账: 拉取已对账且未结算记录
|
站点->>台账: 拉取已核对且未对账未结算记录
|
||||||
财务->>站点: 填写收票信息并提交
|
财务->>站点: 填写收票信息并提交
|
||||||
站点->>站点: 更新预付余额
|
站点->>站点: 更新预付余额
|
||||||
站点->>台账: 回写已付款
|
站点->>台账: 回写已付款(标记已对账)
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
### 7.3 查看对账记录
|
### 7.3 查看对账记录
|
||||||
@@ -4703,12 +4708,12 @@ flowchart TB
|
|||||||
|
|
||||||
对账单明细取自 **车辆氢费明细** 中同时满足以下条件的记录:
|
对账单明细取自 **车辆氢费明细** 中同时满足以下条件的记录:
|
||||||
|
|
||||||
1. 对账状态 = **已对账**(业务侧已在台账完成「完成对账」)
|
1. 核对状态 = **已核对**(能源部已在车辆氢费明细完成「完成核对」)
|
||||||
2. **尚未**被本站点历史对账单结算过(未绑定 statementRecordId)
|
2. 对账状态 = **未对账**,且**尚未**被本站点历史对账单结算过(未绑定 statementRecordId)
|
||||||
3. 加氢时间落在用户选择的 **[账单开始日期, 账单结束日期]** 内
|
3. 加氢时间落在用户选择的 **[账单开始日期, 账单结束日期]** 内
|
||||||
4. 加氢站名称与当前操作站点一致
|
4. 加氢站名称与当前操作站点一致
|
||||||
|
|
||||||
> 对账单明细**仅展示成本字段**(加氢量、成本单价、成本总价等),不展示加氢单价、加氢总价。
|
> **未核对**明细不得进入对账单。明细**仅展示成本字段**(加氢量、成本单价、成本总价等),不展示加氢单价、加氢总价。
|
||||||
|
|
||||||
### 6.2 两阶段交互
|
### 6.2 两阶段交互
|
||||||
|
|
||||||
@@ -4731,7 +4736,8 @@ flowchart TD
|
|||||||
|
|
||||||
- 日期区下方展示:**上次对账单结束时间 YYYY-MM-DD**(无历史则「暂无」)
|
- 日期区下方展示:**上次对账单结束时间 YYYY-MM-DD**(无历史则「暂无」)
|
||||||
- 默认开始日期建议 = 上次结束日 + 1 天
|
- 默认开始日期建议 = 上次结束日 + 1 天
|
||||||
- 点击「生成对账单」时弹出**二次确认**:提示生成后车辆氢费明细对应记录将标记为已对账且无法修改;确认后进入阶段二,日期锁定不可改
|
- 点击「生成对账单」时弹出**二次确认**:提示提交后对应「已核对」记录将标记为已对账且无法修改;确认后进入阶段二,日期锁定不可改
|
||||||
|
- 无符合条件记录时提示调整日期(或先完成核对)
|
||||||
|
|
||||||
**阶段二 · 结算提交**
|
**阶段二 · 结算提交**
|
||||||
|
|
||||||
@@ -4773,13 +4779,13 @@ sequenceDiagram
|
|||||||
participant 站点 as 站点信息
|
participant 站点 as 站点信息
|
||||||
participant 财务 as 结算操作人
|
participant 财务 as 结算操作人
|
||||||
|
|
||||||
台账->>台账: 业务员「完成对账」→ 已对账
|
台账->>台账: 能源部「完成核对」→ 已核对
|
||||||
财务->>站点: 更多 → 生成对账单
|
财务->>站点: 更多 → 生成对账单
|
||||||
站点->>台账: 拉取已对账且未结算记录
|
站点->>台账: 拉取已核对且未对账未结算记录
|
||||||
财务->>站点: 生成对账记录 + 填收票信息
|
财务->>站点: 生成对账记录 + 填收票信息
|
||||||
财务->>站点: 提交对账单
|
财务->>站点: 提交对账单
|
||||||
站点->>站点: 更新预付余额 / 写对账历史
|
站点->>站点: 更新预付余额 / 写对账历史
|
||||||
站点->>台账: 回写对账日期、收票日期、已付款
|
站点->>台账: 回写已对账、对账日期、收票日期、已付款
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -4816,7 +4822,7 @@ sequenceDiagram
|
|||||||
| 场景 | 处理 |
|
| 场景 | 处理 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 日期未填 / 开始晚于结束 | 阻止生成,提示用户 |
|
| 日期未填 / 开始晚于结束 | 阻止生成,提示用户 |
|
||||||
| 区间内无已对账未结算记录 | 阻止生成,提示调整日期 |
|
| 区间内无已核对未对账未结算记录 | 阻止生成,提示调整日期(或先完成核对) |
|
||||||
| 收票日期/金额/附件未填 | 阻止提交 |
|
| 收票日期/金额/附件未填 | 阻止提交 |
|
||||||
| 收票金额 ≤ 0 | 阻止提交 |
|
| 收票金额 ≤ 0 | 阻止提交 |
|
||||||
| 预付余额结算后为负 | 允许提交,列表与明细标「已欠费」 |
|
| 预付余额结算后为负 | 允许提交,列表与明细标「已欠费」 |
|
||||||
@@ -6027,6 +6033,15 @@ const Component = function () {
|
|||||||
var createSubmitting = createSubmittingState[0];
|
var createSubmitting = createSubmittingState[0];
|
||||||
var setCreateSubmitting = createSubmittingState[1];
|
var setCreateSubmitting = createSubmittingState[1];
|
||||||
|
|
||||||
|
var brandListState = useState(function () {
|
||||||
|
return window.H2DispenserBrandStore ? window.H2DispenserBrandStore.list() : [];
|
||||||
|
});
|
||||||
|
var brandList = brandListState[0];
|
||||||
|
var setBrandList = brandListState[1];
|
||||||
|
var brandModalState = useState({ open: false, editingId: '', brand: '', model: '', remark: '' });
|
||||||
|
var brandModal = brandModalState[0];
|
||||||
|
var setBrandModal = brandModalState[1];
|
||||||
|
|
||||||
var editStationState = useState(h2CreateEmptyStationForm());
|
var editStationState = useState(h2CreateEmptyStationForm());
|
||||||
var editStation = editStationState[0];
|
var editStation = editStationState[0];
|
||||||
var setEditStation = editStationState[1];
|
var setEditStation = editStationState[1];
|
||||||
@@ -6795,12 +6810,12 @@ const Component = function () {
|
|||||||
}
|
}
|
||||||
var rows = h2GetAvailableStatementLedgerRows(ledgerStore, statementModal.record.name, statementModal.startDate, statementModal.endDate);
|
var rows = h2GetAvailableStatementLedgerRows(ledgerStore, statementModal.record.name, statementModal.startDate, statementModal.endDate);
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
message.warning('所选日期范围内暂无「已对账」且未结算的加氢记录,请调整日期后重试');
|
message.warning('所选日期范围内暂无「已核对」且未对账、未结算的加氢记录,请调整日期或先完成核对后重试');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认生成对账单?',
|
title: '确认生成对账单?',
|
||||||
content: '生成对账单后,车辆氢费明细中对应记录将自动标记为已对账且无法修改,是否确认?',
|
content: '生成对账单后,车辆氢费明细中对应「已核对」记录将在提交时标记为已对账且无法修改,是否确认?',
|
||||||
okText: '确认生成',
|
okText: '确认生成',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
centered: true,
|
centered: true,
|
||||||
@@ -8445,7 +8460,7 @@ const Component = function () {
|
|||||||
!isDraft ? React.createElement('div', { className: 'h2-statement-form-hint' },
|
!isDraft ? React.createElement('div', { className: 'h2-statement-form-hint' },
|
||||||
'数据来源:',
|
'数据来源:',
|
||||||
React.createElement('strong', null, '车辆氢费明细'),
|
React.createElement('strong', null, '车辆氢费明细'),
|
||||||
' 中标记为「已对账」且尚未结算的加氢记录;明细仅展示成本价与成本总价。'
|
' 中标记为「已核对」且尚未对账结算的加氢记录;未核对记录不会进入对账单;明细仅展示成本价与成本总价。'
|
||||||
) : null
|
) : null
|
||||||
),
|
),
|
||||||
isDraft ? React.createElement('div', { className: 'h2-statement-stats', role: 'group', 'aria-label': '对账统计' },
|
isDraft ? React.createElement('div', { className: 'h2-statement-stats', role: 'group', 'aria-label': '对账统计' },
|
||||||
@@ -9786,6 +9801,17 @@ const Component = function () {
|
|||||||
onClick: function () { setImportModalOpen(true); },
|
onClick: function () { setImportModalOpen(true); },
|
||||||
'aria-label': '批量导入加氢站点'
|
'aria-label': '批量导入加氢站点'
|
||||||
}, '批量导入'),
|
}, '批量导入'),
|
||||||
|
React.createElement('button', {
|
||||||
|
'data-vm-icon': 'settings',
|
||||||
|
type: 'button',
|
||||||
|
className: 'vm-btn vm-btn-ghost ldb-toolbar-btn',
|
||||||
|
'data-annotation-id': 'h2-dispenser-brand',
|
||||||
|
onClick: function () {
|
||||||
|
setBrandList(window.H2DispenserBrandStore ? window.H2DispenserBrandStore.list() : []);
|
||||||
|
setBrandModal({ open: true, editingId: '', brand: '', model: '', remark: '' });
|
||||||
|
},
|
||||||
|
'aria-label': '加氢机品牌管理'
|
||||||
|
}, '加氢机品牌管理'),
|
||||||
React.createElement('button', {
|
React.createElement('button', {
|
||||||
'data-vm-icon': 'plus',
|
'data-vm-icon': 'plus',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
@@ -10130,6 +10156,7 @@ const Component = function () {
|
|||||||
|
|
||||||
React.createElement(Modal, {
|
React.createElement(Modal, {
|
||||||
title: '期末余额设置',
|
title: '期末余额设置',
|
||||||
|
className: 'h2-period-end-modal',
|
||||||
open: periodEndBalanceModal.open,
|
open: periodEndBalanceModal.open,
|
||||||
onCancel: closePeriodEndBalanceModal,
|
onCancel: closePeriodEndBalanceModal,
|
||||||
footer: React.createElement(Space, null,
|
footer: React.createElement(Space, null,
|
||||||
@@ -10150,30 +10177,41 @@ const Component = function () {
|
|||||||
var safePage = Math.min(logPage, logTotalPages);
|
var safePage = Math.min(logPage, logTotalPages);
|
||||||
var pagedLogs = logs.slice((safePage - 1) * logPageSize, safePage * logPageSize);
|
var pagedLogs = logs.slice((safePage - 1) * logPageSize, safePage * logPageSize);
|
||||||
var periodEndTimeValue = h2ToDateTimeDayjs(periodEndBalanceModal.periodEndTime);
|
var periodEndTimeValue = h2ToDateTimeDayjs(periodEndBalanceModal.periodEndTime);
|
||||||
var periodEndPicker = dayjs && DatePicker
|
var periodEndPicker = DatePicker
|
||||||
? React.createElement(DatePicker, {
|
? React.createElement('div', { 'data-annotation-id': 'h2-site-period-end-time' },
|
||||||
showTime: { format: 'HH:mm' },
|
React.createElement(DatePicker, {
|
||||||
needConfirm: false,
|
className: 'h2-period-end-datetime-picker',
|
||||||
style: { width: '100%', borderRadius: 8 },
|
showTime: { format: 'HH:mm' },
|
||||||
format: 'YYYY-MM-DD HH:mm',
|
needConfirm: false,
|
||||||
placeholder: '请选择期末时间',
|
style: { width: '100%' },
|
||||||
allowClear: true,
|
format: 'YYYY-MM-DD HH:mm',
|
||||||
value: periodEndTimeValue,
|
placeholder: '请选择期末时间',
|
||||||
disabledDate: function (current) {
|
allowClear: true,
|
||||||
if (!current || !dayjs) return false;
|
value: periodEndTimeValue,
|
||||||
return current.isAfter(dayjs().endOf('day'));
|
disabledDate: function (current) {
|
||||||
},
|
if (!current || !dayjs) return false;
|
||||||
onChange: function (d) {
|
return current.isAfter(dayjs().endOf('day'));
|
||||||
var text = d && d.isValid && d.isValid() ? d.format('YYYY-MM-DD HH:mm') : '';
|
},
|
||||||
setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { periodEndTime: text }); });
|
onChange: function (d) {
|
||||||
}
|
var text = d && d.isValid && d.isValid() ? d.format('YYYY-MM-DD HH:mm') : '';
|
||||||
})
|
setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { periodEndTime: text }); });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
: React.createElement(Input, {
|
: React.createElement(Input, {
|
||||||
style: { width: '100%', borderRadius: 8 },
|
type: 'datetime-local',
|
||||||
placeholder: 'YYYY-MM-DD HH:mm',
|
'data-annotation-id': 'h2-site-period-end-time',
|
||||||
value: periodEndBalanceModal.periodEndTime || '',
|
className: 'h2-period-end-datetime-picker',
|
||||||
|
style: { width: '100%', borderRadius: 8, height: 32 },
|
||||||
|
placeholder: '请选择期末时间',
|
||||||
|
value: periodEndBalanceModal.periodEndTime
|
||||||
|
? String(periodEndBalanceModal.periodEndTime).replace(' ', 'T')
|
||||||
|
: '',
|
||||||
onChange: function (e) {
|
onChange: function (e) {
|
||||||
setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { periodEndTime: e.target.value }); });
|
var raw = e.target.value || '';
|
||||||
|
setPeriodEndBalanceModal(function (m) {
|
||||||
|
return Object.assign({}, m, { periodEndTime: raw ? raw.replace('T', ' ') : '' });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return React.createElement('div', {
|
return React.createElement('div', {
|
||||||
@@ -10292,6 +10330,152 @@ const Component = function () {
|
|||||||
})()
|
})()
|
||||||
),
|
),
|
||||||
|
|
||||||
|
React.createElement(Modal, {
|
||||||
|
title: '加氢机品牌管理',
|
||||||
|
open: brandModal.open,
|
||||||
|
onCancel: function () {
|
||||||
|
setBrandModal({ open: false, editingId: '', brand: '', model: '', remark: '' });
|
||||||
|
},
|
||||||
|
footer: null,
|
||||||
|
centered: true,
|
||||||
|
width: 720,
|
||||||
|
destroyOnClose: true
|
||||||
|
},
|
||||||
|
React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 14 } },
|
||||||
|
React.createElement('div', {
|
||||||
|
style: {
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: 10,
|
||||||
|
background: '#f0fdf4',
|
||||||
|
border: '1px solid #bbf7d0',
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#166534',
|
||||||
|
lineHeight: 1.55
|
||||||
|
}
|
||||||
|
}, '维护加氢机品牌与型号,供 H5「加氢订单」面板 OCR 识别时作为模型判断参考。数据保存在本机演示 Store,未接真实 API。'),
|
||||||
|
React.createElement('div', {
|
||||||
|
style: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr 1.2fr auto',
|
||||||
|
gap: 10,
|
||||||
|
alignItems: 'end'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
React.createElement('div', null,
|
||||||
|
React.createElement('div', { style: { fontSize: 12, fontWeight: 600, color: '#64748b', marginBottom: 6 } }, '品牌 *'),
|
||||||
|
React.createElement(Input, {
|
||||||
|
placeholder: '如:海德利森',
|
||||||
|
value: brandModal.brand,
|
||||||
|
onChange: function (e) {
|
||||||
|
var v = e.target.value;
|
||||||
|
setBrandModal(function (m) { return Object.assign({}, m, { brand: v }); });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
|
React.createElement('div', null,
|
||||||
|
React.createElement('div', { style: { fontSize: 12, fontWeight: 600, color: '#64748b', marginBottom: 6 } }, '型号 *'),
|
||||||
|
React.createElement(Input, {
|
||||||
|
placeholder: '如:H2-D50',
|
||||||
|
value: brandModal.model,
|
||||||
|
onChange: function (e) {
|
||||||
|
var v = e.target.value;
|
||||||
|
setBrandModal(function (m) { return Object.assign({}, m, { model: v }); });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
|
React.createElement('div', null,
|
||||||
|
React.createElement('div', { style: { fontSize: 12, fontWeight: 600, color: '#64748b', marginBottom: 6 } }, '备注'),
|
||||||
|
React.createElement(Input, {
|
||||||
|
placeholder: '可选,如 OCR 模板说明',
|
||||||
|
value: brandModal.remark,
|
||||||
|
onChange: function (e) {
|
||||||
|
var v = e.target.value;
|
||||||
|
setBrandModal(function (m) { return Object.assign({}, m, { remark: v }); });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
|
React.createElement(Button, {
|
||||||
|
type: 'primary',
|
||||||
|
style: H2_PRIMARY_BTN_STYLE,
|
||||||
|
onClick: function () {
|
||||||
|
if (!window.H2DispenserBrandStore) {
|
||||||
|
message.error('品牌 Store 未加载');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var result = window.H2DispenserBrandStore.upsert({
|
||||||
|
id: brandModal.editingId || undefined,
|
||||||
|
brand: brandModal.brand,
|
||||||
|
model: brandModal.model,
|
||||||
|
remark: brandModal.remark
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
message.warning(result.message || '保存失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBrandList(window.H2DispenserBrandStore.list());
|
||||||
|
setBrandModal({ open: true, editingId: '', brand: '', model: '', remark: '' });
|
||||||
|
message.success(brandModal.editingId ? '已更新品牌型号' : '已新增品牌型号');
|
||||||
|
}
|
||||||
|
}, brandModal.editingId ? '保存修改' : '新增')
|
||||||
|
),
|
||||||
|
React.createElement(Table, {
|
||||||
|
size: 'small',
|
||||||
|
rowKey: 'id',
|
||||||
|
pagination: false,
|
||||||
|
locale: { emptyText: '暂无品牌型号,请先新增' },
|
||||||
|
dataSource: brandList,
|
||||||
|
columns: [
|
||||||
|
{ title: '品牌', dataIndex: 'brand', key: 'brand', width: 140 },
|
||||||
|
{ title: '型号', dataIndex: 'model', key: 'model', width: 140 },
|
||||||
|
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '更新时间',
|
||||||
|
dataIndex: 'updatedAt',
|
||||||
|
key: 'updatedAt',
|
||||||
|
width: 160,
|
||||||
|
render: function (v) {
|
||||||
|
return React.createElement('span', { className: 'tabular-nums' }, v || '—');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 148,
|
||||||
|
render: function (_, record) {
|
||||||
|
return React.createElement(OperationActions, {
|
||||||
|
edit: {
|
||||||
|
onClick: function () {
|
||||||
|
setBrandModal({
|
||||||
|
open: true,
|
||||||
|
editingId: record.id,
|
||||||
|
brand: record.brand,
|
||||||
|
model: record.model,
|
||||||
|
remark: record.remark || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
more: [
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: '删除',
|
||||||
|
danger: true,
|
||||||
|
onClick: function () {
|
||||||
|
if (!window.confirm('确认删除「' + record.brand + ' · ' + record.model + '」?')) return;
|
||||||
|
if (!window.H2DispenserBrandStore) return;
|
||||||
|
window.H2DispenserBrandStore.remove(record.id);
|
||||||
|
setBrandList(window.H2DispenserBrandStore.list());
|
||||||
|
message.success('已删除');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
React.createElement(Modal, {
|
React.createElement(Modal, {
|
||||||
title: '全站余额提醒设置',
|
title: '全站余额提醒设置',
|
||||||
open: globalBalanceAlertModal.open,
|
open: globalBalanceAlertModal.open,
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ const specRoot = path.join(protoRoot, '.spec');
|
|||||||
const fullPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd.md'), 'utf8');
|
const fullPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd.md'), 'utf8');
|
||||||
const listPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-list.md'), 'utf8');
|
const listPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-list.md'), 'utf8');
|
||||||
const statementPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-statement.md'), 'utf8');
|
const statementPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-statement.md'), 'utf8');
|
||||||
|
const statementVerifiedFilterPrd = fs.readFileSync(path.join(specRoot, 'statement-verified-filter.md'), 'utf8');
|
||||||
const kpiBalanceAlertPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-kpi-balance-alert.md'), 'utf8');
|
const kpiBalanceAlertPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-kpi-balance-alert.md'), 'utf8');
|
||||||
const periodEndPrd = fs.readFileSync(path.join(specRoot, 'period-end-balance.md'), 'utf8');
|
const periodEndPrd = fs.readFileSync(path.join(specRoot, 'period-end-balance.md'), 'utf8');
|
||||||
|
const dispenserBrandPrd = fs.readFileSync(path.join(specRoot, 'dispenser-brand.md'), 'utf8');
|
||||||
const scopeMd = fs.readFileSync(path.join(specRoot, 'requirements.md'), 'utf8');
|
const scopeMd = fs.readFileSync(path.join(specRoot, 'requirements.md'), 'utf8');
|
||||||
|
|
||||||
const NODE_DEFS = [
|
const NODE_DEFS = [
|
||||||
@@ -44,7 +46,7 @@ const NODE_DEFS = [
|
|||||||
pageId: 'site-info',
|
pageId: 'site-info',
|
||||||
color: '#d97706',
|
color: '#d97706',
|
||||||
aiPrompt: '工具栏操作与对账单氢费联动。',
|
aiPrompt: '工具栏操作与对账单氢费联动。',
|
||||||
annotationText: '全站余额提醒、批量导入、充值单、新建站点;对账回写车辆氢费明细。',
|
annotationText: '全站余额提醒、批量导入、加氢机品牌管理、充值单、新建站点;对账回写车辆氢费明细。',
|
||||||
locator: {
|
locator: {
|
||||||
selectors: ['.h2-station-page .vm-table-toolbar', '[data-annotation-id="h2-site-toolbar"]'],
|
selectors: ['.h2-station-page .vm-table-toolbar', '[data-annotation-id="h2-site-toolbar"]'],
|
||||||
fingerprint: 'toolbar|statement',
|
fingerprint: 'toolbar|statement',
|
||||||
@@ -91,8 +93,8 @@ const NODE_DEFS = [
|
|||||||
title: '生成对账单',
|
title: '生成对账单',
|
||||||
pageId: 'site-info',
|
pageId: 'site-info',
|
||||||
color: '#7c3aed',
|
color: '#7c3aed',
|
||||||
aiPrompt: '选期拉取氢费明细、二次确认与结算提交逻辑。',
|
aiPrompt: '选期仅拉取已核对未对账氢费明细、二次确认与结算提交。',
|
||||||
annotationText: '选日期拉取站点加氢记录;生成前二次确认;提交后明细锁定为已对账',
|
annotationText: '账期内仅纳入车辆氢费明细「已核对」记录;未核对不进对账单;提交后标为已对账',
|
||||||
locator: {
|
locator: {
|
||||||
selectors: ['[data-annotation-id="h2-site-statement-form"]', '.h2-statement-form-wrap'],
|
selectors: ['[data-annotation-id="h2-site-statement-form"]', '.h2-statement-form-wrap'],
|
||||||
fingerprint: 'form|statement',
|
fingerprint: 'form|statement',
|
||||||
@@ -119,7 +121,11 @@ const NODE_PRD_MAP = {
|
|||||||
'h2-site-table': { file: 'list', heading: '## 5. 列表字段说明' },
|
'h2-site-table': { file: 'list', heading: '## 5. 列表字段说明' },
|
||||||
'h2-site-price-cost-input': { file: 'full', heading: '### 5.2 价格配置' },
|
'h2-site-price-cost-input': { file: 'full', heading: '### 5.2 价格配置' },
|
||||||
'h2-site-price-tiered': { file: 'full', heading: '#### 阶梯价格 · 生成逻辑' },
|
'h2-site-price-tiered': { file: 'full', heading: '#### 阶梯价格 · 生成逻辑' },
|
||||||
'h2-site-statement-form': { file: 'statement', heading: '#### 生成对账单 · 业务逻辑' },
|
'h2-site-statement-form': {
|
||||||
|
file: 'statement',
|
||||||
|
heading: '## 1. 数据来源',
|
||||||
|
combine: ['## 1. 数据来源', '#### 生成对账单 · 业务逻辑'],
|
||||||
|
},
|
||||||
'h2-site-period-end-balance': {
|
'h2-site-period-end-balance': {
|
||||||
file: 'periodEnd',
|
file: 'periodEnd',
|
||||||
heading: '## 1. 背景与目标',
|
heading: '## 1. 背景与目标',
|
||||||
@@ -301,6 +307,13 @@ const source = {
|
|||||||
markdown: statementPrd,
|
markdown: statementPrd,
|
||||||
markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-statement.md',
|
markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-statement.md',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'markdown',
|
||||||
|
id: 'h2-site-doc-statement-verified-filter',
|
||||||
|
title: '对账单纳入规则(已核对)',
|
||||||
|
markdown: statementVerifiedFilterPrd,
|
||||||
|
markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/statement-verified-filter.md',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'markdown',
|
type: 'markdown',
|
||||||
id: 'h2-site-doc-prd-period-end',
|
id: 'h2-site-doc-prd-period-end',
|
||||||
@@ -308,6 +321,13 @@ const source = {
|
|||||||
markdown: periodEndPrd,
|
markdown: periodEndPrd,
|
||||||
markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/period-end-balance.md',
|
markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/period-end-balance.md',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'markdown',
|
||||||
|
id: 'h2-site-doc-prd-dispenser-brand',
|
||||||
|
title: '加氢机品牌管理',
|
||||||
|
markdown: dispenserBrandPrd,
|
||||||
|
markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/dispenser-brand.md',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'markdown',
|
type: 'markdown',
|
||||||
id: 'h2-site-doc-scope',
|
id: 'h2-site-doc-scope',
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
"fingerprint": "daily-stats|h2-record",
|
"fingerprint": "daily-stats|h2-record",
|
||||||
"path": []
|
"path": []
|
||||||
},
|
},
|
||||||
"annotationText": "近30天(含今天,自今日前推):展示起止日期与加氢总量;每日卡片为 2×2:加氢数据条数 / 已对账条数 / 加氢量 / 加氢总额,随筛选结果实时汇总。",
|
"annotationText": "近30天(含今天,自今日前推):展示起止日期与加氢总量;每日卡片上方「数据条数」下分「总数 / 已核对 / 已对账」,下方为加氢量与加氢总额;随筛选结果实时汇总。",
|
||||||
"hasMarkdown": false,
|
"hasMarkdown": false,
|
||||||
"color": "#7C3AED",
|
"color": "#7C3AED",
|
||||||
"images": [],
|
"images": [],
|
||||||
|
|||||||
@@ -180,19 +180,26 @@ var HR_PAGE_STYLE = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([
|
|||||||
'.h2-station-page .hr-daily-stats-head__title { font-size: 15px; font-weight: 700; color: #0f172a; }',
|
'.h2-station-page .hr-daily-stats-head__title { font-size: 15px; font-weight: 700; color: #0f172a; }',
|
||||||
'.h2-station-page .hr-daily-stats-head__meta { font-size: 12px; color: #64748b; font-family: var(--vm-font-mono, ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, Consolas, monospace); font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1; }',
|
'.h2-station-page .hr-daily-stats-head__meta { font-size: 12px; color: #64748b; font-family: var(--vm-font-mono, ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, Consolas, monospace); font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1; }',
|
||||||
'.h2-station-page .hr-daily-stats-carousel { display: flex; align-items: stretch; gap: 8px; }',
|
'.h2-station-page .hr-daily-stats-carousel { display: flex; align-items: stretch; gap: 8px; }',
|
||||||
'.h2-station-page .hr-daily-stats-nav-btn { flex: 0 0 36px; width: 36px !important; min-width: 36px !important; height: auto !important; min-height: 118px !important; padding: 0 !important; border-radius: 10px !important; font-size: 20px !important; font-weight: 700 !important; color: #475569 !important; border: 1px solid #e2e8f0 !important; background: #fff !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); }',
|
'.h2-station-page .hr-daily-stats-nav-btn { flex: 0 0 36px; width: 36px !important; min-width: 36px !important; height: auto !important; min-height: 132px !important; padding: 0 !important; border-radius: 10px !important; font-size: 20px !important; font-weight: 700 !important; color: #475569 !important; border: 1px solid #e2e8f0 !important; background: #fff !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); }',
|
||||||
'.h2-station-page .hr-daily-stats-nav-btn:hover:not(:disabled) { color: #059669 !important; border-color: #10b981 !important; background: #f0fdf4 !important; }',
|
'.h2-station-page .hr-daily-stats-nav-btn:hover:not(:disabled) { color: #059669 !important; border-color: #10b981 !important; background: #f0fdf4 !important; }',
|
||||||
'.h2-station-page .hr-daily-stats-nav-btn:disabled { opacity: 0.35; cursor: not-allowed; box-shadow: none; }',
|
'.h2-station-page .hr-daily-stats-nav-btn:disabled { opacity: 0.35; cursor: not-allowed; box-shadow: none; }',
|
||||||
'.h2-station-page .hr-daily-stats-track { flex: 1; min-width: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 8px; }',
|
'.h2-station-page .hr-daily-stats-track { flex: 1; min-width: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 8px; }',
|
||||||
'.h2-station-page .hr-daily-stat-card { display: flex; flex-direction: column; gap: 8px; min-height: 118px; padding: 12px 10px; border-radius: 12px; border: 1px solid #e2e8f0; background: #fff; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); box-sizing: border-box; min-width: 0; }',
|
'.h2-station-page .hr-daily-stat-card { display: flex; flex-direction: column; gap: 8px; min-height: 132px; padding: 12px 10px; border-radius: 12px; border: 1px solid #e2e8f0; background: #fff; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); box-sizing: border-box; min-width: 0; }',
|
||||||
'.h2-station-page .hr-daily-stat-card--today { border-color: #86efac; background: linear-gradient(180deg, #fff 0%, #f0fdf4 100%); box-shadow: 0 2px 8px rgba(16, 185, 129, 0.12); }',
|
'.h2-station-page .hr-daily-stat-card--today { border-color: #86efac; background: linear-gradient(180deg, #fff 0%, #f0fdf4 100%); box-shadow: 0 2px 8px rgba(16, 185, 129, 0.12); }',
|
||||||
'.h2-station-page .hr-daily-stat-card__date { font-size: 12px; font-weight: 700; color: #0f172a; font-family: var(--vm-font-mono, ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, Consolas, monospace); font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1; padding-bottom: 8px; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: space-between; gap: 4px; }',
|
'.h2-station-page .hr-daily-stat-card__date { font-size: 12px; font-weight: 700; color: #0f172a; font-family: var(--vm-font-mono, ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, Consolas, monospace); font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1; padding-bottom: 8px; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: space-between; gap: 4px; }',
|
||||||
'.h2-station-page .hr-daily-stat-card__today-tag { font-size: 10px; font-weight: 700; color: #059669; background: #ecfdf5; padding: 1px 6px; border-radius: 999px; flex-shrink: 0; }',
|
'.h2-station-page .hr-daily-stat-card__today-tag { font-size: 10px; font-weight: 700; color: #059669; background: #ecfdf5; padding: 1px 6px; border-radius: 999px; flex-shrink: 0; }',
|
||||||
'.h2-station-page .hr-daily-stat-card__metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 10px; }',
|
'.h2-station-page .hr-daily-stat-card__metrics { display: flex; flex-direction: column; gap: 8px; }',
|
||||||
|
'.h2-station-page .hr-daily-stat-card__count-block { display: flex; flex-direction: column; gap: 4px; min-width: 0; }',
|
||||||
|
'.h2-station-page .hr-daily-stat-card__count-title { color: #64748b; font-weight: 600; font-size: 11px; line-height: 1.3; }',
|
||||||
|
'.h2-station-page .hr-daily-stat-card__count-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }',
|
||||||
|
'.h2-station-page .hr-daily-stat-card__count-item { display: flex; flex-direction: column; gap: 2px; min-width: 0; }',
|
||||||
|
'.h2-station-page .hr-daily-stat-card__count-item-label { color: #94a3b8; font-weight: 600; font-size: 10px; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
|
||||||
|
'.h2-station-page .hr-daily-stat-card__metrics-bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 10px; }',
|
||||||
'.h2-station-page .hr-daily-stat-card__metric { display: flex; flex-direction: column; gap: 2px; min-width: 0; }',
|
'.h2-station-page .hr-daily-stat-card__metric { display: flex; flex-direction: column; gap: 2px; min-width: 0; }',
|
||||||
'.h2-station-page .hr-daily-stat-card__label { color: #64748b; font-weight: 600; font-size: 11px; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
|
'.h2-station-page .hr-daily-stat-card__label { color: #64748b; font-weight: 600; font-size: 11px; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
|
||||||
'.h2-station-page .hr-daily-stat-card__value { font-weight: 800; font-family: var(--vm-font-mono, ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, Consolas, monospace); font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1; font-size: 13px; line-height: 1.2; }',
|
'.h2-station-page .hr-daily-stat-card__value { font-weight: 800; font-family: var(--vm-font-mono, ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, Consolas, monospace); font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1; font-size: 13px; line-height: 1.2; }',
|
||||||
'.h2-station-page .hr-daily-stat-card__value--count { color: #2563eb; }',
|
'.h2-station-page .hr-daily-stat-card__value--count { color: #2563eb; }',
|
||||||
|
'.h2-station-page .hr-daily-stat-card__value--verified { color: #7c3aed; }',
|
||||||
'.h2-station-page .hr-daily-stat-card__value--reconciled { color: #0f766e; }',
|
'.h2-station-page .hr-daily-stat-card__value--reconciled { color: #0f766e; }',
|
||||||
'.h2-station-page .hr-daily-stat-card__value--kg { color: #059669; }',
|
'.h2-station-page .hr-daily-stat-card__value--kg { color: #059669; }',
|
||||||
'.h2-station-page .hr-daily-stat-card__value--amount { color: #d97706; }',
|
'.h2-station-page .hr-daily-stat-card__value--amount { color: #d97706; }',
|
||||||
@@ -305,10 +312,13 @@ function hrBuildRecentDailyWindow(list, dayjs, windowDays) {
|
|||||||
var day = hrExtractDateKey(r.hydrogenTime);
|
var day = hrExtractDateKey(r.hydrogenTime);
|
||||||
if (!day) return;
|
if (!day) return;
|
||||||
if (!statsMap[day]) {
|
if (!statsMap[day]) {
|
||||||
statsMap[day] = { count: 0, reconciledCount: 0, totalKg: 0, totalAmount: 0 };
|
statsMap[day] = { count: 0, verifiedCount: 0, reconciledCount: 0, totalKg: 0, totalAmount: 0 };
|
||||||
}
|
}
|
||||||
statsMap[day].count += 1;
|
statsMap[day].count += 1;
|
||||||
if (r.reconcileStatus === 'reconciled' || Boolean(r.reconcileTime)) {
|
if (hrIsVerified(r)) {
|
||||||
|
statsMap[day].verifiedCount += 1;
|
||||||
|
}
|
||||||
|
if (hrIsReconciled(r)) {
|
||||||
statsMap[day].reconciledCount += 1;
|
statsMap[day].reconciledCount += 1;
|
||||||
}
|
}
|
||||||
var kg = parseFloat(r.hydrogenKg);
|
var kg = parseFloat(r.hydrogenKg);
|
||||||
@@ -339,6 +349,7 @@ function hrBuildRecentDailyWindow(list, dayjs, windowDays) {
|
|||||||
dateLabel: shortLabel,
|
dateLabel: shortLabel,
|
||||||
isToday: i === 0,
|
isToday: i === 0,
|
||||||
count: stat ? stat.count : 0,
|
count: stat ? stat.count : 0,
|
||||||
|
verifiedCount: stat ? stat.verifiedCount : 0,
|
||||||
reconciledCount: stat ? stat.reconciledCount : 0,
|
reconciledCount: stat ? stat.reconciledCount : 0,
|
||||||
totalKg: stat ? Math.round(stat.totalKg * 100) / 100 : 0,
|
totalKg: stat ? Math.round(stat.totalKg * 100) / 100 : 0,
|
||||||
totalAmount: stat ? Math.round(stat.totalAmount * 100) / 100 : 0
|
totalAmount: stat ? Math.round(stat.totalAmount * 100) / 100 : 0
|
||||||
@@ -2781,8 +2792,10 @@ const Component = function () {
|
|||||||
className: 'hr-daily-stat-card' + (item.isToday ? ' hr-daily-stat-card--today' : ''),
|
className: 'hr-daily-stat-card' + (item.isToday ? ' hr-daily-stat-card--today' : ''),
|
||||||
role: 'article',
|
role: 'article',
|
||||||
'aria-label': item.date
|
'aria-label': item.date
|
||||||
+ ' 加氢数据 ' + item.count + ' 条,已对账 ' + item.reconciledCount
|
+ ' 数据条数 总数 ' + item.count
|
||||||
+ ' 条,加氢量 ' + hrFormatKg(item.totalKg) + ' kg,加氢总额 '
|
+ ',已核对 ' + item.verifiedCount
|
||||||
|
+ ',已对账 ' + item.reconciledCount
|
||||||
|
+ ',加氢量 ' + hrFormatKg(item.totalKg) + ' kg,加氢总额 '
|
||||||
+ hrFormatYuan(item.totalAmount) + ' 元'
|
+ hrFormatYuan(item.totalAmount) + ' 元'
|
||||||
},
|
},
|
||||||
React.createElement('div', { className: 'hr-daily-stat-card__date' },
|
React.createElement('div', { className: 'hr-daily-stat-card__date' },
|
||||||
@@ -2790,21 +2803,32 @@ const Component = function () {
|
|||||||
item.isToday ? React.createElement('span', { className: 'hr-daily-stat-card__today-tag' }, '今天') : null
|
item.isToday ? React.createElement('span', { className: 'hr-daily-stat-card__today-tag' }, '今天') : null
|
||||||
),
|
),
|
||||||
React.createElement('div', { className: 'hr-daily-stat-card__metrics' },
|
React.createElement('div', { className: 'hr-daily-stat-card__metrics' },
|
||||||
React.createElement('div', { className: 'hr-daily-stat-card__metric' },
|
React.createElement('div', { className: 'hr-daily-stat-card__count-block' },
|
||||||
React.createElement('span', { className: 'hr-daily-stat-card__label' }, '加氢数据条数'),
|
React.createElement('span', { className: 'hr-daily-stat-card__count-title' }, '数据条数'),
|
||||||
React.createElement('span', { className: 'hr-daily-stat-card__value hr-daily-stat-card__value--count tabular-nums' }, String(item.count))
|
React.createElement('div', { className: 'hr-daily-stat-card__count-row' },
|
||||||
|
React.createElement('div', { className: 'hr-daily-stat-card__count-item' },
|
||||||
|
React.createElement('span', { className: 'hr-daily-stat-card__count-item-label' }, '总数'),
|
||||||
|
React.createElement('span', { className: 'hr-daily-stat-card__value hr-daily-stat-card__value--count tabular-nums' }, String(item.count))
|
||||||
|
),
|
||||||
|
React.createElement('div', { className: 'hr-daily-stat-card__count-item' },
|
||||||
|
React.createElement('span', { className: 'hr-daily-stat-card__count-item-label' }, '已核对'),
|
||||||
|
React.createElement('span', { className: 'hr-daily-stat-card__value hr-daily-stat-card__value--verified tabular-nums' }, String(item.verifiedCount))
|
||||||
|
),
|
||||||
|
React.createElement('div', { className: 'hr-daily-stat-card__count-item' },
|
||||||
|
React.createElement('span', { className: 'hr-daily-stat-card__count-item-label' }, '已对账'),
|
||||||
|
React.createElement('span', { className: 'hr-daily-stat-card__value hr-daily-stat-card__value--reconciled tabular-nums' }, String(item.reconciledCount))
|
||||||
|
)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
React.createElement('div', { className: 'hr-daily-stat-card__metric' },
|
React.createElement('div', { className: 'hr-daily-stat-card__metrics-bottom' },
|
||||||
React.createElement('span', { className: 'hr-daily-stat-card__label' }, '已对账条数'),
|
React.createElement('div', { className: 'hr-daily-stat-card__metric' },
|
||||||
React.createElement('span', { className: 'hr-daily-stat-card__value hr-daily-stat-card__value--reconciled tabular-nums' }, String(item.reconciledCount))
|
React.createElement('span', { className: 'hr-daily-stat-card__label' }, '加氢量'),
|
||||||
),
|
React.createElement('span', { className: 'hr-daily-stat-card__value hr-daily-stat-card__value--kg tabular-nums' }, hrFormatKg(item.totalKg))
|
||||||
React.createElement('div', { className: 'hr-daily-stat-card__metric' },
|
),
|
||||||
React.createElement('span', { className: 'hr-daily-stat-card__label' }, '加氢量'),
|
React.createElement('div', { className: 'hr-daily-stat-card__metric' },
|
||||||
React.createElement('span', { className: 'hr-daily-stat-card__value hr-daily-stat-card__value--kg tabular-nums' }, hrFormatKg(item.totalKg))
|
React.createElement('span', { className: 'hr-daily-stat-card__label' }, '加氢总额'),
|
||||||
),
|
React.createElement('span', { className: 'hr-daily-stat-card__value hr-daily-stat-card__value--amount tabular-nums' }, hrFormatYuan(item.totalAmount))
|
||||||
React.createElement('div', { className: 'hr-daily-stat-card__metric' },
|
)
|
||||||
React.createElement('span', { className: 'hr-daily-stat-card__label' }, '加氢总额'),
|
|
||||||
React.createElement('span', { className: 'hr-daily-stat-card__value hr-daily-stat-card__value--amount tabular-nums' }, hrFormatYuan(item.totalAmount))
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user