merge: integrate remote main with local H2 prototypes and xll driver training nav.
Keep remote xll-miniapp registry while preserving local hydrogen station weekly/analysis pages; sync driver-training into ONEOS xll navigation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,12 +5,14 @@ import {
|
||||
ArrowRightLeft,
|
||||
Car,
|
||||
CircleDollarSign,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
MoreHorizontal,
|
||||
OctagonX,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Trash2,
|
||||
Undo2,
|
||||
Upload,
|
||||
@@ -31,6 +33,7 @@ export type OperationActionItem = {
|
||||
const MORE_ACTION_ICONS: Record<string, LucideIcon> = {
|
||||
edit: Pencil,
|
||||
del: Trash2,
|
||||
delete: Trash2,
|
||||
addVehicle: Car,
|
||||
renew: RefreshCw,
|
||||
authorized: FileText,
|
||||
@@ -41,6 +44,9 @@ const MORE_ACTION_ICONS: Record<string, LucideIcon> = {
|
||||
toFormal: ArrowRightLeft,
|
||||
stampSupplement: Upload,
|
||||
uploadStamped: Upload,
|
||||
manage: Settings2,
|
||||
preview: Eye,
|
||||
download: Download,
|
||||
};
|
||||
|
||||
function renderMenuItemLabel(item: OperationActionItem) {
|
||||
|
||||
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);
|
||||
@@ -5,31 +5,44 @@
|
||||
|
||||
var H2_RECONCILE_RECONCILED = 'reconciled';
|
||||
var H2_RECONCILE_PENDING = 'pending';
|
||||
var H2_VERIFY_VERIFIED = 'verified';
|
||||
var H2_VERIFY_UNVERIFIED = 'unverified';
|
||||
/** 记录来源:站端上传 | 羚牛上传(车辆氢费明细人工录入) */
|
||||
var H2_RECORD_SOURCE_STATION = 'station';
|
||||
var H2_RECORD_SOURCE_LINGNIU = 'lingniu';
|
||||
|
||||
var H2_STATION_CODE_MAP = {
|
||||
'嘉兴加氢站(一期)': 'JX-H2-001',
|
||||
'中国石油中油高新能源牙谷加油加氢站': 'JX-H2-001',
|
||||
'杭州临平加氢站': 'HZ-H2-002',
|
||||
'上海宝山加氢站': 'SH-H2-003',
|
||||
'苏州工业园区备用站': 'SZ-H2-004'
|
||||
};
|
||||
|
||||
/** 与车辆氢费明细、加氢站模块对齐的 canonical seed(10 条) */
|
||||
var H2_CANONICAL_LEDGER_SEED = [
|
||||
{ key: 'rf-1', id: 'rf-1', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-28 10:21:08', plateNo: '浙A12345F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 12.5, costUnitPrice: 42.5, costTotal: 531.25, customerUnitPrice: 45, customerAmount: 562.5, settlementStatus: 'customer', mileageKm: 128560, creatorName: '张三', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-2', id: 'rf-2', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-26 14:08:33', plateNo: '浙A67890F', customerName: '浙江绿运物流有限公司', hydrogenKg: 10.0, costUnitPrice: 42.5, costTotal: 425.0, customerUnitPrice: 45, customerAmount: 450.0, settlementStatus: 'internal', mileageKm: 95230, creatorName: '李四', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-3', id: 'rf-3', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-22 09:15:00', plateNo: '浙A88888F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 18.3, costUnitPrice: 42.5, costTotal: 777.75, customerUnitPrice: 45, customerAmount: 823.5, settlementStatus: 'customer_self', mileageKm: 201880, creatorName: '王静', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-4', id: 'rf-4', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-18 16:42:11', plateNo: '浙A03561F', customerName: '嘉兴港务氢能运输队', hydrogenKg: 15.6, costUnitPrice: 42.5, costTotal: 663.0, customerUnitPrice: 45, customerAmount: 702.0, settlementStatus: 'customer', mileageKm: 167420, creatorName: '张三', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-5', id: 'rf-5', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-30 09:30:22', plateNo: '浙B23456F', customerName: '杭州临平城配中心', hydrogenKg: 15.3, costUnitPrice: 43.0, costTotal: 657.9, customerUnitPrice: 46, customerAmount: 703.8, settlementStatus: 'internal', mileageKm: 143200, creatorName: '赵敏', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-6', id: 'rf-6', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-27 18:10:05', plateNo: '浙B99999F', customerName: '浙江氢运科技', hydrogenKg: 18.2, costUnitPrice: 43.0, costTotal: 782.6, customerUnitPrice: 46, customerAmount: 837.2, settlementStatus: 'customer_self', mileageKm: 189650, creatorName: '陈浩', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-7', id: 'rf-7', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-24 11:05:40', plateNo: '浙B58888F', customerName: '杭州临平城配中心', hydrogenKg: 11.8, costUnitPrice: 43.0, costTotal: 507.4, customerUnitPrice: 46, customerAmount: 542.8, settlementStatus: 'customer', mileageKm: 110340, creatorName: '李四', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-8', id: 'rf-8', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-20 16:45:18', plateNo: '沪A88888F', customerName: '上海羚牛氢运', hydrogenKg: 8.0, costUnitPrice: 44.0, costTotal: 352.0, customerUnitPrice: 47, customerAmount: 376.0, settlementStatus: 'internal', mileageKm: 88420, creatorName: '王静', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-9', id: 'rf-9', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-08 09:12:55', plateNo: '沪BDB9161F', customerName: '宝山园区试运车队', hydrogenKg: 9.5, costUnitPrice: 44.0, costTotal: 418.0, customerUnitPrice: 47, customerAmount: 446.5, settlementStatus: 'customer_self', mileageKm: 76500, creatorName: '张三', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-10', id: 'rf-10', stationId: 'SZ-H2-004', stationName: '苏州工业园区备用站', hydrogenTime: '2026-03-15 10:00:00', plateNo: '苏E33333F', customerName: '苏州试运客户', hydrogenKg: 6.2, costUnitPrice: 41.0, costTotal: 254.2, customerUnitPrice: 44, customerAmount: 272.8, settlementStatus: 'customer', mileageKm: 45210, creatorName: '赵敏', reconcileStatus: H2_RECONCILE_PENDING }
|
||||
];
|
||||
var H2_STATION_CODE_SET = (function () {
|
||||
var set = {};
|
||||
Object.keys(H2_STATION_CODE_MAP).forEach(function (name) {
|
||||
set[H2_STATION_CODE_MAP[name]] = true;
|
||||
});
|
||||
return set;
|
||||
})();
|
||||
|
||||
function h2BridgeCloneRows(rows) {
|
||||
return (rows || []).map(function (r) { return Object.assign({}, r); });
|
||||
}
|
||||
/** 与车辆氢费明细、加氢站模块对齐的 canonical seed(含本站未核对 / 已核对未对账用例) */
|
||||
var H2_CANONICAL_LEDGER_SEED = [
|
||||
/* 嘉兴本站 · 列表首页用例:未核对(无核对时间)→ 已核对未对账 → 已核对已对账 */
|
||||
{ key: 'rf-11', id: 'rf-11', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-07-16 09:20:18', plateNo: '浙A55666F', customerName: '中国石油中油高新能源牙谷加油加氢站·站端上报', hydrogenKg: 11.2, costUnitPrice: 42.5, costTotal: 476.0, customerUnitPrice: 42.5, customerAmount: 476.0, settlementStatus: 'customer', mileageKm: 132080, creatorName: '站端账号', verifyStatus: H2_VERIFY_UNVERIFIED, verifiedAt: null, reconcileStatus: H2_RECONCILE_PENDING },
|
||||
{ key: 'rf-12', id: 'rf-12', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-07-15 16:45:02', plateNo: '浙A77888F', customerName: '中国石油中油高新能源牙谷加油加氢站·站端上报', hydrogenKg: 9.8, costUnitPrice: 42.5, costTotal: 416.5, customerUnitPrice: 42.5, customerAmount: 416.5, settlementStatus: 'customer', mileageKm: null, creatorName: '站端账号', verifyStatus: H2_VERIFY_UNVERIFIED, verifiedAt: null, reconcileStatus: H2_RECONCILE_PENDING },
|
||||
{ key: 'rf-13', id: 'rf-13', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-07-14 11:08:40', plateNo: '浙A99001F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 14.0, costUnitPrice: 42.5, costTotal: 595.0, customerUnitPrice: 45, customerAmount: 630.0, settlementStatus: 'customer', mileageKm: 155200, creatorName: '站端账号', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-07-14 15:30:00', reconcileStatus: H2_RECONCILE_PENDING, reconcileDate: null },
|
||||
{ key: 'rf-1', id: 'rf-1', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-28 10:21:08', plateNo: '浙A12345F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 12.5, costUnitPrice: 42.5, costTotal: 531.25, customerUnitPrice: 45, customerAmount: 562.5, settlementStatus: 'customer', mileageKm: 128560, creatorName: '张三', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-29 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-29 10:00:00' },
|
||||
{ key: 'rf-2', id: 'rf-2', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-26 14:08:33', plateNo: '浙A67890F', customerName: '浙江绿运物流有限公司', hydrogenKg: 10.0, costUnitPrice: 42.5, costTotal: 425.0, customerUnitPrice: 45, customerAmount: 450.0, settlementStatus: 'internal', mileageKm: 95230, creatorName: '李四', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-27 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-27 10:00:00' },
|
||||
{ key: 'rf-3', id: 'rf-3', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-22 09:15:00', plateNo: '浙A88888F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 18.3, costUnitPrice: 42.5, costTotal: 777.75, customerUnitPrice: 45, customerAmount: 823.5, settlementStatus: 'customer_self', mileageKm: 201880, creatorName: '王静', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-23 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-23 10:00:00' },
|
||||
{ key: 'rf-4', id: 'rf-4', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-18 16:42:11', plateNo: '浙A03561F', customerName: '嘉兴港务氢能运输队', hydrogenKg: 15.6, costUnitPrice: 42.5, costTotal: 663.0, customerUnitPrice: 45, customerAmount: 702.0, settlementStatus: 'customer', mileageKm: 167420, creatorName: '张三', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-19 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-19 10:00:00' },
|
||||
{ key: 'rf-5', id: 'rf-5', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-30 09:30:22', plateNo: '浙B23456F', customerName: '杭州临平城配中心', hydrogenKg: 15.3, costUnitPrice: 43.0, costTotal: 657.9, customerUnitPrice: 46, customerAmount: 703.8, settlementStatus: 'internal', mileageKm: 143200, creatorName: '赵敏', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-31 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-31 10:00:00' },
|
||||
{ key: 'rf-6', id: 'rf-6', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-27 18:10:05', plateNo: '浙B99999F', customerName: '浙江氢运科技', hydrogenKg: 18.2, costUnitPrice: 43.0, costTotal: 782.6, customerUnitPrice: 46, customerAmount: 837.2, settlementStatus: 'customer_self', mileageKm: 189650, creatorName: '陈浩', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-28 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-28 10:00:00' },
|
||||
{ key: 'rf-7', id: 'rf-7', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-24 11:05:40', plateNo: '浙B58888F', customerName: '杭州临平城配中心', hydrogenKg: 11.8, costUnitPrice: 43.0, costTotal: 507.4, customerUnitPrice: 46, customerAmount: 542.8, settlementStatus: 'customer', mileageKm: 110340, creatorName: '李四', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-25 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-25 10:00:00' },
|
||||
{ key: 'rf-8', id: 'rf-8', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-20 16:45:18', plateNo: '沪A88888F', customerName: '上海羚牛氢运', hydrogenKg: 8.0, costUnitPrice: 44.0, costTotal: 352.0, customerUnitPrice: 47, customerAmount: 376.0, settlementStatus: 'internal', mileageKm: 88420, creatorName: '王静', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-04-21 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-04-21 10:00:00' },
|
||||
{ key: 'rf-9', id: 'rf-9', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-08 09:12:55', plateNo: '沪BDB9161F', customerName: '宝山园区试运车队', hydrogenKg: 9.5, costUnitPrice: 44.0, costTotal: 418.0, customerUnitPrice: 47, customerAmount: 446.5, settlementStatus: 'customer_self', mileageKm: 76500, creatorName: '张三', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-04-09 10:00:00', reconcileStatus: H2_RECONCILE_PENDING, reconcileDate: null },
|
||||
{ key: 'rf-10', id: 'rf-10', stationId: 'SZ-H2-004', stationName: '苏州工业园区备用站', hydrogenTime: '2026-03-15 10:00:00', plateNo: '苏E33333F', customerName: '苏州试运客户', hydrogenKg: 6.2, costUnitPrice: 41.0, costTotal: 254.2, customerUnitPrice: 44, customerAmount: 272.8, settlementStatus: 'customer', mileageKm: 45210, creatorName: '赵敏', verifyStatus: H2_VERIFY_UNVERIFIED, verifiedAt: null, reconcileStatus: H2_RECONCILE_PENDING }
|
||||
];
|
||||
|
||||
function h2BridgeFormatDateTime(value) {
|
||||
if (!value) return '';
|
||||
@@ -38,6 +51,26 @@ function h2BridgeFormatDateTime(value) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** 已对账记录必须有对账时间;缺省时按加氢日次日 10:00:00 推导(原型演示) */
|
||||
function h2BridgeDeriveReconcileTime(hydrogenTime) {
|
||||
var s = h2BridgeFormatDateTime(hydrogenTime);
|
||||
var m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!m) return '2026-01-01 10:00:00';
|
||||
var d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + 1, 10, 0, 0);
|
||||
var pad = function (n) { return n < 10 ? '0' + n : String(n); };
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' 10:00:00';
|
||||
}
|
||||
|
||||
function h2BridgeResolveReconcileTime(row) {
|
||||
/* 对账时间仅来自对账单回写的 reconcileDate;勿用 reconciledAt(完成核对会写入) */
|
||||
var explicit = row.reconcileDate || '';
|
||||
if (explicit) return h2BridgeFormatDateTime(explicit);
|
||||
if (row.reconcileStatus === H2_RECONCILE_RECONCILED) {
|
||||
return h2BridgeDeriveReconcileTime(row.hydrogenTime);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function h2BridgeMatchStatementPatch(row, patch) {
|
||||
if (patch.ledgerId && row.ledgerSyncId && patch.ledgerId === row.ledgerSyncId) return true;
|
||||
var rowTime = h2BridgeFormatDateTime(row.hydrogenTime);
|
||||
@@ -47,6 +80,127 @@ function h2BridgeMatchStatementPatch(row, patch) {
|
||||
&& (rowTime === patchTime || String(row.hydrogenTime || '') === patchTime);
|
||||
}
|
||||
|
||||
function h2BridgeInferRecordSource(row) {
|
||||
if (!row) return H2_RECORD_SOURCE_LINGNIU;
|
||||
if (row.recordSource === H2_RECORD_SOURCE_STATION || row.recordSource === H2_RECORD_SOURCE_LINGNIU) {
|
||||
return row.recordSource;
|
||||
}
|
||||
var creator = String(row.creatorName || '').trim();
|
||||
var customer = String(row.customerName || '');
|
||||
if (creator === '站端账号' || customer.indexOf('·站端上报') >= 0) return H2_RECORD_SOURCE_STATION;
|
||||
return H2_RECORD_SOURCE_LINGNIU;
|
||||
}
|
||||
|
||||
function h2BridgeNormalizeRow(row) {
|
||||
if (!row) return row;
|
||||
var next = Object.assign({}, row);
|
||||
next.recordSource = h2BridgeInferRecordSource(next);
|
||||
if (!Array.isArray(next.changeLogs)) next.changeLogs = [];
|
||||
if (next.verifiedByName == null) next.verifiedByName = '';
|
||||
if (next.reconciledByName == null) next.reconciledByName = '';
|
||||
return next;
|
||||
}
|
||||
|
||||
function h2BridgeFormatLogScalar(value) {
|
||||
if (value == null || value === '') return '—';
|
||||
if (typeof value === 'number' && isFinite(value)) {
|
||||
return String(Math.round(value * 100) / 100);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** 站端/台账编辑时对比字段,生成变更日志条目(时间倒序由调用方 unshift) */
|
||||
function h2BridgeBuildFieldChangeLogs(prev, next, operatorName) {
|
||||
if (!prev || !next) return [];
|
||||
var fields = [
|
||||
{ key: 'hydrogenTime', label: '加氢时间' },
|
||||
{ key: 'plateNo', label: '车牌号' },
|
||||
{ key: 'hydrogenKg', label: '加氢量' },
|
||||
{ key: 'costUnitPrice', label: '氢气单价' },
|
||||
{ key: 'costTotal', label: '加氢总额', alt: 'customerAmount' },
|
||||
{ key: 'mileageKm', label: '里程' }
|
||||
];
|
||||
var logs = [];
|
||||
var now = new Date();
|
||||
var nowStr = now.getFullYear() + '-' + h2BridgePad2(now.getMonth() + 1) + '-' + h2BridgePad2(now.getDate())
|
||||
+ ' ' + h2BridgePad2(now.getHours()) + ':' + h2BridgePad2(now.getMinutes()) + ':' + h2BridgePad2(now.getSeconds());
|
||||
fields.forEach(function (f) {
|
||||
var before = prev[f.key];
|
||||
var after = next[f.key];
|
||||
if (before == null && f.alt) before = prev[f.alt];
|
||||
if (after == null && f.alt) after = next[f.alt];
|
||||
var beforeText = h2BridgeFormatLogScalar(before);
|
||||
var afterText = h2BridgeFormatLogScalar(after);
|
||||
if (beforeText === afterText) return;
|
||||
logs.push({
|
||||
id: 'clog-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
|
||||
at: nowStr,
|
||||
operatorName: operatorName || next.creatorName || '系统',
|
||||
userName: operatorName || next.creatorName || '系统',
|
||||
field: f.key,
|
||||
fieldLabel: f.label,
|
||||
oldValue: beforeText,
|
||||
newValue: afterText,
|
||||
before: beforeText,
|
||||
after: afterText
|
||||
});
|
||||
});
|
||||
return logs;
|
||||
}
|
||||
|
||||
function h2BridgeEnrichSeedDefaults(row) {
|
||||
var next = h2BridgeNormalizeRow(row);
|
||||
if (next.verifyStatus === H2_VERIFY_VERIFIED && !next.verifiedByName) {
|
||||
next.verifiedByName = next.creatorName === '站端账号' ? '李业务' : (next.creatorName || '李业务');
|
||||
}
|
||||
if (next.reconcileStatus === H2_RECONCILE_RECONCILED && !next.reconciledByName) {
|
||||
next.reconciledByName = '系统管理员';
|
||||
}
|
||||
if (next.key === 'rf-13' && (!next.changeLogs || !next.changeLogs.length)) {
|
||||
next.changeLogs = [
|
||||
{
|
||||
id: 'clog-seed-rf13-1',
|
||||
at: '2026-07-14 11:20:00',
|
||||
operatorName: '站端账号',
|
||||
userName: '站端账号',
|
||||
field: 'hydrogenKg',
|
||||
fieldLabel: '加氢量',
|
||||
oldValue: '13.50',
|
||||
newValue: '14.00',
|
||||
before: '13.50',
|
||||
after: '14.00'
|
||||
}
|
||||
];
|
||||
}
|
||||
if (next.key === 'rf-1' && (!next.changeLogs || !next.changeLogs.length)) {
|
||||
next.changeLogs = [
|
||||
{
|
||||
id: 'clog-seed-rf1-1',
|
||||
at: '2026-05-28 11:05:00',
|
||||
operatorName: '张三',
|
||||
userName: '张三',
|
||||
field: 'costUnitPrice',
|
||||
fieldLabel: '氢气单价',
|
||||
oldValue: '42.00',
|
||||
newValue: '42.50',
|
||||
before: '42.00',
|
||||
after: '42.50'
|
||||
}
|
||||
];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function h2BridgeCloneRows(rows) {
|
||||
return (rows || []).map(function (r) {
|
||||
var cloned = Object.assign({}, r);
|
||||
if (Array.isArray(r.changeLogs)) {
|
||||
cloned.changeLogs = r.changeLogs.map(function (log) { return Object.assign({}, log); });
|
||||
}
|
||||
return h2BridgeNormalizeRow(cloned);
|
||||
});
|
||||
}
|
||||
|
||||
function h2BridgeApplyStatementPatches(rows, patches) {
|
||||
if (!patches || !patches.length) return h2BridgeCloneRows(rows);
|
||||
return (rows || []).map(function (r) {
|
||||
@@ -58,26 +212,95 @@ function h2BridgeApplyStatementPatches(rows, patches) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!patch) return r;
|
||||
return Object.assign({}, r, {
|
||||
if (!patch) return h2BridgeNormalizeRow(r);
|
||||
return h2BridgeNormalizeRow(Object.assign({}, r, {
|
||||
statementPatched: true,
|
||||
reconcileStatus: H2_RECONCILE_RECONCILED,
|
||||
reconcileDate: patch.reconcileDate,
|
||||
receiptDate: patch.receiptDate,
|
||||
stationPaymentStatus: patch.paymentStatus || 'paid',
|
||||
paymentStatus: patch.paymentStatus || 'paid',
|
||||
reconciledAt: patch.reconcileDate
|
||||
});
|
||||
statementRecordId: patch.statementRecordId != null ? patch.statementRecordId : r.statementRecordId,
|
||||
reconciledByName: patch.reconciler || patch.reconciledByName || r.reconciledByName || ''
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function h2BridgeBuildInitialRows() {
|
||||
var patches = (typeof window !== 'undefined' && window.H2_STATION_STATEMENT_LEDGER_UPDATES) || [];
|
||||
return h2BridgeApplyStatementPatches(h2BridgeCloneRows(H2_CANONICAL_LEDGER_SEED), patches);
|
||||
var seeded = (H2_CANONICAL_LEDGER_SEED || []).map(h2BridgeEnrichSeedDefaults);
|
||||
return h2BridgeApplyStatementPatches(seeded, patches);
|
||||
}
|
||||
|
||||
function h2BridgePad2(n) {
|
||||
return n < 10 ? '0' + n : String(n);
|
||||
}
|
||||
|
||||
/** 自然日 YYYY-MM-DD(设备本地时区) */
|
||||
function h2BridgeTodayDateKey(d) {
|
||||
var x = d || new Date();
|
||||
return x.getFullYear() + '-' + h2BridgePad2(x.getMonth() + 1) + '-' + h2BridgePad2(x.getDate());
|
||||
}
|
||||
|
||||
function h2BridgeYesterdayDateKey() {
|
||||
var d = new Date();
|
||||
d.setDate(d.getDate() - 1);
|
||||
return h2BridgeTodayDateKey(d);
|
||||
}
|
||||
|
||||
function h2BridgeManualLedgerKey(stationId, ledgerDate) {
|
||||
return String(stationId || '').trim() + '|' + String(ledgerDate || '').trim();
|
||||
}
|
||||
|
||||
/** 解析站点编码:支持传入编码或站名(勿把站名原样当 id) */
|
||||
function h2BridgeResolveStationId(stationId, stationName) {
|
||||
var a = String(stationId || '').trim();
|
||||
var b = String(stationName || '').trim();
|
||||
if (a && H2_STATION_CODE_MAP[a]) return H2_STATION_CODE_MAP[a];
|
||||
if (b && H2_STATION_CODE_MAP[b]) return H2_STATION_CODE_MAP[b];
|
||||
if (a && H2_STATION_CODE_SET[a]) return a;
|
||||
if (b && H2_STATION_CODE_SET[b]) return b;
|
||||
return a || '';
|
||||
}
|
||||
|
||||
/** 手工台账种子:昨日已上传;今日默认未上传,便于演示拦截新增 */
|
||||
function h2BridgeBuildInitialManualLedgers() {
|
||||
var yesterday = h2BridgeYesterdayDateKey();
|
||||
return [
|
||||
{
|
||||
id: 'ml-seed-yesterday',
|
||||
stationId: 'JX-H2-001',
|
||||
stationName: '中国石油中油高新能源牙谷加油加氢站',
|
||||
ledgerDate: yesterday,
|
||||
images: [
|
||||
{
|
||||
id: 'ml-img-seed-1',
|
||||
name: '昨日手工台账.jpg',
|
||||
dataUrl: '',
|
||||
placeholder: true,
|
||||
archived: true,
|
||||
uploadedAt: yesterday + ' 18:30:00'
|
||||
}
|
||||
],
|
||||
updatedAt: yesterday + ' 18:30:00'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function h2BridgeCloneManualLedgers(list) {
|
||||
return (list || []).map(function (item) {
|
||||
return Object.assign({}, item, {
|
||||
images: (item.images || []).map(function (img) { return Object.assign({}, img); })
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function h2BridgeCreateStore() {
|
||||
var listeners = [];
|
||||
var state = { rows: h2BridgeBuildInitialRows() };
|
||||
var state = {
|
||||
rows: h2BridgeBuildInitialRows(),
|
||||
manualLedgers: h2BridgeBuildInitialManualLedgers()
|
||||
};
|
||||
|
||||
function notify() {
|
||||
listeners.forEach(function (fn) {
|
||||
@@ -91,6 +314,7 @@ function h2BridgeCreateStore() {
|
||||
state.rows = h2BridgeCloneRows(rows);
|
||||
notify();
|
||||
},
|
||||
getManualLedgers: function () { return h2BridgeCloneManualLedgers(state.manualLedgers); },
|
||||
subscribe: function (fn) {
|
||||
listeners.push(fn);
|
||||
return function () {
|
||||
@@ -101,6 +325,145 @@ function h2BridgeCreateStore() {
|
||||
if (!patches || !patches.length) return;
|
||||
state.rows = h2BridgeApplyStatementPatches(state.rows, patches);
|
||||
notify();
|
||||
},
|
||||
upsertRow: function (row) {
|
||||
if (!row) return null;
|
||||
var next = Object.assign({}, row);
|
||||
if (!next.id && !next.key) {
|
||||
next.id = 'rf-h5-' + Date.now() + '-' + Math.floor(Math.random() * 1000);
|
||||
next.key = next.id;
|
||||
} else {
|
||||
next.id = next.id || next.key;
|
||||
next.key = next.key || next.id;
|
||||
}
|
||||
if (!next.reconcileStatus) next.reconcileStatus = H2_RECONCILE_PENDING;
|
||||
if (!next.verifyStatus) next.verifyStatus = H2_VERIFY_UNVERIFIED;
|
||||
if (!next.recordSource) next.recordSource = h2BridgeInferRecordSource(next);
|
||||
var found = false;
|
||||
var saved = null;
|
||||
state.rows = state.rows.map(function (r) {
|
||||
if ((r.id || r.key) === next.id || (r.id || r.key) === next.key) {
|
||||
found = true;
|
||||
var merged = h2BridgeNormalizeRow(Object.assign({}, r, next));
|
||||
if (!next.recordSource && r.recordSource) merged.recordSource = r.recordSource;
|
||||
var operator = next.changeLogOperator || next.creatorName || r.creatorName || '系统';
|
||||
var fieldLogs = h2BridgeBuildFieldChangeLogs(r, merged, operator);
|
||||
var prevLogs = Array.isArray(r.changeLogs) ? r.changeLogs.slice() : [];
|
||||
if (Array.isArray(next.changeLogs) && next.replaceChangeLogs) {
|
||||
merged.changeLogs = next.changeLogs.slice();
|
||||
} else {
|
||||
merged.changeLogs = fieldLogs.concat(prevLogs);
|
||||
}
|
||||
saved = merged;
|
||||
return merged;
|
||||
}
|
||||
return r;
|
||||
});
|
||||
if (!found) {
|
||||
saved = h2BridgeNormalizeRow(next);
|
||||
if (!Array.isArray(saved.changeLogs)) saved.changeLogs = [];
|
||||
state.rows = [saved].concat(state.rows);
|
||||
}
|
||||
notify();
|
||||
return Object.assign({}, saved);
|
||||
},
|
||||
removeRow: function (id) {
|
||||
var target = String(id || '');
|
||||
if (!target) return false;
|
||||
var before = state.rows.length;
|
||||
state.rows = state.rows.filter(function (r) {
|
||||
return String(r.id || r.key || '') !== target;
|
||||
});
|
||||
var changed = state.rows.length !== before;
|
||||
if (changed) notify();
|
||||
return changed;
|
||||
},
|
||||
upsertManualLedger: function (input) {
|
||||
if (!input) return null;
|
||||
var stationId = h2BridgeResolveStationId(input.stationId, input.stationName);
|
||||
var stationName = String(input.stationName || '').trim();
|
||||
var ledgerDate = String(input.ledgerDate || h2BridgeTodayDateKey()).trim();
|
||||
if (!stationId || !ledgerDate) return null;
|
||||
var key = h2BridgeManualLedgerKey(stationId, ledgerDate);
|
||||
var existing = null;
|
||||
state.manualLedgers.forEach(function (item) {
|
||||
if (h2BridgeManualLedgerKey(item.stationId, item.ledgerDate) === key) existing = item;
|
||||
});
|
||||
function normalizeImage(img, i) {
|
||||
return {
|
||||
id: img.id || ('ml-img-' + Date.now() + '-' + i),
|
||||
name: String(img.name || ('台账照片' + (i + 1) + '.jpg')),
|
||||
dataUrl: String(img.dataUrl || ''),
|
||||
placeholder: Boolean(img.placeholder),
|
||||
archived: true,
|
||||
uploadedAt: img.uploadedAt || (ledgerDate + ' 12:00:00')
|
||||
};
|
||||
}
|
||||
/** 已归档图不可删:保留原有全部,仅追加新图 */
|
||||
var images = (existing && existing.images ? existing.images : []).map(function (img, i) {
|
||||
return normalizeImage(img, i);
|
||||
});
|
||||
var seen = {};
|
||||
images.forEach(function (img) { seen[img.id] = true; });
|
||||
(input.images || []).forEach(function (img, i) {
|
||||
var nextImg = normalizeImage(img, images.length + i);
|
||||
if (!nextImg.dataUrl && !nextImg.placeholder) return;
|
||||
if (seen[nextImg.id]) return;
|
||||
seen[nextImg.id] = true;
|
||||
images.push(nextImg);
|
||||
});
|
||||
if (!images.length) return null;
|
||||
var now = new Date();
|
||||
var nowStr = now.getFullYear() + '-' + h2BridgePad2(now.getMonth() + 1) + '-' + h2BridgePad2(now.getDate())
|
||||
+ ' ' + h2BridgePad2(now.getHours()) + ':' + h2BridgePad2(now.getMinutes()) + ':' + h2BridgePad2(now.getSeconds());
|
||||
var next = {
|
||||
id: 'ml-' + stationId + '-' + ledgerDate,
|
||||
stationId: stationId,
|
||||
stationName: stationName || stationId,
|
||||
ledgerDate: ledgerDate,
|
||||
images: images,
|
||||
updatedAt: nowStr
|
||||
};
|
||||
var found = false;
|
||||
state.manualLedgers = state.manualLedgers.map(function (item) {
|
||||
if (h2BridgeManualLedgerKey(item.stationId, item.ledgerDate) === key) {
|
||||
found = true;
|
||||
return next;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (!found) state.manualLedgers = [next].concat(state.manualLedgers);
|
||||
notify();
|
||||
return h2BridgeCloneManualLedgers([next])[0];
|
||||
},
|
||||
getManualLedger: function (stationIdOrName, ledgerDate) {
|
||||
var dateKey = String(ledgerDate || h2BridgeTodayDateKey()).trim();
|
||||
var sid = h2BridgeResolveStationId(stationIdOrName, stationIdOrName);
|
||||
if (!sid) sid = h2BridgeResolveStationId('', stationIdOrName);
|
||||
if (!sid) return null;
|
||||
var key = h2BridgeManualLedgerKey(sid, dateKey);
|
||||
var i;
|
||||
for (i = 0; i < state.manualLedgers.length; i++) {
|
||||
var item = state.manualLedgers[i];
|
||||
if (h2BridgeManualLedgerKey(item.stationId, item.ledgerDate) === key) {
|
||||
return h2BridgeCloneManualLedgers([item])[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
listManualLedgersByStation: function (stationIdOrName) {
|
||||
var sid = h2BridgeResolveStationId(stationIdOrName, stationIdOrName);
|
||||
if (!sid) sid = h2BridgeResolveStationId('', stationIdOrName);
|
||||
if (!sid) return [];
|
||||
return h2BridgeCloneManualLedgers(state.manualLedgers.filter(function (item) {
|
||||
return item.stationId === sid;
|
||||
})).sort(function (a, b) {
|
||||
return String(b.ledgerDate).localeCompare(String(a.ledgerDate));
|
||||
});
|
||||
},
|
||||
hasManualLedgerForDate: function (stationIdOrName, ledgerDate) {
|
||||
var entry = this.getManualLedger(stationIdOrName, ledgerDate);
|
||||
return Boolean(entry && entry.images && entry.images.length);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -154,17 +517,39 @@ function h2BridgeMapToStationLedgerRow(row, index) {
|
||||
|
||||
function h2BridgeMapToHrRecord(row, index, fillerNames) {
|
||||
var fillers = fillerNames || ['张三', '李四', '王静', '赵敏', '陈浩'];
|
||||
var costTotal = row.costTotal != null ? row.costTotal : row.costAmount;
|
||||
var unitPrice = row.costUnitPrice != null ? row.costUnitPrice : row.customerUnitPrice;
|
||||
var totalAmount = costTotal != null ? costTotal : row.customerAmount;
|
||||
var reconcileTime = h2BridgeResolveReconcileTime(row);
|
||||
var isReconciled = row.reconcileStatus === H2_RECONCILE_RECONCILED || Boolean(row.reconcileDate);
|
||||
var verifiedAt = row.verifiedAt || '';
|
||||
var changeLogs = Array.isArray(row.changeLogs)
|
||||
? row.changeLogs.map(function (log) { return Object.assign({}, log); })
|
||||
: [];
|
||||
return {
|
||||
id: row.id || row.key || ('hr-' + (index + 1)),
|
||||
hydrogenTime: h2BridgeFormatDateTime(row.hydrogenTime),
|
||||
plateNo: row.plateNo,
|
||||
hydrogenKg: row.hydrogenKg,
|
||||
customerAmount: row.customerAmount,
|
||||
unitPrice: unitPrice,
|
||||
totalAmount: totalAmount,
|
||||
/* 兼容旧列名:金额同站端口径 */
|
||||
customerAmount: totalAmount,
|
||||
costUnitPrice: unitPrice,
|
||||
costTotal: totalAmount,
|
||||
mileageKm: row.mileageKm,
|
||||
fillerName: row.creatorName || fillers[index % fillers.length],
|
||||
settlementStatus: row.settlementStatus,
|
||||
stationName: row.stationName,
|
||||
stationCode: row.stationId || H2_STATION_CODE_MAP[row.stationName] || ''
|
||||
stationCode: row.stationId || H2_STATION_CODE_MAP[row.stationName] || '',
|
||||
recordSource: h2BridgeInferRecordSource(row),
|
||||
verifyStatus: row.verifyStatus === H2_VERIFY_VERIFIED ? H2_VERIFY_VERIFIED : H2_VERIFY_UNVERIFIED,
|
||||
verifiedAt: verifiedAt ? h2BridgeFormatDateTime(verifiedAt) : '',
|
||||
verifiedByName: row.verifiedByName || '',
|
||||
reconcileStatus: isReconciled ? H2_RECONCILE_RECONCILED : H2_RECONCILE_PENDING,
|
||||
reconcileTime: isReconciled ? reconcileTime : '',
|
||||
reconciledByName: row.reconciledByName || '',
|
||||
changeLogs: changeLogs
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,6 +636,84 @@ function h2BridgeGetStationList() {
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 车牌写入口径:大写并补尾缀 F */
|
||||
function h2BridgeNormalizePlate(plateNo) {
|
||||
var plate = String(plateNo || '').trim().toUpperCase();
|
||||
if (plate && !/F$/u.test(plate)) plate += 'F';
|
||||
return plate;
|
||||
}
|
||||
|
||||
/** 时间写入口径:补全秒 */
|
||||
function h2BridgeNormalizeHydrogenTime(hydrogenTime) {
|
||||
var s = String(hydrogenTime || '').trim();
|
||||
if (s.length === 16) s += ':00';
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务主键:同站 + 同车牌 + 同加氢时间(精确到秒)视为重复。
|
||||
* @returns {object|null} 已存在行的浅拷贝,未命中返回 null
|
||||
*/
|
||||
function h2BridgeFindDuplicateRow(candidate, excludeId) {
|
||||
if (!candidate) return null;
|
||||
var store = h2BridgeGetStore();
|
||||
if (!store) return null;
|
||||
var station = String(candidate.stationName || '').trim();
|
||||
var plate = h2BridgeNormalizePlate(candidate.plateNo);
|
||||
var time = h2BridgeNormalizeHydrogenTime(candidate.hydrogenTime);
|
||||
if (!station || !plate || !time) return null;
|
||||
var exclude = excludeId != null ? String(excludeId) : '';
|
||||
var rows = store.getRows();
|
||||
var i;
|
||||
for (i = 0; i < rows.length; i++) {
|
||||
var row = rows[i];
|
||||
var id = String(row.id || row.key || '');
|
||||
if (exclude && id === exclude) continue;
|
||||
if (String(row.stationName || '').trim() !== station) continue;
|
||||
if (h2BridgeNormalizePlate(row.plateNo) !== plate) continue;
|
||||
if (h2BridgeNormalizeHydrogenTime(row.hydrogenTime) !== time) continue;
|
||||
return Object.assign({}, row);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 总额与「单价×量」偏差(元)。容差默认 0.05。
|
||||
* @returns {{ expected: number, actual: number, diff: number }|null}
|
||||
*/
|
||||
function h2BridgeTotalAmountMismatch(unitPrice, hydrogenKg, totalAmount, toleranceYuan) {
|
||||
var p = Number(unitPrice);
|
||||
var k = Number(hydrogenKg);
|
||||
var a = Number(totalAmount);
|
||||
if (!isFinite(p) || !isFinite(k) || !isFinite(a)) return null;
|
||||
var expected = Math.round(p * k * 100) / 100;
|
||||
var actual = Math.round(a * 100) / 100;
|
||||
var tol = toleranceYuan != null ? Number(toleranceYuan) : 0.05;
|
||||
if (!isFinite(tol)) tol = 0.05;
|
||||
var diff = Math.round(Math.abs(actual - expected) * 100) / 100;
|
||||
if (diff <= tol) return null;
|
||||
return { expected: expected, actual: actual, diff: diff };
|
||||
}
|
||||
|
||||
/** 取该站最近一条流水的站端口径单价,缺省 42.5 */
|
||||
function h2BridgeLookupStationUnitPrice(stationName) {
|
||||
var name = String(stationName || '').trim();
|
||||
if (!name) return 42.5;
|
||||
var store = h2BridgeGetStore();
|
||||
if (!store) return 42.5;
|
||||
var rows = store.getRows().slice().sort(function (a, b) {
|
||||
return String(b.hydrogenTime || '').localeCompare(String(a.hydrogenTime || ''));
|
||||
});
|
||||
var i;
|
||||
for (i = 0; i < rows.length; i++) {
|
||||
if (String(rows[i].stationName || '').trim() !== name) continue;
|
||||
var price = rows[i].costUnitPrice != null ? rows[i].costUnitPrice : rows[i].customerUnitPrice;
|
||||
var n = Number(price);
|
||||
if (isFinite(n) && n >= 0) return Math.round(n * 100) / 100;
|
||||
}
|
||||
return 42.5;
|
||||
}
|
||||
|
||||
function h2BridgeInit() {
|
||||
h2BridgeEnsureApi();
|
||||
var store = h2BridgeGetStore();
|
||||
@@ -263,6 +726,26 @@ function h2BridgeInit() {
|
||||
getHrRecords: h2BridgeGetHrRecords,
|
||||
getOrderRecords: h2BridgeGetOrderRecords,
|
||||
getStationList: h2BridgeGetStationList,
|
||||
normalizePlate: h2BridgeNormalizePlate,
|
||||
normalizeHydrogenTime: h2BridgeNormalizeHydrogenTime,
|
||||
findDuplicateRow: h2BridgeFindDuplicateRow,
|
||||
totalAmountMismatch: h2BridgeTotalAmountMismatch,
|
||||
lookupStationUnitPrice: h2BridgeLookupStationUnitPrice,
|
||||
todayDateKey: h2BridgeTodayDateKey,
|
||||
getManualLedger: function (stationIdOrName, ledgerDate) {
|
||||
return store.getManualLedger(stationIdOrName, ledgerDate);
|
||||
},
|
||||
listManualLedgersByStation: function (stationIdOrName) {
|
||||
return store.listManualLedgersByStation(stationIdOrName);
|
||||
},
|
||||
hasManualLedgerForDate: function (stationIdOrName, ledgerDate) {
|
||||
return store.hasManualLedgerForDate(stationIdOrName, ledgerDate);
|
||||
},
|
||||
upsertManualLedger: function (input) {
|
||||
return store.upsertManualLedger(input);
|
||||
},
|
||||
upsertRow: function (row) { return store.upsertRow(row); },
|
||||
removeRow: function (id) { return store.removeRow(id); },
|
||||
subscribe: function (fn) { return store.subscribe(fn); }
|
||||
};
|
||||
}
|
||||
@@ -274,6 +757,12 @@ h2BridgeInit();
|
||||
export {
|
||||
H2_CANONICAL_LEDGER_SEED,
|
||||
H2_STATION_CODE_MAP,
|
||||
H2_RECONCILE_RECONCILED,
|
||||
H2_RECONCILE_PENDING,
|
||||
H2_VERIFY_VERIFIED,
|
||||
H2_VERIFY_UNVERIFIED,
|
||||
H2_RECORD_SOURCE_STATION,
|
||||
H2_RECORD_SOURCE_LINGNIU,
|
||||
h2BridgeInit,
|
||||
h2BridgeGetStore,
|
||||
h2BridgeGetAllRefuelRecords,
|
||||
@@ -281,5 +770,11 @@ export {
|
||||
h2BridgeBuildStationLedgerStore,
|
||||
h2BridgeGetHrRecords,
|
||||
h2BridgeGetOrderRecords,
|
||||
h2BridgeGetStationList
|
||||
h2BridgeGetStationList,
|
||||
h2BridgeNormalizePlate,
|
||||
h2BridgeNormalizeHydrogenTime,
|
||||
h2BridgeFindDuplicateRow,
|
||||
h2BridgeTotalAmountMismatch,
|
||||
h2BridgeLookupStationUnitPrice,
|
||||
h2BridgeInferRecordSource
|
||||
};
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
}
|
||||
|
||||
.vm-kpi-val,
|
||||
.lbd-kpi-val,
|
||||
.bdl-kpi-val,
|
||||
.sob-kpi-val,
|
||||
.vm-mileage,
|
||||
.vm-expire-date,
|
||||
.vm-handover-situation-line--mile,
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
/**
|
||||
* 列表页筛选区全局规则(与 vehicle-management/style.css 中 .vm-filter-grid 4 列布局一致)
|
||||
*
|
||||
* 规则:筛选项超过 1 行(默认 4 项)时,其余收入折叠区,通过「更多筛选」展开。
|
||||
* 完整视觉与交互规范:`rules/global-design-spec.md` §4.1
|
||||
* 参考实现:`src/prototypes/lease-business-detail/components/FilterPanel.tsx`
|
||||
*
|
||||
* ## 布局约束
|
||||
* 1. 收起时:首行(primary)最多展示 `VM_FILTER_COLUMNS_PER_ROW`(默认 4)项,占满 1 行。
|
||||
* 2. 展开时:其余项放入 `vm-filter-expand`(`ldb-filter-expand` 为同义别名);primary 与 extra 各自使用 4 列栅格。
|
||||
* 3. 禁止「末行仅 1 项」:若展开区项数为 4n+1,从首行尾部借调 1 项到展开区(首行变为 3 项)。
|
||||
* 典型:总项数 5 → 首行 3 + 展开 2;总项数 6 → 首行 4 + 展开 2。
|
||||
* 4. 切换文案仅「更多筛选」/「收起」;按钮类名 `vm-btn vm-btn-link vm-filter-toggle`。
|
||||
*
|
||||
* ## 占位符(筛选卡内强制)
|
||||
* font: `var(--vm-font)`;size: 0.875rem;weight: 400;color: `var(--ln-muted-soft)`;opacity: 1
|
||||
*/
|
||||
export const VM_FILTER_COLUMNS_PER_ROW = 4;
|
||||
|
||||
@@ -12,13 +23,37 @@ export function splitFilterFields<T>(
|
||||
fields: T[],
|
||||
primaryCount: number = VM_FILTER_PRIMARY_VISIBLE_COUNT,
|
||||
): { primary: T[]; extra: T[] } {
|
||||
if (fields.length <= primaryCount) {
|
||||
const columnsPerRow = primaryCount;
|
||||
if (fields.length <= columnsPerRow) {
|
||||
return { primary: fields, extra: [] };
|
||||
}
|
||||
return {
|
||||
primary: fields.slice(0, primaryCount),
|
||||
extra: fields.slice(primaryCount),
|
||||
};
|
||||
|
||||
const primary = fields.slice(0, columnsPerRow);
|
||||
const extra = fields.slice(columnsPerRow);
|
||||
|
||||
return rebalanceFilterSplit(primary, extra, columnsPerRow);
|
||||
}
|
||||
|
||||
/** 避免展开区出现 4n+1 项导致末行孤零零 1 项 */
|
||||
function rebalanceFilterSplit<T>(
|
||||
primary: T[],
|
||||
extra: T[],
|
||||
columnsPerRow: number,
|
||||
): { primary: T[]; extra: T[] } {
|
||||
const nextPrimary = [...primary];
|
||||
const nextExtra = [...extra];
|
||||
|
||||
while (
|
||||
nextExtra.length > 0
|
||||
&& nextExtra.length % columnsPerRow === 1
|
||||
&& nextPrimary.length > 0
|
||||
) {
|
||||
const moved = nextPrimary.pop();
|
||||
if (moved === undefined) break;
|
||||
nextExtra.unshift(moved);
|
||||
}
|
||||
|
||||
return { primary: nextPrimary, extra: nextExtra };
|
||||
}
|
||||
|
||||
export function shouldShowFilterExpand(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
28
src/prototypes/customer-payment-collection/index.tsx
Normal file
28
src/prototypes/customer-payment-collection/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @name 客户回款情况
|
||||
* 自 ONE-OS Web 端 / 数据分析 / 客户回款情况 独立预览(OneOS 菜单入口)
|
||||
*/
|
||||
import '../../common/oneosWebLegacy/legacyGlobals';
|
||||
import React, { useEffect } from 'react';
|
||||
import * as antd from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||
import CustomerPaymentCollectionPage from './pages/客户回款情况.jsx';
|
||||
|
||||
window.React = React;
|
||||
window.antd = antd;
|
||||
window.dayjs = dayjs;
|
||||
|
||||
export default function CustomerPaymentCollection() {
|
||||
useEffect(() => {
|
||||
clearHostPrototypeRouteInfo();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<CustomerPaymentCollectionPage />
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
847
src/prototypes/customer-payment-collection/pages/客户回款情况.jsx
Normal file
847
src/prototypes/customer-payment-collection/pages/客户回款情况.jsx
Normal file
@@ -0,0 +1,847 @@
|
||||
// 【重要】必须使用 const Component 作为组件变量名
|
||||
// 数据分析 - 客户回款情况
|
||||
// 默认展示当期客户应收/实收/期末余额;钻取至账单周期与多业务条线;实收变更实时联动期末余额
|
||||
|
||||
const Component = function () {
|
||||
var useState = React.useState;
|
||||
var useMemo = React.useMemo;
|
||||
var useCallback = React.useCallback;
|
||||
|
||||
var antd = window.antd;
|
||||
var App = antd.App;
|
||||
var Card = antd.Card;
|
||||
var Button = antd.Button;
|
||||
var Table = antd.Table;
|
||||
var Select = antd.Select;
|
||||
var DatePicker = antd.DatePicker;
|
||||
var Row = antd.Row;
|
||||
var Col = antd.Col;
|
||||
var Space = antd.Space;
|
||||
var Breadcrumb = antd.Breadcrumb;
|
||||
var InputNumber = antd.InputNumber;
|
||||
var Tag = antd.Tag;
|
||||
var Statistic = antd.Statistic;
|
||||
var message = antd.message;
|
||||
|
||||
var LINE_DEFS = [
|
||||
{ key: 'lease', label: '租赁业务' },
|
||||
{ key: 'logistics', label: '物流业务' },
|
||||
{ key: 'h2', label: '氢费业务' },
|
||||
{ key: 'electricity', label: '电费业务' },
|
||||
{ key: 'etc', label: 'ETC业务' }
|
||||
];
|
||||
|
||||
var LINE_KEYS = LINE_DEFS.map(function (d) { return d.key; });
|
||||
|
||||
var CUSTOMER_SEEDS = [
|
||||
{ name: '嘉兴古道物流有限公司', dept: '业务二部', salesperson: '谈云' },
|
||||
{ name: '杭州绿道城配科技有限公司', dept: '业务二部', salesperson: '刘念念' },
|
||||
{ name: '宁波港联氢运物流有限公司', dept: '业务一部', salesperson: '谯云' },
|
||||
{ name: '上海虹钦物流有限公司', dept: '业务一部', salesperson: '董剑煜' },
|
||||
{ name: '嘉兴市乍浦港口经营有限公司', dept: '业务三部', salesperson: '尚建华' },
|
||||
{ name: '荣达餐饮(广东)集团有限公司', dept: '业务三部', salesperson: '谈云' },
|
||||
{ name: '苏州氢能城配科技有限公司', dept: '业务二部', salesperson: '刘念念' },
|
||||
{ name: '无锡绿运供应链有限公司', dept: '业务一部', salesperson: '谯云' }
|
||||
];
|
||||
|
||||
function filterOption(input, option) {
|
||||
var label = (option && (option.label || option.children)) || '';
|
||||
return String(label).toLowerCase().indexOf(String(input || '').toLowerCase()) >= 0;
|
||||
}
|
||||
|
||||
function fmtMoney(n) {
|
||||
if (n === null || n === undefined || n === '') return '—';
|
||||
var x = Number(n);
|
||||
if (isNaN(x)) return '—';
|
||||
return x.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function numOrZero(v) {
|
||||
if (v === null || v === undefined || v === '') return 0;
|
||||
var n = Number(v);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
function escapeCsv(v) {
|
||||
var s = v == null ? '' : String(v);
|
||||
if (s.indexOf(',') !== -1 || s.indexOf('"') !== -1 || s.indexOf('\n') !== -1) {
|
||||
return '"' + s.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function downloadCsv(filename, lines) {
|
||||
var csv = lines.map(function (row) { return row.map(escapeCsv).join(','); }).join('\n');
|
||||
var blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function defaultPeriodMonth() {
|
||||
try {
|
||||
if (window.dayjs) return window.dayjs('2026-07-01');
|
||||
} catch (e1) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function periodKeyFromMonth(m) {
|
||||
if (!m || !m.format) return '2026-07';
|
||||
return m.format('YYYY-MM');
|
||||
}
|
||||
|
||||
function periodLabel(key) {
|
||||
if (!key || key.indexOf('-') < 0) return key || '—';
|
||||
var parts = key.split('-');
|
||||
return parts[0] + '年' + Number(parts[1]) + '月';
|
||||
}
|
||||
|
||||
function lineLabel(key) {
|
||||
var found = LINE_DEFS.find(function (d) { return d.key === key; });
|
||||
return found ? found.label : key;
|
||||
}
|
||||
|
||||
function sumLineAmounts(lines, field) {
|
||||
var total = 0;
|
||||
LINE_KEYS.forEach(function (k) {
|
||||
total += numOrZero(lines && lines[k] && lines[k][field]);
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
function periodUncollected(lines) {
|
||||
return sumLineAmounts(lines, 'receivable') - sumLineAmounts(lines, 'received');
|
||||
}
|
||||
|
||||
function buildSeedRecords() {
|
||||
var records = [];
|
||||
var months = ['2026-01', '2026-02', '2026-03', '2026-04', '2026-05', '2026-06', '2026-07'];
|
||||
CUSTOMER_SEEDS.forEach(function (c, ci) {
|
||||
var periods = months.map(function (pk, mi) {
|
||||
var lines = {};
|
||||
LINE_KEYS.forEach(function (lk, li) {
|
||||
var base = 28000 + ci * 4200 + mi * 1800 + li * 2600;
|
||||
var recv = Math.round(base * (0.85 + (ci % 3) * 0.05));
|
||||
var got = recv - (mi % 4 === 0 && li % 2 === 0 ? Math.round(recv * 0.18) : Math.round(recv * 0.06));
|
||||
lines[lk] = { receivable: recv, received: got };
|
||||
});
|
||||
return { periodKey: pk, lines: lines };
|
||||
});
|
||||
records.push({
|
||||
id: 'cust-' + ci,
|
||||
customerName: c.name,
|
||||
dept: c.dept,
|
||||
salesperson: c.salesperson,
|
||||
periods: periods
|
||||
});
|
||||
});
|
||||
return records;
|
||||
}
|
||||
|
||||
function enrichCustomerPeriods(customer) {
|
||||
var sorted = (customer.periods || []).slice().sort(function (a, b) {
|
||||
return String(a.periodKey).localeCompare(String(b.periodKey));
|
||||
});
|
||||
var cumulative = 0;
|
||||
return sorted.map(function (p) {
|
||||
var receivable = sumLineAmounts(p.lines, 'receivable');
|
||||
var received = sumLineAmounts(p.lines, 'received');
|
||||
var uncollected = receivable - received;
|
||||
cumulative += uncollected;
|
||||
return Object.assign({}, p, {
|
||||
receivable: receivable,
|
||||
received: received,
|
||||
uncollected: uncollected,
|
||||
endingBalance: cumulative
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getCustomerPeriod(customer, periodKey) {
|
||||
var enriched = enrichCustomerPeriods(customer);
|
||||
return enriched.find(function (p) { return p.periodKey === periodKey; }) || null;
|
||||
}
|
||||
|
||||
function buildCustomerSummaryRows(records, periodKey, filters) {
|
||||
return records.map(function (cust) {
|
||||
var periods = enrichCustomerPeriods(cust);
|
||||
var current = periods.find(function (p) { return p.periodKey === periodKey; });
|
||||
if (!current) return null;
|
||||
if (filters.dept && cust.dept !== filters.dept) return null;
|
||||
if (filters.salesperson && cust.salesperson !== filters.salesperson) return null;
|
||||
if (filters.customer && cust.customerName !== filters.customer) return null;
|
||||
if (filters.lineKey) {
|
||||
var line = current.lines[filters.lineKey];
|
||||
if (!line || (line.receivable === 0 && line.received === 0)) return null;
|
||||
}
|
||||
return {
|
||||
key: cust.id,
|
||||
customerId: cust.id,
|
||||
customerName: cust.customerName,
|
||||
dept: cust.dept,
|
||||
salesperson: cust.salesperson,
|
||||
receivable: current.receivable,
|
||||
received: current.received,
|
||||
uncollected: current.uncollected,
|
||||
endingBalance: current.endingBalance,
|
||||
periodKey: periodKey
|
||||
};
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function buildLineRows(customer, periodKey) {
|
||||
var period = getCustomerPeriod(customer, periodKey);
|
||||
if (!period) return [];
|
||||
return LINE_DEFS.map(function (def) {
|
||||
var line = period.lines[def.key] || { receivable: 0, received: 0 };
|
||||
var recv = numOrZero(line.receivable);
|
||||
var got = numOrZero(line.received);
|
||||
return {
|
||||
key: def.key,
|
||||
lineKey: def.key,
|
||||
lineLabel: def.label,
|
||||
receivable: recv,
|
||||
received: got,
|
||||
uncollected: recv - got
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function updateLineReceived(records, customerId, periodKey, lineKey, newReceived) {
|
||||
return records.map(function (cust) {
|
||||
if (cust.id !== customerId) return cust;
|
||||
var periods = cust.periods.map(function (p) {
|
||||
if (p.periodKey !== periodKey) return p;
|
||||
var lines = Object.assign({}, p.lines);
|
||||
var prev = lines[lineKey] || { receivable: 0, received: 0 };
|
||||
lines[lineKey] = {
|
||||
receivable: prev.receivable,
|
||||
received: Math.max(0, numOrZero(newReceived))
|
||||
};
|
||||
return Object.assign({}, p, { lines: lines });
|
||||
});
|
||||
return Object.assign({}, cust, { periods: periods });
|
||||
});
|
||||
}
|
||||
|
||||
var layoutStyle = { padding: '16px 24px', background: '#f5f7fa', minHeight: '100vh' };
|
||||
var filterLabelStyle = { marginBottom: 6, fontSize: 14, color: 'rgba(0,0,0,0.65)' };
|
||||
var filterItemStyle = { marginBottom: 12 };
|
||||
var filterControlStyle = { width: '100%' };
|
||||
var drillLinkStyle = { cursor: 'pointer', color: '#165dff', border: 'none', background: 'none', padding: 0, font: 'inherit' };
|
||||
var moneyCellStyle = { fontVariantNumeric: 'tabular-nums' };
|
||||
var warnStyle = { color: '#cf1322', fontVariantNumeric: 'tabular-nums' };
|
||||
|
||||
var tableStyle =
|
||||
'.cpr-table .ant-table-thead>tr>th{background:#e8f3ff!important;font-weight:500;font-size:13px}' +
|
||||
'.cpr-table .ant-table-tbody>tr>td{white-space:nowrap}' +
|
||||
'.cpr-table .ant-table-summary{background:#fafafa;font-weight:600}' +
|
||||
'.cpr-kpi .ant-statistic-title{font-size:13px;color:rgba(0,0,0,0.55)}' +
|
||||
'.cpr-kpi .ant-statistic-content{font-variant-numeric:tabular-nums}';
|
||||
|
||||
var recordsState = useState(buildSeedRecords);
|
||||
var records = recordsState[0];
|
||||
var setRecords = recordsState[1];
|
||||
|
||||
var periodDraftState = useState(defaultPeriodMonth);
|
||||
var periodDraft = periodDraftState[0];
|
||||
var setPeriodDraft = periodDraftState[1];
|
||||
var periodAppliedState = useState(defaultPeriodMonth);
|
||||
var periodApplied = periodAppliedState[0];
|
||||
var setPeriodApplied = periodAppliedState[1];
|
||||
|
||||
var deptDraftState = useState(undefined);
|
||||
var deptDraft = deptDraftState[0];
|
||||
var setDeptDraft = deptDraftState[1];
|
||||
var deptAppliedState = useState(undefined);
|
||||
var deptApplied = deptAppliedState[0];
|
||||
var setDeptApplied = deptAppliedState[1];
|
||||
|
||||
var spDraftState = useState(undefined);
|
||||
var spDraft = spDraftState[0];
|
||||
var setSpDraft = spDraftState[1];
|
||||
var spAppliedState = useState(undefined);
|
||||
var spApplied = spAppliedState[0];
|
||||
var setSpApplied = spAppliedState[1];
|
||||
|
||||
var cuDraftState = useState(undefined);
|
||||
var cuDraft = cuDraftState[0];
|
||||
var setCuDraft = cuDraftState[1];
|
||||
var cuAppliedState = useState(undefined);
|
||||
var cuApplied = cuAppliedState[0];
|
||||
var setCuApplied = cuAppliedState[1];
|
||||
|
||||
var lineDraftState = useState(undefined);
|
||||
var lineDraft = lineDraftState[0];
|
||||
var setLineDraft = lineDraftState[1];
|
||||
var lineAppliedState = useState(undefined);
|
||||
var lineApplied = lineAppliedState[0];
|
||||
var setLineApplied = lineAppliedState[1];
|
||||
|
||||
var viewState = useState({ level: 'list', customerId: null, periodKey: null });
|
||||
var view = viewState[0];
|
||||
var setView = viewState[1];
|
||||
|
||||
var appliedPeriodKey = useMemo(function () {
|
||||
return periodKeyFromMonth(periodApplied);
|
||||
}, [periodApplied]);
|
||||
|
||||
var filters = useMemo(function () {
|
||||
return {
|
||||
dept: deptApplied,
|
||||
salesperson: spApplied,
|
||||
customer: cuApplied,
|
||||
lineKey: lineApplied
|
||||
};
|
||||
}, [deptApplied, spApplied, cuApplied, lineApplied]);
|
||||
|
||||
var summaryRows = useMemo(function () {
|
||||
return buildCustomerSummaryRows(records, appliedPeriodKey, filters);
|
||||
}, [records, appliedPeriodKey, filters]);
|
||||
|
||||
var activeCustomer = useMemo(function () {
|
||||
if (!view.customerId) return null;
|
||||
return records.find(function (r) { return r.id === view.customerId; }) || null;
|
||||
}, [records, view.customerId]);
|
||||
|
||||
var customerPeriodRows = useMemo(function () {
|
||||
if (!activeCustomer) return [];
|
||||
return enrichCustomerPeriods(activeCustomer);
|
||||
}, [activeCustomer]);
|
||||
|
||||
var lineRows = useMemo(function () {
|
||||
if (!activeCustomer || !view.periodKey) return [];
|
||||
return buildLineRows(activeCustomer, view.periodKey);
|
||||
}, [activeCustomer, view.periodKey]);
|
||||
|
||||
var kpi = useMemo(function () {
|
||||
var recv = 0;
|
||||
var got = 0;
|
||||
var endBal = 0;
|
||||
summaryRows.forEach(function (r) {
|
||||
recv += numOrZero(r.receivable);
|
||||
got += numOrZero(r.received);
|
||||
endBal += numOrZero(r.endingBalance);
|
||||
});
|
||||
return {
|
||||
customerCount: summaryRows.length,
|
||||
receivable: recv,
|
||||
received: got,
|
||||
uncollected: recv - got,
|
||||
endingBalance: endBal
|
||||
};
|
||||
}, [summaryRows]);
|
||||
|
||||
var deptOptions = useMemo(function () {
|
||||
var set = {};
|
||||
records.forEach(function (r) { set[r.dept] = true; });
|
||||
return [{ value: '', label: '全部部门' }].concat(Object.keys(set).sort().map(function (d) {
|
||||
return { value: d, label: d };
|
||||
}));
|
||||
}, [records]);
|
||||
|
||||
var spOptions = useMemo(function () {
|
||||
var set = {};
|
||||
records.forEach(function (r) { set[r.salesperson] = true; });
|
||||
return [{ value: '', label: '全部业务员' }].concat(Object.keys(set).sort().map(function (s) {
|
||||
return { value: s, label: s };
|
||||
}));
|
||||
}, [records]);
|
||||
|
||||
var cuOptions = useMemo(function () {
|
||||
return [{ value: '', label: '全部客户' }].concat(records.map(function (r) {
|
||||
return { value: r.customerName, label: r.customerName };
|
||||
}));
|
||||
}, [records]);
|
||||
|
||||
var lineOptions = useMemo(function () {
|
||||
return [{ value: '', label: '全部条线' }].concat(LINE_DEFS.map(function (d) {
|
||||
return { value: d.key, label: d.label };
|
||||
}));
|
||||
}, []);
|
||||
|
||||
var handleQuery = useCallback(function () {
|
||||
setPeriodApplied(periodDraft);
|
||||
setDeptApplied(deptDraft || undefined);
|
||||
setSpApplied(spDraft || undefined);
|
||||
setCuApplied(cuDraft || undefined);
|
||||
setLineApplied(lineDraft || undefined);
|
||||
setView({ level: 'list', customerId: null, periodKey: null });
|
||||
}, [periodDraft, deptDraft, spDraft, cuDraft, lineDraft]);
|
||||
|
||||
var handleReset = useCallback(function () {
|
||||
var p0 = defaultPeriodMonth();
|
||||
setPeriodDraft(p0);
|
||||
setPeriodApplied(p0);
|
||||
setDeptDraft(undefined);
|
||||
setDeptApplied(undefined);
|
||||
setSpDraft(undefined);
|
||||
setSpApplied(undefined);
|
||||
setCuDraft(undefined);
|
||||
setCuApplied(undefined);
|
||||
setLineDraft(undefined);
|
||||
setLineApplied(undefined);
|
||||
setView({ level: 'list', customerId: null, periodKey: null });
|
||||
}, []);
|
||||
|
||||
var openCustomer = useCallback(function (customerId) {
|
||||
setView({ level: 'customer', customerId: customerId, periodKey: null });
|
||||
}, []);
|
||||
|
||||
var openPeriod = useCallback(function (periodKey) {
|
||||
setView(function (prev) {
|
||||
return Object.assign({}, prev, { level: 'period', periodKey: periodKey });
|
||||
});
|
||||
}, []);
|
||||
|
||||
var backToList = useCallback(function () {
|
||||
setView({ level: 'list', customerId: null, periodKey: null });
|
||||
}, []);
|
||||
|
||||
var backToCustomer = useCallback(function () {
|
||||
setView(function (prev) {
|
||||
return Object.assign({}, prev, { level: 'customer', periodKey: null });
|
||||
});
|
||||
}, []);
|
||||
|
||||
var handleLineReceivedChange = useCallback(function (lineKey, value) {
|
||||
if (!activeCustomer || !view.periodKey) return;
|
||||
var period = getCustomerPeriod(activeCustomer, view.periodKey);
|
||||
if (!period) return;
|
||||
var line = period.lines[lineKey] || { receivable: 0, received: 0 };
|
||||
var recv = numOrZero(line.receivable);
|
||||
var got = numOrZero(value);
|
||||
if (got > recv) {
|
||||
message.warning('实收不能超过应收');
|
||||
return;
|
||||
}
|
||||
setRecords(function (prev) {
|
||||
return updateLineReceived(prev, activeCustomer.id, view.periodKey, lineKey, got);
|
||||
});
|
||||
}, [activeCustomer, view.periodKey]);
|
||||
|
||||
var exportListCsv = useCallback(function () {
|
||||
var headers = ['客户名称', '业务部门', '业务员', '统计周期', '应收', '实收', '未收', '期末余额'];
|
||||
var lines = [headers];
|
||||
summaryRows.forEach(function (r) {
|
||||
lines.push([
|
||||
r.customerName, r.dept, r.salesperson, periodLabel(appliedPeriodKey),
|
||||
r.receivable, r.received, r.uncollected, r.endingBalance
|
||||
]);
|
||||
});
|
||||
downloadCsv('客户回款情况_' + appliedPeriodKey + '.csv', lines);
|
||||
message.success('已导出客户汇总');
|
||||
}, [summaryRows, appliedPeriodKey]);
|
||||
|
||||
var exportCustomerCsv = useCallback(function () {
|
||||
if (!activeCustomer) return;
|
||||
var headers = ['账单周期', '应收', '实收', '未收', '期末余额'];
|
||||
var lines = [headers];
|
||||
customerPeriodRows.forEach(function (r) {
|
||||
lines.push([periodLabel(r.periodKey), r.receivable, r.received, r.uncollected, r.endingBalance]);
|
||||
});
|
||||
downloadCsv(activeCustomer.customerName + '_账单周期回款.csv', lines);
|
||||
message.success('已导出账单周期明细');
|
||||
}, [activeCustomer, customerPeriodRows]);
|
||||
|
||||
var exportLineCsv = useCallback(function () {
|
||||
if (!activeCustomer || !view.periodKey) return;
|
||||
var headers = ['业务条线', '应收', '实收', '未收'];
|
||||
var lines = [headers];
|
||||
lineRows.forEach(function (r) {
|
||||
lines.push([r.lineLabel, r.receivable, r.received, r.uncollected]);
|
||||
});
|
||||
downloadCsv(activeCustomer.customerName + '_' + view.periodKey + '_条线回款.csv', lines);
|
||||
message.success('已导出条线明细');
|
||||
}, [activeCustomer, view.periodKey, lineRows]);
|
||||
|
||||
var summaryFooter = useCallback(function () {
|
||||
var recv = 0;
|
||||
var got = 0;
|
||||
var uncol = 0;
|
||||
var endBal = 0;
|
||||
summaryRows.forEach(function (r) {
|
||||
recv += numOrZero(r.receivable);
|
||||
got += numOrZero(r.received);
|
||||
uncol += numOrZero(r.uncollected);
|
||||
endBal += numOrZero(r.endingBalance);
|
||||
});
|
||||
return React.createElement(Table.Summary, { fixed: true },
|
||||
React.createElement(Table.Summary.Row, null,
|
||||
React.createElement(Table.Summary.Cell, { index: 0, colSpan: 3 }, '合计(' + summaryRows.length + ' 家客户)'),
|
||||
React.createElement(Table.Summary.Cell, { index: 3, align: 'right' }, React.createElement('span', { style: moneyCellStyle }, fmtMoney(recv))),
|
||||
React.createElement(Table.Summary.Cell, { index: 4, align: 'right' }, React.createElement('span', { style: moneyCellStyle }, fmtMoney(got))),
|
||||
React.createElement(Table.Summary.Cell, { index: 5, align: 'right' }, React.createElement('span', { style: uncol > 0 ? warnStyle : moneyCellStyle }, fmtMoney(uncol))),
|
||||
React.createElement(Table.Summary.Cell, { index: 6, align: 'right' }, React.createElement('span', { style: endBal > 0 ? warnStyle : moneyCellStyle }, fmtMoney(endBal))),
|
||||
React.createElement(Table.Summary.Cell, { index: 7 })
|
||||
)
|
||||
);
|
||||
}, [summaryRows]);
|
||||
|
||||
var listColumns = [
|
||||
{
|
||||
title: '客户名称',
|
||||
dataIndex: 'customerName',
|
||||
key: 'customerName',
|
||||
width: 220,
|
||||
fixed: 'left',
|
||||
render: function (v, row) {
|
||||
return React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'cpr-drill-link',
|
||||
style: drillLinkStyle,
|
||||
onClick: function () { openCustomer(row.customerId); }
|
||||
}, v);
|
||||
}
|
||||
},
|
||||
{ title: '业务部门', dataIndex: 'dept', key: 'dept', width: 110 },
|
||||
{ title: '业务员', dataIndex: 'salesperson', key: 'salesperson', width: 100 },
|
||||
{
|
||||
title: '当期应收',
|
||||
dataIndex: 'receivable',
|
||||
key: 'receivable',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: function (v) { return React.createElement('span', { style: moneyCellStyle }, fmtMoney(v)); }
|
||||
},
|
||||
{
|
||||
title: '当期实收',
|
||||
dataIndex: 'received',
|
||||
key: 'received',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: function (v) { return React.createElement('span', { style: moneyCellStyle }, fmtMoney(v)); }
|
||||
},
|
||||
{
|
||||
title: '当期未收',
|
||||
dataIndex: 'uncollected',
|
||||
key: 'uncollected',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: function (v) {
|
||||
return React.createElement('span', { style: v > 0 ? warnStyle : moneyCellStyle }, fmtMoney(v));
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '期末余额',
|
||||
dataIndex: 'endingBalance',
|
||||
key: 'endingBalance',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: function (v) {
|
||||
return React.createElement('span', {
|
||||
style: v > 0 ? warnStyle : moneyCellStyle,
|
||||
title: '截至本期的累计未收余额(含历史各期未收之和)'
|
||||
}, fmtMoney(v));
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 88,
|
||||
fixed: 'right',
|
||||
render: function (_, row) {
|
||||
return React.createElement(Button, {
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: function () { openCustomer(row.customerId); }
|
||||
}, '钻取');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
var periodColumns = [
|
||||
{
|
||||
title: '账单周期',
|
||||
dataIndex: 'periodKey',
|
||||
key: 'periodKey',
|
||||
width: 140,
|
||||
render: function (v, row) {
|
||||
return React.createElement('button', {
|
||||
type: 'button',
|
||||
style: drillLinkStyle,
|
||||
onClick: function () { openPeriod(row.periodKey); }
|
||||
}, periodLabel(v));
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '应收',
|
||||
dataIndex: 'receivable',
|
||||
key: 'receivable',
|
||||
align: 'right',
|
||||
render: function (v) { return React.createElement('span', { style: moneyCellStyle }, fmtMoney(v)); }
|
||||
},
|
||||
{
|
||||
title: '实收',
|
||||
dataIndex: 'received',
|
||||
key: 'received',
|
||||
align: 'right',
|
||||
render: function (v) { return React.createElement('span', { style: moneyCellStyle }, fmtMoney(v)); }
|
||||
},
|
||||
{
|
||||
title: '未收',
|
||||
dataIndex: 'uncollected',
|
||||
key: 'uncollected',
|
||||
align: 'right',
|
||||
render: function (v) {
|
||||
return React.createElement('span', { style: v > 0 ? warnStyle : moneyCellStyle }, fmtMoney(v));
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '期末余额',
|
||||
dataIndex: 'endingBalance',
|
||||
key: 'endingBalance',
|
||||
align: 'right',
|
||||
render: function (v) {
|
||||
return React.createElement('span', { style: v > 0 ? warnStyle : moneyCellStyle }, fmtMoney(v));
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: function (_, row) {
|
||||
return React.createElement(Button, {
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: function () { openPeriod(row.periodKey); }
|
||||
}, '条线明细');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
var lineColumns = [
|
||||
{ title: '业务条线', dataIndex: 'lineLabel', key: 'lineLabel', width: 120 },
|
||||
{
|
||||
title: '应收',
|
||||
dataIndex: 'receivable',
|
||||
key: 'receivable',
|
||||
align: 'right',
|
||||
render: function (v) { return React.createElement('span', { style: moneyCellStyle }, fmtMoney(v)); }
|
||||
},
|
||||
{
|
||||
title: '实收',
|
||||
dataIndex: 'received',
|
||||
key: 'received',
|
||||
align: 'right',
|
||||
width: 160,
|
||||
render: function (v, row) {
|
||||
return React.createElement(InputNumber, {
|
||||
min: 0,
|
||||
max: numOrZero(row.receivable),
|
||||
step: 100,
|
||||
value: numOrZero(v),
|
||||
formatter: function (val) { return val == null ? '' : String(val); },
|
||||
parser: function (val) { return val ? val.replace(/,/g, '') : ''; },
|
||||
style: { width: '100%' },
|
||||
onChange: function (val) { handleLineReceivedChange(row.lineKey, val); }
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '未收',
|
||||
dataIndex: 'uncollected',
|
||||
key: 'uncollected',
|
||||
align: 'right',
|
||||
render: function (v) {
|
||||
return React.createElement('span', { style: v > 0 ? warnStyle : moneyCellStyle }, fmtMoney(v));
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
var breadcrumbItems = useMemo(function () {
|
||||
var items = [{ title: React.createElement('button', { type: 'button', style: drillLinkStyle, onClick: backToList }, '客户回款情况') }];
|
||||
if (view.level !== 'list' && activeCustomer) {
|
||||
items.push({
|
||||
title: view.level === 'customer'
|
||||
? activeCustomer.customerName
|
||||
: React.createElement('button', { type: 'button', style: drillLinkStyle, onClick: backToCustomer }, activeCustomer.customerName)
|
||||
});
|
||||
}
|
||||
if (view.level === 'period' && view.periodKey) {
|
||||
items.push({ title: periodLabel(view.periodKey) });
|
||||
}
|
||||
return items;
|
||||
}, [view, activeCustomer, backToList, backToCustomer]);
|
||||
|
||||
var pageTitle = view.level === 'list'
|
||||
? '客户回款情况'
|
||||
: view.level === 'customer'
|
||||
? activeCustomer ? activeCustomer.customerName + ' · 账单周期' : '账单周期'
|
||||
: '业务条线回款明细';
|
||||
|
||||
var pageHint = view.level === 'list'
|
||||
? '默认展示当期(' + periodLabel(appliedPeriodKey) + ')全部客户的应收、实收与期末余额;期末余额为截至当期的累计未收。'
|
||||
: view.level === 'customer'
|
||||
? '按账单周期查看该客户各期回款;期末余额逐期累加历史未收。'
|
||||
: '调整实收后,当期未收与本客户后续各期期末余额将实时重算。';
|
||||
|
||||
var exportHandler = view.level === 'list' ? exportListCsv : view.level === 'customer' ? exportCustomerCsv : exportLineCsv;
|
||||
|
||||
var tableData = view.level === 'list'
|
||||
? summaryRows
|
||||
: view.level === 'customer'
|
||||
? customerPeriodRows
|
||||
: lineRows;
|
||||
|
||||
var tableColumns = view.level === 'list'
|
||||
? listColumns
|
||||
: view.level === 'customer'
|
||||
? periodColumns
|
||||
: lineColumns;
|
||||
|
||||
var activePeriodSummary = useMemo(function () {
|
||||
if (!activeCustomer || !view.periodKey) return null;
|
||||
return getCustomerPeriod(activeCustomer, view.periodKey);
|
||||
}, [activeCustomer, view.periodKey, records]);
|
||||
|
||||
return React.createElement(App, null,
|
||||
React.createElement('style', null, tableStyle),
|
||||
React.createElement('div', { style: layoutStyle },
|
||||
React.createElement(Card, {
|
||||
bordered: false,
|
||||
style: { marginBottom: 16 },
|
||||
title: pageTitle,
|
||||
extra: React.createElement(Space, null,
|
||||
view.level !== 'list' ? React.createElement(Button, { onClick: view.level === 'period' ? backToCustomer : backToList }, '返回上一级') : null,
|
||||
React.createElement(Button, { onClick: exportHandler }, '导出 CSV')
|
||||
)
|
||||
},
|
||||
React.createElement(Breadcrumb, { items: breadcrumbItems, style: { marginBottom: 12 } }),
|
||||
React.createElement('div', { style: { marginBottom: 16, color: 'rgba(0,0,0,0.55)', fontSize: 13 } }, pageHint),
|
||||
view.level === 'list' ? React.createElement(Row, { gutter: 16, style: { marginBottom: 16 }, className: 'cpr-kpi' },
|
||||
React.createElement(Col, { xs: 12, sm: 8, md: 4 },
|
||||
React.createElement(Statistic, { title: '客户数', value: kpi.customerCount, suffix: '家' })
|
||||
),
|
||||
React.createElement(Col, { xs: 12, sm: 8, md: 5 },
|
||||
React.createElement(Statistic, { title: '当期应收合计', value: kpi.receivable, precision: 2, suffix: '元' })
|
||||
),
|
||||
React.createElement(Col, { xs: 12, sm: 8, md: 5 },
|
||||
React.createElement(Statistic, { title: '当期实收合计', value: kpi.received, precision: 2, suffix: '元' })
|
||||
),
|
||||
React.createElement(Col, { xs: 12, sm: 8, md: 5 },
|
||||
React.createElement(Statistic, { title: '当期未收合计', value: kpi.uncollected, precision: 2, suffix: '元', valueStyle: kpi.uncollected > 0 ? { color: '#cf1322' } : undefined })
|
||||
),
|
||||
React.createElement(Col, { xs: 12, sm: 8, md: 5 },
|
||||
React.createElement(Statistic, { title: '期末余额合计', value: kpi.endingBalance, precision: 2, suffix: '元', valueStyle: kpi.endingBalance > 0 ? { color: '#cf1322' } : undefined })
|
||||
)
|
||||
) : null,
|
||||
view.level === 'period' && activePeriodSummary ? React.createElement(Row, { gutter: 16, style: { marginBottom: 16 }, className: 'cpr-kpi' },
|
||||
React.createElement(Col, { span: 6 },
|
||||
React.createElement(Statistic, { title: '本期应收', value: activePeriodSummary.receivable, precision: 2, suffix: '元' })
|
||||
),
|
||||
React.createElement(Col, { span: 6 },
|
||||
React.createElement(Statistic, { title: '本期实收', value: activePeriodSummary.received, precision: 2, suffix: '元' })
|
||||
),
|
||||
React.createElement(Col, { span: 6 },
|
||||
React.createElement(Statistic, { title: '本期未收', value: activePeriodSummary.uncollected, precision: 2, suffix: '元', valueStyle: activePeriodSummary.uncollected > 0 ? { color: '#cf1322' } : undefined })
|
||||
),
|
||||
React.createElement(Col, { span: 6 },
|
||||
React.createElement(Statistic, { title: '期末余额', value: activePeriodSummary.endingBalance, precision: 2, suffix: '元', valueStyle: activePeriodSummary.endingBalance > 0 ? { color: '#cf1322' } : undefined })
|
||||
)
|
||||
) : null,
|
||||
view.level === 'list' ? React.createElement(Card, {
|
||||
size: 'small',
|
||||
style: { marginBottom: 16, background: '#fafafa' },
|
||||
title: '筛选条件'
|
||||
},
|
||||
React.createElement(Row, { gutter: 16 },
|
||||
React.createElement(Col, { xs: 24, sm: 12, md: 6 },
|
||||
React.createElement('div', { style: filterItemStyle },
|
||||
React.createElement('div', { style: filterLabelStyle }, '统计周期'),
|
||||
React.createElement(DatePicker, {
|
||||
picker: 'month',
|
||||
style: filterControlStyle,
|
||||
value: periodDraft,
|
||||
onChange: setPeriodDraft,
|
||||
allowClear: false
|
||||
})
|
||||
)
|
||||
),
|
||||
React.createElement(Col, { xs: 24, sm: 12, md: 6 },
|
||||
React.createElement('div', { style: filterItemStyle },
|
||||
React.createElement('div', { style: filterLabelStyle }, '客户名称'),
|
||||
React.createElement(Select, {
|
||||
showSearch: true,
|
||||
allowClear: true,
|
||||
placeholder: '全部客户',
|
||||
style: filterControlStyle,
|
||||
options: cuOptions,
|
||||
value: cuDraft,
|
||||
onChange: setCuDraft,
|
||||
filterOption: filterOption
|
||||
})
|
||||
)
|
||||
),
|
||||
React.createElement(Col, { xs: 24, sm: 12, md: 6 },
|
||||
React.createElement('div', { style: filterItemStyle },
|
||||
React.createElement('div', { style: filterLabelStyle }, '业务部门'),
|
||||
React.createElement(Select, {
|
||||
allowClear: true,
|
||||
placeholder: '全部部门',
|
||||
style: filterControlStyle,
|
||||
options: deptOptions,
|
||||
value: deptDraft,
|
||||
onChange: setDeptDraft
|
||||
})
|
||||
)
|
||||
),
|
||||
React.createElement(Col, { xs: 24, sm: 12, md: 6 },
|
||||
React.createElement('div', { style: filterItemStyle },
|
||||
React.createElement('div', { style: filterLabelStyle }, '业务员'),
|
||||
React.createElement(Select, {
|
||||
showSearch: true,
|
||||
allowClear: true,
|
||||
placeholder: '全部业务员',
|
||||
style: filterControlStyle,
|
||||
options: spOptions,
|
||||
value: spDraft,
|
||||
onChange: setSpDraft,
|
||||
filterOption: filterOption
|
||||
})
|
||||
)
|
||||
),
|
||||
React.createElement(Col, { xs: 24, sm: 12, md: 6 },
|
||||
React.createElement('div', { style: filterItemStyle },
|
||||
React.createElement('div', { style: filterLabelStyle }, '业务条线'),
|
||||
React.createElement(Select, {
|
||||
allowClear: true,
|
||||
placeholder: '全部条线',
|
||||
style: filterControlStyle,
|
||||
options: lineOptions,
|
||||
value: lineDraft,
|
||||
onChange: setLineDraft
|
||||
})
|
||||
)
|
||||
),
|
||||
React.createElement(Col, { xs: 24, sm: 12, md: 6 },
|
||||
React.createElement('div', { style: Object.assign({}, filterItemStyle, { paddingTop: 28 }) },
|
||||
React.createElement(Space, null,
|
||||
React.createElement(Button, { type: 'primary', onClick: handleQuery }, '查询'),
|
||||
React.createElement(Button, { onClick: handleReset }, '重置')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
) : null,
|
||||
view.level === 'period' ? React.createElement(Tag, { color: 'processing', style: { marginBottom: 12 } }, '可在「实收」列直接修改,期末余额将实时联动') : null,
|
||||
React.createElement(Table, {
|
||||
className: 'cpr-table',
|
||||
size: 'small',
|
||||
bordered: true,
|
||||
scroll: { x: view.level === 'list' ? 980 : 720 },
|
||||
columns: tableColumns,
|
||||
dataSource: tableData,
|
||||
pagination: view.level === 'list' ? { pageSize: 10, showSizeChanger: true, showTotal: function (t) { return '共 ' + t + ' 条'; } } : false,
|
||||
summary: view.level === 'list' ? summaryFooter : undefined,
|
||||
locale: { emptyText: '暂无符合条件的数据' }
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default Component;
|
||||
@@ -6,6 +6,11 @@
|
||||
|
||||
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { OperationActions } from '../../common/OperationActions';
|
||||
import {
|
||||
shouldShowFilterExpand,
|
||||
splitFilterFields,
|
||||
VM_FILTER_PRIMARY_VISIBLE_COUNT,
|
||||
} from '../../common/vm-filter-panel';
|
||||
import dayjs from 'dayjs';
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||
import {
|
||||
@@ -6335,7 +6340,7 @@ const Component = function () {
|
||||
};
|
||||
|
||||
const renderFilterField = (label, control) => (
|
||||
<label className="lc-filter-field">
|
||||
<label key={label} className="lc-filter-field">
|
||||
<span className="lc-filter-field-label">{label}</span>
|
||||
<div className="lc-filter-field-control">{control}</div>
|
||||
</label>
|
||||
@@ -6498,160 +6503,229 @@ const Component = function () {
|
||||
onHeaderCell: listColumnHeaderCell,
|
||||
render: (record) => (
|
||||
<OperationActions
|
||||
view={{ label: '管理', onClick: () => openVehicleInsuranceMgmt(record) }}
|
||||
more={[{ key: 'manage', label: '管理', onClick: () => openVehicleInsuranceMgmt(record) }]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const listFilterFieldNodes = [
|
||||
renderFilterField('车牌号', (
|
||||
<Input
|
||||
placeholder={appliedMultiPlates.length ? '已启用多车牌筛选' : '请输入车牌号'}
|
||||
allowClear
|
||||
disabled={appliedMultiPlates.length > 0}
|
||||
value={listFilters.plateNo}
|
||||
onChange={(e) => setListFilters((prev) => ({ ...prev, plateNo: e.target.value }))}
|
||||
onPressEnter={handleListFilterQuery}
|
||||
/>
|
||||
)),
|
||||
renderFilterField('多车牌', (
|
||||
<Popover
|
||||
open={multiPlateOpen}
|
||||
onOpenChange={handleMultiPlateOpenChange}
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
content={(
|
||||
<div className="lc-multi-plate-pop">
|
||||
<div className="lc-multi-plate-pop-hint">每行一个车牌,或同一行内用逗号分隔</div>
|
||||
<Input.TextArea
|
||||
rows={5}
|
||||
value={multiPlateDraft}
|
||||
onChange={(e) => setMultiPlateDraft(e.target.value)}
|
||||
placeholder={'沪A03561F\n粤B58888F'}
|
||||
/>
|
||||
<div className="lc-multi-plate-pop-actions">
|
||||
<Button size="small" onClick={() => setMultiPlateOpen(false)}>取消</Button>
|
||||
<Button size="small" type="primary" onClick={handleListFilterQuery}>应用</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
const listFilterFields = [
|
||||
{
|
||||
key: 'plateNo',
|
||||
active: Boolean(String(listFilters.plateNo || '').trim()),
|
||||
node: renderFilterField('车牌号', (
|
||||
<Input
|
||||
className="lc-multi-plate-trigger"
|
||||
readOnly
|
||||
placeholder="点击输入多个车牌"
|
||||
value={appliedMultiPlates.length ? `已选 ${appliedMultiPlates.length} 个车牌` : ''}
|
||||
/>
|
||||
</Popover>
|
||||
)),
|
||||
renderFilterField('VIN码', (
|
||||
<Input
|
||||
placeholder="请输入车辆识别代码"
|
||||
allowClear
|
||||
value={listFilters.vin}
|
||||
onChange={(e) => setListFilters((prev) => ({ ...prev, vin: e.target.value }))}
|
||||
onPressEnter={handleListFilterQuery}
|
||||
/>
|
||||
)),
|
||||
renderFilterField('品牌', (
|
||||
<Select
|
||||
placeholder="请选择或输入品牌"
|
||||
allowClear
|
||||
showSearch
|
||||
value={listFilters.brand || undefined}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, brand: val || '' }))}
|
||||
options={brandOptions}
|
||||
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)),
|
||||
renderFilterField('型号', (
|
||||
<Select
|
||||
placeholder="请选择或输入型号"
|
||||
allowClear
|
||||
showSearch
|
||||
value={listFilters.model || undefined}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, model: val || '' }))}
|
||||
options={modelOptions}
|
||||
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)),
|
||||
renderFilterField('运营状态', (
|
||||
<Select
|
||||
value={listFilters.operateStatus}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, operateStatus: val }))}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Select.Option value="全部">全部</Select.Option>
|
||||
<Select.Option value="租赁">租赁</Select.Option>
|
||||
<Select.Option value="自营">自营</Select.Option>
|
||||
<Select.Option value="库存">库存</Select.Option>
|
||||
<Select.Option value="退出运营">退出运营</Select.Option>
|
||||
</Select>
|
||||
)),
|
||||
renderFilterField('保险状态', (
|
||||
<Select
|
||||
value={listFilters.insuranceStatus}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, insuranceStatus: val }))}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Select.Option value="全部">全部</Select.Option>
|
||||
<Select.Option value="正常">正常/临期</Select.Option>
|
||||
<Select.Option value="异常">异常</Select.Option>
|
||||
</Select>
|
||||
)),
|
||||
renderFilterField('保险类型', (
|
||||
<Select
|
||||
placeholder="请选择险种"
|
||||
allowClear
|
||||
value={listFilters.insuranceType || undefined}
|
||||
onChange={(val) => setListFilters((prev) => ({
|
||||
...prev,
|
||||
insuranceType: val || '',
|
||||
endDateRange: val ? prev.endDateRange : null,
|
||||
}))}
|
||||
options={INSURANCE_TYPE_ITEMS.map((item) => ({
|
||||
label: item.fullLabel,
|
||||
value: item.fullLabel,
|
||||
}))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)),
|
||||
renderFilterField('到期时间', (
|
||||
<Tooltip title={listFilters.insuranceType ? '' : '须先选择保险类型'}>
|
||||
<DatePicker.RangePicker
|
||||
style={{ width: '100%' }}
|
||||
value={listFilters.endDateRange}
|
||||
disabled={!listFilters.insuranceType}
|
||||
onChange={(range) => {
|
||||
if (!listFilters.insuranceType) {
|
||||
message.warning('请先选择保险类型');
|
||||
return;
|
||||
}
|
||||
setListFilters((prev) => ({ ...prev, endDateRange: range }));
|
||||
}}
|
||||
placeholder={listFilters.insuranceType ? ['开始日期', '结束日期'] : ['请先选择保险类型', '']}
|
||||
placeholder={appliedMultiPlates.length ? '已启用多车牌筛选' : '请输入车牌号'}
|
||||
allowClear
|
||||
disabled={appliedMultiPlates.length > 0}
|
||||
value={listFilters.plateNo}
|
||||
onChange={(e) => setListFilters((prev) => ({ ...prev, plateNo: e.target.value }))}
|
||||
onPressEnter={handleListFilterQuery}
|
||||
/>
|
||||
</Tooltip>
|
||||
)),
|
||||
)),
|
||||
},
|
||||
{
|
||||
key: 'plateNos',
|
||||
active: Boolean(String(listFilters.plateNos || multiPlateDraft || '').trim()),
|
||||
node: renderFilterField('多车牌', (
|
||||
<Popover
|
||||
open={multiPlateOpen}
|
||||
onOpenChange={handleMultiPlateOpenChange}
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
content={(
|
||||
<div className="lc-multi-plate-pop">
|
||||
<div className="lc-multi-plate-pop-hint">每行一个车牌,或同一行内用逗号分隔</div>
|
||||
<Input.TextArea
|
||||
rows={5}
|
||||
value={multiPlateDraft}
|
||||
onChange={(e) => setMultiPlateDraft(e.target.value)}
|
||||
placeholder={'沪A03561F\n粤B58888F'}
|
||||
/>
|
||||
<div className="lc-multi-plate-pop-actions">
|
||||
<Button size="small" onClick={() => setMultiPlateOpen(false)}>取消</Button>
|
||||
<Button size="small" type="primary" onClick={handleListFilterQuery}>应用</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
className="lc-multi-plate-trigger"
|
||||
readOnly
|
||||
placeholder="点击输入多个车牌"
|
||||
value={appliedMultiPlates.length ? `已选 ${appliedMultiPlates.length} 个车牌` : ''}
|
||||
/>
|
||||
</Popover>
|
||||
)),
|
||||
},
|
||||
{
|
||||
key: 'vin',
|
||||
active: Boolean(String(listFilters.vin || '').trim()),
|
||||
node: renderFilterField('VIN码', (
|
||||
<Input
|
||||
placeholder="请输入车辆识别代码"
|
||||
allowClear
|
||||
value={listFilters.vin}
|
||||
onChange={(e) => setListFilters((prev) => ({ ...prev, vin: e.target.value }))}
|
||||
onPressEnter={handleListFilterQuery}
|
||||
/>
|
||||
)),
|
||||
},
|
||||
{
|
||||
key: 'brand',
|
||||
active: Boolean(listFilters.brand),
|
||||
node: renderFilterField('品牌', (
|
||||
<Select
|
||||
placeholder="请选择或输入品牌"
|
||||
allowClear
|
||||
showSearch
|
||||
value={listFilters.brand || undefined}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, brand: val || '' }))}
|
||||
options={brandOptions}
|
||||
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)),
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
active: Boolean(listFilters.model),
|
||||
node: renderFilterField('型号', (
|
||||
<Select
|
||||
placeholder="请选择或输入型号"
|
||||
allowClear
|
||||
showSearch
|
||||
value={listFilters.model || undefined}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, model: val || '' }))}
|
||||
options={modelOptions}
|
||||
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)),
|
||||
},
|
||||
{
|
||||
key: 'operateStatus',
|
||||
active: listFilters.operateStatus !== '全部',
|
||||
node: renderFilterField('运营状态', (
|
||||
<Select
|
||||
value={listFilters.operateStatus}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, operateStatus: val }))}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Select.Option value="全部">全部</Select.Option>
|
||||
<Select.Option value="租赁">租赁</Select.Option>
|
||||
<Select.Option value="自营">自营</Select.Option>
|
||||
<Select.Option value="库存">库存</Select.Option>
|
||||
<Select.Option value="退出运营">退出运营</Select.Option>
|
||||
</Select>
|
||||
)),
|
||||
},
|
||||
{
|
||||
key: 'insuranceStatus',
|
||||
active: listFilters.insuranceStatus !== '全部',
|
||||
node: renderFilterField('保险状态', (
|
||||
<Select
|
||||
value={listFilters.insuranceStatus}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, insuranceStatus: val }))}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Select.Option value="全部">全部</Select.Option>
|
||||
<Select.Option value="正常">正常/临期</Select.Option>
|
||||
<Select.Option value="异常">异常</Select.Option>
|
||||
</Select>
|
||||
)),
|
||||
},
|
||||
{
|
||||
key: 'insuranceType',
|
||||
active: Boolean(listFilters.insuranceType),
|
||||
node: renderFilterField('保险类型', (
|
||||
<Select
|
||||
placeholder="请选择险种"
|
||||
allowClear
|
||||
value={listFilters.insuranceType || undefined}
|
||||
onChange={(val) => setListFilters((prev) => ({
|
||||
...prev,
|
||||
insuranceType: val || '',
|
||||
endDateRange: val ? prev.endDateRange : null,
|
||||
}))}
|
||||
options={INSURANCE_TYPE_ITEMS.map((item) => ({
|
||||
label: item.fullLabel,
|
||||
value: item.fullLabel,
|
||||
}))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)),
|
||||
},
|
||||
{
|
||||
key: 'endDateRange',
|
||||
active: Boolean(listFilters.endDateRange?.[0] || listFilters.endDateRange?.[1]),
|
||||
node: renderFilterField('到期时间', (
|
||||
<Tooltip title={listFilters.insuranceType ? '' : '须先选择保险类型'}>
|
||||
<DatePicker.RangePicker
|
||||
style={{ width: '100%' }}
|
||||
value={listFilters.endDateRange}
|
||||
disabled={!listFilters.insuranceType}
|
||||
onChange={(range) => {
|
||||
if (!listFilters.insuranceType) {
|
||||
message.warning('请先选择保险类型');
|
||||
return;
|
||||
}
|
||||
setListFilters((prev) => ({ ...prev, endDateRange: range }));
|
||||
}}
|
||||
placeholder={listFilters.insuranceType ? ['开始日期', '结束日期'] : ['请先选择保险类型', '']}
|
||||
allowClear
|
||||
/>
|
||||
</Tooltip>
|
||||
)),
|
||||
},
|
||||
];
|
||||
|
||||
const listFilterSplit = splitFilterFields(listFilterFields, VM_FILTER_PRIMARY_VISIBLE_COUNT);
|
||||
const listFilterPrimary = listFilterSplit.primary;
|
||||
const listFilterExtra = listFilterSplit.extra;
|
||||
const showListFilterExpand = shouldShowFilterExpand(listFilterFields.length);
|
||||
const listFilterExtraActiveCount = listFilterExtra.filter((field) => field.active).length;
|
||||
const listFilterExpandPanelId = 'ipc-filter-expand-panel';
|
||||
|
||||
return (
|
||||
<div className="vm-page ipc-page">
|
||||
<style>{PAGE_STYLE}</style>
|
||||
|
||||
<div className="ipc-list-shell">
|
||||
<Card className="lc-filter-card" data-annotation-id="ipc-filter" title="筛选条件" bordered={false}>
|
||||
<div className="lc-filter-grid">
|
||||
{(listFilterExpanded ? listFilterFieldNodes : listFilterFieldNodes.slice(0, 4))}
|
||||
<div className="vm-filter-body">
|
||||
<div className="lc-filter-grid vm-filter-grid--primary" data-filter-tier="primary">
|
||||
{listFilterPrimary.map((field) => field.node)}
|
||||
</div>
|
||||
{showListFilterExpand ? (
|
||||
<div
|
||||
className={`vm-filter-expand${listFilterExpanded ? ' is-expanded' : ''}`}
|
||||
aria-hidden={!listFilterExpanded}
|
||||
id={listFilterExpandPanelId}
|
||||
>
|
||||
<div className="vm-filter-expand-inner">
|
||||
<div className="lc-filter-grid vm-filter-expand-grid" data-filter-tier="extra">
|
||||
{listFilterExpanded ? listFilterExtra.map((field) => field.node) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="lc-filter-actions">
|
||||
{listFilterFieldNodes.length > 4 ? (
|
||||
<Button type="link" className="lc-filter-expand-btn" onClick={() => setListFilterExpanded((prev) => !prev)}>
|
||||
{showListFilterExpand ? (
|
||||
<Button
|
||||
type="link"
|
||||
className="lc-filter-expand-btn vm-filter-toggle"
|
||||
onClick={() => setListFilterExpanded((prev) => !prev)}
|
||||
aria-expanded={listFilterExpanded}
|
||||
aria-controls={listFilterExpandPanelId}
|
||||
>
|
||||
{listFilterExpanded ? '收起' : '更多筛选'}
|
||||
{listFilterExtraActiveCount > 0 && !listFilterExpanded ? (
|
||||
<span className="vm-filter-toggle-badge" aria-label={`已设置 ${listFilterExtraActiveCount} 项扩展筛选`}>
|
||||
{listFilterExtraActiveCount}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={handleListFilterReset}>重置</Button>
|
||||
|
||||
@@ -480,10 +480,10 @@
|
||||
},
|
||||
"markdownMap": {
|
||||
"ipc-header": "# 模块边界\n\n保险采购包含两条**相互独立**的业务线:\n\n1. **保单管理**:一车一档台账,OCR/导入/手工录入,停保/复驶/退保留痕,与车辆管理交车合规联动。\n2. **比价单**:采购前多方报价、批次管理、最晚付费预警、采购审批;**审批通过不自动写入保单台账**。\n\n右上角「查看需求说明」含完整研发 PRD。",
|
||||
"ipc-filter": "# 筛选条件\n\n| 字段 | 说明 |\n|------|------|\n| 车牌号 | 单车模糊匹配;与多车牌互斥 |\n| 多车牌 | 每行一个或逗号分隔 |\n| VIN | 车辆识别代码 |\n| 品牌/型号 | 下拉可搜 |\n| 运营状态 | 租赁/自营/库存/退出运营 |\n| 保险状态 | 正常/临期 或 异常 |\n| 保险类型 + 到期时间 | 须先选险种再选日期范围 |\n\n点击「查询」应用条件;「重置」清空。",
|
||||
"ipc-filter": "# 筛选条件\n\n默认首行展示主筛选项;其余收入「更多筛选」展开区(布局与重排规则同全局 `vm-filter-panel`)。\n\n| 字段 | 说明 |\n|------|------|\n| 车牌号 | 单车模糊匹配;与多车牌互斥 |\n| 多车牌 | 每行一个或逗号分隔 |\n| VIN | 车辆识别代码 |\n| 品牌/型号 | 下拉可搜 |\n| 运营状态 | 租赁/自营/库存/退出运营 |\n| 保险状态 | 正常/临期 或 异常 |\n| 保险类型 + 到期时间 | 须先选险种再选日期范围 |\n\n点击「查询」应用条件;「重置」清空。收起时若扩展区已有条件,按钮显示数量角标。",
|
||||
"ipc-btn-compare-mgmt": "# 比价单管理\n\n独立业务线入口。支持:\n\n- 按创建时间、车牌筛选批次\n- 最晚付费临期/超期 KPI 筛选\n- 新建/编辑比价单\n- 查看采购状态(审批中/通过/驳回/撤回)\n\n**不自动同步**至保单台账。",
|
||||
"ipc-btn-policy-ocr": "# 保单批量识别(OCR)\n\n- 业务类型:保单录入、停保、复驶、退保\n- 自动识别车牌、保单号并匹配台账\n- 识别结果须**人工确认**后落库\n- OCR 确认页**不展示**承保险种明细、分项保费等(手工新增保留)",
|
||||
"ipc-vehicle-table": "# 台账列表\n\n| 列 | 说明 |\n|----|------|\n| 车牌/VIN/品牌型号 | 车辆标识 |\n| 运营状态 | 退出运营行灰显 |\n| 保险状态 | 与 KPI 口径一致 |\n| 五类险种到期日 | 副文案:临期天数/停保/退保标签 |\n| 操作·管理 | 打开车辆保险档案 |",
|
||||
"ipc-vehicle-table": "# 台账列表\n\n| 列 | 说明 |\n|----|------|\n| 车牌/VIN/品牌型号 | 车辆标识 |\n| 运营状态 | 退出运营行灰显 |\n| 保险状态 | 与 KPI 口径一致 |\n| 五类险种到期日 | 副文案:临期天数/停保/退保标签 |\n| 操作·更多 → 管理 | 打开车辆保险档案 |",
|
||||
"ipc-vehicle-mgmt": "# 车辆保险档案\n\n展示车辆元信息、保险状态胶囊、客户/产权方等。\n\n**保险状态**:交强险+商业险均在有效期内为正常,否则异常(禁止交车)。",
|
||||
"ipc-vehicle-mgmt-tabs": "# Tab 结构\n\n- **全周期记录**:时间轴,左新保/续保,右停保/复驶/退保\n- **分险种 Tab**:交强险、商业险、超赔、驾意、货物\n\n历史表含:导入时间、类型、保单号、保险状态、保司、日期、金额、操作。\n\n当前有效保单可办理停保/复驶/退保;归档记录只读。",
|
||||
"ipc-compare-mgmt": "# 比价单批次\n\n列表展示创建时间、创建人、购买记录数、确认金额、备注等。\n\n操作:编辑、删除(未提交审批时)、查看附件。",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
*/
|
||||
@import url('../../../resources/oneos-web-legacy/styles/oneos-app.css');
|
||||
@import url('../../../resources/oneos-web-legacy/styles/ant-table-global-fix.css');
|
||||
/* 列表「更多」下拉:与租赁合同等台账页 OperationActions 效果一致 */
|
||||
@import url('../../../common/vm-operation-actions.css');
|
||||
|
||||
/* 列表页全屏滚动布局 */
|
||||
.vm-page.ipc-page {
|
||||
@@ -200,6 +202,69 @@
|
||||
font-weight: var(--oneos-font-weight-medium);
|
||||
}
|
||||
|
||||
/* 更多筛选展开区 — 与 vm-filter-panel / 合同模板等台账页一致 */
|
||||
.vm-page.ipc-page .vm-filter-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vm-page.ipc-page .vm-filter-expand {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.28s ease, opacity 0.22s ease, margin-top 0.22s ease;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.vm-page.ipc-page .vm-filter-expand.is-expanded {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
margin-top: 12px;
|
||||
pointer-events: auto;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.vm-page.ipc-page .vm-filter-expand.is-expanded .vm-filter-expand-inner {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vm-page.ipc-page .vm-filter-expand-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.vm-page.ipc-page .vm-filter-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.vm-page.ipc-page .vm-filter-toggle-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--oneos-color-primary, #10b981);
|
||||
color: #fff;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vm-page.ipc-page .vm-filter-expand {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 列表工具栏 */
|
||||
.vm-page.ipc-page .lc-table-toolbar-meta {
|
||||
font-size: var(--oneos-font-size-sm);
|
||||
|
||||
153
src/prototypes/lease-business-detail/.spec/field-checks.md
Normal file
153
src/prototypes/lease-business-detail/.spec/field-checks.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 租赁业务明细 · 单元格系统校验
|
||||
|
||||
> 实现:`utils/field-checks.ts`、`utils/system-ref.ts`;UI:`components/FieldCheckIcon.tsx`
|
||||
|
||||
列表部分列在单元格右侧展示**校验图标**(绿色 ✓ / 黄色 ⚠)。点击可查看说明;警告项在支持时提供**一键替换**为系统建议值。
|
||||
|
||||
## 对照数据源(原型本地种子)
|
||||
|
||||
| 来源 | 路径 | 用途 |
|
||||
|---|---|---|
|
||||
| 客户管理 | `customer-management/data/customers.json` | 客户名称是否已登记 |
|
||||
| 车辆管理 | `vehicle-management/data/vehicles.json` | 车牌是否存在、交车/还车日期、登记所有权 |
|
||||
| 明细种子合同 | `lease-business-detail/data/rows.json` | 车牌 + 客户 → 合同(押金、租金、提车/退车日期等) |
|
||||
|
||||
启动时由 `system-ref.ts` 聚合为内存索引;**当前原型未接真实后端 API**。
|
||||
|
||||
## 交互与状态
|
||||
|
||||
| 状态 | 图标 | 点击行为 |
|
||||
|---|---|---|
|
||||
| `ok` | 绿色 ✓ | 展示通过说明(tooltip) |
|
||||
| `warn` | 黄色 ⚠ | 展示差异说明;部分字段可「一键替换」 |
|
||||
| `none` | 不显示 | 无校验条件或无需提示 |
|
||||
|
||||
匹配规则:字符串比较前 **trim 首尾空格**;金额比较容差 **0.005 元**。
|
||||
|
||||
---
|
||||
|
||||
## 客户名称(`customerName`)
|
||||
|
||||
**前置**:车牌、客户名称均非空,否则不校验。
|
||||
|
||||
**期望客户 `expected`**:`resolveExpectedCustomer(车牌, 提车日期, 退车日期)`
|
||||
|
||||
- 在同车牌的所有合同记录中筛选;
|
||||
- 若明细行有提车日期且合同有提车日期 → 必须一致;
|
||||
- 若明细行有退车日期且合同有退车日期 → 必须一致;
|
||||
- 取匹配合同的客户名称(多条时以后遍历结果为准)。
|
||||
|
||||
**判定顺序**(命中即返回,不再往下):
|
||||
|
||||
| 优先级 | 条件 | 结果 | 提示文案 |
|
||||
|---|---|---|---|
|
||||
| 1 | `expected` 非空且与当前客户名完全一致 | ✅ 通过 | 客户名称与系统登记一致 |
|
||||
| 2 | `getContractRef(车牌, 客户名)` 命中且名称一致 | ✅ 通过 | 客户名称与租赁合同一致 |
|
||||
| 3 | `expected` 非空但与当前客户名不一致 | ⚠️ 警告 | 客户名称与系统「客户管理」不对应;**建议客户:{expected}**;可一键替换 |
|
||||
| 4 | `isKnownCustomerName(客户名)` 为真 | ✅ 通过(弱校验) | 客户名称已在客户管理中登记 |
|
||||
| 5 | 以上均否 | ⚠️ 警告 | 客户名称与系统「客户管理」不对应;请核对提车日期与退车日期 |
|
||||
|
||||
**`isKnownCustomerName` 弱校验**:
|
||||
|
||||
- 与客户管理名单**完全一致**;或
|
||||
- **双向包含**(任一方名称包含另一方,如简称/全称)。
|
||||
|
||||
---
|
||||
|
||||
## 车牌号码(`plateNo`)
|
||||
|
||||
| 条件 | 结果 | 说明 |
|
||||
|---|---|---|
|
||||
| 车牌为空 | 不校验 | — |
|
||||
| 车牌在车辆管理中 | ✅ 通过 | 车牌已在车辆管理中登记 |
|
||||
| 否则 | ⚠️ 警告 | 车牌不存在;该车牌未在系统「车辆管理」中登记,无法导入 |
|
||||
|
||||
---
|
||||
|
||||
## 提车日期(`pickupDate`)
|
||||
|
||||
对照车辆管理 `lastDeliveryTime`(交车管理)。
|
||||
|
||||
| 条件 | 结果 |
|
||||
|---|---|
|
||||
| 无车辆档案或无提车日期 | 不校验 |
|
||||
| 与系统交车日期一致(YYYY-MM-DD) | ✅ 提车日期与交车管理一致 |
|
||||
| 不一致 | ⚠️ 警告;展示系统日期;可一键替换 |
|
||||
|
||||
---
|
||||
|
||||
## 退车日期(`returnDate`)
|
||||
|
||||
对照车辆管理 `lastReturnTime`(还车记录)。
|
||||
|
||||
| 条件 | 结果 |
|
||||
|---|---|
|
||||
| 无车辆档案或无退车日期 | 不校验 |
|
||||
| 与系统还车日期一致 | ✅ 退车日期与还车记录一致 |
|
||||
| 不一致 | ⚠️ 警告;展示系统日期;可一键替换 |
|
||||
|
||||
---
|
||||
|
||||
## 押金(`deposit`)
|
||||
|
||||
对照租赁合同保证金(`车牌 + 客户名` 合同)。
|
||||
|
||||
| 条件 | 结果 |
|
||||
|---|---|
|
||||
| 无合同或押金非数值 | 不校验 |
|
||||
| 与合同保证金一致 | ✅ 押金与合同保证金一致 |
|
||||
| 不一致 | ⚠️ 警告;可一键替换为合同值 |
|
||||
|
||||
---
|
||||
|
||||
## 合同标的租金(`contractRent`)
|
||||
|
||||
对照租赁合同租金。
|
||||
|
||||
| 条件 | 结果 |
|
||||
|---|---|
|
||||
| 无合同或无租金 | 不校验 |
|
||||
| 与合同租金一致 | ✅ 租金与合同一致 |
|
||||
| 不一致 | ⚠️ 警告;可一键替换 |
|
||||
|
||||
---
|
||||
|
||||
## 应收租金(`receivableRent`)
|
||||
|
||||
**期望**:`合同标的租金 × 结算周期月数`(月数来自合同 `paymentMethod` 解析:月度 1 / 季度 3 / 半年 6 / 年度 12)。
|
||||
|
||||
| 条件 | 结果 |
|
||||
|---|---|
|
||||
| 期望与当前均为 0 | 不校验 |
|
||||
| 与期望一致 | ✅ 应收租金 = 合同标的租金 × N 个月 |
|
||||
| 不一致 | ⚠️ 警告;展示正确金额;可一键替换 |
|
||||
|
||||
---
|
||||
|
||||
## 里程减免 / 其他减免(`mileageDeduction`、`otherDeduction`)
|
||||
|
||||
| 条件 | 结果 |
|
||||
|---|---|
|
||||
| 值为 0 | 不校验 |
|
||||
| 值为负数 | ✅ 以负数显示(符合 Excel 口径) |
|
||||
| 值为正数 | ⚠️ 警告:应录入负值 |
|
||||
|
||||
---
|
||||
|
||||
## 资产归属(`assetOwner`)
|
||||
|
||||
对照车辆管理「登记所有权」。
|
||||
|
||||
| 条件 | 结果 |
|
||||
|---|---|
|
||||
| 无车辆档案或无资产归属 | 不校验 |
|
||||
| 与登记所有权一致 | ✅ 与车辆登记所有权一致 |
|
||||
| 不一致 | ⚠️ 警告;可一键替换 |
|
||||
|
||||
---
|
||||
|
||||
## 启用校验的列
|
||||
|
||||
在 `components/tableColumns.ts` 中通过 `checkField` 挂载,由 `DetailTable` 调用 `getFieldCheck()`:
|
||||
|
||||
`plateNo`、`customerName`、`pickupDate`、`returnDate`、`deposit`、`contractRent`、`receivableRent`、`mileageDeduction`、`otherDeduction`、`assetOwner`。
|
||||
45
src/prototypes/lease-business-detail/.spec/import-rules.md
Normal file
45
src/prototypes/lease-business-detail/.spec/import-rules.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 租赁业务明细 · 导入与金额规则
|
||||
|
||||
> 实现:`utils/row-key.ts`、`utils/import-merge.ts`、`utils/batch-io.ts`、`utils/validate-detail.ts`
|
||||
> UI:`components/DetailImportModal.tsx`、`components/DetailEditModal.tsx`
|
||||
|
||||
## 导入业务主键(去重)
|
||||
|
||||
| 组成字段 | 说明 |
|
||||
|---|---|
|
||||
| 年份 | `year` |
|
||||
| 月份 | `month` |
|
||||
| 车牌号码 | `plateNo`(trim) |
|
||||
| **客户名称** | `customerName`(trim) |
|
||||
|
||||
键格式:`{year}-{MM}-{plateNo}::{customerName}`(见 `detailRowBusinessKey`)。
|
||||
|
||||
同一主键下支持三种策略:
|
||||
|
||||
| 策略 | 行为 |
|
||||
|---|---|
|
||||
| 跳过重复(默认) | 已存在同键记录则跳过导入行 |
|
||||
| 覆盖重复 | 用导入行覆盖已有记录(保留原 `id`) |
|
||||
| 报错 | 重复行写入错误日志,不入库 |
|
||||
|
||||
**边界**:同一车牌、同一月份、**不同客户**视为两条不同记录,不互相覆盖。
|
||||
|
||||
数据源:原型本地导入解析 + 内存中的已有明细;**未接真实后端 API**。
|
||||
|
||||
---
|
||||
|
||||
## 实收金额 vs 应收合计
|
||||
|
||||
| 项 | 规则 |
|
||||
|---|---|
|
||||
| 导入 | **不拦截**实收大于应收合计 |
|
||||
| 编辑保存 | **不拦截**实收大于应收合计 |
|
||||
| 未收计算 | 仍为「应收合计 − 实收金额」;实收超额时未收为负,列表用超额样式提示 |
|
||||
|
||||
历史规则「实收金额 ≤ 应收合计」已在 `validate-detail.ts` 中注释停用,函数保留签名便于对照。
|
||||
|
||||
---
|
||||
|
||||
## 与收款状态的关系
|
||||
|
||||
收款状态仍按实收与应收比较展示(未收款 / 部分收款 / 已结清);实收 ≥ 应收合计时为「已结清」,与是否允许多收不冲突。
|
||||
@@ -1,94 +1,54 @@
|
||||
# Prototype Review
|
||||
|
||||
- 审查目标:`src/prototypes/lease-business-detail`(页面 `list`)
|
||||
- 用户资料/参考资料:
|
||||
- 对话中已确认的 Excel《2026年业务二部运营台账总表6.17 - 新版》「2.租赁车辆收入明细表」字段顺序、23 项汇总、6 项公式列与 7 项 KPI 口径
|
||||
- `src/resources/oneos-web-legacy/数据分析/租赁车辆收入明细.jsx`
|
||||
- `src/prototypes/oneos-web-data-analysis/pages/08-租赁车辆收入明细.jsx`
|
||||
- `src/prototypes/oneos-web-data-analysis/pages/05-业务部台账.jsx`(租赁业绩数据来源说明)
|
||||
- `src/prototypes/lease-business-ledger/.spec/requirements-prd.md`(对照模块)
|
||||
- 原型源码、`annotation-source.json`、`data/rows.json`
|
||||
- 生成时间:2026-07-09 16:18
|
||||
- 用户资料/参考资料:`.spec/requirements-prd.md`、`lease-business-ledger` PRD、原型源码
|
||||
- 生成时间:2026-07-13 15:50
|
||||
- 状态:**复审通过(对账能力已移除)**
|
||||
|
||||
## 总体点评
|
||||
|
||||
原型在「租赁运营明细宽表」主路径上已具备可运行闭环:筛选 → 7 项 KPI → 47 列分页列表 → 23 项表尾汇总 → 批量导入/导出,且公式列(应收合计、月度收入、未收、车辆实际成本、总成本、盈亏)与 Excel SUBTOTAL 逻辑一致,553 条 2026 年样例数据汇总与台账样表核对无偏差。列表字段、双运维费列、左冻结年份/车牌、日期列排序等近期决策在实现中落地较完整。
|
||||
租赁业务明细为单页宽表维护:筛选(含收款状态)→ 7 项 KPI → 批量导入/导出 → 48 列明细表。行内操作仅保留**编辑、删除、变更日志**;盈亏月度汇总改由业务部台账承接,本页不再提供 Tab 或对账锁定。
|
||||
|
||||
主要缺口集中在**模块边界与资料体系**:相对 `src/resources` 中「租赁车辆收入明细」双 Tab 结构,当前仅实现明细列表,缺少「租赁业务盈亏月度汇总」;相对 `lease-business-ledger`,本原型未说明「明细维护」与「台账收款关联」的分工,实收仍为导入手工列。导入流程可完成演示,但缺少重复数据与行级校验策略,长期批量维护存在数据质量风险。整体适合作为 Excel 明细表的交互原型,尚不足以单独支撑数据分析侧完整用户旅程。
|
||||
主路径「筛选 → KPI → 宽表 → 导入/导出 → 编辑」可完整演示。剩余风险主要为跨模块集成(实收与租赁业务台账/收款模块自动同步)与明细表暂无「氢费预充值」字段——已降为待规划,不阻塞当前原型验收。
|
||||
|
||||
## P0-P3 优先级问题
|
||||
|
||||
### P1 - 缺少「租赁业务盈亏月度汇总」Tab / 页面
|
||||
|
||||
- 证据:`src/resources/oneos-web-legacy/数据分析/租赁车辆收入明细.jsx` 与 `src/prototypes/oneos-web-data-analysis/pages/08-租赁车辆收入明细.jsx` 均包含「租赁业务明细」+「租赁业务盈亏月度汇总」两个 Tab;当前 `index.tsx` 仅单页 `list`,`annotation-source.json` 目录也只有一条路由。
|
||||
- 影响:需要按月查看盈亏汇总、对比年度趋势的用户无法在本原型完成核心分析任务,与既有产品资料表达不一致。
|
||||
- 修复方向:在 `.spec` 明确是否纳入本原型范围;若纳入,增加第二 Tab 或子路由,按年份聚合月份级押金/应收/实收/未收/收入/成本/盈亏,并保留导出。
|
||||
|
||||
### P1 - 与「租赁业务台账」边界不清,实收口径未闭环
|
||||
|
||||
- 证据:`lease-business-ledger` PRD 定义收款关联、`calcReceivedAmountFromReceipts`、明细状态;本原型 `receivedAmount` 为导入手工列(`batch-io.ts`),无关联收款、无「未收款/部分收款/已收款」状态;`oneos-web-data-analysis/05-业务部台账.jsx` 注明租赁业绩来自「租赁业务明细」。
|
||||
- 影响:业务方可能混淆两个模块职责;实收可任意填写导致未收/盈亏与真实收款脱节,下游汇总分析可信度下降。
|
||||
- 修复方向:在 `.spec/requirements-prd.md` 写清「明细表 = 业管 Excel 维护」「台账 = 收款闭环」或统一为一条链路;至少增加实收 ≤ 应收合计校验,并标注与台账/收款模块的集成计划。
|
||||
|
||||
### P2 - 批量导入仅追加,无重复键与覆盖策略
|
||||
|
||||
- 证据:`index.tsx` 导入成功后 `setRecords((prev) => [...imported, ...prev])`;`parseDetailImportFile` 仅以「年份 + 月份 + 车牌号码」判空跳过,不检测同键已存在行。
|
||||
- 影响:业管重复导入同月同车数据会产生重复行,KPI 与表尾汇总被放大,难以恢复。
|
||||
- 修复方向:定义业务主键(建议 `year + month + plateNo`,是否加业务员待确认);导入时支持跳过/覆盖/报错三种策略,并返回行级结果摘要。
|
||||
|
||||
### P2 - 导入与计算缺少行级校验反馈
|
||||
|
||||
- 证据:导入失败仅 Toast(`未解析到有效数据` / `导入失败`);无字段格式、数值范围、必填组合(如平均天数为 0 时成本为 0)的逐行报告;`receivedAmount` 可大于 `receivableTotal` 产生负未收且无提示。
|
||||
- 影响:大批量维护时难以定位问题行,错误数据静默进入列表。
|
||||
- 修复方向:导入结果弹层展示成功/跳过/失败行数与原因;对实收超额、非法日期、押金格式等做行级校验清单。
|
||||
|
||||
### P3 - 缺少正式需求文档,annotation 未覆盖字段与公式说明
|
||||
|
||||
- 证据:`.spec/` 仅有空的 `prototype-comments.json`,无 `requirements-prd.md`;`annotation-source.json` 仅 4 个区块简述,未像 `lease-business-ledger` 那样标注各列口径、公式列只读规则、与 Excel 样表映射。
|
||||
- 影响:评审、测试与后续开发缺少单一事实来源,跨模块对齐成本高。
|
||||
- 修复方向:补充 `.spec/requirements-prd.md`(用户、范围、不做项、验收清单);扩展 annotation 对公式列、导入模板列、KPI/汇总口径的说明。
|
||||
(本轮复审无新增 P0–P3 问题。)
|
||||
|
||||
## 完整性与项目对齐
|
||||
|
||||
| 维度 | 结论 |
|
||||
|------|------|
|
||||
| 核心用户 | 隐含为业务二部业管/运营人员(样例数据均为「业务二部」),未在 `.spec` 明文写出 |
|
||||
| 核心任务 | **已覆盖**:按条件查询明细、查看 KPI 与表尾汇总、Excel 模板导入与导出 |
|
||||
| 主流程入口 | 侧边栏 `prototypes/lease-business-detail`(`sidebar-tree.json`) |
|
||||
| 主流程出口 | 导出当前筛选结果;无保存至服务端、无返回上游分析页导航 |
|
||||
| 范围边界 | **已对齐**:47 列宽表、6 公式列自动计算、7+23 汇总结构、2026 样例 1–6 月 |
|
||||
| 范围缺口 | **未覆盖**:盈亏月度汇总 Tab;行内编辑/删除;持久化;收款关联 |
|
||||
| 与 ledger 关系 | **待确认**:两模块均处理租赁运营金额,字段模型不同(明细 47 列 vs 台账账单字段),需产品定稿分工 |
|
||||
| 与数据分析关系 | `05-业务部台账` 声明租赁业绩取自本明细;当前原型未暴露 API/聚合接口,仅本地 JSON,**集成待确认** |
|
||||
| 资料冲突 | Legacy 数据分析页为简化列(应收/实收/自然月收入等 ~20 列),本原型已升级为 Excel 运营台账 47 列——**以对话与 Excel 样表为准**,Legacy 作交互参考而非字段真相 |
|
||||
| 核心用户 | 业务二部业管 / 运营人员 |
|
||||
| 核心任务 | **已覆盖**:明细维护、状态筛选、KPI/汇总、导入导出 |
|
||||
| Tab 结构 | **已对齐**:单页宽表;盈亏汇总见业务部台账 |
|
||||
| 与 ledger 分工 | 已文档化;实收仍为手工列,**集成待规划** |
|
||||
| 资料冲突 | 明细「已结清」vs 台账「已收款」——跨模块术语待产品统一 |
|
||||
|
||||
## 业务逻辑连贯性
|
||||
|
||||
- **筛选逻辑**:`statMonth`(`YYYY-MM`)、业务部门、业务员精确匹配,客户名称模糊匹配,车牌多选;查询按钮触发 `appliedFilters`,与 KPI/汇总联动,逻辑自洽。
|
||||
- **汇总口径**:KPI 7 项与表尾 23 项均基于**全量筛选结果**(非当前页),与 Excel SUBTOTAL 语义一致;分页仅影响表格 body,不影响汇总,合理。
|
||||
- **公式链路**:`calc-detail.ts` 与 Excel 公式一致(应收合计、月度收入、未收、车辆实际成本、总成本、盈亏);加载种子数据与导入后均调用 `applyDetailCalculations`,避免公式列脏读。
|
||||
- **状态迁移**:无「草稿/已提交/归档」;导入即入库(内存),无版本与审计轨迹。
|
||||
- **角色权限**:未实现员工/主管数据范围(`lease-business-ledger` 有 `filterRecordsByRole`),本原型全员可见全部 553 条。
|
||||
- **跨页一致性**:单页原型,无跨路由状态问题。
|
||||
- 收款状态、公式列、导入校验链路自洽。
|
||||
- KPI/表尾汇总与筛选结果口径一致。
|
||||
|
||||
## 状态、异常、边界与恢复
|
||||
|
||||
| 场景 | 现状 | 评价 |
|
||||
|------|------|------|
|
||||
| 空数据 | 表格区展示空状态文案 + 导入引导 | 已覆盖 |
|
||||
| 加载 | 筛选查询 280ms 骨架屏 | 已覆盖(模拟) |
|
||||
| 导入成功 | Toast + 关闭弹窗 + 重置页码 | 已覆盖 |
|
||||
| 导入无有效行 | Toast 提示检查模板 | 已覆盖,但无行级原因 |
|
||||
| 导入异常 | 通用「导入失败」Toast | 偏弱 |
|
||||
| 导出 | 导出筛选结果 + Toast | 已覆盖 |
|
||||
| 重复导入 | 直接追加 | **缺恢复路径** |
|
||||
| 实收 > 应收 | 允许,未收为负 | **缺业务提示** |
|
||||
| 刷新页面 | 恢复种子数据,导入丢失 | 原型可接受,需标注 |
|
||||
| 宽表横向滚动 | `lbd-table-wrap` overflow 修复 | 已覆盖 |
|
||||
| 并发/权限 | 未涉及 | 不适用 |
|
||||
| 场景 | 现状 |
|
||||
|------|------|
|
||||
| 重复导入 | 跳过 / 覆盖 / 报错;主键 year-month-plate-customer |
|
||||
| 实收超额 | **允许**;导入/编辑不拦截;列表负未收/超额样式 |
|
||||
| 行内操作 | 编辑、删除、变更日志 |
|
||||
|
||||
## 证据与评估说明
|
||||
|
||||
- **用户资料优先级**:本轮无新的用户附件;以对话中 Excel 对齐决策为主。与 Legacy 简化列模型存在差异,已在「完整性与项目对齐」标记,**不视为 P0 冲突**。
|
||||
- **读取范围**:`rules/prototype-review-guide.md`;`index.tsx`、`types.ts`、`utils/calc-detail.ts`、`utils/batch-io.ts`、`utils/detail.ts`、`components/*`、`data/rows.json`、`annotation-source.json`;`src/resources/oneos-web-legacy/数据分析/租赁车辆收入明细.jsx`;`lease-business-ledger/.spec/requirements-prd.md`;`oneos-web-data-analysis/pages/05-业务部台账.jsx`(片段);`.axhub/make/sidebar-tree.json`。
|
||||
- **独立评估**:`degraded`(未使用双子代理;在同一审查内先建立业务基线再对照源码,结论仍基于双源交叉验证)。
|
||||
- **读取范围**:`index.tsx`、`DetailTable.tsx`、`import-merge.ts`、`validate-detail.ts`、`requirements-prd.md`
|
||||
- **独立评估**:`degraded`(单轮复审,对照需求清单验收)
|
||||
|
||||
## 首轮问题关闭记录
|
||||
|
||||
| 原优先级 | 问题 | 处理 |
|
||||
|---|---|---|
|
||||
| P1 | 缺少盈亏月度汇总 Tab | ➡️ 改由业务部台账;本页已移除 Tab |
|
||||
| P1 | 对账/锁定无入口 | ➡️ 产品范围调整:本页不做对账锁定 |
|
||||
| P2 | 导入重复键 | ✅ 三策略 + 主键 year-month-plate-**customer** |
|
||||
| P2 | 实收超额 | ➡️ 产品调整:**允许**超额;拦截已停用 |
|
||||
| P3 | PRD/annotation 偏差 | ✅ 公式、导出 48 列、边界文案已更新 |
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
### 不做范围(当前原型)
|
||||
|
||||
- 「租赁业务盈亏月度汇总」独立 Tab(Legacy 数据分析页有,本原型待规划)
|
||||
- 租赁业务盈亏月度汇总(改由**业务部台账**汇总展示)
|
||||
- 服务端持久化、员工/主管数据权限隔离(主管预览:`?role=supervisor`)
|
||||
- 关联收款记录自动回写实收
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
## 2. 筛选区
|
||||
|
||||
默认展示 5 项(一行),「更多筛选」展开车牌多选。
|
||||
默认展示 **首行 4 项**(统计月份、业务部门、业务员、客户名称),「更多筛选」展开**状态、车牌号码**(与全局 `vm-filter-panel.ts` 4 列栅格规则一致,避免末行仅 1 项)。
|
||||
|
||||
| 筛选项 | 说明 |
|
||||
|---|---|
|
||||
@@ -43,7 +43,7 @@
|
||||
| 业务部门 | 精确匹配 |
|
||||
| 业务员 | 精确匹配 |
|
||||
| 客户名称 | 模糊包含 |
|
||||
| 状态 | 已结清 / 部分收款 / 未收款(按实收与应收合计计算) |
|
||||
| 状态 | 已结清 / 部分收款 / 未收款(按实收与应收合计计算;更多筛选) |
|
||||
| 车牌号码 | 多选精确匹配(更多筛选) |
|
||||
|
||||
查询按钮触发筛选;KPI 与表尾汇总基于**全量筛选结果**(非当前页)。
|
||||
@@ -70,12 +70,26 @@
|
||||
|
||||
| 按钮 | 行为 |
|
||||
|---|---|
|
||||
| 批量导入 | 下载模板(36 列手工录入)→ 上传 Excel → 公式列自动计算 |
|
||||
| 批量导出 | 导出当前筛选结果(完整 48 列,含公式列结果) |
|
||||
| 批量导入 | 下载模板(36 列手工录入)→ 选择重复策略 → 上传 Excel → 公式列自动计算 |
|
||||
| 批量导出 | 导出当前筛选结果(**状态** + 47 列业务字段,共 48 列) |
|
||||
|
||||
### 行内操作
|
||||
|
||||
编辑、删除、变更日志(操作列);已锁定记录仅主管可改(`?role=supervisor`)。
|
||||
编辑、删除、变更日志。
|
||||
|
||||
### 导入重复策略
|
||||
|
||||
业务主键:`年份 + 月份 + 车牌号码 + 客户名称`(完整规则见 **`.spec/import-rules.md`**)。
|
||||
|
||||
| 策略 | 行为 |
|
||||
|---|---|
|
||||
| 跳过重复(默认) | 已存在同键记录则跳过 |
|
||||
| 覆盖重复 | 用导入行覆盖已有记录 |
|
||||
| 报错 | 重复行写入错误日志 |
|
||||
|
||||
### 实收金额
|
||||
|
||||
**不限制**实收 ≤ 应收合计:导入与编辑均允许实收大于应收合计(未收可为负,列表超额样式提示)。旧规则已停用,见 `.spec/import-rules.md`。
|
||||
|
||||
---
|
||||
|
||||
@@ -104,6 +118,32 @@
|
||||
|
||||
押金、合同标的租金、应收合计、应收租金、月度收入、月度租金、维保包干收入、保险上浮费、运维费(收入)、其他收入、里程减免金额、其他减免金额、实收金额、未收、车辆标准成本、车辆实际成本、保险费、氢费、运维费(成本)、居间费、其他、总成本、盈亏。
|
||||
|
||||
### 单元格系统校验
|
||||
|
||||
部分列在单元格右侧展示校验图标(绿色 ✓ / 黄色 ⚠),点击可查看说明;警告项在支持时提供**一键替换**。完整规则见 **`.spec/field-checks.md`**。
|
||||
|
||||
**对照数据源(原型本地种子)**:客户管理、车辆管理、明细种子合同(`system-ref.ts` 聚合);当前未接真实后端。
|
||||
|
||||
| 字段 | 校验要点 |
|
||||
|---|---|
|
||||
| 车牌号码 | 是否在车辆管理中登记 |
|
||||
| **客户名称** | 见下表 |
|
||||
| 提车 / 退车日期 | 与交车管理 / 还车记录一致 |
|
||||
| 押金、合同标的租金 | 与租赁合同一致 |
|
||||
| 应收租金 | = 合同标的租金 × 结算周期月数 |
|
||||
| 里程 / 其他减免 | 应以负数录入 |
|
||||
| 资产归属 | 与车辆登记所有权一致 |
|
||||
|
||||
#### 客户名称判定顺序
|
||||
|
||||
1. **通过(强)**:按「车牌 + 提车日期 + 退车日期」匹配合同,期望客户与当前名**完全一致** →「与系统登记一致」。
|
||||
2. **通过(强)**:「车牌 + 客户名」能命中租赁合同且名称一致 →「与租赁合同一致」。
|
||||
3. **警告**:能推算期望客户但与当前名不一致 → 提示建议客户,**可一键替换**。
|
||||
4. **通过(弱)**:客户名在客户管理中已登记(全等或双向包含简称/全称)→「已在客户管理中登记」。
|
||||
5. **警告**:以上均不满足 →「与系统不对应,请核对提车/退车日期」。
|
||||
|
||||
车牌或客户名为空时不显示校验图标。
|
||||
|
||||
---
|
||||
|
||||
## 6. 公式列(系统自动计算)
|
||||
@@ -112,7 +152,7 @@
|
||||
|
||||
| 字段 | 计算方式 |
|
||||
|---|---|
|
||||
| **应收合计** | 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |
|
||||
| **应收合计** | 应收租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |
|
||||
| **月度收入** | 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |
|
||||
| **未收** | 应收合计 − 实收金额 |
|
||||
| **车辆实际成本** | 车辆标准成本 × 平均天数 |
|
||||
@@ -141,7 +181,10 @@
|
||||
| 筛选 / KPI 汇总 | `utils/detail.ts` |
|
||||
| 收款状态 | `utils/payment-status.ts` |
|
||||
| 公式计算 | `utils/calc-detail.ts` |
|
||||
| 导入导出 | `utils/batch-io.ts` |
|
||||
| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts`、`utils/row-key.ts` |
|
||||
| 单元格校验 | `utils/field-checks.ts`、`utils/system-ref.ts`、`components/FieldCheckIcon.tsx` |
|
||||
| 保存校验 | `utils/validate-detail.ts`(实收超额拦截已停用) |
|
||||
| 导入规则说明 | `.spec/import-rules.md` |
|
||||
| 变更日志 | `utils/change-log.ts` |
|
||||
| 类型 | `types.ts` |
|
||||
| 组件 | `FilterPanel.tsx`、`DetailKpiRow.tsx`、`DetailTable.tsx`、`DetailImportModal.tsx`、`DetailEditModal.tsx`、`DetailChangeLogModal.tsx` |
|
||||
@@ -155,6 +198,9 @@
|
||||
3. 左冻结:状态 + 月份 + 车牌号码
|
||||
4. 氢费非零可点击明细;编辑/删除/变更日志可用
|
||||
5. 未收 > 0 标红;盈亏正负着色
|
||||
6. 客户名称等校验列:通过显示绿勾,不一致显示黄警告并可查看说明 / 一键替换(规则见 `.spec/field-checks.md`)
|
||||
7. 导入主键含客户名称;同车牌同月不同客户可并存
|
||||
8. 实收可大于应收合计:导入/编辑不拦截;未收可为负并有超额样式
|
||||
|
||||
---
|
||||
|
||||
@@ -162,6 +208,5 @@
|
||||
|
||||
详见 `.spec/prototype-review.md`:
|
||||
|
||||
- P1:盈亏月度汇总 Tab
|
||||
- P1:与租赁业务台账 / 收款模块集成口径
|
||||
- P2:导入重复键(年+月+车牌)处理策略
|
||||
- P2:与租赁业务台账 / 收款模块集成口径(实收自动回写)
|
||||
- P3:氢费预充值字段(业务部台账月度汇总占位,明细表暂无对应列)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@ export const COLUMN_HEADER_TIPS: Record<string, string> = {
|
||||
'lbd-col-plate': '车牌号码与系统「车辆管理」匹配;不存在的车牌导入失败。',
|
||||
'lbd-col-vehicle-type': '根据车牌号,显示系统「品牌·型号」。',
|
||||
'lbd-col-business-dept': '根据车牌对应租赁合同显示业务部门与业务员;展示时合并为两行。',
|
||||
'lbd-col-customer': '导入后与客户管理比对;不一致时提示正确客户并支持一键替换。',
|
||||
'lbd-col-customer': '导入后按车牌+租期与合同比对客户名;不一致时提示建议客户并支持一键替换(规则见 field-checks.md)。',
|
||||
'lbd-col-pickup-date': '对照交车管理校验提车日期;不一致时显示警告。',
|
||||
'lbd-col-return-date': '对照还车记录校验退车日期;不一致时显示警告。',
|
||||
'lbd-col-avg-days': '平均天数 = 本月在租天数 ÷ 当月自然日天数,保留两位小数。',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { X } from 'lucide-react';
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
import { detailRowYearMonth } from '../utils/detail';
|
||||
import { DETAIL_EDIT_FIELDS, draftFromRow, parseDraftToPatch } from '../utils/edit-fields';
|
||||
import { validateDetailPatch } from '../utils/validate-detail';
|
||||
|
||||
interface DetailEditModalProps {
|
||||
open: boolean;
|
||||
@@ -13,9 +14,13 @@ interface DetailEditModalProps {
|
||||
|
||||
export function DetailEditModal({ open, row, onClose, onSave }: DetailEditModalProps) {
|
||||
const [draft, setDraft] = useState<Record<string, string | number>>({});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open && row) setDraft(draftFromRow(row));
|
||||
if (open && row) {
|
||||
setDraft(draftFromRow(row));
|
||||
setError('');
|
||||
}
|
||||
}, [open, row]);
|
||||
|
||||
if (!open || !row) return null;
|
||||
@@ -24,7 +29,13 @@ export function DetailEditModal({ open, row, onClose, onSave }: DetailEditModalP
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
onSave(parseDraftToPatch(draft));
|
||||
const patch = parseDraftToPatch(draft);
|
||||
const validationError = validateDetailPatch(row, patch);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
onSave(patch);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -43,7 +54,8 @@ export function DetailEditModal({ open, row, onClose, onSave }: DetailEditModalP
|
||||
</button>
|
||||
</header>
|
||||
<form id="lbd-edit-form" className="vm-modal-body lbd-edit-form" onSubmit={handleSubmit}>
|
||||
<p className="lbd-edit-hint">公式列(应收合计、月度收入、未收、车辆实际成本、总成本、盈亏、氢费等)保存后由系统自动重算。</p>
|
||||
<p className="lbd-edit-hint">公式列(应收合计、月度收入、未收、车辆实际成本、总成本、盈亏、氢费等)保存后由系统自动重算。实收金额允许大于应收合计(未收可为负)。</p>
|
||||
{error ? <p className="lbd-edit-error" role="alert">{error}</p> : null}
|
||||
<div className="lbd-edit-grid">
|
||||
{DETAIL_EDIT_FIELDS.map((field) => (
|
||||
<label key={field.key} className="lbd-edit-field">
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Download, Upload, X } from 'lucide-react';
|
||||
import { Upload, X } from 'lucide-react';
|
||||
import type { DetailImportDuplicateStrategy } from '../utils/import-merge';
|
||||
|
||||
interface DetailImportModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onDownloadTemplate: () => void;
|
||||
onImport: (file: File) => void;
|
||||
duplicateStrategy: DetailImportDuplicateStrategy;
|
||||
onDuplicateStrategyChange: (strategy: DetailImportDuplicateStrategy) => void;
|
||||
failedCount?: number;
|
||||
skippedCount?: number;
|
||||
overwrittenCount?: number;
|
||||
onDownloadErrorLog?: () => void;
|
||||
}
|
||||
|
||||
const DUPLICATE_OPTIONS: { value: DetailImportDuplicateStrategy; label: string; hint: string }[] = [
|
||||
{ value: 'skip', label: '跳过重复', hint: '同「年份+月份+车牌+客户名称」已存在则跳过' },
|
||||
{ value: 'overwrite', label: '覆盖重复', hint: '用导入行覆盖已有记录' },
|
||||
{ value: 'error', label: '报错', hint: '重复行写入错误日志' },
|
||||
];
|
||||
|
||||
export function DetailImportModal({
|
||||
open,
|
||||
onClose,
|
||||
onDownloadTemplate,
|
||||
onImport,
|
||||
duplicateStrategy,
|
||||
onDuplicateStrategyChange,
|
||||
failedCount = 0,
|
||||
skippedCount = 0,
|
||||
overwrittenCount = 0,
|
||||
onDownloadErrorLog,
|
||||
}: DetailImportModalProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
if (!open) return null;
|
||||
|
||||
const hasResultSummary = failedCount > 0 || skippedCount > 0 || overwrittenCount > 0;
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
@@ -42,12 +59,34 @@ export function DetailImportModal({
|
||||
<span className="vm-step-badge">1</span>
|
||||
<span>下载导入模板</span>
|
||||
</div>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate} data-vm-icon="download">下载明细导入模板
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate} data-vm-icon="download">
|
||||
下载明细导入模板
|
||||
</button>
|
||||
</section>
|
||||
<section className="vm-import-step">
|
||||
<div className="vm-import-step-head">
|
||||
<span className="vm-step-badge">2</span>
|
||||
<span>重复记录处理(主键:年份 + 月份 + 车牌 + 客户名称)</span>
|
||||
</div>
|
||||
<div className="lbd-import-dup-options" role="radiogroup" aria-label="重复记录处理">
|
||||
{DUPLICATE_OPTIONS.map((option) => (
|
||||
<label key={option.value} className="lbd-import-dup-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="lbd-import-dup"
|
||||
value={option.value}
|
||||
checked={duplicateStrategy === option.value}
|
||||
onChange={() => onDuplicateStrategyChange(option.value)}
|
||||
/>
|
||||
<span className="lbd-import-dup-label">{option.label}</span>
|
||||
<span className="lbd-import-dup-hint">{option.hint}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="vm-import-step">
|
||||
<div className="vm-import-step-head">
|
||||
<span className="vm-step-badge">3</span>
|
||||
<span>上传填写好的 Excel 文件</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -70,13 +109,28 @@ export function DetailImportModal({
|
||||
/>
|
||||
</button>
|
||||
</section>
|
||||
{failedCount > 0 && onDownloadErrorLog ? (
|
||||
{hasResultSummary ? (
|
||||
<section className="vm-import-step lbd-import-error-step">
|
||||
<p className="lbd-import-error-summary">
|
||||
本次有 <strong>{failedCount}</strong> 条记录导入失败(如车牌号不存在),可下载错误日志核对。
|
||||
</p>
|
||||
<button type="button" className="vm-btn vm-btn-secondary" onClick={onDownloadErrorLog} data-vm-icon="download">下载错误日志
|
||||
</button>
|
||||
{failedCount > 0 ? (
|
||||
<p className="lbd-import-error-summary">
|
||||
本次有 <strong>{failedCount}</strong> 条记录导入失败,可下载错误日志核对。
|
||||
</p>
|
||||
) : null}
|
||||
{skippedCount > 0 ? (
|
||||
<p className="lbd-import-error-summary">
|
||||
已跳过 <strong>{skippedCount}</strong> 条重复记录。
|
||||
</p>
|
||||
) : null}
|
||||
{overwrittenCount > 0 ? (
|
||||
<p className="lbd-import-error-summary">
|
||||
已覆盖 <strong>{overwrittenCount}</strong> 条重复记录。
|
||||
</p>
|
||||
) : null}
|
||||
{failedCount > 0 && onDownloadErrorLog ? (
|
||||
<button type="button" className="vm-btn vm-btn-secondary" onClick={onDownloadErrorLog} data-vm-icon="download">
|
||||
下载错误日志
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
export type DetailMainTab = 'detail' | 'monthly-pl';
|
||||
|
||||
interface DetailMainTabsProps {
|
||||
mainTab: DetailMainTab;
|
||||
onChange: (tab: DetailMainTab) => void;
|
||||
}
|
||||
|
||||
export function DetailMainTabs({ mainTab, onChange }: DetailMainTabsProps) {
|
||||
return (
|
||||
<nav
|
||||
className="lbd-main-tabs lbd-main-tabs--toolbar"
|
||||
aria-label="租赁业务明细模块"
|
||||
data-annotation-id="lbd-main-tabs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`lbd-main-tab${mainTab === 'detail' ? ' is-active' : ''}`}
|
||||
aria-current={mainTab === 'detail' ? 'page' : undefined}
|
||||
onClick={() => onChange('detail')}
|
||||
>
|
||||
租赁业务明细
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`lbd-main-tab${mainTab === 'monthly-pl' ? ' is-active' : ''}`}
|
||||
aria-current={mainTab === 'monthly-pl' ? 'page' : undefined}
|
||||
onClick={() => onChange('monthly-pl')}
|
||||
>
|
||||
租赁业务盈亏月度汇总
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
paymentStatusSortValue,
|
||||
resolveDetailPaymentStatus,
|
||||
} from '../utils/payment-status';
|
||||
import { canEditDetailRow } from '../utils/change-log';
|
||||
import {
|
||||
LedgerDetailPopover,
|
||||
LedgerDetailTable,
|
||||
@@ -38,7 +37,6 @@ interface DetailTableProps {
|
||||
records: LeaseBusinessDetailRow[];
|
||||
summary: LeaseDetailKpiSummary;
|
||||
loading?: boolean;
|
||||
isSupervisor: boolean;
|
||||
onEdit: (row: LeaseBusinessDetailRow) => void;
|
||||
onDelete: (row: LeaseBusinessDetailRow) => void;
|
||||
onChangeLog: (row: LeaseBusinessDetailRow) => void;
|
||||
@@ -157,8 +155,11 @@ function renderCellContent(
|
||||
}
|
||||
|
||||
if (col.key === 'outstanding') {
|
||||
const { text, warn } = displayOutstanding(row.outstanding);
|
||||
const { text, warn, overpaid } = displayOutstanding(row.outstanding);
|
||||
if (!text) return '';
|
||||
if (overpaid) {
|
||||
return <span className="lbd-outstanding-overpaid tabular-nums" title="实收超过应收合计">{text}</span>;
|
||||
}
|
||||
return warn
|
||||
? <span className="lbd-outstanding-warn tabular-nums">{text}</span>
|
||||
: <span className="tabular-nums">{text}</span>;
|
||||
@@ -218,7 +219,10 @@ function renderSummaryCell(col: DetailTableColumn, summary: LeaseDetailKpiSummar
|
||||
|
||||
const value = summary[col.summaryKey];
|
||||
if (col.summaryKey === 'outstanding') {
|
||||
const { text, warn } = displayOutstanding(value);
|
||||
const { text, warn, overpaid } = displayOutstanding(value);
|
||||
if (overpaid) {
|
||||
return <span className="lbd-outstanding-overpaid tabular-nums">{text}</span>;
|
||||
}
|
||||
return warn
|
||||
? <span className="lbd-outstanding-warn tabular-nums">{text}</span>
|
||||
: <span className="tabular-nums">{text}</span>;
|
||||
@@ -249,7 +253,6 @@ export function DetailTable({
|
||||
records,
|
||||
summary,
|
||||
loading = false,
|
||||
isSupervisor,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onChangeLog,
|
||||
@@ -386,11 +389,8 @@ export function DetailTable({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRecords.map((row) => {
|
||||
const canEdit = canEditDetailRow(row, isSupervisor);
|
||||
const archived = row.archiveStatus === 'archived';
|
||||
return (
|
||||
<tr key={row.id} className={archived ? 'lbd-row-archived' : undefined}>
|
||||
{sortedRecords.map((row) => (
|
||||
<tr key={row.id}>
|
||||
{DETAIL_TABLE_COLUMNS.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
@@ -406,16 +406,14 @@ export function DetailTable({
|
||||
))}
|
||||
<td className="sticky-col-right ldb-col-actions">
|
||||
<OperationActions
|
||||
edit={canEdit ? { onClick: () => onEdit(row) } : undefined}
|
||||
edit={{ onClick: () => onEdit(row) }}
|
||||
more={[
|
||||
...(canEdit
|
||||
? [{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
danger: true,
|
||||
onClick: () => onDelete(row),
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
danger: true,
|
||||
onClick: () => onDelete(row),
|
||||
},
|
||||
{
|
||||
key: 'changeLog',
|
||||
label: '变更日志',
|
||||
@@ -425,8 +423,7 @@ export function DetailTable({
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="lbd-summary-row">
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, Search } from 'lucide-react';
|
||||
import {
|
||||
shouldShowFilterExpand,
|
||||
splitFilterFields,
|
||||
VM_FILTER_PRIMARY_VISIBLE_COUNT,
|
||||
} from '../../../common/vm-filter-panel';
|
||||
import type { LeaseBusinessDetailRow, LeaseDetailFilters } from '../types';
|
||||
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
|
||||
import { MultiPlateFilterField } from '../../vehicle-management/components/MultiPlateFilterField';
|
||||
import { buildDetailMonthOptions, buildDetailOptions } from '../utils/detail';
|
||||
import { DETAIL_PAYMENT_STATUS_OPTIONS } from '../utils/payment-status';
|
||||
|
||||
const PRIMARY_KEYS = ['statMonth', 'businessDept', 'salesperson', 'customerName', 'paymentStatus'] as const;
|
||||
const EXTRA_KEYS = ['plateNos'] as const;
|
||||
|
||||
type FieldKey = (typeof PRIMARY_KEYS)[number] | (typeof EXTRA_KEYS)[number];
|
||||
|
||||
interface FilterFieldDef {
|
||||
key: FieldKey;
|
||||
key: string;
|
||||
label: string;
|
||||
control: React.ReactNode;
|
||||
}
|
||||
@@ -26,7 +25,11 @@ interface FilterPanelProps {
|
||||
}
|
||||
|
||||
function hasExtraFiltersActive(filters: LeaseDetailFilters): boolean {
|
||||
return filters.plateNos.length > 0;
|
||||
return Boolean(filters.paymentStatus || filters.plateNos.length > 0);
|
||||
}
|
||||
|
||||
function countExtraFiltersActive(filters: LeaseDetailFilters): number {
|
||||
return [filters.paymentStatus, filters.plateNos.length > 0].filter(Boolean).length;
|
||||
}
|
||||
|
||||
export function FilterPanel({
|
||||
@@ -40,14 +43,14 @@ export function FilterPanel({
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExtraFiltersActive(filters)) setExpanded(true);
|
||||
}, [filters.plateNos]);
|
||||
}, [filters.paymentStatus, filters.plateNos]);
|
||||
|
||||
const monthOptions = useMemo(() => buildDetailMonthOptions(records), [records]);
|
||||
const deptOptions = useMemo(() => buildDetailOptions(records, 'businessDept'), [records]);
|
||||
const salespersonOptions = useMemo(() => buildDetailOptions(records, 'salesperson'), [records]);
|
||||
|
||||
const fieldMap = useMemo<Record<FieldKey, FilterFieldDef>>(() => ({
|
||||
statMonth: {
|
||||
const fields = useMemo<FilterFieldDef[]>(() => [
|
||||
{
|
||||
key: 'statMonth',
|
||||
label: '统计月份',
|
||||
control: (
|
||||
@@ -60,7 +63,7 @@ export function FilterPanel({
|
||||
/>
|
||||
),
|
||||
},
|
||||
businessDept: {
|
||||
{
|
||||
key: 'businessDept',
|
||||
label: '业务部门',
|
||||
control: (
|
||||
@@ -73,7 +76,7 @@ export function FilterPanel({
|
||||
/>
|
||||
),
|
||||
},
|
||||
salesperson: {
|
||||
{
|
||||
key: 'salesperson',
|
||||
label: '业务员',
|
||||
control: (
|
||||
@@ -86,12 +89,12 @@ export function FilterPanel({
|
||||
/>
|
||||
),
|
||||
},
|
||||
customerName: {
|
||||
{
|
||||
key: 'customerName',
|
||||
label: '客户名称',
|
||||
control: (
|
||||
<input
|
||||
className="vm-input"
|
||||
className="vm-input lbd-filter-text-control"
|
||||
value={filters.customerName}
|
||||
placeholder="输入客户名称(模糊匹配)"
|
||||
aria-label="客户名称"
|
||||
@@ -99,7 +102,7 @@ export function FilterPanel({
|
||||
/>
|
||||
),
|
||||
},
|
||||
paymentStatus: {
|
||||
{
|
||||
key: 'paymentStatus',
|
||||
label: '状态',
|
||||
control: (
|
||||
@@ -112,7 +115,7 @@ export function FilterPanel({
|
||||
/>
|
||||
),
|
||||
},
|
||||
plateNos: {
|
||||
{
|
||||
key: 'plateNos',
|
||||
label: '车牌号码',
|
||||
control: (
|
||||
@@ -124,11 +127,12 @@ export function FilterPanel({
|
||||
/>
|
||||
),
|
||||
},
|
||||
}), [filters, onChange, monthOptions, deptOptions, salespersonOptions]);
|
||||
], [filters, onChange, monthOptions, deptOptions, salespersonOptions]);
|
||||
|
||||
const primaryFields = PRIMARY_KEYS.map((key) => fieldMap[key]);
|
||||
const extraFields = EXTRA_KEYS.map((key) => fieldMap[key]);
|
||||
const extraActiveCount = filters.plateNos.length > 0 ? 1 : 0;
|
||||
const { primary, extra } = splitFilterFields(fields, VM_FILTER_PRIMARY_VISIBLE_COUNT);
|
||||
const showExpand = shouldShowFilterExpand(fields.length);
|
||||
const extraActiveCount = countExtraFiltersActive(filters);
|
||||
const expandPanelId = 'lbd-filter-expand-panel';
|
||||
|
||||
const renderField = (field: FilterFieldDef) => (
|
||||
<label key={field.key} className="vm-filter-field">
|
||||
@@ -149,42 +153,47 @@ export function FilterPanel({
|
||||
|
||||
<div className="ldb-filter-body">
|
||||
<div className="vm-filter-grid ldb-filter-grid ldb-filter-grid--primary" data-filter-tier="primary">
|
||||
{primaryFields.map(renderField)}
|
||||
{primary.map(renderField)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`ldb-filter-expand ${expanded ? 'is-expanded' : ''}`}
|
||||
aria-hidden={!expanded}
|
||||
id="lbd-filter-expand-panel"
|
||||
>
|
||||
<div className="ldb-filter-expand-inner">
|
||||
<div className="vm-filter-grid ldb-filter-grid ldb-filter-expand-grid" data-filter-tier="extra">
|
||||
{expanded ? extraFields.map(renderField) : null}
|
||||
{showExpand ? (
|
||||
<div
|
||||
className={`ldb-filter-expand ${expanded ? 'is-expanded' : ''}`}
|
||||
aria-hidden={!expanded}
|
||||
id={expandPanelId}
|
||||
>
|
||||
<div className="ldb-filter-expand-inner">
|
||||
<div className="vm-filter-grid ldb-filter-grid ldb-filter-expand-grid" data-filter-tier="extra">
|
||||
{expanded ? extra.map(renderField) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="vm-filter-actions ldb-filter-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-link ldb-filter-toggle"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="lbd-filter-expand-panel"
|
||||
|
||||
data-vm-icon={expanded ? 'chevron-up' : 'filter'}>
|
||||
{expanded ? '收起' : '更多筛选'}
|
||||
{extraActiveCount > 0 && !expanded ? (
|
||||
<span className="ldb-filter-toggle-badge" aria-label="已设置扩展筛选">
|
||||
{extraActiveCount}
|
||||
</span>
|
||||
) : null}
|
||||
{showExpand ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-link ldb-filter-toggle"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={expandPanelId}
|
||||
data-vm-icon={expanded ? 'chevron-up' : 'filter'}
|
||||
>
|
||||
{expanded ? '收起' : '更多筛选'}
|
||||
{extraActiveCount > 0 && !expanded ? (
|
||||
<span className="ldb-filter-toggle-badge" aria-label="已设置扩展筛选">
|
||||
{extraActiveCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw">
|
||||
重置
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch} data-vm-icon="search">查询
|
||||
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch} data-vm-icon="search">
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
194
src/prototypes/lease-business-detail/components/MonthlyPlTab.tsx
Normal file
194
src/prototypes/lease-business-detail/components/MonthlyPlTab.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
import { formatMoney } from '../utils/detail';
|
||||
import {
|
||||
buildDetailYearOptions,
|
||||
buildMonthlyPlRows,
|
||||
exportMonthlyPlRows,
|
||||
sumMonthlyPlRows,
|
||||
type MonthlyPlRow,
|
||||
} from '../utils/monthly-pl';
|
||||
|
||||
import type { DetailMainTab } from './DetailMainTabs';
|
||||
import { DetailMainTabs } from './DetailMainTabs';
|
||||
|
||||
interface MonthlyPlTabProps {
|
||||
records: LeaseBusinessDetailRow[];
|
||||
onToast: (message: string) => void;
|
||||
mainTab: DetailMainTab;
|
||||
onTabChange: (tab: DetailMainTab) => void;
|
||||
}
|
||||
|
||||
function displayCell(row: MonthlyPlRow, value: number): string {
|
||||
if (!row.hasData && value === 0) return '';
|
||||
return formatMoney(value);
|
||||
}
|
||||
|
||||
function renderOutstanding(row: MonthlyPlRow): React.ReactNode {
|
||||
if (!row.hasData && row.outstanding === 0) return '';
|
||||
const text = formatMoney(row.outstanding);
|
||||
if (row.outstanding > 0) {
|
||||
return <span className="lbd-outstanding-warn tabular-nums">{text}</span>;
|
||||
}
|
||||
if (row.outstanding < 0) {
|
||||
return <span className="lbd-outstanding-overpaid tabular-nums">{text}</span>;
|
||||
}
|
||||
return <span className="tabular-nums">{text}</span>;
|
||||
}
|
||||
|
||||
function renderProfit(row: MonthlyPlRow): React.ReactNode {
|
||||
if (!row.hasData && row.profit === 0) return '';
|
||||
const text = formatMoney(row.profit);
|
||||
const cls = row.profit >= 0 ? 'lbd-profit-pos' : 'lbd-profit-neg';
|
||||
return <span className={`tabular-nums ${cls}`}>{text}</span>;
|
||||
}
|
||||
|
||||
export function MonthlyPlTab({ records, onToast, mainTab, onTabChange }: MonthlyPlTabProps) {
|
||||
const yearOptions = useMemo(() => buildDetailYearOptions(records), [records]);
|
||||
const defaultYear = yearOptions[0] ?? new Date().getFullYear();
|
||||
|
||||
const [draftYear, setDraftYear] = useState(defaultYear);
|
||||
const [appliedYear, setAppliedYear] = useState(defaultYear);
|
||||
|
||||
const rows = useMemo(
|
||||
() => buildMonthlyPlRows(records, appliedYear),
|
||||
[records, appliedYear],
|
||||
);
|
||||
const sums = useMemo(() => sumMonthlyPlRows(rows), [rows]);
|
||||
const title = `租赁业务${appliedYear}年盈亏月度汇总`;
|
||||
|
||||
const handleExport = () => {
|
||||
const hasAny = rows.some((row) => row.hasData);
|
||||
if (!hasAny) {
|
||||
onToast('当前年份无明细数据可导出');
|
||||
return;
|
||||
}
|
||||
exportMonthlyPlRows(rows, appliedYear);
|
||||
onToast(`已导出 ${appliedYear} 年盈亏月度汇总`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="vm-filter-card lbd-filter-card" data-annotation-id="lbd-monthly-pl-filter">
|
||||
<div className="vm-filter-grid lbd-filter-grid lbd-monthly-pl-filter-grid">
|
||||
<label className="vm-filter-field">
|
||||
<span className="vm-filter-label">选择年份</span>
|
||||
<select
|
||||
className="vm-select"
|
||||
value={String(draftYear)}
|
||||
onChange={(event) => setDraftYear(Number(event.target.value))}
|
||||
>
|
||||
{(yearOptions.length ? yearOptions : [defaultYear]).map((year) => (
|
||||
<option key={year} value={year}>{year} 年</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="vm-filter-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-ghost"
|
||||
onClick={() => {
|
||||
setDraftYear(defaultYear);
|
||||
setAppliedYear(defaultYear);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-primary"
|
||||
onClick={() => setAppliedYear(draftYear)}
|
||||
>
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="vm-table-section">
|
||||
<div className="vm-table-toolbar ldb-table-toolbar" data-annotation-id="lbd-monthly-pl-toolbar">
|
||||
<DetailMainTabs mainTab={mainTab} onChange={onTabChange} />
|
||||
<div className="vm-table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
|
||||
data-vm-icon="download"
|
||||
onClick={handleExport}
|
||||
>
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card">
|
||||
<h2 className="lbd-monthly-pl-title">{title}</h2>
|
||||
<div className="vm-table-wrap lbd-monthly-pl-wrap" data-annotation-id="lbd-monthly-pl-table">
|
||||
<table className="vm-table ldb-table lbd-monthly-pl-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>月份</th>
|
||||
<th className="ldb-col-money">押金</th>
|
||||
<th className="ldb-col-money">应收</th>
|
||||
<th className="ldb-col-money">实收</th>
|
||||
<th className="ldb-col-money">未收</th>
|
||||
<th className="ldb-col-money">自然月收入</th>
|
||||
<th className="ldb-col-money">氢费预充值</th>
|
||||
<th className="ldb-col-money">成本</th>
|
||||
<th className="ldb-col-money">居间费</th>
|
||||
<th className="ldb-col-money">氢费</th>
|
||||
<th className="ldb-col-money">总成本</th>
|
||||
<th className="ldb-col-money">盈亏</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.month}>
|
||||
<td className="tabular-nums">{row.month}</td>
|
||||
<td className="ldb-col-money tabular-nums">{displayCell(row, row.deposit)}</td>
|
||||
<td className="ldb-col-money tabular-nums">{displayCell(row, row.receivable)}</td>
|
||||
<td className="ldb-col-money tabular-nums">{displayCell(row, row.received)}</td>
|
||||
<td className="ldb-col-money">{renderOutstanding(row)}</td>
|
||||
<td className="ldb-col-money tabular-nums">{displayCell(row, row.naturalMonthIncome)}</td>
|
||||
<td className="ldb-col-money tabular-nums">{displayCell(row, row.hydrogenPrepay)}</td>
|
||||
<td className="ldb-col-money tabular-nums">{displayCell(row, row.baseCost)}</td>
|
||||
<td className="ldb-col-money tabular-nums">{displayCell(row, row.brokerage)}</td>
|
||||
<td className="ldb-col-money tabular-nums">{displayCell(row, row.hydrogenFee)}</td>
|
||||
<td className="ldb-col-money tabular-nums">{displayCell(row, row.totalCost)}</td>
|
||||
<td className="ldb-col-money">{renderProfit(row)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="lbd-summary-row">
|
||||
<td className="lbd-summary-cell">总计</td>
|
||||
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.deposit ?? 0)}</td>
|
||||
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.receivable ?? 0)}</td>
|
||||
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.received ?? 0)}</td>
|
||||
<td className="ldb-col-money lbd-summary-cell">
|
||||
{Number(sums.outstanding) > 0 ? (
|
||||
<span className="lbd-outstanding-warn tabular-nums">{formatMoney(sums.outstanding ?? 0)}</span>
|
||||
) : (
|
||||
<span className="tabular-nums">{formatMoney(sums.outstanding ?? 0)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.naturalMonthIncome ?? 0)}</td>
|
||||
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.hydrogenPrepay ?? 0)}</td>
|
||||
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.baseCost ?? 0)}</td>
|
||||
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.brokerage ?? 0)}</td>
|
||||
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.hydrogenFee ?? 0)}</td>
|
||||
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.totalCost ?? 0)}</td>
|
||||
<td className="ldb-col-money lbd-summary-cell">
|
||||
<span className={`tabular-nums ${(sums.profit ?? 0) >= 0 ? 'lbd-profit-pos' : 'lbd-profit-neg'}`}>
|
||||
{formatMoney(sums.profit ?? 0)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -268,7 +268,7 @@ export const DETAIL_TABLE_COLUMNS: DetailTableColumn[] = [
|
||||
{ key: 'remark', title: '备注', className: 'ldb-col-remark', defaultWidth: 140 },
|
||||
];
|
||||
|
||||
/** Excel 导入/导出列规格(47 列,含独立年份/月份/业务员) */
|
||||
/** Excel 导入/导出列规格(47 列业务字段;导出时在首列追加「状态」共 48 列) */
|
||||
export const DETAIL_EXPORT_SPECS: { key: keyof LeaseBusinessDetailRow; title: string }[] = [
|
||||
{ key: 'year', title: '年份' },
|
||||
{ key: 'month', title: '月份' },
|
||||
|
||||
@@ -94,33 +94,13 @@
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-33-5",
|
||||
"operatedAt": "2026-01-18 10:22:36",
|
||||
"operatorId": "staff-002",
|
||||
"operatorName": "尚建华",
|
||||
"field": "_reconcile",
|
||||
"fieldLabel": "完成对账",
|
||||
"before": "未对账",
|
||||
"after": "已对账(2026-01-18)"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-33-6",
|
||||
"operatedAt": "2026-01-18 10:22:36",
|
||||
"operatorId": "staff-002",
|
||||
"operatorName": "尚建华",
|
||||
"field": "archiveStatus",
|
||||
"fieldLabel": "对账状态",
|
||||
"before": "未对账",
|
||||
"after": "已对账"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-33-7",
|
||||
"operatedAt": "2026-01-20 14:11:08",
|
||||
"operatorId": "supervisor-001",
|
||||
"operatorName": "王主管",
|
||||
"field": "remark",
|
||||
"fieldLabel": "备注",
|
||||
"before": "—",
|
||||
"after": "主管复核:实收与合同一致,归档确认"
|
||||
"after": "主管复核:实收与合同一致"
|
||||
}
|
||||
],
|
||||
"lbd-280": [
|
||||
|
||||
@@ -6,11 +6,10 @@ import '../vehicle-management/style.css';
|
||||
import '../lease-business-ledger/styles/index.css';
|
||||
import './styles/index.css';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Download, FileUp } from 'lucide-react';
|
||||
import {
|
||||
type AnnotationDirectoryRouteNode,
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||
import seedRows from './data/rows.json';
|
||||
@@ -43,21 +42,18 @@ import {
|
||||
createChangeLogEntry,
|
||||
type DetailChangeLogsByRowId,
|
||||
} from './utils/change-log';
|
||||
import { isDetailSupervisor, resolveDetailOperator } from './utils/role';
|
||||
import { resolveDetailOperator } from './utils/role';
|
||||
import {
|
||||
mergeDetailImportRows,
|
||||
type DetailImportDuplicateStrategy,
|
||||
} from './utils/import-merge';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
const ARCHIVED_DEMO_ROW_IDS = new Set(['lbd-33']);
|
||||
|
||||
export default function LeaseBusinessDetailApp() {
|
||||
const operator = useMemo(() => resolveDetailOperator(), []);
|
||||
const isSupervisor = isDetailSupervisor(operator);
|
||||
|
||||
const [records, setRecords] = useState<LeaseBusinessDetailRow[]>(() =>
|
||||
(seedRows as LeaseBusinessDetailRow[]).map((row) => applyDetailCalculations({
|
||||
...row,
|
||||
archiveStatus: ARCHIVED_DEMO_ROW_IDS.has(row.id) ? 'archived' : (row.archiveStatus ?? 'active'),
|
||||
reconciledAt: ARCHIVED_DEMO_ROW_IDS.has(row.id) ? '2026-01-18' : (row.reconciledAt ?? ''),
|
||||
})),
|
||||
(seedRows as LeaseBusinessDetailRow[]).map((row) => applyDetailCalculations(row)),
|
||||
);
|
||||
const [changeLogs, setChangeLogs] = useState<DetailChangeLogsByRowId>(
|
||||
() => ({ ...(changeLogSeed as DetailChangeLogsByRowId) }),
|
||||
@@ -70,6 +66,9 @@ export default function LeaseBusinessDetailApp() {
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importErrors, setImportErrors] = useState<DetailImportErrorRow[]>([]);
|
||||
const [importErrorHeaders, setImportErrorHeaders] = useState<string[]>([]);
|
||||
const [importSkipped, setImportSkipped] = useState(0);
|
||||
const [importOverwritten, setImportOverwritten] = useState(0);
|
||||
const [duplicateStrategy, setDuplicateStrategy] = useState<DetailImportDuplicateStrategy>('skip');
|
||||
const [toast, setToast] = useState('');
|
||||
const [editRow, setEditRow] = useState<LeaseBusinessDetailRow | null>(null);
|
||||
const [deleteRow, setDeleteRow] = useState<LeaseBusinessDetailRow | null>(null);
|
||||
@@ -94,7 +93,7 @@ export default function LeaseBusinessDetailApp() {
|
||||
const updateRow = useCallback((
|
||||
id: string,
|
||||
patch: Partial<LeaseBusinessDetailRow>,
|
||||
logMeta?: { forceField?: string; fieldLabel?: string },
|
||||
logMeta?: { forceField?: string; fieldLabel?: string; beforeText?: string; afterText?: string },
|
||||
) => {
|
||||
setRecords((prev) => {
|
||||
const before = prev.find((row) => row.id === id);
|
||||
@@ -109,7 +108,12 @@ export default function LeaseBusinessDetailApp() {
|
||||
logMeta.forceField,
|
||||
before[logMeta.forceField as keyof LeaseBusinessDetailRow],
|
||||
after[logMeta.forceField as keyof LeaseBusinessDetailRow],
|
||||
{ fieldLabel: logMeta.fieldLabel, force: true },
|
||||
{
|
||||
fieldLabel: logMeta.fieldLabel,
|
||||
beforeText: logMeta.beforeText,
|
||||
afterText: logMeta.afterText,
|
||||
force: true,
|
||||
},
|
||||
);
|
||||
if (entry) setChangeLogs((prevLogs) => appendChangeLogs(prevLogs, id, [entry]));
|
||||
}
|
||||
@@ -135,28 +139,39 @@ export default function LeaseBusinessDetailApp() {
|
||||
const handleImport = async (file: File) => {
|
||||
try {
|
||||
const result = await parseDetailImportFile(file);
|
||||
setImportErrors(result.errors);
|
||||
setImportErrorHeaders(result.fileHeaders);
|
||||
const merged = mergeDetailImportRows(records, result.success, duplicateStrategy);
|
||||
const allErrors = [
|
||||
...result.errors,
|
||||
...merged.duplicateErrors,
|
||||
...merged.amountErrors,
|
||||
];
|
||||
|
||||
if (result.success.length === 0 && result.errors.length === 0) {
|
||||
setImportErrors(allErrors);
|
||||
setImportErrorHeaders(result.fileHeaders);
|
||||
setImportSkipped(merged.skipped);
|
||||
setImportOverwritten(merged.overwritten);
|
||||
|
||||
const affectedCount = merged.records.length - records.length + merged.overwritten;
|
||||
|
||||
if (affectedCount === 0 && allErrors.length === 0) {
|
||||
showToast('未解析到有效数据,请检查模板与必填列');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success.length > 0) {
|
||||
const imported = result.success.map((row) => applyDetailCalculations({
|
||||
...row,
|
||||
archiveStatus: 'active',
|
||||
reconciledAt: '',
|
||||
}));
|
||||
setRecords((prev) => [...imported, ...prev]);
|
||||
if (affectedCount > 0) {
|
||||
const previousIds = new Set(records.map((row) => row.id));
|
||||
const overwrittenIdSet = new Set(merged.overwrittenIds);
|
||||
setRecords(merged.records);
|
||||
setChangeLogs((prev) => {
|
||||
let next = { ...prev };
|
||||
imported.forEach((row) => {
|
||||
merged.records.forEach((row) => {
|
||||
const isNew = !previousIds.has(row.id);
|
||||
const isOverwrite = overwrittenIdSet.has(row.id);
|
||||
if (!isNew && !isOverwrite) return;
|
||||
const entry = createChangeLogEntry(operator, '_import', null, null, {
|
||||
fieldLabel: '批量导入',
|
||||
beforeText: '—',
|
||||
afterText: '新增明细',
|
||||
afterText: isOverwrite ? '覆盖导入' : '新增明细',
|
||||
force: true,
|
||||
});
|
||||
if (entry) next = appendChangeLogs(next, row.id, [entry]);
|
||||
@@ -166,13 +181,18 @@ export default function LeaseBusinessDetailApp() {
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
if (result.errors.length > 0 && result.success.length > 0) {
|
||||
showToast(`已导入 ${result.success.length} 条;${result.errors.length} 条失败,可下载错误日志`);
|
||||
} else if (result.errors.length > 0) {
|
||||
showToast(`${result.errors.length} 条导入失败,请下载错误日志查看原因`);
|
||||
if (allErrors.length > 0 && affectedCount > 0) {
|
||||
const parts = [`已处理 ${affectedCount} 条`];
|
||||
if (merged.skipped > 0) parts.push(`跳过 ${merged.skipped} 条重复`);
|
||||
parts.push(`${allErrors.length} 条失败,可下载错误日志`);
|
||||
showToast(parts.join(';'));
|
||||
} else if (allErrors.length > 0) {
|
||||
showToast(`${allErrors.length} 条导入失败,请下载错误日志查看原因`);
|
||||
} else {
|
||||
setImportOpen(false);
|
||||
showToast(`已导入 ${result.success.length} 条明细,公式列已自动计算`);
|
||||
const parts = [`已导入 ${affectedCount} 条明细`];
|
||||
if (merged.skipped > 0) parts.push(`跳过 ${merged.skipped} 条重复`);
|
||||
showToast(`${parts.join(',')},公式列已自动计算`);
|
||||
}
|
||||
} catch {
|
||||
showToast('导入失败,请检查文件格式');
|
||||
@@ -214,16 +234,6 @@ export default function LeaseBusinessDetailApp() {
|
||||
return (
|
||||
<>
|
||||
<div className="vm-page ldb-page lc-page lc-page--list-dense lbd-page">
|
||||
<div className="lbd-role-banner" role="status">
|
||||
<span>
|
||||
当前视角:
|
||||
{isSupervisor ? '业务管理部主管(可修改已锁定记录)' : '业管人员'}
|
||||
</span>
|
||||
{!isSupervisor && (
|
||||
<span className="lbd-role-banner-hint">主管预览:URL 追加 ?role=supervisor</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FilterPanel
|
||||
records={records}
|
||||
filters={pendingFilters}
|
||||
@@ -237,29 +247,31 @@ export default function LeaseBusinessDetailApp() {
|
||||
<section className="vm-table-section">
|
||||
<div className="vm-table-toolbar ldb-table-toolbar" data-annotation-id="lbd-toolbar">
|
||||
<div className="vm-table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-secondary ldb-toolbar-btn"
|
||||
data-vm-icon="import"
|
||||
onClick={() => {
|
||||
setImportErrors([]);
|
||||
setImportErrorHeaders([]);
|
||||
setImportOpen(true);
|
||||
}}
|
||||
>
|
||||
批量导入
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
|
||||
data-vm-icon="download"
|
||||
onClick={() => {
|
||||
exportDetailRows(filtered);
|
||||
showToast(`已导出 ${filtered.length} 条明细`);
|
||||
}}
|
||||
>
|
||||
批量导出
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-secondary ldb-toolbar-btn"
|
||||
data-vm-icon="import"
|
||||
onClick={() => {
|
||||
setImportErrors([]);
|
||||
setImportErrorHeaders([]);
|
||||
setImportSkipped(0);
|
||||
setImportOverwritten(0);
|
||||
setImportOpen(true);
|
||||
}}
|
||||
>
|
||||
批量导入
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
|
||||
data-vm-icon="download"
|
||||
onClick={() => {
|
||||
exportDetailRows(filtered);
|
||||
showToast(`已导出 ${filtered.length} 条明细`);
|
||||
}}
|
||||
>
|
||||
批量导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -268,7 +280,6 @@ export default function LeaseBusinessDetailApp() {
|
||||
records={paged}
|
||||
summary={kpi}
|
||||
loading={tableLoading}
|
||||
isSupervisor={isSupervisor}
|
||||
onEdit={setEditRow}
|
||||
onDelete={setDeleteRow}
|
||||
onChangeLog={setChangeLogRow}
|
||||
@@ -297,7 +308,11 @@ export default function LeaseBusinessDetailApp() {
|
||||
onClose={() => setImportOpen(false)}
|
||||
onDownloadTemplate={downloadDetailImportTemplate}
|
||||
onImport={handleImport}
|
||||
duplicateStrategy={duplicateStrategy}
|
||||
onDuplicateStrategyChange={setDuplicateStrategy}
|
||||
failedCount={importErrors.length}
|
||||
skippedCount={importSkipped}
|
||||
overwrittenCount={importOverwritten}
|
||||
onDownloadErrorLog={
|
||||
importErrors.length > 0
|
||||
? () => downloadDetailImportErrorLog(importErrors, importErrorHeaders)
|
||||
|
||||
@@ -9,11 +9,114 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
const jsonPath = join(root, 'annotation-source.json');
|
||||
const prdPath = join(root, '.spec/requirements-prd.md');
|
||||
const fieldChecksPath = join(root, '.spec/field-checks.md');
|
||||
const importRulesPath = join(root, '.spec/import-rules.md');
|
||||
|
||||
const doc = JSON.parse(readFileSync(jsonPath, 'utf8'));
|
||||
const prd = readFileSync(prdPath, 'utf8');
|
||||
const fieldChecksMd = readFileSync(fieldChecksPath, 'utf8');
|
||||
const importRulesMd = readFileSync(importRulesPath, 'utf8');
|
||||
|
||||
for (const node of doc.data.nodes) {
|
||||
if (node.annotationText) {
|
||||
doc.markdownMap[node.id] = node.annotationText;
|
||||
}
|
||||
}
|
||||
|
||||
const mm = doc.markdownMap;
|
||||
|
||||
const filterSectionMatch = prd.match(/## 2\. 筛选区\n+([\s\S]*?)\n+---/);
|
||||
if (filterSectionMatch) {
|
||||
doc.markdownMap['lbd-filter'] = `## 2. 筛选区\n\n${filterSectionMatch[1].trim()}\n\n---`;
|
||||
}
|
||||
|
||||
doc.markdownMap['lbd-doc-field-checks'] = fieldChecksMd;
|
||||
|
||||
doc.markdownMap['lbd-col-customer'] = `## 客户名称 · 系统校验
|
||||
|
||||
导入后按**车牌 + 提车/退车日期**与合同推算期望客户,并与客户管理、租赁合同比对。
|
||||
|
||||
### 判定顺序
|
||||
|
||||
1. 期望客户与当前名完全一致 → ✅ 与系统登记一致
|
||||
2. 车牌+客户名命中租赁合同 → ✅ 与租赁合同一致
|
||||
3. 有期望客户但不一致 → ⚠️ 提示建议客户,可**一键替换**
|
||||
4. 客户管理已登记(全等或简称包含)→ ✅ 弱校验通过
|
||||
5. 否则 → ⚠️ 请核对提车/退车日期
|
||||
|
||||
完整规则见目录「单元格系统校验」或 \`.spec/field-checks.md\`。
|
||||
|
||||
---`;
|
||||
|
||||
const tableSectionMatch = prd.match(/## 5\. 明细列表(48 列)\n+([\s\S]*?)\n+---\n+\n+## 6\./);
|
||||
if (tableSectionMatch) {
|
||||
doc.markdownMap['lbd-table'] = `## 5. 明细列表(48 列)\n\n${tableSectionMatch[1].trim()}\n\n---`;
|
||||
}
|
||||
|
||||
doc.markdownMap['lbd-toolbar'] = `## 4. 列表工具栏
|
||||
|
||||
| 按钮 | 行为 |
|
||||
|---|---|
|
||||
| 批量导入 | 下载模板(36 列)→ 选择重复策略 → 上传 Excel |
|
||||
| 批量导出 | 导出当前筛选结果(状态 + 47 列业务字段,共 48 列) |
|
||||
|
||||
### 行内操作
|
||||
|
||||
编辑、删除、变更日志。
|
||||
|
||||
### 导入重复策略
|
||||
|
||||
主键:年份 + 月份 + 车牌 + **客户名称**。支持跳过 / 覆盖 / 报错。
|
||||
|
||||
### 实收金额
|
||||
|
||||
允许实收大于应收合计(导入/编辑不拦截)。详见 \`.spec/import-rules.md\`。
|
||||
|
||||
---`;
|
||||
|
||||
doc.markdownMap['lbd-doc-import-rules'] = importRulesMd;
|
||||
|
||||
doc.markdownMap['lbd-doc-formulas'] = `## 6. 公式列(系统自动计算)
|
||||
|
||||
| 字段 | 计算方式 |
|
||||
|---|---|
|
||||
| **应收合计** | 应收租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |
|
||||
| **月度收入** | 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |
|
||||
| **未收** | 应收合计 − 实收金额 |
|
||||
| **车辆实际成本** | 车辆标准成本 × 平均天数 |
|
||||
| **总成本** | 车辆实际成本 + 保险费 + 氢费 + 运维费(成本)+ 居间费 + 其他 |
|
||||
| **盈亏** | 月度收入 − 总成本 |
|
||||
|
||||
---`;
|
||||
|
||||
doc.markdownMap['lbd-doc-boundary'] = `## 模块边界
|
||||
|
||||
| 模块 | 定位 |
|
||||
|---|---|
|
||||
| **租赁业务明细** | Excel 宽表维护;48 列;收款状态;导入去重(含客户名称) |
|
||||
| **租赁业务台账** | 账单维度、收款关联、归档与数据范围 |
|
||||
| **业务部台账** | 租赁业绩与盈亏月度汇总(数据来源含本模块明细) |
|
||||
|
||||
### 不做范围
|
||||
|
||||
- 本页不做盈亏月度汇总(见业务部台账)
|
||||
- 服务端持久化、员工数据范围隔离
|
||||
- 收款记录自动回写实收
|
||||
|
||||
---`;
|
||||
|
||||
for (const node of doc.data.nodes) {
|
||||
if (node.id === 'lbd-toolbar') {
|
||||
node.annotationText = doc.markdownMap['lbd-toolbar'];
|
||||
}
|
||||
if (node.id === 'lbd-filter') {
|
||||
node.annotationText = doc.markdownMap['lbd-filter'];
|
||||
}
|
||||
if (node.id === 'lbd-table') {
|
||||
node.annotationText = doc.markdownMap['lbd-table'];
|
||||
}
|
||||
}
|
||||
|
||||
doc.markdownMap['lbd-doc-overview'] = `## 1. 模块定位
|
||||
|
||||
| 项 | 说明 |
|
||||
@@ -21,7 +124,7 @@ doc.markdownMap['lbd-doc-overview'] = `## 1. 模块定位
|
||||
| 模块名称 | 台账数据 — 租赁业务明细 |
|
||||
| 目标用户 | 业务二部业管 / 运营人员 |
|
||||
| 核心任务 | 按 Excel 样表维护租赁车辆收入明细、查询汇总、批量导入导出 |
|
||||
| 页面结构 | 筛选区 + 7 项 KPI + 工具栏 + 48 列宽表(表尾 23 项汇总)+ 分页 |
|
||||
| 页面结构 | 筛选区 + 7 项 KPI + 工具栏 + 48 列宽表 + 分页 |
|
||||
| 设计基底 | vm-page + ldb-page + lc-page |
|
||||
|
||||
对齐 Excel《2026年业务二部运营台账总表》「2.租赁车辆收入明细表」。
|
||||
@@ -90,6 +193,7 @@ doc.directory.nodes = [
|
||||
defaultExpanded: false,
|
||||
children: [
|
||||
nodeDoc('lbd-col-status', '状态'),
|
||||
nodeDoc('lbd-col-customer', '客户名称'),
|
||||
nodeDoc('lbd-col-receivable-total', '应收合计'),
|
||||
nodeDoc('lbd-col-outstanding', '未收'),
|
||||
nodeDoc('lbd-col-vehicle-actual-cost', '车辆实际成本'),
|
||||
@@ -116,6 +220,20 @@ doc.directory.nodes = [
|
||||
title: '模块边界',
|
||||
markdown: mm['lbd-doc-boundary'],
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'lbd-doc-field-checks',
|
||||
title: '单元格系统校验',
|
||||
markdown: mm['lbd-doc-field-checks'],
|
||||
markdownPath: 'src/prototypes/lease-business-detail/.spec/field-checks.md',
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'lbd-doc-import-rules',
|
||||
title: '导入与金额规则',
|
||||
markdown: mm['lbd-doc-import-rules'],
|
||||
markdownPath: 'src/prototypes/lease-business-detail/.spec/import-rules.md',
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'lbd-doc-source',
|
||||
@@ -130,7 +248,11 @@ doc.directory.nodes = [
|
||||
| 筛选 / KPI 汇总 | \`utils/detail.ts\` |
|
||||
| 收款状态 | \`utils/payment-status.ts\` |
|
||||
| 公式计算 | \`utils/calc-detail.ts\` |
|
||||
| 导入导出 | \`utils/batch-io.ts\` |
|
||||
| 导入导出 | \`utils/batch-io.ts\`、\`utils/import-merge.ts\`、\`utils/row-key.ts\` |
|
||||
| 单元格校验 | \`utils/field-checks.ts\`、\`utils/system-ref.ts\`、\`components/FieldCheckIcon.tsx\` |
|
||||
| 保存校验 | \`utils/validate-detail.ts\`(实收超额拦截已停用) |
|
||||
| 校验说明 | \`.spec/field-checks.md\` |
|
||||
| 导入规则 | \`.spec/import-rules.md\` |
|
||||
| 变更日志 | \`utils/change-log.ts\` |
|
||||
| 类型 | \`types.ts\` |
|
||||
| 组件 | \`FilterPanel.tsx\`、\`DetailKpiRow.tsx\`、\`DetailTable.tsx\`、\`DetailImportModal.tsx\`、\`DetailEditModal.tsx\`、\`DetailChangeLogModal.tsx\` |
|
||||
|
||||
@@ -11,6 +11,15 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 筛选区文本输入与 FilterPicker 控件等高 */
|
||||
.lbd-filter-text-control {
|
||||
min-height: var(--vm-control-height);
|
||||
height: var(--vm-control-height);
|
||||
padding: 0 10px;
|
||||
font-size: 0.875rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* —— KPI 汇总卡片(7 项关键指标) —— */
|
||||
.lbd-kpi-row {
|
||||
display: grid;
|
||||
@@ -484,14 +493,6 @@
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.lbd-row-archived td {
|
||||
background: color-mix(in srgb, var(--ln-muted) 6%, var(--ln-surface-card));
|
||||
}
|
||||
|
||||
.lbd-row-archived:hover td {
|
||||
background: color-mix(in srgb, var(--ln-muted) 10%, var(--ln-surface-card));
|
||||
}
|
||||
|
||||
.lbd-changelog-meta {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.8125rem;
|
||||
@@ -678,3 +679,114 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.lbd-edit-error {
|
||||
margin: 0 0 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--ln-radius-control);
|
||||
border: 1px solid color-mix(in srgb, var(--ln-danger, #cf1322) 35%, var(--ln-hairline));
|
||||
background: color-mix(in srgb, var(--ln-danger, #cf1322) 8%, var(--ln-surface-card));
|
||||
color: var(--ln-danger, #cf1322);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.lbd-outstanding-overpaid {
|
||||
color: #d48806;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lbd-main-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: var(--ln-radius-control);
|
||||
border: 1px solid var(--ln-hairline);
|
||||
background: var(--ln-surface-card);
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lbd-main-tabs--toolbar {
|
||||
margin-bottom: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lbd-main-tab {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ln-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 8px 14px;
|
||||
border-radius: calc(var(--ln-radius-control) - 2px);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.lbd-main-tab:hover {
|
||||
background: color-mix(in srgb, var(--ln-primary) 8%, transparent);
|
||||
color: var(--ln-text-primary);
|
||||
}
|
||||
|
||||
.lbd-main-tab.is-active {
|
||||
background: color-mix(in srgb, var(--ln-primary) 12%, var(--ln-surface-card));
|
||||
color: var(--ln-primary);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.lbd-monthly-pl-title {
|
||||
margin: 0 0 12px;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-text-primary);
|
||||
}
|
||||
|
||||
.lbd-monthly-pl-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.lbd-monthly-pl-table th,
|
||||
.lbd-monthly-pl-table td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lbd-monthly-pl-filter-grid {
|
||||
grid-template-columns: minmax(180px, 240px);
|
||||
}
|
||||
|
||||
.lbd-import-dup-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lbd-import-dup-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 10px;
|
||||
align-items: start;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-control);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lbd-import-dup-option:has(input:checked) {
|
||||
border-color: color-mix(in srgb, var(--ln-primary) 40%, var(--ln-hairline));
|
||||
background: color-mix(in srgb, var(--ln-primary) 6%, var(--ln-surface-card));
|
||||
}
|
||||
|
||||
.lbd-import-dup-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--ln-text-primary);
|
||||
}
|
||||
|
||||
.lbd-import-dup-hint {
|
||||
grid-column: 2;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export type DetailArchiveStatus = 'active' | 'archived';
|
||||
|
||||
export interface LeaseBusinessDetailRow {
|
||||
id: string;
|
||||
year: number;
|
||||
@@ -49,10 +47,6 @@ export interface LeaseBusinessDetailRow {
|
||||
assetOwner: string;
|
||||
signingCompany: string;
|
||||
remark: string;
|
||||
/** 未对账可编辑;已对账后仅业务管理部主管可改 */
|
||||
archiveStatus?: DetailArchiveStatus;
|
||||
/** 完成对账日期 YYYY-MM-DD */
|
||||
reconciledAt?: string;
|
||||
}
|
||||
|
||||
export interface LeaseDetailFilters {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { LeaseBusinessDetailRow } from '../types';
|
||||
import { DETAIL_EXPORT_HEADERS, DETAIL_EXPORT_SPECS } from '../components/tableColumns';
|
||||
import { applyDetailCalculations } from './calc-detail';
|
||||
import { getContractRef, getVehicleRef, isPlateInVehicleSystem } from './system-ref';
|
||||
import { resolveDetailPaymentStatus } from './payment-status';
|
||||
|
||||
/** 导入模板列(不含公式列;业务部门/业务员由系统合同反写) */
|
||||
export const DETAIL_IMPORT_TEMPLATE_HEADERS = [
|
||||
@@ -326,7 +327,14 @@ export function downloadDetailImportErrorLog(
|
||||
}
|
||||
|
||||
export function exportDetailRows(rows: LeaseBusinessDetailRow[]): void {
|
||||
const body = [DETAIL_EXPORT_HEADERS, ...rows.map(rowToExportCells)];
|
||||
const headers = ['状态', ...DETAIL_EXPORT_HEADERS];
|
||||
const body = [
|
||||
headers,
|
||||
...rows.map((row) => [
|
||||
resolveDetailPaymentStatus(row.receivableTotal, row.receivedAmount),
|
||||
...rowToExportCells(row),
|
||||
]),
|
||||
];
|
||||
const sheet = XLSX.utils.aoa_to_sheet(body);
|
||||
const book = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(book, sheet, '租赁业务明细');
|
||||
@@ -363,7 +371,9 @@ export async function parseDetailImportFile(file: File): Promise<DetailImportPar
|
||||
return;
|
||||
}
|
||||
|
||||
success.push(applyDetailCalculations(draft));
|
||||
const calculated = applyDetailCalculations(draft);
|
||||
// 实收 ≤ 应收合计:已停用,允许实收大于应收合计
|
||||
success.push(calculated);
|
||||
});
|
||||
|
||||
return { success, errors, fileHeaders };
|
||||
|
||||
@@ -248,7 +248,5 @@ export function applyDetailCalculations(
|
||||
totalCost,
|
||||
outstanding,
|
||||
profitLoss,
|
||||
archiveStatus: row.archiveStatus ?? 'active',
|
||||
reconciledAt: row.reconciledAt ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,11 +17,8 @@ export interface DetailChangeLogEntry {
|
||||
export type DetailChangeLogsByRowId = Record<string, DetailChangeLogEntry[]>;
|
||||
|
||||
const EXTRA_FIELD_LABELS: Record<string, string> = {
|
||||
archiveStatus: '对账状态',
|
||||
reconciledAt: '对账日期',
|
||||
_delete: '删除记录',
|
||||
_import: '批量导入',
|
||||
_reconcile: '完成对账',
|
||||
};
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = Object.fromEntries(
|
||||
@@ -40,11 +37,6 @@ const MONEY_FIELDS = new Set<string>([
|
||||
]);
|
||||
|
||||
export function formatLogValue(field: string, value: unknown): string {
|
||||
if (field === 'archiveStatus') {
|
||||
if (value === 'archived') return '已对账';
|
||||
if (value === 'active') return '未对账';
|
||||
return displayText(value);
|
||||
}
|
||||
if (field === 'year' || field === 'month') {
|
||||
return value === null || value === undefined || value === '' ? '—' : String(value);
|
||||
}
|
||||
@@ -62,8 +54,6 @@ export function valuesEqualForLog(field: string, before: unknown, after: unknown
|
||||
|
||||
const LOGGED_ROW_FIELDS: (keyof LeaseBusinessDetailRow)[] = [
|
||||
...DETAIL_EXPORT_SPECS.map((spec) => spec.key),
|
||||
'archiveStatus',
|
||||
'reconciledAt',
|
||||
];
|
||||
|
||||
export function createChangeLogEntry(
|
||||
@@ -116,11 +106,3 @@ export function collectRowChangeLogs(
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function canEditDetailRow(
|
||||
row: Pick<LeaseBusinessDetailRow, 'archiveStatus'>,
|
||||
isSupervisor: boolean,
|
||||
): boolean {
|
||||
if (row.archiveStatus === 'archived') return isSupervisor;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -30,9 +30,12 @@ export function displayDeposit(value: number | string): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function displayOutstanding(value: number): { text: string; warn: boolean } {
|
||||
if (value === 0) return { text: '0.00', warn: false };
|
||||
return { text: formatMoney(value), warn: value > 0 };
|
||||
export function displayOutstanding(value: number): { text: string; warn: boolean; overpaid: boolean } {
|
||||
if (value === 0) return { text: '0.00', warn: false, overpaid: false };
|
||||
if (value < 0) {
|
||||
return { text: formatMoney(value), warn: true, overpaid: true };
|
||||
}
|
||||
return { text: formatMoney(value), warn: value > 0, overpaid: false };
|
||||
}
|
||||
|
||||
export function applyDetailFilters(
|
||||
|
||||
98
src/prototypes/lease-business-detail/utils/import-merge.ts
Normal file
98
src/prototypes/lease-business-detail/utils/import-merge.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
import type { DetailImportErrorRow } from './batch-io';
|
||||
import { detailRowBusinessKey, detailRowBusinessKeyLabel } from './row-key';
|
||||
|
||||
export type DetailImportDuplicateStrategy = 'skip' | 'overwrite' | 'error';
|
||||
|
||||
export interface DetailImportMergeResult {
|
||||
records: LeaseBusinessDetailRow[];
|
||||
skipped: number;
|
||||
overwritten: number;
|
||||
overwrittenIds: string[];
|
||||
duplicateErrors: DetailImportErrorRow[];
|
||||
/** 历史字段:实收超额校验已停用,始终为空 */
|
||||
amountErrors: DetailImportErrorRow[];
|
||||
}
|
||||
|
||||
function emptyRawCells(): (string | number)[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function mergeDetailImportRows(
|
||||
existing: LeaseBusinessDetailRow[],
|
||||
imported: LeaseBusinessDetailRow[],
|
||||
strategy: DetailImportDuplicateStrategy,
|
||||
): DetailImportMergeResult {
|
||||
const keyToIndex = new Map<string, number>();
|
||||
existing.forEach((row, index) => {
|
||||
keyToIndex.set(detailRowBusinessKey(row), index);
|
||||
});
|
||||
|
||||
const next = [...existing];
|
||||
const toPrepend: LeaseBusinessDetailRow[] = [];
|
||||
let skipped = 0;
|
||||
let overwritten = 0;
|
||||
const overwrittenIds: string[] = [];
|
||||
const duplicateErrors: DetailImportErrorRow[] = [];
|
||||
const amountErrors: DetailImportErrorRow[] = [];
|
||||
|
||||
imported.forEach((row) => {
|
||||
// 实收 ≤ 应收合计:已停用,允许实收大于应收合计
|
||||
const key = detailRowBusinessKey(row);
|
||||
let existingIndex = keyToIndex.get(key);
|
||||
|
||||
if (existingIndex === undefined) {
|
||||
const prependIndex = toPrepend.length;
|
||||
toPrepend.push(row);
|
||||
keyToIndex.set(key, next.length + prependIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingIndex >= next.length) {
|
||||
const prependIndex = existingIndex - next.length;
|
||||
const current = toPrepend[prependIndex];
|
||||
if (strategy === 'skip') {
|
||||
skipped += 1;
|
||||
return;
|
||||
}
|
||||
if (strategy === 'error') {
|
||||
duplicateErrors.push({
|
||||
reason: `重复记录:${detailRowBusinessKeyLabel(row)} 与本批导入重复`,
|
||||
rawCells: emptyRawCells(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
toPrepend[prependIndex] = { ...row, id: current.id };
|
||||
overwritten += 1;
|
||||
overwrittenIds.push(current.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (strategy === 'skip') {
|
||||
skipped += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (strategy === 'error') {
|
||||
duplicateErrors.push({
|
||||
reason: `重复记录:${detailRowBusinessKeyLabel(row)} 已存在`,
|
||||
rawCells: emptyRawCells(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const preservedId = next[existingIndex].id;
|
||||
next[existingIndex] = { ...row, id: preservedId };
|
||||
overwritten += 1;
|
||||
overwrittenIds.push(preservedId);
|
||||
});
|
||||
|
||||
return {
|
||||
records: [...toPrepend, ...next],
|
||||
skipped,
|
||||
overwritten,
|
||||
overwrittenIds,
|
||||
duplicateErrors,
|
||||
amountErrors,
|
||||
};
|
||||
}
|
||||
146
src/prototypes/lease-business-detail/utils/monthly-pl.ts
Normal file
146
src/prototypes/lease-business-detail/utils/monthly-pl.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
import { formatMoney } from './detail';
|
||||
|
||||
export interface MonthlyPlRow {
|
||||
month: number;
|
||||
deposit: number;
|
||||
receivable: number;
|
||||
received: number;
|
||||
outstanding: number;
|
||||
naturalMonthIncome: number;
|
||||
/** 明细表无此字段,汇总占位为 0 */
|
||||
hydrogenPrepay: number;
|
||||
baseCost: number;
|
||||
brokerage: number;
|
||||
hydrogenFee: number;
|
||||
totalCost: number;
|
||||
profit: number;
|
||||
hasData: boolean;
|
||||
}
|
||||
|
||||
export const MONTHLY_PL_SUM_KEYS: (keyof MonthlyPlRow)[] = [
|
||||
'deposit',
|
||||
'receivable',
|
||||
'received',
|
||||
'outstanding',
|
||||
'naturalMonthIncome',
|
||||
'hydrogenPrepay',
|
||||
'baseCost',
|
||||
'brokerage',
|
||||
'hydrogenFee',
|
||||
'totalCost',
|
||||
'profit',
|
||||
];
|
||||
|
||||
function emptyMonthlyRow(month: number): MonthlyPlRow {
|
||||
return {
|
||||
month,
|
||||
deposit: 0,
|
||||
receivable: 0,
|
||||
received: 0,
|
||||
outstanding: 0,
|
||||
naturalMonthIncome: 0,
|
||||
hydrogenPrepay: 0,
|
||||
baseCost: 0,
|
||||
brokerage: 0,
|
||||
hydrogenFee: 0,
|
||||
totalCost: 0,
|
||||
profit: 0,
|
||||
hasData: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** 按年份聚合明细行为 1–12 月盈亏汇总 */
|
||||
export function buildMonthlyPlRows(
|
||||
records: LeaseBusinessDetailRow[],
|
||||
year: number,
|
||||
): MonthlyPlRow[] {
|
||||
const buckets = new Map<number, MonthlyPlRow>();
|
||||
for (let month = 1; month <= 12; month += 1) {
|
||||
buckets.set(month, emptyMonthlyRow(month));
|
||||
}
|
||||
|
||||
records.forEach((row) => {
|
||||
if (row.year !== year) return;
|
||||
const bucket = buckets.get(row.month);
|
||||
if (!bucket) return;
|
||||
|
||||
bucket.hasData = true;
|
||||
bucket.deposit += typeof row.deposit === 'number' ? row.deposit : 0;
|
||||
bucket.receivable += row.receivableTotal;
|
||||
bucket.received += row.receivedAmount;
|
||||
bucket.outstanding += row.outstanding;
|
||||
bucket.naturalMonthIncome += row.monthlyIncome;
|
||||
bucket.baseCost += row.vehicleActualCost;
|
||||
bucket.brokerage += row.brokerageFee;
|
||||
bucket.hydrogenFee += row.hydrogenFee;
|
||||
bucket.totalCost += row.totalCost;
|
||||
bucket.profit += row.profitLoss;
|
||||
});
|
||||
|
||||
return Array.from(buckets.values()).sort((a, b) => a.month - b.month);
|
||||
}
|
||||
|
||||
export function sumMonthlyPlRows(rows: MonthlyPlRow[]): Record<string, number> {
|
||||
const sums: Record<string, number> = {};
|
||||
MONTHLY_PL_SUM_KEYS.forEach((key) => {
|
||||
sums[key] = rows.reduce((acc, row) => acc + (row[key] as number), 0);
|
||||
});
|
||||
return sums;
|
||||
}
|
||||
|
||||
function displayPlMoney(value: number, hasData: boolean): string {
|
||||
if (!hasData && value === 0) return '';
|
||||
return formatMoney(value);
|
||||
}
|
||||
|
||||
export function exportMonthlyPlRows(rows: MonthlyPlRow[], year: number): void {
|
||||
const headers = [
|
||||
'月份',
|
||||
'押金',
|
||||
'应收',
|
||||
'实收',
|
||||
'未收',
|
||||
'自然月收入',
|
||||
'氢费预充值',
|
||||
'成本',
|
||||
'居间费',
|
||||
'氢费',
|
||||
'总成本',
|
||||
'盈亏',
|
||||
];
|
||||
const body = rows.map((row) => [
|
||||
String(row.month),
|
||||
displayPlMoney(row.deposit, row.hasData),
|
||||
displayPlMoney(row.receivable, row.hasData),
|
||||
displayPlMoney(row.received, row.hasData),
|
||||
displayPlMoney(row.outstanding, row.hasData),
|
||||
displayPlMoney(row.naturalMonthIncome, row.hasData),
|
||||
displayPlMoney(row.hydrogenPrepay, row.hasData),
|
||||
displayPlMoney(row.baseCost, row.hasData),
|
||||
displayPlMoney(row.brokerage, row.hasData),
|
||||
displayPlMoney(row.hydrogenFee, row.hasData),
|
||||
displayPlMoney(row.totalCost, row.hasData),
|
||||
displayPlMoney(row.profit, row.hasData),
|
||||
]);
|
||||
|
||||
const sums = sumMonthlyPlRows(rows);
|
||||
const totalRow = [
|
||||
'总计',
|
||||
...MONTHLY_PL_SUM_KEYS.map((key) => formatMoney(sums[key] ?? 0)),
|
||||
];
|
||||
|
||||
const sheet = XLSX.utils.aoa_to_sheet([headers, ...body, totalRow]);
|
||||
const book = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(book, sheet, '盈亏月度汇总');
|
||||
XLSX.writeFile(book, `租赁业务盈亏月度汇总_${year}_${Date.now()}.xlsx`);
|
||||
}
|
||||
|
||||
export function buildDetailYearOptions(records: LeaseBusinessDetailRow[]): number[] {
|
||||
const years = new Set<number>();
|
||||
records.forEach((row) => {
|
||||
if (row.year) years.add(row.year);
|
||||
});
|
||||
return Array.from(years).sort((a, b) => b - a);
|
||||
}
|
||||
15
src/prototypes/lease-business-detail/utils/row-key.ts
Normal file
15
src/prototypes/lease-business-detail/utils/row-key.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
|
||||
/** 业务主键:年份 + 月份 + 车牌号码 + 客户名称 */
|
||||
export function detailRowBusinessKey(
|
||||
row: Pick<LeaseBusinessDetailRow, 'year' | 'month' | 'plateNo' | 'customerName'>,
|
||||
): string {
|
||||
return `${row.year}-${String(row.month).padStart(2, '0')}-${row.plateNo.trim()}::${row.customerName.trim()}`;
|
||||
}
|
||||
|
||||
/** 重复提示用的可读标签 */
|
||||
export function detailRowBusinessKeyLabel(
|
||||
row: Pick<LeaseBusinessDetailRow, 'year' | 'month' | 'plateNo' | 'customerName'>,
|
||||
): string {
|
||||
return `${row.year}年${row.month}月 · ${row.plateNo} · ${row.customerName.trim() || '(无客户)'}`;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
import { applyDetailCalculations } from './calc-detail';
|
||||
|
||||
const MONEY_EPS = 0.005;
|
||||
|
||||
/**
|
||||
* 【已停用】原规则:实收金额不得超过应收合计。
|
||||
* 产品已确认:实收金额可以大于应收合计(多收/预收场景),导入与编辑均不再拦截。
|
||||
* 保留函数签名供对照历史逻辑;当前始终返回 null。
|
||||
*/
|
||||
export function validateReceivedAgainstReceivable(
|
||||
_receivableTotal: number,
|
||||
_receivedAmount: number,
|
||||
): string | null {
|
||||
// if (receivedAmount > receivableTotal + MONEY_EPS) {
|
||||
// return `实收金额(${receivedAmount.toFixed(2)})不能大于应收合计(${receivableTotal.toFixed(2)})`;
|
||||
// }
|
||||
void MONEY_EPS;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateDetailRowAmounts(row: LeaseBusinessDetailRow): string | null {
|
||||
return validateReceivedAgainstReceivable(row.receivableTotal, row.receivedAmount);
|
||||
}
|
||||
|
||||
/** 编辑保存前:合并 patch 并重算后校验(当前无金额上限拦截) */
|
||||
export function validateDetailPatch(
|
||||
before: LeaseBusinessDetailRow,
|
||||
patch: Partial<LeaseBusinessDetailRow>,
|
||||
): string | null {
|
||||
const next = applyDetailCalculations({ ...before, ...patch });
|
||||
return validateDetailRowAmounts(next);
|
||||
}
|
||||
@@ -283,8 +283,8 @@ export const ENERGY_MODULES: LineModule[] = [
|
||||
],
|
||||
closure:
|
||||
'签约站氢费由站点主动上报或 PLC 自动采集,预约订单在线闭环,减少能源部手工补录、提升数据时效性。',
|
||||
prototypeHref: '/prototypes/oneos-web-h2-station',
|
||||
prototypeLabel: '打开加氢订单',
|
||||
prototypeHref: '/prototypes/oneos-h5-h2-order',
|
||||
prototypeLabel: '打开加氢订单(H5)',
|
||||
},
|
||||
{
|
||||
id: 'vehicle-h2-fee',
|
||||
@@ -805,7 +805,7 @@ export const SELF_OPERATED_MODULES: LineModule[] = [
|
||||
],
|
||||
closure: '项目收入、司机与车辆成本在同一台账内可核对,盈亏结果可对接财务实收。',
|
||||
prototypeHref: '/prototypes/self-operated-business-ledger',
|
||||
prototypeLabel: '打开自营业务台账',
|
||||
prototypeLabel: '打开物流业务明细',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ vm-page ldb-page
|
||||
## 筛选区
|
||||
|
||||
- 默认一行 **4 项**,其余放入 `ldb-filter-expand` 动画区(与全局 `vm-filter-expand` / `src/common/vm-filter-panel.ts` 规则一致)
|
||||
- 筛选项顺序按业务优先级排列后,**必须**经 `splitFilterFields()` 拆分;禁止手写 `PRIMARY_KEYS` 超过 4 项导致首行溢出
|
||||
- **禁止末行仅 1 项**:展开区项数为 4n+1 时,从首行借调 1 项到展开区(见 `vm-filter-panel.ts` 内 `rebalanceFilterSplit`)
|
||||
- 日期区间使用 `DateRangeFilterField`(见 `vm-shared/DESIGN.md`),展示分隔符为「**至**」
|
||||
- 重置 / 查询按钮加 `ldb-toolbar-btn`
|
||||
|
||||
|
||||
@@ -197,6 +197,10 @@
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
/*
|
||||
* ldb-filter-* 与全局 vm-filter-* 同义(见 rules/global-design-spec.md §4.1.3)。
|
||||
* 新代码优先使用 vm-filter-body / vm-filter-expand / vm-filter-toggle。
|
||||
*/
|
||||
.ldb-filter-expand.is-expanded .ldb-filter-expand-inner {
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -251,6 +255,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.ldb-filter-toggle-badge {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 加氢订单(H5)Implementation Plan
|
||||
|
||||
> **For agentic workers:** Implement task-by-task. Checkbox tracking. Do **not** git commit unless user asks.
|
||||
|
||||
**Goal:** 交付手机端「加氢订单」原型,站端可查看本站记录、OCR 新增,并对账单联动已对账状态。
|
||||
|
||||
**Architecture:** React 单页原型 + 小羚羚 token 的 390 手机壳;数据读写 `h2VehicleLedgerBridge` 共享 Store;列表 / 分步新增 / 只读详情三视图本地状态切换。
|
||||
|
||||
**Tech Stack:** React + TypeScript、本地 CSS、`@axhub/annotation`、现有 Bridge Store
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Bridge 写入 API
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/common/h2VehicleLedgerBridge.js`
|
||||
|
||||
- [x] 增加 `upsertRow(row)`、`removeRow(id)`,挂到 `H2VehicleLedgerBridge` 与 Store
|
||||
- [x] 新增行默认 `reconcileStatus: pending`;通知 subscribe
|
||||
|
||||
### Task 2: 原型骨架与类型
|
||||
|
||||
**Files:**
|
||||
- Create: `src/prototypes/oneos-h5-h2-order/index.tsx`
|
||||
- Create: `src/prototypes/oneos-h5-h2-order/index.html`(若扫描需要)
|
||||
- Create: `src/prototypes/oneos-h5-h2-order/types.ts`
|
||||
- Create: `src/prototypes/oneos-h5-h2-order/styles/index.css`
|
||||
- Create: `src/prototypes/oneos-h5-h2-order/utils/orders.ts`
|
||||
- Create: `src/prototypes/oneos-h5-h2-order/utils/ocr-mock.ts`
|
||||
- Create: `src/prototypes/oneos-h5-h2-order/utils/format.ts`
|
||||
|
||||
### Task 3: 列表 / 新增向导 / 详情组件
|
||||
|
||||
**Files:**
|
||||
- Create: `components/PhoneShell.tsx`
|
||||
- Create: `components/OrderList.tsx`
|
||||
- Create: `components/OrderCard.tsx`
|
||||
- Create: `components/CreateWizard.tsx`
|
||||
- Create: `components/OrderDetail.tsx`
|
||||
- Create: `components/StationSwitcher.tsx`
|
||||
|
||||
### Task 4: 文档、菜单、标注、导航同步
|
||||
|
||||
**Files:**
|
||||
- Create: `.spec/requirements-prd.md`、`.spec/reconcile-linkage.md`
|
||||
- Create: `annotation-source.json`
|
||||
- Modify: `.axhub/make/sidebar-tree.json`(加氢站管理下「加氢订单」)
|
||||
- Run: `npm run project-nav:sync`、`npm run nav:sync -- --prototype oneos-h5-h2-order --note "..."`
|
||||
|
||||
### Task 5: 验收
|
||||
|
||||
- [ ] `/prototypes/oneos-h5-h2-order` 列表仅本站、FAB 新增 OCR 模拟、已对账只读
|
||||
- [ ] 站点信息对账后本页状态同步(同会话)
|
||||
@@ -0,0 +1,113 @@
|
||||
# 加氢订单(H5)— 已确认需求与设计决策
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 日期 | 2026-07-14 |
|
||||
| 原型 ID | `oneos-h5-h2-order` |
|
||||
| 菜单位置 | OneOS → 加氢站管理 → 加氢订单 |
|
||||
| 文档性质 | 决策快照(对齐当时确认,不随实现逐行同步) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 已确认产品需求
|
||||
|
||||
| 问题 | 用户选择 |
|
||||
|---|---|
|
||||
| 列表数据范围 | **A. 仅本站**(当前演示站点绑定的加氢站) |
|
||||
| 登录态 | **A. 已登录态**;顶栏展示站名,演示可切换示例站 |
|
||||
| 已对账记录 | **A. 不可改**(只读);未对账可编辑 / 删除 |
|
||||
| OCR 演示 | **A. 拍照或选图 → 约 1s 模拟识别 → 可校正**(已改为单表单,非三步) |
|
||||
|
||||
### 1.1 目标与范围
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 目标用户 | 加氢站操作人员(采购建站、绑供应商、开账号后使用) |
|
||||
| 核心任务 | 手机浏览器查看 / 上报本站加氢订单,供能源部对账 |
|
||||
| 访问形态 | 手机浏览器 H5(桌面预览用 390 宽手机框) |
|
||||
| 与 Web 关系 | Web 站端/运营仍用「加氢记录」;本页为站端手机入口 |
|
||||
|
||||
**功能清单**
|
||||
|
||||
1. 本站订单列表(KPI:订单数 / 加氢量 / 加氢金额)
|
||||
2. 卡片字段:加氢时间、加氢站名称、车牌号、氢气单价(元/kg)、加氢量、加氢总额、对账状态、对账时间
|
||||
3. 新增:分步 OCR(车牌 → 加氢机面板)→ 核对提交;加氢时间默认当前;站名只读
|
||||
4. 未对账可编辑 / 删除;已对账只读
|
||||
5. 与「站点信息 → 对账单」共享氢费 Store:对账后本页状态变为「已对账」,对账时间为 `YYYY-MM-DD HH:mm`
|
||||
|
||||
**不做**:真登录鉴权、真 OCR 模型、PLC、原生 App、审批流、后端联调
|
||||
|
||||
### 1.2 验收重点
|
||||
|
||||
1. 手机宽度下列表 + 分步新增顺畅(含两次 OCR 模拟)
|
||||
2. 仅展示当前演示站点数据;切换站点列表随之变化
|
||||
3. 已对账不可编辑 / 删除
|
||||
4. 在站点信息完成对账后,本页对应记录状态与对账时间同步(同会话共享 Store)
|
||||
|
||||
---
|
||||
|
||||
## 2. 已确认设计方案
|
||||
|
||||
| 问题 | 用户选择 |
|
||||
|---|---|
|
||||
| 设计基底 | **A. 小羚羚** `make-project-20260629/.../xll-miniapp/DESIGN.md` |
|
||||
| 布局方案 | 列表 + FAB 新增;新增为单表单页(含 OCR);返回时非空二次确认 |
|
||||
|
||||
### 2.1 设计决策
|
||||
|
||||
| 决策 | 内容 |
|
||||
|---|---|
|
||||
| 主色 | `#7AB929` / `#6AA322`(小羚羚绿);与 OneOS Web `#32a06e` 区分,视为「站端 H5」 |
|
||||
| 预览壳 | 居中 390×844 手机框;真机浏览器可全宽 |
|
||||
| 首屏 | 列表为主 + 悬浮「+」 |
|
||||
| 卡片优先级 | 车牌、状态、时间、量与金额;单价 / 对账时间次级 |
|
||||
| 无底 Tab | 本模块单入口,不套小羚羚全局 Tab |
|
||||
| 演示切换 | 顶栏站点下拉,仅演示,不代表正式鉴权 |
|
||||
|
||||
### 2.2 信息架构
|
||||
|
||||
```text
|
||||
加氢订单(H5)
|
||||
├── 列表
|
||||
│ ├── 顶栏:标题 + 当前站(演示切换)
|
||||
│ ├── 摘要条:未对账 n · 已对账 m
|
||||
│ ├── 卡片列表(时间倒序)
|
||||
│ └── FAB 新增
|
||||
├── 新增 / 编辑(未对账)— 单表单
|
||||
│ ├── 加氢时间(默认当前)+ 站名只读
|
||||
│ ├── 拍车牌 OCR → 可校正
|
||||
│ ├── 拍面板 OCR → 单价 / 量 / 总额可校正
|
||||
│ └── 提交;返回时若有未提交内容二次确认
|
||||
└── 详情(已对账,只读)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据与对账联动(实现约定)
|
||||
|
||||
| 项 | 约定 |
|
||||
|---|---|
|
||||
| 数据源 | `src/common/h2VehicleLedgerBridge.js` 共享 Store(与站点信息、车辆氢费明细同源种子) |
|
||||
| 过滤 | `stationName` / `stationId` === 当前演示站 |
|
||||
| 列表单价 / 总额 | 站端口径:`costUnitPrice`、`costTotal`(加氢机成本侧);`hydrogenKg` |
|
||||
| 对账状态 | `reconcileStatus === 'reconciled'` → 已对账;否则未对账 |
|
||||
| 对账时间 | 优先 `reconcileDate`,格式展示 `YYYY-MM-DD HH:mm` |
|
||||
| 写入 | 扩展 Bridge:`upsertRow` / `removeRow`(或等价),保证站点对账单可拉到站端新增的未对账记录 |
|
||||
| 新增默认 | `reconcileStatus: pending`;站名 / stationId 取当前站;客户等字段用演示占位以满足 Store 结构 |
|
||||
| 原型边界 | 本地种子 + 内存 Store,**未接真实 API** |
|
||||
|
||||
---
|
||||
|
||||
## 4. 实施边界(确认后执行)
|
||||
|
||||
**做**
|
||||
|
||||
- 新建原型 `oneos-h5-h2-order`(列表 / 分步新增 / 只读详情)
|
||||
- 挂菜单、同步导航注册表
|
||||
- Bridge 写入能力与对账订阅展示
|
||||
- `.spec/requirements-prd.md` + 对账联动业务说明 + annotation
|
||||
|
||||
**不做**
|
||||
|
||||
- 改造桌面「加氢记录」为主流程(保持并列)
|
||||
- 真相机 OCR SDK、账号体系
|
||||
49
src/prototypes/oneos-h5-h2-order/.spec/feature-create.md
Normal file
49
src/prototypes/oneos-h5-h2-order/.spec/feature-create.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 功能:新增加氢记录
|
||||
|
||||
## 目标
|
||||
|
||||
站端通过手机浏览器上报一笔加氢记录:拍照识别 + 可校正,提交写入共享氢费 Store。
|
||||
|
||||
## 表单分区
|
||||
|
||||
### 1. 基础信息
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| 加氢时间 * | 默认=点击「新增」时的当前时间;点击底部弹层选日期+时+分 |
|
||||
| 加氢站名称 | 只读,登录本站名称 |
|
||||
|
||||
### 2. 车牌识别
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| 拍摄区 | 提示「请拍摄车牌」;相册/相机;选图后自动叠加**时间、地点**水印;OCR 模拟回填车牌 |
|
||||
| 车牌号 * | 占位「请拍摄或输入车牌号」;点击弹出车牌键盘 |
|
||||
| 车辆标签 | 有车牌后:系统车辆列表命中→「羚牛车辆」,否则→「非羚牛车辆」 |
|
||||
| 里程(km) | 选填;支持**从车机获取**(原型 Mock);识别车牌后可自动回填 |
|
||||
|
||||
### 3. 加氢机面板
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| 拍摄区 | 提示「请拍摄清晰的加氢机照片」;选图后叠加**时间、地点**水印;OCR 模拟回填单价、量、总额 |
|
||||
| OCR 参考 | 展示站点信息维护的加氢机品牌/型号线索(见站点信息「加氢机品牌管理」) |
|
||||
| 氢气单价 * | 可改;变更后重算总额 |
|
||||
| 加氢量 * | 可改;变更后重算总额 |
|
||||
| 加氢总额(元) | 只读;= 单价 × 加氢量(两位小数);界面不展示公式文案 |
|
||||
|
||||
## 提交与拦截
|
||||
|
||||
- 必填齐全方可提交
|
||||
- 同站 + 同车牌 + 同加氢时间 → 拦截重复
|
||||
- 有填写内容点返回 → 二次确认防丢失
|
||||
- 已核对或已对账记录不可从此入口编辑(列表进详情后按钮隐藏)
|
||||
|
||||
## 验收
|
||||
|
||||
1. 时间默认正确且可选到分
|
||||
2. 车牌键盘可用;标签随车牌变化
|
||||
3. 车牌/面板照片右下角可见时间与地点水印
|
||||
4. 可从车机获取里程(有车牌时)
|
||||
5. 改单价或量后总额自动更新且不可手改
|
||||
6. 空表单可直接返回;有内容返回需确认
|
||||
37
src/prototypes/oneos-h5-h2-order/.spec/feature-detail.md
Normal file
37
src/prototypes/oneos-h5-h2-order/.spec/feature-detail.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 功能:详情、编辑与删除
|
||||
|
||||
## 目标
|
||||
|
||||
查看单笔加氢记录完整字段;在「未核对且未对账」时可编辑或删除。
|
||||
|
||||
## 详情展示
|
||||
|
||||
- 顶部:车牌、加氢时间、核对标签、对账标签
|
||||
- 指标条:加氢总额、加氢量
|
||||
- **识别原图**:车牌照片、加氢机面板照片(均为拍摄时叠加时间/地点水印后的原图;种子数据无实拍时用演示水印图)
|
||||
- 明细行:站点、车牌、里程、单价、核对/对账状态与时间
|
||||
|
||||
## 锁定规则
|
||||
|
||||
| 条件 | 结果 |
|
||||
|------|------|
|
||||
| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |
|
||||
| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |
|
||||
| 未核对且未对账 | 可编辑、可删除 |
|
||||
|
||||
判定函数:`isOrderLocked`(核对或对账任一成立即锁)。
|
||||
|
||||
## 编辑
|
||||
|
||||
进入与「新增」同一套表单,预填当前值(含已存水印照片);提交走更新逻辑(仍校验重复键)。
|
||||
|
||||
## 删除
|
||||
|
||||
二次确认后从 Bridge 移除该行。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 未核对未对账:底部有删除/编辑
|
||||
2. 已核对或已对账:无操作栏,有锁定说明
|
||||
3. 详情含对账信息;列表卡片不含对账
|
||||
4. 详情展示车牌与面板带水印原图(新上报提交后为实拍;种子行为演示图)
|
||||
40
src/prototypes/oneos-h5-h2-order/.spec/feature-list.md
Normal file
40
src/prototypes/oneos-h5-h2-order/.spec/feature-list.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 功能:本站列表与 KPI
|
||||
|
||||
## 目标
|
||||
|
||||
站端人员打开「加氢订单」后,一眼看到本站汇总与近期加氢记录,点击卡片进入详情。
|
||||
|
||||
## 页面结构
|
||||
|
||||
1. **当前站点**(只读):登录账号对应加氢站名称,无切站入口
|
||||
2. **KPI 三卡**:订单数 / 加氢量(kg) / 加氢金额
|
||||
3. **加氢记录列表**:按加氢时间倒序
|
||||
4. **底栏「新增」**:固定不遮挡卡片滚动区
|
||||
|
||||
## 列表卡片字段
|
||||
|
||||
| 展示 | 说明 |
|
||||
|------|------|
|
||||
| 车牌号 | 去掉尾缀 F 展示 |
|
||||
| 核对标签 | 未核对(橙)/ 已核对(绿) |
|
||||
| 加氢时间 | 精确到分 |
|
||||
| 加氢量 / 加氢总额 | 等宽数字 |
|
||||
| 单价 | 页脚左侧 |
|
||||
| 核对时间 | 仅已核对时页脚右侧显示 |
|
||||
|
||||
**不展示**:里程、对账状态(对账在站点对账单侧完成)。
|
||||
|
||||
## 交互
|
||||
|
||||
- 点击卡片 → 详情页
|
||||
- 点击底栏「新增」→ 新增表单(时间默认=点击时刻)
|
||||
|
||||
## 数据范围
|
||||
|
||||
仅 `stationName === 当前登录站` 的 Bridge 台账行。原型默认站:中国石油中油高新能源牙谷加油加氢站。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 无切站控件
|
||||
2. KPI 与列表条数一致
|
||||
3. 滚动列表时底栏「新增」仍固定且不压住末条卡片
|
||||
@@ -0,0 +1,61 @@
|
||||
# 功能:手工台账上传
|
||||
|
||||
## 目标
|
||||
|
||||
站端每日上传纸质**手工台账照片**,作为系统电子加氢记录的核对依据。多日未操作时,可通过日历查看缺口并补传过往日期。
|
||||
|
||||
## 产品口径
|
||||
|
||||
| 项 | 规则 |
|
||||
|---|---|
|
||||
| 入口 | H5:底部 Tab「手工台账」;Web:顶栏「手工台账」 |
|
||||
| 站点范围 | 仅展示当前登录用户角色对应站点;单站人员仅 1 个、多站可切换、超级管理员可选全部(原型用标注状态切换演示,未接真实登录) |
|
||||
| 上传形态 | 仅图片(拍照/相册),可多张 |
|
||||
| 维度 | **加氢站 + 自然日(本地日历日)** 一份 |
|
||||
| 日历 | 月历自本站**首笔加氢记录日**起算:绿=已传、橙=未传;首笔之前无需补传;禁选未来日 |
|
||||
| 补传 | 允许为过往未传日期补传;已传日期仅可**追加**新图 |
|
||||
| 归档 | **确认上传后的图片进入已归档状态,不可删除**;未确认的草稿图仍可删除 |
|
||||
| 二次确认 | Web 点「确认上传/补传」先弹窗:「提交后将无法修改,是否确认手工台账照片无误」;确认后才写入 |
|
||||
| 拦截 | **未上传今日手工台账 → 禁止新增加氢记录**(编辑已有记录不拦;补传历史不替代今日校验) |
|
||||
| 数据 | 共享 `H2VehicleLedgerBridge` 内存 Store(未接真实 API);站名/编码解析见 Bridge.`h2BridgeResolveStationId` |
|
||||
|
||||
## 判定顺序(新增拦截)
|
||||
|
||||
| 优先级 | 条件 | 结果 |
|
||||
|--------|------|------|
|
||||
| 1 | 操作=编辑已有记录 | 不校验手工台账 |
|
||||
| 2 | 操作=新增,且目标站今日已有 ≥1 张台账图 | 允许进入新增 |
|
||||
| 3 | 操作=新增,今日未上传 | 提示并跳转手工台账 Tab |
|
||||
|
||||
## 日历与补传
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 数据源 | `listManualLedgersByStation`;当日有 ≥1 张图视为已上传 |
|
||||
| 业务起点 | 取本站 Bridge 加氢记录中最早 `hydrogenTime` 自然日;无记录则历史日均不要求补传 |
|
||||
| 未传计数 | 本月内 `max(月初, 首笔日)`~min(月末, 今日) 中未上传的天数 |
|
||||
| 点选 | 仅 `dateKey ≤ 今日`;选中后右侧/下方展示该日草稿图与上传按钮 |
|
||||
| 归档不可删 | Store 已有图强制保留;`upsertManualLedger` 仅追加新图,忽略删除已有图的请求 |
|
||||
| 与拦截关系 | 仅 **今日** 影响「禁止新增」;历史补传解决核对缺口,不放宽今日门禁 |
|
||||
|
||||
## 用户可见
|
||||
|
||||
- 今日状态双反馈(Web 放在右侧「当日台账」卡片内):未上传「今日尚未上传手工台账(YYYY-MM-DD)」异常态;已上传「今日已上传手工台账(YYYY-MM-DD)」正常态
|
||||
- 今日未上传:橙/黄提示 + Tab 红点
|
||||
- 日历:自首笔加氢日起绿/橙标记;首笔之前无橙点(无需补传);本月未传天数摘要
|
||||
- Web 右侧面板标题「手工台账」:空态提示;有图可点进全屏翻页预览并角标「已归档/待提交」;预览区内上传;底部确认前二次弹窗;提交后立即展示已归档缩略图;左右卡片等高
|
||||
|
||||
## 代码路径
|
||||
|
||||
- Bridge:`src/common/h2VehicleLedgerBridge.js`(`hasManualLedgerForDate` / `upsertManualLedger` / `listManualLedgersByStation`)
|
||||
- H5:`oneos-h5-h2-order` · `ManualLedgerPanel` + `utils/manual-ledger.ts`(`buildManualMonthCells` / `getFirstHydrogenDateKey`)
|
||||
- Web:`oneos-web-h2-station/pages/02-加氢记录.jsx`(手工台账 Tab 月历)
|
||||
|
||||
## 验收
|
||||
|
||||
1. 今日未上传时,H5/Web 点「新增」均被拦截并引导上传
|
||||
2. 上传至少一张图并确认后,可新增电子记录
|
||||
3. 日历自首笔加氢日起区分已传/未传;首笔之前不标未传、不计入补传;可点选范围内未传日补传
|
||||
4. 补传历史日后,该日日历变为已传;今日仍未传时新增仍被拦截
|
||||
5. 确认上传后的图显示已归档且不可删除;仅可追加补传
|
||||
6. H5 与 Web 共用同一 Store,一端上传另一端可见(同会话)
|
||||
30
src/prototypes/oneos-h5-h2-order/.spec/fleet-plate-tag.md
Normal file
30
src/prototypes/oneos-h5-h2-order/.spec/fleet-plate-tag.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 车牌「羚牛车辆 / 非羚牛车辆」标签
|
||||
|
||||
原型本地判定,未接真实车辆管理 API。
|
||||
|
||||
## 判定顺序
|
||||
|
||||
| 优先级 | 条件 | 结果 |
|
||||
|--------|------|------|
|
||||
| 1 | 车牌为空 | 不展示标签 |
|
||||
| 2 | 规范化后车牌 ∈ 本地系统车辆集合 | **羚牛车辆** |
|
||||
| 3 | 有车牌但不在集合中 | **非羚牛车辆** |
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 识别(OCR)或键盘输入得到车牌后才展示。
|
||||
- 比较前去掉尾缀 `F`,统一大写。
|
||||
|
||||
## 数据源
|
||||
|
||||
- `utils/fleet-plates.ts` 内本地种子车牌集合(含加氢 Bridge 演示车牌与部分车辆管理样例)。
|
||||
- 正式环境应改为查询车辆管理 / 系统车辆列表接口。
|
||||
|
||||
## 用户可见结果
|
||||
|
||||
- 绿色标签「羚牛车辆」或橙色标签「非羚牛车辆」。
|
||||
- 不拦截提交(仅提示归属)。
|
||||
|
||||
## 代码路径
|
||||
|
||||
- `getVehicleTag` / `isLingniuVehicle` → `CreateWizard` 车牌行右侧标签。
|
||||
48
src/prototypes/oneos-h5-h2-order/.spec/reconcile-linkage.md
Normal file
48
src/prototypes/oneos-h5-h2-order/.spec/reconcile-linkage.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 加氢订单(H5)· 对账 / 核对联动规则
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 代码路径 | Store:`src/common/h2VehicleLedgerBridge.js`;对账单:`oneos-web-h2-station-site`;PC 加氢记录:`oneos-web-h2-station`;车辆氢费明细:`vehicle-h2-fee-ledger` |
|
||||
| 规格全文 | [../vehicle-h2-fee-ledger/.spec/verify-reconcile.md](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) |
|
||||
| 数据源 | 原型本地种子 + 内存共享 Store(**未接真实 API**) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 两套状态(判定优先级)
|
||||
|
||||
| 优先级 | 状态 | 字段 | 谁触发 | 用户可见 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **核对** | `verifyStatus` / `verifiedAt` | 车辆氢费明细 · **完成核对** | 未核对 / 已核对 + 核对时间 |
|
||||
| 2 | **对账** | `reconcileStatus` / `reconcileDate` | 站点信息 · **对账单提交** | 未对账 / 已对账 + 对账时间 |
|
||||
|
||||
> `reconciledAt` 仅作完成核对时的兼容写入,**不得**再用于判定「已对账」。
|
||||
|
||||
## 2. 数据流
|
||||
|
||||
```text
|
||||
站端 H5 / Web 新增加氢记录
|
||||
→ Bridge.upsertRow(verify=unverified,reconcile=pending;成本单价/量/总额)
|
||||
→ 车辆氢费明细可见
|
||||
|
||||
能源部 · 完成核对
|
||||
→ verifyStatus=verified + verifiedAt
|
||||
→ 站端同步「已核对」,禁改删
|
||||
|
||||
站点信息 · 对账单提交(仅已核对 ∧ 未对账)
|
||||
→ reconcileStatus=reconciled + reconcileDate + statementRecordId
|
||||
→ 站端 / 台账同步「已对账」
|
||||
```
|
||||
|
||||
## 3. 用户可见结果(H5)
|
||||
|
||||
| 结果 | 行为 |
|
||||
|---|---|
|
||||
| 未核对 | 可编辑、删除 |
|
||||
| 已核对未对账 | 只读;提示能源部已核对 |
|
||||
| 已对账 | 只读;提示已纳入对账单 |
|
||||
|
||||
## 4. 边界
|
||||
|
||||
- 站端上报默认未核对、未对账
|
||||
- 对账时间只认 `reconcileDate`(对账单回写)
|
||||
- 跨页联动依赖同浏览器会话共享 Store
|
||||
80
src/prototypes/oneos-h5-h2-order/.spec/requirements-prd.md
Normal file
80
src/prototypes/oneos-h5-h2-order/.spec/requirements-prd.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# 加氢订单(H5)— 产品需求文档(PRD)
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 文档版本 | v1.5 |
|
||||
| 模块名称 | 加氢站管理 → 加氢订单 |
|
||||
| 所属系统 | ONE-OS(加氢站手机浏览器) |
|
||||
| 交互原型 | `/prototypes/oneos-h5-h2-order` |
|
||||
| 设计基底 | 小羚羚 `xll-miniapp/DESIGN.md` |
|
||||
| 关联模块 | [站点信息](/prototypes/oneos-web-h2-station-site)、[加氢记录 Web](/prototypes/oneos-web-h2-station)、[车辆氢费明细](/prototypes/vehicle-h2-fee-ledger) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
采购创建加氢站并绑定供应商、开放系统账号后,加氢站人员通过手机浏览器登录 OneOS,在「加氢订单」模块上报加氢记录。Web「加氢记录」为同一业务的 PC 入口(共享 Bridge 数据;PC 另有导出与预约加氢)。
|
||||
|
||||
每日须上传**手工台账**照片,作为电子记录的核对依据;未上传当日手工台账时不可新增加氢记录。手工台账页提供月历,标出已传/未传日期,支持补传过往缺口。
|
||||
|
||||
### 1.1 目标用户
|
||||
|
||||
加氢站操作人员(户外 / 站场手机使用)。
|
||||
|
||||
### 1.2 功能清单
|
||||
|
||||
| # | 功能 | 说明文档 |
|
||||
|---|---|---|
|
||||
| 1 | 本站列表与 KPI | [feature-list.md](./feature-list.md) |
|
||||
| 2 | 新增加氢记录 | [feature-create.md](./feature-create.md) |
|
||||
| 3 | 详情 / 编辑 / 删除 | [feature-detail.md](./feature-detail.md) |
|
||||
| 4 | **手工台账上传** | [feature-manual-ledger.md](./feature-manual-ledger.md) |
|
||||
| 5 | 羚牛车辆标签 | [fleet-plate-tag.md](./fleet-plate-tag.md) |
|
||||
| 6 | 核对与对账联动 | [reconcile-linkage.md](./reconcile-linkage.md) |
|
||||
|
||||
### 1.3 列表字段
|
||||
|
||||
加氢时间、车牌号、氢气单价、加氢量、加氢总额、核对状态;已核对时展示核对时间。里程在新增/详情展示(选填),列表不展示。列表不展示对账状态。
|
||||
|
||||
### 1.4 不做
|
||||
|
||||
真登录、真 OCR SDK、PLC、原生 App、后端联调。
|
||||
|
||||
---
|
||||
|
||||
## 2. 验收项
|
||||
|
||||
1. 菜单位于 OneOS → 加氢站管理 → 加氢订单
|
||||
2. 默认已登录本站;列表仅本站(无切站)
|
||||
3. 底栏 Tab:加氢订单 / 手工台账;月历可区分已传/未传并补传过往日;未上传今日手工台账不可新增;上传后可新增;时间默认点击新增时刻(到分)
|
||||
4. 已核对或已对账不可编辑/删除;未核对未对账可
|
||||
5. 能源部完成核对后同步「已核对」;站点对账单提交后同步「已对账」+ 对账时间
|
||||
6. 同站同车牌同加氢时间重复保存被拦截
|
||||
7. Web「加氢记录」与 H5「加氢订单」手工台账逻辑一致(同 Store)
|
||||
8. 车牌/面板拍摄预览含水印(时间、地点=本站名称);里程可从车机获取(Mock)
|
||||
9. 面板 OCR 可展示站点信息维护的加氢机品牌型号线索
|
||||
10. 详情页展示车牌与面板带水印原图
|
||||
|
||||
---
|
||||
|
||||
## 3. 复杂逻辑摘要
|
||||
|
||||
| 主题 | 摘要 | 全文 |
|
||||
|---|---|---|
|
||||
| 对账 / 核对 | 核对≠对账;触发方不同 | [reconcile-linkage.md](./reconcile-linkage.md)、[车辆氢费 verify-reconcile](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) |
|
||||
| 重复键 | 与 Web 共用 Bridge | [Web 数据模型](../oneos-web-h2-station/.spec/record-data-model.md) |
|
||||
| 羚牛车辆标签 | 本地系统车辆集合判定 | [fleet-plate-tag.md](./fleet-plate-tag.md) |
|
||||
| 总额 | 单价×加氢量自动算,界面不展示公式 | [feature-create.md](./feature-create.md) |
|
||||
| 手工台账 | 按站+自然日上传图片;月历标已传/未传可补传;未上传今日禁新增 | [feature-manual-ledger.md](./feature-manual-ledger.md) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 页面与标注对照
|
||||
|
||||
| 页面 pageId | 主要能力 |
|
||||
|---|---|
|
||||
| `list` | 站点头、KPI、订单卡片、底栏新增 |
|
||||
| `create` | 时间选择、站名、车牌键盘、面板 OCR、金额字段 |
|
||||
| `detail` | 详情字段、锁定说明、编辑/删除 |
|
||||
|
||||
标注数据:`annotation-source.json`(由 `scripts/build-annotation-source.mjs` 从本目录 Markdown 生成)。
|
||||
513
src/prototypes/oneos-h5-h2-order/annotation-source.json
Normal file
513
src/prototypes/oneos-h5-h2-order/annotation-source.json
Normal file
@@ -0,0 +1,513 @@
|
||||
{
|
||||
"documentVersion": 1,
|
||||
"format": "axhub-annotation-source",
|
||||
"data": {
|
||||
"version": 2,
|
||||
"prototypeName": "oneos-h5-h2-order",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1784280448632,
|
||||
"nodes": [
|
||||
{
|
||||
"images": [],
|
||||
"controls": [],
|
||||
"createdAt": 1784280448632,
|
||||
"updatedAt": 1784280448632,
|
||||
"hasMarkdown": true,
|
||||
"annotationText": "",
|
||||
"id": "h5-hero",
|
||||
"index": 1,
|
||||
"title": "当前站点",
|
||||
"pageId": "list",
|
||||
"color": "#7AB929",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-hero']",
|
||||
".h5-hero"
|
||||
],
|
||||
"fingerprint": "hero|list",
|
||||
"path": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"images": [],
|
||||
"controls": [],
|
||||
"createdAt": 1784280448632,
|
||||
"updatedAt": 1784280448632,
|
||||
"hasMarkdown": true,
|
||||
"annotationText": "",
|
||||
"id": "h5-summary",
|
||||
"index": 2,
|
||||
"title": "本站 KPI",
|
||||
"pageId": "list",
|
||||
"color": "#0E8A7B",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-summary']",
|
||||
".h5-summary"
|
||||
],
|
||||
"fingerprint": "summary|list",
|
||||
"path": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"images": [],
|
||||
"controls": [],
|
||||
"createdAt": 1784280448632,
|
||||
"updatedAt": 1784280448632,
|
||||
"hasMarkdown": true,
|
||||
"annotationText": "",
|
||||
"id": "h5-card",
|
||||
"index": 3,
|
||||
"title": "订单卡片",
|
||||
"pageId": "list",
|
||||
"color": "#FF7D00",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-card']",
|
||||
".h5-card"
|
||||
],
|
||||
"fingerprint": "card|list",
|
||||
"path": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"images": [],
|
||||
"controls": [],
|
||||
"createdAt": 1784280448632,
|
||||
"updatedAt": 1784280448632,
|
||||
"hasMarkdown": true,
|
||||
"annotationText": "",
|
||||
"id": "h5-fab",
|
||||
"index": 4,
|
||||
"title": "底栏新增",
|
||||
"pageId": "list",
|
||||
"color": "#7AB929",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-fab']",
|
||||
".h5-fab"
|
||||
],
|
||||
"fingerprint": "fab|list",
|
||||
"path": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"images": [],
|
||||
"controls": [],
|
||||
"createdAt": 1784280448632,
|
||||
"updatedAt": 1784280448632,
|
||||
"hasMarkdown": true,
|
||||
"annotationText": "",
|
||||
"id": "h5-time",
|
||||
"index": 5,
|
||||
"title": "加氢时间",
|
||||
"pageId": "create",
|
||||
"color": "#7AB929",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-time']",
|
||||
"#h5-time"
|
||||
],
|
||||
"fingerprint": "field|h5-time",
|
||||
"path": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"images": [],
|
||||
"controls": [],
|
||||
"createdAt": 1784280448632,
|
||||
"updatedAt": 1784280448632,
|
||||
"hasMarkdown": true,
|
||||
"annotationText": "",
|
||||
"id": "h5-station",
|
||||
"index": 6,
|
||||
"title": "加氢站名称",
|
||||
"pageId": "create",
|
||||
"color": "#86909C",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-station']",
|
||||
"#h5-station"
|
||||
],
|
||||
"fingerprint": "field|h5-station",
|
||||
"path": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"images": [],
|
||||
"controls": [],
|
||||
"createdAt": 1784280448632,
|
||||
"updatedAt": 1784280448632,
|
||||
"hasMarkdown": true,
|
||||
"annotationText": "",
|
||||
"id": "h5-plate",
|
||||
"index": 7,
|
||||
"title": "车牌号",
|
||||
"pageId": "create",
|
||||
"color": "#FF7D00",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-plate']",
|
||||
"#h5-plate"
|
||||
],
|
||||
"fingerprint": "field|h5-plate",
|
||||
"path": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"images": [],
|
||||
"controls": [],
|
||||
"createdAt": 1784280448632,
|
||||
"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,
|
||||
"annotationText": "",
|
||||
"id": "h5-price",
|
||||
"index": 10,
|
||||
"title": "氢气单价",
|
||||
"pageId": "create",
|
||||
"color": "#7AB929",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-price']",
|
||||
"#h5-price"
|
||||
],
|
||||
"fingerprint": "field|h5-price",
|
||||
"path": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"images": [],
|
||||
"controls": [],
|
||||
"createdAt": 1784280448632,
|
||||
"updatedAt": 1784280448632,
|
||||
"hasMarkdown": true,
|
||||
"annotationText": "",
|
||||
"id": "h5-kg",
|
||||
"index": 11,
|
||||
"title": "加氢量",
|
||||
"pageId": "create",
|
||||
"color": "#7AB929",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-kg']",
|
||||
"#h5-kg"
|
||||
],
|
||||
"fingerprint": "field|h5-kg",
|
||||
"path": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"images": [],
|
||||
"controls": [],
|
||||
"createdAt": 1784280448632,
|
||||
"updatedAt": 1784280448632,
|
||||
"hasMarkdown": true,
|
||||
"annotationText": "",
|
||||
"id": "h5-total-label",
|
||||
"index": 12,
|
||||
"title": "加氢总额",
|
||||
"pageId": "create",
|
||||
"color": "#0E8A7B",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-total-label']",
|
||||
"#h5-total"
|
||||
],
|
||||
"fingerprint": "field|h5-total",
|
||||
"path": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"images": [],
|
||||
"controls": [],
|
||||
"createdAt": 1784280448632,
|
||||
"updatedAt": 1784280448632,
|
||||
"hasMarkdown": true,
|
||||
"annotationText": "",
|
||||
"id": "h5-detail",
|
||||
"index": 13,
|
||||
"title": "记录详情",
|
||||
"pageId": "detail",
|
||||
"color": "#FF7D00",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-detail']",
|
||||
".h5-panel--detail"
|
||||
],
|
||||
"fingerprint": "detail|panel",
|
||||
"path": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"images": [],
|
||||
"controls": [],
|
||||
"createdAt": 1784280448632,
|
||||
"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,
|
||||
"annotationText": "",
|
||||
"id": "h5-manual",
|
||||
"index": 15,
|
||||
"title": "手工台账",
|
||||
"pageId": "manual",
|
||||
"color": "#7AB929",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-manual']",
|
||||
".h5-manual"
|
||||
],
|
||||
"fingerprint": "manual|panel",
|
||||
"path": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"images": [],
|
||||
"controls": [],
|
||||
"createdAt": 1784280448632,
|
||||
"updatedAt": 1784280448632,
|
||||
"hasMarkdown": true,
|
||||
"annotationText": "",
|
||||
"id": "h5-manual-calendar",
|
||||
"index": 16,
|
||||
"title": "台账日历",
|
||||
"pageId": "manual",
|
||||
"color": "#0E8A7B",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-manual-calendar']",
|
||||
".h5-cal-grid"
|
||||
],
|
||||
"fingerprint": "manual|calendar",
|
||||
"path": []
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"markdownMap": {
|
||||
"h5-hero": "## 当前站点\n\n只读展示登录账号对应加氢站,列表页不提供切站。\n\n详见 [本站列表](feature-list)。",
|
||||
"h5-summary": "## 本站 KPI\n\n订单数 / 加氢量(kg) / 加氢金额,随本站列表实时汇总。\n\n详见功能说明「本站列表与 KPI」。",
|
||||
"h5-card": "# 功能:本站列表与 KPI\n\n## 目标\n\n站端人员打开「加氢订单」后,一眼看到本站汇总与近期加氢记录,点击卡片进入详情。\n\n## 页面结构\n\n1. **当前站点**(只读):登录账号对应加氢站名称,无切站入口 \n2. **KPI 三卡**:订单数 / 加氢量(kg) / 加氢金额 \n3. **加氢记录列表**:按加氢时间倒序 \n4. **底栏「新增」**:固定不遮挡卡片滚动区 \n\n## 列表卡片字段\n\n| 展示 | 说明 |\n|------|------|\n| 车牌号 | 去掉尾缀 F 展示 |\n| 核对标签 | 未核对(橙)/ 已核对(绿) |\n| 加氢时间 | 精确到分 |\n| 加氢量 / 加氢总额 | 等宽数字 |\n| 单价 | 页脚左侧 |\n| 核对时间 | 仅已核对时页脚右侧显示 |\n\n**不展示**:里程、对账状态(对账在站点对账单侧完成)。\n\n## 交互\n\n- 点击卡片 → 详情页 \n- 点击底栏「新增」→ 新增表单(时间默认=点击时刻) \n\n## 数据范围\n\n仅 `stationName === 当前登录站` 的 Bridge 台账行。原型默认站:中国石油中油高新能源牙谷加油加氢站。\n\n## 验收\n\n1. 无切站控件 \n2. KPI 与列表条数一致 \n3. 滚动列表时底栏「新增」仍固定且不压住末条卡片 \n",
|
||||
"h5-fab": "## 底栏新增\n\n固定在列表底部,不遮挡卡片滚动区。点击进入新增表单,加氢时间默认=点击时刻。\n\n详见功能说明「新增加氢记录」。",
|
||||
"h5-time": "## 加氢时间(必填)\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-mileage": "## 里程(km)\n\n选填。支持「从车机获取」(原型 Mock);识别车牌后可自动回填。正式环境对接车机 / T-Box。\n\n详见 [新增加氢记录](feature-create)。",
|
||||
"h5-dispenser-brands": "## 加氢机品牌参考\n\n展示站点信息「加氢机品牌管理」维护的品牌·型号,供面板 OCR 模型判断参考。数据源:`h2DispenserBrandStore`。",
|
||||
"h5-price": "## 氢气单价(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额(单价×加氢量)。",
|
||||
"h5-kg": "## 加氢量(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额。",
|
||||
"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\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-calendar": "## 台账日历\n\n自本站首笔加氢记录日起:绿=已上传,橙=未上传;首笔之前无需补传。禁选未来日。点选范围内未传日可补传。仅「今日」是否上传影响新增加氢记录。\n\n详见 [手工台账上传](feature-manual-ledger)。"
|
||||
},
|
||||
"assetMap": {},
|
||||
"directory": {
|
||||
"nodes": [
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "h5-doc-root",
|
||||
"title": "加氢订单(H5)说明",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "h5-doc-prd",
|
||||
"title": "PRD 全文",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "h5-doc-features",
|
||||
"title": "功能说明",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "h5-doc-list",
|
||||
"title": "本站列表与 KPI",
|
||||
"markdown": "# 功能:本站列表与 KPI\n\n## 目标\n\n站端人员打开「加氢订单」后,一眼看到本站汇总与近期加氢记录,点击卡片进入详情。\n\n## 页面结构\n\n1. **当前站点**(只读):登录账号对应加氢站名称,无切站入口 \n2. **KPI 三卡**:订单数 / 加氢量(kg) / 加氢金额 \n3. **加氢记录列表**:按加氢时间倒序 \n4. **底栏「新增」**:固定不遮挡卡片滚动区 \n\n## 列表卡片字段\n\n| 展示 | 说明 |\n|------|------|\n| 车牌号 | 去掉尾缀 F 展示 |\n| 核对标签 | 未核对(橙)/ 已核对(绿) |\n| 加氢时间 | 精确到分 |\n| 加氢量 / 加氢总额 | 等宽数字 |\n| 单价 | 页脚左侧 |\n| 核对时间 | 仅已核对时页脚右侧显示 |\n\n**不展示**:里程、对账状态(对账在站点对账单侧完成)。\n\n## 交互\n\n- 点击卡片 → 详情页 \n- 点击底栏「新增」→ 新增表单(时间默认=点击时刻) \n\n## 数据范围\n\n仅 `stationName === 当前登录站` 的 Bridge 台账行。原型默认站:中国石油中油高新能源牙谷加油加氢站。\n\n## 验收\n\n1. 无切站控件 \n2. KPI 与列表条数一致 \n3. 滚动列表时底栏「新增」仍固定且不压住末条卡片 \n",
|
||||
"markdownPath": ".spec/feature-list.md"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "h5-doc-create",
|
||||
"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) | 选填;支持**从车机获取**(原型 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"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "h5-doc-manual",
|
||||
"title": "手工台账上传",
|
||||
"markdown": "# 功能:手工台账上传\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",
|
||||
"markdownPath": ".spec/feature-manual-ledger.md"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "h5-doc-detail",
|
||||
"title": "详情编辑删除",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "h5-doc-fleet",
|
||||
"title": "羚牛车辆标签",
|
||||
"markdown": "# 车牌「羚牛车辆 / 非羚牛车辆」标签\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",
|
||||
"markdownPath": ".spec/fleet-plate-tag.md"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "h5-doc-reconcile",
|
||||
"title": "核对与对账联动",
|
||||
"markdown": "# 加氢订单(H5)· 对账 / 核对联动规则\n\n| 项 | 说明 |\n|---|---|\n| 代码路径 | Store:`src/common/h2VehicleLedgerBridge.js`;对账单:`oneos-web-h2-station-site`;PC 加氢记录:`oneos-web-h2-station`;车辆氢费明细:`vehicle-h2-fee-ledger` |\n| 规格全文 | [../vehicle-h2-fee-ledger/.spec/verify-reconcile.md](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) |\n| 数据源 | 原型本地种子 + 内存共享 Store(**未接真实 API**) |\n\n---\n\n## 1. 两套状态(判定优先级)\n\n| 优先级 | 状态 | 字段 | 谁触发 | 用户可见 |\n|---|---|---|---|---|\n| 1 | **核对** | `verifyStatus` / `verifiedAt` | 车辆氢费明细 · **完成核对** | 未核对 / 已核对 + 核对时间 |\n| 2 | **对账** | `reconcileStatus` / `reconcileDate` | 站点信息 · **对账单提交** | 未对账 / 已对账 + 对账时间 |\n\n> `reconciledAt` 仅作完成核对时的兼容写入,**不得**再用于判定「已对账」。\n\n## 2. 数据流\n\n```text\n站端 H5 / Web 新增加氢记录\n → Bridge.upsertRow(verify=unverified,reconcile=pending;成本单价/量/总额)\n → 车辆氢费明细可见\n\n能源部 · 完成核对\n → verifyStatus=verified + verifiedAt\n → 站端同步「已核对」,禁改删\n\n站点信息 · 对账单提交(仅已核对 ∧ 未对账)\n → reconcileStatus=reconciled + reconcileDate + statementRecordId\n → 站端 / 台账同步「已对账」\n```\n\n## 3. 用户可见结果(H5)\n\n| 结果 | 行为 |\n|---|---|\n| 未核对 | 可编辑、删除 |\n| 已核对未对账 | 只读;提示能源部已核对 |\n| 已对账 | 只读;提示已纳入对账单 |\n\n## 4. 边界\n\n- 站端上报默认未核对、未对账\n- 对账时间只认 `reconcileDate`(对账单回写)\n- 跨页联动依赖同浏览器会话共享 Store\n",
|
||||
"markdownPath": ".spec/reconcile-linkage.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "h5-doc-pages",
|
||||
"title": "按页面",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "h5-page-list",
|
||||
"title": "列表页",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "route",
|
||||
"id": "h5-route-list",
|
||||
"title": "list",
|
||||
"route": "list",
|
||||
"payload": {
|
||||
"pageId": "list"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "h5-page-list-md",
|
||||
"title": "列表页要点",
|
||||
"markdown": "# 功能:本站列表与 KPI\n\n## 目标\n\n站端人员打开「加氢订单」后,一眼看到本站汇总与近期加氢记录,点击卡片进入详情。\n\n## 页面结构\n\n1. **当前站点**(只读):登录账号对应加氢站名称,无切站入口 \n2. **KPI 三卡**:订单数 / 加氢量(kg) / 加氢金额 \n3. **加氢记录列表**:按加氢时间倒序 \n4. **底栏「新增」**:固定不遮挡卡片滚动区 \n\n## 列表卡片字段\n\n| 展示 | 说明 |\n|------|------|\n| 车牌号 | 去掉尾缀 F 展示 |\n| 核对标签 | 未核对(橙)/ 已核对(绿) |\n| 加氢时间 | 精确到分 |\n| 加氢量 / 加氢总额 | 等宽数字 |\n| 单价 | 页脚左侧 |\n| 核对时间 | 仅已核对时页脚右侧显示 |\n\n**不展示**:里程、对账状态(对账在站点对账单侧完成)。\n\n## 交互\n\n- 点击卡片 → 详情页 \n- 点击底栏「新增」→ 新增表单(时间默认=点击时刻) \n\n## 数据范围\n\n仅 `stationName === 当前登录站` 的 Bridge 台账行。原型默认站:中国石油中油高新能源牙谷加油加氢站。\n\n## 验收\n\n1. 无切站控件 \n2. KPI 与列表条数一致 \n3. 滚动列表时底栏「新增」仍固定且不压住末条卡片 \n"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "h5-page-create",
|
||||
"title": "新增页",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "route",
|
||||
"id": "h5-route-create",
|
||||
"title": "create",
|
||||
"route": "create",
|
||||
"payload": {
|
||||
"pageId": "create"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "h5-page-create-md",
|
||||
"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) | 选填;支持**从车机获取**(原型 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"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "h5-page-detail",
|
||||
"title": "详情页",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "route",
|
||||
"id": "h5-route-detail",
|
||||
"title": "detail",
|
||||
"route": "detail",
|
||||
"payload": {
|
||||
"pageId": "detail"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "h5-page-detail-md",
|
||||
"title": "详情页要点",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
486
src/prototypes/oneos-h5-h2-order/components/CreateWizard.tsx
Normal file
486
src/prototypes/oneos-h5-h2-order/components/CreateWizard.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import type { CreateDraft } from '../types';
|
||||
import { getVehicleTag } from '../utils/fleet-plates';
|
||||
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 {
|
||||
IconCamera,
|
||||
IconCheck,
|
||||
IconChevronLeft,
|
||||
IconClock,
|
||||
IconFuel,
|
||||
IconSpinner,
|
||||
} from './Icons';
|
||||
import { PhoneShell } from './PhoneShell';
|
||||
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 = {
|
||||
mode: 'create' | 'edit';
|
||||
draft: CreateDraft;
|
||||
initialDraft: CreateDraft;
|
||||
stationName: string;
|
||||
onChangeDraft: (patch: Partial<CreateDraft>) => void;
|
||||
onBack: () => void;
|
||||
onSubmit: () => void;
|
||||
toast?: string;
|
||||
};
|
||||
|
||||
function draftFingerprint(draft: CreateDraft): string {
|
||||
return [
|
||||
draft.hydrogenTime.trim(),
|
||||
draft.plateNo.trim(),
|
||||
draft.mileageKm.trim(),
|
||||
draft.unitPrice.trim(),
|
||||
draft.hydrogenKg.trim(),
|
||||
draft.totalAmount.trim(),
|
||||
draft.platePreviewUrl ? '1' : '0',
|
||||
draft.panelPreviewUrl ? '1' : '0',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export function isDraftDirty(
|
||||
draft: CreateDraft,
|
||||
initialDraft: CreateDraft,
|
||||
mode: 'create' | 'edit',
|
||||
): boolean {
|
||||
if (mode === 'edit') {
|
||||
return draftFingerprint(draft) !== draftFingerprint(initialDraft);
|
||||
}
|
||||
return Boolean(
|
||||
draft.plateNo.trim() ||
|
||||
draft.mileageKm.trim() ||
|
||||
draft.unitPrice.trim() ||
|
||||
draft.hydrogenKg.trim() ||
|
||||
draft.totalAmount.trim() ||
|
||||
draft.platePreviewUrl ||
|
||||
draft.panelPreviewUrl,
|
||||
);
|
||||
}
|
||||
|
||||
function calcTotalAmount(unitPrice: string, hydrogenKg: string): string {
|
||||
const price = Number(unitPrice);
|
||||
const kg = Number(hydrogenKg);
|
||||
if (!Number.isFinite(price) || !Number.isFinite(kg) || unitPrice === '' || hydrogenKg === '') {
|
||||
return '';
|
||||
}
|
||||
return String(Math.round(price * kg * 100) / 100);
|
||||
}
|
||||
|
||||
function RequiredMark() {
|
||||
return (
|
||||
<span className="h5-req" aria-hidden>
|
||||
*
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateWizard({
|
||||
mode,
|
||||
draft,
|
||||
initialDraft,
|
||||
stationName,
|
||||
onChangeDraft,
|
||||
onBack,
|
||||
onSubmit,
|
||||
toast,
|
||||
}: Props) {
|
||||
const plateInputRef = useRef<HTMLInputElement>(null);
|
||||
const panelInputRef = useRef<HTMLInputElement>(null);
|
||||
const [recognizing, setRecognizing] = useState<'plate' | 'panel' | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [fetchingMileage, setFetchingMileage] = useState(false);
|
||||
const [mileageHint, setMileageHint] = useState('');
|
||||
const [timeOpen, setTimeOpen] = useState(false);
|
||||
const [plateOpen, setPlateOpen] = useState(false);
|
||||
|
||||
const vehicleTag = useMemo(() => getVehicleTag(draft.plateNo), [draft.plateNo]);
|
||||
const brandHints = listDispenserBrandHints();
|
||||
|
||||
function patchDraft(patch: Partial<CreateDraft>) {
|
||||
const nextPrice = patch.unitPrice !== undefined ? patch.unitPrice : draft.unitPrice;
|
||||
const nextKg = patch.hydrogenKg !== undefined ? patch.hydrogenKg : draft.hydrogenKg;
|
||||
if (patch.unitPrice !== undefined || patch.hydrogenKg !== undefined) {
|
||||
patch = { ...patch, totalAmount: calcTotalAmount(nextPrice, nextKg) };
|
||||
}
|
||||
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) {
|
||||
if (!file) return;
|
||||
setRecognizing('plate');
|
||||
try {
|
||||
const rawUrl = await readFileAsDataUrl(file);
|
||||
const url = await watermarkPreview(rawUrl);
|
||||
await delay(900);
|
||||
const ocr = mockPlateOcr();
|
||||
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);
|
||||
} finally {
|
||||
setRecognizing(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePanelFile(file: File | null) {
|
||||
if (!file) return;
|
||||
setRecognizing('panel');
|
||||
try {
|
||||
const rawUrl = await readFileAsDataUrl(file);
|
||||
const url = await watermarkPreview(rawUrl);
|
||||
await delay(900);
|
||||
const ocr = mockPanelOcr();
|
||||
onChangeDraft({
|
||||
panelPreviewUrl: url,
|
||||
unitPrice: String(ocr.unitPrice),
|
||||
hydrogenKg: String(ocr.hydrogenKg),
|
||||
totalAmount: String(ocr.totalAmount),
|
||||
});
|
||||
if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate(10);
|
||||
} finally {
|
||||
setRecognizing(null);
|
||||
}
|
||||
}
|
||||
|
||||
function requestBack() {
|
||||
if (isDraftDirty(draft, initialDraft, mode)) {
|
||||
const ok = window.confirm('当前内容尚未提交,返回将丢失已填写信息。确认返回?');
|
||||
if (!ok) return;
|
||||
}
|
||||
onBack();
|
||||
}
|
||||
|
||||
async function handleSubmitClick() {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
onSubmit();
|
||||
} finally {
|
||||
window.setTimeout(() => setSubmitting(false), 400);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
Boolean(draft.plateNo.trim()) &&
|
||||
Boolean(draft.unitPrice) &&
|
||||
Boolean(draft.hydrogenKg) &&
|
||||
Boolean(draft.totalAmount) &&
|
||||
Boolean(draft.hydrogenTime.trim()) &&
|
||||
!recognizing &&
|
||||
!submitting;
|
||||
|
||||
return (
|
||||
<PhoneShell
|
||||
title={mode === 'edit' ? '编辑加氢记录' : '新增加氢记录'}
|
||||
left={
|
||||
<button type="button" className="h5-nav-btn" onClick={requestBack} aria-label="返回">
|
||||
<IconChevronLeft size={20} />
|
||||
</button>
|
||||
}
|
||||
bodyClassName="h5-body--wizard"
|
||||
footer={
|
||||
<div className="h5-action-bar">
|
||||
<button type="button" className="h5-btn" onClick={requestBack} disabled={submitting}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h5-btn h5-btn--primary"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void handleSubmitClick()}
|
||||
>
|
||||
{submitting ? <IconSpinner size={18} /> : <IconCheck size={18} />}
|
||||
{submitting ? '提交中…' : '提交'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<section className="h5-panel" data-annotation-id="h5-create-basic">
|
||||
<div className="h5-panel-head">
|
||||
<div className="h5-panel-badge" aria-hidden>
|
||||
<IconClock size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3>基础信息</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h5-field">
|
||||
<label htmlFor="h5-time">
|
||||
<RequiredMark />
|
||||
加氢时间
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
id="h5-time"
|
||||
className="h5-field-trigger tabular-nums"
|
||||
data-annotation-id="h5-time"
|
||||
onClick={() => setTimeOpen(true)}
|
||||
>
|
||||
{draft.hydrogenTime || '请选择加氢时间'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="h5-field">
|
||||
<label htmlFor="h5-station">加氢站名称</label>
|
||||
<input
|
||||
id="h5-station"
|
||||
data-annotation-id="h5-station"
|
||||
value={stationName}
|
||||
disabled
|
||||
title="取当前登录账号对应加氢站"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="h5-panel">
|
||||
<div className="h5-panel-head">
|
||||
<div className="h5-panel-badge" aria-hidden>
|
||||
<IconCamera size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3>车牌识别</h3>
|
||||
<p>请拍摄车牌</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`h5-shot h5-shot--compact${draft.platePreviewUrl ? ' h5-shot--filled' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => plateInputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') plateInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
{draft.platePreviewUrl ? <img src={draft.platePreviewUrl} alt="车牌照片预览" /> : null}
|
||||
<div className="h5-shot-content">
|
||||
{recognizing === 'plate' ? <IconSpinner size={18} /> : <IconCamera size={18} />}
|
||||
<span>
|
||||
{recognizing === 'plate'
|
||||
? '识别中…'
|
||||
: draft.platePreviewUrl
|
||||
? '重新拍摄 / 选图'
|
||||
: '拍摄车牌'}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref={plateInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={(e) => {
|
||||
void handlePlateFile(e.target.files?.[0] || null);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="h5-field" style={{ marginTop: 12 }}>
|
||||
<label htmlFor="h5-plate">
|
||||
<RequiredMark />
|
||||
车牌号
|
||||
</label>
|
||||
<div className="h5-plate-row">
|
||||
<button
|
||||
type="button"
|
||||
id="h5-plate"
|
||||
className="h5-field-trigger"
|
||||
data-annotation-id="h5-plate"
|
||||
onClick={() => setPlateOpen(true)}
|
||||
>
|
||||
{draft.plateNo || (
|
||||
<span className="h5-field-trigger__ph">请拍摄或输入车牌号</span>
|
||||
)}
|
||||
</button>
|
||||
{vehicleTag === 'lingniu' ? (
|
||||
<span className="h5-vehicle-tag h5-vehicle-tag--in">羚牛车辆</span>
|
||||
) : null}
|
||||
{vehicleTag === 'external' ? (
|
||||
<span className="h5-vehicle-tag h5-vehicle-tag--out">非羚牛车辆</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h5-field" data-annotation-id="h5-mileage">
|
||||
<label htmlFor="h5-mileage">里程(km)</label>
|
||||
<div className="h5-mileage-row">
|
||||
<input
|
||||
id="h5-mileage"
|
||||
inputMode="numeric"
|
||||
value={draft.mileageKm}
|
||||
onChange={(e) => {
|
||||
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>
|
||||
</section>
|
||||
|
||||
<section className="h5-panel">
|
||||
<div className="h5-panel-head">
|
||||
<div className="h5-panel-badge" aria-hidden>
|
||||
<IconFuel size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3>加氢机面板识别</h3>
|
||||
<p>请拍摄清晰的加氢机照片</p>
|
||||
</div>
|
||||
</div>
|
||||
{brandHints.length ? (
|
||||
<p className="h5-brand-hint" data-annotation-id="h5-dispenser-brands">
|
||||
OCR 参考品牌型号(站点信息维护):{brandHints.join('、')}
|
||||
{brandHints.length >= 4 ? '…' : ''}
|
||||
</p>
|
||||
) : null}
|
||||
<div
|
||||
className={`h5-shot h5-shot--compact${draft.panelPreviewUrl ? ' h5-shot--filled' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => panelInputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') panelInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
{draft.panelPreviewUrl ? <img src={draft.panelPreviewUrl} alt="加氢机面板预览" /> : null}
|
||||
<div className="h5-shot-content">
|
||||
{recognizing === 'panel' ? <IconSpinner size={18} /> : <IconCamera size={18} />}
|
||||
<span>
|
||||
{recognizing === 'panel'
|
||||
? '识别中…'
|
||||
: draft.panelPreviewUrl
|
||||
? '重新拍摄 / 选图'
|
||||
: '拍摄面板'}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref={panelInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={(e) => {
|
||||
void handlePanelFile(e.target.files?.[0] || null);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="h5-field" style={{ marginTop: 12 }}>
|
||||
<label htmlFor="h5-price">
|
||||
<RequiredMark />
|
||||
氢气单价(元/kg)
|
||||
</label>
|
||||
<input
|
||||
id="h5-price"
|
||||
data-annotation-id="h5-price"
|
||||
inputMode="decimal"
|
||||
value={draft.unitPrice}
|
||||
onChange={(e) => patchDraft({ unitPrice: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="h5-field">
|
||||
<label htmlFor="h5-kg">
|
||||
<RequiredMark />
|
||||
加氢量(kg)
|
||||
</label>
|
||||
<input
|
||||
id="h5-kg"
|
||||
data-annotation-id="h5-kg"
|
||||
inputMode="decimal"
|
||||
value={draft.hydrogenKg}
|
||||
onChange={(e) => patchDraft({ hydrogenKg: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="h5-field">
|
||||
<label htmlFor="h5-total" data-annotation-id="h5-total-label">
|
||||
加氢总额(元)
|
||||
</label>
|
||||
<input
|
||||
id="h5-total"
|
||||
inputMode="decimal"
|
||||
value={draft.totalAmount}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{toast ? (
|
||||
<div className="h5-toast" role="status" aria-live="polite">
|
||||
{toast}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DateTimePickerSheet
|
||||
open={timeOpen}
|
||||
value={draft.hydrogenTime}
|
||||
onClose={() => setTimeOpen(false)}
|
||||
onConfirm={(next) => {
|
||||
onChangeDraft({ hydrogenTime: next });
|
||||
setTimeOpen(false);
|
||||
}}
|
||||
/>
|
||||
<PlateKeyboardSheet
|
||||
open={plateOpen}
|
||||
value={draft.plateNo}
|
||||
onClose={() => setPlateOpen(false)}
|
||||
onConfirm={(next) => {
|
||||
onChangeDraft({ plateNo: next });
|
||||
setPlateOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PhoneShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { pad2 } from '../utils/format';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
value: string;
|
||||
onClose: () => void;
|
||||
onConfirm: (next: string) => void;
|
||||
};
|
||||
|
||||
function parseValue(value: string): { date: string; hour: number; minute: number } {
|
||||
const raw = String(value || '').trim().replace('T', ' ');
|
||||
const date = raw.slice(0, 10);
|
||||
const hour = Number(raw.slice(11, 13));
|
||||
const minute = Number(raw.slice(14, 16));
|
||||
const now = new Date();
|
||||
return {
|
||||
date: /^\d{4}-\d{2}-\d{2}$/.test(date)
|
||||
? date
|
||||
: `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`,
|
||||
hour: Number.isFinite(hour) ? hour : now.getHours(),
|
||||
minute: Number.isFinite(minute) ? minute : now.getMinutes(),
|
||||
};
|
||||
}
|
||||
|
||||
export function DateTimePickerSheet({ open, value, onClose, onConfirm }: Props) {
|
||||
const initial = useMemo(() => parseValue(value), [value, open]);
|
||||
const [date, setDate] = useState(initial.date);
|
||||
const [hour, setHour] = useState(initial.hour);
|
||||
const [minute, setMinute] = useState(initial.minute);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const next = parseValue(value);
|
||||
setDate(next.date);
|
||||
setHour(next.hour);
|
||||
setMinute(next.minute);
|
||||
}, [open, value]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const preview = `${date} ${pad2(hour)}:${pad2(minute)}`;
|
||||
|
||||
return (
|
||||
<div className="h5-sheet-root" role="dialog" aria-modal="true" aria-label="选择加氢时间">
|
||||
<button type="button" className="h5-sheet-mask" aria-label="关闭" onClick={onClose} />
|
||||
<div className="h5-sheet">
|
||||
<div className="h5-sheet-bar">
|
||||
<button type="button" className="h5-sheet-bar-btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<strong>加氢时间</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="h5-sheet-bar-btn h5-sheet-bar-btn--ok"
|
||||
onClick={() => onConfirm(preview)}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
<div className="h5-sheet-preview tabular-nums">{preview}</div>
|
||||
<div className="h5-dt-grid">
|
||||
<label className="h5-dt-field">
|
||||
<span>日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="h5-dt-field">
|
||||
<span>时</span>
|
||||
<select
|
||||
value={hour}
|
||||
onChange={(e) => setHour(Number(e.target.value))}
|
||||
aria-label="小时"
|
||||
>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<option key={i} value={i}>
|
||||
{pad2(i)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="h5-dt-field">
|
||||
<span>分</span>
|
||||
<select
|
||||
value={minute}
|
||||
onChange={(e) => setMinute(Number(e.target.value))}
|
||||
aria-label="分钟"
|
||||
>
|
||||
{Array.from({ length: 60 }, (_, i) => (
|
||||
<option key={i} value={i}>
|
||||
{pad2(i)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
src/prototypes/oneos-h5-h2-order/components/Icons.tsx
Normal file
145
src/prototypes/oneos-h5-h2-order/components/Icons.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
|
||||
type IconProps = {
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Svg({
|
||||
size = 20,
|
||||
className,
|
||||
children,
|
||||
}: IconProps & { children: React.ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronLeft(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPlus(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCamera(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||
<circle cx="12" cy="13" r="4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFuel(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M3 22h12M5 22V10l7-4v16M19 22V8l-4-2v16" />
|
||||
<circle cx="8.5" cy="17.5" r="1.5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconList(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconDroplet(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconWallet(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M20 7H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
|
||||
<path d="M16 14h.01" />
|
||||
<path d="M22 10V7a2 2 0 0 0-2-2H8" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMapPin(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCheck(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconClock(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconInbox(props: IconProps) {
|
||||
return (
|
||||
<Svg size={props.size || 40} className={props.className}>
|
||||
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
|
||||
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSpinner({ size = 16, className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={`h5-spinner ${className || ''}`.trim()}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||
<path
|
||||
d="M21 12a9 9 0 0 0-9-9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { StationOption } from '../types';
|
||||
import { readFileAsDataUrl } from '../utils/ocr-mock';
|
||||
import {
|
||||
buildManualMonthCells,
|
||||
countMissingInMonth,
|
||||
getFirstHydrogenDateKey,
|
||||
getManualLedger,
|
||||
isArchivedManualImage,
|
||||
listManualLedgersByStation,
|
||||
todayDateKey,
|
||||
upsertManualLedger,
|
||||
type ManualLedgerImage,
|
||||
} from '../utils/manual-ledger';
|
||||
import { IconCamera, IconCheck } from './Icons';
|
||||
|
||||
type Props = {
|
||||
station: StationOption;
|
||||
tick: number;
|
||||
onUploaded: () => void;
|
||||
showToast: (msg: string) => void;
|
||||
};
|
||||
|
||||
const WEEK_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
export function ManualLedgerPanel({ station, tick, onUploaded, showToast }: Props) {
|
||||
const today = todayDateKey();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [draftImages, setDraftImages] = useState<ManualLedgerImage[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState(today);
|
||||
const [cursorYm, setCursorYm] = useState(() => {
|
||||
const [y, m] = today.split('-').map(Number);
|
||||
return { y, m };
|
||||
});
|
||||
|
||||
const stationKey = station.stationId || station.stationName;
|
||||
|
||||
const allEntries = useMemo(
|
||||
() => listManualLedgersByStation(stationKey),
|
||||
[stationKey, tick],
|
||||
);
|
||||
|
||||
const uploadedSet = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
allEntries.forEach((item) => {
|
||||
if (item.images?.length) set.add(item.ledgerDate);
|
||||
});
|
||||
return set;
|
||||
}, [allEntries]);
|
||||
|
||||
const firstHydrogenDate = useMemo(
|
||||
() => getFirstHydrogenDateKey(stationKey) || getFirstHydrogenDateKey(station.stationName),
|
||||
[stationKey, station.stationName, tick],
|
||||
);
|
||||
|
||||
const selectedEntry = useMemo(
|
||||
() => getManualLedger(stationKey, selectedDate),
|
||||
[stationKey, selectedDate, tick],
|
||||
);
|
||||
|
||||
const cells = useMemo(
|
||||
() => buildManualMonthCells(cursorYm.y, cursorYm.m, uploadedSet, today, firstHydrogenDate),
|
||||
[cursorYm.y, cursorYm.m, uploadedSet, today, firstHydrogenDate],
|
||||
);
|
||||
|
||||
const missingInMonth = useMemo(
|
||||
() => countMissingInMonth(cursorYm.y, cursorYm.m, uploadedSet, today, firstHydrogenDate),
|
||||
[cursorYm.y, cursorYm.m, uploadedSet, today, firstHydrogenDate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEntry?.images?.length) {
|
||||
setDraftImages(
|
||||
selectedEntry.images.map((img) => ({
|
||||
...img,
|
||||
archived: true,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
setDraftImages([]);
|
||||
}
|
||||
}, [selectedEntry, selectedDate, tick]);
|
||||
|
||||
const selectedUploaded = Boolean(selectedEntry?.images?.length);
|
||||
const isTodaySelected = selectedDate === today;
|
||||
const canEditSelected = selectedDate <= today;
|
||||
const pendingNewCount = draftImages.filter((img) => !isArchivedManualImage(img)).length;
|
||||
const canSubmit = canEditSelected && (selectedUploaded ? pendingNewCount > 0 : draftImages.length > 0);
|
||||
|
||||
async function handleFiles(files: FileList | null) {
|
||||
if (!files?.length || !canEditSelected) return;
|
||||
const next: ManualLedgerImage[] = [...draftImages];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file.type.startsWith('image/')) continue;
|
||||
next.push({
|
||||
id: `ml-img-${Date.now()}-${i}`,
|
||||
name: file.name || `台账照片${next.length + 1}.jpg`,
|
||||
dataUrl: await readFileAsDataUrl(file),
|
||||
archived: false,
|
||||
uploadedAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
});
|
||||
}
|
||||
setDraftImages(next);
|
||||
}
|
||||
|
||||
function removeImage(id: string) {
|
||||
setDraftImages((prev) =>
|
||||
prev.filter((img) => {
|
||||
if (img.id !== id) return true;
|
||||
return !isArchivedManualImage(img);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!canEditSelected) {
|
||||
showToast('不能上传未来日期');
|
||||
return;
|
||||
}
|
||||
if (selectedUploaded && pendingNewCount === 0) {
|
||||
showToast('请先添加补传照片');
|
||||
return;
|
||||
}
|
||||
if (!draftImages.length) {
|
||||
showToast('请至少上传一张手工台账照片');
|
||||
return;
|
||||
}
|
||||
const ok = window.confirm('确认上传后该记录将归档无法修改,是否确认无误');
|
||||
if (!ok) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = upsertManualLedger({
|
||||
stationId: station.stationId,
|
||||
stationName: station.stationName,
|
||||
ledgerDate: selectedDate,
|
||||
images: draftImages,
|
||||
});
|
||||
if (!saved) {
|
||||
showToast('上传失败,请重试');
|
||||
return;
|
||||
}
|
||||
showToast(
|
||||
selectedUploaded
|
||||
? `${selectedDate} 已补传`
|
||||
: isTodaySelected
|
||||
? '今日手工台账已上传'
|
||||
: `${selectedDate} 手工台账已上传`,
|
||||
);
|
||||
onUploaded();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function shiftMonth(delta: number) {
|
||||
setCursorYm((prev) => {
|
||||
let m = prev.m + delta;
|
||||
let y = prev.y;
|
||||
if (m < 1) {
|
||||
m = 12;
|
||||
y -= 1;
|
||||
} else if (m > 12) {
|
||||
m = 1;
|
||||
y += 1;
|
||||
}
|
||||
return { y, m };
|
||||
});
|
||||
}
|
||||
|
||||
function selectDay(dateKey: string, isFuture: boolean) {
|
||||
if (isFuture) return;
|
||||
setSelectedDate(dateKey);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h5-manual" data-annotation-id="h5-manual">
|
||||
<div
|
||||
className={`h5-manual-status${
|
||||
uploadedSet.has(today) ? ' h5-manual-status--ok' : ''
|
||||
}`}
|
||||
>
|
||||
{uploadedSet.has(today) ? <IconCheck size={16} /> : null}
|
||||
<div>
|
||||
<strong>{uploadedSet.has(today) ? '今日已上传' : '今日尚未上传'}</strong>
|
||||
<p>
|
||||
{today} · {station.stationName}
|
||||
{!uploadedSet.has(today) ? '。上传今日台账后才可新增加氢记录。' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="h5-panel" data-annotation-id="h5-manual-calendar">
|
||||
<div className="h5-cal-head">
|
||||
<button type="button" className="h5-cal-nav" onClick={() => shiftMonth(-1)} aria-label="上一月">
|
||||
‹
|
||||
</button>
|
||||
<strong className="tabular-nums">
|
||||
{cursorYm.y}年{cursorYm.m}月
|
||||
</strong>
|
||||
<button type="button" className="h5-cal-nav" onClick={() => shiftMonth(1)} aria-label="下一月">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
<p className="h5-cal-summary">
|
||||
{firstHydrogenDate
|
||||
? missingInMonth > 0
|
||||
? `本月尚有 ${missingInMonth} 天未上传(自 ${firstHydrogenDate} 起),点选日期可补传`
|
||||
: `本月自 ${firstHydrogenDate} 起截至今日均已上传`
|
||||
: '本站尚无加氢记录,历史日期无需补传'}
|
||||
</p>
|
||||
<div className="h5-cal-week">
|
||||
{WEEK_LABELS.map((w) => (
|
||||
<span key={w}>{w}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="h5-cal-grid" role="grid" aria-label="手工台账日历">
|
||||
{cells.map((cell) => {
|
||||
const cls = [
|
||||
'h5-cal-day',
|
||||
cell.inMonth ? '' : 'is-out',
|
||||
cell.isFuture ? 'is-future' : '',
|
||||
cell.isToday ? 'is-today' : '',
|
||||
!cell.needsLedger && cell.inMonth && !cell.isFuture ? 'is-before-start' : '',
|
||||
cell.needsLedger && cell.uploaded ? 'is-uploaded' : '',
|
||||
cell.needsLedger && !cell.uploaded ? 'is-missing' : '',
|
||||
selectedDate === cell.dateKey ? 'is-selected' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const statusLabel = cell.isFuture
|
||||
? '未来'
|
||||
: !cell.needsLedger
|
||||
? '无需补传'
|
||||
: cell.uploaded
|
||||
? '已上传'
|
||||
: '未上传';
|
||||
return (
|
||||
<button
|
||||
key={cell.dateKey + (cell.inMonth ? '' : '-o')}
|
||||
type="button"
|
||||
className={cls}
|
||||
disabled={cell.isFuture}
|
||||
onClick={() => selectDay(cell.dateKey, cell.isFuture)}
|
||||
aria-label={`${cell.dateKey}${statusLabel}`}
|
||||
>
|
||||
<span className="tabular-nums">{cell.day}</span>
|
||||
{cell.inMonth && cell.needsLedger ? (
|
||||
<i className={`h5-cal-dot${cell.uploaded ? ' is-on' : ''}`} aria-hidden />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="h5-cal-legend">
|
||||
<span>
|
||||
<i className="h5-cal-dot is-on" /> 已上传
|
||||
</span>
|
||||
<span>
|
||||
<i className="h5-cal-dot" /> 未上传
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="h5-panel">
|
||||
<div className="h5-panel-head">
|
||||
<div className="h5-panel-badge" aria-hidden>
|
||||
<IconCamera size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3>
|
||||
上传 {selectedDate}
|
||||
{isTodaySelected ? '(今日)' : ''} 手工台账
|
||||
</h3>
|
||||
<p>
|
||||
{selectedUploaded
|
||||
? '该日台账已归档,仅可补传新图,不可删除'
|
||||
: '请拍摄清晰的纸质台账照片,可多张'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h5-manual-grid">
|
||||
{draftImages.map((img) => {
|
||||
const archived = isArchivedManualImage(img);
|
||||
return (
|
||||
<div
|
||||
key={img.id}
|
||||
className={`h5-manual-thumb${archived ? ' is-archived' : ''}`}
|
||||
>
|
||||
{img.dataUrl ? (
|
||||
<img src={img.dataUrl} alt={img.name} />
|
||||
) : (
|
||||
<div className="h5-manual-thumb__ph">已归档</div>
|
||||
)}
|
||||
{archived ? (
|
||||
<span className="h5-manual-thumb__badge">已归档</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="h5-manual-thumb__del"
|
||||
onClick={() => removeImage(img.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className="h5-manual-add"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={!canEditSelected}
|
||||
aria-label="添加台账照片"
|
||||
>
|
||||
<IconCamera size={22} />
|
||||
<span>拍照/选图</span>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
void handleFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="h5-btn h5-btn--primary"
|
||||
style={{ width: '100%', marginTop: 14 }}
|
||||
disabled={saving || !canSubmit}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{selectedUploaded ? `确认补传 ${selectedDate}` : `确认上传 ${selectedDate}`}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/prototypes/oneos-h5-h2-order/components/OrderCard.tsx
Normal file
55
src/prototypes/oneos-h5-h2-order/components/OrderCard.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import type { H5OrderRecord } from '../types';
|
||||
import { formatDateTimeMinute, formatKg, formatMoney, formatPlateDisplay } from '../utils/format';
|
||||
import { IconCheck, IconClock } from './Icons';
|
||||
|
||||
type Props = {
|
||||
order: H5OrderRecord;
|
||||
onClick: () => void;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
export function OrderCard({ order, onClick, index = 0 }: Props) {
|
||||
const verified = order.verifyStatus === 'verified';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`h5-card${verified ? ' h5-card--done' : ' h5-card--pending'}`}
|
||||
data-annotation-id="h5-card"
|
||||
onClick={onClick}
|
||||
style={{ animationDelay: `${Math.min(index, 8) * 40}ms` }}
|
||||
aria-label={`${formatPlateDisplay(order.plateNo)},${verified ? '已核对' : '未核对'}`}
|
||||
>
|
||||
<div className="h5-card-top">
|
||||
<div className="h5-plate">{formatPlateDisplay(order.plateNo)}</div>
|
||||
<span className={`h5-tag ${verified ? 'h5-tag--done' : 'h5-tag--pending'}`}>
|
||||
{verified ? <IconCheck size={12} /> : <IconClock size={12} />}
|
||||
{verified ? '已核对' : '未核对'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h5-card-meta">
|
||||
<IconClock size={13} />
|
||||
<span className="tabular-nums">{formatDateTimeMinute(order.hydrogenTime)}</span>
|
||||
</div>
|
||||
<div className="h5-card-metrics" aria-label="加氢数据">
|
||||
<div>
|
||||
<span>加氢量</span>
|
||||
<strong>
|
||||
{formatKg(order.hydrogenKg)}
|
||||
<em> kg</em>
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>加氢总额</span>
|
||||
<strong className="h5-card-metrics__money">¥{formatMoney(order.totalAmount)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h5-card-foot">
|
||||
<span>单价 {formatMoney(order.unitPrice)} 元/kg</span>
|
||||
{verified ? (
|
||||
<span className="h5-card-foot__verify">核对 {formatDateTimeMinute(order.verifiedAt)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
183
src/prototypes/oneos-h5-h2-order/components/OrderDetail.tsx
Normal file
183
src/prototypes/oneos-h5-h2-order/components/OrderDetail.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { H5OrderRecord } from '../types';
|
||||
import { buildDemoWatermarkedPhoto } from '../utils/demo-photo';
|
||||
import { formatDateTimeMinute, formatKg, formatMileage, formatMoney, formatPlateDisplay } from '../utils/format';
|
||||
import { IconCheck, IconChevronLeft, IconClock } from './Icons';
|
||||
import { PhoneShell } from './PhoneShell';
|
||||
import { isOrderLocked } from '../utils/orders';
|
||||
|
||||
type Props = {
|
||||
order: H5OrderRecord;
|
||||
onBack: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
};
|
||||
|
||||
export function OrderDetail({ order, onBack, onEdit, onDelete }: Props) {
|
||||
const reconciled = order.reconcileStatus === 'reconciled';
|
||||
const verified = order.verifyStatus === 'verified';
|
||||
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 (
|
||||
<PhoneShell
|
||||
title="加氢记录详情"
|
||||
left={
|
||||
<button type="button" className="h5-nav-btn" onClick={onBack} aria-label="返回">
|
||||
<IconChevronLeft size={20} />
|
||||
</button>
|
||||
}
|
||||
bodyClassName={locked ? undefined : 'h5-body--wizard'}
|
||||
footer={
|
||||
locked ? null : (
|
||||
<div className="h5-action-bar">
|
||||
<button type="button" className="h5-btn h5-btn--danger" onClick={onDelete}>
|
||||
删除
|
||||
</button>
|
||||
<button type="button" className="h5-btn h5-btn--primary" onClick={onEdit}>
|
||||
编辑
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<section className="h5-panel h5-panel--detail" data-annotation-id="h5-detail">
|
||||
<div className="h5-detail-hero">
|
||||
<div>
|
||||
<div className="h5-plate">{formatPlateDisplay(order.plateNo)}</div>
|
||||
<div className="h5-detail-hero__time tabular-nums">
|
||||
{formatDateTimeMinute(order.hydrogenTime)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h5-tag-row">
|
||||
<span className={`h5-tag ${verified ? 'h5-tag--done' : 'h5-tag--pending'}`}>
|
||||
{verified ? <IconCheck size={12} /> : <IconClock size={12} />}
|
||||
{verified ? '已核对' : '未核对'}
|
||||
</span>
|
||||
<span className={`h5-tag ${reconciled ? 'h5-tag--done' : 'h5-tag--muted'}`}>
|
||||
{reconciled ? <IconCheck size={12} /> : <IconClock size={12} />}
|
||||
{reconciled ? '已对账' : '未对账'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h5-detail-metrics" aria-label="加氢金额与用量">
|
||||
<div>
|
||||
<span>加氢总额</span>
|
||||
<strong>¥{formatMoney(order.totalAmount)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>加氢量</span>
|
||||
<strong>
|
||||
{formatKg(order.hydrogenKg)}
|
||||
<em> kg</em>
|
||||
</strong>
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h5-detail-row h5-detail-row--station">
|
||||
<span>加氢站名称</span>
|
||||
<strong title={order.stationName}>{order.stationName}</strong>
|
||||
</div>
|
||||
<div className="h5-detail-row">
|
||||
<span>车牌号</span>
|
||||
<strong>{formatPlateDisplay(order.plateNo)}</strong>
|
||||
</div>
|
||||
<div className="h5-detail-row">
|
||||
<span>里程</span>
|
||||
<strong>{formatMileage(order.mileageKm)} km</strong>
|
||||
</div>
|
||||
<div className="h5-detail-row">
|
||||
<span>氢气单价</span>
|
||||
<strong>{formatMoney(order.unitPrice)} 元/kg</strong>
|
||||
</div>
|
||||
<div className="h5-detail-row">
|
||||
<span>核对状态</span>
|
||||
<strong>{verified ? '已核对' : '未核对'}</strong>
|
||||
</div>
|
||||
<div className="h5-detail-row">
|
||||
<span>核对时间</span>
|
||||
<strong>{verified ? formatDateTimeMinute(order.verifiedAt) : '—'}</strong>
|
||||
</div>
|
||||
<div className="h5-detail-row">
|
||||
<span>对账状态</span>
|
||||
<strong>{reconciled ? '已对账' : '未对账'}</strong>
|
||||
</div>
|
||||
<div className="h5-detail-row">
|
||||
<span>对账时间</span>
|
||||
<strong>{reconciled ? formatDateTimeMinute(order.reconcileTime) : '—'}</strong>
|
||||
</div>
|
||||
</section>
|
||||
{reconciled ? (
|
||||
<p className="h5-lock-note">
|
||||
<IconCheck size={16} />
|
||||
该记录已纳入站点对账单,不可修改
|
||||
</p>
|
||||
) : verified ? (
|
||||
<p className="h5-lock-note">
|
||||
<IconCheck size={16} />
|
||||
能源部已完成核对,不可修改
|
||||
</p>
|
||||
) : null}
|
||||
</PhoneShell>
|
||||
);
|
||||
}
|
||||
31
src/prototypes/oneos-h5-h2-order/components/PhoneShell.tsx
Normal file
31
src/prototypes/oneos-h5-h2-order/components/PhoneShell.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
left?: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
bodyClassName?: string;
|
||||
footer?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function PhoneShell({ title, left, right, children, bodyClassName, footer }: Props) {
|
||||
const now = new Date();
|
||||
const timeLabel = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
|
||||
return (
|
||||
<div className="h5-phone">
|
||||
<div className="h5-status-bar" aria-hidden>
|
||||
<span>{timeLabel}</span>
|
||||
<span>5G · 86%</span>
|
||||
</div>
|
||||
<header className="h5-nav">
|
||||
<div className="h5-nav-side">{left || null}</div>
|
||||
<h1 className="h5-nav-title">{title}</h1>
|
||||
<div className="h5-nav-side h5-nav-side--end">{right || null}</div>
|
||||
</header>
|
||||
<main className={`h5-body${bodyClassName ? ` ${bodyClassName}` : ''}`}>{children}</main>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
value: string;
|
||||
onClose: () => void;
|
||||
onConfirm: (next: string) => void;
|
||||
};
|
||||
|
||||
const PROVINCES = [
|
||||
'京', '津', '沪', '渝', '冀', '豫', '云', '辽', '黑', '湘',
|
||||
'皖', '鲁', '新', '苏', '浙', '赣', '鄂', '桂', '甘', '晋',
|
||||
'蒙', '陕', '吉', '闽', '贵', '粤', '青', '藏', '川', '宁', '琼',
|
||||
];
|
||||
|
||||
const LETTERS = 'ABCDEFGHJKLMNPQRSTUVWXYZ'.split('');
|
||||
const DIGITS = '0123456789'.split('');
|
||||
const SPECIAL = ['学', '挂', '警', '港', '澳'];
|
||||
|
||||
const MAX_LEN = 8;
|
||||
|
||||
export function PlateKeyboardSheet({ open, value, onClose, onConfirm }: Props) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setDraft(String(value || '').toUpperCase().replace(/F$/u, ''));
|
||||
}, [open, value]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const needProvince = draft.length === 0;
|
||||
|
||||
function append(ch: string) {
|
||||
setDraft((prev) => {
|
||||
if (prev.length >= MAX_LEN) return prev;
|
||||
return `${prev}${ch}`.slice(0, MAX_LEN);
|
||||
});
|
||||
}
|
||||
|
||||
function backspace() {
|
||||
setDraft((prev) => prev.slice(0, -1));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h5-sheet-root" role="dialog" aria-modal="true" aria-label="输入车牌号">
|
||||
<button type="button" className="h5-sheet-mask" aria-label="关闭" onClick={onClose} />
|
||||
<div className="h5-sheet h5-sheet--plate">
|
||||
<div className="h5-sheet-bar">
|
||||
<button type="button" className="h5-sheet-bar-btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<strong>车牌号</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="h5-sheet-bar-btn h5-sheet-bar-btn--ok"
|
||||
disabled={draft.length < 7}
|
||||
onClick={() => onConfirm(draft)}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
<div className="h5-plate-draft" aria-live="polite">
|
||||
{draft || <span className="h5-plate-draft__ph">请输入车牌号</span>}
|
||||
</div>
|
||||
<div className="h5-plate-keys">
|
||||
{needProvince
|
||||
? PROVINCES.map((p) => (
|
||||
<button key={p} type="button" className="h5-plate-key" onClick={() => append(p)}>
|
||||
{p}
|
||||
</button>
|
||||
))
|
||||
: (
|
||||
<>
|
||||
{LETTERS.map((ch) => (
|
||||
<button key={ch} type="button" className="h5-plate-key" onClick={() => append(ch)}>
|
||||
{ch}
|
||||
</button>
|
||||
))}
|
||||
{DIGITS.map((ch) => (
|
||||
<button key={ch} type="button" className="h5-plate-key" onClick={() => append(ch)}>
|
||||
{ch}
|
||||
</button>
|
||||
))}
|
||||
{SPECIAL.map((ch) => (
|
||||
<button key={ch} type="button" className="h5-plate-key h5-plate-key--soft" onClick={() => append(ch)}>
|
||||
{ch}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="h5-plate-key h5-plate-key--wide" onClick={backspace}>
|
||||
删除
|
||||
</button>
|
||||
{!needProvince ? (
|
||||
<button
|
||||
type="button"
|
||||
className="h5-plate-key h5-plate-key--wide"
|
||||
onClick={() => setDraft('')}
|
||||
>
|
||||
重选省份
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
401
src/prototypes/oneos-h5-h2-order/index.tsx
Normal file
401
src/prototypes/oneos-h5-h2-order/index.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* @name 加氢订单
|
||||
* 加氢站手机浏览器 H5:本站加氢记录列表、OCR 新增、与站点对账单联动
|
||||
*/
|
||||
import '../../common/h2VehicleLedgerBridge.js';
|
||||
import '../../common/h2DispenserBrandStore.js';
|
||||
import './styles/index.css';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
import { CreateWizard } from './components/CreateWizard';
|
||||
import {
|
||||
IconCamera,
|
||||
IconChevronLeft,
|
||||
IconDroplet,
|
||||
IconInbox,
|
||||
IconList,
|
||||
IconMapPin,
|
||||
IconPlus,
|
||||
IconWallet,
|
||||
} from './components/Icons';
|
||||
import { ManualLedgerPanel } from './components/ManualLedgerPanel';
|
||||
import { OrderCard } from './components/OrderCard';
|
||||
import { OrderDetail } from './components/OrderDetail';
|
||||
import { PhoneShell } from './components/PhoneShell';
|
||||
import type { AppView, CreateDraft, StationOption } from './types';
|
||||
import { formatDateTimeMinute, formatKg, formatMoney, nowDateTimeMinute } from './utils/format';
|
||||
import { hasManualLedgerForDate } from './utils/manual-ledger';
|
||||
import {
|
||||
findDuplicateStationOrder,
|
||||
getTotalAmountMismatch,
|
||||
isOrderLocked,
|
||||
listOrdersForStation,
|
||||
listStations,
|
||||
removeStationOrder,
|
||||
subscribeOrders,
|
||||
summarizeOrders,
|
||||
upsertStationOrder,
|
||||
} from './utils/orders';
|
||||
|
||||
function emptyDraft(partial?: Partial<CreateDraft>): CreateDraft {
|
||||
return {
|
||||
hydrogenTime: nowDateTimeMinute(),
|
||||
plateNo: '',
|
||||
mileageKm: '',
|
||||
unitPrice: '',
|
||||
hydrogenKg: '',
|
||||
totalAmount: '',
|
||||
platePreviewUrl: '',
|
||||
panelPreviewUrl: '',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
export default function OneosH5H2Order() {
|
||||
useEffect(() => {
|
||||
clearHostPrototypeRouteInfo();
|
||||
}, []);
|
||||
|
||||
const stations = useMemo(() => listStations(), []);
|
||||
/** 已登录本站(原型固定演示站,不提供切换) */
|
||||
const station: StationOption = useMemo(
|
||||
() => stations[0] || { stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站' },
|
||||
[stations],
|
||||
);
|
||||
const [tick, setTick] = useState(0);
|
||||
const [view, setView] = useState<AppView>({ name: 'list' });
|
||||
const [mainTab, setMainTab] = useState<'orders' | 'manual'>('orders');
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeOrders(() => setTick((n) => n + 1));
|
||||
}, []);
|
||||
|
||||
const orders = useMemo(
|
||||
() => listOrdersForStation(station.stationName),
|
||||
[station.stationName, tick],
|
||||
);
|
||||
const summary = useMemo(() => summarizeOrders(orders), [orders]);
|
||||
|
||||
function showToast(msg: string) {
|
||||
setToast(msg);
|
||||
window.setTimeout(() => setToast(''), 2400);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (!hasManualLedgerForDate(station.stationId || station.stationName)) {
|
||||
showToast('请先上传今日手工台账');
|
||||
setMainTab('manual');
|
||||
setView({ name: 'list' });
|
||||
return;
|
||||
}
|
||||
const draft = emptyDraft();
|
||||
setView({
|
||||
name: 'create',
|
||||
mode: 'create',
|
||||
draft,
|
||||
initialDraft: { ...draft },
|
||||
});
|
||||
}
|
||||
|
||||
function openEdit(id: string) {
|
||||
const order = orders.find((item) => item.id === id);
|
||||
if (!order || isOrderLocked(order)) return;
|
||||
const draft = emptyDraft({
|
||||
id: order.id,
|
||||
hydrogenTime:
|
||||
formatDateTimeMinute(order.hydrogenTime) === '—'
|
||||
? nowDateTimeMinute()
|
||||
: order.hydrogenTime.slice(0, 16),
|
||||
plateNo: order.plateNo.replace(/F$/u, ''),
|
||||
mileageKm: order.mileageKm != null ? String(order.mileageKm) : '',
|
||||
unitPrice: String(order.unitPrice),
|
||||
hydrogenKg: String(order.hydrogenKg),
|
||||
totalAmount: String(order.totalAmount),
|
||||
platePreviewUrl: order.platePhotoUrl || '',
|
||||
panelPreviewUrl: order.panelPhotoUrl || '',
|
||||
});
|
||||
setView({
|
||||
name: 'create',
|
||||
mode: 'edit',
|
||||
draft,
|
||||
initialDraft: { ...draft },
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (view.name !== 'create') return;
|
||||
const { draft, mode } = view;
|
||||
const unitPrice = Number(draft.unitPrice);
|
||||
const hydrogenKg = Number(draft.hydrogenKg);
|
||||
const totalAmount = Number(draft.totalAmount);
|
||||
if (!draft.plateNo.trim() || !Number.isFinite(unitPrice) || !Number.isFinite(hydrogenKg) || !Number.isFinite(totalAmount)) {
|
||||
showToast('请完善加氢信息');
|
||||
return;
|
||||
}
|
||||
let mileageKm: number | null = null;
|
||||
const mileageRaw = draft.mileageKm.trim();
|
||||
if (mileageRaw) {
|
||||
const n = Number(mileageRaw);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
showToast('里程须为不小于 0 的数字');
|
||||
return;
|
||||
}
|
||||
mileageKm = Math.round(n);
|
||||
}
|
||||
if (
|
||||
findDuplicateStationOrder({
|
||||
id: draft.id,
|
||||
stationName: station.stationName,
|
||||
plateNo: draft.plateNo.trim(),
|
||||
hydrogenTime: draft.hydrogenTime,
|
||||
})
|
||||
) {
|
||||
showToast('已存在相同加氢站、车牌号与加氢时间的记录');
|
||||
return;
|
||||
}
|
||||
const mismatch = getTotalAmountMismatch(unitPrice, hydrogenKg, totalAmount);
|
||||
if (mismatch) {
|
||||
const ok = window.confirm(
|
||||
`加氢总额与单价×量不一致:应为 ${mismatch.expected.toFixed(2)} 元,当前 ${mismatch.actual.toFixed(2)} 元。仍要保存吗?`,
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
const saved = upsertStationOrder({
|
||||
id: draft.id,
|
||||
stationId: station.stationId,
|
||||
stationName: station.stationName,
|
||||
hydrogenTime: draft.hydrogenTime,
|
||||
plateNo: draft.plateNo.trim(),
|
||||
mileageKm,
|
||||
unitPrice,
|
||||
hydrogenKg,
|
||||
totalAmount,
|
||||
platePhotoUrl: draft.platePreviewUrl || '',
|
||||
panelPhotoUrl: draft.panelPreviewUrl || '',
|
||||
});
|
||||
if (!saved) {
|
||||
showToast('保存失败,请刷新后重试');
|
||||
return;
|
||||
}
|
||||
setTick((n) => n + 1);
|
||||
setView({ name: 'list' });
|
||||
showToast(mode === 'edit' ? '已更新加氢记录' : '已新增加氢记录');
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
const order = orders.find((item) => item.id === id);
|
||||
if (!order || isOrderLocked(order)) return;
|
||||
if (!window.confirm(`确认删除车牌 ${order.plateNo.replace(/F$/u, '')} 的未对账记录?`)) return;
|
||||
removeStationOrder(id);
|
||||
setTick((n) => n + 1);
|
||||
setView({ name: 'list' });
|
||||
showToast('已删除');
|
||||
}
|
||||
|
||||
const annotationPageId =
|
||||
view.name === 'create' ? 'create' : view.name === 'detail' ? 'detail' : 'list';
|
||||
|
||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||
() => ({
|
||||
showToolbar: true,
|
||||
showThemeToggle: true,
|
||||
showColorFilter: true,
|
||||
emptyWhenNoData: false,
|
||||
toolbarEdge: 'right',
|
||||
currentPageId: annotationPageId,
|
||||
}),
|
||||
[annotationPageId],
|
||||
);
|
||||
|
||||
let content: React.ReactNode = null;
|
||||
|
||||
if (view.name === 'create') {
|
||||
content = (
|
||||
<CreateWizard
|
||||
mode={view.mode}
|
||||
draft={view.draft}
|
||||
initialDraft={view.initialDraft}
|
||||
stationName={station.stationName}
|
||||
onChangeDraft={(patch) =>
|
||||
setView((prev) =>
|
||||
prev.name === 'create' ? { ...prev, draft: { ...prev.draft, ...patch } } : prev,
|
||||
)
|
||||
}
|
||||
onBack={() => setView({ name: 'list' })}
|
||||
onSubmit={handleSubmit}
|
||||
toast={toast}
|
||||
/>
|
||||
);
|
||||
} else if (view.name === 'detail') {
|
||||
const order = orders.find((item) => item.id === view.id);
|
||||
content = order ? (
|
||||
<OrderDetail
|
||||
order={order}
|
||||
onBack={() => setView({ name: 'list' })}
|
||||
onEdit={() => openEdit(order.id)}
|
||||
onDelete={() => handleDelete(order.id)}
|
||||
/>
|
||||
) : (
|
||||
<PhoneShell
|
||||
title="加氢记录详情"
|
||||
left={
|
||||
<button
|
||||
type="button"
|
||||
className="h5-nav-btn"
|
||||
onClick={() => setView({ name: 'list' })}
|
||||
aria-label="返回"
|
||||
>
|
||||
<IconChevronLeft size={20} />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="h5-empty">
|
||||
<div className="h5-empty-icon" aria-hidden>
|
||||
<IconInbox size={36} />
|
||||
</div>
|
||||
<strong>记录不存在</strong>
|
||||
请返回列表查看
|
||||
</div>
|
||||
</PhoneShell>
|
||||
);
|
||||
} else {
|
||||
const todayUploaded = hasManualLedgerForDate(station.stationId || station.stationName);
|
||||
content = (
|
||||
<PhoneShell
|
||||
title={mainTab === 'manual' ? '手工台账' : '加氢订单'}
|
||||
bodyClassName="h5-body--list"
|
||||
footer={
|
||||
<>
|
||||
{mainTab === 'orders' ? (
|
||||
<div className="h5-fab-dock" data-annotation-id="h5-fab-dock">
|
||||
{!todayUploaded ? (
|
||||
<p className="h5-fab-dock__hint">今日未上传手工台账,无法新增电子记录</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="h5-fab"
|
||||
data-annotation-id="h5-fab"
|
||||
onClick={openCreate}
|
||||
aria-label="新增加氢记录"
|
||||
>
|
||||
<IconPlus size={22} />
|
||||
<span className="h5-fab-label">新增</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<nav className="h5-tabbar" aria-label="主导航">
|
||||
<button
|
||||
type="button"
|
||||
className={`h5-tabbar-item${mainTab === 'orders' ? ' is-active' : ''}`}
|
||||
onClick={() => setMainTab('orders')}
|
||||
>
|
||||
<IconList size={18} />
|
||||
加氢订单
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`h5-tabbar-item${mainTab === 'manual' ? ' is-active' : ''}`}
|
||||
onClick={() => setMainTab('manual')}
|
||||
>
|
||||
<IconCamera size={18} />
|
||||
手工台账
|
||||
{!todayUploaded ? <span className="h5-tabbar-dot" aria-label="今日未上传" /> : null}
|
||||
</button>
|
||||
</nav>
|
||||
{toast ? (
|
||||
<div className="h5-toast h5-toast--above-dock" role="status" aria-live="polite">
|
||||
{toast}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{mainTab === 'manual' ? (
|
||||
<ManualLedgerPanel
|
||||
station={station}
|
||||
tick={tick}
|
||||
showToast={showToast}
|
||||
onUploaded={() => setTick((n) => n + 1)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="h5-hero" data-annotation-id="h5-hero" aria-label="当前站点">
|
||||
<div className="h5-hero-label">
|
||||
<IconMapPin size={13} />
|
||||
当前站点
|
||||
</div>
|
||||
<div className="h5-hero-station">{station.stationName}</div>
|
||||
</div>
|
||||
|
||||
<div className="h5-summary" data-annotation-id="h5-summary" aria-label="本站汇总">
|
||||
<div className="h5-summary-chip">
|
||||
<div className="h5-summary-chip-icon" aria-hidden>
|
||||
<IconList size={15} />
|
||||
</div>
|
||||
<strong>{summary.count}</strong>
|
||||
<span>订单数</span>
|
||||
</div>
|
||||
<div className="h5-summary-chip">
|
||||
<div className="h5-summary-chip-icon" aria-hidden>
|
||||
<IconDroplet size={15} />
|
||||
</div>
|
||||
<strong>{formatKg(summary.totalKg)}</strong>
|
||||
<span>加氢量 kg</span>
|
||||
</div>
|
||||
<div className="h5-summary-chip">
|
||||
<div className="h5-summary-chip-icon" aria-hidden>
|
||||
<IconWallet size={15} />
|
||||
</div>
|
||||
<strong>{formatMoney(summary.totalAmount)}</strong>
|
||||
<span>加氢金额</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<div className="h5-empty">
|
||||
<div className="h5-empty-icon" aria-hidden>
|
||||
<IconInbox size={36} />
|
||||
</div>
|
||||
<strong>本站暂无加氢记录</strong>
|
||||
点击下方「新增」开始上报第一笔订单
|
||||
</div>
|
||||
) : (
|
||||
<section className="h5-list-section" aria-label="加氢记录列表">
|
||||
<div className="h5-list-head">
|
||||
<h2 className="h5-list-head__title">加氢记录</h2>
|
||||
<span className="h5-list-head__count tabular-nums">共 {orders.length} 条</span>
|
||||
</div>
|
||||
{orders.map((order, index) => (
|
||||
<OrderCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
index={index}
|
||||
onClick={() => setView({ name: 'detail', id: order.id })}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PhoneShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h5-h2-order-page" data-ax="h5-h2-order-root">
|
||||
{content}
|
||||
<PrototypeAnnotationHost
|
||||
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||
options={annotationOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* 从 .spec/*.md 生成 annotation-source.json
|
||||
* 用法:node src/prototypes/oneos-h5-h2-order/scripts/build-annotation-source.mjs
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const specDir = path.join(root, '.spec');
|
||||
const outFile = path.join(root, 'annotation-source.json');
|
||||
const now = Date.now();
|
||||
|
||||
function readMd(name) {
|
||||
return fs.readFileSync(path.join(specDir, name), 'utf8');
|
||||
}
|
||||
|
||||
function node(partial) {
|
||||
return {
|
||||
images: [],
|
||||
controls: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
hasMarkdown: true,
|
||||
annotationText: '',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
const mdPrd = readMd('requirements-prd.md');
|
||||
const mdList = readMd('feature-list.md');
|
||||
const mdCreate = readMd('feature-create.md');
|
||||
const mdDetail = readMd('feature-detail.md');
|
||||
const mdManual = readMd('feature-manual-ledger.md');
|
||||
const mdFleet = readMd('fleet-plate-tag.md');
|
||||
const mdReconcile = readMd('reconcile-linkage.md');
|
||||
|
||||
const source = {
|
||||
documentVersion: 1,
|
||||
format: 'axhub-annotation-source',
|
||||
data: {
|
||||
version: 2,
|
||||
prototypeName: 'oneos-h5-h2-order',
|
||||
pageId: 'list',
|
||||
updatedAt: now,
|
||||
nodes: [
|
||||
node({
|
||||
id: 'h5-hero',
|
||||
index: 1,
|
||||
title: '当前站点',
|
||||
pageId: 'list',
|
||||
color: '#7AB929',
|
||||
locator: {
|
||||
selectors: ["[data-annotation-id='h5-hero']", '.h5-hero'],
|
||||
fingerprint: 'hero|list',
|
||||
path: [],
|
||||
},
|
||||
}),
|
||||
node({
|
||||
id: 'h5-summary',
|
||||
index: 2,
|
||||
title: '本站 KPI',
|
||||
pageId: 'list',
|
||||
color: '#0E8A7B',
|
||||
locator: {
|
||||
selectors: ["[data-annotation-id='h5-summary']", '.h5-summary'],
|
||||
fingerprint: 'summary|list',
|
||||
path: [],
|
||||
},
|
||||
}),
|
||||
node({
|
||||
id: 'h5-card',
|
||||
index: 3,
|
||||
title: '订单卡片',
|
||||
pageId: 'list',
|
||||
color: '#FF7D00',
|
||||
locator: {
|
||||
selectors: ["[data-annotation-id='h5-card']", '.h5-card'],
|
||||
fingerprint: 'card|list',
|
||||
path: [],
|
||||
},
|
||||
}),
|
||||
node({
|
||||
id: 'h5-fab',
|
||||
index: 4,
|
||||
title: '底栏新增',
|
||||
pageId: 'list',
|
||||
color: '#7AB929',
|
||||
locator: {
|
||||
selectors: ["[data-annotation-id='h5-fab']", '.h5-fab'],
|
||||
fingerprint: 'fab|list',
|
||||
path: [],
|
||||
},
|
||||
}),
|
||||
node({
|
||||
id: 'h5-time',
|
||||
index: 5,
|
||||
title: '加氢时间',
|
||||
pageId: 'create',
|
||||
color: '#7AB929',
|
||||
locator: {
|
||||
selectors: ["[data-annotation-id='h5-time']", '#h5-time'],
|
||||
fingerprint: 'field|h5-time',
|
||||
path: [],
|
||||
},
|
||||
}),
|
||||
node({
|
||||
id: 'h5-station',
|
||||
index: 6,
|
||||
title: '加氢站名称',
|
||||
pageId: 'create',
|
||||
color: '#86909C',
|
||||
locator: {
|
||||
selectors: ["[data-annotation-id='h5-station']", '#h5-station'],
|
||||
fingerprint: 'field|h5-station',
|
||||
path: [],
|
||||
},
|
||||
}),
|
||||
node({
|
||||
id: 'h5-plate',
|
||||
index: 7,
|
||||
title: '车牌号',
|
||||
pageId: 'create',
|
||||
color: '#FF7D00',
|
||||
locator: {
|
||||
selectors: ["[data-annotation-id='h5-plate']", '#h5-plate'],
|
||||
fingerprint: 'field|h5-plate',
|
||||
path: [],
|
||||
},
|
||||
}),
|
||||
node({
|
||||
id: 'h5-mileage',
|
||||
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: '氢气单价',
|
||||
pageId: 'create',
|
||||
color: '#7AB929',
|
||||
locator: {
|
||||
selectors: ["[data-annotation-id='h5-price']", '#h5-price'],
|
||||
fingerprint: 'field|h5-price',
|
||||
path: [],
|
||||
},
|
||||
}),
|
||||
node({
|
||||
id: 'h5-kg',
|
||||
index: 11,
|
||||
title: '加氢量',
|
||||
pageId: 'create',
|
||||
color: '#7AB929',
|
||||
locator: {
|
||||
selectors: ["[data-annotation-id='h5-kg']", '#h5-kg'],
|
||||
fingerprint: 'field|h5-kg',
|
||||
path: [],
|
||||
},
|
||||
}),
|
||||
node({
|
||||
id: 'h5-total-label',
|
||||
index: 12,
|
||||
title: '加氢总额',
|
||||
pageId: 'create',
|
||||
color: '#0E8A7B',
|
||||
locator: {
|
||||
selectors: ["[data-annotation-id='h5-total-label']", '#h5-total'],
|
||||
fingerprint: 'field|h5-total',
|
||||
path: [],
|
||||
},
|
||||
}),
|
||||
node({
|
||||
id: 'h5-detail',
|
||||
index: 13,
|
||||
title: '记录详情',
|
||||
pageId: 'detail',
|
||||
color: '#FF7D00',
|
||||
locator: {
|
||||
selectors: ["[data-annotation-id='h5-detail']", '.h5-panel--detail'],
|
||||
fingerprint: 'detail|panel',
|
||||
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({
|
||||
id: 'h5-manual',
|
||||
index: 15,
|
||||
title: '手工台账',
|
||||
pageId: 'manual',
|
||||
color: '#7AB929',
|
||||
locator: {
|
||||
selectors: ["[data-annotation-id='h5-manual']", '.h5-manual'],
|
||||
fingerprint: 'manual|panel',
|
||||
path: [],
|
||||
},
|
||||
}),
|
||||
node({
|
||||
id: 'h5-manual-calendar',
|
||||
index: 16,
|
||||
title: '台账日历',
|
||||
pageId: 'manual',
|
||||
color: '#0E8A7B',
|
||||
locator: {
|
||||
selectors: ["[data-annotation-id='h5-manual-calendar']", '.h5-cal-grid'],
|
||||
fingerprint: 'manual|calendar',
|
||||
path: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
markdownMap: {
|
||||
'h5-hero':
|
||||
'## 当前站点\n\n只读展示登录账号对应加氢站,列表页不提供切站。\n\n详见 [本站列表](feature-list)。',
|
||||
'h5-summary':
|
||||
'## 本站 KPI\n\n订单数 / 加氢量(kg) / 加氢金额,随本站列表实时汇总。\n\n详见功能说明「本站列表与 KPI」。',
|
||||
'h5-card': mdList,
|
||||
'h5-fab':
|
||||
'## 底栏新增\n\n固定在列表底部,不遮挡卡片滚动区。点击进入新增表单,加氢时间默认=点击时刻。\n\n详见功能说明「新增加氢记录」。',
|
||||
'h5-time':
|
||||
'## 加氢时间(必填)\n\n默认=点击「新增」时的当前时间;点击后底部弹层选择日期 + 时 + 分。',
|
||||
'h5-station':
|
||||
'## 加氢站名称\n\n只读。默认显示当前登录账号对应加氢站名称,不可切换。',
|
||||
'h5-plate': mdFleet,
|
||||
'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-kg':
|
||||
'## 加氢量(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额。',
|
||||
'h5-total-label':
|
||||
'## 加氢总额(元)\n\n只读展示;由单价×加氢量自动计算(两位小数)。界面标签不展示公式文案。',
|
||||
'h5-detail': mdDetail,
|
||||
'h5-detail-photos':
|
||||
'## 识别原图(含水印)\n\n详情展示车牌识别、加氢机面板的原始照片;照片在拍摄/选图时已叠加时间与地点水印并随记录保存。种子数据无实拍时用演示水印图。\n\n详见 [详情编辑删除](feature-detail)。',
|
||||
'h5-manual': mdManual,
|
||||
'h5-manual-calendar':
|
||||
'## 台账日历\n\n自本站首笔加氢记录日起:绿=已上传,橙=未上传;首笔之前无需补传。禁选未来日。点选范围内未传日可补传。仅「今日」是否上传影响新增加氢记录。\n\n详见 [手工台账上传](feature-manual-ledger)。',
|
||||
},
|
||||
assetMap: {},
|
||||
directory: {
|
||||
nodes: [
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'h5-doc-root',
|
||||
title: '加氢订单(H5)说明',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'h5-doc-prd',
|
||||
title: 'PRD 全文',
|
||||
markdown: mdPrd,
|
||||
markdownPath: '.spec/requirements-prd.md',
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'h5-doc-features',
|
||||
title: '功能说明',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'h5-doc-list',
|
||||
title: '本站列表与 KPI',
|
||||
markdown: mdList,
|
||||
markdownPath: '.spec/feature-list.md',
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'h5-doc-create',
|
||||
title: '新增加氢记录',
|
||||
markdown: mdCreate,
|
||||
markdownPath: '.spec/feature-create.md',
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'h5-doc-manual',
|
||||
title: '手工台账上传',
|
||||
markdown: mdManual,
|
||||
markdownPath: '.spec/feature-manual-ledger.md',
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'h5-doc-detail',
|
||||
title: '详情编辑删除',
|
||||
markdown: mdDetail,
|
||||
markdownPath: '.spec/feature-detail.md',
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'h5-doc-fleet',
|
||||
title: '羚牛车辆标签',
|
||||
markdown: mdFleet,
|
||||
markdownPath: '.spec/fleet-plate-tag.md',
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'h5-doc-reconcile',
|
||||
title: '核对与对账联动',
|
||||
markdown: mdReconcile,
|
||||
markdownPath: '.spec/reconcile-linkage.md',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'h5-doc-pages',
|
||||
title: '按页面',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'h5-page-list',
|
||||
title: '列表页',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{
|
||||
type: 'route',
|
||||
id: 'h5-route-list',
|
||||
title: 'list',
|
||||
route: 'list',
|
||||
payload: { pageId: 'list' },
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'h5-page-list-md',
|
||||
title: '列表页要点',
|
||||
markdown: mdList,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'h5-page-create',
|
||||
title: '新增页',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{
|
||||
type: 'route',
|
||||
id: 'h5-route-create',
|
||||
title: 'create',
|
||||
route: 'create',
|
||||
payload: { pageId: 'create' },
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'h5-page-create-md',
|
||||
title: '新增页要点',
|
||||
markdown: mdCreate,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'h5-page-detail',
|
||||
title: '详情页',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{
|
||||
type: 'route',
|
||||
id: 'h5-route-detail',
|
||||
title: 'detail',
|
||||
route: 'detail',
|
||||
payload: { pageId: 'detail' },
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'h5-page-detail-md',
|
||||
title: '详情页要点',
|
||||
markdown: mdDetail,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(outFile, `${JSON.stringify(source, null, 2)}\n`, 'utf8');
|
||||
console.log(`Wrote ${path.relative(process.cwd(), outFile)} (${source.data.nodes.length} markers)`);
|
||||
1721
src/prototypes/oneos-h5-h2-order/styles/index.css
Normal file
1721
src/prototypes/oneos-h5-h2-order/styles/index.css
Normal file
File diff suppressed because it is too large
Load Diff
50
src/prototypes/oneos-h5-h2-order/types.ts
Normal file
50
src/prototypes/oneos-h5-h2-order/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export type ReconcileStatus = 'pending' | 'reconciled';
|
||||
export type VerifyStatus = 'unverified' | 'verified';
|
||||
|
||||
export type StationOption = {
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
};
|
||||
|
||||
export type H5OrderRecord = {
|
||||
id: string;
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
hydrogenTime: string;
|
||||
plateNo: string;
|
||||
/** 里程 km;未填为 null */
|
||||
mileageKm: number | null;
|
||||
unitPrice: number;
|
||||
hydrogenKg: number;
|
||||
totalAmount: number;
|
||||
verifyStatus: VerifyStatus;
|
||||
verifiedAt: string;
|
||||
reconcileStatus: ReconcileStatus;
|
||||
reconcileTime: string;
|
||||
/** 车牌识别原图(含水印),无则为空 */
|
||||
platePhotoUrl: string;
|
||||
/** 加氢机面板原图(含水印),无则为空 */
|
||||
panelPhotoUrl: string;
|
||||
};
|
||||
|
||||
export type CreateDraft = {
|
||||
id?: string;
|
||||
hydrogenTime: string;
|
||||
plateNo: string;
|
||||
mileageKm: string;
|
||||
unitPrice: string;
|
||||
hydrogenKg: string;
|
||||
totalAmount: string;
|
||||
platePreviewUrl: string;
|
||||
panelPreviewUrl: string;
|
||||
};
|
||||
|
||||
export type AppView =
|
||||
| { name: 'list' }
|
||||
| {
|
||||
name: 'create';
|
||||
draft: CreateDraft;
|
||||
initialDraft: CreateDraft;
|
||||
mode: 'create' | 'edit';
|
||||
}
|
||||
| { name: 'detail'; id: string };
|
||||
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 || '加氢站',
|
||||
});
|
||||
}
|
||||
47
src/prototypes/oneos-h5-h2-order/utils/fleet-plates.ts
Normal file
47
src/prototypes/oneos-h5-h2-order/utils/fleet-plates.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/** 原型本地「系统车辆列表」车牌(不含尾缀 F),用于羚牛/非羚牛判定 */
|
||||
const FLEET_PLATES = new Set(
|
||||
[
|
||||
'浙A55666',
|
||||
'浙A77888',
|
||||
'浙A99001',
|
||||
'浙A12345',
|
||||
'浙A67890',
|
||||
'浙A88888',
|
||||
'浙A03561',
|
||||
'浙B23456',
|
||||
'浙B99999',
|
||||
'浙B58888',
|
||||
'沪A88888',
|
||||
'沪BDB9161',
|
||||
'沪ADB9161',
|
||||
'苏E33333',
|
||||
'浙A88H201',
|
||||
'浙F00688',
|
||||
'浙F07588',
|
||||
'浙F06618',
|
||||
'粤AGR8556',
|
||||
'粤AGP5156',
|
||||
].map((p) => p.toUpperCase()),
|
||||
);
|
||||
|
||||
export function normalizePlateKey(plateNo: string): string {
|
||||
return String(plateNo || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/F$/u, '');
|
||||
}
|
||||
|
||||
/** 车牌是否在系统车辆列表中 → 羚牛车辆 */
|
||||
export function isLingniuVehicle(plateNo: string): boolean {
|
||||
const key = normalizePlateKey(plateNo);
|
||||
if (!key) return false;
|
||||
return FLEET_PLATES.has(key);
|
||||
}
|
||||
|
||||
export type VehicleTagKind = 'lingniu' | 'external' | null;
|
||||
|
||||
export function getVehicleTag(plateNo: string): VehicleTagKind {
|
||||
const key = normalizePlateKey(plateNo);
|
||||
if (!key) return null;
|
||||
return isLingniuVehicle(key) ? 'lingniu' : 'external';
|
||||
}
|
||||
40
src/prototypes/oneos-h5-h2-order/utils/format.ts
Normal file
40
src/prototypes/oneos-h5-h2-order/utils/format.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** 展示用:YYYY-MM-DD HH:mm */
|
||||
export function formatDateTimeMinute(value: string | null | undefined): string {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '—';
|
||||
const normalized = raw.replace('T', ' ');
|
||||
if (normalized.length >= 16) return normalized.slice(0, 16);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function nowDateTimeMinute(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function formatPlateDisplay(plateNo: string): string {
|
||||
const p = String(plateNo || '').replace(/F$/u, '');
|
||||
return p || '—';
|
||||
}
|
||||
|
||||
export function formatMoney(n: number): string {
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export function formatKg(n: number): string {
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 1, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/** 里程展示;未填显示 — */
|
||||
export function formatMileage(n: number | null | undefined): string {
|
||||
if (n == null) return '—';
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v)) return '—';
|
||||
return Math.round(v).toLocaleString('zh-CN');
|
||||
}
|
||||
202
src/prototypes/oneos-h5-h2-order/utils/manual-ledger.ts
Normal file
202
src/prototypes/oneos-h5-h2-order/utils/manual-ledger.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
export type ManualLedgerImage = {
|
||||
id: string;
|
||||
name: string;
|
||||
dataUrl: string;
|
||||
/** 已确认入库:界面不可删除,仅可补传新图 */
|
||||
archived?: boolean;
|
||||
placeholder?: boolean;
|
||||
uploadedAt: string;
|
||||
};
|
||||
|
||||
export function isArchivedManualImage(img: ManualLedgerImage): boolean {
|
||||
return Boolean(img.archived || img.placeholder);
|
||||
}
|
||||
|
||||
export type ManualLedgerEntry = {
|
||||
id: string;
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
ledgerDate: string;
|
||||
images: ManualLedgerImage[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ManualCalendarCell = {
|
||||
dateKey: string;
|
||||
day: number;
|
||||
inMonth: boolean;
|
||||
isToday: boolean;
|
||||
isFuture: boolean;
|
||||
/** 是否纳入手工台账补传范围(≥本站首笔加氢日 且 ≤今日) */
|
||||
needsLedger: boolean;
|
||||
uploaded: boolean;
|
||||
};
|
||||
|
||||
function bridge() {
|
||||
return typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function dateKeyFromHydrogenTime(hydrogenTime: unknown): string {
|
||||
const m = String(hydrogenTime || '').match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
/** 本站首笔加氢记录的自然日;无记录时返回 null(日历不要求历史补传) */
|
||||
export function getFirstHydrogenDateKey(stationIdOrName: string): string | null {
|
||||
const b = bridge();
|
||||
const rows = b?.getRows?.() || [];
|
||||
const key = String(stationIdOrName || '').trim();
|
||||
if (!key) return null;
|
||||
let earliest: string | null = null;
|
||||
rows.forEach((row) => {
|
||||
const sid = String(row.stationId || '').trim();
|
||||
const sn = String(row.stationName || '').trim();
|
||||
if (sid !== key && sn !== key) return;
|
||||
const dk = dateKeyFromHydrogenTime(row.hydrogenTime);
|
||||
if (!dk) return;
|
||||
if (!earliest || dk < earliest) earliest = dk;
|
||||
});
|
||||
return earliest;
|
||||
}
|
||||
|
||||
export function todayDateKey(): string {
|
||||
const b = bridge();
|
||||
if (b?.todayDateKey) return b.todayDateKey();
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
|
||||
export function hasManualLedgerForDate(
|
||||
stationIdOrName: string,
|
||||
ledgerDate?: string,
|
||||
): boolean {
|
||||
const b = bridge();
|
||||
if (!b?.hasManualLedgerForDate) return false;
|
||||
return Boolean(b.hasManualLedgerForDate(stationIdOrName, ledgerDate || todayDateKey()));
|
||||
}
|
||||
|
||||
export function getManualLedger(
|
||||
stationIdOrName: string,
|
||||
ledgerDate?: string,
|
||||
): ManualLedgerEntry | null {
|
||||
const b = bridge();
|
||||
if (!b?.getManualLedger) return null;
|
||||
return b.getManualLedger(stationIdOrName, ledgerDate || todayDateKey()) || null;
|
||||
}
|
||||
|
||||
export function listManualLedgersByStation(stationIdOrName: string): ManualLedgerEntry[] {
|
||||
const b = bridge();
|
||||
if (!b?.listManualLedgersByStation) return [];
|
||||
return b.listManualLedgersByStation(stationIdOrName) || [];
|
||||
}
|
||||
|
||||
export function upsertManualLedger(input: {
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
ledgerDate: string;
|
||||
images: Array<{ id?: string; name: string; dataUrl: string; placeholder?: boolean }>;
|
||||
}): ManualLedgerEntry | null {
|
||||
const b = bridge();
|
||||
if (!b?.upsertManualLedger) return null;
|
||||
return b.upsertManualLedger(input) || null;
|
||||
}
|
||||
|
||||
function cellNeedsLedger(
|
||||
dateKey: string,
|
||||
todayKey: string,
|
||||
firstHydrogenDateKey: string | null,
|
||||
): boolean {
|
||||
if (dateKey > todayKey) return false;
|
||||
if (!firstHydrogenDateKey) return false;
|
||||
return dateKey >= firstHydrogenDateKey;
|
||||
}
|
||||
|
||||
/** 构建某月日历格(含上月末 / 本月 / 下月头,共 6×7) */
|
||||
export function buildManualMonthCells(
|
||||
year: number,
|
||||
month1to12: number,
|
||||
uploadedDateSet: Set<string>,
|
||||
todayKey: string = todayDateKey(),
|
||||
firstHydrogenDateKey: string | null = null,
|
||||
): ManualCalendarCell[] {
|
||||
const first = new Date(year, month1to12 - 1, 1);
|
||||
const startWeekday = first.getDay();
|
||||
const daysInMonth = new Date(year, month1to12, 0).getDate();
|
||||
const prevDays = new Date(year, month1to12 - 1, 0).getDate();
|
||||
const cells: ManualCalendarCell[] = [];
|
||||
|
||||
for (let i = 0; i < 42; i++) {
|
||||
let y = year;
|
||||
let m = month1to12;
|
||||
let d: number;
|
||||
let inMonth = true;
|
||||
if (i < startWeekday) {
|
||||
d = prevDays - startWeekday + i + 1;
|
||||
m = month1to12 - 1;
|
||||
if (m < 1) {
|
||||
m = 12;
|
||||
y -= 1;
|
||||
}
|
||||
inMonth = false;
|
||||
} else if (i >= startWeekday + daysInMonth) {
|
||||
d = i - startWeekday - daysInMonth + 1;
|
||||
m = month1to12 + 1;
|
||||
if (m > 12) {
|
||||
m = 1;
|
||||
y += 1;
|
||||
}
|
||||
inMonth = false;
|
||||
} else {
|
||||
d = i - startWeekday + 1;
|
||||
}
|
||||
const dateKey = `${y}-${pad2(m)}-${pad2(d)}`;
|
||||
const needsLedger = cellNeedsLedger(dateKey, todayKey, firstHydrogenDateKey);
|
||||
cells.push({
|
||||
dateKey,
|
||||
day: d,
|
||||
inMonth,
|
||||
isToday: dateKey === todayKey,
|
||||
isFuture: dateKey > todayKey,
|
||||
needsLedger,
|
||||
uploaded: uploadedDateSet.has(dateKey),
|
||||
});
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
/** 本月内需补传且未上传的天数(从首笔加氢日起算,不含未来) */
|
||||
export function countMissingInMonth(
|
||||
year: number,
|
||||
month1to12: number,
|
||||
uploadedDateSet: Set<string>,
|
||||
todayKey: string = todayDateKey(),
|
||||
firstHydrogenDateKey: string | null = null,
|
||||
): number {
|
||||
const daysInMonth = new Date(year, month1to12, 0).getDate();
|
||||
let n = 0;
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const key = `${year}-${pad2(month1to12)}-${pad2(d)}`;
|
||||
if (key > todayKey) break;
|
||||
if (!cellNeedsLedger(key, todayKey, firstHydrogenDateKey)) continue;
|
||||
if (!uploadedDateSet.has(key)) n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
H2VehicleLedgerBridge?: {
|
||||
todayDateKey?: () => string;
|
||||
getRows?: () => Array<{ stationId?: string; stationName?: string; hydrogenTime?: string }>;
|
||||
hasManualLedgerForDate?: (stationIdOrName: string, ledgerDate?: string) => boolean;
|
||||
getManualLedger?: (stationIdOrName: string, ledgerDate?: string) => ManualLedgerEntry | null;
|
||||
listManualLedgersByStation?: (stationIdOrName: string) => ManualLedgerEntry[];
|
||||
upsertManualLedger?: (input: unknown) => ManualLedgerEntry | null;
|
||||
subscribe?: (fn: () => void) => () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
39
src/prototypes/oneos-h5-h2-order/utils/ocr-mock.ts
Normal file
39
src/prototypes/oneos-h5-h2-order/utils/ocr-mock.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
const PLATE_POOL = ['浙A88H201', '浙A12345', '浙B23456', '沪ADB9161', '苏E33333'];
|
||||
|
||||
function pick<T>(list: T[]): T {
|
||||
return list[Math.floor(Math.random() * list.length)];
|
||||
}
|
||||
|
||||
function round1(n: number): number {
|
||||
return Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
export function mockPlateOcr(): { plateNo: string } {
|
||||
return { plateNo: pick(PLATE_POOL) };
|
||||
}
|
||||
|
||||
export function mockPanelOcr(): { unitPrice: number; hydrogenKg: number; totalAmount: number } {
|
||||
const unitPrice = pick([42.5, 43, 44, 41, 45]);
|
||||
const hydrogenKg = round1(8 + Math.random() * 12);
|
||||
const totalAmount = round2(unitPrice * hydrogenKg);
|
||||
return { unitPrice, hydrogenKg, totalAmount };
|
||||
}
|
||||
|
||||
export function readFileAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
224
src/prototypes/oneos-h5-h2-order/utils/orders.ts
Normal file
224
src/prototypes/oneos-h5-h2-order/utils/orders.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import type { H5OrderRecord, StationOption } from '../types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
H2VehicleLedgerBridge?: {
|
||||
getRows: () => Array<Record<string, unknown>>;
|
||||
getStationList: () => Array<{ value: string; label: string }>;
|
||||
upsertRow: (row: Record<string, unknown>) => Record<string, unknown> | null;
|
||||
removeRow: (id: string) => boolean;
|
||||
subscribe: (fn: (rows: Array<Record<string, unknown>>) => void) => () => void;
|
||||
findDuplicateRow?: (
|
||||
candidate: { stationName?: string; plateNo?: string; hydrogenTime?: string },
|
||||
excludeId?: string,
|
||||
) => Record<string, unknown> | null;
|
||||
totalAmountMismatch?: (
|
||||
unitPrice: number,
|
||||
hydrogenKg: number,
|
||||
totalAmount: number,
|
||||
toleranceYuan?: number,
|
||||
) => { expected: number; actual: number; diff: number } | null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const RECONCILED = 'reconciled';
|
||||
const VERIFIED = 'verified';
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
/** 已对账必须有对账时间;缺省时按加氢日次日 10:00:00 推导(原型演示) */
|
||||
function deriveReconcileTime(hydrogenTime: string): string {
|
||||
const m = String(hydrogenTime || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!m) return '2026-01-01 10:00:00';
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + 1, 10, 0, 0);
|
||||
const pad = (n: number) => (n < 10 ? `0${n}` : String(n));
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 10:00:00`;
|
||||
}
|
||||
|
||||
export function mapRowToOrder(row: Record<string, unknown>): H5OrderRecord {
|
||||
/* 对账时间仅认 reconcileDate,勿用 reconciledAt(完成核对会写) */
|
||||
let reconcileTime = asString(row.reconcileDate);
|
||||
const reconcileStatus =
|
||||
asString(row.reconcileStatus) === RECONCILED || Boolean(reconcileTime)
|
||||
? 'reconciled'
|
||||
: 'pending';
|
||||
if (reconcileStatus === 'reconciled' && !reconcileTime) {
|
||||
reconcileTime = deriveReconcileTime(asString(row.hydrogenTime));
|
||||
}
|
||||
const verifyStatus =
|
||||
asString(row.verifyStatus) === VERIFIED || Boolean(row.verifiedAt) ? 'verified' : 'unverified';
|
||||
const verifiedAt = verifyStatus === 'verified' ? asString(row.verifiedAt) : '';
|
||||
const unitPrice = asNumber(row.costUnitPrice, asNumber(row.customerUnitPrice));
|
||||
const hydrogenKg = asNumber(row.hydrogenKg);
|
||||
const totalAmount = asNumber(row.costTotal, asNumber(row.costAmount, asNumber(row.customerAmount)));
|
||||
const mileageRaw = row.mileageKm;
|
||||
let mileageKm: number | null = null;
|
||||
if (mileageRaw != null && mileageRaw !== '') {
|
||||
const n = Number(mileageRaw);
|
||||
if (Number.isFinite(n) && n >= 0) mileageKm = Math.round(n);
|
||||
}
|
||||
return {
|
||||
id: asString(row.id || row.key),
|
||||
stationId: asString(row.stationId),
|
||||
stationName: asString(row.stationName),
|
||||
hydrogenTime: asString(row.hydrogenTime),
|
||||
plateNo: asString(row.plateNo),
|
||||
mileageKm,
|
||||
unitPrice,
|
||||
hydrogenKg,
|
||||
totalAmount,
|
||||
verifyStatus,
|
||||
verifiedAt,
|
||||
reconcileStatus,
|
||||
reconcileTime: reconcileStatus === 'reconciled' ? reconcileTime : '',
|
||||
platePhotoUrl: asString(row.platePhotoUrl),
|
||||
panelPhotoUrl: asString(row.panelPhotoUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export function isOrderLocked(order: H5OrderRecord): boolean {
|
||||
return order.verifyStatus === 'verified' || order.reconcileStatus === 'reconciled';
|
||||
}
|
||||
|
||||
export function listStations(): StationOption[] {
|
||||
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||
if (!bridge) {
|
||||
return [
|
||||
{ stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站' },
|
||||
{ stationId: 'HZ-H2-002', stationName: '杭州临平加氢站' },
|
||||
{ stationId: 'SH-H2-003', stationName: '上海宝山加氢站' },
|
||||
{ stationId: 'SZ-H2-004', stationName: '苏州工业园区备用站' },
|
||||
];
|
||||
}
|
||||
return bridge.getStationList().map((item) => ({
|
||||
stationId: item.value,
|
||||
stationName: item.label,
|
||||
}));
|
||||
}
|
||||
|
||||
export function listOrdersForStation(stationName: string): H5OrderRecord[] {
|
||||
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||
const rows = bridge?.getRows() || [];
|
||||
return rows
|
||||
.map(mapRowToOrder)
|
||||
.filter((r) => r.stationName === stationName)
|
||||
.sort((a, b) => String(b.hydrogenTime).localeCompare(String(a.hydrogenTime)));
|
||||
}
|
||||
|
||||
export function subscribeOrders(onChange: () => void): () => void {
|
||||
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||
if (!bridge?.subscribe) return () => undefined;
|
||||
return bridge.subscribe(() => onChange());
|
||||
}
|
||||
|
||||
export function upsertStationOrder(input: {
|
||||
id?: string;
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
hydrogenTime: string;
|
||||
plateNo: string;
|
||||
mileageKm?: number | null;
|
||||
unitPrice: number;
|
||||
hydrogenKg: number;
|
||||
totalAmount: number;
|
||||
platePhotoUrl?: string;
|
||||
panelPhotoUrl?: string;
|
||||
}): H5OrderRecord | null {
|
||||
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||
if (!bridge?.upsertRow) return null;
|
||||
const plate = input.plateNo.endsWith('F') ? input.plateNo : `${input.plateNo}F`;
|
||||
const hydrogenTime =
|
||||
input.hydrogenTime.length === 16 ? `${input.hydrogenTime}:00` : input.hydrogenTime;
|
||||
const mileageKm =
|
||||
input.mileageKm != null && Number.isFinite(input.mileageKm) && input.mileageKm >= 0
|
||||
? Math.round(input.mileageKm)
|
||||
: null;
|
||||
const saved = bridge.upsertRow({
|
||||
id: input.id,
|
||||
key: input.id,
|
||||
stationId: input.stationId,
|
||||
stationName: input.stationName,
|
||||
hydrogenTime,
|
||||
plateNo: plate,
|
||||
customerName: `${input.stationName}·站端上报`,
|
||||
hydrogenKg: input.hydrogenKg,
|
||||
costUnitPrice: input.unitPrice,
|
||||
costTotal: input.totalAmount,
|
||||
customerUnitPrice: input.unitPrice,
|
||||
customerAmount: input.totalAmount,
|
||||
settlementStatus: 'customer',
|
||||
mileageKm,
|
||||
platePhotoUrl: String(input.platePhotoUrl || ''),
|
||||
panelPhotoUrl: String(input.panelPhotoUrl || ''),
|
||||
creatorName: '站端账号',
|
||||
recordSource: 'station',
|
||||
changeLogOperator: '站端账号',
|
||||
reconcileStatus: 'pending',
|
||||
verifyStatus: 'unverified',
|
||||
});
|
||||
return saved ? mapRowToOrder(saved) : null;
|
||||
}
|
||||
|
||||
export function findDuplicateStationOrder(input: {
|
||||
id?: string;
|
||||
stationName: string;
|
||||
plateNo: string;
|
||||
hydrogenTime: string;
|
||||
}): boolean {
|
||||
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||
if (!bridge?.findDuplicateRow) return false;
|
||||
const plate = input.plateNo.endsWith('F') ? input.plateNo : `${input.plateNo}F`;
|
||||
const hydrogenTime =
|
||||
input.hydrogenTime.length === 16 ? `${input.hydrogenTime}:00` : input.hydrogenTime;
|
||||
return Boolean(
|
||||
bridge.findDuplicateRow(
|
||||
{ stationName: input.stationName, plateNo: plate, hydrogenTime },
|
||||
input.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getTotalAmountMismatch(
|
||||
unitPrice: number,
|
||||
hydrogenKg: number,
|
||||
totalAmount: number,
|
||||
): { expected: number; actual: number; diff: number } | null {
|
||||
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||
if (bridge?.totalAmountMismatch) {
|
||||
return bridge.totalAmountMismatch(unitPrice, hydrogenKg, totalAmount, 0.05);
|
||||
}
|
||||
const expected = Math.round(unitPrice * hydrogenKg * 100) / 100;
|
||||
const actual = Math.round(totalAmount * 100) / 100;
|
||||
const diff = Math.round(Math.abs(actual - expected) * 100) / 100;
|
||||
return diff > 0.05 ? { expected, actual, diff } : null;
|
||||
}
|
||||
|
||||
export function removeStationOrder(id: string): boolean {
|
||||
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||
if (!bridge?.removeRow) return false;
|
||||
return bridge.removeRow(id);
|
||||
}
|
||||
|
||||
export function summarizeOrders(orders: H5OrderRecord[]): {
|
||||
count: number;
|
||||
totalKg: number;
|
||||
totalAmount: number;
|
||||
} {
|
||||
return orders.reduce(
|
||||
(acc, row) => {
|
||||
acc.count += 1;
|
||||
acc.totalKg += row.hydrogenKg || 0;
|
||||
acc.totalAmount += row.totalAmount || 0;
|
||||
return acc;
|
||||
},
|
||||
{ count: 0, totalKg: 0, totalAmount: 0 },
|
||||
);
|
||||
}
|
||||
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: '车机未直连,已按演示规则回填里程',
|
||||
};
|
||||
}
|
||||
@@ -32,7 +32,8 @@ const CLOUD_PUBLISH_PATH_ALIASES: Record<string, string> = {
|
||||
};
|
||||
|
||||
function cloudPrototypeSegment(prototypeId: string): string {
|
||||
return CLOUD_PUBLISH_PATH_ALIASES[prototypeId] || `prototypes/${prototypeId}`;
|
||||
// 与 axhub.config.json cloudPublishing.s3 及 Make 发布路径一致:/{id}/index.html
|
||||
return CLOUD_PUBLISH_PATH_ALIASES[prototypeId] || prototypeId;
|
||||
}
|
||||
|
||||
/** 对象存储静态发布站点(无 Make 本地 dev server 路由) */
|
||||
@@ -40,7 +41,7 @@ export function isPublishedCloudHost(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const { hostname, pathname } = window.location;
|
||||
if (PUBLISHED_CLOUD_HOSTS.has(hostname)) return true;
|
||||
// export-html 发布页:/{prototype}/index.html 或 /prototypes/{prototype}/index.html
|
||||
// export-html 发布页:/{prototype-id}/index.html(与 Make 对象存储发布一致)
|
||||
return /\/index\.html$/u.test(pathname);
|
||||
}
|
||||
|
||||
@@ -126,6 +127,7 @@ type SidebarNode = {
|
||||
};
|
||||
|
||||
const GROUP_ICONS: Record<string, LucideIcon> = {
|
||||
车辆资产: Car,
|
||||
运维管理: Wrench,
|
||||
业务管理: Briefcase,
|
||||
合同配置: FileText,
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
{
|
||||
"id": "folder-1782874599304-roj8fd",
|
||||
"kind": "folder",
|
||||
"title": "运维管理",
|
||||
"title": "车辆资产",
|
||||
"children": [
|
||||
{
|
||||
"id": "item:prototypes:vehicle-management",
|
||||
@@ -111,10 +111,16 @@
|
||||
"itemKey": "prototypes/oneos-web-h2-station-site"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:oneos-web-h2-station-weekly",
|
||||
"id": "item:prototypes:oneos-h5-h2-order",
|
||||
"kind": "item",
|
||||
"title": "站点周报统计",
|
||||
"itemKey": "prototypes/oneos-web-h2-station-weekly"
|
||||
"title": "加氢记录(H5)",
|
||||
"itemKey": "prototypes/oneos-h5-h2-order"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:oneos-web-h2-station",
|
||||
"kind": "item",
|
||||
"title": "加氢记录",
|
||||
"itemKey": "prototypes/oneos-web-h2-station"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -126,8 +132,20 @@
|
||||
{
|
||||
"id": "item:prototypes:business-dept-ledger",
|
||||
"kind": "item",
|
||||
"title": "业务部台账",
|
||||
"title": "项目盈亏情况",
|
||||
"itemKey": "prototypes/business-dept-ledger"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:oneos-web-h2-station-stats",
|
||||
"kind": "item",
|
||||
"title": "加氢站数量统计",
|
||||
"itemKey": "prototypes/oneos-web-h2-station-stats"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:customer-payment-collection",
|
||||
"kind": "item",
|
||||
"title": "客户回款情况",
|
||||
"itemKey": "prototypes/customer-payment-collection"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -190,12 +208,6 @@
|
||||
"kind": "item",
|
||||
"title": "还车应结款",
|
||||
"itemKey": "prototypes/vehicle-return-settlement"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:vehicle-return-settlement-v2",
|
||||
"kind": "item",
|
||||
"title": "还车应结款-新",
|
||||
"itemKey": "prototypes/vehicle-return-settlement-v2"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -212,6 +224,19 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "folder-1784341576489-spa7ms",
|
||||
"kind": "folder",
|
||||
"title": "审批中心",
|
||||
"children": [
|
||||
{
|
||||
"id": "item:prototypes:oneos-web-approval",
|
||||
"kind": "item",
|
||||
"title": "审批中心",
|
||||
"itemKey": "prototypes/oneos-web-approval"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:lease-business-line-overview",
|
||||
"kind": "item",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-07-12T14:43:43.194Z",
|
||||
"updatedAt": "2026-07-20T05:34:46.063Z",
|
||||
"title": "小羚羚",
|
||||
"sectionId": "folder-prototypes-xll-miniapp",
|
||||
"description": "氢能车辆运营移动端原型;菜单与小羚羚「小程序」项目目录同步。",
|
||||
"prototypeId": "xll-miniapp",
|
||||
"runtimeOrigin": "http://localhost:51721",
|
||||
"hrefPrefix": "http://localhost:51721/prototypes/xll-miniapp",
|
||||
"runtimeOrigin": "http://localhost:51723",
|
||||
"hrefPrefix": "http://localhost:51723/prototypes/xll-miniapp",
|
||||
"groups": [
|
||||
{
|
||||
"id": "directory-main",
|
||||
@@ -80,6 +80,12 @@
|
||||
"pageId": "replace",
|
||||
"route": "replace"
|
||||
},
|
||||
{
|
||||
"id": "route-driver-training",
|
||||
"title": "司机安全培训",
|
||||
"pageId": "driver-training",
|
||||
"route": "driver-training"
|
||||
},
|
||||
{
|
||||
"id": "route-audit-return",
|
||||
"title": "还车应结款(说明)",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"projectRoot": "/Users/sylvawong/make-project-20260629",
|
||||
"projectRoot": "C:/Users/hsu06/Projects/xll-mini-program",
|
||||
"prototypeId": "xll-miniapp",
|
||||
"annotationSource": "src/prototypes/xll-miniapp/annotation-source.json",
|
||||
"devServerInfo": ".axhub/make/.dev-server-info.json",
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* @name 合同模板管理
|
||||
* 自 ONE-OS web端 原稿复刻(1 个页面)
|
||||
*/
|
||||
import '../../common/oneosWebLegacy/legacyGlobals';
|
||||
import React from 'react';
|
||||
import '../vehicle-management/style.css';
|
||||
import Page1 from './pages/01-合同模板管理.jsx';
|
||||
import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell';
|
||||
|
||||
const pages = [
|
||||
{ id: 'u5408u540cu6a21u677fu7ba1u7406', title: '合同模板管理', component: Page1 },
|
||||
];
|
||||
|
||||
export default function OneosWebContractTemplate() {
|
||||
return (
|
||||
<OneosWebLegacyShell
|
||||
moduleTitle="合同模板管理"
|
||||
pages={pages}
|
||||
defaultPageId="u5408u540cu6a21u677fu7ba1u7406"
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @name 数据分析
|
||||
* 自 ONE-OS web端 原稿复刻(8 个页面;业务部台账已迁至 OneOS → 数据分析)
|
||||
* 自 ONE-OS web端 原稿复刻(8 个页面;业务部台账、客户回款情况已迁至 OneOS → 数据分析)
|
||||
*/
|
||||
import '../../common/oneosWebLegacy/legacyGlobals';
|
||||
import React from 'react';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 已加载)。
|
||||
@@ -0,0 +1,171 @@
|
||||
# 期末余额设置 · 需求说明
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 模块 | 加氢站管理 → 站点信息 |
|
||||
| 功能名称 | 期末余额设置 |
|
||||
| 交互原型 | `/prototypes/oneos-web-h2-station-site` |
|
||||
| 关联功能 | 预付余额列表展示、预付余额下钻、车辆氢费明细、生成对账单 |
|
||||
| 文档状态 | 已对齐原型 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求背景
|
||||
|
||||
运营与加氢站(如嘉兴站)对账后,账面「期末余额」与系统内车辆氢费明细之间常有**时间差**:对账时点之后,站点仍可能继续产生加氢支出或充值收入,导致列表「预付余额」无法直接等于对账结果。
|
||||
|
||||
需要提供能力:在站点侧登记一次对账时的**期末余额**与**期末时间**,并以此为锚点推算当前可用余额。
|
||||
|
||||
---
|
||||
|
||||
## 2. 需求目标
|
||||
|
||||
1. 支持按站点登记期末余额、期末时间。
|
||||
2. 保存时按统一公式重算「当前预付余额」并写回列表。
|
||||
3. 预付余额下钻流水以期末时间为锚点,重新展示余额滚动。
|
||||
4. 保留完整变更记录,便于追溯。
|
||||
|
||||
---
|
||||
|
||||
## 3. 用户与入口
|
||||
|
||||
| 角色 | 诉求 |
|
||||
|---|---|
|
||||
| 财务 / 结算 | 对账后登记期末余额,得到可解释的当前预付余额 |
|
||||
| 加氢站运营 | 查看当前余额及下钻流水是否与期末锚点一致 |
|
||||
|
||||
**入口**:站点信息列表 → 操作列 **更多(⋮)** → **期末余额设置**
|
||||
(菜单位置:余额提醒设置 与 生成对账单 之间)
|
||||
|
||||
---
|
||||
|
||||
## 4. 功能描述
|
||||
|
||||
### 4.1 弹窗内容
|
||||
|
||||
| 区域 | 内容 | 说明 |
|
||||
|---|---|---|
|
||||
| 站点摘要 | 站点名称、当前预付余额 | 只读 |
|
||||
| 表单 | 期末余额(元)*、期末时间 * | 必填 |
|
||||
| 操作 | 取消 / 保存 | 保存触发校验与重算 |
|
||||
| 变更记录 | 期末余额、生效时间、操作人、操作时间 | 只追加;支持分页 |
|
||||
|
||||
### 4.2 主流程
|
||||
|
||||
```text
|
||||
打开期末余额设置
|
||||
→ 填写期末余额、期末时间
|
||||
→ 点击保存
|
||||
→ 校验通过
|
||||
→ 追加变更记录
|
||||
→ 按规则重算当前预付余额并写回列表
|
||||
→ 提示保存成功
|
||||
```
|
||||
|
||||
打开该站点「预付余额」下钻时,若已存在生效期末配置,流水按锚点规则重算展示(见 §6)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 业务规则(核心)
|
||||
|
||||
### 5.1 字段规则
|
||||
|
||||
| 规则 ID | 规则 | 说明 |
|
||||
|---|---|---|
|
||||
| R1 | 期末余额必填 | 须为有效数字;**允许负数**(欠费场景) |
|
||||
| R2 | 期末时间必填 | 精确到分钟(`YYYY-MM-DD HH:mm`) |
|
||||
| R3 | 期末时间不可晚于当前时刻 | 日期不可选今天之后;保存时若时刻晚于现在,阻止保存 |
|
||||
| R4 | 以最近一次保存为生效锚点 | 站点仅保留一套「当前生效」的期末余额 / 期末时间;历史见变更记录 |
|
||||
|
||||
### 5.2 当前预付余额计算公式
|
||||
|
||||
**仅在点击「保存」时计算一次**(不监听之后新产生的流水自动刷新)。
|
||||
|
||||
```text
|
||||
当前预付余额 = 期末余额 − 期末后支出合计 + 期末后收入合计
|
||||
```
|
||||
|
||||
| 规则 ID | 规则 | 说明 |
|
||||
|---|---|---|
|
||||
| R5 | 支出范围 | 该站点、业务时间 **严格晚于** 期末时间 `T` 的车辆氢费明细,按**成本总价**合计 |
|
||||
| R6 | 收入范围 | 该站点、变更时间 **严格晚于** `T` 的充值类收入合计 |
|
||||
| R7 | 取整 | 结果保留 **2 位小数** 后写回「预付余额(元)」 |
|
||||
| R8 | 时间边界 | 等于期末时间 `T` 当刻的流水 **不计入** 期末后收支(只统计 `> T`) |
|
||||
|
||||
### 5.3 变更记录规则
|
||||
|
||||
| 规则 ID | 规则 | 说明 |
|
||||
|---|---|---|
|
||||
| R9 | 只追加 | 每次保存成功新增一条记录 |
|
||||
| R10 | 不可删除 | 界面不提供删除 / 清空 |
|
||||
| R11 | 不可编辑 | 历史行不可改 |
|
||||
| R12 | 记录字段 | 期末余额、生效时间(= 本次期末时间)、操作人、操作时间(保存时刻) |
|
||||
| R13 | 分页 | 变更记录支持分页(紧凑页码:5 / 10 / 20 / 50) |
|
||||
|
||||
### 5.4 与其他功能的关系
|
||||
|
||||
| 规则 ID | 规则 | 说明 |
|
||||
|---|---|---|
|
||||
| R14 | KPI / 余额提醒 | 仍读取「预付余额」;期末保存写回后,预警 / 欠费 KPI 随之变化 |
|
||||
| R15 | 生成对账单 | 对账单提交仍可扣减 / 写回预付余额;与期末重算冲突时,**以后发生的写操作为准** |
|
||||
| R16 | 未配置期末 | 未保存过期末余额时,预付余额逻辑保持原状(对账扣减、手工/其他入口) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 预付余额下钻规则
|
||||
|
||||
当站点已配置生效的期末余额、期末时间时:
|
||||
|
||||
| 规则 ID | 规则 | 说明 |
|
||||
|---|---|---|
|
||||
| R17 | 插入锚点行 | 在流水中增加类型为「期末余额」的行:时间为期末时间,余额列 = 期末余额 |
|
||||
| R18 | 锚点前流水 | 时间 ≤ 期末时间的历史流水可继续展示 |
|
||||
| R19 | 锚点后流水 | 时间 > 期末时间的收入 / 支出,从期末余额起按时间顺序累加,重算每行余额 |
|
||||
| R20 | 下钻不写回 | 打开下钻只重算展示,**不再次写回**列表预付余额(列表值仍以最近一次保存结果为准) |
|
||||
|
||||
**锚点后单行余额推算:**
|
||||
|
||||
```text
|
||||
行余额 = 上一行余额 + 本行收入 − 本行支出
|
||||
(起点上一行余额 = 期末余额)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 校验与异常
|
||||
|
||||
| 场景 | 系统行为 |
|
||||
|---|---|
|
||||
| 期末余额为空 / 非数字 | 阻止保存,提示填写有效期末余额 |
|
||||
| 期末时间为空 | 阻止保存,提示选择期末时间 |
|
||||
| 期末时间晚于当前时刻 | 阻止保存,提示「期末时间不能晚于今天」 |
|
||||
| 期末后无收支流水 | 允许保存;当前预付余额 = 期末余额 |
|
||||
| 变更记录为空 | 展示「暂无变更记录」 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
- [ ] 更多菜单可见「期末余额设置」
|
||||
- [ ] 必填与「期末时间不可晚于今天」校验生效
|
||||
- [ ] 保存后列表预付余额 = 公式计算结果
|
||||
- [ ] 变更记录追加成功,无删除入口,分页可用
|
||||
- [ ] 余额下钻出现「期末余额」锚点,其后余额连续正确
|
||||
- [ ] 未配置期末时,原有预付余额 / 下钻行为不受影响
|
||||
|
||||
---
|
||||
|
||||
## 9. 非目标(本期不做)
|
||||
|
||||
- 变更记录编辑、删除、回滚到某一历史锚点
|
||||
- 保存后对新流水的自动实时重算
|
||||
- 多套期末锚点同时生效
|
||||
- 真实充值流水 API、审批流(原型可用 Mock)
|
||||
|
||||
---
|
||||
|
||||
## 10. 修订记录
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|---|---|---|
|
||||
| v1.0 | 2026-07-17 | 首版需求:口径、写回时机、不可删日志、时间上限、下钻锚点 |
|
||||
@@ -4,7 +4,7 @@
|
||||
- 用户资料/参考资料:
|
||||
- `src/prototypes/oneos-web-h2-station-site/.spec/requirements.md`
|
||||
- `src/resources/oneos-web-legacy/加氢站管理/站点管理使用说明书.md`
|
||||
- 页面实现:`src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx`
|
||||
- 页面实现:`src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx`
|
||||
- 氢费共享 Store:`src/common/h2VehicleLedgerBridge.js`
|
||||
- 生成时间:2026-07-01(复审)
|
||||
|
||||
|
||||
@@ -203,4 +203,4 @@ flowchart TD
|
||||
| 口径函数 | `h2IsBalanceAlertStation` / `h2IsArrearsStation` |
|
||||
| 打开弹窗 | `openAlertStationModal` |
|
||||
| 批量充值 | `handleBatchRechargeFromAlert` → `openRechargeModal` |
|
||||
| 页面文件 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` |
|
||||
| 页面文件 | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` |
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
1. **筛选区** — 名称、签约、地区、营业状态
|
||||
2. **KPI 分类** — 全部 / 签约·普通 / 预付余额预警 / 已欠费 / 无加氢
|
||||
3. **工具栏** — 全站余额提醒、批量导入、发起充值单、新建站点
|
||||
3. **工具栏** — 全站余额提醒、批量导入、加氢机品牌管理、发起充值单、新建站点
|
||||
4. **站点列表** — 主数据 + 运营指标 + 操作列
|
||||
5. **分页栏** — 每页 5/10/20/50 条
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
|---|---|
|
||||
| 全站余额提醒设置 | 配置全局预付余额预警阈值 |
|
||||
| 批量导入 | 下载模板 → 上传 CSV/Excel → 预览确认导入 |
|
||||
| 加氢机品牌管理 | 维护品牌与型号,供 H5 面板 OCR 参考(见 `.spec/dispenser-brand.md`) |
|
||||
| 发起充值单 | 为站点发起预付充值流程(原型演示) |
|
||||
| 新建站点 | 进入同页内嵌新建视图 |
|
||||
|
||||
@@ -100,7 +101,7 @@
|
||||
| 联系方式 | 联系人 + 电话 |
|
||||
| 操作 | 查看 + 更多菜单 |
|
||||
|
||||
**更多菜单**:编辑、删除、营业状态、价格配置、生成对账单、查看对账记录等(按状态与权限显隐)。
|
||||
**更多菜单**:编辑、删除、营业状态、价格配置、余额提醒、期末余额设置、生成对账单、查看对账记录等(按状态与权限显隐)。
|
||||
|
||||
**分页**:默认 10 条,可选 5/10/20/50。
|
||||
|
||||
@@ -119,4 +120,5 @@
|
||||
| 入口 | 路径 |
|
||||
|---|---|
|
||||
| 独立原型(本页) | OneOS → 加氢站管理 → 站点信息 |
|
||||
| 合包 | ONE-OS Web 端 → 加氢站管理 → 加氢订单 / 加氢记录 |
|
||||
| 加氢订单 | OneOS → 加氢站管理 → 加氢订单(H5) |
|
||||
| 加氢记录 | OneOS → 加氢站管理 → 加氢记录(Web) |
|
||||
|
||||
@@ -1,37 +1,51 @@
|
||||
# 加氢站管理 · 站点信息 — 对账与结算 PRD
|
||||
|
||||
> 本模块与「车辆氢费明细」配合完成**站点侧氢费结算闭环**。
|
||||
> 本模块与「车辆氢费明细」配合完成**站点侧氢费结算闭环**。
|
||||
> **重要**:生成对账单时,账期筛选出的明细**必须来自车辆氢费明细中「已核对」的记录**;未核对明细不得进入对账单。完整判定见 [statement-verified-filter.md](./statement-verified-filter.md);台账双状态见车辆氢费明细 `.spec/verify-reconcile.md`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 数据来源
|
||||
|
||||
对账单明细取自 **车辆氢费明细** 中同时满足以下条件的记录:
|
||||
对账单明细取自 **车辆氢费明细**(共享 Store)中**同时满足**以下条件的记录:
|
||||
|
||||
1. 对账状态 = **已对账**(台账已完成「完成对账」)
|
||||
2. **尚未**被本站点历史对账单结算过(未绑定 `statementRecordId`)
|
||||
3. 加氢时间落在用户选择的 **[账单开始日期, 账单结束日期]** 内
|
||||
4. 加氢站名称与当前操作站点一致
|
||||
| 优先级 | 条件 | 说明 |
|
||||
|---|---|---|
|
||||
| 1 | 未绑定 `statementRecordId` | 尚未被历史对账单结算 |
|
||||
| 2 | 核对状态 = **已核对**(`verifyStatus = verified`) | **硬约束**:未核对一律不纳入 |
|
||||
| 3 | 对账状态 = **未对账**(`reconcileStatus = pending`) | 已对账由对账单提交产生,不再进入新账单 |
|
||||
| 4 | 加氢站名称 = 当前操作站点 | 仅本站 |
|
||||
| 5 | 加氢时间落在 **[账单开始日期, 账单结束日期]** | 用户所选账期(按日闭区间) |
|
||||
|
||||
> 对账单明细**仅展示成本字段**(加氢量、成本单价、成本总价等),不展示加氢单价、加氢总价。
|
||||
|
||||
### 1.1 为何必须「已核对」
|
||||
|
||||
能源部在车辆氢费明细点击「完成核对」后,明细才视为业务已确认。站点侧生成对账单是结算动作,只能拉取已确认数据;未核对记录即使时间落在账期内,也**静默排除**,不出现在对账单明细与统计中。
|
||||
|
||||
```text
|
||||
未核对 ──✕──→ 不可进对账单
|
||||
已核对 + 未对账 ──✓──→ 可进对账单(提交后变为已对账)
|
||||
已核对 + 已对账 ──✕──→ 不可再进下一张对账单
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 两阶段交互
|
||||
|
||||
#### 生成对账单 · 业务逻辑
|
||||
|
||||
1. 选择**账单开始日期**与**账单结束日期**后,从**车辆氢费明细**获取该时间段内、对应当前站点的加氢记录(已对账且尚未结算)。
|
||||
2. 点击「**生成对账单**」时弹出**二次确认**,提示:生成对账单后,车辆氢费明细中对应记录将自动标记为已对账且无法修改。
|
||||
1. 选择**账单开始日期**与**账单结束日期**后,从**车辆氢费明细**获取该时间段内、对应当前站点、且为**已核对且尚未结算(未对账)**的加氢记录。
|
||||
2. 点击「**生成对账单**」时弹出**二次确认**,提示:生成并提交后,车辆氢费明细中对应记录将标记为**已对账**且无法修改。
|
||||
3. 确认后进入结算阶段,填写**收票日期**、**收票金额**及**发票附件**(对账日期在提交时由系统自动回写)。
|
||||
4. 点击「**提交对账单**」后,本批明细在车辆氢费明细中锁定为已对账/已结算状态,不可再修改。
|
||||
4. 点击「**提交对账单**」后,本批明细在车辆氢费明细中锁定为**已对账**并绑定本对账单,不可再进入后续对账单。
|
||||
|
||||
**阶段一 · 选期生成**
|
||||
|
||||
- 日期区下方展示:**上次对账单结束时间**(无历史则「暂无」)
|
||||
- 默认开始日期建议 = 上次结束日 + 1 天
|
||||
- 点击「生成对账单」时弹出**二次确认**:提示生成后车辆氢费明细对应记录将标记为已对账且无法修改;确认后进入阶段二,日期锁定不可改
|
||||
- 无符合条件记录时提示调整日期
|
||||
- 点击「生成对账单」时弹出**二次确认**;确认后进入阶段二,日期锁定不可改
|
||||
- 无符合条件记录时提示调整日期(含:账期内仅有未核对、或均已结算等情况)
|
||||
|
||||
**阶段二 · 结算提交**
|
||||
|
||||
@@ -39,7 +53,7 @@
|
||||
|
||||
| 指标 | 说明 |
|
||||
|---|---|
|
||||
| 加氢次数 | 本账单包含的已对账记录笔数 |
|
||||
| 加氢次数 | 本账单包含的**已核对**记录笔数 |
|
||||
| 加氢总量 | kg 合计 |
|
||||
| 成本总金额 | 成本总价合计 |
|
||||
|
||||
@@ -65,12 +79,15 @@
|
||||
|
||||
| 车辆氢费明细字段 | 回写值 |
|
||||
|---|---|
|
||||
| 对账状态 | **已对账**(`reconcileStatus = reconciled`) |
|
||||
| 对账日期 | 操作完成时间 |
|
||||
| 收票日期 | 操作完成日期 |
|
||||
| 加氢站付款状态 | 已付款 |
|
||||
|
||||
原型通过 `H2_STATION_STATEMENT_LEDGER_UPDATES` / `h2VehicleLedgerBridge` 模拟跨页回写。
|
||||
|
||||
> 「完成核对」只改变核对状态,**不会**把记录写成已对账;已对账仅由本页对账单提交产生。
|
||||
|
||||
---
|
||||
|
||||
## 4. 查看对账记录
|
||||
@@ -88,7 +105,8 @@
|
||||
| 场景 | 处理 |
|
||||
|---|---|
|
||||
| 日期未填 / 开始晚于结束 | 阻止生成 |
|
||||
| 区间内无已对账未结算记录 | 阻止生成,提示调整日期 |
|
||||
| 区间内无「已核对且未对账未结算」记录 | 阻止生成,提示调整日期(或先完成核对) |
|
||||
| 区间内存在未核对记录 | **不纳入**对账单;不单独弹窗枚举(静默排除) |
|
||||
| 点击「生成对账单」未确认 | 不进入结算阶段 |
|
||||
| 收票日期/金额/附件未填 | 阻止提交 |
|
||||
| 收票金额 ≤ 0 | 阻止提交 |
|
||||
@@ -98,8 +116,10 @@
|
||||
|
||||
## 6. 研发要点
|
||||
|
||||
1. **对账单幂等**:同一笔已对账记录只能进入一次成功提交的对账单
|
||||
2. **上次结束时间**:取该站点已成功提交对账单的最大 `endDate`
|
||||
3. **余额计算**:`balanceAfter = prepaidBalance - sum(costTotal)`
|
||||
4. **跨模块同步**:生产对接统一台账;原型使用 Bridge Store
|
||||
5. **明细字段范围**:对账弹窗不展示客户侧加氢单价/总价
|
||||
1. **准入**:`verifyStatus === verified` 为硬条件;勿再用「已对账」作为生成筛选条件
|
||||
2. **对账单幂等**:同一笔明细只能进入一次成功提交的对账单(`statementRecordId` 约束)
|
||||
3. **上次结束时间**:取该站点已成功提交对账单的最大 `endDate`
|
||||
4. **余额计算**:`balanceAfter = prepaidBalance - sum(costTotal)`
|
||||
5. **跨模块同步**:生产对接统一台账;原型使用 Bridge Store
|
||||
6. **明细字段范围**:对账弹窗不展示客户侧加氢单价/总价
|
||||
7. **规格全文**:[statement-verified-filter.md](./statement-verified-filter.md)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| 所属系统 | ONE-OS Web 端 |
|
||||
| 目标读者 | 前端 / 后端 / 测试 / 业务产品对接人 |
|
||||
| 交互原型 | `/prototypes/oneos-web-h2-station-site` |
|
||||
| 关联原型 | [车辆氢费明细](/prototypes/vehicle-h2-fee-ledger)、[加氢站合包](/prototypes/oneos-web-h2-station) |
|
||||
| 关联原型 | [车辆氢费明细](/prototypes/vehicle-h2-fee-ledger)、[加氢记录](/prototypes/oneos-web-h2-station)、[加氢订单 H5](/prototypes/oneos-h5-h2-order) |
|
||||
| 文档状态 | 已对齐原型 |
|
||||
|
||||
---
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
- 对账单审批流、ERP 自动过账
|
||||
- 发票 OCR 识别与验真
|
||||
- 加氢订单、加氢记录(仍在合包 `oneos-web-h2-station`)
|
||||
- 加氢订单(独立 H5:`oneos-h5-h2-order`)、加氢记录(独立 Web:`oneos-web-h2-station`)本页不内嵌
|
||||
- 后端接口联调(原型使用 Mock + 共享 Store)
|
||||
|
||||
---
|
||||
@@ -118,22 +118,37 @@
|
||||
3. **阶梯量重置日期**见价格配置表单内「阶梯量重置日期」;到达重置日后,累计加氢量**从零重新计数**,用于判断当前所处价格区间。
|
||||
4. **生效时间**自表单内「生效时间」起对新数据生效,**不追溯**此前已产生的数据;生效时间之前的历史记录,仍按当时已达的**最高阶梯价格**结算。
|
||||
|
||||
### 5.3 下钻分析
|
||||
### 5.3 期末余额设置
|
||||
|
||||
> 完整规则见 [period-end-balance.md](./period-end-balance.md)
|
||||
|
||||
与加氢站对账存在时间差时,登记**期末余额**与**期末时间**作为锚点:
|
||||
|
||||
- **入口**:更多 → 期末余额设置
|
||||
- **计算**:`当前预付余额 = 期末余额 − 期末后支出 + 期末后收入`(仅保存时重算一次)
|
||||
- **约束**:期末时间不可晚于今天;变更记录只追加、不可删除
|
||||
- **下钻**:余额流水从期末锚点行起重新累加
|
||||
|
||||
### 5.4 下钻分析
|
||||
|
||||
- **加氢量下钻**:统计卡 + 明细表 + 导出,字段对齐车辆氢费明细
|
||||
- **预付余额下钻**:充值/加氢流水 + 趋势图 + 导出
|
||||
- **预付余额下钻**:充值/加氢流水 + 趋势图 + 导出;若已配置期末余额,自期末时间锚点重算后续余额
|
||||
|
||||
---
|
||||
|
||||
## 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. 提交回写 | 更新预付余额、写对账历史;台账变为 **已对账**(对账日期、收票日期、已付款) |
|
||||
|
||||
> **核对 ≠ 对账**:核对由车辆氢费明细「完成核对」产生;对账仅由本页「提交对账单」回写。
|
||||
|
||||
---
|
||||
|
||||
@@ -143,7 +158,7 @@
|
||||
|---|---|
|
||||
| 氢费明细 | `src/common/h2VehicleLedgerBridge.js` |
|
||||
| 对账回写 | `H2_STATION_STATEMENT_LEDGER_UPDATES` |
|
||||
| 页面实现 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` |
|
||||
| 页面实现 | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` |
|
||||
| 独立入口 | `src/prototypes/oneos-web-h2-station-site/index.tsx` |
|
||||
|
||||
---
|
||||
@@ -159,11 +174,17 @@
|
||||
|
||||
### 8.2 对账流程
|
||||
|
||||
- [ ] 仅已对账未结算记录可进入对账单
|
||||
- [ ] 仅 **已核对** 且未对账、未结算的记录可进入对账单;未核对明细即使在账期内也不出现
|
||||
- [ ] 两阶段交互与必填校验完整
|
||||
- [ ] 提交后回写车辆氢费明细可跨页验收
|
||||
- [ ] 提交后回写车辆氢费明细为 **已对账**,可跨页验收
|
||||
|
||||
### 8.3 标注与文档
|
||||
### 8.3 期末余额设置
|
||||
|
||||
- [ ] 更多菜单可打开期末余额设置;期末时间不可晚于今天
|
||||
- [ ] 保存后写回预付余额,变更记录只追加不可删
|
||||
- [ ] 余额下钻出现期末锚点且后续余额连续推算
|
||||
|
||||
### 8.4 标注与文档
|
||||
|
||||
- [ ] `@axhub/annotation` 已接入,关键区域有 `data-annotation-id`
|
||||
- [ ] 右侧工具栏目录可阅 PRD 全文与页面标注
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
| 包含 | 不包含 |
|
||||
|------|--------|
|
||||
| 站点列表、KPI、新建/编辑/查看/删除 | 加氢订单、加氢记录(仍在合包) |
|
||||
| 站点列表、KPI、新建/编辑/查看/删除 | 加氢订单(H5 独立)、加氢记录(Web 独立) |
|
||||
| 营业状态、价格配置、余额下钻 | 后端接口联调 |
|
||||
| 两阶段对账单、充值单、批量导入 | |
|
||||
|
||||
@@ -31,4 +31,5 @@
|
||||
## 双入口说明
|
||||
|
||||
- **本原型**:OneOS → 加氢站管理 → 站点信息
|
||||
- **合包**:ONE-OS Web 端 → 加氢站管理 → 加氢订单 / 加氢记录
|
||||
- **加氢订单**:OneOS → 加氢站管理 → 加氢订单(`oneos-h5-h2-order`)
|
||||
- **加氢记录**:OneOS → 加氢站管理 → 加氢记录(`oneos-web-h2-station`)
|
||||
|
||||
@@ -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/h2VehicleLedgerBridge.js';
|
||||
import '../../common/h2DispenserBrandStore.js';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import * as antd 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 '../vehicle-management/style.css';
|
||||
import '../lease-contract-management/styles/lease-contract.css';
|
||||
@@ -17,9 +21,22 @@ import {
|
||||
} from '@axhub/annotation';
|
||||
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||
import SiteInfoPage from '../oneos-web-h2-station/pages/03-站点信息.jsx';
|
||||
import SiteInfoPage from './pages/03-站点信息.jsx';
|
||||
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 主题一致 */
|
||||
const vmTheme = {
|
||||
token: {
|
||||
@@ -50,12 +67,16 @@ const vmTheme = {
|
||||
InputNumber: {
|
||||
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() {
|
||||
useEffect(() => {
|
||||
clearHostPrototypeRouteInfo();
|
||||
@@ -74,7 +95,7 @@ export default function OneosWebH2StationSite() {
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={vmTheme}>
|
||||
<ConfigProvider locale={zhCN} theme={vmTheme}>
|
||||
<SiteInfoPage />
|
||||
<PrototypeAnnotationHost
|
||||
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-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-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-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; }',
|
||||
@@ -1137,6 +1142,28 @@ function h2FormatReceiptAmountInput(text) {
|
||||
return n.toFixed(2);
|
||||
}
|
||||
|
||||
/** 期末余额等可允许负数的金额输入 */
|
||||
function h2SanitizeSignedAmountInput(text) {
|
||||
var raw = String(text || '');
|
||||
var negative = raw.trim().charAt(0) === '-';
|
||||
var s = raw.replace(/[^\d.]/g, '');
|
||||
var parts = s.split('.');
|
||||
if (parts.length > 2) s = parts[0] + '.' + parts.slice(1).join('');
|
||||
var dot = s.indexOf('.');
|
||||
if (dot >= 0 && s.length - dot - 1 > 2) s = s.slice(0, dot + 3);
|
||||
if (negative && s) s = '-' + s;
|
||||
else if (negative && !s) s = '-';
|
||||
return s;
|
||||
}
|
||||
|
||||
function h2FormatSignedAmountInput(text) {
|
||||
var s = String(text || '').trim();
|
||||
if (!s || s === '-') return '';
|
||||
var n = parseFloat(s);
|
||||
if (isNaN(n)) return '';
|
||||
return n.toFixed(2);
|
||||
}
|
||||
|
||||
function h2ResolveCurrentCostPrice(record) {
|
||||
var defaultCfg = h2GetDefaultAllCustomerPriceConfig(record);
|
||||
if (defaultCfg) {
|
||||
@@ -2996,7 +3023,8 @@ function h2FormatYuanSymbol(v, options) {
|
||||
var H2_BALANCE_BIZ_TYPE_MAP = {
|
||||
recharge: { key: 'recharge', label: '充值', color: 'success' },
|
||||
refuel: { key: 'refuel', label: '车辆加氢', color: 'warning' },
|
||||
ending: { key: 'ending', label: '期末', color: 'default' }
|
||||
ending: { key: 'ending', label: '期末', color: 'default' },
|
||||
period_end: { key: 'period_end', label: '期末余额', color: 'processing' }
|
||||
};
|
||||
|
||||
function h2Pad2(n) {
|
||||
@@ -3142,6 +3170,115 @@ function h2BuildMockPrepaidBalanceRows(record) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 期末余额变更日志(只追加,不可删除) */
|
||||
function h2CreatePeriodEndBalanceLog(periodEndBalance, effectiveTime, operator) {
|
||||
return {
|
||||
id: 'peb-' + Date.now() + '-' + Math.floor(Math.random() * 10000),
|
||||
periodEndBalance: periodEndBalance,
|
||||
effectiveTime: effectiveTime,
|
||||
operator: operator || H2_CURRENT_OPERATOR,
|
||||
operateTime: h2OperateTimestamp()
|
||||
};
|
||||
}
|
||||
|
||||
/** 期末时间之后该站氢费明细成本总价合计(支出) */
|
||||
function h2SumStationLedgerExpenseAfter(store, stationName, periodEndTime) {
|
||||
var t0 = h2ParseDateTimeMs(periodEndTime);
|
||||
if (isNaN(t0)) return 0;
|
||||
var name = String(stationName || '').trim();
|
||||
var sum = 0;
|
||||
var i;
|
||||
for (i = 0; i < (store || []).length; i++) {
|
||||
var r = store[i];
|
||||
if ((r.stationName || '') !== name) continue;
|
||||
var t = h2ParseDateTimeMs(r.hydrogenTime);
|
||||
if (isNaN(t) || t <= t0) continue;
|
||||
sum += h2NumOrZero(r.costTotal);
|
||||
}
|
||||
return Math.round(sum * 100) / 100;
|
||||
}
|
||||
|
||||
/** 期末时间之后余额流水中的充值收入合计 */
|
||||
function h2SumBalanceIncomeAfter(rows, periodEndTime) {
|
||||
var t0 = h2ParseDateTimeMs(periodEndTime);
|
||||
if (isNaN(t0)) return 0;
|
||||
var sum = 0;
|
||||
var i;
|
||||
for (i = 0; i < (rows || []).length; i++) {
|
||||
var r = rows[i];
|
||||
if (r.bizType === 'period_end' || r.bizType === 'ending') continue;
|
||||
var t = h2ParseChangeTimeMs(r.changeTime);
|
||||
if (!t || t <= t0) continue;
|
||||
if (r.incomeAmount != null && r.incomeAmount !== '') sum += h2NumOrZero(r.incomeAmount);
|
||||
}
|
||||
return Math.round(sum * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存期末余额时重算预付余额:
|
||||
* 当前余额 = 期末余额 − 期末后支出 + 期末后收入
|
||||
*/
|
||||
function h2RecalcPrepaidFromPeriodEnd(record, ledgerStore, periodEndBalance, periodEndTime) {
|
||||
var rawRows = h2BuildMockPrepaidBalanceRows(Object.assign({}, record, {
|
||||
periodEndBalance: null,
|
||||
periodEndTime: null
|
||||
}));
|
||||
var expenseSum = h2SumStationLedgerExpenseAfter(ledgerStore, record && record.name, periodEndTime);
|
||||
var incomeSum = h2SumBalanceIncomeAfter(rawRows, periodEndTime);
|
||||
var prepaidBalance = Math.round((h2NumOrZero(periodEndBalance) - expenseSum + incomeSum) * 100) / 100;
|
||||
return { prepaidBalance: prepaidBalance, expenseSum: expenseSum, incomeSum: incomeSum };
|
||||
}
|
||||
|
||||
/** 余额下钻:从期末锚点起重算后续行余额 */
|
||||
function h2ApplyPeriodEndAnchorToBalanceRows(record, rows) {
|
||||
var periodEndTime = record && record.periodEndTime;
|
||||
var periodEndBalance = record && record.periodEndBalance;
|
||||
if (periodEndTime == null || periodEndTime === '' || periodEndBalance == null || periodEndBalance === '') {
|
||||
return (rows || []).slice();
|
||||
}
|
||||
var t0 = h2ParseDateTimeMs(periodEndTime);
|
||||
if (isNaN(t0)) return (rows || []).slice();
|
||||
var E = h2NumOrZero(periodEndBalance);
|
||||
var before = [];
|
||||
var after = [];
|
||||
var i;
|
||||
for (i = 0; i < (rows || []).length; i++) {
|
||||
var r = rows[i];
|
||||
if (r.bizType === 'period_end') continue;
|
||||
var t = h2ParseChangeTimeMs(r.changeTime);
|
||||
if (t && t > t0) after.push(r);
|
||||
else before.push(r);
|
||||
}
|
||||
after.sort(function (a, b) {
|
||||
return h2ParseChangeTimeMs(a.changeTime) - h2ParseChangeTimeMs(b.changeTime);
|
||||
});
|
||||
var timeText = String(periodEndTime).trim();
|
||||
if (timeText.length === 16) timeText += ':00';
|
||||
var anchor = {
|
||||
key: String(record.id != null ? record.id : 'st') + '-period-end',
|
||||
stationName: (record && record.name) || '',
|
||||
changeTime: timeText,
|
||||
bizType: 'period_end',
|
||||
incomeAmount: null,
|
||||
expenseAmount: null,
|
||||
balance: E,
|
||||
orderNo: 'PE' + String(record.id != null ? record.id : '')
|
||||
};
|
||||
var bal = E;
|
||||
var rebuiltAfter = after.map(function (row) {
|
||||
var income = row.incomeAmount != null && row.incomeAmount !== '' ? h2NumOrZero(row.incomeAmount) : 0;
|
||||
var expense = row.expenseAmount != null && row.expenseAmount !== '' ? h2NumOrZero(row.expenseAmount) : 0;
|
||||
bal = Math.round((bal + income - expense) * 100) / 100;
|
||||
return Object.assign({}, row, { balance: bal });
|
||||
});
|
||||
return before.concat([anchor]).concat(rebuiltAfter);
|
||||
}
|
||||
|
||||
function h2BuildPrepaidBalanceRowsForRecord(record) {
|
||||
var base = h2BuildMockPrepaidBalanceRows(record);
|
||||
return h2ApplyPeriodEndAnchorToBalanceRows(record, base);
|
||||
}
|
||||
|
||||
function h2FormatBalanceTrendLabel(changeTime) {
|
||||
if (!changeTime) return '—';
|
||||
var text = String(changeTime).trim();
|
||||
@@ -3512,8 +3649,13 @@ function h2GetAvailableStatementLedgerRows(store, stationName, startDate, endDat
|
||||
if (!name || isNaN(startMs) || isNaN(endMs)) return [];
|
||||
var endInclusive = endMs + 86400000 - 1;
|
||||
return (store || []).filter(function (r) {
|
||||
if (r.reconcileStatus !== H2_RECONCILE_RECONCILED) return false;
|
||||
/* 对账单仅纳入:已核对 ∧ 未对账 ∧ 未绑定其他对账单 */
|
||||
if (r.statementRecordId) return false;
|
||||
var verify = r.verifyStatus || 'unverified';
|
||||
if (verify !== 'verified') return false;
|
||||
var st = r.reconcileStatus || H2_RECONCILE_PENDING;
|
||||
if (st === H2_RECONCILE_RECONCILED) return false;
|
||||
if (st !== H2_RECONCILE_PENDING) return false;
|
||||
if ((r.stationName || '') !== name) return false;
|
||||
var t = h2ParseDateTimeMs(r.hydrogenTime);
|
||||
if (isNaN(t)) return false;
|
||||
@@ -3983,7 +4125,7 @@ mindmap
|
||||
|------|----------|----------|
|
||||
| **平台管理员(admin)** | 新建、编辑、批量导入、删除、绑定系统账号 | 系统账号支持多选绑定 |
|
||||
| **加氢站运营账号** | 编辑本站资料、营业状态、价格配置 | 编辑时**不能改**供应商与系统账号 |
|
||||
| **财务 / 结算人员** | 生成对账单、登记收票、查看对账记录 | 需先在台账完成「已对账」 |
|
||||
| **财务 / 结算人员** | 生成对账单、登记收票、查看对账记录 | 需先在台账完成「已核对」 |
|
||||
|
||||
---
|
||||
|
||||
@@ -4211,7 +4353,15 @@ flowchart LR
|
||||
4. 保存后:
|
||||
- 若已到生效时间 → 列表「当前成本价格」**立即更新**
|
||||
- 若未到生效时间 → 到点后自动更新
|
||||
4. 下方展示价格调整记录(操作人、调整前后价格、生效时间)。
|
||||
5. 下方展示价格调整记录(操作人、调整前后价格、生效时间)。
|
||||
|
||||
### 5.3 期末余额设置
|
||||
|
||||
**入口**:更多 → **期末余额设置**
|
||||
|
||||
1. 填写 **期末余额(元)** 与 **期末时间**(不可晚于今天)。
|
||||
2. 保存后按「期末余额 − 期末后支出 + 期末后收入」重算并写回「预付余额」(仅保存时计算一次)。
|
||||
3. 下方展示变更记录(期末余额、生效时间、操作人、操作时间),只追加不可删除,支持分页。
|
||||
|
||||
---
|
||||
|
||||
@@ -4235,6 +4385,7 @@ flowchart LR
|
||||
|
||||
- 当前余额(负数显示「已欠费」)
|
||||
- 充值 / 车辆加氢流水明细
|
||||
- 若已配置期末余额:流水中出现「期末余额」锚点行,其后收入/支出从该余额连续推算
|
||||
- **导出 CSV** 按钮
|
||||
|
||||
---
|
||||
@@ -4247,12 +4398,12 @@ flowchart LR
|
||||
|
||||
生成对账单前,台账中须已有满足以下**全部**条件的记录:
|
||||
|
||||
1. 对账状态 = **已对账**(业务员已在台账点击「完成对账」)
|
||||
2. **尚未**被本站点历史对账单结算过
|
||||
1. 核对状态 = **已核对**(能源部已在车辆氢费明细点击「完成核对」)
|
||||
2. 对账状态 = **未对账**,且**尚未**被本站点历史对账单结算过
|
||||
3. 加氢时间在所选账单日期区间内
|
||||
4. 加氢站名称与当前站点一致
|
||||
|
||||
> 对账单只展示**成本侧**字段(加氢量、成本单价、成本总价),不展示客户加氢价。
|
||||
> **未核对**明细即使落在账期内也**不会**进入对账单。对账单只展示**成本侧**字段(加氢量、成本单价、成本总价),不展示客户加氢价。
|
||||
|
||||
### 7.2 生成对账单 — 两阶段操作
|
||||
|
||||
@@ -4315,12 +4466,12 @@ sequenceDiagram
|
||||
participant 站点 as 站点信息
|
||||
participant 台账 as 车辆氢费明细
|
||||
|
||||
业务->>台账: 完成对账 → 已对账
|
||||
业务->>台账: 完成核对 → 已核对
|
||||
财务->>站点: 更多 → 生成对账单
|
||||
站点->>台账: 拉取已对账且未结算记录
|
||||
站点->>台账: 拉取已核对且未对账未结算记录
|
||||
财务->>站点: 填写收票信息并提交
|
||||
站点->>站点: 更新预付余额
|
||||
站点->>台账: 回写已付款
|
||||
站点->>台账: 回写已付款(标记已对账)
|
||||
\`\`\`
|
||||
|
||||
### 7.3 查看对账记录
|
||||
@@ -4345,6 +4496,8 @@ sequenceDiagram
|
||||
| 编辑 | 整页编辑站点主数据 |
|
||||
| 营业状态 | 维护营业状态与营业时间 |
|
||||
| 价格配置 | 设置成本价与生效时间 |
|
||||
| 余额提醒设置 | 设置预付余额预警阈值 |
|
||||
| 期末余额设置 | 登记期末余额与期末时间,重算预付余额 |
|
||||
| 生成对账单 | 两阶段对账结算 |
|
||||
| 查看对账记录 | 历史对账单与明细 |
|
||||
| 删除 | 删除站点(不可恢复) |
|
||||
@@ -4535,7 +4688,15 @@ flowchart TB
|
||||
|
||||
**加氢量下钻**:统计卡(次数、加氢量、成本总价、加氢总价)+ 明细表 + 导出 CSV,字段对齐车辆氢费明细。
|
||||
|
||||
**预付余额下钻**:充值/车辆加氢流水 + 导出 CSV;余额为负显示已欠费。
|
||||
**预付余额下钻**:充值/车辆加氢流水 + 导出 CSV;余额为负显示已欠费;若已配置期末余额,自期末时间锚点重算后续余额。
|
||||
|
||||
### 5.4 期末余额设置
|
||||
|
||||
**入口**:更多 → **期末余额设置**
|
||||
|
||||
1. 填写 **期末余额(元)** 与 **期末时间**(不可晚于今天)。
|
||||
2. 保存后:「当前预付余额 = 期末余额 − 期末后支出 + 期末后收入」(仅保存时重算一次并写回列表)。
|
||||
3. 下方展示变更记录(期末余额、生效时间、操作人、操作时间),只追加不可删除,支持分页。
|
||||
|
||||
---
|
||||
|
||||
@@ -4547,12 +4708,12 @@ flowchart TB
|
||||
|
||||
对账单明细取自 **车辆氢费明细** 中同时满足以下条件的记录:
|
||||
|
||||
1. 对账状态 = **已对账**(业务侧已在台账完成「完成对账」)
|
||||
2. **尚未**被本站点历史对账单结算过(未绑定 statementRecordId)
|
||||
1. 核对状态 = **已核对**(能源部已在车辆氢费明细完成「完成核对」)
|
||||
2. 对账状态 = **未对账**,且**尚未**被本站点历史对账单结算过(未绑定 statementRecordId)
|
||||
3. 加氢时间落在用户选择的 **[账单开始日期, 账单结束日期]** 内
|
||||
4. 加氢站名称与当前操作站点一致
|
||||
|
||||
> 对账单明细**仅展示成本字段**(加氢量、成本单价、成本总价等),不展示加氢单价、加氢总价。
|
||||
> **未核对**明细不得进入对账单。明细**仅展示成本字段**(加氢量、成本单价、成本总价等),不展示加氢单价、加氢总价。
|
||||
|
||||
### 6.2 两阶段交互
|
||||
|
||||
@@ -4575,7 +4736,8 @@ flowchart TD
|
||||
|
||||
- 日期区下方展示:**上次对账单结束时间 YYYY-MM-DD**(无历史则「暂无」)
|
||||
- 默认开始日期建议 = 上次结束日 + 1 天
|
||||
- 点击「生成对账单」时弹出**二次确认**:提示生成后车辆氢费明细对应记录将标记为已对账且无法修改;确认后进入阶段二,日期锁定不可改
|
||||
- 点击「生成对账单」时弹出**二次确认**:提示提交后对应「已核对」记录将标记为已对账且无法修改;确认后进入阶段二,日期锁定不可改
|
||||
- 无符合条件记录时提示调整日期(或先完成核对)
|
||||
|
||||
**阶段二 · 结算提交**
|
||||
|
||||
@@ -4617,13 +4779,13 @@ sequenceDiagram
|
||||
participant 站点 as 站点信息
|
||||
participant 财务 as 结算操作人
|
||||
|
||||
台账->>台账: 业务员「完成对账」→ 已对账
|
||||
台账->>台账: 能源部「完成核对」→ 已核对
|
||||
财务->>站点: 更多 → 生成对账单
|
||||
站点->>台账: 拉取已对账且未结算记录
|
||||
站点->>台账: 拉取已核对且未对账未结算记录
|
||||
财务->>站点: 生成对账记录 + 填收票信息
|
||||
财务->>站点: 提交对账单
|
||||
站点->>站点: 更新预付余额 / 写对账历史
|
||||
站点->>台账: 回写对账日期、收票日期、已付款
|
||||
站点->>台账: 回写已对账、对账日期、收票日期、已付款
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
@@ -4660,7 +4822,7 @@ sequenceDiagram
|
||||
| 场景 | 处理 |
|
||||
|------|------|
|
||||
| 日期未填 / 开始晚于结束 | 阻止生成,提示用户 |
|
||||
| 区间内无已对账未结算记录 | 阻止生成,提示调整日期 |
|
||||
| 区间内无已核对未对账未结算记录 | 阻止生成,提示调整日期(或先完成核对) |
|
||||
| 收票日期/金额/附件未填 | 阻止提交 |
|
||||
| 收票金额 ≤ 0 | 阻止提交 |
|
||||
| 预付余额结算后为负 | 允许提交,列表与明细标「已欠费」 |
|
||||
@@ -5770,6 +5932,17 @@ const Component = function () {
|
||||
var balanceAlertModal = balanceAlertModalState[0];
|
||||
var setBalanceAlertModal = balanceAlertModalState[1];
|
||||
|
||||
var periodEndBalanceModalState = useState({
|
||||
open: false,
|
||||
record: null,
|
||||
periodEndBalance: '',
|
||||
periodEndTime: '',
|
||||
logPage: 1,
|
||||
logPageSize: 5
|
||||
});
|
||||
var periodEndBalanceModal = periodEndBalanceModalState[0];
|
||||
var setPeriodEndBalanceModal = periodEndBalanceModalState[1];
|
||||
|
||||
var globalBalanceAlertModalState = useState({ open: false, threshold: '' });
|
||||
var globalBalanceAlertModal = globalBalanceAlertModalState[0];
|
||||
var setGlobalBalanceAlertModal = globalBalanceAlertModalState[1];
|
||||
@@ -5860,6 +6033,15 @@ const Component = function () {
|
||||
var createSubmitting = createSubmittingState[0];
|
||||
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 editStation = editStationState[0];
|
||||
var setEditStation = editStationState[1];
|
||||
@@ -6275,6 +6457,9 @@ const Component = function () {
|
||||
costUnitPrice: createStation.costUnitPrice ? parseFloat(createStation.costUnitPrice) : undefined,
|
||||
customerUnitPrice: createStation.customerUnitPrice ? parseFloat(createStation.customerUnitPrice) : undefined,
|
||||
prepaidBalance: 0,
|
||||
periodEndBalance: null,
|
||||
periodEndTime: null,
|
||||
periodEndBalanceLogs: [],
|
||||
businessStatusLogs: [],
|
||||
costPriceLogs: [],
|
||||
updateTime: now,
|
||||
@@ -6543,7 +6728,7 @@ const Component = function () {
|
||||
open: true,
|
||||
stationName: record.name || '',
|
||||
endingBalance: h2NumOrZero(record.prepaidBalance),
|
||||
rows: h2BuildMockPrepaidBalanceRows(record)
|
||||
rows: h2BuildPrepaidBalanceRowsForRecord(record)
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -6625,12 +6810,12 @@ const Component = function () {
|
||||
}
|
||||
var rows = h2GetAvailableStatementLedgerRows(ledgerStore, statementModal.record.name, statementModal.startDate, statementModal.endDate);
|
||||
if (!rows.length) {
|
||||
message.warning('所选日期范围内暂无「已对账」且未结算的加氢记录,请调整日期后重试');
|
||||
message.warning('所选日期范围内暂无「已核对」且未对账、未结算的加氢记录,请调整日期或先完成核对后重试');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认生成对账单?',
|
||||
content: '生成对账单后,车辆氢费明细中对应记录将自动标记为已对账且无法修改,是否确认?',
|
||||
content: '生成对账单后,车辆氢费明细中对应「已核对」记录将在提交时标记为已对账且无法修改,是否确认?',
|
||||
okText: '确认生成',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
@@ -6708,10 +6893,13 @@ const Component = function () {
|
||||
hydrogenTime: r.hydrogenTime,
|
||||
reconcileDate: operateTime,
|
||||
receiptDate: operateDate,
|
||||
paymentStatus: H2_PAYMENT_STATUS_PAID
|
||||
paymentStatus: H2_PAYMENT_STATUS_PAID,
|
||||
statementRecordId: statementId,
|
||||
reconcileStatus: H2_RECONCILE_RECONCILED
|
||||
});
|
||||
return Object.assign({}, r, {
|
||||
statementRecordId: statementId,
|
||||
reconcileStatus: H2_RECONCILE_RECONCILED,
|
||||
reconcileDate: operateTime,
|
||||
receiptDate: operateDate,
|
||||
paymentStatus: H2_PAYMENT_STATUS_PAID
|
||||
@@ -7174,6 +7362,92 @@ const Component = function () {
|
||||
setBalanceAlertModal({ open: false, record: null, threshold: '' });
|
||||
}, []);
|
||||
|
||||
var openPeriodEndBalanceSetting = useCallback(function (record) {
|
||||
var balanceText = record.periodEndBalance != null && record.periodEndBalance !== ''
|
||||
? h2FormatYuanNum(record.periodEndBalance)
|
||||
: '';
|
||||
setPeriodEndBalanceModal({
|
||||
open: true,
|
||||
record: record,
|
||||
periodEndBalance: balanceText,
|
||||
periodEndTime: record.periodEndTime || '',
|
||||
logPage: 1,
|
||||
logPageSize: 5
|
||||
});
|
||||
}, []);
|
||||
|
||||
var closePeriodEndBalanceModal = useCallback(function () {
|
||||
setPeriodEndBalanceModal({
|
||||
open: false,
|
||||
record: null,
|
||||
periodEndBalance: '',
|
||||
periodEndTime: '',
|
||||
logPage: 1,
|
||||
logPageSize: 5
|
||||
});
|
||||
}, []);
|
||||
|
||||
var handleSavePeriodEndBalance = useCallback(function () {
|
||||
if (!periodEndBalanceModal.record) return;
|
||||
var balanceText = h2FormatSignedAmountInput(periodEndBalanceModal.periodEndBalance);
|
||||
if (balanceText === '' || balanceText == null) {
|
||||
message.warning('请填写有效的期末余额');
|
||||
return;
|
||||
}
|
||||
var balanceNum = parseFloat(balanceText);
|
||||
if (isNaN(balanceNum)) {
|
||||
message.warning('请填写有效的期末余额');
|
||||
return;
|
||||
}
|
||||
var timeText = String(periodEndBalanceModal.periodEndTime || '').trim();
|
||||
if (!timeText) {
|
||||
message.warning('请选择期末时间');
|
||||
return;
|
||||
}
|
||||
var timeMs = h2ParseDateTimeMs(timeText);
|
||||
if (isNaN(timeMs)) {
|
||||
message.warning('期末时间格式不正确');
|
||||
return;
|
||||
}
|
||||
if (timeMs > Date.now()) {
|
||||
message.warning('期末时间不能晚于今天');
|
||||
return;
|
||||
}
|
||||
var recordId = periodEndBalanceModal.record.id;
|
||||
var stationSnapshot = periodEndBalanceModal.record;
|
||||
var recalc = h2RecalcPrepaidFromPeriodEnd(stationSnapshot, ledgerStore, balanceNum, timeText);
|
||||
var newLog = h2CreatePeriodEndBalanceLog(balanceNum, timeText, H2_CURRENT_OPERATOR);
|
||||
setListData(function (prev) {
|
||||
return prev.map(function (r) {
|
||||
if (r.id !== recordId) return r;
|
||||
var logs = [newLog].concat(r.periodEndBalanceLogs || []);
|
||||
return Object.assign({}, r, {
|
||||
periodEndBalance: balanceNum,
|
||||
periodEndTime: timeText,
|
||||
periodEndBalanceLogs: logs,
|
||||
prepaidBalance: recalc.prepaidBalance,
|
||||
updateTime: h2OperateTimestamp()
|
||||
});
|
||||
});
|
||||
});
|
||||
setPeriodEndBalanceModal(function (m) {
|
||||
if (!m.record || m.record.id !== recordId) return m;
|
||||
var nextRecord = Object.assign({}, m.record, {
|
||||
periodEndBalance: balanceNum,
|
||||
periodEndTime: timeText,
|
||||
periodEndBalanceLogs: [newLog].concat(m.record.periodEndBalanceLogs || []),
|
||||
prepaidBalance: recalc.prepaidBalance
|
||||
});
|
||||
return Object.assign({}, m, {
|
||||
record: nextRecord,
|
||||
periodEndBalance: h2FormatYuanNum(balanceNum),
|
||||
periodEndTime: timeText,
|
||||
logPage: 1
|
||||
});
|
||||
});
|
||||
message.success('期末余额已保存,预付余额已按流水重算为 ¥' + h2FormatYuanNum(recalc.prepaidBalance) + '(原型)');
|
||||
}, [periodEndBalanceModal, ledgerStore]);
|
||||
|
||||
var handleSaveBalanceAlert = useCallback(function () {
|
||||
if (!balanceAlertModal.record) return;
|
||||
var thresholdText = h2FormatReceiptAmountInput(balanceAlertModal.threshold);
|
||||
@@ -7362,12 +7636,13 @@ const Component = function () {
|
||||
{ key: 'business', label: '营业状态', onClick: function () { openBusinessSetting(record); } },
|
||||
{ key: 'price', label: '价格配置', onClick: function () { openPriceConfig(record); } },
|
||||
{ key: 'balanceAlert', label: '余额提醒设置', onClick: function () { openBalanceAlertSetting(record); } },
|
||||
{ key: 'periodEndBalance', label: '期末余额设置', onClick: function () { openPeriodEndBalanceSetting(record); } },
|
||||
{ key: 'statement', label: '生成对账单', onClick: function () { openStatementModal(record); } },
|
||||
{ key: 'statementHistory', label: '查看对账记录', onClick: function () { openStatementHistoryModal(record); } },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: '删除', danger: true, onClick: function () { setDeleteModal({ open: true, record: record }); } }
|
||||
];
|
||||
}, [openBusinessSetting, openPriceConfig, openBalanceAlertSetting, openStatementModal, openStatementHistoryModal]);
|
||||
}, [openBusinessSetting, openPriceConfig, openBalanceAlertSetting, openPeriodEndBalanceSetting, openStatementModal, openStatementHistoryModal]);
|
||||
|
||||
var closeImportModal = useCallback(function () {
|
||||
setImportModalOpen(false);
|
||||
@@ -8185,7 +8460,7 @@ const Component = function () {
|
||||
!isDraft ? React.createElement('div', { className: 'h2-statement-form-hint' },
|
||||
'数据来源:',
|
||||
React.createElement('strong', null, '车辆氢费明细'),
|
||||
' 中标记为「已对账」且尚未结算的加氢记录;明细仅展示成本价与成本总价。'
|
||||
' 中标记为「已核对」且尚未对账结算的加氢记录;未核对记录不会进入对账单;明细仅展示成本价与成本总价。'
|
||||
) : null
|
||||
),
|
||||
isDraft ? React.createElement('div', { className: 'h2-statement-stats', role: 'group', 'aria-label': '对账统计' },
|
||||
@@ -9526,6 +9801,17 @@ const Component = function () {
|
||||
onClick: function () { setImportModalOpen(true); },
|
||||
'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', {
|
||||
'data-vm-icon': 'plus',
|
||||
type: 'button',
|
||||
@@ -9868,6 +10154,328 @@ const Component = function () {
|
||||
: null
|
||||
),
|
||||
|
||||
React.createElement(Modal, {
|
||||
title: '期末余额设置',
|
||||
className: 'h2-period-end-modal',
|
||||
open: periodEndBalanceModal.open,
|
||||
onCancel: closePeriodEndBalanceModal,
|
||||
footer: React.createElement(Space, null,
|
||||
renderH2GhostButton('取消', closePeriodEndBalanceModal),
|
||||
React.createElement(Button, { type: 'primary', onClick: handleSavePeriodEndBalance, style: H2_PRIMARY_BTN_STYLE }, '保存')
|
||||
),
|
||||
centered: true,
|
||||
width: 640,
|
||||
destroyOnClose: true,
|
||||
styles: { body: { maxHeight: '78vh', overflow: 'auto' } }
|
||||
},
|
||||
(function () {
|
||||
if (!periodEndBalanceModal.record) return null;
|
||||
var logs = periodEndBalanceModal.record.periodEndBalanceLogs || [];
|
||||
var logPage = periodEndBalanceModal.logPage || 1;
|
||||
var logPageSize = periodEndBalanceModal.logPageSize || 5;
|
||||
var logTotalPages = Math.max(1, Math.ceil(logs.length / logPageSize));
|
||||
var safePage = Math.min(logPage, logTotalPages);
|
||||
var pagedLogs = logs.slice((safePage - 1) * logPageSize, safePage * logPageSize);
|
||||
var periodEndTimeValue = h2ToDateTimeDayjs(periodEndBalanceModal.periodEndTime);
|
||||
var periodEndPicker = DatePicker
|
||||
? React.createElement('div', { 'data-annotation-id': 'h2-site-period-end-time' },
|
||||
React.createElement(DatePicker, {
|
||||
className: 'h2-period-end-datetime-picker',
|
||||
showTime: { format: 'HH:mm' },
|
||||
needConfirm: false,
|
||||
style: { width: '100%' },
|
||||
format: 'YYYY-MM-DD HH:mm',
|
||||
placeholder: '请选择期末时间',
|
||||
allowClear: true,
|
||||
value: periodEndTimeValue,
|
||||
disabledDate: function (current) {
|
||||
if (!current || !dayjs) return false;
|
||||
return current.isAfter(dayjs().endOf('day'));
|
||||
},
|
||||
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, {
|
||||
type: 'datetime-local',
|
||||
'data-annotation-id': 'h2-site-period-end-time',
|
||||
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) {
|
||||
var raw = e.target.value || '';
|
||||
setPeriodEndBalanceModal(function (m) {
|
||||
return Object.assign({}, m, { periodEndTime: raw ? raw.replace('T', ' ') : '' });
|
||||
});
|
||||
}
|
||||
});
|
||||
return React.createElement('div', {
|
||||
'data-annotation-id': 'h2-site-period-end-balance',
|
||||
style: { display: 'flex', flexDirection: 'column', gap: 14 }
|
||||
},
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
padding: '12px 14px',
|
||||
borderRadius: 10,
|
||||
background: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: 13,
|
||||
color: '#475569',
|
||||
lineHeight: 1.55
|
||||
}
|
||||
},
|
||||
React.createElement('div', { style: { fontWeight: 700, color: '#0f172a', marginBottom: 4 } }, periodEndBalanceModal.record.name || '—'),
|
||||
'登记与加氢站对账时的期末余额与期末时间。保存后按「期末余额 − 期末后支出 + 期末后收入」重算并写回预付余额。当前预付余额:',
|
||||
React.createElement('strong', {
|
||||
style: {
|
||||
color: h2NumOrZero(periodEndBalanceModal.record.prepaidBalance) < 0 ? '#dc2626' : '#059669',
|
||||
fontVariantNumeric: 'tabular-nums'
|
||||
}
|
||||
}, h2FormatYuanNum(periodEndBalanceModal.record.prepaidBalance)),
|
||||
' 元'
|
||||
),
|
||||
React.createElement(Form, { layout: 'vertical', requiredMark: false },
|
||||
React.createElement(Form.Item, {
|
||||
label: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '期末余额(元)')
|
||||
},
|
||||
React.createElement(Input, {
|
||||
className: 'h2-statement-receipt-amount-input',
|
||||
inputMode: 'decimal',
|
||||
prefix: '¥',
|
||||
style: { width: '100%', borderRadius: 8 },
|
||||
placeholder: '请输入期末余额',
|
||||
value: periodEndBalanceModal.periodEndBalance || '',
|
||||
onChange: function (e) {
|
||||
var next = h2SanitizeSignedAmountInput(e.target.value);
|
||||
setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { periodEndBalance: next }); });
|
||||
},
|
||||
onBlur: function (e) {
|
||||
var formatted = h2FormatSignedAmountInput(e.target.value);
|
||||
setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { periodEndBalance: formatted }); });
|
||||
}
|
||||
})
|
||||
),
|
||||
React.createElement(Form.Item, {
|
||||
label: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '期末时间'),
|
||||
extra: '不可晚于今天;保存后作为余额计算与下钻流水的锚点'
|
||||
}, periodEndPicker)
|
||||
),
|
||||
React.createElement('div', null,
|
||||
React.createElement('div', {
|
||||
style: { fontSize: 13, fontWeight: 600, color: '#0f172a', marginBottom: 8 }
|
||||
}, '变更记录'),
|
||||
React.createElement(Table, {
|
||||
className: 'h2-status-log-table',
|
||||
size: 'small',
|
||||
rowKey: 'id',
|
||||
pagination: false,
|
||||
locale: { emptyText: '暂无变更记录' },
|
||||
dataSource: pagedLogs,
|
||||
columns: [
|
||||
{
|
||||
title: '期末余额(元)',
|
||||
dataIndex: 'periodEndBalance',
|
||||
key: 'periodEndBalance',
|
||||
width: 120,
|
||||
render: function (v) {
|
||||
return React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums', fontWeight: 600 } }, h2FormatYuanNum(v));
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '生效时间',
|
||||
dataIndex: 'effectiveTime',
|
||||
key: 'effectiveTime',
|
||||
width: 148,
|
||||
render: function (v) { return v || '—'; }
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'operator',
|
||||
key: 'operator',
|
||||
width: 100,
|
||||
render: function (v) { return v || '—'; }
|
||||
},
|
||||
{
|
||||
title: '操作时间',
|
||||
dataIndex: 'operateTime',
|
||||
key: 'operateTime',
|
||||
width: 148,
|
||||
render: function (v) { return v || '—'; }
|
||||
}
|
||||
]
|
||||
}),
|
||||
React.createElement('div', { className: 'vm-table-footer vm-table-footer--compact', style: { marginTop: 8, padding: '8px 0', borderTop: 'none' } },
|
||||
React.createElement(TablePagination, {
|
||||
page: safePage,
|
||||
pageSize: logPageSize,
|
||||
total: logs.length,
|
||||
pageSizeOptions: COMPACT_PAGE_SIZE_OPTIONS,
|
||||
onPageChange: function (p) {
|
||||
setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { logPage: p }); });
|
||||
},
|
||||
onPageSizeChange: function (size) {
|
||||
setPeriodEndBalanceModal(function (m) {
|
||||
return Object.assign({}, m, { logPageSize: size, logPage: 1 });
|
||||
});
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
})()
|
||||
),
|
||||
|
||||
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, {
|
||||
title: '全站余额提醒设置',
|
||||
open: globalBalanceAlertModal.open,
|
||||
@@ -9,7 +9,10 @@ const specRoot = path.join(protoRoot, '.spec');
|
||||
const fullPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd.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 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 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 NODE_DEFS = [
|
||||
@@ -43,7 +46,7 @@ const NODE_DEFS = [
|
||||
pageId: 'site-info',
|
||||
color: '#d97706',
|
||||
aiPrompt: '工具栏操作与对账单氢费联动。',
|
||||
annotationText: '全站余额提醒、批量导入、充值单、新建站点;对账回写车辆氢费明细。',
|
||||
annotationText: '全站余额提醒、批量导入、加氢机品牌管理、充值单、新建站点;对账回写车辆氢费明细。',
|
||||
locator: {
|
||||
selectors: ['.h2-station-page .vm-table-toolbar', '[data-annotation-id="h2-site-toolbar"]'],
|
||||
fingerprint: 'toolbar|statement',
|
||||
@@ -90,13 +93,25 @@ const NODE_DEFS = [
|
||||
title: '生成对账单',
|
||||
pageId: 'site-info',
|
||||
color: '#7c3aed',
|
||||
aiPrompt: '选期拉取氢费明细、二次确认与结算提交逻辑。',
|
||||
annotationText: '选日期拉取站点加氢记录;生成前二次确认;提交后明细锁定为已对账',
|
||||
aiPrompt: '选期仅拉取已核对未对账氢费明细、二次确认与结算提交。',
|
||||
annotationText: '账期内仅纳入车辆氢费明细「已核对」记录;未核对不进对账单;提交后标为已对账',
|
||||
locator: {
|
||||
selectors: ['[data-annotation-id="h2-site-statement-form"]', '.h2-statement-form-wrap'],
|
||||
fingerprint: 'form|statement',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'h2-site-period-end-balance',
|
||||
title: '期末余额设置',
|
||||
pageId: 'site-info',
|
||||
color: '#0891b2',
|
||||
aiPrompt: '期末余额锚点、重算预付余额与变更记录。',
|
||||
annotationText: '保存时按期末余额−支出+收入重算预付余额;变更记录只追加;期末时间不可晚于今天',
|
||||
locator: {
|
||||
selectors: ['[data-annotation-id="h2-site-period-end-balance"]'],
|
||||
fingerprint: 'modal|period-end-balance',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const NODE_PRD_MAP = {
|
||||
@@ -106,10 +121,26 @@ const NODE_PRD_MAP = {
|
||||
'h2-site-table': { file: 'list', heading: '## 5. 列表字段说明' },
|
||||
'h2-site-price-cost-input': { file: 'full', heading: '### 5.2 价格配置' },
|
||||
'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': {
|
||||
file: 'periodEnd',
|
||||
heading: '## 1. 背景与目标',
|
||||
combine: [
|
||||
'## 1. 背景与目标',
|
||||
'## 2. 已确认决策',
|
||||
'## 3. 入口与交互',
|
||||
'## 5. 重算逻辑(保存时)',
|
||||
'## 6. 余额下钻联动',
|
||||
'## 10. 验收清单',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const prdByKey = { list: listPrd, statement: statementPrd, full: fullPrd };
|
||||
const prdByKey = { list: listPrd, statement: statementPrd, full: fullPrd, periodEnd: periodEndPrd };
|
||||
|
||||
function extractSection(md, startHeading) {
|
||||
const lines = md.split('\n');
|
||||
@@ -276,6 +307,27 @@ const source = {
|
||||
markdown: statementPrd,
|
||||
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',
|
||||
id: 'h2-site-doc-prd-period-end',
|
||||
title: '期末余额设置',
|
||||
markdown: periodEndPrd,
|
||||
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',
|
||||
id: 'h2-site-doc-scope',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 加氢站数量统计 — 产品需求文档(PRD)
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 文档版本 | v1.0 |
|
||||
| 确认日期 | 2026-07-13 |
|
||||
| 模块名称 | 加氢站管理 → 加氢站数量统计 |
|
||||
| 所属系统 | ONE-OS Web 端 |
|
||||
| 交互原型 | `/prototypes/oneos-web-h2-station-stats` |
|
||||
| 数据来源 | 加氢站管理 → 站点信息台账 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
业务管理中心每周通过邮件手工汇总全国加氢站数量(总数、签约/普通、本周新增明细)。本模块将该流程系统化,支持按周筛选、同比/环比、图表与 PDF 报告导出。
|
||||
|
||||
## 2. 已确认决策
|
||||
|
||||
| 决策 | 结论 |
|
||||
|---|---|
|
||||
| 菜单位置 | 加氢站管理 → 加氢站数量统计 |
|
||||
| 明细范围 | 两个区块:汇总明细(全部)+ 新增明细(周期内) |
|
||||
| 停止营业 | 计入总数 |
|
||||
| 设计基底 | 与站点信息一致的 vm 布局 |
|
||||
|
||||
## 3. 页面结构
|
||||
|
||||
1. 筛选区(统计周、省/市、签约状态、导出 PDF)
|
||||
2. KPI 四卡(总数、签约、普通、本周新增 + 同比/环比)
|
||||
3. 文字摘要条
|
||||
4. 图表四宫格(占比、近 8 周趋势、省份 TOP10、本周新增构成)
|
||||
5. 新增明细表
|
||||
6. 汇总明细表(可导出 CSV)
|
||||
|
||||
## 4. 统计口径
|
||||
|
||||
- **截止点**:所选周周日 23:59:59 的台账快照
|
||||
- **本周新增**:`createTime` 落在该周周一至周日
|
||||
- **同比**:与去年同期同周对比
|
||||
- **环比**:与上一周对比
|
||||
|
||||
## 5. 验收重点
|
||||
|
||||
- [ ] 默认当前周,起止日期为周一至周日
|
||||
- [ ] 签约 + 普通 = 总数;停止营业计入
|
||||
- [ ] 切换周后 KPI / 图表 / 双表联动
|
||||
- [ ] 无新增时显示「本周暂无新增站点」
|
||||
- [ ] PDF 导出含 KPI、摘要、新增/汇总明细
|
||||
122
src/prototypes/oneos-web-h2-station-stats/annotation-source.json
Normal file
122
src/prototypes/oneos-web-h2-station-stats/annotation-source.json
Normal file
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"documentVersion": 1,
|
||||
"format": "axhub-annotation-source",
|
||||
"data": {
|
||||
"version": 2,
|
||||
"prototypeName": "oneos-web-h2-station-stats",
|
||||
"pageId": "h2-station-stats",
|
||||
"updatedAt": 1784000000000,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "h2s-filter",
|
||||
"index": 1,
|
||||
"title": "筛选条件",
|
||||
"pageId": "h2-station-stats",
|
||||
"locator": {
|
||||
"selectors": [".h2s-filter-card", "[data-annotation-id=\"h2s-filter\"]"],
|
||||
"fingerprint": "filter|h2-station-stats",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "按周筛选、地区与签约状态;切换周后自动刷新;支持导出 PDF。",
|
||||
"annotationText": "统计周期(按周)、省份、城市、签约状态;重置/查询;导出 PDF 分析报告。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
"createdAt": 1784000000000,
|
||||
"updatedAt": 1784000000000,
|
||||
"controls": []
|
||||
},
|
||||
{
|
||||
"id": "h2s-kpi",
|
||||
"index": 2,
|
||||
"title": "KPI 汇总",
|
||||
"pageId": "h2-station-stats",
|
||||
"locator": {
|
||||
"selectors": [".h2s-kpi-row", "[data-annotation-id=\"h2s-kpi\"]"],
|
||||
"fingerprint": "kpi|h2-station-stats",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "全国总数、签约、普通、本周新增;含同比/环比。",
|
||||
"annotationText": "四张 KPI 卡:截止周日的台账快照 + 本周新增;卡片 ? 说明口径。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
"createdAt": 1784000000000,
|
||||
"updatedAt": 1784000000000,
|
||||
"controls": []
|
||||
},
|
||||
{
|
||||
"id": "h2s-charts",
|
||||
"index": 3,
|
||||
"title": "统计图表",
|
||||
"pageId": "h2-station-stats",
|
||||
"locator": {
|
||||
"selectors": [".h2s-charts", "[data-annotation-id=\"h2s-charts\"]"],
|
||||
"fingerprint": "charts|h2-station-stats",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "签约占比、近8周趋势、省份TOP10、本周新增构成。",
|
||||
"annotationText": "四宫格图表区,随筛选联动。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#059669",
|
||||
"images": [],
|
||||
"createdAt": 1784000000000,
|
||||
"updatedAt": 1784000000000,
|
||||
"controls": []
|
||||
},
|
||||
{
|
||||
"id": "h2s-table-new",
|
||||
"index": 4,
|
||||
"title": "新增明细",
|
||||
"pageId": "h2-station-stats",
|
||||
"locator": {
|
||||
"selectors": ["[data-annotation-id=\"h2s-table-new\"]"],
|
||||
"fingerprint": "table-new|h2-station-stats",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "所选周期内新建站点;空态「本周暂无新增站点」。",
|
||||
"annotationText": "序号、省份、城市、站点名称、签约状态、建档时间。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#d97706",
|
||||
"images": [],
|
||||
"createdAt": 1784000000000,
|
||||
"updatedAt": 1784000000000,
|
||||
"controls": []
|
||||
},
|
||||
{
|
||||
"id": "h2s-table-all",
|
||||
"index": 5,
|
||||
"title": "汇总明细",
|
||||
"pageId": "h2-station-stats",
|
||||
"locator": {
|
||||
"selectors": ["[data-annotation-id=\"h2s-table-all\"]"],
|
||||
"fingerprint": "table-all|h2-station-stats",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "截止所选周日的全部站点台账;支持 CSV 导出。",
|
||||
"annotationText": "含营业状态列;导出 CSV。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#d97706",
|
||||
"images": [],
|
||||
"createdAt": 1784000000000,
|
||||
"updatedAt": 1784000000000,
|
||||
"controls": []
|
||||
}
|
||||
],
|
||||
"directory": [
|
||||
{
|
||||
"id": "h2s-prd-root",
|
||||
"title": "产品需求",
|
||||
"type": "folder",
|
||||
"children": [
|
||||
{
|
||||
"id": "h2s-prd-main",
|
||||
"title": "加氢站数量统计 PRD",
|
||||
"type": "markdown",
|
||||
"markdownPath": "src/prototypes/oneos-web-h2-station-stats/.spec/requirements-prd.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { FileDown, RotateCcw, Search } from 'lucide-react';
|
||||
import type { H2StationRecord, StatsFilters } from '../types';
|
||||
import { buildRegionOptions } from '../utils/stats';
|
||||
import { buildRecentWeekOptions, type WeekRange } from '../utils/week';
|
||||
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
|
||||
|
||||
const SIGNED_OPTIONS = ['全部', '签约站点', '普通站点'] as const;
|
||||
|
||||
const SIGNED_LABEL_TO_VALUE: Record<(typeof SIGNED_OPTIONS)[number], StatsFilters['signed']> = {
|
||||
全部: '',
|
||||
签约站点: 'yes',
|
||||
普通站点: 'no',
|
||||
};
|
||||
|
||||
const SIGNED_VALUE_TO_LABEL: Record<StatsFilters['signed'], (typeof SIGNED_OPTIONS)[number]> = {
|
||||
'': '全部',
|
||||
yes: '签约站点',
|
||||
no: '普通站点',
|
||||
};
|
||||
|
||||
interface FilterPanelProps {
|
||||
records: H2StationRecord[];
|
||||
filters: StatsFilters;
|
||||
onChange: (patch: Partial<StatsFilters>) => void;
|
||||
onReset: () => void;
|
||||
onSearch: () => void;
|
||||
onExportPdf: () => void;
|
||||
}
|
||||
|
||||
export function FilterPanel({
|
||||
records,
|
||||
filters,
|
||||
onChange,
|
||||
onReset,
|
||||
onSearch,
|
||||
onExportPdf,
|
||||
}: FilterPanelProps) {
|
||||
const weekOptions = useMemo(() => buildRecentWeekOptions(20), []);
|
||||
const { provinces, citiesByProvince } = useMemo(() => buildRegionOptions(records), [records]);
|
||||
|
||||
const weekOptionLabels = weekOptions.map((w: WeekRange) => w.label);
|
||||
const weekValue = weekOptions.find((w) => w.year === filters.year && w.week === filters.week)?.label
|
||||
?? weekOptionLabels[0]
|
||||
?? '';
|
||||
|
||||
const activeChips = [
|
||||
filters.province ? `省份:${filters.province}` : null,
|
||||
filters.city ? `城市:${filters.city}` : null,
|
||||
filters.signed === 'yes' ? '签约站点' : filters.signed === 'no' ? '普通站点' : null,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<section className="vm-filter-card h2s-filter-card" data-annotation-id="h2s-filter" aria-label="筛选条件">
|
||||
<div className="vm-filter-head">
|
||||
<div className="h2s-filter-head-main">
|
||||
<h2 className="vm-filter-title">筛选条件</h2>
|
||||
<p className="h2s-filter-hint">切换统计周后自动刷新;地区与签约状态需点击查询</p>
|
||||
</div>
|
||||
<div className="h2s-filter-actions-top">
|
||||
<button type="button" className="vm-btn vm-btn--primary h2s-export-btn" onClick={onExportPdf}>
|
||||
<FileDown size={15} aria-hidden />
|
||||
导出 PDF 分析报告
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vm-filter-grid h2s-filter-grid">
|
||||
<label className="vm-filter-field">
|
||||
<span>统计周期(按周)</span>
|
||||
<FilterPickerField
|
||||
value={weekValue}
|
||||
options={weekOptionLabels}
|
||||
placeholder="选择统计周"
|
||||
ariaLabel="统计周期"
|
||||
onChange={(label) => {
|
||||
const matched = weekOptions.find((w) => w.label === label);
|
||||
if (!matched) return;
|
||||
onChange({ year: matched.year, week: matched.week });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="vm-filter-field">
|
||||
<span>省份</span>
|
||||
<FilterPickerField
|
||||
value={filters.province}
|
||||
options={provinces}
|
||||
placeholder="全部省份"
|
||||
ariaLabel="省份"
|
||||
onChange={(province) => onChange({ province, city: '' })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="vm-filter-field">
|
||||
<span>城市</span>
|
||||
<FilterPickerField
|
||||
value={filters.city}
|
||||
options={filters.province ? citiesByProvince[filters.province] ?? [] : []}
|
||||
placeholder={filters.province ? '全部城市' : '请先选择省份'}
|
||||
ariaLabel="城市"
|
||||
onChange={(city) => onChange({ city })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="vm-filter-field">
|
||||
<span>签约状态</span>
|
||||
<FilterPickerField
|
||||
value={SIGNED_VALUE_TO_LABEL[filters.signed]}
|
||||
options={[...SIGNED_OPTIONS]}
|
||||
placeholder="全部"
|
||||
ariaLabel="签约状态"
|
||||
onChange={(label) => {
|
||||
if (!label) {
|
||||
onChange({ signed: '' });
|
||||
return;
|
||||
}
|
||||
const signed = SIGNED_LABEL_TO_VALUE[label as (typeof SIGNED_OPTIONS)[number]] ?? '';
|
||||
onChange({ signed });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{activeChips.length > 0 ? (
|
||||
<div className="h2s-active-filters" aria-label="已选筛选">
|
||||
{activeChips.map((chip) => (
|
||||
<span key={chip} className="h2s-filter-chip">{chip}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="vm-filter-foot">
|
||||
<button type="button" className="vm-btn vm-btn--ghost" onClick={onReset}>
|
||||
<RotateCcw size={15} aria-hidden />
|
||||
重置
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn--primary" onClick={onSearch}>
|
||||
<Search size={15} aria-hidden />
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { Download, Inbox } from 'lucide-react';
|
||||
import type { H2StationRecord } from '../types';
|
||||
import { formatDateTime } from '../utils/week';
|
||||
|
||||
interface StationDetailTableProps {
|
||||
title: string;
|
||||
rows: H2StationRecord[];
|
||||
mode: 'all' | 'new';
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
onExportCsv?: () => void;
|
||||
annotationId?: string;
|
||||
}
|
||||
|
||||
function statusClass(status: H2StationRecord['businessStatus']): string {
|
||||
if (status === '营业中') return 'h2s-status h2s-status--open';
|
||||
if (status === '暂停营业') return 'h2s-status h2s-status--pause';
|
||||
return 'h2s-status h2s-status--stop';
|
||||
}
|
||||
|
||||
export function StationDetailTable({
|
||||
title,
|
||||
rows,
|
||||
mode,
|
||||
page,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onExportCsv,
|
||||
annotationId,
|
||||
}: StationDetailTableProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const start = (safePage - 1) * pageSize;
|
||||
const paged = rows.slice(start, start + pageSize);
|
||||
|
||||
return (
|
||||
<section className="vm-table-section h2s-table-section" data-annotation-id={annotationId}>
|
||||
<div className="vm-table-toolbar h2s-table-toolbar">
|
||||
<div className="h2s-table-head">
|
||||
<h3 className="h2s-table-title">{title}</h3>
|
||||
<p className="h2s-table-subtitle">
|
||||
共 <span className="tabular-nums">{rows.length}</span> 座
|
||||
{mode === 'new' ? ' · 周期内新建' : ' · 截止周日台账'}
|
||||
</p>
|
||||
</div>
|
||||
{onExportCsv ? (
|
||||
<button type="button" className="vm-btn vm-btn--ghost h2s-table-export" onClick={onExportCsv}>
|
||||
<Download size={15} aria-hidden />
|
||||
导出 CSV
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card h2s-table-card">
|
||||
{rows.length === 0 && mode === 'new' ? (
|
||||
<div className="h2s-empty" role="status">
|
||||
<span className="h2s-empty-icon" aria-hidden>
|
||||
<Inbox size={28} strokeWidth={1.75} />
|
||||
</span>
|
||||
<p className="h2s-empty-title">本周暂无新增站点</p>
|
||||
<p className="h2s-empty-desc">与邮件周报「本周暂无新增」口径一致,可切换其他统计周查看历史新增。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h2s-table-wrap">
|
||||
<table className="h2s-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">序号</th>
|
||||
<th scope="col">所在省份</th>
|
||||
<th scope="col">所在城市</th>
|
||||
<th scope="col">站点名称</th>
|
||||
<th scope="col">签约状态</th>
|
||||
{mode === 'all' ? <th scope="col">营业状态</th> : null}
|
||||
<th scope="col">建档时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((row, index) => (
|
||||
<tr key={row.id}>
|
||||
<td className="tabular-nums h2s-cell-muted">{start + index + 1}</td>
|
||||
<td>{row.province}</td>
|
||||
<td>{row.city}</td>
|
||||
<td className="h2s-cell-name">
|
||||
<span className="h2s-station-name">{row.name}</span>
|
||||
{row.isSigned ? <span className="h2s-tag h2s-tag--signed">签约</span> : null}
|
||||
</td>
|
||||
<td>
|
||||
<span className={row.isSigned ? 'h2s-tag h2s-tag--signed-soft' : 'h2s-tag h2s-tag--ordinary-soft'}>
|
||||
{row.isSigned ? '签约站点' : '普通站点'}
|
||||
</span>
|
||||
</td>
|
||||
{mode === 'all' ? (
|
||||
<td><span className={statusClass(row.businessStatus)}>{row.businessStatus}</span></td>
|
||||
) : null}
|
||||
<td className="tabular-nums h2s-cell-time">{formatDateTime(row.createTime)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length > 0 ? (
|
||||
<div className="h2s-pagination">
|
||||
<label className="h2s-pagination-size">
|
||||
每页
|
||||
<select
|
||||
value={pageSize}
|
||||
aria-label="每页条数"
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
>
|
||||
{[10, 20, 50].map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
条
|
||||
</label>
|
||||
<div className="h2s-pagination-btns">
|
||||
<button type="button" className="h2s-page-btn" disabled={safePage <= 1} onClick={() => onPageChange(safePage - 1)}>上一页</button>
|
||||
<span className="h2s-page-indicator tabular-nums">{safePage} / {totalPages}</span>
|
||||
<button type="button" className="h2s-page-btn" disabled={safePage >= totalPages} onClick={() => onPageChange(safePage + 1)}>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { ChartData, WeekTrendPoint } from '../types';
|
||||
|
||||
interface StatsChartsProps {
|
||||
chart: ChartData;
|
||||
}
|
||||
|
||||
interface TooltipState {
|
||||
x: number;
|
||||
y: number;
|
||||
title: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
function ChartTooltip({ tip }: { tip: TooltipState | null }) {
|
||||
if (!tip) return null;
|
||||
return (
|
||||
<div
|
||||
className="h2s-chart-tooltip"
|
||||
style={{ left: tip.x, top: tip.y }}
|
||||
role="tooltip"
|
||||
>
|
||||
<p className="h2s-chart-tooltip-title">{tip.title}</p>
|
||||
{tip.lines.map((line) => (
|
||||
<p key={line} className="h2s-chart-tooltip-line">{line}</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pct(part: number, total: number): string {
|
||||
if (total <= 0) return '0.0%';
|
||||
return `${((part / total) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function DonutChart({ signed, ordinary }: { signed: number; ordinary: number }) {
|
||||
const total = signed + ordinary;
|
||||
const signedPct = total > 0 ? signed / total : 0;
|
||||
const radius = 58;
|
||||
const stroke = 16;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const signedLen = circumference * signedPct;
|
||||
const ordinaryLen = circumference - signedLen;
|
||||
|
||||
const items = [
|
||||
{ key: 'signed', label: '签约站点', value: signed, color: '#10b981' },
|
||||
{ key: 'ordinary', label: '普通站点', value: ordinary, color: '#64748b' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h2s-chart h2s-chart--donut">
|
||||
<div className="h2s-donut-visual">
|
||||
<svg viewBox="0 0 160 160" preserveAspectRatio="xMidYMid meet" role="img" aria-label={`签约 ${signed} 座占 ${pct(signed, total)},普通 ${ordinary} 座占 ${pct(ordinary, total)}`}>
|
||||
<circle cx="80" cy="80" r={radius} fill="none" stroke="#e2e8f0" strokeWidth={stroke} />
|
||||
<circle
|
||||
cx="80"
|
||||
cy="80"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${signedLen} ${circumference}`}
|
||||
transform="rotate(-90 80 80)"
|
||||
className="h2s-donut-segment"
|
||||
/>
|
||||
<circle
|
||||
cx="80"
|
||||
cy="80"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#64748b"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${ordinaryLen} ${circumference}`}
|
||||
strokeDashoffset={-signedLen}
|
||||
transform="rotate(-90 80 80)"
|
||||
className="h2s-donut-segment h2s-donut-segment--delay"
|
||||
/>
|
||||
<text x="80" y="74" textAnchor="middle" className="h2s-donut-total">{total}</text>
|
||||
<text x="80" y="92" textAnchor="middle" className="h2s-donut-sub">站点总计</text>
|
||||
</svg>
|
||||
</div>
|
||||
<ul className="h2s-donut-legend">
|
||||
{items.map((item) => (
|
||||
<li key={item.key} className="h2s-donut-legend-item">
|
||||
<span className="h2s-donut-legend-dot" style={{ background: item.color }} aria-hidden />
|
||||
<span className="h2s-donut-legend-label">{item.label}</span>
|
||||
<span className="h2s-donut-legend-val tabular-nums">{item.value}</span>
|
||||
<span className="h2s-donut-legend-pct tabular-nums">{pct(item.value, total)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendChart({ chart }: { chart: ChartData }) {
|
||||
const [tip, setTip] = useState<TooltipState | null>(null);
|
||||
const max = Math.max(1, ...chart.weekTrend.map((p) => p.totalNew));
|
||||
const width = 640;
|
||||
const height = 200;
|
||||
const pad = { l: 36, r: 20, t: 18, b: 34 };
|
||||
const innerW = width - pad.l - pad.r;
|
||||
const innerH = height - pad.t - pad.b;
|
||||
const count = chart.weekTrend.length;
|
||||
const barGroupW = innerW / count;
|
||||
const barW = Math.min(28, barGroupW * 0.42);
|
||||
|
||||
const showTip = (event: React.MouseEvent, point: WeekTrendPoint) => {
|
||||
const host = (event.currentTarget as HTMLElement).closest('.h2s-chart--trend');
|
||||
if (!host) return;
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
const targetRect = event.currentTarget.getBoundingClientRect();
|
||||
setTip({
|
||||
x: targetRect.left - hostRect.left + targetRect.width / 2,
|
||||
y: targetRect.top - hostRect.top - 8,
|
||||
title: `${point.year} 年第 ${point.week} 周`,
|
||||
lines: [
|
||||
`合计新增 ${point.totalNew} 座`,
|
||||
`签约 ${point.signedNew} 座 · 普通 ${point.ordinaryNew} 座`,
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const linePoints = chart.weekTrend.map((p, i) => {
|
||||
const cx = pad.l + barGroupW * i + barGroupW / 2;
|
||||
const cy = pad.t + innerH - (p.totalNew / max) * innerH;
|
||||
return { cx, cy, ...p };
|
||||
});
|
||||
const linePath = linePoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.cx} ${p.cy}`).join(' ');
|
||||
const areaPath = `${linePath} L ${linePoints[linePoints.length - 1]?.cx ?? pad.l} ${pad.t + innerH} L ${linePoints[0]?.cx ?? pad.l} ${pad.t + innerH} Z`;
|
||||
|
||||
return (
|
||||
<div className="h2s-chart h2s-chart--trend">
|
||||
<ChartTooltip tip={tip} />
|
||||
<div className="h2s-chart-svg-wrap">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
className="h2s-chart-svg"
|
||||
role="img"
|
||||
aria-label="近 8 周新增趋势"
|
||||
onMouseLeave={() => setTip(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="h2s-trend-area" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#2563eb" stopOpacity="0.22" />
|
||||
<stop offset="100%" stopColor="#2563eb" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const y = pad.t + innerH * (1 - ratio);
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line x1={pad.l} y1={y} x2={width - pad.r} y2={y} className="h2s-grid-line" />
|
||||
<text x={8} y={y + 4} className="h2s-axis-label">{Math.round(max * ratio)}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<path d={areaPath} fill="url(#h2s-trend-area)" />
|
||||
<path d={linePath} fill="none" className="h2s-trend-line" strokeWidth="2.5" />
|
||||
{chart.weekTrend.map((p, i) => {
|
||||
const cx = pad.l + barGroupW * i + barGroupW / 2;
|
||||
const signedH = (p.signedNew / max) * innerH;
|
||||
const ordinaryH = (p.ordinaryNew / max) * innerH;
|
||||
const baseY = pad.t + innerH;
|
||||
const stackTop = baseY - signedH - ordinaryH;
|
||||
return (
|
||||
<g key={`${p.year}-${p.week}`}>
|
||||
{ordinaryH > 0 ? (
|
||||
<rect
|
||||
x={cx - barW / 2}
|
||||
y={stackTop}
|
||||
width={barW}
|
||||
height={ordinaryH}
|
||||
rx={3}
|
||||
className="h2s-bar h2s-bar--ordinary"
|
||||
onMouseEnter={(e) => showTip(e, p)}
|
||||
/>
|
||||
) : null}
|
||||
{signedH > 0 ? (
|
||||
<rect
|
||||
x={cx - barW / 2}
|
||||
y={baseY - signedH}
|
||||
width={barW}
|
||||
height={signedH}
|
||||
rx={3}
|
||||
className="h2s-bar h2s-bar--signed"
|
||||
onMouseEnter={(e) => showTip(e, p)}
|
||||
/>
|
||||
) : null}
|
||||
{p.totalNew > 0 ? (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={pad.t + innerH - (p.totalNew / max) * innerH}
|
||||
r={4.5}
|
||||
className="h2s-trend-dot"
|
||||
onMouseEnter={(e) => showTip(e, p)}
|
||||
/>
|
||||
) : null}
|
||||
<text x={cx} y={height - 12} textAnchor="middle" className="h2s-axis-label">{p.label}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
<div className="h2s-chart-legend">
|
||||
<span><i className="h2s-dot h2s-dot--signed" aria-hidden />签约新增</span>
|
||||
<span><i className="h2s-dot h2s-dot--ordinary" aria-hidden />普通新增</span>
|
||||
<span><i className="h2s-dot h2s-dot--line" aria-hidden />合计趋势线</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProvinceBarChart({ items }: { items: ChartData['provinceTop10'] }) {
|
||||
const max = Math.max(1, ...items.map((i) => i.count));
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="h2s-chart-empty-wrap">
|
||||
<p className="h2s-chart-empty">暂无省份分布数据</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="h2s-chart h2s-chart--province" role="list" aria-label="各省站点分布 TOP10">
|
||||
{items.map((item, index) => (
|
||||
<div key={item.province} className="h2s-province-row" role="listitem">
|
||||
<span className={['h2s-province-rank', index < 3 ? 'h2s-province-rank--top' : ''].filter(Boolean).join(' ')}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="h2s-province-name" title={item.province}>
|
||||
{item.province.replace(/省|市|自治区/g, '')}
|
||||
</span>
|
||||
<div className="h2s-province-track" aria-hidden>
|
||||
<div
|
||||
className="h2s-province-bar"
|
||||
style={{ width: `${(item.count / max) * 100}%`, transitionDelay: `${index * 30}ms` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="h2s-province-val tabular-nums">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewWeekChart({ signed, ordinary }: { signed: number; ordinary: number }) {
|
||||
const total = signed + ordinary;
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="h2s-newweek-empty">
|
||||
<div className="h2s-newweek-empty-icon" aria-hidden />
|
||||
<p className="h2s-newweek-empty-title">本周暂无新增</p>
|
||||
<p className="h2s-newweek-empty-desc">切换其他统计周可查看历史新增构成</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const signedPct = (signed / total) * 100;
|
||||
return (
|
||||
<div className="h2s-chart h2s-chart--newweek">
|
||||
<div className="h2s-newweek-stats">
|
||||
<div className="h2s-newweek-stat h2s-newweek-stat--signed">
|
||||
<span className="h2s-newweek-stat-label">签约新增</span>
|
||||
<span className="h2s-newweek-stat-val tabular-nums">{signed}</span>
|
||||
<span className="h2s-newweek-stat-pct tabular-nums">{signedPct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h2s-newweek-stat h2s-newweek-stat--ordinary">
|
||||
<span className="h2s-newweek-stat-label">普通新增</span>
|
||||
<span className="h2s-newweek-stat-val tabular-nums">{ordinary}</span>
|
||||
<span className="h2s-newweek-stat-pct tabular-nums">{(100 - signedPct).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h2s-newweek-bar" role="img" aria-label={`本周新增签约 ${signed} 座,普通 ${ordinary} 座`}>
|
||||
<div className="h2s-newweek-bar-signed" style={{ width: `${signedPct}%` }}>
|
||||
{signedPct >= 18 ? <span className="tabular-nums">{signed}</span> : null}
|
||||
</div>
|
||||
<div className="h2s-newweek-bar-ordinary" style={{ width: `${100 - signedPct}%` }}>
|
||||
{(100 - signedPct) >= 18 ? <span className="tabular-nums">{ordinary}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="h2s-newweek-caption">
|
||||
本周新增合计 <strong className="tabular-nums">{total}</strong> 座
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsCharts({ chart }: StatsChartsProps) {
|
||||
const panels = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'donut',
|
||||
title: '签约 vs 普通占比',
|
||||
subtitle: '截止所选周日的站点结构',
|
||||
layout: 'half' as const,
|
||||
node: <DonutChart signed={chart.signedCount} ordinary={chart.ordinaryCount} />,
|
||||
},
|
||||
{
|
||||
key: 'newweek',
|
||||
title: '本周新增构成',
|
||||
subtitle: '签约与普通新增对比',
|
||||
layout: 'half' as const,
|
||||
node: <NewWeekChart signed={chart.newSignedThisWeek} ordinary={chart.newOrdinaryThisWeek} />,
|
||||
},
|
||||
{
|
||||
key: 'trend',
|
||||
title: '近 8 周新增趋势',
|
||||
subtitle: '堆叠柱 + 合计折线',
|
||||
layout: 'full' as const,
|
||||
node: <TrendChart chart={chart} />,
|
||||
},
|
||||
{
|
||||
key: 'province',
|
||||
title: '各省站点分布 TOP10',
|
||||
subtitle: '按省份汇总站点数',
|
||||
layout: 'full' as const,
|
||||
node: <ProvinceBarChart items={chart.provinceTop10} />,
|
||||
},
|
||||
],
|
||||
[chart],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="h2s-analytics" data-annotation-id="h2s-charts" aria-label="统计图表">
|
||||
<header className="h2s-section-head">
|
||||
<h2 className="h2s-section-title">数据分析</h2>
|
||||
<p className="h2s-section-desc">图表随筛选条件联动更新;悬停可查看明细数值</p>
|
||||
</header>
|
||||
<div className="h2s-charts">
|
||||
{panels.map((panel) => (
|
||||
<article
|
||||
key={panel.key}
|
||||
className={[
|
||||
'h2s-chart-card',
|
||||
panel.layout === 'full' ? 'h2s-chart-card--full' : 'h2s-chart-card--half',
|
||||
].join(' ')}
|
||||
>
|
||||
<header className="h2s-chart-head">
|
||||
<h3 className="h2s-chart-title">{panel.title}</h3>
|
||||
<p className="h2s-chart-subtitle">{panel.subtitle}</p>
|
||||
</header>
|
||||
<div className="h2s-chart-body">
|
||||
{panel.node}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { Building2, CircleHelp, Handshake, MapPin, Minus, Sparkles, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import type { StatsKpiSummary } from '../types';
|
||||
import { compareTone, formatComparePercent } from '../utils/stats';
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
key: 'total' as const,
|
||||
title: '全国加氢站总数',
|
||||
desc: '截止所选周周日 23:59:59,台账中全部站点(含停止营业)',
|
||||
icon: Building2,
|
||||
tone: 'total',
|
||||
},
|
||||
{
|
||||
key: 'signed' as const,
|
||||
title: '签约站点',
|
||||
desc: '截止所选周周日,已签约(isSigned = true)的站点数',
|
||||
icon: Handshake,
|
||||
tone: 'signed',
|
||||
},
|
||||
{
|
||||
key: 'ordinary' as const,
|
||||
title: '普通站点',
|
||||
desc: '截止所选周周日,未签约(isSigned = false)的站点数',
|
||||
icon: MapPin,
|
||||
tone: 'ordinary',
|
||||
},
|
||||
{
|
||||
key: 'newThisWeek' as const,
|
||||
title: '本周新增',
|
||||
desc: '建档时间在所选周周一至周日内的站点数',
|
||||
icon: Sparkles,
|
||||
tone: 'new',
|
||||
},
|
||||
];
|
||||
|
||||
interface StatsKpiRowProps {
|
||||
summary: StatsKpiSummary;
|
||||
}
|
||||
|
||||
function CompareBadge({ label, value }: { label: string; value: number | null }) {
|
||||
const tone = compareTone(value);
|
||||
const Icon = tone === 'up' ? TrendingUp : tone === 'down' ? TrendingDown : Minus;
|
||||
return (
|
||||
<span className={['h2s-kpi-badge', `h2s-kpi-badge--${tone}`].join(' ')}>
|
||||
<Icon size={11} aria-hidden strokeWidth={2.5} />
|
||||
<span className="h2s-kpi-badge-label">{label}</span>
|
||||
<span className="h2s-kpi-badge-val tabular-nums">{formatComparePercent(value)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsKpiRow({ summary }: StatsKpiRowProps) {
|
||||
return (
|
||||
<div className="h2s-kpi-row" data-annotation-id="h2s-kpi" aria-label="加氢站数量统计指标">
|
||||
{CARDS.map((card) => {
|
||||
const metric = summary[card.key];
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<article key={card.key} className={['h2s-kpi-card', `h2s-kpi-card--${card.tone}`].join(' ')}>
|
||||
<div className="h2s-kpi-card-accent" aria-hidden />
|
||||
<div className="h2s-kpi-card-body">
|
||||
<div className="h2s-kpi-card-head">
|
||||
<span className="h2s-kpi-icon" aria-hidden>
|
||||
<Icon size={16} strokeWidth={2.25} />
|
||||
</span>
|
||||
<span className="h2s-kpi-label">{card.title}</span>
|
||||
<span className="h2s-kpi-tip" tabIndex={0} aria-label={`${card.title}说明`}>
|
||||
<CircleHelp size={13} aria-hidden />
|
||||
<span className="h2s-kpi-tooltip" role="tooltip">{card.desc}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="h2s-kpi-val tabular-nums">{metric.value}</p>
|
||||
<div className="h2s-kpi-badges">
|
||||
<CompareBadge label="环比" value={metric.mom} />
|
||||
<CompareBadge label="同比" value={metric.yoy} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { CalendarRange, TrendingUp } from 'lucide-react';
|
||||
import type { ChartData, StatsKpiSummary } from '../types';
|
||||
|
||||
interface SummaryInsightProps {
|
||||
weekLabel: string;
|
||||
kpi: StatsKpiSummary;
|
||||
chart: ChartData;
|
||||
}
|
||||
|
||||
function Metric({ value, label }: { value: number; label: string }) {
|
||||
return (
|
||||
<span className="h2s-insight-metric">
|
||||
<strong className="tabular-nums">{value}</strong>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SummaryInsight({ weekLabel, kpi, chart }: SummaryInsightProps) {
|
||||
const hasNew = kpi.newThisWeek.value > 0;
|
||||
const signedPct = kpi.total.value > 0
|
||||
? ((kpi.signed.value / kpi.total.value) * 100).toFixed(1)
|
||||
: '0.0';
|
||||
|
||||
return (
|
||||
<section className="h2s-insight" aria-label="周期统计摘要">
|
||||
<div className="h2s-insight-icon" aria-hidden>
|
||||
{hasNew ? <TrendingUp size={18} strokeWidth={2.25} /> : <CalendarRange size={18} strokeWidth={2.25} />}
|
||||
</div>
|
||||
<div className="h2s-insight-body">
|
||||
<p className="h2s-insight-period">{weekLabel}</p>
|
||||
{hasNew ? (
|
||||
<p className="h2s-insight-text">
|
||||
本周新增
|
||||
{' '}
|
||||
<Metric value={kpi.newThisWeek.value} label="座" />
|
||||
(签约 <Metric value={chart.newSignedThisWeek} label="座" />,普通 <Metric value={chart.newOrdinaryThisWeek} label="座" />)。
|
||||
截止本周日,全国台账共 <Metric value={kpi.total.value} label="座" />
|
||||
,签约占比 <strong className="tabular-nums">{signedPct}%</strong>。
|
||||
</p>
|
||||
) : (
|
||||
<p className="h2s-insight-text">
|
||||
<span className="h2s-insight-muted">本周暂无新增站点。</span>
|
||||
{' '}
|
||||
截止本周日,全国台账共 <Metric value={kpi.total.value} label="座" />
|
||||
(签约 <Metric value={kpi.signed.value} label="座" />,普通 <Metric value={kpi.ordinary.value} label="座" />)。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
938
src/prototypes/oneos-web-h2-station-stats/data/stations.json
Normal file
938
src/prototypes/oneos-web-h2-station-stats/data/stations.json
Normal file
@@ -0,0 +1,938 @@
|
||||
[
|
||||
{
|
||||
"id": "h2s-1",
|
||||
"name": "成都加氢站1号",
|
||||
"province": "四川省",
|
||||
"city": "成都市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2024-01-01 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-2",
|
||||
"name": "成都加氢站2号",
|
||||
"province": "四川省",
|
||||
"city": "成都市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-02-03 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-3",
|
||||
"name": "成都加氢站3号",
|
||||
"province": "四川省",
|
||||
"city": "成都市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-03-05 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-4",
|
||||
"name": "德阳加氢站1号",
|
||||
"province": "四川省",
|
||||
"city": "德阳市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2024-02-02 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-5",
|
||||
"name": "德阳加氢站2号",
|
||||
"province": "四川省",
|
||||
"city": "德阳市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-03-04 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-6",
|
||||
"name": "德阳加氢站3号",
|
||||
"province": "四川省",
|
||||
"city": "德阳市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "暂停营业",
|
||||
"createTime": "2025-04-06 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-7",
|
||||
"name": "绵阳加氢站1号",
|
||||
"province": "四川省",
|
||||
"city": "绵阳市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2024-03-03 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-8",
|
||||
"name": "绵阳加氢站2号",
|
||||
"province": "四川省",
|
||||
"city": "绵阳市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "暂停营业",
|
||||
"createTime": "2025-04-05 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-9",
|
||||
"name": "绵阳加氢站3号",
|
||||
"province": "四川省",
|
||||
"city": "绵阳市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "停止营业",
|
||||
"createTime": "2025-05-07 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-10",
|
||||
"name": "乐山加氢站1号",
|
||||
"province": "四川省",
|
||||
"city": "乐山市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "暂停营业",
|
||||
"createTime": "2024-04-04 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-11",
|
||||
"name": "乐山加氢站2号",
|
||||
"province": "四川省",
|
||||
"city": "乐山市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "停止营业",
|
||||
"createTime": "2025-05-06 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-12",
|
||||
"name": "乐山加氢站3号",
|
||||
"province": "四川省",
|
||||
"city": "乐山市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-06-08 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-13",
|
||||
"name": "杭州加氢站1号",
|
||||
"province": "浙江省",
|
||||
"city": "杭州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2024-02-02 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-14",
|
||||
"name": "杭州加氢站2号",
|
||||
"province": "浙江省",
|
||||
"city": "杭州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-03-04 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-15",
|
||||
"name": "杭州加氢站3号",
|
||||
"province": "浙江省",
|
||||
"city": "杭州市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "暂停营业",
|
||||
"createTime": "2025-04-06 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-16",
|
||||
"name": "嘉兴加氢站1号",
|
||||
"province": "浙江省",
|
||||
"city": "嘉兴市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2024-03-03 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-17",
|
||||
"name": "嘉兴加氢站2号",
|
||||
"province": "浙江省",
|
||||
"city": "嘉兴市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "暂停营业",
|
||||
"createTime": "2025-04-05 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-18",
|
||||
"name": "嘉兴加氢站3号",
|
||||
"province": "浙江省",
|
||||
"city": "嘉兴市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "停止营业",
|
||||
"createTime": "2025-05-07 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-19",
|
||||
"name": "宁波加氢站1号",
|
||||
"province": "浙江省",
|
||||
"city": "宁波市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "暂停营业",
|
||||
"createTime": "2024-04-04 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-20",
|
||||
"name": "宁波加氢站2号",
|
||||
"province": "浙江省",
|
||||
"city": "宁波市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "停止营业",
|
||||
"createTime": "2025-05-06 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-21",
|
||||
"name": "宁波加氢站3号",
|
||||
"province": "浙江省",
|
||||
"city": "宁波市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-06-08 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-22",
|
||||
"name": "温州加氢站1号",
|
||||
"province": "浙江省",
|
||||
"city": "温州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "停止营业",
|
||||
"createTime": "2024-05-05 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-23",
|
||||
"name": "温州加氢站2号",
|
||||
"province": "浙江省",
|
||||
"city": "温州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-06-07 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-24",
|
||||
"name": "温州加氢站3号",
|
||||
"province": "浙江省",
|
||||
"city": "温州市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-07-09 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-25",
|
||||
"name": "上海加氢站1号",
|
||||
"province": "上海市",
|
||||
"city": "上海市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2024-03-03 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-26",
|
||||
"name": "上海加氢站2号",
|
||||
"province": "上海市",
|
||||
"city": "上海市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "暂停营业",
|
||||
"createTime": "2025-04-05 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-27",
|
||||
"name": "上海加氢站3号",
|
||||
"province": "上海市",
|
||||
"city": "上海市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "停止营业",
|
||||
"createTime": "2025-05-07 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-28",
|
||||
"name": "南京加氢站1号",
|
||||
"province": "江苏省",
|
||||
"city": "南京市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "暂停营业",
|
||||
"createTime": "2024-04-04 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-29",
|
||||
"name": "南京加氢站2号",
|
||||
"province": "江苏省",
|
||||
"city": "南京市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "停止营业",
|
||||
"createTime": "2025-05-06 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-30",
|
||||
"name": "南京加氢站3号",
|
||||
"province": "江苏省",
|
||||
"city": "南京市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-06-08 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-31",
|
||||
"name": "苏州加氢站1号",
|
||||
"province": "江苏省",
|
||||
"city": "苏州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "停止营业",
|
||||
"createTime": "2024-05-05 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-32",
|
||||
"name": "苏州加氢站2号",
|
||||
"province": "江苏省",
|
||||
"city": "苏州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-06-07 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-33",
|
||||
"name": "苏州加氢站3号",
|
||||
"province": "江苏省",
|
||||
"city": "苏州市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-07-09 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-34",
|
||||
"name": "无锡加氢站1号",
|
||||
"province": "江苏省",
|
||||
"city": "无锡市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2024-06-06 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-35",
|
||||
"name": "无锡加氢站2号",
|
||||
"province": "江苏省",
|
||||
"city": "无锡市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-07-08 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-36",
|
||||
"name": "无锡加氢站3号",
|
||||
"province": "江苏省",
|
||||
"city": "无锡市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-08-10 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-37",
|
||||
"name": "广州加氢站1号",
|
||||
"province": "广东省",
|
||||
"city": "广州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "停止营业",
|
||||
"createTime": "2024-05-05 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-38",
|
||||
"name": "广州加氢站2号",
|
||||
"province": "广东省",
|
||||
"city": "广州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-06-07 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-39",
|
||||
"name": "广州加氢站3号",
|
||||
"province": "广东省",
|
||||
"city": "广州市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-07-09 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-40",
|
||||
"name": "深圳加氢站1号",
|
||||
"province": "广东省",
|
||||
"city": "深圳市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2024-06-06 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-41",
|
||||
"name": "深圳加氢站2号",
|
||||
"province": "广东省",
|
||||
"city": "深圳市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-07-08 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-42",
|
||||
"name": "深圳加氢站3号",
|
||||
"province": "广东省",
|
||||
"city": "深圳市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-08-10 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-43",
|
||||
"name": "佛山加氢站1号",
|
||||
"province": "广东省",
|
||||
"city": "佛山市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2024-07-07 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-44",
|
||||
"name": "佛山加氢站2号",
|
||||
"province": "广东省",
|
||||
"city": "佛山市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-08-09 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-45",
|
||||
"name": "佛山加氢站3号",
|
||||
"province": "广东省",
|
||||
"city": "佛山市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "暂停营业",
|
||||
"createTime": "2025-09-11 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-46",
|
||||
"name": "济南加氢站1号",
|
||||
"province": "山东省",
|
||||
"city": "济南市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2024-06-06 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-47",
|
||||
"name": "济南加氢站2号",
|
||||
"province": "山东省",
|
||||
"city": "济南市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-07-08 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-48",
|
||||
"name": "济南加氢站3号",
|
||||
"province": "山东省",
|
||||
"city": "济南市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-08-10 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-49",
|
||||
"name": "青岛加氢站1号",
|
||||
"province": "山东省",
|
||||
"city": "青岛市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2024-07-07 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-50",
|
||||
"name": "青岛加氢站2号",
|
||||
"province": "山东省",
|
||||
"city": "青岛市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-08-09 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-51",
|
||||
"name": "青岛加氢站3号",
|
||||
"province": "山东省",
|
||||
"city": "青岛市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "暂停营业",
|
||||
"createTime": "2025-09-11 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-52",
|
||||
"name": "郑州加氢站1号",
|
||||
"province": "河南省",
|
||||
"city": "郑州市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2024-07-07 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-53",
|
||||
"name": "郑州加氢站2号",
|
||||
"province": "河南省",
|
||||
"city": "郑州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-08-09 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-54",
|
||||
"name": "郑州加氢站3号",
|
||||
"province": "河南省",
|
||||
"city": "郑州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "暂停营业",
|
||||
"createTime": "2025-09-11 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-55",
|
||||
"name": "洛阳加氢站1号",
|
||||
"province": "河南省",
|
||||
"city": "洛阳市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2024-08-08 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-56",
|
||||
"name": "洛阳加氢站2号",
|
||||
"province": "河南省",
|
||||
"city": "洛阳市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "暂停营业",
|
||||
"createTime": "2025-09-10 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-57",
|
||||
"name": "洛阳加氢站3号",
|
||||
"province": "河南省",
|
||||
"city": "洛阳市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "停止营业",
|
||||
"createTime": "2025-10-12 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-58",
|
||||
"name": "石家庄加氢站1号",
|
||||
"province": "河北省",
|
||||
"city": "石家庄市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2024-08-08 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-59",
|
||||
"name": "石家庄加氢站2号",
|
||||
"province": "河北省",
|
||||
"city": "石家庄市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "暂停营业",
|
||||
"createTime": "2025-09-10 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-60",
|
||||
"name": "石家庄加氢站3号",
|
||||
"province": "河北省",
|
||||
"city": "石家庄市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "停止营业",
|
||||
"createTime": "2025-10-12 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-61",
|
||||
"name": "保定加氢站1号",
|
||||
"province": "河北省",
|
||||
"city": "保定市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "暂停营业",
|
||||
"createTime": "2024-09-09 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-62",
|
||||
"name": "保定加氢站2号",
|
||||
"province": "河北省",
|
||||
"city": "保定市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "停止营业",
|
||||
"createTime": "2025-10-11 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-63",
|
||||
"name": "保定加氢站3号",
|
||||
"province": "河北省",
|
||||
"city": "保定市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-11-13 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-64",
|
||||
"name": "武汉加氢站1号",
|
||||
"province": "湖北省",
|
||||
"city": "武汉市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "暂停营业",
|
||||
"createTime": "2024-09-09 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-65",
|
||||
"name": "武汉加氢站2号",
|
||||
"province": "湖北省",
|
||||
"city": "武汉市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "停止营业",
|
||||
"createTime": "2025-10-11 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-66",
|
||||
"name": "武汉加氢站3号",
|
||||
"province": "湖北省",
|
||||
"city": "武汉市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-11-13 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-67",
|
||||
"name": "宜昌加氢站1号",
|
||||
"province": "湖北省",
|
||||
"city": "宜昌市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "停止营业",
|
||||
"createTime": "2024-10-10 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-68",
|
||||
"name": "宜昌加氢站2号",
|
||||
"province": "湖北省",
|
||||
"city": "宜昌市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-11-12 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-69",
|
||||
"name": "宜昌加氢站3号",
|
||||
"province": "湖北省",
|
||||
"city": "宜昌市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-01-14 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-70",
|
||||
"name": "西安加氢站1号",
|
||||
"province": "陕西省",
|
||||
"city": "西安市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "停止营业",
|
||||
"createTime": "2024-10-10 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-71",
|
||||
"name": "西安加氢站2号",
|
||||
"province": "陕西省",
|
||||
"city": "西安市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-11-12 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-72",
|
||||
"name": "西安加氢站3号",
|
||||
"province": "陕西省",
|
||||
"city": "西安市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-01-14 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-73",
|
||||
"name": "咸阳加氢站1号",
|
||||
"province": "陕西省",
|
||||
"city": "咸阳市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2024-11-11 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-74",
|
||||
"name": "咸阳加氢站2号",
|
||||
"province": "陕西省",
|
||||
"city": "咸阳市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-01-13 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-75",
|
||||
"name": "咸阳加氢站3号",
|
||||
"province": "陕西省",
|
||||
"city": "咸阳市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-02-15 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-76",
|
||||
"name": "成都市新都区加氢站",
|
||||
"province": "四川省",
|
||||
"city": "成都市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-07-08 09:30:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-77",
|
||||
"name": "成都市龙泉驿区加氢站",
|
||||
"province": "四川省",
|
||||
"city": "成都市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-07-09 09:30:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-78",
|
||||
"name": "成都市双流区加氢站",
|
||||
"province": "四川省",
|
||||
"city": "成都市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-07-10 09:30:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-79",
|
||||
"name": "成都市郫都区加氢站",
|
||||
"province": "四川省",
|
||||
"city": "成都市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-07-11 09:30:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-80",
|
||||
"name": "成都市温江区加氢站",
|
||||
"province": "四川省",
|
||||
"city": "成都市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-07-12 09:30:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-81",
|
||||
"name": "成都市青白江区加氢站",
|
||||
"province": "四川省",
|
||||
"city": "成都市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-07-08 09:30:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-82",
|
||||
"name": "成都市天府新区加氢站",
|
||||
"province": "四川省",
|
||||
"city": "成都市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-07-09 09:30:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-83",
|
||||
"name": "成都市简阳市加氢站",
|
||||
"province": "四川省",
|
||||
"city": "成都市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-07-10 09:30:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-84",
|
||||
"name": "德阳市旌阳区加氢站",
|
||||
"province": "四川省",
|
||||
"city": "德阳市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-07-09 14:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-85",
|
||||
"name": "德阳市罗江区加氢站",
|
||||
"province": "四川省",
|
||||
"city": "德阳市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-07-10 14:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-86",
|
||||
"name": "德阳市广汉市加氢站",
|
||||
"province": "四川省",
|
||||
"city": "德阳市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-07-11 14:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-87",
|
||||
"name": "德阳市什邡市加氢站",
|
||||
"province": "四川省",
|
||||
"city": "德阳市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-07-12 14:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-88",
|
||||
"name": "趋势演示站-W27-1",
|
||||
"province": "浙江省",
|
||||
"city": "杭州市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-06-27 11:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-89",
|
||||
"name": "趋势演示站-W26-2",
|
||||
"province": "浙江省",
|
||||
"city": "杭州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-06-24 11:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-90",
|
||||
"name": "趋势演示站-W25-3",
|
||||
"province": "浙江省",
|
||||
"city": "杭州市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-06-21 11:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-91",
|
||||
"name": "趋势演示站-W24-4",
|
||||
"province": "浙江省",
|
||||
"city": "杭州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-06-18 11:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-92",
|
||||
"name": "趋势演示站-W23-5",
|
||||
"province": "浙江省",
|
||||
"city": "杭州市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-06-15 11:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-93",
|
||||
"name": "趋势演示站-W22-6",
|
||||
"province": "浙江省",
|
||||
"city": "杭州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-06-12 11:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-94",
|
||||
"name": "趋势演示站-W21-7",
|
||||
"province": "浙江省",
|
||||
"city": "杭州市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-06-09 11:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-95",
|
||||
"name": "同比基准站-1",
|
||||
"province": "广东省",
|
||||
"city": "广州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-07-10 08:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-96",
|
||||
"name": "同比基准站-2",
|
||||
"province": "广东省",
|
||||
"city": "广州市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-07-11 08:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-97",
|
||||
"name": "同比基准站-3",
|
||||
"province": "广东省",
|
||||
"city": "广州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-07-12 08:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-98",
|
||||
"name": "同比基准站-4",
|
||||
"province": "广东省",
|
||||
"city": "广州市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-07-13 08:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-99",
|
||||
"name": "同比基准站-5",
|
||||
"province": "广东省",
|
||||
"city": "广州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-07-14 08:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-100",
|
||||
"name": "同比基准站-6",
|
||||
"province": "广东省",
|
||||
"city": "广州市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-07-15 08:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-101",
|
||||
"name": "同比基准站-7",
|
||||
"province": "广东省",
|
||||
"city": "广州市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-07-16 08:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-102",
|
||||
"name": "同比基准站-8",
|
||||
"province": "广东省",
|
||||
"city": "广州市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2025-07-17 08:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-cw-1",
|
||||
"name": "成都市温江区示范加氢站",
|
||||
"province": "四川省",
|
||||
"city": "成都市",
|
||||
"isSigned": true,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-07-13 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "h2s-cw-2",
|
||||
"name": "成都市青白江区示范加氢站",
|
||||
"province": "四川省",
|
||||
"city": "成都市",
|
||||
"isSigned": false,
|
||||
"businessStatus": "营业中",
|
||||
"createTime": "2026-07-13 11:00:00"
|
||||
}
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user