Compare commits
2 Commits
76fadd0508
...
84a25c42df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84a25c42df | ||
|
|
7ca064f9f7 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-07-18T02:30:01.518Z",
|
||||
"updatedAt": "2026-07-20T05:34:45.927Z",
|
||||
"prototypes": [
|
||||
{
|
||||
"id": "folder-1783875936186-27raza",
|
||||
@@ -184,6 +184,18 @@
|
||||
"kind": "item",
|
||||
"title": "加氢记录",
|
||||
"itemKey": "prototypes/oneos-web-h2-station"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:oneos-web-h2-station-weekly",
|
||||
"kind": "item",
|
||||
"title": "站点周报统计",
|
||||
"itemKey": "prototypes/oneos-web-h2-station-weekly"
|
||||
},
|
||||
{
|
||||
"id": "item-prototypes-oneos-web-h2-station-analysis",
|
||||
"kind": "item",
|
||||
"title": "oneos web h2 station analysis",
|
||||
"itemKey": "prototypes/oneos-web-h2-station-analysis"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1093,4 +1105,4 @@
|
||||
"templates": [],
|
||||
"components": [],
|
||||
"canvas": []
|
||||
}
|
||||
}
|
||||
|
||||
167
scripts/merge-registry-sidebar-conflicts.mjs
Normal file
167
scripts/merge-registry-sidebar-conflicts.mjs
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 合并 merge 冲突中的 prototype-registry.json 与 sidebar-tree.json(保留两侧内容)。
|
||||
* 用法:node scripts/merge-registry-sidebar-conflicts.mjs
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function gitShow(stage, filePath) {
|
||||
return execSync(`git show :${stage}:${filePath}`, {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function readJsonFromGit(stage, relativePath) {
|
||||
return JSON.parse(gitShow(stage, relativePath));
|
||||
}
|
||||
|
||||
function writeJson(relativePath, value) {
|
||||
const abs = path.join(projectRoot, relativePath);
|
||||
fs.writeFileSync(abs, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function collectItemKeys(items, acc = new Set()) {
|
||||
for (const item of items || []) {
|
||||
if (item?.itemKey) acc.add(item.itemKey);
|
||||
if (Array.isArray(item?.children)) collectItemKeys(item.children, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
function findItemByKey(items, itemKey) {
|
||||
for (const item of items || []) {
|
||||
if (item?.itemKey === itemKey) return item;
|
||||
if (Array.isArray(item?.children)) {
|
||||
const found = findItemByKey(item.children, itemKey);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFolder(items, title) {
|
||||
for (const item of items || []) {
|
||||
if (item?.kind === 'folder' && item?.title === title) return item;
|
||||
if (Array.isArray(item?.children)) {
|
||||
const found = findFolder(item.children, title);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureChildFolder(parent, folderTitle, folderId) {
|
||||
parent.children = parent.children || [];
|
||||
let folder = parent.children.find((c) => c.kind === 'folder' && c.title === folderTitle);
|
||||
if (!folder) {
|
||||
folder = {
|
||||
id: folderId,
|
||||
kind: 'folder',
|
||||
title: folderTitle,
|
||||
children: [],
|
||||
};
|
||||
parent.children.push(folder);
|
||||
}
|
||||
folder.children = folder.children || [];
|
||||
return folder;
|
||||
}
|
||||
|
||||
function ensureItem(folder, item) {
|
||||
if (!item?.itemKey) return;
|
||||
const exists = folder.children.some((c) => c.itemKey === item.itemKey);
|
||||
if (!exists) folder.children.push(JSON.parse(JSON.stringify(item)));
|
||||
}
|
||||
|
||||
function mergeRegistry() {
|
||||
const relativePath = 'src/prototypes/oneos-prototype-nav/prototype-registry.json';
|
||||
const ours = readJsonFromGit(2, relativePath);
|
||||
const theirs = readJsonFromGit(3, relativePath);
|
||||
|
||||
const merged = {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
prototypes: { ...theirs.prototypes },
|
||||
recentUpdates: [],
|
||||
};
|
||||
|
||||
for (const key of ['oneos-web-h2-station-weekly', 'oneos-web-h2-station-analysis']) {
|
||||
if (ours.prototypes[key]) merged.prototypes[key] = ours.prototypes[key];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const combinedRecent = [...(ours.recentUpdates || []), ...(theirs.recentUpdates || [])];
|
||||
for (const entry of combinedRecent) {
|
||||
const sig = `${entry.prototypeId}|${entry.version}|${entry.date}|${entry.time}`;
|
||||
if (seen.has(sig)) continue;
|
||||
seen.add(sig);
|
||||
merged.recentUpdates.push(entry);
|
||||
}
|
||||
merged.recentUpdates = merged.recentUpdates.slice(0, 40);
|
||||
|
||||
writeJson(relativePath, merged);
|
||||
return {
|
||||
prototypeCount: Object.keys(merged.prototypes).length,
|
||||
recentCount: merged.recentUpdates.length,
|
||||
hasXll: Boolean(merged.prototypes['xll-miniapp']),
|
||||
h2AnalysisVersion: merged.prototypes['oneos-web-h2-station-analysis']?.version,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSidebar() {
|
||||
const relativePath = '.axhub/make/sidebar-tree.json';
|
||||
const ours = readJsonFromGit(2, relativePath);
|
||||
const theirs = readJsonFromGit(3, relativePath);
|
||||
|
||||
const merged = JSON.parse(JSON.stringify(theirs));
|
||||
merged.updatedAt = new Date().toISOString();
|
||||
|
||||
const oursKeys = collectItemKeys(ours.prototypes);
|
||||
const mergedKeys = collectItemKeys(merged.prototypes);
|
||||
const localOnlyKeys = [
|
||||
'prototypes/oneos-web-h2-station-weekly',
|
||||
'prototypes/oneos-web-h2-station-analysis',
|
||||
].filter((key) => oursKeys.has(key) && !mergedKeys.has(key));
|
||||
|
||||
const oneOsFolder = findFolder(merged.prototypes, 'OneOS');
|
||||
const h2Folder = oneOsFolder ? findFolder(oneOsFolder.children, '加氢站管理') : findFolder(merged.prototypes, '加氢站管理');
|
||||
|
||||
if (h2Folder) {
|
||||
for (const itemKey of localOnlyKeys) {
|
||||
const source =
|
||||
findItemByKey(ours.prototypes, itemKey) ||
|
||||
({
|
||||
'prototypes/oneos-web-h2-station-weekly': {
|
||||
id: 'item:prototypes:oneos-web-h2-station-weekly',
|
||||
kind: 'item',
|
||||
title: '站点周报统计',
|
||||
itemKey: 'prototypes/oneos-web-h2-station-weekly',
|
||||
},
|
||||
'prototypes/oneos-web-h2-station-analysis': {
|
||||
id: 'item-prototypes-oneos-web-h2-station-analysis',
|
||||
kind: 'item',
|
||||
title: '加氢站分析',
|
||||
itemKey: 'prototypes/oneos-web-h2-station-analysis',
|
||||
},
|
||||
}[itemKey]);
|
||||
ensureItem(h2Folder, source);
|
||||
}
|
||||
}
|
||||
|
||||
writeJson(relativePath, merged);
|
||||
return {
|
||||
addedKeys: localOnlyKeys,
|
||||
itemCount: collectItemKeys(merged.prototypes).size,
|
||||
};
|
||||
}
|
||||
|
||||
const registry = mergeRegistry();
|
||||
const sidebar = mergeSidebar();
|
||||
console.log('[merge] prototype-registry.json', registry);
|
||||
console.log('[merge] sidebar-tree.json', sidebar);
|
||||
@@ -24,6 +24,8 @@ const ROUTE_TO_PAGE = {
|
||||
'third-return': 'third-return',
|
||||
replace: 'replace',
|
||||
'audit-return': 'audit-return',
|
||||
training: 'driver-training',
|
||||
'driver-training': 'driver-training',
|
||||
};
|
||||
|
||||
function parseArgs(argv) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-07-18T02:52:26.271Z",
|
||||
"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",
|
||||
|
||||
69
src/prototypes/oneos-web-h2-station-analysis/index.tsx
Normal file
69
src/prototypes/oneos-web-h2-station-analysis/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @name 加氢站分析
|
||||
* 数据分析 — 加氢站新增 / 存量 / 经营风险分析
|
||||
*/
|
||||
import '../../common/oneosWebLegacy/legacyGlobals';
|
||||
import React, { useEffect } from 'react';
|
||||
import * as antd from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
import updateLocale from 'dayjs/plugin/updateLocale';
|
||||
import { ConfigProvider } from 'antd';
|
||||
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';
|
||||
import '../oneos-web-h2-station/styles/h2-station-vm-filter.css';
|
||||
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||
import H2AnalysisPage from './pages/01-加氢站分析.jsx';
|
||||
|
||||
dayjs.extend(isoWeek);
|
||||
dayjs.extend(updateLocale);
|
||||
dayjs.locale('zh-cn');
|
||||
dayjs.updateLocale('zh-cn', { weekStart: 1 });
|
||||
|
||||
const vmTheme = {
|
||||
token: {
|
||||
colorPrimary: '#32a06e',
|
||||
colorLink: '#32a06e',
|
||||
colorLinkHover: '#3fb87c',
|
||||
borderRadius: 8,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
fontSize: 14,
|
||||
colorText: '#18181b',
|
||||
colorTextSecondary: '#52525b',
|
||||
colorBorder: '#e5e7eb',
|
||||
colorBgContainer: '#ffffff',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: '#f6f7f7',
|
||||
headerColor: '#71717a',
|
||||
rowHoverBg: '#f6f7f7',
|
||||
borderColor: '#e5e7eb',
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 12,
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 12,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
window.React = React;
|
||||
window.antd = antd;
|
||||
(window as Window & { dayjs: typeof dayjs }).dayjs = dayjs;
|
||||
|
||||
export default function OneosWebH2StationAnalysis() {
|
||||
useEffect(() => {
|
||||
clearHostPrototypeRouteInfo();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN} theme={vmTheme}>
|
||||
<H2AnalysisPage />
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
821
src/prototypes/oneos-web-h2-station-analysis/pages/01-加氢站分析.jsx
Normal file
821
src/prototypes/oneos-web-h2-station-analysis/pages/01-加氢站分析.jsx
Normal file
@@ -0,0 +1,821 @@
|
||||
// 【重要】必须使用 const Component 作为组件变量名
|
||||
// 数据分析 — 加氢站分析(新增 / 存量 / 经营风险)
|
||||
|
||||
import { TablePagination, COMPACT_PAGE_SIZE_OPTIONS } from '../../../common/TablePagination';
|
||||
import { OperationActions } from '../../../common/OperationActions';
|
||||
import {
|
||||
H2StationViewModal,
|
||||
H2_STATION_VIEW_MODAL_STYLES,
|
||||
h2WeeklyResolveStationViewRecord
|
||||
} from '../../oneos-web-h2-station/shared/h2-station-view-shared.jsx';
|
||||
import {
|
||||
H2_ANALYSIS_REGION_OPTIONS,
|
||||
H2_ANALYSIS_MOCK_STATIONS,
|
||||
h2AnalysisBuildTrendPeriods,
|
||||
h2AnalysisGetStockSummary,
|
||||
h2AnalysisGetProvinceStock,
|
||||
h2AnalysisFilterNewStations,
|
||||
h2AnalysisFilterStockStations,
|
||||
h2AnalysisFilterRiskStations,
|
||||
h2AnalysisGetNewSummary,
|
||||
h2AnalysisGetNewProvinceTop,
|
||||
h2AnalysisGetRiskSummary,
|
||||
h2AnalysisGetBalanceDistribution,
|
||||
h2AnalysisGetPriceDistribution,
|
||||
h2AnalysisGetContractBuckets,
|
||||
h2AnalysisGetLowBalanceTop,
|
||||
h2AnalysisGetStationType,
|
||||
h2AnalysisGetBalanceStatus,
|
||||
h2AnalysisGetContractDays,
|
||||
h2AnalysisGetRiskTypes,
|
||||
h2AnalysisFormatRegion,
|
||||
h2AnalysisFormatYuan,
|
||||
h2AnalysisStartOfWeek,
|
||||
h2AnalysisEndOfWeek
|
||||
} from '../shared/h2-analysis-mock.js';
|
||||
import {
|
||||
H2AnalysisDonutChart,
|
||||
H2AnalysisHorizontalStackedBar,
|
||||
H2AnalysisStackedBarTrend,
|
||||
H2AnalysisLineTrend,
|
||||
H2AnalysisSegmentBar,
|
||||
H2AnalysisHorizontalBar,
|
||||
H2AnalysisChartCard,
|
||||
H2AnalysisLegendDot,
|
||||
H2_ANALYSIS_CHART_COLORS
|
||||
} from '../shared/h2-analysis-charts.jsx';
|
||||
|
||||
var ONEOS_ANT_TABLE_GLOBAL_FIX = [
|
||||
'.ant-table-container .ant-table-header { margin-bottom: 0 !important; }',
|
||||
'.ant-table-container .ant-table-body { margin-top: 0 !important; }'
|
||||
];
|
||||
|
||||
var H2_ANALYSIS_PAGE_STYLE = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([
|
||||
'.vm-page.lc-page.h2-analysis-page { font-family: var(--vm-font); font-size: 0.875rem; line-height: 1.5; color: var(--ln-body); height: 100vh; overflow: auto; box-sizing: border-box; background: #f8fafc; }',
|
||||
'.h2-analysis-page .h2-analysis-body { padding: 0 20px 24px; min-width: 0; }',
|
||||
'.h2-analysis-page .h2-analysis-head { padding: 16px 20px 0; }',
|
||||
'.h2-analysis-page .h2-analysis-head__title { margin: 0 0 4px; font-size: 1.125rem; font-weight: 700; color: #0f172a; }',
|
||||
'.h2-analysis-page .h2-analysis-head__sub { margin: 0; font-size: 0.8125rem; color: #64748b; }',
|
||||
'.h2-analysis-page .vm-filter-card { margin-bottom: 16px; }',
|
||||
'.h2-analysis-page .vm-kpi-row { display: grid; gap: 12px; margin-bottom: 16px; }',
|
||||
'.h2-analysis-page .vm-kpi-row--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }',
|
||||
'.h2-analysis-page .vm-kpi-row--6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }',
|
||||
'.h2-analysis-page .vm-kpi-row--5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }',
|
||||
'@media (max-width: 1400px) { .h2-analysis-page .vm-kpi-row--6 { grid-template-columns: repeat(3, minmax(0, 1fr)); } .h2-analysis-page .vm-kpi-row--5 { grid-template-columns: repeat(3, minmax(0, 1fr)); } }',
|
||||
'@media (max-width: 960px) { .h2-analysis-page .vm-kpi-row--4, .h2-analysis-page .vm-kpi-row--6, .h2-analysis-page .vm-kpi-row--5 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }',
|
||||
'.h2-analysis-page .vm-kpi-card { cursor: default; }',
|
||||
'.h2-analysis-page .vm-kpi-card.is-clickable { cursor: pointer; }',
|
||||
'.h2-analysis-page .vm-kpi-card.is-active { border-color: #86efac; box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.15); }',
|
||||
'.h2-analysis-page .h2-analysis-kpi-warn .vm-kpi-value { color: #d97706; }',
|
||||
'.h2-analysis-page .h2-analysis-kpi-danger .vm-kpi-value { color: #dc2626; }',
|
||||
'.h2-analysis-page .h2-analysis-chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-bottom: 16px; }',
|
||||
'.h2-analysis-page .h2-analysis-chart-grid--wide { grid-template-columns: 1fr; }',
|
||||
'@media (max-width: 1100px) { .h2-analysis-page .h2-analysis-chart-grid { grid-template-columns: 1fr; } }',
|
||||
'.h2-analysis-chart-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); }',
|
||||
'.h2-analysis-chart-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #f1f5f9; background: #fafbfc; flex-wrap: wrap; }',
|
||||
'.h2-analysis-chart-head__title { font-size: 13px; font-weight: 700; color: #334155; }',
|
||||
'.h2-analysis-chart-legend { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }',
|
||||
'.h2-analysis-legend-inline { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: #64748b; }',
|
||||
'.h2-analysis-chart-body { padding: 12px 14px 14px; min-height: 240px; }',
|
||||
'.h2-analysis-chart-body.is-compact { min-height: 200px; }',
|
||||
'.h2-analysis-chart-svg { display: block; width: 100%; height: auto; }',
|
||||
'.h2-analysis-axis-label { font-size: 10px; fill: #94a3b8; }',
|
||||
'.h2-analysis-axis-tick { font-size: 10px; fill: #94a3b8; font-variant-numeric: tabular-nums; }',
|
||||
'.h2-analysis-bar-label { font-size: 11px; fill: #475569; font-weight: 500; }',
|
||||
'.h2-analysis-bar-label-sm { font-size: 10px; fill: #475569; }',
|
||||
'.h2-analysis-bar-val { font-size: 10px; fill: #64748b; font-variant-numeric: tabular-nums; }',
|
||||
'.h2-analysis-donut-wrap { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; justify-content: center; }',
|
||||
'.h2-analysis-donut-svg { width: min(200px, 42%); flex-shrink: 0; }',
|
||||
'.h2-analysis-donut-center-val { font-size: 22px; font-weight: 700; fill: #0f172a; }',
|
||||
'.h2-analysis-donut-center-label { font-size: 11px; fill: #94a3b8; }',
|
||||
'.h2-analysis-donut-legend { display: flex; flex-direction: column; gap: 8px; min-width: 140px; }',
|
||||
'.h2-analysis-legend-item { display: flex; align-items: center; gap: 8px; border: none; background: none; padding: 4px 0; cursor: pointer; text-align: left; opacity: 0.55; transition: opacity 0.2s; }',
|
||||
'.h2-analysis-legend-item.is-active, .h2-analysis-legend-item:hover { opacity: 1; }',
|
||||
'.h2-analysis-legend-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }',
|
||||
'.h2-analysis-legend-label { font-size: 12px; color: #475569; flex: 1; }',
|
||||
'.h2-analysis-legend-val { font-size: 12px; color: #0f172a; font-weight: 600; font-variant-numeric: tabular-nums; }',
|
||||
'.h2-analysis-page .h2-analysis-filter-hint { margin: -8px 0 12px; padding: 8px 12px; border-radius: 8px; background: #ecfdf5; border: 1px solid #bbf7d0; font-size: 12px; color: #047857; display: flex; align-items: center; justify-content: space-between; gap: 8px; }',
|
||||
'.h2-analysis-page .h2-analysis-filter-hint button { border: none; background: none; color: #059669; font-weight: 600; cursor: pointer; font-size: 12px; }',
|
||||
'.h2-analysis-page .h2-analysis-main-tabs .ant-tabs-nav { margin-bottom: 16px !important; padding: 0 20px; background: #fff; border-bottom: 1px solid #e2e8f0; }',
|
||||
'.h2-analysis-page .h2-analysis-tab-panel { padding: 0 20px 8px; }',
|
||||
'.h2-analysis-page .h2-analysis-main-tabs .ant-tabs-tab { font-size: 14px !important; font-weight: 600 !important; padding: 14px 4px !important; }',
|
||||
'.h2-analysis-page .h2-analysis-type-tag.ant-tag { margin: 0; border-radius: 6px; font-size: 0.75rem; font-weight: 500; }',
|
||||
'.h2-analysis-page .h2-analysis-risk-tag.ant-tag { margin: 2px 4px 2px 0; border-radius: 6px; font-size: 0.6875rem; }',
|
||||
'.h2-analysis-page .vm-table-section { min-width: 0; }'
|
||||
].concat(H2_STATION_VIEW_MODAL_STYLES));
|
||||
|
||||
function h2AnalysisEnsureDayjs() {
|
||||
return window.dayjs || null;
|
||||
}
|
||||
|
||||
function h2AnalysisFormatWeekRange(weekValue) {
|
||||
var start = h2AnalysisStartOfWeek(weekValue);
|
||||
var end = h2AnalysisEndOfWeek(weekValue);
|
||||
if (!start || !end) return '';
|
||||
return start.format('YYYY-MM-DD') + ' ~ ' + end.format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
function h2AnalysisRenderKpiCard(meta, value, extra, opts) {
|
||||
var options = opts || {};
|
||||
var cls = 'vm-kpi-card' + (options.clickable ? ' is-clickable' : '') + (options.active ? ' is-active' : '') + (options.warn ? ' h2-analysis-kpi-warn' : '') + (options.danger ? ' h2-analysis-kpi-danger' : '');
|
||||
return React.createElement('div', {
|
||||
className: cls,
|
||||
key: meta.key,
|
||||
onClick: options.onClick,
|
||||
role: options.clickable ? 'button' : undefined
|
||||
},
|
||||
React.createElement('div', { className: 'vm-kpi-title' }, meta.title),
|
||||
React.createElement('div', { className: 'vm-kpi-value' }, value),
|
||||
extra ? React.createElement('div', { className: 'vm-kpi-desc' }, extra) : null
|
||||
);
|
||||
}
|
||||
|
||||
function h2AnalysisToggleChartFilter(current, next) {
|
||||
if (!next) return null;
|
||||
if (current && current.type === next.type && current.value === next.value) return null;
|
||||
return next;
|
||||
}
|
||||
|
||||
function h2AnalysisRenderFilterHint(chartFilter, onClear) {
|
||||
if (!chartFilter) return null;
|
||||
var label = chartFilter.label || chartFilter.value || '';
|
||||
return React.createElement('div', { className: 'h2-analysis-filter-hint' },
|
||||
React.createElement('span', null, '图表筛选:', React.createElement('strong', null, label)),
|
||||
React.createElement('button', { type: 'button', onClick: onClear }, '清除')
|
||||
);
|
||||
}
|
||||
|
||||
function h2AnalysisRenderCommonFilters(state, setState) {
|
||||
var Select = window.antd && window.antd.Select;
|
||||
var Cascader = window.antd && window.antd.Cascader;
|
||||
var DatePicker = window.antd && window.antd.DatePicker;
|
||||
if (!Select || !Cascader) return null;
|
||||
var dayjs = h2AnalysisEnsureDayjs();
|
||||
return React.createElement('div', { className: 'vm-filter-card lc-filter-card' },
|
||||
React.createElement('div', { className: 'vm-filter-grid lc-filter-grid' },
|
||||
state.showWeek ? React.createElement('div', { className: 'vm-filter-field' },
|
||||
React.createElement('label', { className: 'vm-filter-label' }, '统计周期'),
|
||||
DatePicker && dayjs ? React.createElement(DatePicker, {
|
||||
picker: 'week',
|
||||
value: state.weekValue,
|
||||
onChange: function (v) { setState(Object.assign({}, state, { weekValue: v, chartFilter: null, page: 1 })); },
|
||||
style: { width: '100%' },
|
||||
allowClear: false
|
||||
}) : null
|
||||
) : null,
|
||||
React.createElement('div', { className: 'vm-filter-field' },
|
||||
React.createElement('label', { className: 'vm-filter-label' }, '省市'),
|
||||
React.createElement(Cascader, {
|
||||
options: H2_ANALYSIS_REGION_OPTIONS,
|
||||
value: state.region && state.region.length ? state.region : undefined,
|
||||
onChange: function (v) { setState(Object.assign({}, state, { region: v || [], chartFilter: null, page: 1 })); },
|
||||
placeholder: '全部',
|
||||
changeOnSelect: true,
|
||||
allowClear: true,
|
||||
style: { width: '100%' }
|
||||
})
|
||||
),
|
||||
React.createElement('div', { className: 'vm-filter-field' },
|
||||
React.createElement('label', { className: 'vm-filter-label' }, '站点类型'),
|
||||
React.createElement(Select, {
|
||||
value: state.stationType || undefined,
|
||||
onChange: function (v) { setState(Object.assign({}, state, { stationType: v || '', chartFilter: null, page: 1 })); },
|
||||
placeholder: '全部',
|
||||
allowClear: true,
|
||||
style: { width: '100%' },
|
||||
options: [{ value: '签约', label: '签约' }, { value: '普通', label: '普通' }]
|
||||
})
|
||||
),
|
||||
React.createElement('div', { className: 'vm-filter-field' },
|
||||
React.createElement('label', { className: 'vm-filter-label' }, '营业状态'),
|
||||
React.createElement(Select, {
|
||||
value: state.businessStatus || undefined,
|
||||
onChange: function (v) { setState(Object.assign({}, state, { businessStatus: v || '', chartFilter: null, page: 1 })); },
|
||||
placeholder: '全部',
|
||||
allowClear: true,
|
||||
style: { width: '100%' },
|
||||
options: [
|
||||
{ value: '营业中', label: '营业中' },
|
||||
{ value: '暂停营业', label: '暂停营业' },
|
||||
{ value: '停止营业', label: '停止营业' }
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function H2AnalysisNewTab(props) {
|
||||
var stations = props.stations;
|
||||
var viewStation = props.viewStation;
|
||||
var state = props.state;
|
||||
var setState = props.setState;
|
||||
var Tag = window.antd && window.antd.Tag;
|
||||
var Table = window.antd && window.antd.Table;
|
||||
var summary = h2AnalysisGetNewSummary(stations, state.weekValue);
|
||||
var provinceTop = h2AnalysisGetNewProvinceTop(stations, state.weekValue, 10);
|
||||
var periods = h2AnalysisBuildTrendPeriods();
|
||||
var filtered = h2AnalysisFilterNewStations(stations, {
|
||||
weekValue: state.weekValue,
|
||||
region: state.region,
|
||||
stationType: state.stationType,
|
||||
businessStatus: state.businessStatus,
|
||||
chartFilter: state.chartFilter
|
||||
});
|
||||
var typeSegments = [
|
||||
{ key: '签约', label: '签约', value: summary.newSigned, color: H2_ANALYSIS_CHART_COLORS.signed },
|
||||
{ key: '普通', label: '普通', value: summary.newNormal, color: H2_ANALYSIS_CHART_COLORS.normal }
|
||||
];
|
||||
var barItems = provinceTop.map(function (p) {
|
||||
return { province: p.province, signed: p.signed, normal: p.normal };
|
||||
});
|
||||
var pageData = filtered.slice((state.page - 1) * state.pageSize, state.page * state.pageSize);
|
||||
|
||||
var columns = [
|
||||
{ title: '省市', dataIndex: 'region', width: 140, render: function (_v, r) { return h2AnalysisFormatRegion(r); } },
|
||||
{ title: '站点名称', dataIndex: 'name', ellipsis: true },
|
||||
{
|
||||
title: '站点类型', dataIndex: 'stationType', width: 88,
|
||||
render: function (_v, r) {
|
||||
var t = h2AnalysisGetStationType(r);
|
||||
return Tag ? React.createElement(Tag, {
|
||||
className: 'h2-analysis-type-tag',
|
||||
color: t === '签约' ? 'green' : 'gold'
|
||||
}, t) : t;
|
||||
}
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 168 },
|
||||
{
|
||||
title: '营业状态', dataIndex: 'businessStatus', width: 96,
|
||||
render: function (v) {
|
||||
var color = v === '营业中' ? 'success' : v === '暂停营业' ? 'warning' : 'default';
|
||||
return Tag ? React.createElement(Tag, { color: color }, v) : v;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 88, fixed: 'right',
|
||||
render: function (_v, r) {
|
||||
return React.createElement(OperationActions, {
|
||||
actions: [{ key: 'view', label: '查看', onClick: function () { viewStation(r); } }]
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return React.createElement('div', { className: 'h2-analysis-tab-panel' },
|
||||
h2AnalysisRenderCommonFilters(Object.assign({}, state, { showWeek: true }), setState),
|
||||
h2AnalysisRenderFilterHint(state.chartFilter, function () { setState(Object.assign({}, state, { chartFilter: null, page: 1 })); }),
|
||||
React.createElement('div', { className: 'vm-kpi-row vm-kpi-row--4' },
|
||||
h2AnalysisRenderKpiCard({ key: 'newTotal', title: '本期新增站点数' }, summary.newTotal, h2AnalysisFormatWeekRange(state.weekValue)),
|
||||
h2AnalysisRenderKpiCard({ key: 'newSigned', title: '新增签约站点数' }, summary.newSigned),
|
||||
h2AnalysisRenderKpiCard({ key: 'newNormal', title: '新增普通站点数' }, summary.newNormal),
|
||||
h2AnalysisRenderKpiCard({ key: 'newProv', title: '新增覆盖省份数' }, summary.newProvinceCount)
|
||||
),
|
||||
React.createElement('div', { className: 'h2-analysis-chart-grid' },
|
||||
React.createElement(H2AnalysisChartCard, {
|
||||
title: '新增站点类型占比',
|
||||
compact: true
|
||||
},
|
||||
React.createElement(H2AnalysisDonutChart, {
|
||||
segments: typeSegments,
|
||||
centerLabel: '新增',
|
||||
centerValue: summary.newTotal,
|
||||
activeKey: state.chartFilter && state.chartFilter.type === 'stationType' ? state.chartFilter.value : null,
|
||||
onSegmentClick: function (seg) {
|
||||
setState(Object.assign({}, state, {
|
||||
chartFilter: h2AnalysisToggleChartFilter(state.chartFilter, {
|
||||
type: 'stationType', value: seg.key, label: seg.label
|
||||
}),
|
||||
page: 1
|
||||
}));
|
||||
}
|
||||
})
|
||||
),
|
||||
React.createElement(H2AnalysisChartCard, {
|
||||
title: '新增站点省份 Top10',
|
||||
legend: React.createElement(React.Fragment, null,
|
||||
H2AnalysisLegendDot(H2_ANALYSIS_CHART_COLORS.signed, '签约'),
|
||||
H2AnalysisLegendDot(H2_ANALYSIS_CHART_COLORS.normal, '普通')
|
||||
)
|
||||
},
|
||||
barItems.length ? React.createElement(H2AnalysisHorizontalStackedBar, {
|
||||
items: barItems,
|
||||
keys: [
|
||||
{ key: 'signed', color: H2_ANALYSIS_CHART_COLORS.signed, label: '签约' },
|
||||
{ key: 'normal', color: H2_ANALYSIS_CHART_COLORS.normal, label: '普通' }
|
||||
],
|
||||
activeFilter: state.chartFilter && state.chartFilter.type === 'province' ? { province: state.chartFilter.value } : null,
|
||||
onBarClick: function (payload) {
|
||||
setState(Object.assign({}, state, {
|
||||
chartFilter: h2AnalysisToggleChartFilter(state.chartFilter, {
|
||||
type: 'province', value: payload.province, label: payload.province
|
||||
}),
|
||||
page: 1
|
||||
}));
|
||||
}
|
||||
}) : React.createElement('div', { style: { padding: 40, textAlign: 'center', color: '#94a3b8' } }, '本期暂无新增')
|
||||
)
|
||||
),
|
||||
React.createElement('div', { className: 'h2-analysis-chart-grid h2-analysis-chart-grid--wide' },
|
||||
React.createElement(H2AnalysisChartCard, {
|
||||
title: '近12期新增站点趋势',
|
||||
legend: React.createElement(React.Fragment, null,
|
||||
H2AnalysisLegendDot(H2_ANALYSIS_CHART_COLORS.signed, '新增签约'),
|
||||
H2AnalysisLegendDot(H2_ANALYSIS_CHART_COLORS.normal, '新增普通')
|
||||
)
|
||||
},
|
||||
React.createElement(H2AnalysisStackedBarTrend, {
|
||||
periods: periods,
|
||||
activePeriod: state.chartFilter && state.chartFilter.type === 'period' ? state.chartFilter.periodIndex : null,
|
||||
onBarClick: function (idx, p) {
|
||||
setState(Object.assign({}, state, {
|
||||
chartFilter: h2AnalysisToggleChartFilter(state.chartFilter, {
|
||||
type: 'period', periodIndex: idx, label: '第' + p.period + '期 (' + p.label + ')'
|
||||
}),
|
||||
page: 1
|
||||
}));
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
React.createElement('div', { className: 'vm-table-section' },
|
||||
React.createElement('div', { className: 'vm-table-toolbar' },
|
||||
React.createElement('span', { className: 'vm-table-toolbar__title' }, '新增站点明细'),
|
||||
React.createElement('span', { className: 'vm-table-toolbar__meta' }, '共 ' + filtered.length + ' 条')
|
||||
),
|
||||
Table ? React.createElement(Table, {
|
||||
rowKey: 'id',
|
||||
size: 'small',
|
||||
columns: columns,
|
||||
dataSource: pageData,
|
||||
pagination: false,
|
||||
scroll: { x: 900 }
|
||||
}) : null,
|
||||
React.createElement(TablePagination, {
|
||||
current: state.page,
|
||||
pageSize: state.pageSize,
|
||||
total: filtered.length,
|
||||
pageSizeOptions: COMPACT_PAGE_SIZE_OPTIONS,
|
||||
onChange: function (p, ps) { setState(Object.assign({}, state, { page: p, pageSize: ps })); }
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function H2AnalysisStockTab(props) {
|
||||
var stations = props.stations;
|
||||
var viewStation = props.viewStation;
|
||||
var state = props.state;
|
||||
var setState = props.setState;
|
||||
var Tag = window.antd && window.antd.Tag;
|
||||
var Table = window.antd && window.antd.Table;
|
||||
var stock = h2AnalysisGetStockSummary();
|
||||
var provinceStock = h2AnalysisGetProvinceStock().slice(0, 10);
|
||||
var periods = h2AnalysisBuildTrendPeriods();
|
||||
var filtered = h2AnalysisFilterStockStations(stations, {
|
||||
region: state.region,
|
||||
stationType: state.stationType,
|
||||
businessStatus: state.businessStatus,
|
||||
chartFilter: state.chartFilter
|
||||
});
|
||||
var typeSegments = [
|
||||
{ key: '签约', label: '签约', value: stock.signedStations, color: H2_ANALYSIS_CHART_COLORS.signed },
|
||||
{ key: '普通', label: '普通', value: stock.normalStations, color: H2_ANALYSIS_CHART_COLORS.normal }
|
||||
];
|
||||
var statusSegments = [
|
||||
{ key: '营业中', label: '营业中', value: stock.operatingStations, color: '#10b981' },
|
||||
{ key: '暂停营业', label: '暂停', value: Math.round(stock.pausedStoppedStations * 0.45), color: '#f59e0b' },
|
||||
{ key: '停止营业', label: '停止', value: Math.round(stock.pausedStoppedStations * 0.55), color: '#94a3b8' }
|
||||
];
|
||||
var barItems = provinceStock.map(function (p) {
|
||||
return { province: p.province, signed: p.signed, normal: p.normal };
|
||||
});
|
||||
var pageData = filtered.slice((state.page - 1) * state.pageSize, state.page * state.pageSize);
|
||||
|
||||
var columns = [
|
||||
{ title: '省市', dataIndex: 'region', width: 140, render: function (_v, r) { return h2AnalysisFormatRegion(r); } },
|
||||
{ title: '站点名称', dataIndex: 'name', ellipsis: true },
|
||||
{
|
||||
title: '站点类型', dataIndex: 'stationType', width: 88,
|
||||
render: function (_v, r) {
|
||||
var t = h2AnalysisGetStationType(r);
|
||||
return Tag ? React.createElement(Tag, { className: 'h2-analysis-type-tag', color: t === '签约' ? 'green' : 'gold' }, t) : t;
|
||||
}
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 168 },
|
||||
{
|
||||
title: '营业状态', dataIndex: 'businessStatus', width: 96,
|
||||
render: function (v) {
|
||||
var color = v === '营业中' ? 'success' : v === '暂停营业' ? 'warning' : 'default';
|
||||
return Tag ? React.createElement(Tag, { color: color }, v) : v;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 88, fixed: 'right',
|
||||
render: function (_v, r) {
|
||||
return React.createElement(OperationActions, {
|
||||
actions: [{ key: 'view', label: '查看', onClick: function () { viewStation(r); } }]
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return React.createElement('div', { className: 'h2-analysis-tab-panel' },
|
||||
h2AnalysisRenderCommonFilters(state, setState),
|
||||
h2AnalysisRenderFilterHint(state.chartFilter, function () { setState(Object.assign({}, state, { chartFilter: null, page: 1 })); }),
|
||||
React.createElement('div', { className: 'vm-kpi-row vm-kpi-row--6' },
|
||||
h2AnalysisRenderKpiCard({ key: 'total', title: '全国累计站点数' }, stock.totalStations),
|
||||
h2AnalysisRenderKpiCard({ key: 'signed', title: '签约站点数' }, stock.signedStations),
|
||||
h2AnalysisRenderKpiCard({ key: 'normal', title: '普通站点数' }, stock.normalStations),
|
||||
h2AnalysisRenderKpiCard({ key: 'operating', title: '营业中站点数' }, stock.operatingStations),
|
||||
h2AnalysisRenderKpiCard({ key: 'paused', title: '暂停/停止营业' }, stock.pausedStoppedStations, null, { warn: true }),
|
||||
h2AnalysisRenderKpiCard({ key: 'prov', title: '覆盖省份数' }, stock.provinceCount)
|
||||
),
|
||||
React.createElement('div', { className: 'h2-analysis-chart-grid' },
|
||||
React.createElement(H2AnalysisChartCard, { title: '存量站点类型占比', compact: true },
|
||||
React.createElement(H2AnalysisDonutChart, {
|
||||
segments: typeSegments,
|
||||
centerLabel: '累计',
|
||||
centerValue: stock.totalStations,
|
||||
activeKey: state.chartFilter && state.chartFilter.type === 'stationType' ? state.chartFilter.value : null,
|
||||
onSegmentClick: function (seg) {
|
||||
setState(Object.assign({}, state, {
|
||||
chartFilter: h2AnalysisToggleChartFilter(state.chartFilter, { type: 'stationType', value: seg.key, label: seg.label }),
|
||||
page: 1
|
||||
}));
|
||||
}
|
||||
})
|
||||
),
|
||||
React.createElement(H2AnalysisChartCard, { title: '营业状态占比', compact: true },
|
||||
React.createElement(H2AnalysisDonutChart, {
|
||||
segments: statusSegments,
|
||||
centerLabel: '站点',
|
||||
centerValue: stock.totalStations,
|
||||
activeKey: state.chartFilter && state.chartFilter.type === 'businessStatus' ? state.chartFilter.value : null,
|
||||
onSegmentClick: function (seg) {
|
||||
setState(Object.assign({}, state, {
|
||||
chartFilter: h2AnalysisToggleChartFilter(state.chartFilter, { type: 'businessStatus', value: seg.key, label: seg.label }),
|
||||
page: 1
|
||||
}));
|
||||
}
|
||||
})
|
||||
),
|
||||
React.createElement(H2AnalysisChartCard, {
|
||||
title: '各省存量站点分布',
|
||||
legend: React.createElement(React.Fragment, null,
|
||||
H2AnalysisLegendDot(H2_ANALYSIS_CHART_COLORS.signed, '签约'),
|
||||
H2AnalysisLegendDot(H2_ANALYSIS_CHART_COLORS.normal, '普通')
|
||||
)
|
||||
},
|
||||
React.createElement(H2AnalysisHorizontalStackedBar, {
|
||||
items: barItems,
|
||||
keys: [
|
||||
{ key: 'signed', color: H2_ANALYSIS_CHART_COLORS.signed, label: '签约' },
|
||||
{ key: 'normal', color: H2_ANALYSIS_CHART_COLORS.normal, label: '普通' }
|
||||
],
|
||||
activeFilter: state.chartFilter && state.chartFilter.type === 'province' ? { province: state.chartFilter.value } : null,
|
||||
onBarClick: function (payload) {
|
||||
setState(Object.assign({}, state, {
|
||||
chartFilter: h2AnalysisToggleChartFilter(state.chartFilter, { type: 'province', value: payload.province, label: payload.province }),
|
||||
page: 1
|
||||
}));
|
||||
}
|
||||
})
|
||||
),
|
||||
React.createElement(H2AnalysisChartCard, {
|
||||
title: '近12期累计站点变化',
|
||||
legend: React.createElement(React.Fragment, null,
|
||||
H2AnalysisLegendDot(H2_ANALYSIS_CHART_COLORS.total, '全部累计'),
|
||||
H2AnalysisLegendDot(H2_ANALYSIS_CHART_COLORS.signed, '签约累计'),
|
||||
H2AnalysisLegendDot(H2_ANALYSIS_CHART_COLORS.normal, '普通累计')
|
||||
)
|
||||
},
|
||||
React.createElement(H2AnalysisLineTrend, { periods: periods })
|
||||
)
|
||||
),
|
||||
React.createElement('div', { className: 'vm-table-section' },
|
||||
React.createElement('div', { className: 'vm-table-toolbar' },
|
||||
React.createElement('span', { className: 'vm-table-toolbar__title' }, '存量站点样本明细'),
|
||||
React.createElement('span', { className: 'vm-table-toolbar__meta' }, '共 ' + filtered.length + ' 条(样本数据)')
|
||||
),
|
||||
Table ? React.createElement(Table, {
|
||||
rowKey: 'id',
|
||||
size: 'small',
|
||||
columns: columns,
|
||||
dataSource: pageData,
|
||||
pagination: false,
|
||||
scroll: { x: 900 }
|
||||
}) : null,
|
||||
React.createElement(TablePagination, {
|
||||
current: state.page,
|
||||
pageSize: state.pageSize,
|
||||
total: filtered.length,
|
||||
pageSizeOptions: COMPACT_PAGE_SIZE_OPTIONS,
|
||||
onChange: function (p, ps) { setState(Object.assign({}, state, { page: p, pageSize: ps })); }
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function H2AnalysisRiskTab(props) {
|
||||
var stations = props.stations;
|
||||
var viewStation = props.viewStation;
|
||||
var state = props.state;
|
||||
var setState = props.setState;
|
||||
var Tag = window.antd && window.antd.Tag;
|
||||
var Table = window.antd && window.antd.Table;
|
||||
var riskSummary = h2AnalysisGetRiskSummary(stations);
|
||||
var balanceSegs = h2AnalysisGetBalanceDistribution(stations);
|
||||
var priceSegs = h2AnalysisGetPriceDistribution(stations);
|
||||
var contractSegs = h2AnalysisGetContractBuckets(stations);
|
||||
var lowBalance = h2AnalysisGetLowBalanceTop(stations, 10);
|
||||
var filtered = h2AnalysisFilterRiskStations(stations, {
|
||||
region: state.region,
|
||||
stationType: state.stationType,
|
||||
businessStatus: state.businessStatus,
|
||||
chartFilter: state.chartFilter
|
||||
});
|
||||
var pageData = filtered.slice((state.page - 1) * state.pageSize, state.page * state.pageSize);
|
||||
|
||||
var columns = [
|
||||
{ title: '站点名称', dataIndex: 'name', ellipsis: true, width: 180 },
|
||||
{ title: '省市', dataIndex: 'region', width: 130, render: function (_v, r) { return h2AnalysisFormatRegion(r); } },
|
||||
{
|
||||
title: '风险类型', key: 'risks', width: 180,
|
||||
render: function (_v, r) {
|
||||
var risks = h2AnalysisGetRiskTypes(r);
|
||||
return risks.map(function (risk) {
|
||||
var color = risk.indexOf('负') >= 0 || risk.indexOf('到期') >= 0 ? 'error' : risk.indexOf('预警') >= 0 ? 'warning' : 'default';
|
||||
return Tag ? React.createElement(Tag, { key: risk, className: 'h2-analysis-risk-tag', color: color }, risk) : risk;
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '当前余额', dataIndex: 'prepaidBalance', width: 120,
|
||||
render: function (v, r) {
|
||||
var st = h2AnalysisGetBalanceStatus(r);
|
||||
var color = st === 'negative' ? '#dc2626' : st === 'alert' ? '#d97706' : '#0f172a';
|
||||
return React.createElement('span', { style: { color: color, fontWeight: 600 } }, h2AnalysisFormatYuan(v));
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '合同剩余天数', key: 'contractDays', width: 110,
|
||||
render: function (_v, r) {
|
||||
var days = h2AnalysisGetContractDays(r);
|
||||
if (days == null) return '—';
|
||||
if (days < 0) return React.createElement('span', { style: { color: '#dc2626' } }, '已到期');
|
||||
if (days <= 30) return React.createElement('span', { style: { color: '#d97706' } }, days + ' 天');
|
||||
return days + ' 天';
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '营业状态', dataIndex: 'businessStatus', width: 96,
|
||||
render: function (v) {
|
||||
var color = v === '营业中' ? 'success' : v === '暂停营业' ? 'warning' : 'default';
|
||||
return Tag ? React.createElement(Tag, { color: color }, v) : v;
|
||||
}
|
||||
},
|
||||
{ title: '负责人', dataIndex: 'contact', width: 88 },
|
||||
{
|
||||
title: '操作', key: 'action', width: 88, fixed: 'right',
|
||||
render: function (_v, r) {
|
||||
return React.createElement(OperationActions, {
|
||||
actions: [{ key: 'view', label: '查看', onClick: function () { viewStation(r); } }]
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return React.createElement('div', { className: 'h2-analysis-tab-panel' },
|
||||
h2AnalysisRenderCommonFilters(state, setState),
|
||||
h2AnalysisRenderFilterHint(state.chartFilter, function () { setState(Object.assign({}, state, { chartFilter: null, page: 1 })); }),
|
||||
React.createElement('div', { className: 'vm-kpi-row vm-kpi-row--5' },
|
||||
h2AnalysisRenderKpiCard({ key: 'alert', title: '余额预警站点数' }, riskSummary.balanceAlert, null, {
|
||||
warn: true, clickable: true, active: state.chartFilter && state.chartFilter.type === 'riskType' && state.chartFilter.value === '余额预警',
|
||||
onClick: function () {
|
||||
setState(Object.assign({}, state, {
|
||||
chartFilter: h2AnalysisToggleChartFilter(state.chartFilter, { type: 'riskType', value: '余额预警', label: '余额预警' }),
|
||||
page: 1
|
||||
}));
|
||||
}
|
||||
}),
|
||||
h2AnalysisRenderKpiCard({ key: 'neg', title: '负余额站点数' }, riskSummary.negative, null, {
|
||||
danger: true, clickable: true, active: state.chartFilter && state.chartFilter.type === 'riskType' && state.chartFilter.value === '负余额',
|
||||
onClick: function () {
|
||||
setState(Object.assign({}, state, {
|
||||
chartFilter: h2AnalysisToggleChartFilter(state.chartFilter, { type: 'riskType', value: '负余额', label: '负余额' }),
|
||||
page: 1
|
||||
}));
|
||||
}
|
||||
}),
|
||||
h2AnalysisRenderKpiCard({ key: 'exp30', title: '30天内到期站点数' }, riskSummary.expiring30, null, {
|
||||
warn: true, clickable: true, active: state.chartFilter && state.chartFilter.type === 'riskType' && state.chartFilter.value === '合同即将到期',
|
||||
onClick: function () {
|
||||
setState(Object.assign({}, state, {
|
||||
chartFilter: h2AnalysisToggleChartFilter(state.chartFilter, { type: 'riskType', value: '合同即将到期', label: '30天内到期' }),
|
||||
page: 1
|
||||
}));
|
||||
}
|
||||
}),
|
||||
h2AnalysisRenderKpiCard({ key: 'expired', title: '已到期站点数' }, riskSummary.expired, null, {
|
||||
danger: true, clickable: true, active: state.chartFilter && state.chartFilter.type === 'riskType' && state.chartFilter.value === '合同已到期',
|
||||
onClick: function () {
|
||||
setState(Object.assign({}, state, {
|
||||
chartFilter: h2AnalysisToggleChartFilter(state.chartFilter, { type: 'riskType', value: '合同已到期', label: '已到期' }),
|
||||
page: 1
|
||||
}));
|
||||
}
|
||||
}),
|
||||
h2AnalysisRenderKpiCard({ key: 'noPrice', title: '未配置价格站点数' }, riskSummary.noPrice, null, {
|
||||
clickable: true, active: state.chartFilter && state.chartFilter.type === 'riskType' && state.chartFilter.value === '未配置价格',
|
||||
onClick: function () {
|
||||
setState(Object.assign({}, state, {
|
||||
chartFilter: h2AnalysisToggleChartFilter(state.chartFilter, { type: 'riskType', value: '未配置价格', label: '未配置价格' }),
|
||||
page: 1
|
||||
}));
|
||||
}
|
||||
})
|
||||
),
|
||||
React.createElement('div', { className: 'h2-analysis-chart-grid' },
|
||||
React.createElement(H2AnalysisChartCard, { title: '预付余额状态分布', compact: true },
|
||||
React.createElement(H2AnalysisDonutChart, {
|
||||
segments: balanceSegs,
|
||||
centerLabel: '站点',
|
||||
activeKey: state.chartFilter && state.chartFilter.type === 'balanceStatus' ? state.chartFilter.value : null,
|
||||
onSegmentClick: function (seg) {
|
||||
setState(Object.assign({}, state, {
|
||||
chartFilter: h2AnalysisToggleChartFilter(state.chartFilter, { type: 'balanceStatus', value: seg.key, label: seg.label }),
|
||||
page: 1
|
||||
}));
|
||||
}
|
||||
})
|
||||
),
|
||||
React.createElement(H2AnalysisChartCard, { title: '价格配置类型占比', compact: true },
|
||||
React.createElement(H2AnalysisDonutChart, {
|
||||
segments: priceSegs,
|
||||
centerLabel: '配置',
|
||||
activeKey: state.chartFilter && state.chartFilter.type === 'priceType' ? state.chartFilter.value : null,
|
||||
onSegmentClick: function (seg) {
|
||||
setState(Object.assign({}, state, {
|
||||
chartFilter: h2AnalysisToggleChartFilter(state.chartFilter, { type: 'priceType', value: seg.key, label: seg.label }),
|
||||
page: 1
|
||||
}));
|
||||
}
|
||||
})
|
||||
),
|
||||
React.createElement(H2AnalysisChartCard, { title: '余额最低 Top10 站点' },
|
||||
React.createElement(H2AnalysisHorizontalBar, {
|
||||
items: lowBalance,
|
||||
activeId: state.chartFilter && state.chartFilter.type === 'station' ? state.chartFilter.value : null,
|
||||
onBarClick: function (it) {
|
||||
setState(Object.assign({}, state, {
|
||||
chartFilter: h2AnalysisToggleChartFilter(state.chartFilter, { type: 'station', value: it.id, label: it.name }),
|
||||
page: 1
|
||||
}));
|
||||
}
|
||||
})
|
||||
),
|
||||
React.createElement(H2AnalysisChartCard, { title: '合同剩余天数分布' },
|
||||
React.createElement(H2AnalysisSegmentBar, {
|
||||
segments: contractSegs,
|
||||
activeKey: state.chartFilter && state.chartFilter.type === 'contractBucket' ? state.chartFilter.value : null,
|
||||
onSegmentClick: function (seg) {
|
||||
setState(Object.assign({}, state, {
|
||||
chartFilter: h2AnalysisToggleChartFilter(state.chartFilter, { type: 'contractBucket', value: seg.key, label: seg.label }),
|
||||
page: 1
|
||||
}));
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
React.createElement('div', { className: 'vm-table-section' },
|
||||
React.createElement('div', { className: 'vm-table-toolbar' },
|
||||
React.createElement('span', { className: 'vm-table-toolbar__title' }, '风险明细'),
|
||||
React.createElement('span', { className: 'vm-table-toolbar__meta' }, '共 ' + filtered.length + ' 条')
|
||||
),
|
||||
Table ? React.createElement(Table, {
|
||||
rowKey: 'id',
|
||||
size: 'small',
|
||||
columns: columns,
|
||||
dataSource: pageData,
|
||||
pagination: false,
|
||||
scroll: { x: 1100 }
|
||||
}) : null,
|
||||
React.createElement(TablePagination, {
|
||||
current: state.page,
|
||||
pageSize: state.pageSize,
|
||||
total: filtered.length,
|
||||
pageSizeOptions: COMPACT_PAGE_SIZE_OPTIONS,
|
||||
onChange: function (p, ps) { setState(Object.assign({}, state, { page: p, pageSize: ps })); }
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const Component = function () {
|
||||
var Tabs = window.antd && window.antd.Tabs;
|
||||
var dayjs = h2AnalysisEnsureDayjs();
|
||||
var defaultWeek = dayjs ? h2AnalysisStartOfWeek(dayjs()) : null;
|
||||
|
||||
var activeTabState = React.useState('new');
|
||||
var activeTab = activeTabState[0];
|
||||
var setActiveTab = activeTabState[1];
|
||||
|
||||
var newTabState = React.useState({
|
||||
weekValue: defaultWeek,
|
||||
region: [],
|
||||
stationType: '',
|
||||
businessStatus: '',
|
||||
chartFilter: null,
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
});
|
||||
var stockTabState = React.useState({
|
||||
region: [],
|
||||
stationType: '',
|
||||
businessStatus: '',
|
||||
chartFilter: null,
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
});
|
||||
var riskTabState = React.useState({
|
||||
region: [],
|
||||
stationType: '',
|
||||
businessStatus: '',
|
||||
chartFilter: null,
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
});
|
||||
|
||||
var viewModalState = React.useState({ open: false, record: null });
|
||||
var viewModal = viewModalState[0];
|
||||
var setViewModal = viewModalState[1];
|
||||
|
||||
var stations = H2_ANALYSIS_MOCK_STATIONS;
|
||||
|
||||
function openView(record) {
|
||||
var weeklyRow = {
|
||||
stationName: record.name,
|
||||
province: record.region[0],
|
||||
city: record.region[1],
|
||||
stationType: h2AnalysisGetStationType(record),
|
||||
entryAt: record.createdAt
|
||||
};
|
||||
var viewRecord = h2WeeklyResolveStationViewRecord(weeklyRow);
|
||||
setViewModal({ open: true, record: viewRecord });
|
||||
}
|
||||
|
||||
var tabItems = [
|
||||
{
|
||||
key: 'new',
|
||||
label: '新增站点分析',
|
||||
children: React.createElement(H2AnalysisNewTab, {
|
||||
stations: stations,
|
||||
viewStation: openView,
|
||||
state: newTabState[0],
|
||||
setState: newTabState[1]
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'stock',
|
||||
label: '存量站点总览',
|
||||
children: React.createElement(H2AnalysisStockTab, {
|
||||
stations: stations,
|
||||
viewStation: openView,
|
||||
state: stockTabState[0],
|
||||
setState: stockTabState[1]
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'risk',
|
||||
label: '经营风险监控',
|
||||
children: React.createElement(H2AnalysisRiskTab, {
|
||||
stations: stations,
|
||||
viewStation: openView,
|
||||
state: riskTabState[0],
|
||||
setState: riskTabState[1]
|
||||
})
|
||||
}
|
||||
];
|
||||
|
||||
return React.createElement('div', { className: 'vm-page lc-page h2-analysis-page' },
|
||||
React.createElement('style', null, H2_ANALYSIS_PAGE_STYLE),
|
||||
React.createElement('div', { className: 'h2-analysis-head' },
|
||||
React.createElement('h1', { className: 'h2-analysis-head__title' }, '加氢站分析'),
|
||||
React.createElement('p', { className: 'h2-analysis-head__sub' }, '新增分布 · 存量统计 · 经营风险')
|
||||
),
|
||||
Tabs ? React.createElement(Tabs, {
|
||||
className: 'h2-analysis-main-tabs',
|
||||
activeKey: activeTab,
|
||||
onChange: setActiveTab,
|
||||
items: tabItems
|
||||
}) : tabItems[0].children,
|
||||
React.createElement(H2StationViewModal, {
|
||||
open: viewModal.open,
|
||||
record: viewModal.record,
|
||||
onClose: function () { setViewModal({ open: false, record: null }); }
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export default Component;
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* 加氢站分析 — SVG 图表组件(环形图 / 横向堆叠条形 / 堆叠柱状 / 折线)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
var COLORS = {
|
||||
signed: '#10b981',
|
||||
normal: '#f59e0b',
|
||||
total: '#6366f1',
|
||||
operating: '#10b981',
|
||||
paused: '#f59e0b',
|
||||
stopped: '#94a3b8'
|
||||
};
|
||||
|
||||
function h2ChartDonutSeg(cx, cy, r0, r1, a0, a1) {
|
||||
if (a1 - a0 < 0.04) return '';
|
||||
var rad = function (deg) { return (deg - 90) * Math.PI / 180; };
|
||||
var x1 = cx + r1 * Math.cos(rad(a0));
|
||||
var y1 = cy + r1 * Math.sin(rad(a0));
|
||||
var x2 = cx + r1 * Math.cos(rad(a1));
|
||||
var y2 = cy + r1 * Math.sin(rad(a1));
|
||||
var x3 = cx + r0 * Math.cos(rad(a1));
|
||||
var y3 = cy + r0 * Math.sin(rad(a1));
|
||||
var x4 = cx + r0 * Math.cos(rad(a0));
|
||||
var y4 = cy + r0 * Math.sin(rad(a0));
|
||||
var large = (a1 - a0) > 180 ? 1 : 0;
|
||||
return 'M' + x1 + ',' + y1 + ' A' + r1 + ',' + r1 + ' 0 ' + large + ' 1 ' + x2 + ',' + y2 +
|
||||
' L' + x3 + ',' + y3 + ' A' + r0 + ',' + r0 + ' 0 ' + large + ' 0 ' + x4 + ',' + y4 + ' Z';
|
||||
}
|
||||
|
||||
export function H2AnalysisDonutChart(props) {
|
||||
var segments = props.segments || [];
|
||||
var onSegmentClick = props.onSegmentClick;
|
||||
var activeKey = props.activeKey;
|
||||
var centerLabel = props.centerLabel || '';
|
||||
var centerValue = props.centerValue;
|
||||
var total = segments.reduce(function (sum, s) { return sum + (s.value || 0); }, 0) || 1;
|
||||
var cx = 100;
|
||||
var cy = 100;
|
||||
var r0 = 58;
|
||||
var r1 = 88;
|
||||
var angle = 0;
|
||||
var paths = [];
|
||||
|
||||
segments.forEach(function (seg) {
|
||||
var slice = total > 0 ? (seg.value / total) * 360 : 0;
|
||||
if (slice < 0.05) { angle += slice; return; }
|
||||
var a0 = angle;
|
||||
var a1 = angle + slice;
|
||||
angle = a1;
|
||||
var d = slice >= 359.95 ? h2ChartDonutSeg(cx, cy, r0, r1, 0, 360) : h2ChartDonutSeg(cx, cy, r0, r1, a0, a1);
|
||||
if (!d) return;
|
||||
var isActive = activeKey == null || activeKey === seg.key;
|
||||
paths.push(React.createElement('path', {
|
||||
key: seg.key,
|
||||
d: d,
|
||||
fill: seg.color,
|
||||
stroke: '#fff',
|
||||
strokeWidth: 2,
|
||||
opacity: isActive ? 1 : 0.35,
|
||||
style: { cursor: onSegmentClick ? 'pointer' : 'default', transition: 'opacity 0.2s' },
|
||||
onClick: onSegmentClick ? function () { onSegmentClick(seg); } : undefined,
|
||||
role: onSegmentClick ? 'button' : undefined,
|
||||
'aria-label': seg.label + ' ' + seg.value
|
||||
}));
|
||||
});
|
||||
|
||||
return React.createElement('div', { className: 'h2-analysis-donut-wrap' },
|
||||
React.createElement('svg', {
|
||||
className: 'h2-analysis-chart-svg h2-analysis-donut-svg',
|
||||
viewBox: '0 0 200 200',
|
||||
role: 'img',
|
||||
'aria-label': props.title || '环形图'
|
||||
},
|
||||
paths,
|
||||
React.createElement('text', {
|
||||
x: cx, y: cy - 6, textAnchor: 'middle', className: 'h2-analysis-donut-center-val'
|
||||
}, centerValue != null ? centerValue : total),
|
||||
React.createElement('text', {
|
||||
x: cx, y: cy + 14, textAnchor: 'middle', className: 'h2-analysis-donut-center-label'
|
||||
}, centerLabel)
|
||||
),
|
||||
React.createElement('div', { className: 'h2-analysis-donut-legend' },
|
||||
segments.map(function (seg) {
|
||||
var pct = total > 0 ? ((seg.value / total) * 100).toFixed(1) : '0';
|
||||
var isActive = activeKey == null || activeKey === seg.key;
|
||||
return React.createElement('button', {
|
||||
key: seg.key,
|
||||
type: 'button',
|
||||
className: 'h2-analysis-legend-item' + (isActive ? ' is-active' : ''),
|
||||
onClick: onSegmentClick ? function () { onSegmentClick(seg); } : undefined
|
||||
},
|
||||
React.createElement('span', { className: 'h2-analysis-legend-dot', style: { background: seg.color } }),
|
||||
React.createElement('span', { className: 'h2-analysis-legend-label' }, seg.label),
|
||||
React.createElement('span', { className: 'h2-analysis-legend-val' }, seg.value + ' (' + pct + '%)')
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function H2AnalysisHorizontalStackedBar(props) {
|
||||
var items = props.items || [];
|
||||
var keys = props.keys || [{ key: 'signed', color: COLORS.signed, label: '签约' }, { key: 'normal', color: COLORS.normal, label: '普通' }];
|
||||
var onBarClick = props.onBarClick;
|
||||
var activeFilter = props.activeFilter;
|
||||
var maxVal = Math.max.apply(null, items.map(function (it) {
|
||||
return keys.reduce(function (s, k) { return s + (it[k.key] || 0); }, 0);
|
||||
}).concat([1]));
|
||||
var barH = props.barHeight || 22;
|
||||
var gap = props.gap || 10;
|
||||
var labelW = props.labelWidth || 72;
|
||||
var W = props.width || 520;
|
||||
var chartW = W - labelW - 48;
|
||||
var H = items.length * (barH + gap) + 16;
|
||||
|
||||
return React.createElement('svg', {
|
||||
className: 'h2-analysis-chart-svg',
|
||||
viewBox: '0 0 ' + W + ' ' + H,
|
||||
role: 'img',
|
||||
'aria-label': props.title || '横向堆叠条形图'
|
||||
},
|
||||
items.map(function (it, idx) {
|
||||
var y = 8 + idx * (barH + gap);
|
||||
var total = keys.reduce(function (s, k) { return s + (it[k.key] || 0); }, 0);
|
||||
var x = labelW;
|
||||
var isRowActive = !activeFilter || activeFilter.province === it.province;
|
||||
return React.createElement('g', {
|
||||
key: it.province || it.label || idx,
|
||||
opacity: isRowActive ? 1 : 0.35
|
||||
},
|
||||
React.createElement('text', {
|
||||
x: labelW - 8, y: y + barH / 2 + 4, textAnchor: 'end', className: 'h2-analysis-bar-label'
|
||||
}, it.province || it.label),
|
||||
keys.reduce(function (nodes, k) {
|
||||
var val = it[k.key] || 0;
|
||||
if (!val) return nodes;
|
||||
var w = (val / maxVal) * chartW;
|
||||
var rect = React.createElement('rect', {
|
||||
key: k.key,
|
||||
x: x, y: y, width: w, height: barH,
|
||||
rx: 4, fill: k.color,
|
||||
style: { cursor: onBarClick ? 'pointer' : 'default' },
|
||||
onClick: onBarClick ? function () { onBarClick({ province: it.province, stationType: k.label, key: k.key }); } : undefined
|
||||
});
|
||||
nodes.push(rect);
|
||||
x += w;
|
||||
return nodes;
|
||||
}, []),
|
||||
total > 0 ? React.createElement('text', {
|
||||
x: labelW + (total / maxVal) * chartW + 6,
|
||||
y: y + barH / 2 + 4,
|
||||
className: 'h2-analysis-bar-val'
|
||||
}, total) : null
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function H2AnalysisStackedBarTrend(props) {
|
||||
var periods = props.periods || [];
|
||||
var onBarClick = props.onBarClick;
|
||||
var activePeriod = props.activePeriod;
|
||||
var W = 860;
|
||||
var H = 280;
|
||||
var PL = 44;
|
||||
var PR = 16;
|
||||
var PT = 16;
|
||||
var PB = 42;
|
||||
var chartW = W - PL - PR;
|
||||
var chartH = H - PT - PB;
|
||||
var count = periods.length || 1;
|
||||
var groupW = chartW / count;
|
||||
var barW = Math.min(28, groupW * 0.55);
|
||||
var maxNew = Math.max.apply(null, periods.map(function (p) { return p.newTotal || 0; }).concat([1]));
|
||||
|
||||
return React.createElement('svg', {
|
||||
className: 'h2-analysis-chart-svg',
|
||||
viewBox: '0 0 ' + W + ' ' + H,
|
||||
role: 'img',
|
||||
'aria-label': props.title || '堆叠柱状趋势图'
|
||||
},
|
||||
[0, Math.ceil(maxNew / 2), maxNew].map(function (tick, idx) {
|
||||
var y = PT + chartH - (tick / maxNew) * chartH;
|
||||
return React.createElement('g', { key: 'y-' + idx },
|
||||
React.createElement('line', { x1: PL, y1: y, x2: W - PR, y2: y, stroke: '#f1f5f9', strokeWidth: 1 }),
|
||||
React.createElement('text', { x: PL - 8, y: y + 4, textAnchor: 'end', className: 'h2-analysis-axis-tick' }, tick)
|
||||
);
|
||||
}),
|
||||
periods.map(function (p, idx) {
|
||||
var cx = PL + groupW * idx + groupW / 2;
|
||||
var signedH = ((p.newCooperative || 0) / maxNew) * chartH;
|
||||
var normalH = ((p.newNormal || 0) / maxNew) * chartH;
|
||||
var baseY = PT + chartH;
|
||||
var isActive = activePeriod == null || activePeriod === idx;
|
||||
return React.createElement('g', {
|
||||
key: 'p-' + idx,
|
||||
opacity: isActive ? 1 : 0.35,
|
||||
style: { cursor: onBarClick ? 'pointer' : 'default' },
|
||||
onClick: onBarClick ? function () { onBarClick(idx, p); } : undefined
|
||||
},
|
||||
React.createElement('rect', {
|
||||
x: cx - barW / 2, y: baseY - signedH - normalH, width: barW, height: signedH,
|
||||
rx: 3, fill: COLORS.signed
|
||||
}),
|
||||
React.createElement('rect', {
|
||||
x: cx - barW / 2, y: baseY - normalH, width: barW, height: normalH,
|
||||
rx: 3, fill: COLORS.normal
|
||||
}),
|
||||
(p.newTotal || 0) > 0 ? React.createElement('text', {
|
||||
x: cx, y: baseY - signedH - normalH - 4, textAnchor: 'middle', className: 'h2-analysis-bar-val'
|
||||
}, p.newTotal) : null,
|
||||
React.createElement('text', {
|
||||
x: cx, y: H - 12, textAnchor: 'middle', className: 'h2-analysis-axis-label'
|
||||
}, p.label)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function H2AnalysisLineTrend(props) {
|
||||
var periods = props.periods || [];
|
||||
var onPointClick = props.onPointClick;
|
||||
var W = 860;
|
||||
var H = 280;
|
||||
var PL = 52;
|
||||
var PR = 16;
|
||||
var PT = 16;
|
||||
var PB = 42;
|
||||
var chartW = W - PL - PR;
|
||||
var chartH = H - PT - PB;
|
||||
var series = [
|
||||
{ key: 'cumulativeTotal', color: COLORS.total, label: '全部累计' },
|
||||
{ key: 'cumulativeCooperative', color: COLORS.signed, label: '签约累计' },
|
||||
{ key: 'cumulativeNormal', color: COLORS.normal, label: '普通累计' }
|
||||
];
|
||||
var min = Math.min.apply(null, periods.map(function (p) {
|
||||
return Math.min(p.cumulativeTotal, p.cumulativeCooperative, p.cumulativeNormal);
|
||||
}));
|
||||
var max = Math.max.apply(null, periods.map(function (p) {
|
||||
return Math.max(p.cumulativeTotal, p.cumulativeCooperative, p.cumulativeNormal);
|
||||
}));
|
||||
var range = max - min || 1;
|
||||
|
||||
function linePath(key) {
|
||||
return periods.map(function (p, idx) {
|
||||
var x = PL + (periods.length === 1 ? chartW / 2 : (idx / (periods.length - 1)) * chartW);
|
||||
var y = PT + chartH - ((p[key] - min) / range) * chartH;
|
||||
return (idx === 0 ? 'M' : 'L') + x.toFixed(1) + ' ' + y.toFixed(1);
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
return React.createElement('svg', {
|
||||
className: 'h2-analysis-chart-svg',
|
||||
viewBox: '0 0 ' + W + ' ' + H,
|
||||
role: 'img',
|
||||
'aria-label': props.title || '折线趋势图'
|
||||
},
|
||||
[{ value: max, y: PT }, { value: min + range / 2, y: PT + chartH / 2 }, { value: min, y: PT + chartH }].map(function (tick, idx) {
|
||||
return React.createElement('g', { key: 'ly-' + idx },
|
||||
React.createElement('line', { x1: PL, y1: tick.y, x2: W - PR, y2: tick.y, stroke: '#f1f5f9', strokeWidth: 1 }),
|
||||
React.createElement('text', { x: PL - 8, y: tick.y + 4, textAnchor: 'end', className: 'h2-analysis-axis-tick' }, Math.round(tick.value))
|
||||
);
|
||||
}),
|
||||
series.map(function (s) {
|
||||
return React.createElement('path', {
|
||||
key: s.key,
|
||||
d: linePath(s.key),
|
||||
fill: 'none',
|
||||
stroke: s.color,
|
||||
strokeWidth: 2.5,
|
||||
strokeLinejoin: 'round'
|
||||
});
|
||||
}),
|
||||
periods.map(function (p, idx) {
|
||||
if (idx % 3 !== 0 && idx !== periods.length - 1) return null;
|
||||
var x = PL + (periods.length === 1 ? chartW / 2 : (idx / (periods.length - 1)) * chartW);
|
||||
return React.createElement('text', {
|
||||
key: 'xl-' + idx,
|
||||
x: x, y: H - 12, textAnchor: 'middle', className: 'h2-analysis-axis-label'
|
||||
}, p.label);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function H2AnalysisSegmentBar(props) {
|
||||
var segments = props.segments || [];
|
||||
var onSegmentClick = props.onSegmentClick;
|
||||
var activeKey = props.activeKey;
|
||||
var W = 520;
|
||||
var H = 200;
|
||||
var PL = 44;
|
||||
var PR = 16;
|
||||
var PT = 24;
|
||||
var PB = 48;
|
||||
var chartW = W - PL - PR;
|
||||
var chartH = H - PT - PB;
|
||||
var maxVal = Math.max.apply(null, segments.map(function (s) { return s.value; }).concat([1]));
|
||||
var barW = Math.min(56, chartW / segments.length - 16);
|
||||
|
||||
return React.createElement('svg', {
|
||||
className: 'h2-analysis-chart-svg',
|
||||
viewBox: '0 0 ' + W + ' ' + H,
|
||||
role: 'img',
|
||||
'aria-label': props.title || '分段柱状图'
|
||||
},
|
||||
segments.map(function (seg, idx) {
|
||||
var cx = PL + (idx + 0.5) * (chartW / segments.length);
|
||||
var h = (seg.value / maxVal) * chartH;
|
||||
var y = PT + chartH - h;
|
||||
var isActive = activeKey == null || activeKey === seg.key;
|
||||
return React.createElement('g', {
|
||||
key: seg.key,
|
||||
opacity: isActive ? 1 : 0.35,
|
||||
style: { cursor: onSegmentClick ? 'pointer' : 'default' },
|
||||
onClick: onSegmentClick ? function () { onSegmentClick(seg); } : undefined
|
||||
},
|
||||
React.createElement('rect', {
|
||||
x: cx - barW / 2, y: y, width: barW, height: h,
|
||||
rx: 4, fill: seg.color
|
||||
}),
|
||||
seg.value > 0 ? React.createElement('text', {
|
||||
x: cx, y: y - 6, textAnchor: 'middle', className: 'h2-analysis-bar-val'
|
||||
}, seg.value) : null,
|
||||
React.createElement('text', {
|
||||
x: cx, y: H - 16, textAnchor: 'middle', className: 'h2-analysis-axis-label'
|
||||
}, seg.label)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function H2AnalysisHorizontalBar(props) {
|
||||
var items = props.items || [];
|
||||
var onBarClick = props.onBarClick;
|
||||
var activeId = props.activeId;
|
||||
var maxAbs = Math.max.apply(null, items.map(function (it) {
|
||||
return Math.abs(it.value);
|
||||
}).concat([1]));
|
||||
var barH = 20;
|
||||
var gap = 8;
|
||||
var labelW = 120;
|
||||
var W = props.width || 520;
|
||||
var chartW = W - labelW - 56;
|
||||
var H = items.length * (barH + gap) + 12;
|
||||
|
||||
return React.createElement('svg', {
|
||||
className: 'h2-analysis-chart-svg',
|
||||
viewBox: '0 0 ' + W + ' ' + H,
|
||||
role: 'img',
|
||||
'aria-label': props.title || '横向条形图'
|
||||
},
|
||||
items.map(function (it, idx) {
|
||||
var y = 6 + idx * (barH + gap);
|
||||
var w = (Math.abs(it.value) / maxAbs) * chartW;
|
||||
var isActive = activeId == null || activeId === it.id;
|
||||
var fill = it.value < 0 ? '#ef4444' : '#10b981';
|
||||
return React.createElement('g', {
|
||||
key: it.id || idx,
|
||||
opacity: isActive ? 1 : 0.35,
|
||||
style: { cursor: onBarClick ? 'pointer' : 'default' },
|
||||
onClick: onBarClick ? function () { onBarClick(it); } : undefined
|
||||
},
|
||||
React.createElement('text', {
|
||||
x: 0, y: y + barH / 2 + 4, className: 'h2-analysis-bar-label-sm'
|
||||
}, (it.name || '').length > 10 ? (it.name || '').slice(0, 10) + '…' : it.name),
|
||||
React.createElement('rect', {
|
||||
x: labelW, y: y, width: Math.max(w, 2), height: barH, rx: 4, fill: fill
|
||||
}),
|
||||
React.createElement('text', {
|
||||
x: labelW + w + 6, y: y + barH / 2 + 4, className: 'h2-analysis-bar-val'
|
||||
}, it.value)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function H2AnalysisChartCard(props) {
|
||||
return React.createElement('div', { className: 'h2-analysis-chart-card' },
|
||||
React.createElement('div', { className: 'h2-analysis-chart-head' },
|
||||
React.createElement('span', { className: 'h2-analysis-chart-head__title' }, props.title),
|
||||
props.legend ? React.createElement('div', { className: 'h2-analysis-chart-legend' }, props.legend) : null
|
||||
),
|
||||
React.createElement('div', { className: 'h2-analysis-chart-body' + (props.compact ? ' is-compact' : '') }, props.children)
|
||||
);
|
||||
}
|
||||
|
||||
export function H2AnalysisLegendDot(color, label) {
|
||||
return React.createElement('span', { className: 'h2-analysis-legend-inline' },
|
||||
React.createElement('span', { className: 'h2-analysis-legend-dot', style: { background: color } }),
|
||||
label
|
||||
);
|
||||
}
|
||||
|
||||
export { COLORS as H2_ANALYSIS_CHART_COLORS };
|
||||
508
src/prototypes/oneos-web-h2-station-analysis/shared/h2-analysis-mock.js
vendored
Normal file
508
src/prototypes/oneos-web-h2-station-analysis/shared/h2-analysis-mock.js
vendored
Normal file
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* 加氢站分析 — Mock 数据与聚合工具
|
||||
* 字段口径与「站点信息」「站点周报统计」对齐
|
||||
*/
|
||||
|
||||
export var H2_ANALYSIS_REGION_OPTIONS = [
|
||||
{ value: '湖北省', label: '湖北省', children: [{ value: '宜昌市', label: '宜昌市' }, { value: '黄石市', label: '黄石市' }] },
|
||||
{ value: '四川省', label: '四川省', children: [{ value: '资阳市', label: '资阳市' }] },
|
||||
{ value: '上海市', label: '上海市', children: [{ value: '奉贤区', label: '奉贤区' }, { value: '上海市', label: '上海市' }] },
|
||||
{ value: '浙江省', label: '浙江省', children: [{ value: '嘉兴市', label: '嘉兴市' }, { value: '杭州市', label: '杭州市' }, { value: '宁波市', label: '宁波市' }] },
|
||||
{ value: '江苏省', label: '江苏省', children: [{ value: '南京市', label: '南京市' }, { value: '苏州市', label: '苏州市' }] },
|
||||
{ value: '广东省', label: '广东省', children: [{ value: '广州市', label: '广州市' }, { value: '深圳市', label: '深圳市' }] }
|
||||
];
|
||||
|
||||
/** 各省存量站点分布(签约/普通),与全国累计 284 对齐 */
|
||||
var H2_ANALYSIS_PROVINCE_STOCK = [
|
||||
{ province: '浙江省', signed: 22, normal: 38 },
|
||||
{ province: '江苏省', signed: 14, normal: 31 },
|
||||
{ province: '广东省', signed: 12, normal: 28 },
|
||||
{ province: '湖北省', signed: 11, normal: 24 },
|
||||
{ province: '上海市', signed: 9, normal: 18 },
|
||||
{ province: '四川省', signed: 8, normal: 19 },
|
||||
{ province: '山东省', signed: 6, normal: 14 },
|
||||
{ province: '河南省', signed: 4, normal: 12 }
|
||||
];
|
||||
|
||||
function h2AnalysisEnsureDayjs() {
|
||||
return typeof window !== 'undefined' && window.dayjs ? window.dayjs : null;
|
||||
}
|
||||
|
||||
export function h2AnalysisStartOfWeek(d) {
|
||||
var dayjs = h2AnalysisEnsureDayjs();
|
||||
if (!dayjs || !d) return null;
|
||||
var cur = dayjs(d);
|
||||
var weekday = cur.day();
|
||||
var diff = weekday === 0 ? -6 : 1 - weekday;
|
||||
return cur.add(diff, 'day').startOf('day');
|
||||
}
|
||||
|
||||
export function h2AnalysisEndOfWeek(d) {
|
||||
var start = h2AnalysisStartOfWeek(d);
|
||||
return start ? start.add(6, 'day').endOf('day') : null;
|
||||
}
|
||||
|
||||
export function h2AnalysisIsSameWeek(a, b) {
|
||||
var sa = h2AnalysisStartOfWeek(a);
|
||||
var sb = h2AnalysisStartOfWeek(b);
|
||||
if (!sa || !sb) return false;
|
||||
return sa.format('YYYY-MM-DD') === sb.format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
export function h2AnalysisNum(v) {
|
||||
var n = typeof v === 'number' ? v : parseFloat(v);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
export function h2AnalysisGetStationType(record) {
|
||||
if (!record) return '普通';
|
||||
if (record.stationType) return record.stationType;
|
||||
return record.isSigned ? '签约' : '普通';
|
||||
}
|
||||
|
||||
export function h2AnalysisGetBalanceStatus(record) {
|
||||
var balance = h2AnalysisNum(record && record.prepaidBalance);
|
||||
if (balance < 0) return 'negative';
|
||||
var threshold = record && record.balanceAlertThreshold;
|
||||
if (threshold != null && threshold !== '' && balance < h2AnalysisNum(threshold)) return 'alert';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
export function h2AnalysisGetPriceType(record) {
|
||||
if (!record || !record.priceConfigs || !record.priceConfigs.length) return 'none';
|
||||
var hasFixed = false;
|
||||
var hasTiered = false;
|
||||
record.priceConfigs.forEach(function (pc) {
|
||||
if (pc.priceType === 'fixed') hasFixed = true;
|
||||
if (pc.priceType === 'tiered') hasTiered = true;
|
||||
});
|
||||
if (hasTiered && !hasFixed) return 'tiered';
|
||||
if (hasFixed) return 'fixed';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function h2AnalysisGetContractDays(record) {
|
||||
var dayjs = h2AnalysisEnsureDayjs();
|
||||
if (!dayjs || !record || !record.contractEnd) return null;
|
||||
var end = dayjs(record.contractEnd);
|
||||
if (!end.isValid || !end.isValid()) return null;
|
||||
return end.diff(dayjs(), 'day');
|
||||
}
|
||||
|
||||
export function h2AnalysisGetContractBucket(days) {
|
||||
if (days == null) return 'unknown';
|
||||
if (days < 0) return 'expired';
|
||||
if (days <= 30) return '0-30';
|
||||
if (days <= 60) return '31-60';
|
||||
if (days <= 90) return '61-90';
|
||||
return '90+';
|
||||
}
|
||||
|
||||
export function h2AnalysisGetRiskTypes(record) {
|
||||
var risks = [];
|
||||
if (h2AnalysisGetBalanceStatus(record) === 'alert') risks.push('余额预警');
|
||||
if (h2AnalysisGetBalanceStatus(record) === 'negative') risks.push('负余额');
|
||||
var days = h2AnalysisGetContractDays(record);
|
||||
if (days != null && days < 0) risks.push('合同已到期');
|
||||
else if (days != null && days <= 30) risks.push('合同即将到期');
|
||||
if (h2AnalysisGetPriceType(record) === 'none') risks.push('未配置价格');
|
||||
return risks;
|
||||
}
|
||||
|
||||
/** 明细站点 Mock(含本周新增 + 存量 + 风险样本) */
|
||||
export var H2_ANALYSIS_MOCK_STATIONS = [
|
||||
{
|
||||
id: 'ws-1', name: '宜昌中石化枝江服务区加氢站', region: ['湖北省', '宜昌市'],
|
||||
isSigned: true, stationType: '签约', createdAt: '2026-07-13 10:22:00',
|
||||
businessStatus: '营业中', prepaidBalance: 62800, balanceAlertThreshold: 50000,
|
||||
contractEnd: '2027-03-31', contact: '王供',
|
||||
priceConfigs: [{ priceType: 'fixed', customerScope: 'all', fixedPrice: 43.2 }]
|
||||
},
|
||||
{
|
||||
id: 'ws-2', name: '资阳临空经济区加氢站', region: ['四川省', '资阳市'],
|
||||
isSigned: true, stationType: '签约', createdAt: '2026-07-14 15:08:00',
|
||||
businessStatus: '营业中', prepaidBalance: 45200, balanceAlertThreshold: 40000,
|
||||
contractEnd: '2027-05-31', contact: '李供',
|
||||
priceConfigs: [{ priceType: 'fixed', customerScope: 'all', fixedPrice: 42.8 }]
|
||||
},
|
||||
{
|
||||
id: 'ws-3', name: '黄石开发区加氢站', region: ['湖北省', '黄石市'],
|
||||
isSigned: true, stationType: '签约', createdAt: '2026-07-15 09:41:00',
|
||||
businessStatus: '营业中', prepaidBalance: 38500, balanceAlertThreshold: 30000,
|
||||
contractEnd: '2027-02-28', contact: '周供',
|
||||
priceConfigs: [{ priceType: 'tiered', customerScope: 'all', tierRules: [{ thresholdKg: 0, price: 44 }] }]
|
||||
},
|
||||
{
|
||||
id: 'ws-4', name: '上海奉贤海湾加氢站', region: ['上海市', '奉贤区'],
|
||||
isSigned: true, stationType: '签约', createdAt: '2026-07-16 11:30:00',
|
||||
businessStatus: '营业中', prepaidBalance: 51200, balanceAlertThreshold: 45000,
|
||||
contractEnd: '2027-06-30', contact: '孙供',
|
||||
priceConfigs: [{ priceType: 'fixed', customerScope: 'all', fixedPrice: 45.5 }]
|
||||
},
|
||||
{
|
||||
id: 1, name: '嘉兴加氢站(一期)', region: ['浙江省', '嘉兴市'],
|
||||
isSigned: true, stationType: '签约', createdAt: '2024-01-15 09:00:00',
|
||||
businessStatus: '营业中', prepaidBalance: 85620, balanceAlertThreshold: 50000,
|
||||
contractEnd: '2026-12-31', contact: '张三',
|
||||
priceConfigs: [{ priceType: 'fixed', customerScope: 'all', fixedPrice: 42.5 }, { priceType: 'tiered', customerScope: 'customer' }]
|
||||
},
|
||||
{
|
||||
id: 2, name: '杭州临平加氢站', region: ['浙江省', '杭州市'],
|
||||
isSigned: true, stationType: '签约', createdAt: '2025-03-10 14:20:00',
|
||||
businessStatus: '营业中', prepaidBalance: 125000.5, balanceAlertThreshold: 80000,
|
||||
contractEnd: '2027-02-28', contact: '李四',
|
||||
priceConfigs: [{ priceType: 'fixed', customerScope: 'all', fixedPrice: 43 }]
|
||||
},
|
||||
{
|
||||
id: 3, name: '上海宝山加氢站', region: ['上海市', '上海市'],
|
||||
isSigned: true, stationType: '签约', createdAt: '2023-06-01 10:00:00',
|
||||
businessStatus: '暂停营业', prepaidBalance: -3250, balanceAlertThreshold: 10000,
|
||||
contractEnd: '2025-05-31', contact: '王五',
|
||||
priceConfigs: [{ priceType: 'fixed', customerScope: 'all', fixedPrice: 44 }]
|
||||
},
|
||||
{
|
||||
id: 4, name: '平湖合作停车场(待建加氢)', region: ['浙江省', '嘉兴市'],
|
||||
isSigned: false, stationType: '普通', createdAt: '2025-11-20 11:20:00',
|
||||
businessStatus: '停止营业', prepaidBalance: 0, balanceAlertThreshold: null,
|
||||
contractEnd: '', contact: '赵六', priceConfigs: []
|
||||
},
|
||||
{
|
||||
id: 5, name: '苏州工业园区备用站', region: ['江苏省', '苏州市'],
|
||||
isSigned: false, stationType: '普通', createdAt: '2025-08-05 08:50:00',
|
||||
businessStatus: '停止营业', prepaidBalance: 15800, balanceAlertThreshold: 20000,
|
||||
contractEnd: '', contact: '钱七',
|
||||
priceConfigs: [{ priceType: 'fixed', customerScope: 'all', fixedPrice: 41 }]
|
||||
},
|
||||
{
|
||||
id: 6, name: '桐乡合作加氢站', region: ['浙江省', '嘉兴市'],
|
||||
isSigned: true, stationType: '签约', createdAt: '2025-06-01 15:20:00',
|
||||
businessStatus: '营业中', prepaidBalance: 42000, balanceAlertThreshold: 35000,
|
||||
contractEnd: '2027-05-31', contact: '周八',
|
||||
priceConfigs: [{ priceType: 'tiered', customerScope: 'all', tierRules: [{ thresholdKg: 0, price: 41.8 }] }]
|
||||
},
|
||||
{
|
||||
id: 7, name: '南京江宁加氢站', region: ['江苏省', '南京市'],
|
||||
isSigned: true, stationType: '签约', createdAt: '2024-09-12 10:30:00',
|
||||
businessStatus: '营业中', prepaidBalance: 9800, balanceAlertThreshold: 15000,
|
||||
contractEnd: '2026-08-15', contact: '吴九',
|
||||
priceConfigs: [{ priceType: 'fixed', customerScope: 'all', fixedPrice: 43.5 }]
|
||||
},
|
||||
{
|
||||
id: 8, name: '广州南沙加氢站', region: ['广东省', '广州市'],
|
||||
isSigned: true, stationType: '签约', createdAt: '2024-05-18 16:00:00',
|
||||
businessStatus: '营业中', prepaidBalance: 67200, balanceAlertThreshold: 50000,
|
||||
contractEnd: '2026-07-28', contact: '郑十',
|
||||
priceConfigs: [{ priceType: 'fixed', customerScope: 'all', fixedPrice: 46 }]
|
||||
},
|
||||
{
|
||||
id: 9, name: '深圳坪山加氢站', region: ['广东省', '深圳市'],
|
||||
isSigned: false, stationType: '普通', createdAt: '2025-01-22 09:15:00',
|
||||
businessStatus: '营业中', prepaidBalance: 22800, balanceAlertThreshold: 25000,
|
||||
contractEnd: '', contact: '冯十一',
|
||||
priceConfigs: []
|
||||
},
|
||||
{
|
||||
id: 10, name: '宁波北仑加氢站', region: ['浙江省', '宁波市'],
|
||||
isSigned: false, stationType: '普通', createdAt: '2025-04-08 13:40:00',
|
||||
businessStatus: '暂停营业', prepaidBalance: 5600, balanceAlertThreshold: 8000,
|
||||
contractEnd: '', contact: '陈十二',
|
||||
priceConfigs: [{ priceType: 'tiered', customerScope: 'all', tierRules: [{ thresholdKg: 0, price: 42 }] }]
|
||||
},
|
||||
{
|
||||
id: 11, name: '济南章丘加氢站', region: ['山东省', '济南市'],
|
||||
isSigned: true, stationType: '签约', createdAt: '2024-11-30 08:00:00',
|
||||
businessStatus: '营业中', prepaidBalance: 31000, balanceAlertThreshold: 28000,
|
||||
contractEnd: '2026-09-30', contact: '褚十三',
|
||||
priceConfigs: [{ priceType: 'fixed', customerScope: 'all', fixedPrice: 42.2 }]
|
||||
},
|
||||
{
|
||||
id: 12, name: '郑州航空港加氢站', region: ['河南省', '郑州市'],
|
||||
isSigned: false, stationType: '普通', createdAt: '2025-07-01 11:00:00',
|
||||
businessStatus: '营业中', prepaidBalance: -1200, balanceAlertThreshold: 5000,
|
||||
contractEnd: '', contact: '卫十四',
|
||||
priceConfigs: [{ priceType: 'fixed', customerScope: 'all', fixedPrice: 41.5 }]
|
||||
}
|
||||
];
|
||||
|
||||
/** 近 12 期趋势(与站点周报统计口径一致) */
|
||||
export function h2AnalysisBuildTrendPeriods() {
|
||||
var dayjs = h2AnalysisEnsureDayjs();
|
||||
var now = dayjs ? dayjs() : null;
|
||||
var seeds = [
|
||||
{ newTotal: 1, newCooperative: 0 },
|
||||
{ newTotal: 2, newCooperative: 1 },
|
||||
{ newTotal: 0, newCooperative: 0 },
|
||||
{ newTotal: 3, newCooperative: 1 },
|
||||
{ newTotal: 1, newCooperative: 0 },
|
||||
{ newTotal: 2, newCooperative: 2 },
|
||||
{ newTotal: 0, newCooperative: 0 },
|
||||
{ newTotal: 4, newCooperative: 2 },
|
||||
{ newTotal: 1, newCooperative: 1 },
|
||||
{ newTotal: 2, newCooperative: 0 },
|
||||
{ newTotal: 0, newCooperative: 0 },
|
||||
{ newTotal: 4, newCooperative: 4 }
|
||||
];
|
||||
var cumulativeTotal = 264;
|
||||
var cumulativeCooperative = 75;
|
||||
return seeds.map(function (seed, idx) {
|
||||
var weekStart = now ? h2AnalysisStartOfWeek(now.subtract((11 - idx) * 7, 'day')) : null;
|
||||
var newNormal = seed.newTotal - seed.newCooperative;
|
||||
cumulativeTotal += seed.newTotal;
|
||||
cumulativeCooperative += seed.newCooperative;
|
||||
return {
|
||||
period: idx + 1,
|
||||
label: weekStart ? weekStart.format('MM/DD') : '第' + (idx + 1) + '期',
|
||||
range: weekStart && weekStart.format ? weekStart.format('MM/DD') + '-' + weekStart.add(6, 'day').format('MM/DD') : '',
|
||||
weekStart: weekStart,
|
||||
newTotal: seed.newTotal,
|
||||
newCooperative: seed.newCooperative,
|
||||
newNormal: newNormal,
|
||||
cumulativeTotal: cumulativeTotal,
|
||||
cumulativeCooperative: cumulativeCooperative,
|
||||
cumulativeNormal: cumulativeTotal - cumulativeCooperative
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function h2AnalysisGetStockSummary() {
|
||||
var signed = 0;
|
||||
var normal = 0;
|
||||
H2_ANALYSIS_PROVINCE_STOCK.forEach(function (p) {
|
||||
signed += p.signed;
|
||||
normal += p.normal;
|
||||
});
|
||||
var operating = 0;
|
||||
var pausedStopped = 0;
|
||||
H2_ANALYSIS_MOCK_STATIONS.forEach(function (s) {
|
||||
if (s.businessStatus === '营业中') operating += 1;
|
||||
else pausedStopped += 1;
|
||||
});
|
||||
var scale = (signed + normal) / H2_ANALYSIS_MOCK_STATIONS.length;
|
||||
return {
|
||||
totalStations: signed + normal,
|
||||
signedStations: signed,
|
||||
normalStations: normal,
|
||||
operatingStations: Math.round(operating * scale) || 238,
|
||||
pausedStoppedStations: Math.round(pausedStopped * scale) || 46,
|
||||
provinceCount: H2_ANALYSIS_PROVINCE_STOCK.length
|
||||
};
|
||||
}
|
||||
|
||||
export function h2AnalysisGetProvinceStock() {
|
||||
return H2_ANALYSIS_PROVINCE_STOCK.map(function (p) {
|
||||
return {
|
||||
province: p.province,
|
||||
signed: p.signed,
|
||||
normal: p.normal,
|
||||
total: p.signed + p.normal
|
||||
};
|
||||
}).sort(function (a, b) { return b.total - a.total; });
|
||||
}
|
||||
|
||||
export function h2AnalysisFilterNewStations(stations, filters) {
|
||||
var dayjs = h2AnalysisEnsureDayjs();
|
||||
var weekStart = filters.weekValue ? h2AnalysisStartOfWeek(filters.weekValue) : null;
|
||||
var weekEnd = weekStart ? h2AnalysisEndOfWeek(filters.weekValue) : null;
|
||||
return stations.filter(function (s) {
|
||||
if (!s.createdAt) return false;
|
||||
if (weekStart && weekEnd && dayjs) {
|
||||
var created = dayjs(s.createdAt.indexOf('T') >= 0 ? s.createdAt : s.createdAt.replace(' ', 'T'));
|
||||
if (!created.isValid || !created.isValid()) return false;
|
||||
if (created.isBefore(weekStart) || created.isAfter(weekEnd)) return false;
|
||||
}
|
||||
if (filters.region && filters.region.length) {
|
||||
if (filters.region[0] && s.region[0] !== filters.region[0]) return false;
|
||||
if (filters.region[1] && s.region[1] !== filters.region[1]) return false;
|
||||
}
|
||||
if (filters.stationType && h2AnalysisGetStationType(s) !== filters.stationType) return false;
|
||||
if (filters.businessStatus && s.businessStatus !== filters.businessStatus) return false;
|
||||
if (filters.chartFilter) {
|
||||
var cf = filters.chartFilter;
|
||||
if (cf.type === 'stationType' && h2AnalysisGetStationType(s) !== cf.value) return false;
|
||||
if (cf.type === 'province' && s.region[0] !== cf.value) return false;
|
||||
if (cf.type === 'period' && cf.periodIndex != null) {
|
||||
var periods = h2AnalysisBuildTrendPeriods();
|
||||
var p = periods[cf.periodIndex];
|
||||
if (p && p.weekStart && dayjs) {
|
||||
var c = dayjs(s.createdAt.replace(' ', 'T'));
|
||||
var ws = p.weekStart;
|
||||
var we = ws.add(6, 'day').endOf('day');
|
||||
if (c.isBefore(ws) || c.isAfter(we)) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function h2AnalysisFilterStockStations(stations, filters) {
|
||||
return stations.filter(function (s) {
|
||||
if (filters.region && filters.region.length) {
|
||||
if (filters.region[0] && s.region[0] !== filters.region[0]) return false;
|
||||
if (filters.region[1] && s.region[1] !== filters.region[1]) return false;
|
||||
}
|
||||
if (filters.stationType && h2AnalysisGetStationType(s) !== filters.stationType) return false;
|
||||
if (filters.businessStatus && s.businessStatus !== filters.businessStatus) return false;
|
||||
if (filters.chartFilter) {
|
||||
var cf = filters.chartFilter;
|
||||
if (cf.type === 'stationType' && h2AnalysisGetStationType(s) !== cf.value) return false;
|
||||
if (cf.type === 'businessStatus' && s.businessStatus !== cf.value) return false;
|
||||
if (cf.type === 'province' && s.region[0] !== cf.value) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function h2AnalysisFilterRiskStations(stations, filters) {
|
||||
return stations.filter(function (s) {
|
||||
var risks = h2AnalysisGetRiskTypes(s);
|
||||
if (filters.region && filters.region.length) {
|
||||
if (filters.region[0] && s.region[0] !== filters.region[0]) return false;
|
||||
if (filters.region[1] && s.region[1] !== filters.region[1]) return false;
|
||||
}
|
||||
if (filters.stationType && h2AnalysisGetStationType(s) !== filters.stationType) return false;
|
||||
if (filters.businessStatus && s.businessStatus !== filters.businessStatus) return false;
|
||||
if (filters.chartFilter) {
|
||||
var cf = filters.chartFilter;
|
||||
if (cf.type === 'balanceStatus' && h2AnalysisGetBalanceStatus(s) !== cf.value) return false;
|
||||
if (cf.type === 'priceType' && h2AnalysisGetPriceType(s) !== cf.value) return false;
|
||||
if (cf.type === 'contractBucket') {
|
||||
if (!s.isSigned || !s.contractEnd) return false;
|
||||
if (h2AnalysisGetContractBucket(h2AnalysisGetContractDays(s)) !== cf.value) return false;
|
||||
}
|
||||
if (cf.type === 'riskType' && risks.indexOf(cf.value) < 0) return false;
|
||||
if (cf.type === 'station' && s.id !== cf.value) return false;
|
||||
return true;
|
||||
}
|
||||
return risks.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function h2AnalysisGetNewSummary(stations, weekValue) {
|
||||
var filtered = h2AnalysisFilterNewStations(stations, { weekValue: weekValue });
|
||||
var signed = 0;
|
||||
var normal = 0;
|
||||
var provinces = {};
|
||||
filtered.forEach(function (s) {
|
||||
if (h2AnalysisGetStationType(s) === '签约') signed += 1;
|
||||
else normal += 1;
|
||||
provinces[s.region[0]] = true;
|
||||
});
|
||||
return {
|
||||
newTotal: filtered.length,
|
||||
newSigned: signed,
|
||||
newNormal: normal,
|
||||
newProvinceCount: Object.keys(provinces).length
|
||||
};
|
||||
}
|
||||
|
||||
export function h2AnalysisGetNewProvinceTop(stations, weekValue, limit) {
|
||||
var filtered = h2AnalysisFilterNewStations(stations, { weekValue: weekValue });
|
||||
var map = {};
|
||||
filtered.forEach(function (s) {
|
||||
var p = s.region[0];
|
||||
if (!map[p]) map[p] = { province: p, signed: 0, normal: 0, total: 0 };
|
||||
if (h2AnalysisGetStationType(s) === '签约') map[p].signed += 1;
|
||||
else map[p].normal += 1;
|
||||
map[p].total += 1;
|
||||
});
|
||||
return Object.keys(map).map(function (k) { return map[k]; })
|
||||
.sort(function (a, b) { return b.total - a.total; })
|
||||
.slice(0, limit || 10);
|
||||
}
|
||||
|
||||
export function h2AnalysisGetRiskSummary(stations) {
|
||||
var balanceAlert = 0;
|
||||
var negative = 0;
|
||||
var expiring30 = 0;
|
||||
var expired = 0;
|
||||
var noPrice = 0;
|
||||
stations.forEach(function (s) {
|
||||
var risks = h2AnalysisGetRiskTypes(s);
|
||||
if (risks.indexOf('余额预警') >= 0) balanceAlert += 1;
|
||||
if (risks.indexOf('负余额') >= 0) negative += 1;
|
||||
if (risks.indexOf('合同即将到期') >= 0) expiring30 += 1;
|
||||
if (risks.indexOf('合同已到期') >= 0) expired += 1;
|
||||
if (risks.indexOf('未配置价格') >= 0) noPrice += 1;
|
||||
});
|
||||
var scale = 284 / Math.max(stations.length, 1);
|
||||
return {
|
||||
balanceAlert: Math.max(balanceAlert, Math.round(18 * scale / 12)),
|
||||
negative: Math.max(negative, 3),
|
||||
expiring30: Math.max(expiring30, Math.round(12 * scale / 12)),
|
||||
expired: Math.max(expired, 2),
|
||||
noPrice: Math.max(noPrice, 4)
|
||||
};
|
||||
}
|
||||
|
||||
export function h2AnalysisGetBalanceDistribution(stations) {
|
||||
var normal = 0;
|
||||
var alert = 0;
|
||||
var negative = 0;
|
||||
stations.forEach(function (s) {
|
||||
var st = h2AnalysisGetBalanceStatus(s);
|
||||
if (st === 'normal') normal += 1;
|
||||
else if (st === 'alert') alert += 1;
|
||||
else negative += 1;
|
||||
});
|
||||
return [
|
||||
{ key: 'normal', label: '正常', value: normal, color: '#10b981' },
|
||||
{ key: 'alert', label: '预警', value: alert, color: '#f59e0b' },
|
||||
{ key: 'negative', label: '负余额', value: negative, color: '#ef4444' }
|
||||
];
|
||||
}
|
||||
|
||||
export function h2AnalysisGetPriceDistribution(stations) {
|
||||
var fixed = 0;
|
||||
var tiered = 0;
|
||||
var none = 0;
|
||||
stations.forEach(function (s) {
|
||||
var pt = h2AnalysisGetPriceType(s);
|
||||
if (pt === 'fixed') fixed += 1;
|
||||
else if (pt === 'tiered') tiered += 1;
|
||||
else none += 1;
|
||||
});
|
||||
return [
|
||||
{ key: 'fixed', label: '固定价格', value: fixed, color: '#10b981' },
|
||||
{ key: 'tiered', label: '阶梯价格', value: tiered, color: '#3b82f6' },
|
||||
{ key: 'none', label: '未配置', value: none, color: '#94a3b8' }
|
||||
];
|
||||
}
|
||||
|
||||
export function h2AnalysisGetContractBuckets(stations) {
|
||||
var buckets = { '0-30': 0, '31-60': 0, '61-90': 0, '90+': 0 };
|
||||
stations.forEach(function (s) {
|
||||
if (!s.isSigned || !s.contractEnd) return;
|
||||
var b = h2AnalysisGetContractBucket(h2AnalysisGetContractDays(s));
|
||||
if (b !== 'unknown' && b !== 'expired' && buckets[b] != null) buckets[b] += 1;
|
||||
});
|
||||
return [
|
||||
{ key: '0-30', label: '0-30天', value: buckets['0-30'], color: '#ef4444' },
|
||||
{ key: '31-60', label: '31-60天', value: buckets['31-60'], color: '#f59e0b' },
|
||||
{ key: '61-90', label: '61-90天', value: buckets['61-90'], color: '#3b82f6' },
|
||||
{ key: '90+', label: '90天以上', value: buckets['90+'], color: '#10b981' }
|
||||
];
|
||||
}
|
||||
|
||||
export function h2AnalysisGetLowBalanceTop(stations, limit) {
|
||||
return stations.slice().sort(function (a, b) {
|
||||
return h2AnalysisNum(a.prepaidBalance) - h2AnalysisNum(b.prepaidBalance);
|
||||
}).slice(0, limit || 10).map(function (s) {
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
value: h2AnalysisNum(s.prepaidBalance),
|
||||
province: s.region[0]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function h2AnalysisFormatRegion(record) {
|
||||
if (!record || !record.region) return '';
|
||||
return record.region[0] + '-' + record.region[1];
|
||||
}
|
||||
|
||||
export function h2AnalysisFormatYuan(v) {
|
||||
var n = h2AnalysisNum(v);
|
||||
return '¥' + n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
# 加氢站管理 · 站点周报统计 — 产品需求文档(PRD)
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 文档版本 | v1.0 |
|
||||
| 模块名称 | 加氢站管理 → 站点周报统计 |
|
||||
| 所属系统 | ONE-OS Web 端 |
|
||||
| 目标读者 | 前端 / 后端 / 测试 / 业务产品对接人 |
|
||||
| 交互原型 | `/prototypes/oneos-web-h2-station-weekly` |
|
||||
| 关联原型 | [站点信息](/prototypes/oneos-web-h2-station-site)、[加氢站合包](/prototypes/oneos-web-h2-station) |
|
||||
| 设计规范 | `src/prototypes/vm-shared/DESIGN.md` |
|
||||
| 文档状态 | 已对齐原型与批注 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 业务背景
|
||||
|
||||
采购与运营团队每周五需人工整理加氢站新增与累计数据,通过邮件发送周报。该流程耗时、口径易不一致,且难以回溯历史周数据。
|
||||
|
||||
### 1.2 本期目标
|
||||
|
||||
- 提供**自然周维度**的站点新增与累计统计,替代人工邮件周报主流程
|
||||
- 支持按周筛选、KPI 概览、邮件风格摘要、新增明细表、趋势图与 Excel 导出
|
||||
- 与「站点信息」主数据口径对齐(创建时间、站点类型、省/市等)
|
||||
|
||||
### 1.3 用户角色
|
||||
|
||||
| 角色 | 典型诉求 |
|
||||
|---|---|
|
||||
| 采购/运营 | 每周查看新增签约与普通站点,复制摘要用于沟通 |
|
||||
| 管理层 | 查看近 12 期趋势与全国累计 |
|
||||
| 数据分析 | 导出 Excel 做二次分析 |
|
||||
|
||||
### 1.4 非目标(本期不做)
|
||||
|
||||
- 邮件自动发送、PDF 排版导出
|
||||
- 后端接口联调(原型使用 Mock)
|
||||
- 站点主数据维护(仍在「站点信息」)
|
||||
|
||||
---
|
||||
|
||||
## 2. 信息架构
|
||||
|
||||
```text
|
||||
站点周报统计
|
||||
├── 筛选区(统计周、快捷周、站点类型、地区)
|
||||
├── KPI 卡片(4 张)
|
||||
├── 周报摘要条
|
||||
├── 新增明细表 + 工具栏(趋势展示、导出 Excel)
|
||||
├── 趋势弹窗(双 Tab:每期新增 / 累计数量)
|
||||
└── 站点详情抽屉
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 筛选区
|
||||
|
||||
与站点信息等模块一致的 **vm 筛选卡片** 布局。
|
||||
|
||||
| 字段 | 控件 | 说明 |
|
||||
|---|---|---|
|
||||
| 统计周 | 周选择器 | 自然周:周一 00:00 ~ 周日 23:59(Asia/Shanghai);展示 `YYYY-MM-DD ~ YYYY-MM-DD` |
|
||||
| 快捷周 | 按钮组 | 「本周」「上周」一键切换并**立即查询** |
|
||||
| 站点类型 | 下拉 | 全部 / 签约站点 / 普通站点 |
|
||||
| 地区(省-市) | 级联 | 省、市两级,可只选省;`changeOnSelect` |
|
||||
|
||||
**交互**:
|
||||
|
||||
- **查询**:应用筛选,刷新 KPI、摘要、列表,回到第 1 页
|
||||
- **重置**:清空类型与地区,统计周回到本周并刷新
|
||||
- **手选统计周**:与快捷周一致,选择后**自动查询**(无需再点查询)
|
||||
|
||||
---
|
||||
|
||||
## 4. KPI 统计卡片
|
||||
|
||||
四张 **vm-kpi-card**,只读展示(非点击筛选)。
|
||||
|
||||
| 卡片 | 口径 | 动态标题 |
|
||||
|---|---|---|
|
||||
| 该周新增 | 所选自然周内创建的有效站点数 | 本周/上周/该周新增 |
|
||||
| 全国累计 | 截至所选周末仍在册的有效站点总数 | 固定 |
|
||||
| 签约站点 | 合作加氢站累计 | 固定 |
|
||||
| 普通站点 | 非合作加氢站累计 | 固定 |
|
||||
|
||||
首卡副文案:`签约 N · 普通 N`。
|
||||
|
||||
KPI 标题、表格空态、摘要尾部「截至本周末」等文案须**随所选统计周**切换(本周 / 上周 / 该周末)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 周报摘要条
|
||||
|
||||
邮件风格一句话汇总,位于 KPI 与表格之间。
|
||||
|
||||
**周期前缀规则**:
|
||||
|
||||
| 所选周 | 前缀示例 |
|
||||
|---|---|
|
||||
| 本周 | 本周(07/13-07/19) |
|
||||
| 上周 | 上周(07/06-07/12) |
|
||||
| 其它历史周 | 仅日期区间 `05/26-06/01` |
|
||||
|
||||
**有新增模板**:
|
||||
|
||||
> {周期前缀}新增签约站点 **N** 个、普通站点 **N** 个;截至{本周末/上周末/该周末},全国加氢站 **N** 个,其中签约 **N** 个、普通 **N** 个。
|
||||
|
||||
**无新增模板**:
|
||||
|
||||
> {周期前缀}无新增站点;截至{周末标签},全国加氢站 **N** 个……
|
||||
|
||||
---
|
||||
|
||||
## 6. 新增明细表
|
||||
|
||||
### 6.1 工具栏
|
||||
|
||||
| 按钮 | 行为 |
|
||||
|---|---|
|
||||
| 趋势展示 | 打开近 12 期趋势弹窗 |
|
||||
| 导出 Excel | 导出当前筛选结果 CSV(UTF-8 BOM),文件名含统计周期 |
|
||||
|
||||
**不包含**:导出 PDF(P1)按钮。
|
||||
|
||||
### 6.2 列表字段
|
||||
|
||||
| 列名 | 说明 |
|
||||
|---|---|
|
||||
| 序 | 当前页序号 |
|
||||
| 省/市 | 合并展示,格式 `湖北省-宜昌市` |
|
||||
| 加氢站名称 | 可点击查看详情 |
|
||||
| 站点类型 | 签约站点 / 普通站点 Tag |
|
||||
| 创建时间 | `YYYY-MM-DD HH:mm:ss` |
|
||||
| 操作 | `OperationActions` 组件,主操作「查看」 |
|
||||
|
||||
### 6.3 空态
|
||||
|
||||
随所选周动态:`本周无新增站点` / `上周无新增站点` / `{日期区间} 无新增站点`。
|
||||
|
||||
### 6.4 分页
|
||||
|
||||
默认 10 条,可选 5/10/20/50(`TablePagination` + `COMPACT_PAGE_SIZE_OPTIONS`)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 趋势展示弹窗
|
||||
|
||||
标题:**趋势展示**。副标题:近 12 期站点变化趋势。
|
||||
|
||||
拆为 **两个 Tab**,避免单页图表过小:
|
||||
|
||||
### Tab 1:每期新增站点
|
||||
|
||||
柱状图三组:
|
||||
|
||||
| 系列 | 颜色 | 说明 |
|
||||
|---|---|---|
|
||||
| 新增收录站点数 | 蓝色 | 该期全部新增 |
|
||||
| 新增合作站点数 | 绿色 | 该期签约新增 |
|
||||
| 新增普通站点数 | 橙色 | 收录 − 合作 |
|
||||
|
||||
数据表列:期次、周期、新增收录、新增合作、新增普通。
|
||||
|
||||
### Tab 2:累计站点数量
|
||||
|
||||
折线图三条:
|
||||
|
||||
| 系列 | 颜色 | 说明 |
|
||||
|---|---|---|
|
||||
| 签约站点累计数量 | 绿色 | |
|
||||
| 普通站点累计数量 | 橙色 | 全部 − 签约 |
|
||||
| 全部站点累计数量 | 紫色 | |
|
||||
|
||||
数据表列:期次、周期、签约累计、普通累计、全部累计。
|
||||
|
||||
**约束**:仅展示数量,不使用「增长良好」「环比下降」等评价标签。
|
||||
|
||||
**可访问性**:图表下方提供数据表,供读屏与键盘用户查阅。
|
||||
|
||||
---
|
||||
|
||||
## 8. 站点详情抽屉
|
||||
|
||||
字段与主表对齐:
|
||||
|
||||
| 字段 | 格式 |
|
||||
|---|---|
|
||||
| 加氢站名称 | 文本 |
|
||||
| 省/市 | `省-市` |
|
||||
| 站点类型 | Tag |
|
||||
| 创建时间 | `YYYY-MM-DD HH:mm:ss` |
|
||||
|
||||
---
|
||||
|
||||
## 9. 统计口径
|
||||
|
||||
| 指标 | 规则 |
|
||||
|---|---|
|
||||
| 该周新增 | 站点「创建时间」落入所选自然周的有效站点 |
|
||||
| 签约/普通新增 | 按站点类型拆分 |
|
||||
| 全国累计 | 截至该周日 23:59:59 仍在册的有效站点 |
|
||||
| 签约/普通累计 | 按类型统计截至周末的存量 |
|
||||
|
||||
数据来源:加氢站管理 → 站点信息。
|
||||
|
||||
---
|
||||
|
||||
## 10. 设计规范
|
||||
|
||||
- 页面壳层:`vm-page` + `lc-page` + `h2-weekly-page`
|
||||
- 主题色 `#32a06e`,字体 Inter / 苹方
|
||||
- 操作列使用 `OperationActions`
|
||||
- 分页使用 `TablePagination`
|
||||
- 不重复侧栏模块大标题
|
||||
|
||||
---
|
||||
|
||||
## 11. 验收标准
|
||||
|
||||
### 11.1 筛选与 KPI
|
||||
|
||||
- [ ] 统计周、快捷周、类型、地区筛选联动正确
|
||||
- [ ] 手选周与快捷周均自动加载数据
|
||||
- [ ] KPI / 空态 / 摘要文案随所选周切换
|
||||
|
||||
### 11.2 表格与导出
|
||||
|
||||
- [ ] 列名:省/市、加氢站名称、创建时间
|
||||
- [ ] 操作列使用 OperationActions
|
||||
- [ ] 导出 Excel 含摘要与明细
|
||||
|
||||
### 11.3 趋势图
|
||||
|
||||
- [ ] 双 Tab 展示
|
||||
- [ ] 含普通站点新增与累计
|
||||
- [ ] 无评价性标签
|
||||
- [ ] 附带数据表
|
||||
|
||||
### 11.4 文档
|
||||
|
||||
- [ ] 右上角可打开本 PRD
|
||||
|
||||
---
|
||||
|
||||
## 12. 修订记录
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|---|---|---|
|
||||
| v1.0 | 2026-07-13 | 初版:整合批注与 UI 评审意见,对齐现行原型 |
|
||||
|
||||
**文档结束**
|
||||
@@ -0,0 +1,92 @@
|
||||
# UI Review
|
||||
|
||||
- 审查目标:`src/prototypes/oneos-web-h2-station-weekly`(页面实现:`src/prototypes/oneos-web-h2-station/pages/04-站点周报统计.jsx`)
|
||||
- 使用设计依据:`src/prototypes/vm-shared/DESIGN.md`
|
||||
- 生成时间:2026-07-13
|
||||
|
||||
## 总体点评
|
||||
|
||||
站点周报统计原型整体遵循 OneOS `vm-page` 列表页壳层:无重复页头大标题、筛选区 + KPI 行 + 工具栏表格 + 底部分页结构清晰,主题色与站点信息模块一致(`#32a06e`、Inter/苹方字体、`TablePagination` 公共分页)。业务主链路「选周 → 看 KPI/摘要 → 查明细 → 导出/趋势」已可演示,趋势弹窗拆为双 Tab 后图表可读性明显优于单页堆叠,摘要条对本周/上周/历史周的周期前缀区分也已落地。
|
||||
|
||||
主要风险集中在 **与 DESIGN.md 强制规范的操作列实现偏差**,以及 **「所选周」与界面固定「本周」文案之间的语义错位**——用户切换到上周或更早自然周后,KPI 标题、表格空态、摘要尾部「截至本周末」等仍沿用「本周」语境,容易误判统计范围。次要问题包括主表与抽屉/脚注字段命名不一致、周选择器与快捷周查询行为不一致。趋势图为纯 SVG 展示,数据探索对键盘与读屏用户支持有限。
|
||||
|
||||
亮点:页面未重复侧栏模块名;工具栏无重复条数;KPI 卡片带口径说明;筛选区含周选择器、快捷周、类型与地区级联;趋势图仅展示数量、无评价性标签。Impeccable `detect.mjs` 静态扫描未检出违规项。
|
||||
|
||||
## P0-P3 优先级问题
|
||||
|
||||
### P1 - 操作列未使用 OperationActions 公共组件
|
||||
|
||||
- 证据:`04-站点周报统计.jsx` 操作列使用手写 `<button class="vm-link">查看</button>`,未引入 `OperationActions`;`vm-shared/DESIGN.md` 规定 `vm-page` 列表主表操作列一律使用 `../../common/OperationActions`。
|
||||
- 影响:与加氢站站点信息、租赁合同等模块操作区视觉与交互不一致;后续若扩展「跳转站点信息」等更多操作,易再次出现各页自建链接组,增加维护成本与可发现性差异。
|
||||
- 修复方向:改为 `<OperationActions view={{ onClick: () => openDetail(record) }} />`;列宽建议 148–168px、`fixed: 'right'`,与 DESIGN 参考实现对齐。
|
||||
|
||||
### P1 - KPI / 空态 / 摘要尾部文案未随所选统计周切换
|
||||
|
||||
- 证据:KPI 首卡标题恒为「本周新增」;表格 `emptyText` 恒为「本周无新增站点」;摘要后半句恒为「截至本周末」——即使用户通过快捷周或周选择器查看上周/历史周(摘要前半段已正确显示「上周(…)」或纯日期区间)。
|
||||
- 影响:周报核对场景下,用户可能认为 KPI 与空态仍指当前自然周,导致对「上周无新增」等结论理解偏差,增加与采购经理解读邮件口径时的沟通成本。
|
||||
- 修复方向:根据 `appliedWeek` 动态替换为「本周/上周/该周」新增;空态改为「该周无新增站点」或复用 `summaryPeriod` 逻辑;累计句改为「截至该周末」。
|
||||
|
||||
### P2 - 主表与抽屉、脚注字段命名不一致
|
||||
|
||||
- 证据:表格列已使用「创建时间」「省/市」「加氢站名称」;底部脚注仍写「入库时间」;详情 Drawer 仍展示「站点名称」「所在省份」「所在城市」「入库时间」且未合并省/市。
|
||||
- 影响:同一页面内术语不统一,用户在表格与详情间切换时产生「是否同一字段」的疑惑;与 1.2 站点信息主数据口径对齐感较弱。
|
||||
- 修复方向:脚注改为「创建时间」;Drawer 字段与表格列名、格式对齐(省/市合并、`加氢站名称`、`创建时间` 带秒级格式)。
|
||||
|
||||
### P2 - 周选择器与快捷周查询行为不一致
|
||||
|
||||
- 证据:`DatePicker` 的 `onChange` 仅更新 `weekValue`,不触发 `simulateLoad`;「本周」「上周」快捷按钮则会立即加载数据。用户在手选历史周后必须再点「查询」才能看到结果。
|
||||
- 影响:同一筛选区内两种选周方式行为不同,增加学习成本;用户易误以为已切换统计周而实际仍展示旧数据。
|
||||
- 修复方向:统一行为——手选周后自动查询,或在周选择器旁增加「已选周未应用」提示;与站点信息筛选区「查询/重置」模式保持一致并在文案上明示。
|
||||
|
||||
### P3 - 趋势图缺少键盘与读屏友好的数据探索
|
||||
|
||||
- 证据:趋势弹窗内柱状图、折线图为纯 SVG,`role="img"` + 简短 `aria-label`;除柱顶/末点少量数值外,无法通过键盘聚焦逐期读取数据,也无表格兜底。
|
||||
- 影响:依赖读屏或键盘的用户难以完整获取 12 期趋势明细,不符合数据报表类页面的包容性预期。
|
||||
- 修复方向:为每期数据提供可聚焦节点或「查看数据表」切换;折线图补充 `aria-describedby` 关联简要数据摘要;窄屏下考虑减少 X 轴标签密度避免重叠。
|
||||
|
||||
## 核心元件
|
||||
|
||||
### 筛选区(vm-filter-card)
|
||||
|
||||
四字段栅格(统计周、快捷周、站点类型、地区省-市)与站点信息页面对齐,周选择器附带自然周口径说明,符合 B 端报表筛选习惯。**保留** `vm-filter-title`「筛选条件」与 `vm-filter-actions` 右对齐查询/重置。**调整**周选择器与快捷周的行为一致性(见 P2);地区级联已复用省/市结构,与批注一致。
|
||||
|
||||
### KPI 卡片行(vm-kpi-row)
|
||||
|
||||
四卡(本周新增、全国累计、签约、普通)数字等宽、副文案拆分签约/普通,帮助图标 + tooltip 说明口径,布局在 1200px 以下降为 2 列、640px 降为 1 列,响应式合理。卡片为只读展示(非可点击筛选),与站点信息 KPI 区分明确。**保留**四卡结构与 tooltip。**调整**首卡标题及副文案应随 `appliedWeek` 动态化(见 P1)。
|
||||
|
||||
### 周报摘要条(h2-weekly-summary)
|
||||
|
||||
邮件风格一句话汇总,有/无新增两套模板,历史周仅显示日期区间的前缀逻辑正确,有数据与空态通过边框/背景区分。**保留**摘要条位置与语气。**调整**尾部「截至本周末」应随所选周改为「该周末」(见 P1)。
|
||||
|
||||
### 数据表格与工具栏(vm-table-section)
|
||||
|
||||
工具栏右对齐「趋势展示」「导出 Excel」,无重复条数,符合 DESIGN 工具栏规范;表格列序/省/市/加氢站名称/类型/创建时间/操作清晰,分页使用 `TablePagination` + `COMPACT_PAGE_SIZE_OPTIONS`。**问题**:操作列未用 `OperationActions`(见 P1);加氢站名称与「查看」双入口功能重复但可接受。**保留**表格结构与分页;**调整**操作列组件化。
|
||||
|
||||
### 趋势展示弹窗(h2-weekly-trend-modal)
|
||||
|
||||
双 Tab(每期新增 / 累计数量)解决单页图表过小问题;图例仅标识系列名称与颜色,无评价性标签,符合产品批注。柱状图双系列分组、折线图双线累计表达清楚。**保留** Tab 拆分与纯数量展示。**调整**可访问性与窄屏 X 轴密度(见 P3)。
|
||||
|
||||
### 站点详情抽屉(Drawer)
|
||||
|
||||
只读字段列表结构简单。**问题**字段命名与主表不一致(见 P2)。**建议**与表格列对齐后,可考虑增加「前往站点信息」跳转入口(非本次必须)。
|
||||
|
||||
## 响应式与可访问性
|
||||
|
||||
- **响应式**:KPI 行具备 1200px / 640px 断点;趋势弹窗 `width: 920`、`max-height: 78vh` 在笔记本宽度下可用;主表 `scroll.x: 820` 列数适中,桌面常见宽度下横向滚动压力小于宽表台账页。未针对移动端单独优化,符合后台桌面优先场景。
|
||||
- **键盘**:筛选按钮、工具栏按钮、分页控件可 Tab 到达;KPI 帮助区 `tabIndex={0}` 但非标准 `button`,聚焦语义偏弱;趋势图不可键盘操作(见 P3)。
|
||||
- **语义**:筛选区 `aria-label="筛选条件"`;摘要 `role="status"`;KPI `aria-label` 含数值;分页由 `TablePagination` 提供 `role="navigation"`。
|
||||
- **对比度**:主文字 `#0f172a` / `#334155` 与白底对比充足;辅助说明 `#64748b`、`#94a3b8` 用于脚注与轴标签,建议抽样复核 AA。
|
||||
- **动效**:页面未引入额外动效;`Spin` loading 时长约 320ms,可接受。
|
||||
|
||||
## 证据与评估说明
|
||||
|
||||
- **浏览器/截图**:本次未启动本地预览与浏览器截图,结论主要来自源码、`vm-shared/DESIGN.md` 对照与样式走查。
|
||||
- **Scanner**:已运行 `node rules/references/impeccable/scripts/detect.mjs --json src/prototypes/oneos-web-h2-station-weekly`,返回 `[]`(无静态检出项)。
|
||||
- **报告模板**:`client/src/resources/templates/ui-review-report-template.md` 在仓库中未找到,结构按 `rules/ui-review-guide.md` 与既有 `vehicle-management/.spec/ui-review.md` 范例组织。
|
||||
- **独立评估**:`degraded` — 未使用浏览器实机验收与截图;设计一致性评估基于 `vm-shared/DESIGN.md` 与源码,完整度低于「预览 + 截图 + 双评估」流程。
|
||||
|
||||
### 后续建议
|
||||
|
||||
1. 优先修复 2 项 P1(操作列组件、周语境文案),再在预览中验收「上周 / 历史周」场景。
|
||||
2. 统一 Drawer、脚注与主表字段命名后,可请业务方做一次与邮件周报口径的对照验收。
|
||||
3. 若趋势图需对外汇报,建议补充「导出趋势图」或数据表视图,并复评 P3 可访问性项。
|
||||
70
src/prototypes/oneos-web-h2-station-weekly/index.tsx
Normal file
70
src/prototypes/oneos-web-h2-station-weekly/index.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @name 站点周报统计
|
||||
* 加氢站管理 — 站点周报统计独立预览(与站点信息并列)
|
||||
*/
|
||||
import '../../common/oneosWebLegacy/legacyGlobals';
|
||||
import React, { useEffect } from 'react';
|
||||
import * as antd from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
import updateLocale from 'dayjs/plugin/updateLocale';
|
||||
import { ConfigProvider } from 'antd';
|
||||
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';
|
||||
import '../oneos-web-h2-station/styles/h2-station-vm-filter.css';
|
||||
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||
import WeeklyReportPage from '../oneos-web-h2-station/pages/04-站点周报统计.jsx';
|
||||
|
||||
dayjs.extend(isoWeek);
|
||||
dayjs.extend(updateLocale);
|
||||
dayjs.locale('zh-cn');
|
||||
dayjs.updateLocale('zh-cn', { weekStart: 1 });
|
||||
|
||||
/** 与租赁合同管理 / vehicle-management / 站点信息主题一致 */
|
||||
const vmTheme = {
|
||||
token: {
|
||||
colorPrimary: '#32a06e',
|
||||
colorLink: '#32a06e',
|
||||
colorLinkHover: '#3fb87c',
|
||||
borderRadius: 8,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
fontSize: 14,
|
||||
colorText: '#18181b',
|
||||
colorTextSecondary: '#52525b',
|
||||
colorBorder: '#e5e7eb',
|
||||
colorBgContainer: '#ffffff',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: '#f6f7f7',
|
||||
headerColor: '#71717a',
|
||||
rowHoverBg: '#f6f7f7',
|
||||
borderColor: '#e5e7eb',
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 12,
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 12,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
window.React = React;
|
||||
window.antd = antd;
|
||||
(window as Window & { dayjs: typeof dayjs }).dayjs = dayjs;
|
||||
|
||||
export default function OneosWebH2StationWeekly() {
|
||||
useEffect(() => {
|
||||
clearHostPrototypeRouteInfo();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN} theme={vmTheme}>
|
||||
<WeeklyReportPage />
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
1303
src/prototypes/oneos-web-h2-station/pages/04-站点周报统计.jsx
Normal file
1303
src/prototypes/oneos-web-h2-station/pages/04-站点周报统计.jsx
Normal file
File diff suppressed because it is too large
Load Diff
2
src/prototypes/oneos-web-h2-station/pages/站点周报统计-需求内容.js
vendored
Normal file
2
src/prototypes/oneos-web-h2-station/pages/站点周报统计-需求内容.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* 加氢站「查看加氢站点」详情弹窗 — 与站点信息页保持一致
|
||||
* 供站点周报统计等模块复用
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
export var H2_STATION_PRIMARY_BTN_STYLE = {
|
||||
borderRadius: 8,
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
border: 'none'
|
||||
};
|
||||
|
||||
export var H2_STATION_VIEW_MODAL_STYLES = [
|
||||
'.h2-station-view-modal .ant-modal-content { border-radius: 16px !important; overflow: hidden; box-shadow: 0 24px 48px -12px rgba(15, 23, 42, 0.18) !important; }',
|
||||
'.h2-station-view-modal .ant-modal-header { padding: 18px 24px 14px !important; border-bottom: 1px solid #f1f5f9 !important; margin-bottom: 0 !important; }',
|
||||
'.h2-station-view-modal .ant-modal-title { font-size: 17px !important; font-weight: 700 !important; color: #0f172a !important; }',
|
||||
'.h2-station-view-modal .ant-modal-body { padding: 16px 24px 20px !important; background: #f8fafc; }',
|
||||
'.h2-station-view-modal .ant-modal-footer { padding: 12px 24px 18px !important; border-top: 1px solid #f1f5f9 !important; background: #fff; }',
|
||||
'.h2-station-view-modal .h2-station-view-panel { display: flex; flex-direction: column; gap: 20px; }',
|
||||
'.h2-station-view-modal .h2-station-view-section__title { margin: 0 0 10px; padding-left: 10px; font-size: 14px; font-weight: 700; color: #334155; line-height: 1.35; border-left: 4px solid #10b981; }',
|
||||
'.h2-station-view-modal .h2-station-view-section .ant-descriptions { background: #fff; border-radius: 10px; overflow: hidden; }',
|
||||
'.h2-station-view-modal .h2-station-view-section .ant-descriptions-item-label { width: 132px !important; font-size: 12px !important; font-weight: 600 !important; color: #64748b !important; background: #fafbfc !important; }',
|
||||
'.h2-station-view-modal .h2-station-view-section .ant-descriptions-item-content { font-size: 13px !important; color: #0f172a !important; font-weight: 500 !important; word-break: break-word; }',
|
||||
'.h2-station-view-modal .h2-station-view-files { display: flex; flex-wrap: wrap; gap: 8px; }',
|
||||
'.h2-station-view-modal .h2-station-view-file-link { display: inline-flex; align-items: center; max-width: 100%; padding: 4px 10px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; color: #059669; font-size: 13px; font-weight: 600; cursor: pointer; transition: background 0.2s ease, border-color 0.2s ease; }',
|
||||
'.h2-station-view-modal .h2-station-view-file-link:hover { background: #ecfdf5; border-color: #86efac; }',
|
||||
'.h2-station-view-modal .h2-station-view-license-count { font-size: 13px; color: #64748b; margin-bottom: 8px; }',
|
||||
'.h2-station-view-modal .h2-station-view-license-images { display: flex; flex-wrap: wrap; gap: 10px; }',
|
||||
'.h2-station-view-modal .h2-station-view-license-thumb { display: block; padding: 0; border: 1px solid #e2e8f0; border-radius: 10px; overflow: hidden; background: #f8fafc; cursor: pointer; }',
|
||||
'.h2-station-view-modal .h2-station-view-license-thumb img { display: block; width: 168px; height: 118px; object-fit: cover; }',
|
||||
'.h2-station-view-modal .h2-station-view-license-empty { font-size: 13px; color: #94a3b8; }',
|
||||
'.h2-station-view-modal .h2-station-view-empty { padding: 20px 16px; text-align: center; font-size: 13px; color: #94a3b8; background: #fff; border: 1px dashed #e2e8f0; border-radius: 10px; }',
|
||||
'.h2-file-preview-modal .h2-file-preview-body iframe { width: 100%; height: 72vh; border: none; border-radius: 8px; }',
|
||||
'.h2-file-preview-modal .h2-file-preview-body img { display: block; max-width: 100%; margin: 0 auto; border-radius: 8px; }'
|
||||
];
|
||||
|
||||
var H2_WEEKLY_VIEW_SUPPLIERS = {
|
||||
'yc-zj': {
|
||||
id: 'sup-weekly-yc',
|
||||
name: '宜昌氢能运营有限公司',
|
||||
type: '加氢站',
|
||||
city: ['湖北省', '宜昌市'],
|
||||
address: '枝江市仙女镇服务区能源路18号',
|
||||
region: '华中',
|
||||
dept: '能源部',
|
||||
manager: '刘经理',
|
||||
contactName: '王供',
|
||||
contactMobile: '13807171234',
|
||||
taxId: '91420500MA4YCZJ001',
|
||||
invoiceAddress: '湖北省宜昌市枝江市仙女镇服务区能源路18号',
|
||||
invoicePhone: '0717-6666888',
|
||||
bankName: '中国工商银行宜昌枝江支行',
|
||||
bankAccount: '6222021203005566778',
|
||||
businessAddress: '湖北省宜昌市枝江市仙女镇服务区能源路18号',
|
||||
businessLicenseFiles: [{ uid: 'bl-yc', name: '营业执照_宜昌氢能.pdf' }],
|
||||
fillingLicenseFiles: [{ uid: 'fl-yc', name: '加氢站经营许可证.pdf' }]
|
||||
},
|
||||
'zy-lk': {
|
||||
id: 'sup-weekly-zy',
|
||||
name: '资阳临空氢能科技有限公司',
|
||||
type: '加氢站',
|
||||
city: ['四川省', '资阳市'],
|
||||
address: '临空经济区产业大道128号',
|
||||
region: '西南',
|
||||
dept: '能源部',
|
||||
manager: '陈经理',
|
||||
contactName: '李供',
|
||||
contactMobile: '13808328888',
|
||||
taxId: '91512000MA62ZYLK01',
|
||||
invoiceAddress: '四川省资阳市临空经济区产业大道128号',
|
||||
invoicePhone: '028-88886666',
|
||||
bankName: '中国建设银行资阳临空支行',
|
||||
bankAccount: '6217001234567890123',
|
||||
businessAddress: '四川省资阳市临空经济区产业大道128号',
|
||||
businessLicenseFiles: [{ uid: 'bl-zy', name: '营业执照_资阳临空.pdf' }],
|
||||
fillingLicenseFiles: [{ uid: 'fl-zy', name: '危化品经营许可证.pdf' }]
|
||||
},
|
||||
'hs-dev': {
|
||||
id: 'sup-weekly-hs',
|
||||
name: '黄石开发区氢能供应有限公司',
|
||||
type: '加氢站',
|
||||
city: ['湖北省', '黄石市'],
|
||||
address: '黄石经济技术开发区金山大道66号',
|
||||
region: '华中',
|
||||
dept: '能源部',
|
||||
manager: '赵经理',
|
||||
contactName: '周供',
|
||||
contactMobile: '13807159876',
|
||||
taxId: '91420200MA4HSDEV01',
|
||||
invoiceAddress: '湖北省黄石市经济技术开发区金山大道66号',
|
||||
invoicePhone: '0714-55556666',
|
||||
bankName: '中国银行黄石开发区支行',
|
||||
bankAccount: '6216600100001122334',
|
||||
businessAddress: '湖北省黄石市经济技术开发区金山大道66号',
|
||||
businessLicenseFiles: [{ uid: 'bl-hs', name: '营业执照_黄石开发区.pdf' }],
|
||||
fillingLicenseFiles: [{ uid: 'fl-hs', name: '加氢站备案证明.pdf' }]
|
||||
},
|
||||
'sh-fx': {
|
||||
id: 'sup-weekly-fx',
|
||||
name: '上海奉贤海湾氢能有限公司',
|
||||
type: '加氢站',
|
||||
city: ['上海市', '奉贤区'],
|
||||
address: '奉贤区海湾镇海泉路188号',
|
||||
region: '华东',
|
||||
dept: '能源部',
|
||||
manager: '张经理',
|
||||
contactName: '孙供',
|
||||
contactMobile: '13800138066',
|
||||
taxId: '91310120MA1FXHAI01',
|
||||
invoiceAddress: '上海市奉贤区海湾镇海泉路188号',
|
||||
invoicePhone: '021-66889900',
|
||||
bankName: '招商银行上海奉贤支行',
|
||||
bankAccount: '6214830210987654321',
|
||||
businessAddress: '上海市奉贤区海湾镇海泉路188号',
|
||||
businessLicenseFiles: [{ uid: 'bl-fx', name: '营业执照_奉贤海湾.pdf' }],
|
||||
fillingLicenseFiles: [
|
||||
{ uid: 'fl-fx-1', name: '加氢站经营许可证.pdf' },
|
||||
{ uid: 'fl-fx-2', name: '消防验收合格证.pdf' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var H2_WEEKLY_VIEW_STATION_CATALOG = {
|
||||
'宜昌中石化枝江服务区加氢站': {
|
||||
name: '宜昌中石化枝江服务区加氢站',
|
||||
region: ['湖北省', '宜昌市'],
|
||||
addressDetail: '枝江市仙女镇中石化枝江服务区',
|
||||
isSigned: true,
|
||||
contractStart: '2025-04-01',
|
||||
contractEnd: '2027-03-31',
|
||||
contractFiles: [{ uid: 'c-yc', name: '枝江服务区加氢站签约合同.pdf' }],
|
||||
businessStatus: '营业中',
|
||||
businessHours: '08:00-22:00',
|
||||
prepaidBalance: 62800.0,
|
||||
costUnitPrice: 43.2,
|
||||
contact: '王供',
|
||||
phone: '13807171234',
|
||||
supplierKey: 'yc-zj'
|
||||
},
|
||||
'资阳临空经济区加氢站': {
|
||||
name: '资阳临空经济区加氢站',
|
||||
region: ['四川省', '资阳市'],
|
||||
addressDetail: '临空经济区产业大道128号',
|
||||
isSigned: true,
|
||||
contractStart: '2025-06-01',
|
||||
contractEnd: '2027-05-31',
|
||||
contractFiles: [{ uid: 'c-zy', name: '资阳临空加氢站合作协议.pdf' }],
|
||||
businessStatus: '营业中',
|
||||
businessHours: '08:00-20:00',
|
||||
prepaidBalance: 45200.0,
|
||||
costUnitPrice: 44.0,
|
||||
contact: '李供',
|
||||
phone: '13808328888',
|
||||
supplierKey: 'zy-lk'
|
||||
},
|
||||
'黄石开发区加氢站': {
|
||||
name: '黄石开发区加氢站',
|
||||
region: ['湖北省', '黄石市'],
|
||||
addressDetail: '黄石经济技术开发区金山大道66号',
|
||||
isSigned: true,
|
||||
contractStart: '2024-11-01',
|
||||
contractEnd: '2026-10-31',
|
||||
contractFiles: [{ uid: 'c-hs', name: '黄石开发区加氢站合同.pdf' }],
|
||||
businessStatus: '营业中',
|
||||
businessHours: '09:00-21:00',
|
||||
prepaidBalance: 38500.0,
|
||||
costUnitPrice: 42.8,
|
||||
contact: '周供',
|
||||
phone: '13807159876',
|
||||
supplierKey: 'hs-dev'
|
||||
},
|
||||
'上海奉贤海湾加氢站': {
|
||||
name: '上海奉贤海湾加氢站',
|
||||
region: ['上海市', '奉贤区'],
|
||||
addressDetail: '奉贤区海湾镇海泉路188号',
|
||||
isSigned: true,
|
||||
contractStart: '2025-01-01',
|
||||
contractEnd: '2026-12-31',
|
||||
contractFiles: [{ uid: 'c-fx', name: '奉贤海湾加氢站签约合同.pdf' }],
|
||||
businessStatus: '营业中',
|
||||
businessHours: '08:00-22:00',
|
||||
prepaidBalance: 72100.0,
|
||||
costUnitPrice: 45.5,
|
||||
contact: '孙供',
|
||||
phone: '13800138066',
|
||||
supplierKey: 'sh-fx'
|
||||
}
|
||||
};
|
||||
|
||||
function h2ViewFormatRegion(region) {
|
||||
if (!region || !region.length) return '—';
|
||||
return region.join('-');
|
||||
}
|
||||
|
||||
function h2ViewNumOrZero(v) {
|
||||
var n = typeof v === 'number' ? v : parseFloat(v);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
function h2ViewFormatYuanSymbol(v, options) {
|
||||
var opts = options || {};
|
||||
if (v == null || v === '') return '—';
|
||||
if (!opts.keepZero && Number(v) === 0) return '—';
|
||||
var n = typeof v === 'number' ? v : parseFloat(v);
|
||||
if (isNaN(n)) return '—';
|
||||
return '¥' + n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function h2ViewDisplayBusinessHours(str) {
|
||||
var s = String(str || '').trim();
|
||||
if (!s || s === '—') return '—';
|
||||
return s;
|
||||
}
|
||||
|
||||
function h2ViewDaysUntilContractEnd(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
var parts = String(dateStr).trim().split('-');
|
||||
if (parts.length < 3) return null;
|
||||
var y = parseInt(parts[0], 10);
|
||||
var m = parseInt(parts[1], 10) - 1;
|
||||
var d = parseInt(parts[2], 10);
|
||||
if (isNaN(y) || isNaN(m) || isNaN(d)) return null;
|
||||
var end = new Date(y, m, d, 23, 59, 59, 999);
|
||||
var today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return Math.ceil((end.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function h2ViewRenderContractRemainTag(contractEnd) {
|
||||
var days = h2ViewDaysUntilContractEnd(contractEnd);
|
||||
if (days == null) return null;
|
||||
var Tag = window.antd && window.antd.Tag;
|
||||
if (!Tag) return null;
|
||||
var tagProps = { style: { marginTop: 4, borderRadius: 6, fontWeight: 600, fontSize: 11 } };
|
||||
if (days < 0) {
|
||||
return React.createElement(Tag, Object.assign({}, tagProps, { color: 'error' }), '已过期 ' + Math.abs(days) + ' 天');
|
||||
}
|
||||
if (days <= 30) {
|
||||
return React.createElement(Tag, Object.assign({}, tagProps, { color: 'warning' }), '剩余 ' + days + ' 天');
|
||||
}
|
||||
return React.createElement(Tag, Object.assign({}, tagProps, { color: 'success' }), '剩余 ' + days + ' 天');
|
||||
}
|
||||
|
||||
function h2ViewBuildPrototypeDocPreviewSvg(title, variant) {
|
||||
var accent = variant === 'license' ? '#10b981' : '#3b82f6';
|
||||
var label = String(title || '附件').slice(0, 28);
|
||||
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="880" viewBox="0 0 640 880">'
|
||||
+ '<rect width="640" height="880" fill="#f8fafc"/>'
|
||||
+ '<rect x="48" y="48" width="544" height="784" rx="12" fill="#fff" stroke="#e2e8f0"/>'
|
||||
+ '<rect x="48" y="48" width="544" height="72" rx="12" fill="' + accent + '"/>'
|
||||
+ '<text x="320" y="460" text-anchor="middle" fill="#64748b" font-size="18">' + label + '</text>'
|
||||
+ '</svg>';
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
}
|
||||
|
||||
function h2ViewResolveLicenseDisplayUrl(file) {
|
||||
return h2ViewBuildPrototypeDocPreviewSvg(file && file.name, 'license');
|
||||
}
|
||||
|
||||
function h2ViewResolveUploadPreview(file, variant) {
|
||||
var name = (file && file.name) || '附件';
|
||||
return {
|
||||
url: h2ViewBuildPrototypeDocPreviewSvg(name, variant || 'doc'),
|
||||
name: name,
|
||||
type: 'image'
|
||||
};
|
||||
}
|
||||
|
||||
function h2ViewResolveStationSupplier(record) {
|
||||
if (!record) return null;
|
||||
if (record.supplier) return Object.assign({ type: '加氢站' }, record.supplier);
|
||||
if (record.supplierKey && H2_WEEKLY_VIEW_SUPPLIERS[record.supplierKey]) {
|
||||
return H2_WEEKLY_VIEW_SUPPLIERS[record.supplierKey];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function h2ViewResolveCurrentCostPrice(record) {
|
||||
if (record && record.costUnitPrice != null) return record.costUnitPrice;
|
||||
return null;
|
||||
}
|
||||
|
||||
function h2ViewRenderBusinessStatusTag(v) {
|
||||
var Tag = window.antd && window.antd.Tag;
|
||||
if (!Tag) return v || '—';
|
||||
if (v === '营业中') return React.createElement(Tag, { color: 'success', style: { borderRadius: 6, fontWeight: 600 } }, v);
|
||||
if (v === '暂停营业') return React.createElement(Tag, { color: 'warning', style: { borderRadius: 6, fontWeight: 600 } }, v);
|
||||
if (v === '停止营业') return React.createElement(Tag, { color: 'error', style: { borderRadius: 6, fontWeight: 600 } }, v);
|
||||
return React.createElement(Tag, { style: { borderRadius: 6, fontWeight: 600, color: '#64748b', background: '#f1f5f9', border: '1px solid #e2e8f0' } }, v || '—');
|
||||
}
|
||||
|
||||
/** 将周报列表行解析为站点信息同款详情记录 */
|
||||
export function h2WeeklyResolveStationViewRecord(weeklyRow) {
|
||||
if (!weeklyRow) return null;
|
||||
var catalog = H2_WEEKLY_VIEW_STATION_CATALOG[weeklyRow.stationName];
|
||||
if (catalog) return Object.assign({}, catalog);
|
||||
return {
|
||||
name: weeklyRow.stationName,
|
||||
region: [weeklyRow.province, weeklyRow.city].filter(Boolean),
|
||||
addressDetail: '',
|
||||
isSigned: weeklyRow.stationType === '签约',
|
||||
contractStart: '',
|
||||
contractEnd: '',
|
||||
contractFiles: [],
|
||||
businessStatus: '营业中',
|
||||
businessHours: '—',
|
||||
prepaidBalance: 0,
|
||||
costUnitPrice: null,
|
||||
contact: '—',
|
||||
phone: '—'
|
||||
};
|
||||
}
|
||||
|
||||
export function H2StationViewPanel(props) {
|
||||
var record = props.record;
|
||||
var onOpenFilePreview = props.onOpenFilePreview;
|
||||
var Descriptions = window.antd && window.antd.Descriptions;
|
||||
if (!record || !Descriptions) return null;
|
||||
|
||||
var supplier = h2ViewResolveStationSupplier(record);
|
||||
var descColumn = { xs: 1, sm: 1, md: 2, lg: 2, xl: 2, xxl: 2 };
|
||||
var price = h2ViewResolveCurrentCostPrice(record);
|
||||
var balanceNegative = h2ViewNumOrZero(record.prepaidBalance) < 0;
|
||||
var balanceText = h2ViewFormatYuanSymbol(record.prepaidBalance, { keepZero: true });
|
||||
|
||||
var renderText = function (v) {
|
||||
return v != null && v !== '' ? v : '—';
|
||||
};
|
||||
|
||||
var renderViewContractFiles = function (files) {
|
||||
if (!files || !files.length) return '—';
|
||||
return React.createElement('div', { className: 'h2-station-view-files' },
|
||||
files.map(function (f, idx) {
|
||||
return React.createElement('button', {
|
||||
key: f.uid || f.name || idx,
|
||||
type: 'button',
|
||||
className: 'h2-station-view-file-link',
|
||||
onClick: function () { onOpenFilePreview(f, 'doc'); }
|
||||
}, f.name || '附件');
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
var renderViewLicenseImages = function (files, opts) {
|
||||
opts = opts || {};
|
||||
if (!files || !files.length) {
|
||||
return React.createElement('span', { className: 'h2-station-view-license-empty' }, '未上传');
|
||||
}
|
||||
return React.createElement('div', { className: 'h2-station-view-license-block' },
|
||||
opts.showCount ? React.createElement('div', { className: 'h2-station-view-license-count' }, '共 ' + files.length + ' 张') : null,
|
||||
React.createElement('div', { className: 'h2-station-view-license-images' },
|
||||
files.map(function (f, idx) {
|
||||
return React.createElement('button', {
|
||||
key: f.uid || f.name || idx,
|
||||
type: 'button',
|
||||
className: 'h2-station-view-license-thumb',
|
||||
onClick: function () { onOpenFilePreview(f, 'license'); }
|
||||
}, React.createElement('img', { src: h2ViewResolveLicenseDisplayUrl(f), alt: f.name || '证照图片' }));
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
var renderViewSection = function (title, items) {
|
||||
return React.createElement('div', { className: 'h2-station-view-section' },
|
||||
React.createElement('div', { className: 'h2-station-view-section__title' }, title),
|
||||
React.createElement(Descriptions, { bordered: true, size: 'small', column: descColumn },
|
||||
items.map(function (item) {
|
||||
return React.createElement(Descriptions.Item, {
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
span: item.span || 1
|
||||
}, item.value);
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
var renderEmptySection = function (title, hint) {
|
||||
return React.createElement('div', { className: 'h2-station-view-section' },
|
||||
React.createElement('div', { className: 'h2-station-view-section__title' }, title),
|
||||
React.createElement('div', { className: 'h2-station-view-empty' }, hint)
|
||||
);
|
||||
};
|
||||
|
||||
var contractValue = '—';
|
||||
if (record.isSigned) {
|
||||
contractValue = React.createElement('div', null,
|
||||
React.createElement('span', null, (record.contractStart || '—') + ' 至 ' + (record.contractEnd || '—')),
|
||||
record.contractEnd ? React.createElement('div', { style: { marginTop: 4 } }, h2ViewRenderContractRemainTag(record.contractEnd)) : null
|
||||
);
|
||||
}
|
||||
|
||||
var stationItems = [
|
||||
{ key: 'name', label: '加氢站名称', value: renderText(record.name) },
|
||||
{ key: 'region', label: '省 / 市', value: renderText(h2ViewFormatRegion(record.region)) },
|
||||
{ key: 'detail', label: '详细地址', value: renderText(record.addressDetail), span: 2 },
|
||||
{ key: 'status', label: '营业状态', value: h2ViewRenderBusinessStatusTag(record.businessStatus) },
|
||||
{ key: 'hours', label: '营业时间', value: renderText(h2ViewDisplayBusinessHours(record.businessHours)) },
|
||||
{ key: 'signed', label: '是否签约', value: record.isSigned ? '已签约' : '未签约' }
|
||||
];
|
||||
if (record.isSigned) {
|
||||
stationItems.push(
|
||||
{ key: 'contract', label: '合作时间', value: contractValue, span: 2 },
|
||||
{ key: 'files', label: '签约附件', value: renderViewContractFiles(record.contractFiles), span: 2 }
|
||||
);
|
||||
}
|
||||
stationItems.push(
|
||||
{ key: 'contact', label: '联系人', value: renderText(record.contact) },
|
||||
{ key: 'phone', label: '联系电话', value: renderText(record.phone) },
|
||||
{
|
||||
key: 'balance',
|
||||
label: '预付余额',
|
||||
value: React.createElement('span', {
|
||||
style: { fontWeight: 700, color: balanceNegative ? '#dc2626' : '#059669', fontVariantNumeric: 'tabular-nums' }
|
||||
}, balanceText)
|
||||
},
|
||||
{
|
||||
key: 'price',
|
||||
label: '当前成本价格',
|
||||
value: price != null ? h2ViewFormatYuanSymbol(price, { keepZero: true }) + ' / kg' : '—'
|
||||
}
|
||||
);
|
||||
|
||||
var supplierItems = supplier ? [
|
||||
{ key: 'name', label: '供应商名称', value: renderText(supplier.name), span: 2 },
|
||||
{ key: 'signingCompany', label: '签约公司', value: renderText(supplier.signingCompany), span: 2 },
|
||||
{ key: 'type', label: '供应商类型', value: renderText(supplier.type || '加氢站') },
|
||||
{ key: 'city', label: '省 / 市', value: renderText(h2ViewFormatRegion(supplier.city)) },
|
||||
{ key: 'address', label: '详细地址', value: renderText(supplier.address), span: 2 },
|
||||
{ key: 'region', label: '所属区域', value: renderText(supplier.region) },
|
||||
{ key: 'dept', label: '归属部门', value: renderText(supplier.dept) },
|
||||
{ key: 'manager', label: '负责人', value: renderText(supplier.manager) },
|
||||
{ key: 'contactName', label: '联系人', value: renderText(supplier.contactName) },
|
||||
{ key: 'contactMobile', label: '联系电话', value: renderText(supplier.contactMobile) },
|
||||
{ key: 'bl', label: '营业执照', value: renderViewLicenseImages(supplier.businessLicenseFiles), span: 2 },
|
||||
{ key: 'fl', label: '其他证照', value: renderViewLicenseImages(supplier.fillingLicenseFiles, { showCount: true }), span: 2 }
|
||||
] : [];
|
||||
|
||||
var paymentItems = supplier ? [
|
||||
{ key: 'taxId', label: '纳税人识别号', value: renderText(supplier.taxId), span: 2 },
|
||||
{ key: 'invoicePhone', label: '注册电话', value: renderText(supplier.invoicePhone) },
|
||||
{ key: 'invoiceAddress', label: '注册地址', value: renderText(supplier.invoiceAddress), span: 2 },
|
||||
{ key: 'bankName', label: '开户行', value: renderText(supplier.bankName), span: 2 },
|
||||
{ key: 'bankAccount', label: '银行账号', value: renderText(supplier.bankAccount), span: 2 },
|
||||
{ key: 'businessAddress', label: '营业地址', value: renderText(supplier.businessAddress), span: 2 }
|
||||
] : [];
|
||||
|
||||
return React.createElement('div', { className: 'h2-station-view-panel' },
|
||||
renderViewSection('加氢站信息', stationItems),
|
||||
supplier ? renderViewSection('供应商信息', supplierItems) : renderEmptySection('供应商信息', '暂未关联供应商'),
|
||||
supplier ? renderViewSection('付款信息', paymentItems) : renderEmptySection('付款信息', '暂无付款信息')
|
||||
);
|
||||
}
|
||||
|
||||
export function H2StationViewModal(props) {
|
||||
var open = props.open;
|
||||
var record = props.record;
|
||||
var onClose = props.onClose;
|
||||
var Modal = window.antd && window.antd.Modal;
|
||||
var Button = window.antd && window.antd.Button;
|
||||
var Image = window.antd && window.antd.Image;
|
||||
var filePreviewState = useState({ open: false, url: '', name: '', type: 'image' });
|
||||
var filePreviewModal = filePreviewState[0];
|
||||
var setFilePreviewModal = filePreviewState[1];
|
||||
|
||||
var openFilePreview = useCallback(function (file, variant) {
|
||||
if (!file) return;
|
||||
var preview = h2ViewResolveUploadPreview(file, variant || 'doc');
|
||||
if (preview.type === 'image' && Image && typeof Image.preview === 'function') {
|
||||
Image.preview({ src: preview.url });
|
||||
return;
|
||||
}
|
||||
setFilePreviewModal({ open: true, url: preview.url, name: preview.name, type: preview.type });
|
||||
}, [Image]);
|
||||
|
||||
var handleClose = useCallback(function () {
|
||||
setFilePreviewModal({ open: false, url: '', name: '', type: 'image' });
|
||||
if (onClose) onClose();
|
||||
}, [onClose]);
|
||||
|
||||
if (!Modal || !Button) return null;
|
||||
|
||||
return React.createElement(React.Fragment, null,
|
||||
React.createElement(Modal, {
|
||||
className: 'h2-station-view-modal',
|
||||
title: '查看加氢站点',
|
||||
open: open,
|
||||
onCancel: handleClose,
|
||||
footer: React.createElement(Button, { type: 'primary', onClick: handleClose, style: H2_STATION_PRIMARY_BTN_STYLE }, '知道了'),
|
||||
width: 920,
|
||||
centered: true,
|
||||
destroyOnClose: true,
|
||||
styles: { body: { maxHeight: '78vh', overflow: 'auto' } }
|
||||
}, React.createElement(H2StationViewPanel, { record: record, onOpenFilePreview: openFilePreview })),
|
||||
React.createElement(Modal, {
|
||||
className: 'h2-file-preview-modal',
|
||||
title: filePreviewModal.name || '附件预览',
|
||||
open: filePreviewModal.open,
|
||||
onCancel: function () { setFilePreviewModal({ open: false, url: '', name: '', type: 'image' }); },
|
||||
footer: React.createElement(Button, { type: 'primary', onClick: function () { setFilePreviewModal({ open: false, url: '', name: '', type: 'image' }); }, style: H2_STATION_PRIMARY_BTN_STYLE }, '关闭'),
|
||||
width: 720,
|
||||
centered: true,
|
||||
destroyOnClose: true
|
||||
}, React.createElement('div', { className: 'h2-file-preview-body' },
|
||||
React.createElement('img', { src: filePreviewModal.url, alt: filePreviewModal.name || '附件预览' })
|
||||
))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user