diff --git a/.axhub/make/sidebar-tree.json b/.axhub/make/sidebar-tree.json index 98ddeb1..77dd792 100644 --- a/.axhub/make/sidebar-tree.json +++ b/.axhub/make/sidebar-tree.json @@ -1,7 +1,19 @@ { "version": 1, - "updatedAt": "2026-07-15T00:52:00.000Z", + "updatedAt": "2026-07-17T03:32:12.024Z", "prototypes": [ + { + "id": "item-prototypes-oneos-web-approval", + "kind": "item", + "title": "oneos web approval", + "itemKey": "prototypes/oneos-web-approval" + }, + { + "id": "item-prototypes-oneos-web-h2-station-analysis", + "kind": "item", + "title": "oneos web h2 station analysis", + "itemKey": "prototypes/oneos-web-h2-station-analysis" + }, { "id": "folder-1783875936186-27raza", "kind": "folder", diff --git a/src/prototypes/oneos-prototype-nav/prototype-registry.json b/src/prototypes/oneos-prototype-nav/prototype-registry.json index e9a1e0e..8ccba1f 100644 --- a/src/prototypes/oneos-prototype-nav/prototype-registry.json +++ b/src/prototypes/oneos-prototype-nav/prototype-registry.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-07-16T01:06:15.895Z", + "updatedAt": "2026-07-20T01:48:32.898Z", "prototypes": { "oneos-web-workbench": { "title": "工作台", @@ -3879,11 +3879,11 @@ "changelog": [] }, "oneos-web-h2-station-analysis": { - "title": "oneos-web-h2-station-analysis", - "version": "v1.6", - "revision": 6, - "lastUpdated": "2026-07-13T07:20:12.550Z", - "contentHash": "a9a6164ccd59111309617d0c4421bd809d07d84a496ad7552bb0af57a4afa463", + "title": "oneos web h2 station analysis", + "version": "v1.7", + "revision": 7, + "lastUpdated": "2026-07-20T01:48:32.897Z", + "contentHash": "a34c72c7929aff96697f049c4f730fb0bb7cc3e5e1c7c76513c2cd4e6e55e234", "trackedFiles": [ "index.tsx", "pages/01-加氢站分析.jsx", @@ -3891,6 +3891,18 @@ "shared/h2-analysis-mock.js" ], "changelog": [ + { + "version": "v1.7", + "date": "2026-07-20", + "time": "09:48", + "summary": "更新页面逻辑与交互", + "files": [ + "index.tsx", + "pages/01-加氢站分析.jsx", + "shared/h2-analysis-charts.jsx", + "shared/h2-analysis-mock.js" + ] + }, { "version": "v1.6", "date": "2026-07-13", @@ -4229,6 +4241,20 @@ } }, "recentUpdates": [ + { + "prototypeId": "oneos-web-h2-station-analysis", + "title": "oneos web h2 station analysis", + "version": "v1.7", + "date": "2026-07-20", + "time": "09:48", + "summary": "更新页面逻辑与交互", + "files": [ + "index.tsx", + "pages/01-加氢站分析.jsx", + "shared/h2-analysis-charts.jsx", + "shared/h2-analysis-mock.js" + ] + }, { "prototypeId": "oneos-web-approval-initiated", "title": "我发起的", @@ -4805,22 +4831,6 @@ "pages/04-站点周报统计.jsx", "styles/h2-station-vm-filter.css" ] - }, - { - "prototypeId": "oneos-web-h2-station", - "title": "加氢站管理", - "version": "v1.33", - "date": "2026-07-13", - "time": "13:09", - "summary": "更新页面样式", - "files": [ - "index.tsx", - "pages/01-加氢订单.jsx", - "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", - "pages/04-站点周报统计.jsx", - "styles/h2-station-vm-filter.css" - ] } ] } diff --git a/src/prototypes/oneos-web-h2-station-analysis/index.tsx b/src/prototypes/oneos-web-h2-station-analysis/index.tsx new file mode 100644 index 0000000..aa21da7 --- /dev/null +++ b/src/prototypes/oneos-web-h2-station-analysis/index.tsx @@ -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 ( + + + + ); +} diff --git a/src/prototypes/oneos-web-h2-station-analysis/pages/01-加氢站分析.jsx b/src/prototypes/oneos-web-h2-station-analysis/pages/01-加氢站分析.jsx new file mode 100644 index 0000000..148f980 --- /dev/null +++ b/src/prototypes/oneos-web-h2-station-analysis/pages/01-加氢站分析.jsx @@ -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; diff --git a/src/prototypes/oneos-web-h2-station-analysis/shared/h2-analysis-charts.jsx b/src/prototypes/oneos-web-h2-station-analysis/shared/h2-analysis-charts.jsx new file mode 100644 index 0000000..20aa7c0 --- /dev/null +++ b/src/prototypes/oneos-web-h2-station-analysis/shared/h2-analysis-charts.jsx @@ -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 }; diff --git a/src/prototypes/oneos-web-h2-station-analysis/shared/h2-analysis-mock.js b/src/prototypes/oneos-web-h2-station-analysis/shared/h2-analysis-mock.js new file mode 100644 index 0000000..6f2cc3b --- /dev/null +++ b/src/prototypes/oneos-web-h2-station-analysis/shared/h2-analysis-mock.js @@ -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 }); +} diff --git a/src/prototypes/oneos-web-h2-station-weekly/.spec/requirements-prd.md b/src/prototypes/oneos-web-h2-station-weekly/.spec/requirements-prd.md new file mode 100644 index 0000000..f1429b9 --- /dev/null +++ b/src/prototypes/oneos-web-h2-station-weekly/.spec/requirements-prd.md @@ -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 评审意见,对齐现行原型 | + +**文档结束** diff --git a/src/prototypes/oneos-web-h2-station-weekly/.spec/reviews/ui-review.md b/src/prototypes/oneos-web-h2-station-weekly/.spec/reviews/ui-review.md new file mode 100644 index 0000000..9565859 --- /dev/null +++ b/src/prototypes/oneos-web-h2-station-weekly/.spec/reviews/ui-review.md @@ -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` 操作列使用手写 ``,未引入 `OperationActions`;`vm-shared/DESIGN.md` 规定 `vm-page` 列表主表操作列一律使用 `../../common/OperationActions`。 +- 影响:与加氢站站点信息、租赁合同等模块操作区视觉与交互不一致;后续若扩展「跳转站点信息」等更多操作,易再次出现各页自建链接组,增加维护成本与可发现性差异。 +- 修复方向:改为 ` 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 可访问性项。 diff --git a/src/prototypes/oneos-web-h2-station-weekly/index.tsx b/src/prototypes/oneos-web-h2-station-weekly/index.tsx new file mode 100644 index 0000000..b7e141f --- /dev/null +++ b/src/prototypes/oneos-web-h2-station-weekly/index.tsx @@ -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 ( + + + + ); +} diff --git a/src/prototypes/oneos-web-h2-station/pages/04-站点周报统计.jsx b/src/prototypes/oneos-web-h2-station/pages/04-站点周报统计.jsx new file mode 100644 index 0000000..43753d9 --- /dev/null +++ b/src/prototypes/oneos-web-h2-station/pages/04-站点周报统计.jsx @@ -0,0 +1,1303 @@ +// 【重要】必须使用 const Component 作为组件变量名 +// 加氢站管理 - 站点周报统计(替代每周五人工邮件周报) + +import { TablePagination, COMPACT_PAGE_SIZE_OPTIONS } from '../../../common/TablePagination'; +import { OperationActions } from '../../../common/OperationActions'; +import { H2_WEEKLY_REQUIREMENT_DOC } from './站点周报统计-需求内容.js'; +import { + H2StationViewModal, + H2_STATION_VIEW_MODAL_STYLES, + h2WeeklyResolveStationViewRecord +} from '../shared/h2-station-view-shared.jsx'; + +/** @see web端/styles/oneos-ant-table-global-fix.js */ +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; }', + '.ant-table-container .ant-table-body > table, .ant-table-content table { margin-top: 0 !important; }', + '.ant-table-tbody > tr.ant-table-measure-row, .ant-table-tbody > tr.ant-table-measure-row > td, .ant-table-tbody > tr.ant-table-measure-row > th { display: none !important; height: 0 !important; max-height: 0 !important; min-height: 0 !important; padding: 0 !important; margin: 0 !important; border: none !important; line-height: 0 !important; font-size: 0 !important; overflow: hidden !important; visibility: hidden !important; pointer-events: none !important; }' +]; + +var H2_WEEKLY_PAGE_STYLE = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([ + '.vm-page.lc-page.h2-weekly-page { font-family: var(--vm-font); font-size: 0.875rem; line-height: 1.5; letter-spacing: -0.01em; color: var(--ln-body); -webkit-font-smoothing: antialiased; height: 100vh; overflow: auto; box-sizing: border-box; }', + '.vm-page.lc-page.h2-weekly-page .h2-weekly-body { min-width: 0; }', + '.vm-page.lc-page.h2-weekly-page .vm-filter-card { margin-bottom: 16px; }', + '.vm-page.lc-page.h2-weekly-page .vm-kpi-row { grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: 16px; }', + '@media (max-width: 1200px) { .vm-page.lc-page.h2-weekly-page .vm-kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }', + '@media (max-width: 640px) { .vm-page.lc-page.h2-weekly-page .vm-kpi-row { grid-template-columns: 1fr; } }', + '.vm-page.lc-page.h2-weekly-page .vm-kpi-card { cursor: default; }', + '.vm-page.lc-page.h2-weekly-page .vm-kpi-card.h2-weekly-kpi--clickable { cursor: pointer; }', + '.vm-page.lc-page.h2-weekly-page .h2-weekly-kpi-sub { display: block; margin-top: 4px; font-size: 0.75rem; font-weight: 500; color: var(--ln-muted); font-variant-numeric: tabular-nums; }', + '.vm-page.lc-page.h2-weekly-page .h2-weekly-summary { margin-bottom: 16px; padding: 12px 16px; border-radius: 10px; border: 1px solid #bbf7d0; background: linear-gradient(135deg, #ecfdf5 0%, #f8fafc 100%); color: #334155; font-size: 0.8125rem; line-height: 1.55; }', + '.vm-page.lc-page.h2-weekly-page .h2-weekly-summary--empty { border-color: #e2e8f0; background: #f8fafc; }', + '.vm-page.lc-page.h2-weekly-page .h2-weekly-summary strong { color: #0f172a; font-weight: 600; }', + '.vm-page.lc-page.h2-weekly-page .h2-weekly-footnote { margin-top: 12px; padding: 0 2px; font-size: 0.75rem; color: var(--ln-muted); line-height: 1.5; }', + '.vm-page.lc-page.h2-weekly-page .h2-weekly-week-hint { margin-top: 6px; font-size: 0.75rem; color: var(--ln-muted); font-variant-numeric: tabular-nums; }', + '.vm-page.lc-page.h2-weekly-page .h2-weekly-quick { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }', + '.vm-page.lc-page.h2-weekly-page .h2-weekly-quick .vm-btn { min-height: var(--vm-control-height, 32px); }', + '.vm-page.lc-page.h2-weekly-page .h2-weekly-type-tag.ant-tag { margin: 0; border-radius: 6px; font-size: 0.75rem; font-weight: 500; line-height: 20px; }', + '.vm-page.lc-page.h2-weekly-page .vm-table-section { min-width: 0; max-width: 100%; flex: 1; display: flex; flex-direction: column; min-height: 0; }', + '.vm-page.lc-page.h2-weekly-page .vm-filter-field .ant-select, .vm-page.lc-page.h2-weekly-page .vm-filter-field .ant-picker, .vm-page.lc-page.h2-weekly-page .vm-filter-field .ant-cascader { width: 100%; }', + '.h2-weekly-trend-modal .ant-modal-content { border-radius: 16px !important; overflow: hidden; }', + '.h2-weekly-trend-modal .ant-modal-header { padding: 18px 24px 12px !important; border-bottom: 1px solid #f1f5f9 !important; margin-bottom: 0 !important; }', + '.h2-weekly-trend-modal .ant-modal-title { font-size: 17px !important; font-weight: 700 !important; color: #0f172a !important; }', + '.h2-weekly-trend-modal .ant-modal-body { padding: 16px 24px 20px !important; background: #f8fafc; max-height: 78vh; overflow: auto; }', + '.h2-weekly-trend-modal .ant-modal-footer { padding: 12px 24px 18px !important; border-top: 1px solid #f1f5f9 !important; background: #fff; }', + '.h2-weekly-trend-panel { display: flex; flex-direction: column; gap: 16px; }', + '.h2-weekly-trend-subtitle { margin: 0; font-size: 13px; font-weight: 600; color: #334155; }', + '.h2-weekly-trend-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-weekly-trend-chart-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #f1f5f9; background: #fafbfc; }', + '.h2-weekly-trend-chart-head__title { font-size: 13px; font-weight: 700; color: #334155; }', + '.h2-weekly-trend-legend { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }', + '.h2-weekly-trend-legend__item { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: #64748b; }', + '.h2-weekly-trend-legend__dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }', + '.h2-weekly-trend-legend__dot--line { border-radius: 50%; }', + '.h2-weekly-trend-chart-body { padding: 12px 14px 10px; min-height: 300px; }', + '.h2-weekly-trend-tabs .ant-tabs-nav { margin-bottom: 12px !important; }', + '.h2-weekly-trend-tabs .ant-tabs-tab { font-size: 13px !important; font-weight: 600 !important; padding: 8px 4px !important; }', + '.h2-weekly-trend-tabs .ant-tabs-content-holder { padding-top: 4px; }', + '.h2-weekly-trend-svg { display: block; width: 100%; height: auto; }', + '.h2-weekly-trend-axis-label { font-size: 10px; fill: #94a3b8; font-family: var(--vm-font); }', + '.h2-weekly-trend-axis-tick { font-size: 10px; fill: #94a3b8; font-family: var(--vm-font-mono); }', + '.h2-weekly-trend-bar-label { font-size: 9px; fill: #64748b; font-family: var(--vm-font-mono); }', + '.h2-weekly-trend-data-table { width: 100%; margin-top: 12px; border-collapse: collapse; font-size: 12px; }', + '.h2-weekly-trend-data-table th, .h2-weekly-trend-data-table td { padding: 6px 8px; border: 1px solid #e2e8f0; text-align: left; }', + '.h2-weekly-trend-data-table th { background: #f8fafc; color: #64748b; font-weight: 600; }', + '.h2-weekly-trend-data-table td { color: #334155; font-variant-numeric: tabular-nums; }', + '.h2-weekly-trend-data-table caption { caption-side: top; text-align: left; padding-bottom: 8px; font-size: 12px; font-weight: 600; color: #475569; }', + '.h2-weekly-page-top { display: flex; justify-content: flex-end; align-items: center; margin-bottom: 12px; }', + '.h2-weekly-page-top__link { border: none; background: none; padding: 0; cursor: pointer; font-size: 14px; font-weight: 500; color: #32a06e; }', + '.h2-weekly-page-top__link:hover { color: #3fb87c; text-decoration: underline; }', + '.h2-weekly-req-doc-panel { padding: 4px 4px 16px; }' +].concat(H2_STATION_VIEW_MODAL_STYLES)); + +var H2_WEEKLY_REGION_CASCADER_OPTIONS = [ + { value: '湖北省', label: '湖北省', children: [{ value: '宜昌市', label: '宜昌市' }, { value: '黄石市', label: '黄石市' }] }, + { value: '四川省', label: '四川省', children: [{ value: '资阳市', label: '资阳市' }] }, + { value: '上海市', label: '上海市', children: [{ 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: '深圳市' }] } +]; + +var H2_WEEKLY_KPI_META = [ + { key: 'newTotal', title: '本周新增', desc: '所选自然周内入库的有效站点数量' }, + { key: 'totalStations', title: '全国累计', desc: '截至所选周末仍在册的有效站点总数' }, + { key: 'cooperative', title: '签约站点', desc: '合作加氢站累计(签约站点)' }, + { key: 'normal', title: '普通站点', desc: '非合作加氢站累计(普通站点)' } +]; + +function h2WeeklyHelpIcon() { + return React.createElement('svg', { + xmlns: 'http://www.w3.org/2000/svg', + width: 14, + height: 14, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + 'aria-hidden': true + }, + React.createElement('circle', { cx: 12, cy: 12, r: 10 }), + React.createElement('path', { d: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3' }), + React.createElement('path', { d: 'M12 17h.01' }) + ); +} + +function h2WeeklyEnsureDayjs() { + return window.dayjs || null; +} + +function h2WeeklyStartOfWeek(d) { + var dayjs = h2WeeklyEnsureDayjs(); + if (!dayjs || !d) return null; + var cur = dayjs(d); + var weekday = cur.day(); // 0=Sun … 6=Sat + var diff = weekday === 0 ? -6 : 1 - weekday; + return cur.add(diff, 'day').startOf('day'); +} + +function h2WeeklyEndOfWeek(d) { + var start = h2WeeklyStartOfWeek(d); + return start ? start.add(6, 'day').endOf('day') : null; +} + +function h2WeeklyFormatRange(weekStart) { + var dayjs = h2WeeklyEnsureDayjs(); + if (!dayjs || !weekStart) return ''; + var start = h2WeeklyStartOfWeek(weekStart); + var end = h2WeeklyEndOfWeek(weekStart); + if (!start || !end) return ''; + return start.format('YYYY-MM-DD') + ' ~ ' + end.format('YYYY-MM-DD'); +} + +function h2WeeklyFormatRangeShort(weekStart) { + var dayjs = h2WeeklyEnsureDayjs(); + if (!dayjs || !weekStart) return ''; + var start = h2WeeklyStartOfWeek(weekStart); + var end = h2WeeklyEndOfWeek(weekStart); + if (!start || !end) return ''; + return start.format('MM/DD') + '-' + end.format('MM/DD'); +} + +/** 摘要条周期文案:本周 / 上周 / 仅日期区间 */ +function h2WeeklyResolveSummaryPeriod(weekStart) { + var dayjs = h2WeeklyEnsureDayjs(); + var range = h2WeeklyFormatRangeShort(weekStart); + var now = dayjs ? dayjs() : null; + if (!now || !weekStart) return { type: 'other', range: range }; + if (h2WeeklyIsSameWeek(weekStart, now)) return { type: 'this', range: range }; + if (h2WeeklyIsSameWeek(weekStart, now.subtract(7, 'day'))) return { type: 'last', range: range }; + return { type: 'other', range: range }; +} + +function h2WeeklyRenderSummaryPeriodLead(period) { + if (!period || !period.range) return null; + if (period.type === 'this') { + return React.createElement(React.Fragment, null, + '本周(', React.createElement('strong', null, period.range), ')' + ); + } + if (period.type === 'last') { + return React.createElement(React.Fragment, null, + '上周(', React.createElement('strong', null, period.range), ')' + ); + } + return React.createElement('strong', null, period.range); +} + +/** 随所选统计周切换的文案(KPI / 空态 / 摘要尾部) */ +function h2WeeklyGetPeriodCopy(period) { + if (!period) { + return { newLabel: '该周新增', emptyText: '该周无新增站点', weekendLabel: '该周末' }; + } + if (period.type === 'this') { + return { newLabel: '本周新增', emptyText: '本周无新增站点', weekendLabel: '本周末' }; + } + if (period.type === 'last') { + return { newLabel: '上周新增', emptyText: '上周无新增站点', weekendLabel: '上周末' }; + } + return { + newLabel: '该周新增', + emptyText: (period.range || '该周') + ' 无新增站点', + weekendLabel: '该周末' + }; +} + +function h2WeeklyFormatCreatedAt(value) { + if (!value) return ''; + var dayjs = h2WeeklyEnsureDayjs(); + if (dayjs) { + var d = dayjs(value.indexOf('T') >= 0 ? value : value.replace(' ', 'T')); + if (d.isValid && d.isValid()) return d.format('YYYY-MM-DD HH:mm:ss'); + } + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(value)) return value + ':00'; + return value; +} + +function h2WeeklyFormatRegion(record) { + if (!record) return ''; + return record.province + '-' + record.city; +} + +function h2WeeklyMatchRegion(record, region) { + if (!region || !region.length) return true; + var provinceMatch = !region[0] || record.province === region[0]; + var cityMatch = !region[1] || record.city === region[1]; + return provinceMatch && cityMatch; +} + +function h2WeeklyIsSameWeek(a, b) { + var sa = h2WeeklyStartOfWeek(a); + var sb = h2WeeklyStartOfWeek(b); + if (!sa || !sb) return false; + return sa.format('YYYY-MM-DD') === sb.format('YYYY-MM-DD'); +} + +/** Mock A:本周有新增 4 条签约站;累计 284 / 86 / 198 */ +var H2_WEEKLY_MOCK_WITH_NEW = { + summary: { + newTotal: 4, + newCooperative: 4, + newNormal: 0, + totalStations: 284, + cooperativeStations: 86, + normalStations: 198 + }, + list: [ + { + id: 'ws-1', + province: '湖北省', + city: '宜昌市', + stationName: '宜昌中石化枝江服务区加氢站', + stationType: '签约', + entryAt: '2026-07-13 10:22:00' + }, + { + id: 'ws-2', + province: '四川省', + city: '资阳市', + stationName: '资阳临空经济区加氢站', + stationType: '签约', + entryAt: '2026-07-14 15:08:00' + }, + { + id: 'ws-3', + province: '湖北省', + city: '黄石市', + stationName: '黄石开发区加氢站', + stationType: '签约', + entryAt: '2026-07-15 09:41:00' + }, + { + id: 'ws-4', + province: '上海市', + city: '奉贤区', + stationName: '上海奉贤海湾加氢站', + stationType: '签约', + entryAt: '2026-07-16 11:30:00' + } + ] +}; + +/** Mock B:上周无新增 */ +var H2_WEEKLY_MOCK_EMPTY = { + summary: { + newTotal: 0, + newCooperative: 0, + newNormal: 0, + totalStations: 284, + cooperativeStations: 86, + normalStations: 198 + }, + list: [] +}; + +function h2WeeklyPickMock(weekValue) { + var dayjs = h2WeeklyEnsureDayjs(); + var now = dayjs ? dayjs() : null; + if (now && h2WeeklyIsSameWeek(weekValue, now)) return H2_WEEKLY_MOCK_WITH_NEW; + var lastWeek = now ? now.subtract(7, 'day') : null; + if (lastWeek && h2WeeklyIsSameWeek(weekValue, lastWeek)) return H2_WEEKLY_MOCK_EMPTY; + // 其它历史周:默认无新增(便于演示空态) + return H2_WEEKLY_MOCK_EMPTY; +} + +/** 近 12 期(自然周)站点变化趋势 mock */ +function h2WeeklyBuildTrendPeriods() { + var dayjs = h2WeeklyEnsureDayjs(); + 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 ? h2WeeklyStartOfWeek(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 ? h2WeeklyFormatRangeShort(weekStart) : '', + newTotal: seed.newTotal, + newCooperative: seed.newCooperative, + newNormal: newNormal, + cumulativeTotal: cumulativeTotal, + cumulativeCooperative: cumulativeCooperative, + cumulativeNormal: cumulativeTotal - cumulativeCooperative + }; + }); +} + +function h2WeeklyTrendLegendItem(color, label, isLine) { + return React.createElement('span', { className: 'h2-weekly-trend-legend__item', key: label }, + React.createElement('span', { + className: 'h2-weekly-trend-legend__dot' + (isLine ? ' h2-weekly-trend-legend__dot--line' : ''), + style: { background: color } + }), + label + ); +} + +function h2WeeklyRenderBarTrendChart(periods) { + var W = 860; + var H = 300; + 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; + var groupW = chartW / count; + var barW = Math.min(10, groupW * 0.16); + var gap = 3; + var maxNew = Math.max.apply(null, periods.map(function (p) { + return Math.max(p.newTotal, p.newCooperative, p.newNormal); + }).concat([1])); + + return React.createElement('svg', { + className: 'h2-weekly-trend-svg', + viewBox: '0 0 ' + W + ' ' + H, + role: 'img', + 'aria-label': '每期新增站点柱状图' + }, + [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-weekly-trend-axis-tick' + }, tick) + ); + }), + periods.map(function (p, idx) { + var cx = PL + groupW * idx + groupW / 2; + var totalH = (p.newTotal / maxNew) * chartH; + var coopH = (p.newCooperative / maxNew) * chartH; + var normalH = (p.newNormal / maxNew) * chartH; + var totalX = cx - barW - gap - barW; + var coopX = cx - barW / 2; + var normalX = cx + gap; + var baseY = PT + chartH; + return React.createElement('g', { key: 'bar-' + p.period }, + React.createElement('rect', { + x: totalX, y: baseY - totalH, width: barW, height: totalH, + rx: 3, fill: '#3b82f6' + }), + p.newTotal > 0 ? React.createElement('text', { + x: totalX + barW / 2, y: baseY - totalH - 4, + textAnchor: 'middle', className: 'h2-weekly-trend-bar-label' + }, p.newTotal) : null, + React.createElement('rect', { + x: coopX, y: baseY - coopH, width: barW, height: coopH, + rx: 3, fill: '#10b981' + }), + p.newCooperative > 0 ? React.createElement('text', { + x: coopX + barW / 2, y: baseY - coopH - 4, + textAnchor: 'middle', className: 'h2-weekly-trend-bar-label' + }, p.newCooperative) : null, + React.createElement('rect', { + x: normalX, y: baseY - normalH, width: barW, height: normalH, + rx: 3, fill: '#f59e0b' + }), + p.newNormal > 0 ? React.createElement('text', { + x: normalX + barW / 2, y: baseY - normalH - 4, + textAnchor: 'middle', className: 'h2-weekly-trend-bar-label' + }, p.newNormal) : null, + React.createElement('text', { + x: cx, y: H - 12, textAnchor: 'middle', className: 'h2-weekly-trend-axis-label' + }, p.label) + ); + }) + ); +} + +function h2WeeklyRenderLineTrendChart(periods) { + var W = 860; + var H = 300; + var PL = 52; + var PR = 16; + var PT = 16; + var PB = 42; + var chartW = W - PL - PR; + var chartH = H - PT - PB; + var min = Math.min.apply(null, periods.map(function (p) { + return Math.min(p.cumulativeCooperative, p.cumulativeTotal, p.cumulativeNormal); + })); + var max = Math.max.apply(null, periods.map(function (p) { + return Math.max(p.cumulativeCooperative, p.cumulativeTotal, p.cumulativeNormal); + })); + var range = max - min || 1; + var coordsTotal = periods.map(function (p, idx) { + var x = PL + (periods.length === 1 ? chartW / 2 : (idx / (periods.length - 1)) * chartW); + var y = PT + chartH - ((p.cumulativeTotal - min) / range) * chartH; + return { x: x, y: y, value: p.cumulativeTotal, label: p.label }; + }); + var coordsCoop = periods.map(function (p, idx) { + var x = PL + (periods.length === 1 ? chartW / 2 : (idx / (periods.length - 1)) * chartW); + var y = PT + chartH - ((p.cumulativeCooperative - min) / range) * chartH; + return { x: x, y: y, value: p.cumulativeCooperative, label: p.label }; + }); + var coordsNormal = periods.map(function (p, idx) { + var x = PL + (periods.length === 1 ? chartW / 2 : (idx / (periods.length - 1)) * chartW); + var y = PT + chartH - ((p.cumulativeNormal - min) / range) * chartH; + return { x: x, y: y, value: p.cumulativeNormal, label: p.label }; + }); + var linePath = function (coords) { + return coords.map(function (c, i) { + return (i === 0 ? 'M' : 'L') + c.x.toFixed(1) + ' ' + c.y.toFixed(1); + }).join(' '); + }; + var yTicks = [ + { value: max, y: PT }, + { value: min + range / 2, y: PT + chartH / 2 }, + { value: min, y: PT + chartH } + ]; + + return React.createElement('svg', { + className: 'h2-weekly-trend-svg', + viewBox: '0 0 ' + W + ' ' + H, + role: 'img', + 'aria-label': '站点累计数量折线图' + }, + yTicks.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-weekly-trend-axis-tick' + }, Math.round(tick.value)) + ); + }), + React.createElement('path', { + d: linePath(coordsTotal), fill: 'none', stroke: '#6366f1', strokeWidth: 2.5, strokeLinejoin: 'round' + }), + React.createElement('path', { + d: linePath(coordsCoop), fill: 'none', stroke: '#10b981', strokeWidth: 2.5, strokeLinejoin: 'round' + }), + React.createElement('path', { + d: linePath(coordsNormal), fill: 'none', stroke: '#f59e0b', strokeWidth: 2.5, strokeLinejoin: 'round' + }), + coordsTotal.map(function (c, idx) { + return React.createElement('g', { key: 'pt-' + idx }, + React.createElement('circle', { cx: c.x, cy: c.y, r: 3.5, fill: '#6366f1', stroke: '#fff', strokeWidth: 1.5 }), + idx === coordsTotal.length - 1 ? React.createElement('text', { + x: c.x, y: c.y - 8, textAnchor: 'middle', className: 'h2-weekly-trend-bar-label' + }, c.value) : null + ); + }), + coordsCoop.map(function (c, idx) { + return React.createElement('g', { key: 'pc-' + idx }, + React.createElement('circle', { cx: c.x, cy: c.y, r: 3.5, fill: '#10b981', stroke: '#fff', strokeWidth: 1.5 }), + idx === coordsCoop.length - 1 ? React.createElement('text', { + x: c.x, y: c.y + 14, textAnchor: 'middle', className: 'h2-weekly-trend-bar-label' + }, c.value) : null + ); + }), + coordsNormal.map(function (c, idx) { + return React.createElement('g', { key: 'pn-' + idx }, + React.createElement('circle', { cx: c.x, cy: c.y, r: 3.5, fill: '#f59e0b', stroke: '#fff', strokeWidth: 1.5 }), + idx === coordsNormal.length - 1 ? React.createElement('text', { + x: c.x, y: c.y + 26, textAnchor: 'middle', className: 'h2-weekly-trend-bar-label' + }, c.value) : null + ); + }), + coordsTotal.filter(function (_c, idx) { + return idx === 0 || idx === coordsTotal.length - 1 || idx % 3 === 0; + }).map(function (c, idx) { + return React.createElement('text', { + key: 'xl-' + idx, + x: c.x, y: H - 12, textAnchor: 'middle', className: 'h2-weekly-trend-axis-label' + }, c.label); + }) + ); +} + +function h2WeeklyRenderTrendNewTable(periods) { + return React.createElement('table', { className: 'h2-weekly-trend-data-table' }, + React.createElement('caption', null, '每期新增数据表(可供读屏与键盘用户查阅)'), + React.createElement('thead', null, + React.createElement('tr', null, + React.createElement('th', { scope: 'col' }, '期次'), + React.createElement('th', { scope: 'col' }, '周期'), + React.createElement('th', { scope: 'col' }, '新增收录'), + React.createElement('th', { scope: 'col' }, '新增合作'), + React.createElement('th', { scope: 'col' }, '新增普通') + ) + ), + React.createElement('tbody', null, + periods.map(function (p) { + return React.createElement('tr', { key: 'new-row-' + p.period }, + React.createElement('td', null, '第' + p.period + '期'), + React.createElement('td', null, p.range || p.label), + React.createElement('td', null, p.newTotal), + React.createElement('td', null, p.newCooperative), + React.createElement('td', null, p.newNormal) + ); + }) + ) + ); +} + +function h2WeeklyRenderTrendCumulativeTable(periods) { + return React.createElement('table', { className: 'h2-weekly-trend-data-table' }, + React.createElement('caption', null, '累计站点数据表(可供读屏与键盘用户查阅)'), + React.createElement('thead', null, + React.createElement('tr', null, + React.createElement('th', { scope: 'col' }, '期次'), + React.createElement('th', { scope: 'col' }, '周期'), + React.createElement('th', { scope: 'col' }, '签约累计'), + React.createElement('th', { scope: 'col' }, '普通累计'), + React.createElement('th', { scope: 'col' }, '全部累计') + ) + ), + React.createElement('tbody', null, + periods.map(function (p) { + return React.createElement('tr', { key: 'cum-row-' + p.period }, + React.createElement('td', null, '第' + p.period + '期'), + React.createElement('td', null, p.range || p.label), + React.createElement('td', null, p.cumulativeCooperative), + React.createElement('td', null, p.cumulativeNormal), + React.createElement('td', null, p.cumulativeTotal) + ); + }) + ) + ); +} + +function h2WeeklyRenderTrendPanel() { + var Tabs = window.antd && window.antd.Tabs; + var periods = h2WeeklyBuildTrendPeriods(); + var newChartCard = React.createElement('div', { className: 'h2-weekly-trend-chart-card' }, + React.createElement('div', { className: 'h2-weekly-trend-chart-head' }, + React.createElement('span', { className: 'h2-weekly-trend-chart-head__title' }, '每期新增站点'), + React.createElement('div', { className: 'h2-weekly-trend-legend' }, + h2WeeklyTrendLegendItem('#3b82f6', '新增收录站点数', false), + h2WeeklyTrendLegendItem('#10b981', '新增合作站点数', false), + h2WeeklyTrendLegendItem('#f59e0b', '新增普通站点数', false) + ) + ), + React.createElement('div', { className: 'h2-weekly-trend-chart-body' }, + h2WeeklyRenderBarTrendChart(periods), + h2WeeklyRenderTrendNewTable(periods) + ) + ); + var cumulativeChartCard = React.createElement('div', { className: 'h2-weekly-trend-chart-card' }, + React.createElement('div', { className: 'h2-weekly-trend-chart-head' }, + React.createElement('span', { className: 'h2-weekly-trend-chart-head__title' }, '累计站点数量'), + React.createElement('div', { className: 'h2-weekly-trend-legend' }, + h2WeeklyTrendLegendItem('#10b981', '签约站点累计数量', true), + h2WeeklyTrendLegendItem('#f59e0b', '普通站点累计数量', true), + h2WeeklyTrendLegendItem('#6366f1', '全部站点累计数量', true) + ) + ), + React.createElement('div', { className: 'h2-weekly-trend-chart-body' }, + h2WeeklyRenderLineTrendChart(periods), + h2WeeklyRenderTrendCumulativeTable(periods) + ) + ); + + if (!Tabs) { + return React.createElement('div', { className: 'h2-weekly-trend-panel' }, + React.createElement('p', { className: 'h2-weekly-trend-subtitle' }, '近12期站点变化趋势'), + newChartCard, + cumulativeChartCard + ); + } + + return React.createElement('div', { className: 'h2-weekly-trend-panel' }, + React.createElement('p', { className: 'h2-weekly-trend-subtitle' }, '近12期站点变化趋势'), + React.createElement(Tabs, { + className: 'h2-weekly-trend-tabs', + defaultActiveKey: 'new', + items: [ + { key: 'new', label: '每期新增站点', children: newChartCard }, + { key: 'cumulative', label: '累计站点数量', children: cumulativeChartCard } + ] + }) + ); +} + +function h2WeeklyParsePrdInlineText(text) { + var parts = String(text || '').split(/(\*\*[^*]+\*\*)/g); + var nodes = []; + for (var i = 0; i < parts.length; i++) { + var p = parts[i]; + if (!p) continue; + if (p.indexOf('**') === 0 && p.lastIndexOf('**') === p.length - 2) { + nodes.push(React.createElement('strong', { key: i }, p.slice(2, -2))); + } else { + nodes.push(p); + } + } + return nodes.length === 1 ? nodes[0] : nodes; +} + +function h2WeeklyIsPrdTableRow(line) { + return /^\|.+\|$/.test(String(line || '').trim()); +} + +function h2WeeklyIsPrdTableSep(line) { + return /^\|[\s\-:|]+\|$/.test(String(line || '').trim()); +} + +function h2WeeklyRenderPrdTableRow(line, rowKey, isHeader) { + var cells = String(line).trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function (c) { + return c.trim(); + }); + return React.createElement('tr', { key: rowKey }, + cells.map(function (cell, ci) { + var Tag = isHeader ? 'th' : 'td'; + return React.createElement(Tag, { + key: ci, + style: { + border: '1px solid #e5e7eb', + padding: '8px 10px', + textAlign: 'left', + verticalAlign: 'top', + fontWeight: isHeader ? 600 : 400, + background: isHeader ? '#f8fafc' : '#fff', + fontSize: 13, + lineHeight: 1.5 + } + }, h2WeeklyParsePrdInlineText(cell)); + }) + ); +} + +function h2WeeklyRenderPrdMarkdown(markdown) { + var lines = String(markdown || '').split(/\r?\n/); + var nodes = []; + var i = 0; + var inCode = false; + var codeBuf = []; + + while (i < lines.length) { + var line = lines[i]; + var trimmed = String(line || '').trim(); + + if (trimmed.indexOf('```') === 0) { + if (inCode) { + nodes.push(React.createElement('pre', { + key: 'code-' + i, + style: { + margin: '12px 0', padding: '12px 14px', background: '#f6f8fa', + border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 12, + lineHeight: 1.6, overflow: 'auto', color: '#334155', whiteSpace: 'pre-wrap' + } + }, codeBuf.join('\n'))); + codeBuf = []; + inCode = false; + } else { + inCode = true; + } + i += 1; + continue; + } + if (inCode) { codeBuf.push(line); i += 1; continue; } + if (trimmed === '---') { + nodes.push(React.createElement('hr', { key: 'hr-' + i, style: { border: 'none', borderTop: '1px solid #e8ecf0', margin: '20px 0' } })); + i += 1; + continue; + } + if (h2WeeklyIsPrdTableRow(trimmed)) { + var tableLines = []; + while (i < lines.length && h2WeeklyIsPrdTableRow(String(lines[i]).trim())) { + tableLines.push(String(lines[i]).trim()); + i += 1; + } + var bodyRows = []; + for (var ti = 0; ti < tableLines.length; ti++) { + if (h2WeeklyIsPrdTableSep(tableLines[ti])) continue; + bodyRows.push(h2WeeklyRenderPrdTableRow(tableLines[ti], 'tr-' + i + '-' + ti, ti === 0)); + } + if (bodyRows.length) { + nodes.push(React.createElement('div', { key: 'tbl-' + i, style: { overflowX: 'auto', margin: '12px 0 16px' } }, + React.createElement('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: 13 } }, + React.createElement('tbody', null, bodyRows) + ) + )); + } + continue; + } + if (!trimmed) { i += 1; continue; } + if (trimmed.indexOf('# ') === 0) { + nodes.push(React.createElement('h1', { key: 'h1-' + i, style: { fontSize: 20, fontWeight: 700, color: '#0f172a', margin: '0 0 16px', lineHeight: 1.35 } }, h2WeeklyParsePrdInlineText(trimmed.slice(2).trim()))); + i += 1; continue; + } + if (trimmed.indexOf('## ') === 0) { + nodes.push(React.createElement('h2', { key: 'h2-' + i, style: { fontSize: 16, fontWeight: 700, color: '#1e293b', margin: '24px 0 12px', paddingBottom: 6, borderBottom: '2px solid #e0f2fe', lineHeight: 1.4 } }, h2WeeklyParsePrdInlineText(trimmed.slice(3).trim()))); + i += 1; continue; + } + if (trimmed.indexOf('### ') === 0) { + nodes.push(React.createElement('h3', { key: 'h3-' + i, style: { fontSize: 14, fontWeight: 600, color: '#334155', margin: '16px 0 8px', lineHeight: 1.45 } }, h2WeeklyParsePrdInlineText(trimmed.slice(4).trim()))); + i += 1; continue; + } + if (trimmed === '**文档结束**') { + nodes.push(React.createElement('div', { key: 'end-' + i, style: { marginTop: 24, paddingTop: 16, borderTop: '1px dashed #e2e8f0', color: '#94a3b8', fontSize: 13, textAlign: 'center' } }, '— 文档结束 —')); + i += 1; continue; + } + if (trimmed.indexOf('- ') === 0) { + nodes.push(React.createElement('div', { key: 'ul-' + i, style: { display: 'flex', gap: 8, fontSize: 13, color: '#475569', lineHeight: 1.75, margin: '4px 0 4px 2px' } }, + React.createElement('span', { style: { color: '#32a06e', flexShrink: 0 } }, '•'), + React.createElement('span', { style: { flex: 1 } }, h2WeeklyParsePrdInlineText(trimmed.slice(2).trim())) + )); + i += 1; continue; + } + if (trimmed.indexOf('> ') === 0) { + nodes.push(React.createElement('blockquote', { key: 'bq-' + i, style: { margin: '8px 0', padding: '8px 12px', borderLeft: '3px solid #bbf7d0', background: '#f8fafc', color: '#475569', fontSize: 13, lineHeight: 1.75 } }, h2WeeklyParsePrdInlineText(trimmed.slice(2).trim()))); + i += 1; continue; + } + nodes.push(React.createElement('p', { key: 'p-' + i, style: { fontSize: 13, color: '#475569', lineHeight: 1.75, margin: '6px 0' } }, h2WeeklyParsePrdInlineText(trimmed))); + i += 1; + } + return nodes; +} + +function h2WeeklyRenderRequirementDocPanel() { + var md = H2_WEEKLY_REQUIREMENT_DOC || ''; + if (!md) { + return React.createElement('div', { style: { padding: 24, color: '#64748b', textAlign: 'center' } }, '需求文档未加载'); + } + return React.createElement('div', { className: 'h2-weekly-req-doc-panel' }, h2WeeklyRenderPrdMarkdown(md)); +} + +function h2WeeklyDownloadCsv(weekStart, summary, list) { + var range = h2WeeklyFormatRange(weekStart).replace(/\s/g, ''); + var rows = []; + rows.push(['统计周期', range]); + rows.push(['本周新增', String(summary.newTotal)]); + rows.push(['本周新增签约', String(summary.newCooperative)]); + rows.push(['本周新增普通', String(summary.newNormal)]); + rows.push(['全国累计', String(summary.totalStations)]); + rows.push(['签约站点累计', String(summary.cooperativeStations)]); + rows.push(['普通站点累计', String(summary.normalStations)]); + rows.push([]); + rows.push(['序', '省/市', '加氢站名称', '站点类型', '创建时间']); + (list || []).forEach(function (row, idx) { + rows.push([ + String(idx + 1), + h2WeeklyFormatRegion(row), + row.stationName, + row.stationType, + h2WeeklyFormatCreatedAt(row.entryAt) + ]); + }); + var csv = rows.map(function (cols) { + return cols.map(function (cell) { + var s = cell == null ? '' : String(cell); + if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'; + return s; + }).join(','); + }).join('\n'); + var blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = '加氢站周报_' + range.replace(/~/g, '_') + '.csv'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +const Component = function () { + var useState = React.useState; + var useMemo = React.useMemo; + var useCallback = React.useCallback; + var useEffect = React.useEffect; + + var antd = window.antd; + var Table = antd.Table; + var Select = antd.Select; + var DatePicker = antd.DatePicker; + var Cascader = antd.Cascader; + var Tag = antd.Tag; + var Modal = antd.Modal; + var message = antd.message; + var Spin = antd.Spin; + var dayjs = h2WeeklyEnsureDayjs(); + + var today = dayjs ? dayjs() : null; + var defaultWeek = today ? h2WeeklyStartOfWeek(today) : null; + + var weekState = useState(defaultWeek); + var weekValue = weekState[0]; + var setWeekValue = weekState[1]; + + var typeState = useState('全部'); + var stationType = typeState[0]; + var setStationType = typeState[1]; + + var appliedWeekState = useState(defaultWeek); + var appliedWeek = appliedWeekState[0]; + var setAppliedWeek = appliedWeekState[1]; + + var appliedTypeState = useState('全部'); + var appliedType = appliedTypeState[0]; + var setAppliedType = appliedTypeState[1]; + + var regionState = useState(undefined); + var region = regionState[0]; + var setRegion = regionState[1]; + + var appliedRegionState = useState(undefined); + var appliedRegion = appliedRegionState[0]; + var setAppliedRegion = appliedRegionState[1]; + + var loadingState = useState(true); + var loading = loadingState[0]; + var setLoading = loadingState[1]; + + var pageState = useState(1); + var page = pageState[0]; + var setPage = pageState[1]; + + var pageSizeState = useState(20); + var pageSize = pageSizeState[0]; + var setPageSize = pageSizeState[1]; + + var viewModalState = useState({ open: false, viewRecord: null }); + var viewModal = viewModalState[0]; + var setViewModal = viewModalState[1]; + + var trendModalState = useState(false); + var trendModalOpen = trendModalState[0]; + var setTrendModalOpen = trendModalState[1]; + + var reqDocState = useState(false); + var reqDocOpen = reqDocState[0]; + var setReqDocOpen = reqDocState[1]; + + var simulateLoad = useCallback(function (nextWeek, nextType, nextRegion) { + setLoading(true); + window.setTimeout(function () { + setAppliedWeek(nextWeek); + setAppliedType(nextType); + setAppliedRegion(nextRegion); + setPage(1); + setLoading(false); + }, 320); + }, []); + + useEffect(function () { + simulateLoad(defaultWeek, '全部', undefined); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + var mockPayload = useMemo(function () { + return h2WeeklyPickMock(appliedWeek); + }, [appliedWeek]); + + var filteredList = useMemo(function () { + var list = mockPayload.list || []; + if (appliedType === '签约') list = list.filter(function (r) { return r.stationType === '签约'; }); + else if (appliedType === '普通') list = list.filter(function (r) { return r.stationType === '普通'; }); + if (appliedRegion && appliedRegion.length) { + list = list.filter(function (r) { return h2WeeklyMatchRegion(r, appliedRegion); }); + } + return list; + }, [mockPayload, appliedType, appliedRegion]); + + var displaySummary = useMemo(function () { + var s = mockPayload.summary; + if (appliedType === '全部') return s; + var list = filteredList; + var coop = list.filter(function (r) { return r.stationType === '签约'; }).length; + var normal = list.filter(function (r) { return r.stationType === '普通'; }).length; + return Object.assign({}, s, { + newTotal: list.length, + newCooperative: coop, + newNormal: normal + }); + }, [mockPayload, filteredList, appliedType]); + + var pagedList = useMemo(function () { + var start = (page - 1) * pageSize; + return filteredList.slice(start, start + pageSize).map(function (row, idx) { + return Object.assign({}, row, { _seq: start + idx + 1 }); + }); + }, [filteredList, page, pageSize]); + + var totalCount = filteredList.length; + var totalPages = Math.max(1, Math.ceil(totalCount / pageSize) || 1); + var safePage = Math.min(page, totalPages); + + var handleQuery = useCallback(function () { + simulateLoad(weekValue, stationType, region); + }, [weekValue, stationType, region, simulateLoad]); + + var handleReset = useCallback(function () { + var nowWeek = dayjs ? h2WeeklyStartOfWeek(dayjs()) : defaultWeek; + setWeekValue(nowWeek); + setStationType('全部'); + setRegion(undefined); + simulateLoad(nowWeek, '全部', undefined); + }, [dayjs, defaultWeek, simulateLoad]); + + var jumpToThisWeek = useCallback(function () { + var nowWeek = dayjs ? h2WeeklyStartOfWeek(dayjs()) : defaultWeek; + setWeekValue(nowWeek); + simulateLoad(nowWeek, stationType, region); + }, [dayjs, defaultWeek, stationType, region, simulateLoad]); + + var jumpToLastWeek = useCallback(function () { + var last = dayjs ? h2WeeklyStartOfWeek(dayjs().subtract(7, 'day')) : defaultWeek; + setWeekValue(last); + simulateLoad(last, stationType, region); + }, [dayjs, defaultWeek, stationType, region, simulateLoad]); + + var handleExportExcel = useCallback(function () { + h2WeeklyDownloadCsv(appliedWeek, displaySummary, filteredList); + message.success('已导出 Excel(CSV)'); + }, [appliedWeek, displaySummary, filteredList, message]); + + var openTrendModal = useCallback(function () { + setTrendModalOpen(true); + }, []); + + var closeTrendModal = useCallback(function () { + setTrendModalOpen(false); + }, []); + + var openDetail = useCallback(function (record) { + setViewModal({ open: true, viewRecord: h2WeeklyResolveStationViewRecord(record) }); + }, []); + + var closeDetail = useCallback(function () { + setViewModal({ open: false, viewRecord: null }); + }, []); + + var renderFilterField = useCallback(function (label, control) { + return React.createElement('label', { className: 'vm-filter-field' }, + React.createElement('span', null, label), + control + ); + }, []); + + var renderKpiCard = useCallback(function (meta, value, subText) { + return React.createElement('div', { + key: meta.key, + className: 'vm-kpi-card', + role: 'group', + 'aria-label': meta.title + ',' + value + }, + React.createElement('span', { + className: 'vm-kpi-tip', + tabIndex: 0, + role: 'note', + 'aria-label': meta.title + '说明' + }, + h2WeeklyHelpIcon(), + React.createElement('span', { className: 'vm-kpi-tooltip', role: 'tooltip' }, meta.desc) + ), + React.createElement('span', { className: 'vm-kpi-main' }, + React.createElement('span', { className: 'vm-kpi-eyebrow' }, meta.title), + React.createElement('span', { className: 'vm-kpi-val' }, value), + subText ? React.createElement('span', { className: 'h2-weekly-kpi-sub' }, subText) : null + ) + ); + }, []); + + var columns = useMemo(function () { + return [ + { + title: '序', + dataIndex: '_seq', + key: 'seq', + width: 64, + align: 'center' + }, + { + title: '省/市', + key: 'region', + width: 160, + render: function (_text, record) { + return h2WeeklyFormatRegion(record); + } + }, + { + title: '加氢站名称', + dataIndex: 'stationName', + key: 'stationName', + ellipsis: true, + render: function (text, record) { + return React.createElement('button', { + type: 'button', + className: 'vm-link', + style: { border: 'none', background: 'none', padding: 0, cursor: 'pointer' }, + onClick: function () { openDetail(record); } + }, text); + } + }, + { + title: '站点类型', + dataIndex: 'stationType', + key: 'stationType', + width: 100, + render: function (type) { + var isSigned = type === '签约'; + return React.createElement(Tag, { + className: 'h2-weekly-type-tag', + color: isSigned ? 'success' : 'default' + }, isSigned ? '签约站点' : '普通站点'); + } + }, + { + title: '创建时间', + dataIndex: 'entryAt', + key: 'entryAt', + width: 180, + render: function (v) { + return React.createElement('span', { + style: { fontVariantNumeric: 'tabular-nums', fontFamily: 'var(--vm-font-mono)' } + }, h2WeeklyFormatCreatedAt(v)); + } + }, + { + title: '操作', + key: 'action', + width: 148, + fixed: 'right', + render: function (_v, record) { + return React.createElement(OperationActions, { + view: { onClick: function () { openDetail(record); } } + }); + } + } + ]; + }, [openDetail]); + + var summaryPeriod = useMemo(function () { + return h2WeeklyResolveSummaryPeriod(appliedWeek); + }, [appliedWeek]); + + var periodCopy = useMemo(function () { + return h2WeeklyGetPeriodCopy(summaryPeriod); + }, [summaryPeriod]); + + var emptyNode = React.createElement('div', { style: { padding: '40px 0', textAlign: 'center' } }, + React.createElement('div', { style: { color: '#94a3b8', fontSize: 13 } }, periodCopy.emptyText) + ); + + var summaryText = displaySummary.newTotal > 0 + ? React.createElement(React.Fragment, null, + h2WeeklyRenderSummaryPeriodLead(summaryPeriod), + summaryPeriod.type === 'other' ? ' ' : '', + '新增签约站点 ', + React.createElement('strong', null, displaySummary.newCooperative), ' 个、普通站点 ', + React.createElement('strong', null, displaySummary.newNormal), ' 个;截至' + periodCopy.weekendLabel + ',全国加氢站 ', + React.createElement('strong', null, displaySummary.totalStations), ' 个,其中签约 ', + React.createElement('strong', null, displaySummary.cooperativeStations), ' 个、普通 ', + React.createElement('strong', null, displaySummary.normalStations), ' 个。' + ) + : React.createElement(React.Fragment, null, + h2WeeklyRenderSummaryPeriodLead(summaryPeriod), + summaryPeriod.type === 'other' ? ' ' : '', + '无新增站点;截至' + periodCopy.weekendLabel + ',全国加氢站 ', + React.createElement('strong', null, displaySummary.totalStations), ' 个,其中签约 ', + React.createElement('strong', null, displaySummary.cooperativeStations), ' 个、普通 ', + React.createElement('strong', null, displaySummary.normalStations), ' 个。' + ); + + + return React.createElement('div', { className: 'vm-page lc-page h2-site-page h2-site-list-shell h2-weekly-page' }, + React.createElement('style', null, H2_WEEKLY_PAGE_STYLE), + + React.createElement('div', { className: 'h2-weekly-page-top' }, + React.createElement('button', { + type: 'button', + className: 'h2-weekly-page-top__link', + onClick: function () { setReqDocOpen(true); } + }, '查看需求文档') + ), + + React.createElement('section', { + className: 'vm-filter-card', + 'aria-label': '筛选条件' + }, + React.createElement('header', { className: 'vm-filter-header' }, + React.createElement('h2', { className: 'vm-filter-title' }, '筛选条件') + ), + React.createElement('div', { className: 'vm-filter-grid' }, + renderFilterField('统计周', React.createElement('div', null, + React.createElement(DatePicker, { + picker: 'week', + value: weekValue || undefined, + allowClear: false, + placeholder: '选择自然周', + style: { width: '100%' }, + onChange: function (v) { + var nextWeek = v ? h2WeeklyStartOfWeek(v) : defaultWeek; + setWeekValue(nextWeek); + simulateLoad(nextWeek, stationType, region); + }, + format: function (val) { + return h2WeeklyFormatRange(val); + } + }), + React.createElement('div', { className: 'h2-weekly-week-hint' }, + '自然周:周一 00:00 ~ 周日 23:59(Asia/Shanghai)' + ) + )), + renderFilterField('快捷周', React.createElement('div', { className: 'h2-weekly-quick' }, + React.createElement('button', { + type: 'button', + className: 'vm-btn vm-btn-ghost ldb-toolbar-btn', + onClick: jumpToThisWeek + }, '本周'), + React.createElement('button', { + type: 'button', + className: 'vm-btn vm-btn-ghost ldb-toolbar-btn', + onClick: jumpToLastWeek + }, '上周') + )), + renderFilterField('站点类型', React.createElement(Select, { + value: stationType, + onChange: setStationType, + options: [ + { value: '全部', label: '全部' }, + { value: '签约', label: '签约站点' }, + { value: '普通', label: '普通站点' } + ], + style: { width: '100%' } + })), + renderFilterField('地区(省-市)', React.createElement(Cascader, { + options: H2_WEEKLY_REGION_CASCADER_OPTIONS, + value: region && region.length ? region : undefined, + onChange: function (v) { setRegion(v || undefined); }, + placeholder: '请选择省 / 市', + allowClear: true, + style: { width: '100%' }, + changeOnSelect: true + })) + ), + React.createElement('div', { className: 'vm-filter-actions' }, + React.createElement('button', { + type: 'button', + className: 'vm-btn vm-btn-ghost ldb-toolbar-btn', + 'data-vm-icon': 'rotate-ccw', + onClick: handleReset + }, '重置'), + React.createElement('button', { + type: 'button', + className: 'vm-btn vm-btn-primary ldb-toolbar-btn', + 'data-vm-icon': 'search', + onClick: handleQuery + }, '查询') + ) + ), + + React.createElement(Spin, { spinning: loading }, + React.createElement('div', { className: 'h2-weekly-body' }, + React.createElement('div', { + className: 'vm-kpi-row', + role: 'group', + 'aria-label': '周报统计' + }, + renderKpiCard( + Object.assign({}, H2_WEEKLY_KPI_META[0], { title: periodCopy.newLabel }), + displaySummary.newTotal, + '签约 ' + displaySummary.newCooperative + ' · 普通 ' + displaySummary.newNormal + ), + renderKpiCard(H2_WEEKLY_KPI_META[1], displaySummary.totalStations, '截至所选周末'), + renderKpiCard(H2_WEEKLY_KPI_META[2], displaySummary.cooperativeStations, '合作加氢站'), + renderKpiCard(H2_WEEKLY_KPI_META[3], displaySummary.normalStations, '非合作加氢站') + ), + + React.createElement('div', { + className: 'h2-weekly-summary' + (displaySummary.newTotal === 0 ? ' h2-weekly-summary--empty' : ''), + role: 'status' + }, summaryText), + + React.createElement('section', { className: 'vm-table-section' }, + React.createElement('div', { className: 'vm-table-toolbar' }, + React.createElement('div', { className: 'vm-table-actions' }, + React.createElement('button', { + type: 'button', + className: 'vm-btn vm-btn-ghost ldb-toolbar-btn', + 'data-vm-icon': 'bar-chart-2', + onClick: openTrendModal + }, '趋势展示'), + React.createElement('button', { + type: 'button', + className: 'vm-btn vm-btn-primary ldb-toolbar-btn', + 'data-vm-icon': 'download', + onClick: handleExportExcel + }, '导出 Excel') + ) + ), + React.createElement('div', { className: 'vm-table-card' }, + React.createElement('div', { className: 'vm-table-wrap' }, + React.createElement(Table, { + className: 'vm-list-table', + rowKey: 'id', + columns: columns, + dataSource: pagedList, + pagination: false, + size: 'small', + tableLayout: 'fixed', + scroll: { x: 820 }, + locale: { emptyText: emptyNode } + }) + ), + React.createElement('div', { className: 'vm-table-footer' }, + React.createElement(TablePagination, { + page: safePage, + pageSize: pageSize, + total: totalCount, + pageSizeOptions: COMPACT_PAGE_SIZE_OPTIONS, + onPageChange: setPage, + onPageSizeChange: function (size) { + setPageSize(size); + setPage(1); + } + }) + ) + ) + ), + + React.createElement('p', { className: 'h2-weekly-footnote' }, + '统计口径:该周新增按站点「创建时间」落入所选自然周统计;累计为截至该周日 23:59:59 仍在册的有效站点。数据来源:加氢站管理 → 站点信息。' + ) + ) + ), + + React.createElement(Modal, { + className: 'h2-weekly-trend-modal', + title: '趋势展示', + open: trendModalOpen, + onCancel: closeTrendModal, + footer: React.createElement('button', { + type: 'button', + className: 'vm-btn vm-btn-ghost', + onClick: closeTrendModal + }, '关闭'), + centered: true, + width: 920, + destroyOnClose: true + }, h2WeeklyRenderTrendPanel()), + + React.createElement(Modal, { + title: '站点周报统计 — 需求文档', + open: reqDocOpen, + onCancel: function () { setReqDocOpen(false); }, + footer: React.createElement('button', { + type: 'button', + className: 'vm-btn vm-btn-primary', + onClick: function () { setReqDocOpen(false); } + }, '知道了'), + centered: true, + width: 840, + destroyOnClose: true, + styles: { + body: { maxHeight: '72vh', overflow: 'auto', paddingTop: 8, paddingBottom: 16 } + } + }, h2WeeklyRenderRequirementDocPanel()), + + React.createElement(H2StationViewModal, { + open: viewModal.open, + record: viewModal.viewRecord, + onClose: closeDetail + }) + ); +}; + +if (typeof window !== 'undefined') window.Component = Component; + +export default Component; diff --git a/src/prototypes/oneos-web-h2-station/pages/站点周报统计-需求内容.js b/src/prototypes/oneos-web-h2-station/pages/站点周报统计-需求内容.js new file mode 100644 index 0000000..f0e6356 --- /dev/null +++ b/src/prototypes/oneos-web-h2-station/pages/站点周报统计-需求内容.js @@ -0,0 +1,2 @@ +// 与 oneos-web-h2-station-weekly/.spec/requirements-prd.md 同步 +export var H2_WEEKLY_REQUIREMENT_DOC = "# 加氢站管理 · 站点周报统计 — 产品需求文档(PRD)\r\n\r\n| 项 | 内容 |\r\n|---|---|\r\n| 文档版本 | v1.0 |\r\n| 模块名称 | 加氢站管理 → 站点周报统计 |\r\n| 所属系统 | ONE-OS Web 端 |\r\n| 目标读者 | 前端 / 后端 / 测试 / 业务产品对接人 |\r\n| 交互原型 | `/prototypes/oneos-web-h2-station-weekly` |\r\n| 关联原型 | [站点信息](/prototypes/oneos-web-h2-station-site)、[加氢站合包](/prototypes/oneos-web-h2-station) |\r\n| 设计规范 | `src/prototypes/vm-shared/DESIGN.md` |\r\n| 文档状态 | 已对齐原型与批注 |\r\n\r\n---\r\n\r\n## 1. 背景与目标\r\n\r\n### 1.1 业务背景\r\n\r\n采购与运营团队每周五需人工整理加氢站新增与累计数据,通过邮件发送周报。该流程耗时、口径易不一致,且难以回溯历史周数据。\r\n\r\n### 1.2 本期目标\r\n\r\n- 提供**自然周维度**的站点新增与累计统计,替代人工邮件周报主流程\r\n- 支持按周筛选、KPI 概览、邮件风格摘要、新增明细表、趋势图与 Excel 导出\r\n- 与「站点信息」主数据口径对齐(创建时间、站点类型、省/市等)\r\n\r\n### 1.3 用户角色\r\n\r\n| 角色 | 典型诉求 |\r\n|---|---|\r\n| 采购/运营 | 每周查看新增签约与普通站点,复制摘要用于沟通 |\r\n| 管理层 | 查看近 12 期趋势与全国累计 |\r\n| 数据分析 | 导出 Excel 做二次分析 |\r\n\r\n### 1.4 非目标(本期不做)\r\n\r\n- 邮件自动发送、PDF 排版导出\r\n- 后端接口联调(原型使用 Mock)\r\n- 站点主数据维护(仍在「站点信息」)\r\n\r\n---\r\n\r\n## 2. 信息架构\r\n\r\n```text\r\n站点周报统计\r\n├── 筛选区(统计周、快捷周、站点类型、地区)\r\n├── KPI 卡片(4 张)\r\n├── 周报摘要条\r\n├── 新增明细表 + 工具栏(趋势展示、导出 Excel)\r\n├── 趋势弹窗(双 Tab:每期新增 / 累计数量)\r\n└── 站点详情抽屉\r\n```\r\n\r\n---\r\n\r\n## 3. 筛选区\r\n\r\n与站点信息等模块一致的 **vm 筛选卡片** 布局。\r\n\r\n| 字段 | 控件 | 说明 |\r\n|---|---|---|\r\n| 统计周 | 周选择器 | 自然周:周一 00:00 ~ 周日 23:59(Asia/Shanghai);展示 `YYYY-MM-DD ~ YYYY-MM-DD` |\r\n| 快捷周 | 按钮组 | 「本周」「上周」一键切换并**立即查询** |\r\n| 站点类型 | 下拉 | 全部 / 签约站点 / 普通站点 |\r\n| 地区(省-市) | 级联 | 省、市两级,可只选省;`changeOnSelect` |\r\n\r\n**交互**:\r\n\r\n- **查询**:应用筛选,刷新 KPI、摘要、列表,回到第 1 页\r\n- **重置**:清空类型与地区,统计周回到本周并刷新\r\n- **手选统计周**:与快捷周一致,选择后**自动查询**(无需再点查询)\r\n\r\n---\r\n\r\n## 4. KPI 统计卡片\r\n\r\n四张 **vm-kpi-card**,只读展示(非点击筛选)。\r\n\r\n| 卡片 | 口径 | 动态标题 |\r\n|---|---|---|\r\n| 该周新增 | 所选自然周内创建的有效站点数 | 本周/上周/该周新增 |\r\n| 全国累计 | 截至所选周末仍在册的有效站点总数 | 固定 |\r\n| 签约站点 | 合作加氢站累计 | 固定 |\r\n| 普通站点 | 非合作加氢站累计 | 固定 |\r\n\r\n首卡副文案:`签约 N · 普通 N`。\r\n\r\nKPI 标题、表格空态、摘要尾部「截至本周末」等文案须**随所选统计周**切换(本周 / 上周 / 该周末)。\r\n\r\n---\r\n\r\n## 5. 周报摘要条\r\n\r\n邮件风格一句话汇总,位于 KPI 与表格之间。\r\n\r\n**周期前缀规则**:\r\n\r\n| 所选周 | 前缀示例 |\r\n|---|---|\r\n| 本周 | 本周(07/13-07/19) |\r\n| 上周 | 上周(07/06-07/12) |\r\n| 其它历史周 | 仅日期区间 `05/26-06/01` |\r\n\r\n**有新增模板**:\r\n\r\n> {周期前缀}新增签约站点 **N** 个、普通站点 **N** 个;截至{本周末/上周末/该周末},全国加氢站 **N** 个,其中签约 **N** 个、普通 **N** 个。\r\n\r\n**无新增模板**:\r\n\r\n> {周期前缀}无新增站点;截至{周末标签},全国加氢站 **N** 个……\r\n\r\n---\r\n\r\n## 6. 新增明细表\r\n\r\n### 6.1 工具栏\r\n\r\n| 按钮 | 行为 |\r\n|---|---|\r\n| 趋势展示 | 打开近 12 期趋势弹窗 |\r\n| 导出 Excel | 导出当前筛选结果 CSV(UTF-8 BOM),文件名含统计周期 |\r\n\r\n**不包含**:导出 PDF(P1)按钮。\r\n\r\n### 6.2 列表字段\r\n\r\n| 列名 | 说明 |\r\n|---|---|\r\n| 序 | 当前页序号 |\r\n| 省/市 | 合并展示,格式 `湖北省-宜昌市` |\r\n| 加氢站名称 | 可点击查看详情 |\r\n| 站点类型 | 签约站点 / 普通站点 Tag |\r\n| 创建时间 | `YYYY-MM-DD HH:mm:ss` |\r\n| 操作 | `OperationActions` 组件,主操作「查看」 |\r\n\r\n### 6.3 空态\r\n\r\n随所选周动态:`本周无新增站点` / `上周无新增站点` / `{日期区间} 无新增站点`。\r\n\r\n### 6.4 分页\r\n\r\n默认 10 条,可选 5/10/20/50(`TablePagination` + `COMPACT_PAGE_SIZE_OPTIONS`)。\r\n\r\n---\r\n\r\n## 7. 趋势展示弹窗\r\n\r\n标题:**趋势展示**。副标题:近 12 期站点变化趋势。\r\n\r\n拆为 **两个 Tab**,避免单页图表过小:\r\n\r\n### Tab 1:每期新增站点\r\n\r\n柱状图三组:\r\n\r\n| 系列 | 颜色 | 说明 |\r\n|---|---|---|\r\n| 新增收录站点数 | 蓝色 | 该期全部新增 |\r\n| 新增合作站点数 | 绿色 | 该期签约新增 |\r\n| 新增普通站点数 | 橙色 | 收录 − 合作 |\r\n\r\n数据表列:期次、周期、新增收录、新增合作、新增普通。\r\n\r\n### Tab 2:累计站点数量\r\n\r\n折线图三条:\r\n\r\n| 系列 | 颜色 | 说明 |\r\n|---|---|---|\r\n| 签约站点累计数量 | 绿色 | |\r\n| 普通站点累计数量 | 橙色 | 全部 − 签约 |\r\n| 全部站点累计数量 | 紫色 | |\r\n\r\n数据表列:期次、周期、签约累计、普通累计、全部累计。\r\n\r\n**约束**:仅展示数量,不使用「增长良好」「环比下降」等评价标签。\r\n\r\n**可访问性**:图表下方提供数据表,供读屏与键盘用户查阅。\r\n\r\n---\r\n\r\n## 8. 站点详情抽屉\r\n\r\n字段与主表对齐:\r\n\r\n| 字段 | 格式 |\r\n|---|---|\r\n| 加氢站名称 | 文本 |\r\n| 省/市 | `省-市` |\r\n| 站点类型 | Tag |\r\n| 创建时间 | `YYYY-MM-DD HH:mm:ss` |\r\n\r\n---\r\n\r\n## 9. 统计口径\r\n\r\n| 指标 | 规则 |\r\n|---|---|\r\n| 该周新增 | 站点「创建时间」落入所选自然周的有效站点 |\r\n| 签约/普通新增 | 按站点类型拆分 |\r\n| 全国累计 | 截至该周日 23:59:59 仍在册的有效站点 |\r\n| 签约/普通累计 | 按类型统计截至周末的存量 |\r\n\r\n数据来源:加氢站管理 → 站点信息。\r\n\r\n---\r\n\r\n## 10. 设计规范\r\n\r\n- 页面壳层:`vm-page` + `lc-page` + `h2-weekly-page`\r\n- 主题色 `#32a06e`,字体 Inter / 苹方\r\n- 操作列使用 `OperationActions`\r\n- 分页使用 `TablePagination`\r\n- 不重复侧栏模块大标题\r\n\r\n---\r\n\r\n## 11. 验收标准\r\n\r\n### 11.1 筛选与 KPI\r\n\r\n- [ ] 统计周、快捷周、类型、地区筛选联动正确\r\n- [ ] 手选周与快捷周均自动加载数据\r\n- [ ] KPI / 空态 / 摘要文案随所选周切换\r\n\r\n### 11.2 表格与导出\r\n\r\n- [ ] 列名:省/市、加氢站名称、创建时间\r\n- [ ] 操作列使用 OperationActions\r\n- [ ] 导出 Excel 含摘要与明细\r\n\r\n### 11.3 趋势图\r\n\r\n- [ ] 双 Tab 展示\r\n- [ ] 含普通站点新增与累计\r\n- [ ] 无评价性标签\r\n- [ ] 附带数据表\r\n\r\n### 11.4 文档\r\n\r\n- [ ] 右上角可打开本 PRD\r\n\r\n---\r\n\r\n## 12. 修订记录\r\n\r\n| 版本 | 日期 | 说明 |\r\n|---|---|---|\r\n| v1.0 | 2026-07-13 | 初版:整合批注与 UI 评审意见,对齐现行原型 |\r\n\r\n**文档结束**\r\n"; diff --git a/src/prototypes/oneos-web-h2-station/shared/h2-station-view-shared.jsx b/src/prototypes/oneos-web-h2-station/shared/h2-station-view-shared.jsx new file mode 100644 index 0000000..832e49a --- /dev/null +++ b/src/prototypes/oneos-web-h2-station/shared/h2-station-view-shared.jsx @@ -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 = '' + + '' + + '' + + '' + + '' + label + '' + + ''; + 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 || '附件预览' }) + )) + ); +}