Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4430fb75f1 | ||
|
|
df259fc12b | ||
|
|
df6b8201e8 | ||
|
|
a177781fe7 | ||
|
|
637a72c1e9 | ||
|
|
98efde3f75 |
@@ -0,0 +1,14 @@
|
||||
# Copy to .env.local; never commit real credentials. Environment values win.
|
||||
HYDROGEN_DB_HOST=
|
||||
HYDROGEN_DB_PORT=3306
|
||||
HYDROGEN_DB_USER=
|
||||
HYDROGEN_DB_PASSWORD=
|
||||
HYDROGEN_DB_NAME=
|
||||
# 单站与外部客户数据租户;仅由服务端设置,不接受网页传入。
|
||||
HYDROGEN_TENANT_ID=000000
|
||||
# Optional legacy/electric business database; use a database-level read-only user.
|
||||
DB_HOST=
|
||||
DB_PORT=3306
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_NAME=
|
||||
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.worktrees
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
RUN npm ci --registry=https://registry.npmmirror.com/
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
@@ -10,7 +10,7 @@ FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
RUN npm ci --omit=dev --registry=https://registry.npmmirror.com/
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY src/server ./src/server
|
||||
COPY src/shared ./src/shared
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 本地只读预览
|
||||
|
||||
使用 Node.js 22+ 和 npm,在本项目内执行 `npm ci`,避免依赖其他工作目录的 node_modules 软链接。
|
||||
|
||||
将 `.env.local.example` 复制为 `.env.local` 并填写连接配置,或通过进程环境变量提供配置。使用数据库侧只读账号;不要提交真实凭据。
|
||||
|
||||
执行 `npm run dev:local`,浏览器访问 http://127.0.0.1:8115/energy#hydrogen 。前后端固定绑定本机 8115 / 3001;端口占用时退出,不自动换端口。停止终端进程即可停止服务;此命令不配置开机自启。
|
||||
|
||||
本地入口关闭 mock、启用本地免登录,禁用后台建表与定时归档,并拒绝 API 的 POST / PUT / PATCH / DELETE。请勿把免登录预览暴露到公网。氢能查询启用只读 SQL 检查;这不能替代数据库账号的只读权限。
|
||||
|
||||
`npm run dev` / `npm start` 保留原部署方式;设置 `DB_READ_ONLY=1` 时同样不启动后台任务,且 API 禁止写入。生产登录流程需要写请求,因此不要把本地只读模式当作生产部署配置。
|
||||
|
||||
验证:`npm test`、`npm run lint`、`npm run build`;健康检查为 `GET http://127.0.0.1:3001/api/health`。健康检查成功仅代表进程可用,还需检查氢能 meta / overview 的真实数据响应。
|
||||
@@ -4,6 +4,9 @@
|
||||
"version": "1.1.15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:local": "concurrently -k -n server,client \"npm run dev:local:server\" \"npm run dev:local:client\"",
|
||||
"dev:local:server": "node --import tsx src/server/local.ts",
|
||||
"dev:local:client": "DEV_MOCK_API=0 VITE_DEV_BYPASS_AUTH=1 vite --host 127.0.0.1 --port 8115 --strictPort",
|
||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||
"dev:server": "tsx watch src/server/index.ts",
|
||||
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<svg width="150" height="36" viewBox="0 0 150 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.0459 4.69253L24.4084 4.6875L17.3427 16.9334C16.0108 19.2369 14.67 21.5335 13.357 23.8492C12.8279 24.6836 12.7967 25.5367 14.0379 25.5663C17.6611 25.6526 21.3268 25.5356 24.9495 25.6042C24.4224 26.7246 23.2874 28.5534 22.6381 29.6706L21.6984 31.3061L13.8032 31.3074C11.2823 31.3084 7.42295 31.774 6.15339 28.9623C5.10923 26.6498 6.73472 24.2701 7.86608 22.3109L10.1604 18.3408L18.0459 4.69253Z" fill="#2F2828"/>
|
||||
<path d="M30.7029 4.69293L38.1664 4.6931C40.8704 4.68656 45.5224 4.1044 46.4284 7.56236C47.0408 9.9001 45.3543 12.286 44.21 14.218C42.2951 14.125 39.809 14.1965 37.8457 14.1908C38.3321 13.3838 40.2747 10.8182 38.8199 10.5324C37.4531 10.2639 35.1947 10.4548 33.7653 10.4209C32.0072 13.3847 30.3349 16.4036 28.5844 19.3723C28.4964 19.5215 28.3602 19.7716 28.257 19.9038L21.9204 19.9023L30.7029 4.69293Z" fill="#2F2828"/>
|
||||
<path d="M89.1232 4.67032C89.7835 4.62775 90.9099 4.66078 91.6053 4.65867L90.978 7.75816L98.7808 7.75999C98.6607 8.46499 98.5297 9.16816 98.3877 9.86921L90.5769 9.86774C90.1934 11.0614 89.853 13.7315 89.4731 15.1689L97.9522 15.1851C97.8321 15.8946 97.6872 16.5697 97.5364 17.2721C94.9175 17.1811 91.7297 17.2538 89.0954 17.2677C88.594 19.3603 88.1541 21.9308 87.7303 24.0635C86.9061 24.0335 86.0812 24.0264 85.2563 24.0421C85.7525 22.0904 86.18 19.3243 86.6177 17.2638L85.2372 17.257L77.0092 17.2648C77.1446 16.5758 77.2727 15.8857 77.3957 15.1944C79.8624 15.1112 82.3687 15.2077 84.839 15.1714C85.5417 15.161 86.2942 15.1588 86.9939 15.2057C87.447 13.5537 87.6608 11.5742 88.1087 9.87872C86.2986 9.82772 84.3142 9.86591 82.4909 9.86569C81.7604 11.226 81.0028 12.4816 80.2657 13.8163L77.5362 13.8114C79.6816 10.4975 80.6808 8.44223 82.2311 4.86287C83.0545 4.83579 84.0075 4.85801 84.8405 4.85771C84.4152 5.82928 83.998 6.81499 83.512 7.75669C85.1516 7.77967 86.8351 7.76247 88.4769 7.76452C88.7163 6.8407 88.9417 5.61995 89.1232 4.67032Z" fill="#2F2828"/>
|
||||
<path d="M70.2503 4.65824L73.7308 4.65625C74.7336 7.08713 76.1309 9.57515 77.5099 11.8054C76.5818 11.7752 75.527 11.798 74.5887 11.7967C73.5397 9.99962 72.6069 8.13727 71.7957 6.22117C71.2298 6.89947 70.7149 7.63591 70.1079 8.35496L72.1324 8.35415C72.359 9.56198 72.4332 11.2339 72.4619 12.4558L70.2398 12.4478C70.2372 11.0171 70.1331 9.90113 70.0025 8.48344C69.1539 9.57259 67.962 10.814 66.9868 11.7877L65.7182 11.7932L64.2958 11.7963C64.2202 12.4282 64.0755 13.0643 63.9444 13.6881L60.6789 13.6869C60.5363 14.5168 60.3766 15.3436 60.1998 16.1668L63.9171 16.1664L63.5281 18.0826C62.277 18.0555 60.9445 18.0784 59.687 18.0782C58.9474 20.2403 57.828 22.2532 56.3813 24.0224C55.6509 24.0768 54.3913 24.03 53.5917 24.0442C55.3706 21.7397 56.0946 20.7987 57.3125 18.0802L53.6008 18.0731C53.7608 17.396 53.882 16.8655 53.9824 16.1718C55.2848 16.1587 56.5873 16.1591 57.8898 16.1732L58.3652 13.6896L54.9231 13.6853C55.1139 13.0172 55.1958 12.5027 55.3015 11.8164C56.2519 11.7504 57.7795 11.7881 58.7587 11.8109C58.8945 11.0981 59.037 10.3867 59.1861 9.67657C57.9106 9.6642 56.6351 9.66427 55.3597 9.67657C55.4544 9.05996 55.5544 8.44415 55.66 7.8293C57.5354 7.80764 59.4484 7.82827 61.3266 7.83003C61.9933 6.86852 62.6551 5.74668 63.0825 4.66341L65.5379 4.65888C64.9966 5.88817 64.5907 6.67983 63.9019 7.83288L65.5427 7.82645C65.4452 8.4301 65.2987 9.07927 65.1744 9.68205C63.9717 9.64854 62.7013 9.67152 61.4934 9.67532C61.3775 10.3714 61.2146 11.1007 61.0712 11.7947L63.9987 11.7913C66.5788 9.20798 68.0943 7.61586 70.2503 4.65824Z" fill="#2F2828"/>
|
||||
<path d="M122.532 11.8166C125.798 11.7231 129.441 11.7977 132.734 11.7984C132.466 13.5695 131.978 15.624 131.64 17.4038C131.42 18.5691 130.997 20.9151 130.604 21.9327C130.313 22.7085 129.869 23.4177 129.299 24.0182C128.475 24.0895 127.218 24.0195 126.289 24.0612C126.736 23.701 127.014 23.4316 127.42 23.0228C128.176 22.1519 128.359 21.6669 128.744 20.5722C126.97 20.5048 124.822 20.5601 123.018 20.5584C122.945 21.3158 122.507 23.1882 122.339 24.0641C121.646 24.031 120.793 24.0186 120.111 24.0547C120.247 23.0506 120.522 21.8389 120.724 20.8256L121.799 15.4428C121.945 14.7076 122.299 12.3722 122.532 11.8166ZM129.104 18.8694C129.21 18.2708 129.304 17.7024 129.465 17.1139C127.646 17.0946 125.48 17.1746 123.726 17.1053C123.609 17.6915 123.487 18.2767 123.359 18.8607C125.251 18.8633 127.22 18.8364 129.104 18.8694ZM124.059 15.3809C124.989 15.3848 129.088 15.5282 129.805 15.3507C129.899 14.7602 130.003 14.261 130.135 13.6774C128.223 13.6522 126.311 13.6515 124.399 13.6752C124.302 14.1834 124.187 14.895 124.059 15.3809Z" fill="#2F2828"/>
|
||||
<path d="M28.257 19.9042C30.3408 19.8373 32.5067 19.9107 34.6003 19.8799C33.5304 21.7989 32.4375 23.705 31.3217 25.5977L37.6027 25.587C37.1076 26.6547 36.2454 28.0061 35.655 29.0623C35.3804 29.5534 34.6405 30.8847 34.3403 31.3077L28.0049 31.3022C28.8125 29.6956 29.8532 28.1298 30.7172 26.5471C30.8889 26.2323 31.0858 25.9059 31.2849 25.6084C29.1922 25.6072 27.049 25.5816 24.9604 25.61C25.6026 24.4466 26.26 23.2918 26.9324 22.1456C27.3144 21.4843 27.8407 20.5151 28.257 19.9042Z" fill="#007143"/>
|
||||
<path d="M99.7206 10.1076C106.203 9.98573 113.088 10.0971 119.599 10.1018C118.777 13.8389 118.005 17.7955 117.477 21.5875C117.37 22.3581 118.045 23.5899 118.033 24.042C117.125 24.0338 116.218 24.0361 115.311 24.0488C114.92 21.1109 115.519 19.0866 116.068 16.2425C116.357 14.7439 116.652 13.209 116.981 11.7202L99.4205 11.7137C99.5633 11.1642 99.6343 10.6691 99.7206 10.1076Z" fill="#2F2828"/>
|
||||
<path d="M64.5255 12.945C68.4291 12.8445 72.7488 12.9376 76.6835 12.9394C76.5327 13.651 76.4317 14.5237 75.9691 15.0919C75.4018 15.7882 74.7928 16.4582 74.197 17.1312L70.7419 21.0305L69.7438 21.059C70.2176 22.0474 70.6693 23.0462 71.0985 24.0548C70.1752 24.0352 69.2515 24.0344 68.3282 24.0525C67.9964 23.2947 67.6583 22.5397 67.3138 21.7875C66.4548 19.874 65.5159 17.9972 64.5 16.1621C65.3591 16.1484 66.2391 16.156 67.1001 16.1542C67.8294 17.3343 68.5019 18.5485 69.1155 19.7926C70.6881 18.1842 72.2203 16.5366 73.7103 14.8514L64.1427 14.8496C64.2745 14.2155 64.402 13.5807 64.5255 12.945Z" fill="#2F2828"/>
|
||||
<path d="M100.218 12.4635C104.946 12.4535 109.674 12.4727 114.401 12.5212C114.273 12.9715 114.112 13.3776 113.946 13.814C112.657 14.4639 111.179 15.1061 109.857 15.7056C111.599 15.949 113.275 15.9778 115.027 16.028C114.844 16.6716 114.645 17.3102 114.427 17.943C112.64 17.957 110.455 17.6924 108.705 17.3225C108.285 17.2407 107.508 16.991 107.072 16.8654C104.084 18.0184 101.851 17.949 98.7192 17.9316C98.791 17.2822 98.9052 16.6738 99.0208 16.0327C102.633 15.9161 105.599 15.8462 108.933 14.123C106.178 14.2266 102.668 14.1367 99.8604 14.135C99.9731 13.5764 100.092 13.0192 100.218 12.4635Z" fill="#2F2828"/>
|
||||
<path d="M98.7625 18.3504C100.425 18.3036 102.282 18.3451 103.963 18.3452L114.077 18.3459C113.979 18.982 113.891 19.4244 113.733 20.0481C111.681 19.9793 109.274 20.0318 107.196 20.0488C107.14 20.5854 106.873 21.7822 106.759 22.3601C108.872 22.2731 111.751 22.3361 113.883 22.3568C113.718 22.9049 113.8 23.5534 113.504 23.9553C113.406 24.0871 113.154 24.042 112.96 24.0414C107.731 23.9823 102.283 24.0035 97.0497 24.0421C97.15 23.4629 97.2678 22.9333 97.3996 22.3599C99.6372 22.3158 102.265 22.3081 104.496 22.3613C104.62 21.6 104.787 20.8055 104.936 20.0457C102.829 19.9936 100.54 20.0389 98.4199 20.0394L98.7625 18.3504Z" fill="#2F2828"/>
|
||||
<path d="M133.256 13.9166C133.997 13.9023 134.788 13.9161 135.533 13.9168C135.386 14.8072 135.174 15.7896 135.001 16.6831C137.384 16.4394 139.769 16.2153 142.155 16.0109C141.98 16.6792 141.965 17.2281 141.716 17.9362C141.421 17.9321 141.131 17.9513 140.839 17.9823C138.756 18.2028 136.663 18.3336 134.585 18.5982C134.457 19.6086 134.11 21.1394 133.89 22.1662L135.596 22.1591H140.893C140.792 22.7924 140.684 23.4247 140.569 24.0557C137.586 23.9698 134.244 24.0318 131.246 24.0396C131.9 20.9278 132.738 17.0234 133.256 13.9166Z" fill="#2F2828"/>
|
||||
<path d="M135.098 4.62931C135.853 4.62166 136.607 4.62419 137.363 4.6369C137.162 5.41299 137.017 6.28154 136.868 7.07464C139.102 6.84738 141.908 6.69114 144.053 6.37755C143.943 7.01342 143.835 7.73358 143.657 8.34683L136.465 8.98722C136.314 9.81163 136.157 10.6349 135.994 11.4569C136.445 11.4309 137.106 11.4499 137.573 11.4482C139.397 11.4591 141.222 11.4559 143.046 11.4386C142.935 12.1076 142.839 12.6758 142.674 13.3374C139.624 13.2772 136.387 13.3227 133.323 13.3214C133.552 12.5671 133.793 11.1594 133.955 10.3413C134.324 8.43507 134.706 6.53103 135.098 4.62931Z" fill="#2F2828"/>
|
||||
<path d="M102.916 4.63357C103.716 4.61473 104.573 4.6323 105.378 4.63596L104.934 5.48128L121.832 5.48318C121.739 6.10161 121.672 6.52446 121.507 7.12456C119.818 7.05448 117.769 7.10604 116.053 7.10625L103.984 7.11772C103.38 7.9858 102.762 8.89904 102.054 9.68109C101.302 9.67604 100.124 9.63799 99.419 9.71446C99.8816 9.06719 100.543 8.33907 101.04 7.66743C101.837 6.59222 102.296 5.78928 102.916 4.63357Z" fill="#2F2828"/>
|
||||
<path d="M126 4.67433C126.546 4.62029 128.007 4.65657 128.619 4.65502C127.72 6.37437 126.984 7.53498 125.875 9.09798C127.492 9.05503 129.258 9.08605 130.886 9.08474C130.822 8.136 130.773 7.13368 130.53 6.2156C131.306 6.15771 132.166 6.17288 132.949 6.17043C133.021 7.06163 133.221 8.13834 133.316 9.09988C133.309 9.63037 133.157 10.3841 133.057 10.9028C129.465 10.8026 125.423 10.8945 121.795 10.8945C123.602 8.78078 124.778 7.14823 126 4.67433Z" fill="#2F2828"/>
|
||||
<path d="M37.8456 14.1928C39.809 14.1984 42.2951 14.127 44.2099 14.22L41.9937 18.0919C41.7788 18.4647 41.1512 19.6063 40.9155 19.9025L34.5791 19.8957C35.6336 17.9833 36.7911 16.1017 37.8456 14.1928Z" fill="#007143"/>
|
||||
<path d="M40.9155 19.902C43.0195 19.8499 45.1502 19.9224 47.2506 19.8789C46.4721 21.4168 44.8928 24.105 43.9715 25.602L37.6138 25.6077C38.4831 23.9831 39.9311 21.447 40.9155 19.902Z" fill="#007143"/>
|
||||
<path d="M104.415 7.76391L120.496 7.76172C120.35 8.3135 120.238 8.87355 120.16 9.43887C118.084 9.37345 115.82 9.41552 113.733 9.4153L104.085 9.42408C104.155 8.93055 104.312 8.26469 104.415 7.76391Z" fill="#2F2828"/>
|
||||
<path d="M96.4304 26.879C99.2507 26.6667 98.9579 30.7714 95.9312 31.3474C92.5488 31.3098 93.6973 27.1881 96.4304 26.879ZM95.9883 30.5709C96.9304 30.2786 97.7955 29.3384 97.4662 28.3229C97.3944 28.096 97.2327 27.9087 97.0175 27.8056C96.7964 27.6975 96.4978 27.6448 96.2518 27.6614C95.3083 27.9393 94.49 28.8604 94.7842 29.8668C94.8501 30.099 95.0075 30.2944 95.2205 30.4079C95.4752 30.5427 95.7065 30.5599 95.9883 30.5709Z" fill="#2F2828"/>
|
||||
<path d="M120.579 26.8756C123.424 26.7179 123.034 30.7691 120.086 31.3456C116.806 31.3978 117.733 27.1994 120.579 26.8756ZM120.22 30.5368C122.019 29.9774 122.223 27.4751 120.314 27.6674C119.642 27.9156 119.439 28.0285 119.093 28.7081C118.616 29.6484 119.009 30.7721 120.22 30.5368Z" fill="#2F2828"/>
|
||||
<path d="M116.66 26.9238L117.992 26.9219C117.747 28.2834 117.347 29.9417 117.049 31.3153L116.157 31.3083C116.347 30.6023 116.495 29.7902 116.644 29.0672C116.688 28.9313 116.767 28.502 116.801 28.3408C116.395 29.0335 115.459 30.9511 114.675 31.3444C114.566 31.399 114.465 31.3038 114.371 31.2359C114.165 30.71 113.925 28.8772 113.836 28.242L113.198 31.3117L112.31 31.3165L113.23 26.9279L114.491 26.9225C114.605 27.7811 114.833 28.7942 114.973 29.6896C115.472 28.9993 116.215 27.6823 116.66 26.9238Z" fill="#2F2828"/>
|
||||
<path d="M86.5921 26.9211C86.9917 26.9147 87.4287 26.9264 87.8225 26.9095C90.4759 26.7956 90.1867 29.6754 88.5223 30.9111C87.5305 31.4374 86.7699 31.3173 85.6661 31.3024C85.7957 30.2878 86.3578 28.007 86.5921 26.9211ZM86.7231 30.4522C87.1359 30.4503 87.7347 30.4871 88.1007 30.316C88.6401 29.8127 89.3033 28.7778 88.6848 28.0956C88.3158 27.6887 87.7976 27.7551 87.2911 27.7533C87.1257 28.5612 86.9295 29.669 86.7231 30.4522Z" fill="#2F2828"/>
|
||||
<path d="M123.457 26.9226C124.213 26.9143 125.634 26.8007 126.186 27.3128C126.71 27.7978 125.896 28.5423 125.68 28.9376C125.595 29.0947 126.087 29.5814 126.078 29.8632C126.065 30.2604 125.746 30.6635 125.492 30.9391C124.737 31.4476 123.501 31.3147 122.564 31.3117C122.81 29.8751 123.187 28.3774 123.457 26.9226ZM123.593 30.5161C124.141 30.5116 124.358 30.5255 124.891 30.3884C125.067 30.151 125.129 30.0825 125.184 29.7933C125.024 29.2759 124.325 29.3801 123.862 29.3789L123.593 30.5161ZM123.999 28.6156C124.282 28.6098 124.866 28.6145 125.123 28.5796C125.245 28.3715 125.336 28.252 125.328 28.0082C125.082 27.6121 124.658 27.6901 124.214 27.6711C124.144 27.9762 124.058 28.3111 123.999 28.6156Z" fill="#2F2828"/>
|
||||
<path d="M90.735 26.9215C92.024 26.9243 94.8238 26.5442 93.2493 29.0441C93.0876 29.3009 92.7831 29.446 92.5137 29.583C92.7384 30.1413 92.9478 30.7419 93.1556 31.3095C92.8314 31.3198 92.3036 31.3998 92.0979 31.1551C91.9179 30.6395 91.7971 30.0956 91.3931 29.741C90.8792 29.6812 90.7592 30.8999 90.6765 31.3136L89.8201 31.3149C90.1114 29.8477 90.4166 28.3831 90.735 26.9215ZM91.2167 28.8258C91.4736 28.8227 92.2707 28.8268 92.4815 28.7911C92.6447 28.5511 92.7304 28.4484 92.7926 28.1652C92.6755 27.6152 91.9171 27.7496 91.4458 27.7535C91.366 28.1101 91.2891 28.4675 91.2167 28.8258Z" fill="#2F2828"/>
|
||||
<path d="M69.3081 26.9222L70.1771 26.9141C69.9698 28.186 69.5337 30.0383 69.2388 31.3177L68.1065 31.3098C67.8398 30.3494 67.4692 29.2815 67.1696 28.3128C67.0189 29.2546 66.7757 30.3753 66.5616 31.3076L65.6787 31.3079L66.6075 26.9221L67.7409 26.9223C68.0639 27.8733 68.37 28.898 68.6717 29.8604C68.873 28.8787 69.0851 27.8992 69.3081 26.9222Z" fill="#2F2828"/>
|
||||
<path d="M108.881 29.8868C109.027 29.2478 109.289 27.5001 109.54 27.0002C109.723 26.889 110.12 26.9136 110.352 26.9105C110.263 27.8288 109.679 30.3103 109.457 31.3037L108.341 31.3085C108.011 30.3531 107.719 29.367 107.384 28.4004L106.772 31.3134L105.9 31.3077C106.155 29.911 106.523 28.3126 106.832 26.9175L107.966 26.9206C108.2 27.8185 108.6 28.9795 108.881 29.8868Z" fill="#2F2828"/>
|
||||
<path d="M61.0197 26.9225L61.8629 26.9102C61.7079 28.0991 61.2332 30.0627 60.9722 31.3099L59.8503 31.3086C59.519 30.3592 59.2069 29.3429 58.8931 28.3815L58.2722 31.3125L57.3802 31.3049C57.7123 30.0129 58.0673 28.24 58.3208 26.9184L59.4601 26.922C59.6904 27.8033 60.1149 29.0009 60.4008 29.8927C60.5467 28.9635 60.7999 27.8383 61.0197 26.9225Z" fill="#2F2828"/>
|
||||
<path d="M78.6518 26.9215L79.5294 26.9146C79.4255 27.4595 79.2959 28.0172 79.1766 28.5603C79.689 28.553 80.216 28.5592 80.7298 28.5592L81.0951 26.923L81.9493 26.9141C81.8519 27.9269 81.2576 30.0868 81.0526 31.1643C81.0343 31.2615 80.9867 31.26 80.8865 31.3094C80.6281 31.3129 80.44 31.3262 80.1889 31.262C80.2658 30.6683 80.4253 30.0042 80.5534 29.4127L78.998 29.4118C78.867 30.0925 78.7586 30.6456 78.5669 31.3166L77.7339 31.3159C77.9893 29.9039 78.3363 28.3198 78.6518 26.9215Z" fill="#2F2828"/>
|
||||
<path d="M103.391 26.918L106.201 26.9222L106.032 27.7871C105.48 27.7389 104.623 27.7531 104.059 27.7493C104.008 28.0771 103.939 28.4261 103.88 28.7546L105.576 28.7521L105.445 29.4786C105.093 29.4693 104.014 29.408 103.727 29.5427L103.677 29.7236L103.488 30.4471L105.46 30.4415C105.391 30.7111 105.332 31.0336 105.273 31.3098C104.347 31.2889 103.359 31.3061 102.428 31.3067C102.714 29.9523 103.028 28.2233 103.391 26.918Z" fill="#2F2828"/>
|
||||
<path d="M101.23 26.8806C101.741 26.8186 102.269 26.978 102.715 27.2175C102.67 27.4649 102.637 27.8569 102.466 28.0164C102.325 28.0441 101.618 27.7141 101.272 27.6162C100.683 27.7595 100.181 27.9029 99.8684 28.4645C99.2265 29.6163 99.5405 30.8072 101.032 30.4857C101.475 30.0635 101.026 29.4324 101.687 29.1473C101.888 29.1663 102.02 29.1878 102.216 29.2257C102.312 29.5647 101.987 30.9407 101.742 31.0531C97.9931 32.7738 97.5708 27.2933 101.23 26.8806Z" fill="#2F2828"/>
|
||||
<path d="M74.9385 26.9214C75.1712 26.9118 75.4808 26.8744 75.6821 26.9707C75.7905 27.2226 75.7246 27.2838 75.6726 27.6035C75.3345 28.829 75.382 30.2578 74.2211 31.0737C73.6941 31.4969 72.454 31.4555 72.0957 30.8256C71.57 29.9017 72.2194 27.9607 72.4365 26.9236L73.3245 26.923C73.2315 27.4568 72.5294 29.9806 72.8643 30.2786C74.3522 31.6025 74.7181 27.8186 74.9385 26.9214Z" fill="#2F2828"/>
|
||||
<path d="M64.4934 26.8798C65.0658 26.8499 65.4038 26.9622 65.9183 27.1614C65.9004 27.4821 65.8231 27.8782 65.7694 28.201C65.3237 27.8267 65.0369 27.7669 64.4898 27.6111C64.0346 27.7299 63.4555 27.898 63.1815 28.3015C62.8659 28.7125 62.5666 30.01 63.1207 30.2988C64.5127 31.0242 64.4083 30.3038 64.6865 29.2341C64.6957 29.1988 65.0789 29.1547 65.1737 29.1403L65.443 29.2169C65.5798 29.5824 65.2474 30.6016 65.1325 31.0264C64.7903 31.1835 64.4256 31.2859 64.0516 31.3298C60.9761 31.6856 61.3322 27.1955 64.4934 26.8798Z" fill="#2F2828"/>
|
||||
<path d="M85.1552 26.9215L86.2297 26.918C85.7693 27.4771 85.3097 28.0605 84.8551 28.6258C84.028 29.5226 84.0163 30.1406 83.7762 31.3103L82.8773 31.3089C83.4446 29.0983 83.1291 29.0171 82.3298 26.9199L83.3107 26.9197C83.512 27.4507 83.7059 27.9844 83.8926 28.5204L85.1552 26.9215Z" fill="#2F2828"/>
|
||||
<path d="M139.373 26.9247L140.422 26.918C140.11 27.4009 139.441 28.178 139.059 28.6479C138.207 29.6578 138.305 30.0537 137.985 31.3112L137.113 31.3094C137.262 30.7606 137.396 30.0375 137.516 29.4702C137.197 28.6188 136.873 27.7696 136.543 26.9227L137.516 26.921C137.728 27.4476 137.932 27.9767 138.13 28.5083C138.556 27.9893 138.97 27.4613 139.373 26.9247Z" fill="#2F2828"/>
|
||||
<path d="M57.3993 4.67527C58.1061 4.65383 58.8637 4.66377 59.5748 4.66016C59.8347 5.63537 59.8946 6.41442 59.9652 7.40979C59.3798 7.36896 58.4268 7.39837 57.8139 7.39757C57.7331 6.3945 57.6463 5.64817 57.3993 4.67527Z" fill="#2F2828"/>
|
||||
<path d="M133.406 26.9219L136.447 26.9253L136.333 27.7751C135.986 27.7619 135.585 27.7671 135.234 27.7638C135.054 28.5778 134.891 29.4212 134.722 30.2397L134.468 31.3148L133.589 31.3145C133.858 30.1379 134.11 28.9573 134.343 27.7731C134.007 27.7561 133.613 27.7725 133.273 27.7783L133.406 26.9219Z" fill="#2F2828"/>
|
||||
<path d="M53.4287 26.918L54.3232 26.9254L53.5875 30.453L55.5179 30.4498L55.3763 31.3123C54.4204 31.2926 53.4227 31.3031 52.4631 31.2988C52.7142 30.6151 53.2746 27.7241 53.4287 26.918Z" fill="#2F2828"/>
|
||||
<path d="M128.859 26.924L129.753 26.9219C129.475 28.0093 129.226 29.3489 128.993 30.4563L130.915 30.4496L130.763 31.3133C129.848 31.2883 128.851 31.3078 127.93 31.3087L128.859 26.924Z" fill="#2F2828"/>
|
||||
<path d="M127.308 26.9204L128.164 26.918L127.239 31.3152L126.351 31.3151C126.65 29.8952 126.949 28.3149 127.308 26.9204Z" fill="#2F2828"/>
|
||||
<path d="M56.7724 26.918L57.6656 26.9141C57.3133 28.3287 57.0132 29.8795 56.7123 31.3129L55.8318 31.3086C56.1293 29.8417 56.4429 28.3781 56.7724 26.918Z" fill="#2F2828"/>
|
||||
<path d="M70.8595 26.9214L71.7381 26.918C71.567 28.0722 71.0941 30.1443 70.8119 31.3022L69.9707 31.3105C70.1852 29.9437 70.5812 28.3064 70.8595 26.9214Z" fill="#2F2828"/>
|
||||
<path d="M132.146 26.9187L133.056 26.918C132.809 28.082 132.418 30.2402 132.087 31.3152L131.25 31.3052C131.533 29.8444 131.902 28.3846 132.146 26.9187Z" fill="#2F2828"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
|
||||
import { Building2, ChevronRight } from 'lucide-react';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { DemoModeProvider } from './Blur';
|
||||
import FeedbackFab from './FeedbackFab';
|
||||
@@ -155,7 +155,9 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
|
||||
<ShieldCheck size={18} />
|
||||
<svg width="28" height="22" viewBox="4 3 44 30" role="img" aria-label="羚牛 Logo">
|
||||
<image href="/lingniu-logo-light.svg" width="150" height="36" style={{ filter: 'brightness(0) invert(1)' }} />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,9 @@ import type {
|
||||
H2BiDailyResponse,
|
||||
H2BiDailyTreeQuery,
|
||||
H2BiDailyTreeResponse,
|
||||
H2BiDrillReadOptions,
|
||||
H2BiFullDrillOptions,
|
||||
H2BiFullDrillResponse,
|
||||
H2BiDrillQuery,
|
||||
H2BiDrillResponse,
|
||||
H2BiMetaResponse,
|
||||
@@ -20,9 +23,9 @@ function queryString(query: Record<string, unknown>) {
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function request<T>(path: string, query: object = {}) {
|
||||
function request<T>(path: string, query: object = {}, options?: RequestInit) {
|
||||
const qs = queryString(query as Record<string, unknown>);
|
||||
return fetchJson<T>(`${BASE}/${path}${qs ? `?${qs}` : ''}`);
|
||||
return fetchJson<T>(`${BASE}/${path}${qs ? `?${qs}` : ''}`, options);
|
||||
}
|
||||
|
||||
export function fetchH2BiMeta() {
|
||||
@@ -87,6 +90,93 @@ export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) {
|
||||
return request<H2BiDailyTreeResponse>('daily-tree', { date, ...query });
|
||||
}
|
||||
|
||||
export function fetchH2BiDrill(query: H2BiDrillQuery) {
|
||||
return request<H2BiDrillResponse>('drill', query);
|
||||
export async function fetchH2BiDrill(
|
||||
query: H2BiDrillQuery,
|
||||
options?: H2BiDrillReadOptions,
|
||||
) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = Math.min(Math.max(1, query.pageSize ?? 100), 200);
|
||||
const response = await request<H2BiDrillResponse>(
|
||||
'drill',
|
||||
{ ...query, page, pageSize },
|
||||
options,
|
||||
);
|
||||
const itemCount = query.groupBy === 'record'
|
||||
? response.records.length
|
||||
: response.groups.length;
|
||||
// Older servers only set hasMore for record pages. A full page is therefore
|
||||
// also treated as potentially incomplete; the final empty/short page proves
|
||||
// completion without silently dropping grouped rows.
|
||||
return {
|
||||
...response,
|
||||
page: {
|
||||
...response.page,
|
||||
page,
|
||||
pageSize,
|
||||
itemCount,
|
||||
hasMore: Boolean(response.page?.hasMore) || itemCount === pageSize,
|
||||
},
|
||||
} satisfies H2BiDrillResponse;
|
||||
}
|
||||
|
||||
function abortError() {
|
||||
const error = new Error('已取消全量读取');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every page only after a caller explicitly asks for a complete result.
|
||||
* It never returns a partial collection: server errors, cancellation, and the
|
||||
* page guard all reject before an export can be created.
|
||||
*/
|
||||
export async function fetchAllH2BiDrill(
|
||||
query: Omit<H2BiDrillQuery, 'page' | 'pageSize'>,
|
||||
options: H2BiFullDrillOptions = {},
|
||||
): Promise<H2BiFullDrillResponse> {
|
||||
const pageSize = Math.min(Math.max(1, options.pageSize ?? 200), 200);
|
||||
const maxPages = Math.max(1, options.maxPages ?? 250);
|
||||
const maxRows = Math.max(1, options.maxRows ?? 50_000);
|
||||
const groups: H2BiDrillResponse['groups'] = [];
|
||||
const records: H2BiDrillResponse['records'] = [];
|
||||
let first: H2BiDrillResponse | null = null;
|
||||
|
||||
for (let page = 1; page <= maxPages; page += 1) {
|
||||
if (options.signal?.aborted) throw abortError();
|
||||
const result = await fetchH2BiDrill(
|
||||
{ ...query, page, pageSize },
|
||||
{ signal: options.signal },
|
||||
);
|
||||
if (!first) first = result;
|
||||
groups.push(...result.groups);
|
||||
records.push(...result.records);
|
||||
const itemCount = query.groupBy === 'record' ? records.length : groups.length;
|
||||
if (itemCount > maxRows)
|
||||
throw new Error(`全量读取超过 ${maxRows} 条保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
|
||||
if (!result.page.hasMore) {
|
||||
return {
|
||||
...first,
|
||||
groups,
|
||||
records,
|
||||
page: {
|
||||
...result.page,
|
||||
page: 1,
|
||||
pageSize,
|
||||
itemCount,
|
||||
hasMore: false,
|
||||
pagesRead: page,
|
||||
complete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error(`全量读取超过 ${maxPages} 页保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
|
||||
}
|
||||
|
||||
/** Reusable contract for station-detail consumers that need every raw record. */
|
||||
export function fetchAllH2BiDrillRecords(
|
||||
query: Omit<H2BiDrillQuery, 'groupBy' | 'page' | 'pageSize'>,
|
||||
options?: H2BiFullDrillOptions,
|
||||
) {
|
||||
return fetchAllH2BiDrill({ ...query, groupBy: 'record' }, options);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { fetchAllH2BiDrill, fetchAllH2BiDrillRecords } from "./api";
|
||||
import type { H2BiDrillResponse } from "./types";
|
||||
|
||||
const query = {
|
||||
year: 2026,
|
||||
vehicleScope: "all" as const,
|
||||
verifyScope: "all" as const,
|
||||
};
|
||||
|
||||
function response(overrides: Partial<H2BiDrillResponse> = {}): H2BiDrillResponse {
|
||||
return {
|
||||
groupBy: "station",
|
||||
amountScope: "all",
|
||||
filters: {},
|
||||
summary: {},
|
||||
groups: [],
|
||||
records: [],
|
||||
page: { page: 1, pageSize: 2, hasMore: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function withFetch(
|
||||
handler: (url: URL) => H2BiDrillResponse | Promise<H2BiDrillResponse>,
|
||||
run: () => Promise<void>,
|
||||
) {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = new URL(String(input), "http://ln-bi.local");
|
||||
return new Response(JSON.stringify(await handler(url)), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
}
|
||||
|
||||
test("完整分组读取跨页,并以短页而非旧服务 hasMore 字段确认结束", async () => {
|
||||
const pages: number[] = [];
|
||||
await withFetch((url) => {
|
||||
const page = Number(url.searchParams.get("page"));
|
||||
pages.push(page);
|
||||
return response({
|
||||
groups: page === 1
|
||||
? ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 }))
|
||||
: [{ id: "3", name: "丙", province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 }],
|
||||
});
|
||||
}, async () => {
|
||||
const result = await fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2 });
|
||||
assert.deepEqual(result.groups.map((row) => row.name), ["甲", "乙", "丙"]);
|
||||
assert.equal(result.page.complete, true);
|
||||
assert.equal(result.page.pagesRead, 2);
|
||||
});
|
||||
assert.deepEqual(pages, [1, 2]);
|
||||
});
|
||||
|
||||
test("完整记录读取跨页后保留全部订单", async () => {
|
||||
await withFetch((url) => {
|
||||
const page = Number(url.searchParams.get("page"));
|
||||
return response({
|
||||
groupBy: "record",
|
||||
records: page === 1
|
||||
? [{ id: "a" }, { id: "b" }]
|
||||
: [{ id: "c" }],
|
||||
});
|
||||
}, async () => {
|
||||
const result = await fetchAllH2BiDrillRecords(query, { pageSize: 2 });
|
||||
assert.deepEqual(result.records.map((row) => row.id), ["a", "b", "c"]);
|
||||
assert.equal(result.page.hasMore, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("全量读取在中途请求失败时拒绝,不返回部分结果", async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const page = new URL(String(input), "http://ln-bi.local").searchParams.get("page");
|
||||
if (page === "2") return new Response("failed", { status: 502, statusText: "Bad Gateway" });
|
||||
return new Response(JSON.stringify(response({
|
||||
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
|
||||
})), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2 }),
|
||||
/API error: 502/,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("空结果是完整结果而不是加载失败", async () => {
|
||||
await withFetch(() => response(), async () => {
|
||||
const result = await fetchAllH2BiDrill({ ...query, groupBy: "station" });
|
||||
assert.deepEqual(result.groups, []);
|
||||
assert.equal(result.page.pagesRead, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("取消的全量读取不会发起请求或生成部分数据", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { signal: controller.signal }),
|
||||
(error: Error) => error.name === "AbortError",
|
||||
);
|
||||
});
|
||||
|
||||
test("全量读取在第一页完成后也会响应取消,不会请求下一页", async () => {
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
await withFetch(() => {
|
||||
calls += 1;
|
||||
controller.abort();
|
||||
return response({
|
||||
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
|
||||
});
|
||||
}, async () => {
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { signal: controller.signal, pageSize: 2 }),
|
||||
(error: Error) => error.name === "AbortError",
|
||||
);
|
||||
});
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("maxRows 和 maxPages 均拒绝不完整的全量读取", async () => {
|
||||
await withFetch(() => response({
|
||||
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
|
||||
}), async () => {
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2, maxRows: 1 }),
|
||||
/超过 1 条保护上限/,
|
||||
);
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2, maxPages: 1 }),
|
||||
/超过 1 页保护上限/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
@import './drill-workspace.css';
|
||||
.ehb-drill-modal--unified .ehb-modal-body { padding: 18px; background: #f6f8fb; }
|
||||
.ehb-drill-modal--unified .ehb-drill-root-tabs { display: flex; justify-content: flex-end; margin: 0 0 12px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
@@ -15,6 +16,34 @@
|
||||
.ehb-drill-modal--unified .ehb-modal-filter-group { width: 100%; gap: 8px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-hint-text { flex: 1 1 280px; min-width: 220px; color: #7b8aa1; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table-wrap { border-color: #d7e1ed; border-radius: 8px; background: #fff; }
|
||||
|
||||
/* Reserve space for pagination instead of clipping it below a fixed-height table. */
|
||||
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) > .ehb-modal-table-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 100px;
|
||||
}
|
||||
.ehb-drill-page-controls {
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.ehb-drill-page-controls .ehb-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 72px;
|
||||
width: 72px !important;
|
||||
min-height: 40px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-table th {
|
||||
height: 44px; padding: 10px 12px; border-bottom-color: #d7e1ed;
|
||||
background: #f0f3f8; color: #52627a;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/* Shared drill workspace: navigation stays visible, data owns the remaining height. */
|
||||
.ehb-drill-modal--unified .ehb-inline-child-row > td { background: #f8fbff; border-bottom: 1px solid #e2eaf4; }
|
||||
.ehb-drill-modal--unified .ehb-inline-child-row > td:first-child { background: #f8fbff !important; }
|
||||
.ehb-inline-child-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.ehb-inline-child-name > span { overflow-wrap: anywhere; font-weight: 600; }
|
||||
.ehb-inline-child-name small { display: block; color: #64748b; }
|
||||
.ehb-inline-child-toggle { flex: 0 0 32px; width: 32px; height: 36px; border: 0; border-radius: 6px; color: #2563eb; background: #eaf2ff; cursor: pointer; }
|
||||
.ehb-drill-modal--unified .ehb-inline-child-status > td,
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-row > td { padding: 8px 16px !important; background: #f8fbff; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-all { border: 0; background: transparent; color: #2563eb; font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title-group {
|
||||
min-width: 0;
|
||||
gap: 14px;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title-group > div { min-width: 0; }
|
||||
@media (max-width: 767px) {
|
||||
.ehb-modal-card.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) > .ehb-modal-head {
|
||||
min-height: 68px;
|
||||
padding: 10px 12px !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-back-btn {
|
||||
width: 44px !important;
|
||||
min-width: 44px !important;
|
||||
height: 44px !important;
|
||||
min-height: 44px !important;
|
||||
flex: 0 0 44px;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 10px;
|
||||
background: rgb(255 255 255 / 6%) !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-back-btn > span { display: none; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title {
|
||||
font-size: 16px !important;
|
||||
line-height: 1.4 !important;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__sub {
|
||||
margin-top: 3px;
|
||||
font-size: 11px !important;
|
||||
line-height: 1.5;
|
||||
color: #a7b6cc;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-table-wrap > table > thead > tr > th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: #f0f3f8;
|
||||
box-shadow: inset 0 -1px 0 #d7e1ed;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-table-wrap > table > thead > tr > th:first-child {
|
||||
z-index: 11;
|
||||
}
|
||||
.ehb-modal-overlay .ehb-modal-card.ehb-drill-modal--unified {
|
||||
width: 96vw !important;
|
||||
max-width: 1800px !important;
|
||||
height: 94dvh !important;
|
||||
max-height: 94dvh !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-head {
|
||||
padding: 10px 16px !important;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified > .ehb-drill-breadcrumbs { padding: 5px 16px; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-body {
|
||||
flex: 1 1 auto !important;
|
||||
min-height: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 6px !important;
|
||||
padding: 8px 16px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-body > * { flex: 0 0 auto; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-body > .ehb-modal-hint-text {
|
||||
flex: 0 0 auto !important;
|
||||
min-width: 0;
|
||||
margin: 0 !important;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-summary > span {
|
||||
display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-drill-root-tabs { margin: 0 !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-bar { margin: 0 !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-item { padding: 6px 12px !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-val { font-size: 18px !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-primary-actions {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-summary {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 40px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #d7e1ed;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
color: #334155;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-summary strong {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-panel:not(.is-open) { display: none !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-panel.is-open {
|
||||
display: flex !important;
|
||||
max-height: 30dvh;
|
||||
overflow: auto;
|
||||
margin: 0 !important;
|
||||
padding: 8px !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-drill-usage-guide { font-size: 12px; margin: 0; display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-body > .ehb-modal-table-wrap {
|
||||
flex: 1 1 0 !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
margin: 0 !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-drill-page-controls { margin-top: 0 !important; padding: 0; }
|
||||
@media (max-width: 767px), (max-height: 500px) {
|
||||
.ehb-modal-overlay .ehb-modal-card.ehb-drill-modal--unified {
|
||||
width: 100vw !important; height: 100dvh !important; max-height: 100dvh !important;
|
||||
border-radius: 0 !important; margin: 0 !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-head { padding: 6px 10px !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-body {
|
||||
padding: 6px 8px max(6px, env(safe-area-inset-bottom)) !important; gap: 4px !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
display: flex !important; flex-wrap: nowrap !important; overflow-x: auto !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-item { flex: 0 0 auto; width: auto !important; min-width: 120px; padding: 5px 9px !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-val { font-size: 16px !important; }
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronDown, ChevronLeft, Download, Search, SlidersHorizontal, Truck, X } from "lucide-react";
|
||||
import { MobileListFullscreenButton } from "../../../vendor/lnbi-8113-exact/common/MobileListFullscreenButton";
|
||||
import { fetchH2BiDrill, fetchH2BiMeta } from "./api";
|
||||
import { fetchAllH2BiDrill, fetchH2BiDrill, fetchH2BiMeta } from "./api";
|
||||
import { downloadExcelAoa } from "./prototype-download";
|
||||
import { bearingLabels } from "./bearing-labels";
|
||||
import type {
|
||||
@@ -133,9 +134,35 @@ function DrillSearchSelect({
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [menuPosition, setMenuPosition] = useState({ left: 0, top: 0, width: 180, maxHeight: 280 });
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const reposition = () => {
|
||||
const rect = rootRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const width = Math.min(Math.max(rect.width, 260), window.innerWidth - 16);
|
||||
const below = window.innerHeight - rect.bottom - 12;
|
||||
const above = rect.top - 12;
|
||||
const upwards = below < 220 && above > below;
|
||||
const maxHeight = Math.min(280, upwards ? above : below);
|
||||
setMenuPosition({ left: Math.max(8, Math.min(rect.left, window.innerWidth - width - 8)),
|
||||
top: upwards ? rect.top - maxHeight - 4 : rect.bottom + 4, width, maxHeight });
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); };
|
||||
reposition();
|
||||
window.addEventListener("resize", reposition);
|
||||
document.addEventListener("scroll", reposition, true);
|
||||
document.addEventListener("keydown", escape);
|
||||
return () => {
|
||||
window.removeEventListener("resize", reposition);
|
||||
document.removeEventListener("scroll", reposition, true);
|
||||
document.removeEventListener("keydown", escape);
|
||||
};
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
if (!rootRef.current?.contains(event.target as Node) && !menuRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
if (open) document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
@@ -171,13 +198,15 @@ function DrillSearchSelect({
|
||||
onClick={() => !disabled && setOpen((shown) => !shown)}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
title={selectedLabel}
|
||||
>
|
||||
<span className="ehb-bi-search-select__label">{selectedLabel}</span>
|
||||
<ChevronDown size={13} className="ehb-bi-search-select__chevron" />
|
||||
</button>
|
||||
{open && !disabled ? (
|
||||
<div className="ehb-bi-search-select__dropdown">
|
||||
{open && !disabled ? createPortal(
|
||||
<div ref={menuRef} className="ehb-bi-search-select__dropdown ehb-drill-select-portal"
|
||||
style={{ position: "fixed", ...menuPosition, right: "auto", minWidth: 0, zIndex: 10000, display: "flex", flexDirection: "column" }}>
|
||||
<label className="ehb-bi-search-select__search">
|
||||
<Search size={13} />
|
||||
<input
|
||||
@@ -188,7 +217,7 @@ function DrillSearchSelect({
|
||||
aria-label={`搜索${ariaLabel}`}
|
||||
/>
|
||||
</label>
|
||||
<div className="ehb-bi-search-select__list">
|
||||
<div className="ehb-bi-search-select__list" style={{ minHeight: 0, flex: "1 1 auto", maxHeight: "none" }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-bi-search-select__item ${value ? "" : "is-selected"}`}
|
||||
@@ -217,7 +246,7 @@ function DrillSearchSelect({
|
||||
<div className="ehb-bi-search-select__empty">无匹配项</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>, document.body
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -327,11 +356,17 @@ function nextState(current: DrillState, row: H2BiDrillGroupRow): DrillState {
|
||||
return current;
|
||||
}
|
||||
|
||||
function useDrill(query: H2BiQuery, state: DrillState, enabled = true) {
|
||||
function useDrill(
|
||||
query: H2BiQuery,
|
||||
state: DrillState,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
enabled = true,
|
||||
) {
|
||||
const [data, setData] = useState<H2BiDrillResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const key = JSON.stringify({ ...query, ...state });
|
||||
const key = JSON.stringify({ ...query, ...state, page, pageSize });
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setData(null);
|
||||
@@ -340,6 +375,7 @@ function useDrill(query: H2BiQuery, state: DrillState, enabled = true) {
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
const controller = new AbortController();
|
||||
let finishTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const loadingStartedAt = Date.now();
|
||||
setLoading(true);
|
||||
@@ -350,8 +386,10 @@ function useDrill(query: H2BiQuery, state: DrillState, enabled = true) {
|
||||
...state,
|
||||
groupBy: state.level,
|
||||
amountScope: state.amountScope,
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
page,
|
||||
pageSize,
|
||||
}, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((result) => alive && setData(result))
|
||||
.catch((reason: unknown) => {
|
||||
@@ -369,9 +407,10 @@ function useDrill(query: H2BiQuery, state: DrillState, enabled = true) {
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
controller.abort();
|
||||
if (finishTimer) clearTimeout(finishTimer);
|
||||
};
|
||||
}, [enabled, key]);
|
||||
}, [enabled, key, page, pageSize]);
|
||||
return { data, error, loading };
|
||||
}
|
||||
|
||||
@@ -407,6 +446,52 @@ function BearingTags({ row }: { row: { settlementTypes?: unknown; settlementType
|
||||
</span>;
|
||||
}
|
||||
|
||||
function ExpandedChildren({ data, state, query, kind, label, depth = 1 }: {
|
||||
data: H2BiDrillResponse; state: DrillState; query: H2BiQuery; kind: DrillKind; label: string; depth?: number;
|
||||
}) {
|
||||
return <>{data.groups.map((row) => <ExpandedChild key={rowIdentity(row)} row={row} state={state} query={query} kind={kind} label={label} depth={depth} />)}
|
||||
{data.records.map((record, index) => <tr className="ehb-inline-child-row" key={`${record.id}-${index}`}>
|
||||
<td><div className="ehb-inline-child-name" style={{ paddingLeft: depth * 14 }}><span>{String(record.orderNo || record.id)}</span><small>{String(record.time || "")}</small></div></td>
|
||||
<td>{record.vehicleScope === "lingniu" ? "羚牛车辆" : "外部车辆"}</td><td><BearingTags row={record} /></td><td>{sourceLabel(record.source)}</td><td>{verifyLabel(record.verifyStatus)}</td><td style={{textAlign:"right"}}>1 笔</td>
|
||||
<ExpandedMetrics row={record} kind={kind} label={label} amountScope={state.amountScope} />
|
||||
</tr>)}
|
||||
</>;
|
||||
}
|
||||
|
||||
function ExpandedMetrics({ row, kind, label, amountScope }: {
|
||||
row: { kg?: unknown; cost?: unknown; revenue?: unknown }; kind: DrillKind; label: string; amountScope: H2BiAmountScope;
|
||||
}) {
|
||||
const cell = (value: unknown, money = false) => <td style={{textAlign:"right"}}>{money ? "¥" : ""}{formatNumber(value)}</td>;
|
||||
if (label === "加氢利润") return <>{cell(row.revenue, true)}{cell(row.cost, true)}{cell(Number(row.revenue ?? 0) - Number(row.cost ?? 0), true)}</>;
|
||||
if (kind === "customer") return <>{cell(row.cost, true)}{cell(row.revenue, true)}<td>未接入</td><td>未接入</td></>;
|
||||
return <>{cell(row.kg)}{cell(amountScope === "customer" ? row.revenue : row.cost, true)}{label === "本日加氢" || label === "本月加氢" ? <td style={{textAlign:"right"}}>—</td> : null}</>;
|
||||
}
|
||||
|
||||
function ExpandedChild({ row, state, query, kind, label, depth }: {
|
||||
row: H2BiDrillGroupRow; state: DrillState; query: H2BiQuery; kind: DrillKind; label: string; depth: number;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const childState = nextState(state, row);
|
||||
const live = useDrill(query, childState, page, 50, expanded);
|
||||
const columns = label === "加氢利润" || label === "本日加氢" || label === "本月加氢" ? 9 : kind === "customer" ? 10 : 8;
|
||||
return <>
|
||||
<tr className="ehb-inline-child-row">
|
||||
<td><div className="ehb-inline-child-name" style={{ paddingLeft: depth * 14 }}>
|
||||
<button type="button" className="ehb-inline-child-toggle" aria-expanded={expanded} aria-label={`${expanded ? "收起" : "展开"}${row.name}`} onClick={() => { setExpanded(!expanded); setPage(1); }}>{expanded ? "▾" : "▸"}</button>
|
||||
<span>{row.name}</span>
|
||||
</div></td>
|
||||
<td>{titleFor(state.level)}</td><td><BearingTags row={row} /></td><td>账本汇总</td><td>汇总</td><td style={{textAlign:"right"}}>{formatNumber(row.recordCount, 0)} 笔</td>
|
||||
<ExpandedMetrics row={row} kind={kind} label={label} amountScope={state.amountScope} />
|
||||
</tr>
|
||||
{expanded ? live.loading || live.error || !live.data ? <tr className="ehb-inline-child-status"><td colSpan={columns}>{live.error ? `加载失败:${live.error},请收起后重试` : "正在加载子级数据…"}</td></tr> : <>
|
||||
<ExpandedChildren data={live.data} state={childState} query={query} kind={kind} label={label} depth={depth + 1} />
|
||||
{!live.data.groups.length && !live.data.records.length ? <tr className="ehb-inline-child-status"><td colSpan={columns}>当前层暂无子级数据</td></tr> : null}
|
||||
{page > 1 || live.data.page.hasMore ? <tr className="ehb-inline-child-status"><td colSpan={columns}>子级第 {page} 页 <button disabled={page === 1} onClick={() => setPage(page - 1)}>上一页</button> <button disabled={!live.data.page.hasMore} onClick={() => setPage(page + 1)}>下一页</button></td></tr> : null}
|
||||
</> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
function GroupTable({
|
||||
state,
|
||||
data,
|
||||
@@ -418,6 +503,7 @@ function GroupTable({
|
||||
onToggle,
|
||||
kind,
|
||||
label,
|
||||
query,
|
||||
}: {
|
||||
state: DrillState;
|
||||
data: H2BiDrillResponse;
|
||||
@@ -429,6 +515,7 @@ function GroupTable({
|
||||
onToggle: (row: H2BiDrillGroupRow) => void;
|
||||
kind: DrillKind;
|
||||
label: string;
|
||||
query: H2BiQuery;
|
||||
}) {
|
||||
const safeLabel = String(label ?? "");
|
||||
const amountFor = (row: { cost?: unknown; revenue?: unknown }) =>
|
||||
@@ -683,30 +770,19 @@ function GroupTable({
|
||||
<td style={{ textAlign: "right" }}>{formatNumber(row.recordCount, 0)} 笔</td>
|
||||
{isProfit ? <><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(row.revenue)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatNumber(row.cost)}</td><td className="ehb-key-profit" style={{ textAlign: "right" }}>¥{formatNumber(Number(row.revenue) - Number(row.cost))}</td></> : isMonth || isDay ? <><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(row.kg)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatNumber(amountFor(row))}</td><td className="ehb-key-profit" style={{ textAlign: "right" }}>—</td></> : isCustomerBill ? <><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatNumber(row.cost)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(row.revenue)}</td><td>未接入</td><td>未接入</td></> : <><td className="ehb-key-volume" style={{ textAlign: "right", fontWeight: 700 }}>{formatNumber(row.kg)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(amountFor(row))}</td></>}
|
||||
</tr>
|
||||
{expanded && !expandedLoading && !expandedError && expandedData ? <ExpandedChildren data={expandedData} state={nextState(state, row)} query={query} kind={kind} label={label} /> : null}
|
||||
{expanded ? (
|
||||
<tr className="ehb-tree-expanded-row">
|
||||
<td colSpan={12}>
|
||||
<td colSpan={isProfit || isMonth || isDay ? 9 : isCustomerBill ? 10 : 8}>
|
||||
<div className="ehb-tree-expanded-content" role="region" aria-label={`${row.name}的${nextLevel}列表`}>
|
||||
{expandedLoading ? "正在加载子级数据…" : expandedError ? (
|
||||
<span role="alert">子级数据加载失败:{expandedError}。请收起后重新展开。</span>
|
||||
) : nextState(state, row).level === "record" && expandedData?.records.length ? (
|
||||
<ul>
|
||||
{expandedData.records.map((record, recordIndex) => (
|
||||
<li key={`${String(record.id)}-${recordIndex}`}>
|
||||
<span>{String(record.orderNo || record.id || "加氢订单")}</span>
|
||||
<small>{formatNumber(record.kg)} Kg · {String(record.time || "未记录时间")}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : expandedData?.groups.length ? (
|
||||
<ul>
|
||||
{expandedData.groups.map((child, childIndex) => (
|
||||
<li key={`${child.id}-${child.name}-${childIndex}`}>
|
||||
<span>{child.name}</span><small>{formatNumber(child.kg)} Kg · {formatNumber(child.recordCount, 0)} 笔</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : "当前层暂无子级数据"}
|
||||
) : expandedData && (expandedData.groups.length || expandedData.records.length) ? null : "当前层暂无子级数据"}
|
||||
{expandedData?.page.hasMore ? (
|
||||
<p className="ehb-modal-hint-text">
|
||||
此处仅预览前 {expandedData.page.itemCount ?? 50} 条;进入完整明细后可继续翻页。
|
||||
</p>
|
||||
) : null}
|
||||
{!expandedLoading && !expandedError ? (
|
||||
<button type="button" className="ehb-tree-expanded-all" onClick={() => onOpen(row)}>查看全部{nextLevel} ›</button>
|
||||
) : null}
|
||||
@@ -764,6 +840,9 @@ export function PrototypeDrillModal({
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const [expandedRow, setExpandedRow] = useState<H2BiDrillGroupRow | null>(null);
|
||||
const [meta, setMeta] = useState<H2BiMetaResponse | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [exportingAll, setExportingAll] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [fleetCategoryFilter, setFleetCategoryFilter] = useState<
|
||||
"all" | "own" | "external"
|
||||
>(() =>
|
||||
@@ -778,6 +857,7 @@ export function PrototypeDrillModal({
|
||||
const historyScrollRef = useRef<number[]>([]);
|
||||
const pendingScrollRef = useRef<number | null>(null);
|
||||
const tableWrapRef = useRef<HTMLDivElement>(null);
|
||||
const exportControllerRef = useRef<AbortController | null>(null);
|
||||
const stateRef = useRef(state);
|
||||
const forceCloseRef = useRef(false);
|
||||
const onCloseRef = useRef(onClose);
|
||||
@@ -864,12 +944,26 @@ export function PrototypeDrillModal({
|
||||
}),
|
||||
[fleetCategoryFilter, label, query],
|
||||
);
|
||||
const live = useDrill(liveQuery, state);
|
||||
const live = useDrill(liveQuery, state, page, 100);
|
||||
const expandedState = useMemo(
|
||||
() => expandedRow ? nextState(state, expandedRow) : state,
|
||||
[expandedRow, state],
|
||||
);
|
||||
const expandedLive = useDrill(liveQuery, expandedState, Boolean(expandedRow));
|
||||
const expandedLive = useDrill(liveQuery, expandedState, 1, 50, Boolean(expandedRow));
|
||||
const drillFilterKey = JSON.stringify({ liveQuery, state });
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
// Full exports are tied to the exact filter and drill level that started
|
||||
// them. Changing either must not leave an old export running in the
|
||||
// background or present its result as if it belonged to the new view.
|
||||
if (exportControllerRef.current) {
|
||||
exportControllerRef.current.abort();
|
||||
exportControllerRef.current = null;
|
||||
setExportingAll(false);
|
||||
}
|
||||
setExportError(null);
|
||||
}, [drillFilterKey]);
|
||||
useEffect(() => () => exportControllerRef.current?.abort(), []);
|
||||
const data = useMemo(() => {
|
||||
if (!live.data || !search.trim()) return live.data;
|
||||
const term = search.trim().toLowerCase();
|
||||
@@ -893,6 +987,7 @@ export function PrototypeDrillModal({
|
||||
historyScrollRef.current.push(tableWrapRef.current?.scrollTop ?? 0);
|
||||
setHistory((items) => [...items, state]);
|
||||
setState(nextState(state, row));
|
||||
setPage(1);
|
||||
setExpandedRow(null);
|
||||
};
|
||||
const toggle = (row: H2BiDrillGroupRow) => {
|
||||
@@ -964,8 +1059,7 @@ export function PrototypeDrillModal({
|
||||
setSearch("");
|
||||
setState((current) => ({ ...current, amountScope }));
|
||||
};
|
||||
const exportCurrent = () => {
|
||||
if (!data) return;
|
||||
const exportData = (exported: H2BiDrillResponse, suffix: string) => {
|
||||
const rows: Array<Array<string | number>> = [
|
||||
[
|
||||
"加氢站 / 客户 / 车辆与凭证链路",
|
||||
@@ -975,7 +1069,7 @@ export function PrototypeDrillModal({
|
||||
],
|
||||
];
|
||||
if (state.level === "record") {
|
||||
data.records.forEach((record) =>
|
||||
exported.records.forEach((record) =>
|
||||
rows.push([
|
||||
`${String(record.stationName || "未关联站点")} / ${String(record.customerName || "未关联客户")} / ${String(record.plateNo || "无车牌")} / ${String(record.orderNo || record.id || "—")}`,
|
||||
Number(record.kg ?? 0),
|
||||
@@ -984,11 +1078,42 @@ export function PrototypeDrillModal({
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
data.groups.forEach((row) =>
|
||||
exported.groups.forEach((row) =>
|
||||
rows.push([row.name, row.kg, row.cost, row.revenue]),
|
||||
);
|
||||
}
|
||||
downloadExcelAoa(rows, `${cleanLabel}_真实账本穿透.xlsx`, "真实账本穿透");
|
||||
downloadExcelAoa(rows, `${cleanLabel}_${suffix}_真实账本穿透.xlsx`, "真实账本穿透");
|
||||
};
|
||||
const exportCurrent = () => {
|
||||
if (data) exportData(data, `第${page}页`);
|
||||
};
|
||||
const exportAll = async () => {
|
||||
if (exportingAll) {
|
||||
exportControllerRef.current?.abort();
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
exportControllerRef.current = controller;
|
||||
setExportingAll(true);
|
||||
setExportError(null);
|
||||
try {
|
||||
const complete = await fetchAllH2BiDrill({
|
||||
...liveQuery,
|
||||
...state,
|
||||
groupBy: state.level,
|
||||
amountScope: state.amountScope,
|
||||
}, { signal: controller.signal });
|
||||
if (!controller.signal.aborted) exportData(complete, "全部");
|
||||
} catch (reason) {
|
||||
if (!controller.signal.aborted) {
|
||||
setExportError(reason instanceof Error ? reason.message : "全量导出失败,未生成文件");
|
||||
}
|
||||
} finally {
|
||||
if (exportControllerRef.current === controller) {
|
||||
exportControllerRef.current = null;
|
||||
setExportingAll(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
const switchRootDimension = (rootDimension: DrillRootDimension) => {
|
||||
if (kind !== "kpi" || state.rootDimension === rootDimension) return;
|
||||
@@ -1074,7 +1199,7 @@ export function PrototypeDrillModal({
|
||||
</button>
|
||||
<div>
|
||||
<div className="ehb-modal-head__title">{displayTitle}</div>
|
||||
<div className="ehb-modal-head__sub">统计时间范围:{scope}</div>
|
||||
<div className="ehb-modal-head__sub" aria-label={`统计时间范围:${scope}`}>{scope.replaceAll("-", ".").replace(" 至 ", " — ")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-modal-head__actions">
|
||||
@@ -1115,6 +1240,7 @@ export function PrototypeDrillModal({
|
||||
</div>
|
||||
) : null}
|
||||
<DrillSummaryCards kind={kind} label={cleanLabel} data={live.data} />
|
||||
{cleanLabel === "待核对订单" ? <div className="ehb-modal-hint-text">核对功能上线期初:2026-05-01(含)。仅统计当前查询范围与上线期初之后的交集;此前订单不计入待核对。</div> : null}
|
||||
{cleanLabel === "加氢利润" && customerBearingScope ? (
|
||||
<div className="ehb-modal-hint-text" style={{ marginBottom: 10 }}>
|
||||
利润口径:客户承担订单的对客总价 ¥
|
||||
@@ -1203,15 +1329,20 @@ export function PrototypeDrillModal({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="ehb-btn ehb-btn--outline ehb-export-btn" onClick={exportCurrent}>
|
||||
<button type="button" className="ehb-btn ehb-btn--outline ehb-export-btn" onClick={exportCurrent} disabled={!data}>
|
||||
<Download size={14} aria-hidden />
|
||||
导出 Excel 穿透账单
|
||||
导出当前页
|
||||
</button>
|
||||
<span className="ehb-modal-hint-text">提示:点击表格行可四级层层展开【加氢站 → 客户 → 车牌 → 单笔订单与核对明细】</span>
|
||||
<button type="button" className="ehb-btn ehb-btn--outline ehb-export-btn" onClick={() => void exportAll()} disabled={!data && !exportingAll}>
|
||||
<Download size={14} aria-hidden />
|
||||
{exportingAll ? "取消全量导出" : "导出全部"}
|
||||
</button>
|
||||
<span className="ehb-modal-hint-text">当前表按页加载(每页最多 100 条);“导出全部”才会逐页读取完整结果。</span>
|
||||
{exportError ? <span className="ehb-modal-hint-text" role="alert">{exportError}</span> : null}
|
||||
</div>
|
||||
<label className="ehb-modal-search-input">
|
||||
<Search size={14} />
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索当前结果" />
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索当前页结果" aria-label="搜索当前页结果" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="ehb-drill-usage-guide" aria-label="明细表操作说明">
|
||||
@@ -1235,12 +1366,19 @@ export function PrototypeDrillModal({
|
||||
resetKey={`${cleanLabel}|${JSON.stringify(state)}|${search}|${fleetCategoryFilter}`}
|
||||
onRecover={() => resetToRoot()}
|
||||
>
|
||||
<GroupTable state={state} data={data} onOpen={open} expandedRowId={expandedRow ? rowIdentity(expandedRow) : null} expandedData={expandedRow ? expandedLive.data : null} expandedLoading={Boolean(expandedRow && expandedLive.loading)} expandedError={expandedRow ? expandedLive.error : null} onToggle={toggle} kind={kind} label={cleanLabel} />
|
||||
<GroupTable query={liveQuery} state={state} data={data} onOpen={open} expandedRowId={expandedRow ? rowIdentity(expandedRow) : null} expandedData={expandedRow ? expandedLive.data : null} expandedLoading={Boolean(expandedRow && expandedLive.loading)} expandedError={expandedRow ? expandedLive.error : null} onToggle={toggle} kind={kind} label={cleanLabel} />
|
||||
</DrillTableErrorBoundary>
|
||||
) : (
|
||||
<div className="ehb-empty">当前筛选范围暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
{data ? (
|
||||
<div className="ehb-drill-page-controls" aria-label="明细分页" style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 8, marginTop: 10 }}>
|
||||
<span className="ehb-modal-hint-text">第 {page} 页 · 本页 {data.page.itemCount ?? (state.level === "record" ? data.records.length : data.groups.length)} 条</span>
|
||||
<button type="button" className="ehb-btn ehb-btn--outline" disabled={page === 1 || live.loading} onClick={() => setPage((current) => Math.max(1, current - 1))}>上一页</button>
|
||||
<button type="button" className="ehb-btn ehb-btn--outline" disabled={!live.data?.page.hasMore || live.loading} onClick={() => setPage((current) => current + 1)}>下一页</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,6 +93,15 @@
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
/* Give nested day → station → customer → vehicle rows a viewport-sized workspace. */
|
||||
.ehb-real-daily-detail.ehb-daily-table-card > .ehb-table-wrap {
|
||||
height: 80vh;
|
||||
height: 80dvh;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ehb-real-daily-detail .ehb-table th:first-child {
|
||||
z-index: 4;
|
||||
background-color: #f8fafc;
|
||||
|
||||
@@ -238,10 +238,37 @@ export interface H2BiDrillResponse {
|
||||
page: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
/** Number of rows returned on this page (groups or records). */
|
||||
itemCount?: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** Options shared by paged drill reads and explicit full exports. */
|
||||
export interface H2BiDrillReadOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* A deliberately bounded, complete drill read. This is for exports and
|
||||
* dedicated detail views only; normal drill tables should use one page.
|
||||
*/
|
||||
export interface H2BiFullDrillResponse extends H2BiDrillResponse {
|
||||
page: H2BiDrillResponse["page"] & {
|
||||
pagesRead: number;
|
||||
complete: true;
|
||||
};
|
||||
}
|
||||
|
||||
export interface H2BiFullDrillOptions extends H2BiDrillReadOptions {
|
||||
/** Protect the browser from an accidental unbounded export. Default: 250. */
|
||||
maxPages?: number;
|
||||
/** Reject instead of retaining an unexpectedly huge complete result. Default: 50000. */
|
||||
maxRows?: number;
|
||||
/** API page size, capped by the server at 200. Default: 200. */
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface H2BiDailyTreeCustomer {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@@ -185,6 +185,13 @@ export interface HydrogenStationBoardResponse {
|
||||
selected: {
|
||||
daily: HydrogenStationBoardDailyRow[];
|
||||
customerMonths: HydrogenStationBoardCustomerMonth[];
|
||||
externalCustomerMonths?: HydrogenStationBoardCustomerMonth[];
|
||||
externalReceipts?: {
|
||||
scope: 'customer';
|
||||
reason?: string;
|
||||
rows: Array<{ id: string; date: string; customerName: string; amount: number;
|
||||
payMethod: string; source: string; sourceRecordCount: number; updatedAt: string | null }>;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import { Hono } from 'hono';
|
||||
import { readOnlyMiddleware } from './read-only-middleware.js';
|
||||
import { cors } from 'hono/cors';
|
||||
import authRouter from './auth/login.js';
|
||||
import { authMiddleware } from './auth/middleware.js';
|
||||
@@ -20,6 +21,7 @@ export function createApp(): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.use('/api/*', cors());
|
||||
app.use('/api/*', readOnlyMiddleware);
|
||||
|
||||
// 登录接口公开,其余 API 统一经过认证与数据权限检查。
|
||||
app.route('/api/auth', authRouter);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { startMileageBackgroundJobs } from './routes/mileage/index.js';
|
||||
|
||||
/** 启动只应在服务进程中运行的数据库准备和定时任务。 */
|
||||
export function startBackgroundServices(): void {
|
||||
if (process.env.DB_READ_ONLY === '1') return;
|
||||
ensureSchedulingTables().catch((error) => {
|
||||
console.error('scheduling bootstrap error:', error);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Local credentials stay outside version control. Exported environment wins.
|
||||
dotenv.config({ path: '.env.local' });
|
||||
dotenv.config();
|
||||
Object.assign(process.env, {
|
||||
DB_READ_ONLY: '1',
|
||||
HYDROGEN_DB_READ_ONLY: '1',
|
||||
MILEAGE_REPORT_AUTO_ARCHIVE: '0',
|
||||
DEV_BYPASS_AUTH: '1',
|
||||
});
|
||||
|
||||
// Import after configuring the environment: pools/auth read it at module load.
|
||||
const [{ serve }, { createApp }] = await Promise.all([
|
||||
import('@hono/node-server'),
|
||||
import('./app.js'),
|
||||
]);
|
||||
// Intentionally do not call bootstrap: no schema initialization or schedulers.
|
||||
serve({ fetch: createApp().fetch, hostname: '127.0.0.1', port: 3001 }, () => {
|
||||
console.log('Local read-only BI API: http://127.0.0.1:3001');
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import { readOnlyMiddleware } from './read-only-middleware.js';
|
||||
|
||||
test('read-only preview blocks write handlers but permits reads; normal mode is unchanged', async () => {
|
||||
const previous = process.env.DB_READ_ONLY;
|
||||
try {
|
||||
let calls = 0;
|
||||
const app = new Hono();
|
||||
app.use('*', readOnlyMiddleware);
|
||||
app.all('*', (c) => { calls++; return c.text('ok'); });
|
||||
process.env.DB_READ_ONLY = '1';
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
assert.equal((await app.request('/api/example', { method })).status, 403);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
|
||||
assert.equal((await app.request('/api/example', { method })).status, 200);
|
||||
}
|
||||
delete process.env.DB_READ_ONLY;
|
||||
assert.equal((await app.request('/api/example', { method: 'POST' })).status, 200);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.DB_READ_ONLY;
|
||||
else process.env.DB_READ_ONLY = previous;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
|
||||
/** Guard preview write endpoints before auth/route handlers can perform work. */
|
||||
export const readOnlyMiddleware: MiddlewareHandler = async (context, next) => {
|
||||
if (process.env.DB_READ_ONLY === '1'
|
||||
&& !['GET', 'HEAD', 'OPTIONS'].includes(context.req.method)) {
|
||||
return context.json({ error: '当前为只读预览环境,不允许写入操作' }, 403);
|
||||
}
|
||||
return next();
|
||||
};
|
||||
@@ -5,4 +5,8 @@ export const HYDROGEN_TABLE = 'hydrogen_fuel_ledger';
|
||||
export const HYDROGEN_LOCAL = `refuel_time`;
|
||||
export const HYDROGEN_BASE_WHERE = `del_flag = '0'`;
|
||||
export const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`;
|
||||
// 充值记录与加氢事实共用台账。经营、日报和下钻必须显式排除充值,
|
||||
// 充值模块则继续使用 BASE 条件读取自己的流水。
|
||||
export const HYDROGEN_FUEL_ONLY_WHERE = `${HYDROGEN_BASE_WHERE} AND COALESCE(record_source, '') <> 'external_recharge_manual'`;
|
||||
export const HYDROGEN_FUEL_ONLY_WHERE_B = `${HYDROGEN_BASE_WHERE_B} AND COALESCE(b.record_source, '') <> 'external_recharge_manual'`;
|
||||
export const ELECTRIC_LOCAL = `charging_start_time`;
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { Hono } from "hono";
|
||||
import type { RowDataPacket } from "mysql2";
|
||||
import type hydrogenPool from "../../hydrogen-db.js";
|
||||
import {
|
||||
HYDROGEN_BASE_WHERE_B,
|
||||
HYDROGEN_FUEL_ONLY_WHERE,
|
||||
HYDROGEN_FUEL_ONLY_WHERE_B,
|
||||
HYDROGEN_LOCAL,
|
||||
HYDROGEN_MIN_DATE,
|
||||
HYDROGEN_TABLE,
|
||||
@@ -150,7 +151,7 @@ function resolveFilter(query: (key: string) => string | undefined): Filter {
|
||||
? (query("verifyScope") as VerifyScope)
|
||||
: "all";
|
||||
const clauses = [
|
||||
HYDROGEN_BASE_WHERE_B,
|
||||
HYDROGEN_FUEL_ONLY_WHERE_B,
|
||||
`b.${HYDROGEN_LOCAL} >= ?`,
|
||||
`b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)`,
|
||||
];
|
||||
@@ -185,6 +186,7 @@ function resolveFilter(query: (key: string) => string | undefined): Filter {
|
||||
if (verifyScope === "unverified")
|
||||
clauses.push(
|
||||
"LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) <> 'verified'",
|
||||
"b.refuel_time >= '2026-05-01'",
|
||||
);
|
||||
return {
|
||||
startDate: safeStart,
|
||||
@@ -309,7 +311,7 @@ async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) {
|
||||
DATE_FORMAT(MIN(${HYDROGEN_LOCAL}), '%Y-%m-%d') AS startDate,
|
||||
DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d') AS endDate
|
||||
FROM ${HYDROGEN_TABLE}
|
||||
WHERE del_flag = '0' AND ${HYDROGEN_LOCAL} >= ?
|
||||
WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?
|
||||
GROUP BY YEAR(${HYDROGEN_LOCAL})
|
||||
ORDER BY value DESC`,
|
||||
[HYDROGEN_MIN_DATE],
|
||||
@@ -326,7 +328,7 @@ async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) {
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE b.del_flag = '0'
|
||||
WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B}
|
||||
AND b.station_id = s.id
|
||||
AND COALESCE(b.amount_kg, 0) > 0
|
||||
)
|
||||
@@ -335,7 +337,7 @@ async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) {
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt
|
||||
FROM ${HYDROGEN_TABLE}
|
||||
WHERE del_flag = '0' AND ${HYDROGEN_LOCAL} >= ?`,
|
||||
WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?`,
|
||||
[HYDROGEN_MIN_DATE],
|
||||
),
|
||||
]);
|
||||
@@ -765,7 +767,8 @@ async function drill(
|
||||
: groupBy === "date"
|
||||
? `DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')`
|
||||
: "COALESCE(NULLIF(b.license_plate, ''), '无车牌')";
|
||||
const groupOrder = groupBy === "date" ? "id DESC" : "kg DESC";
|
||||
// Stable tie-breakers prevent equal-volume groups drifting between pages.
|
||||
const groupOrder = groupBy === "date" ? "id DESC" : "kg DESC, id ASC, name ASC";
|
||||
const groupHaving =
|
||||
groupBy === "station" ? "HAVING SUM(COALESCE(b.amount_kg, 0)) > 0" : "";
|
||||
const [summaryRows, groupRows, recordRows] = await Promise.all([
|
||||
@@ -864,7 +867,7 @@ async function drill(
|
||||
cost: number(row.cost),
|
||||
revenue: number(row.revenue),
|
||||
})),
|
||||
page: { page, pageSize, hasMore: recordRows[0].length === pageSize },
|
||||
page: { page, pageSize, hasMore: (groupBy === "record" ? recordRows[0] : groupRows[0]).length === pageSize },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import type hydrogenPool from '../../hydrogen-db.js';
|
||||
import type { cached } from './cache.js';
|
||||
import { HYDROGEN_BASE_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.js';
|
||||
import { HYDROGEN_FUEL_ONLY_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.js';
|
||||
import { customerClause, type CustomerKind } from './query-model.js';
|
||||
|
||||
export interface HydrogenDailyDetailDependencies {
|
||||
@@ -47,7 +47,7 @@ export function registerHydrogenDailyDetailRoute(
|
||||
ROUND(COALESCE(b.cost_total, 0), 2) AS fee
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B}
|
||||
AND DATE(b.${HYDROGEN_LOCAL}) = ?
|
||||
AND ${customerClause(customer).replaceAll('vehicle_id', 'b.vehicle_id')}${verifyScope === 'verified' ? " AND LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) = 'verified'" : ''}${stationClause}
|
||||
ORDER BY b.${HYDROGEN_LOCAL} ASC, b.id ASC
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { RowDataPacket } from 'mysql2';
|
||||
import type hydrogenPool from '../../hydrogen-db.js';
|
||||
import type { cached } from './cache.js';
|
||||
import {
|
||||
HYDROGEN_BASE_WHERE_B,
|
||||
HYDROGEN_FUEL_ONLY_WHERE_B,
|
||||
HYDROGEN_LOCAL,
|
||||
HYDROGEN_MIN_DATE,
|
||||
HYDROGEN_TABLE,
|
||||
@@ -37,7 +37,7 @@ export function registerHydrogenDailyRoute(
|
||||
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}${verifyScope === 'verified' ? '&verify=verified' : ''}`, async () => {
|
||||
|
||||
const where = [
|
||||
HYDROGEN_BASE_WHERE_B,
|
||||
HYDROGEN_FUEL_ONLY_WHERE_B,
|
||||
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
|
||||
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
|
||||
customerClause(customer).replaceAll('vehicle_id', 'b.vehicle_id'),
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import type hydrogenPool from '../../hydrogen-db.js';
|
||||
import type { cached } from './cache.js';
|
||||
import { HYDROGEN_BASE_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_MIN_DATE, HYDROGEN_TABLE } from './constants.js';
|
||||
import { HYDROGEN_FUEL_ONLY_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_MIN_DATE, HYDROGEN_TABLE } from './constants.js';
|
||||
|
||||
export interface HydrogenOverviewDetailDependencies {
|
||||
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
|
||||
@@ -44,7 +44,7 @@ export function registerHydrogenOverviewDetailRoute(
|
||||
: 2_000;
|
||||
const force = c.req.query('force') === '1';
|
||||
|
||||
const clauses = [HYDROGEN_BASE_WHERE_B, `b.${HYDROGEN_LOCAL} >= ?`, `YEAR(b.${HYDROGEN_LOCAL}) = ?`];
|
||||
const clauses = [HYDROGEN_FUEL_ONLY_WHERE_B, `b.${HYDROGEN_LOCAL} >= ?`, `YEAR(b.${HYDROGEN_LOCAL}) = ?`];
|
||||
const params: unknown[] = [HYDROGEN_MIN_DATE, year];
|
||||
if (vehicleScope === 'lingniu') clauses.push('b.vehicle_id IS NOT NULL');
|
||||
if (vehicleScope === 'external') clauses.push('b.vehicle_id IS NULL');
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { RowDataPacket } from 'mysql2';
|
||||
import type hydrogenPool from '../../hydrogen-db.js';
|
||||
import type { cached } from './cache.js';
|
||||
import {
|
||||
HYDROGEN_BASE_WHERE,
|
||||
HYDROGEN_BASE_WHERE_B,
|
||||
HYDROGEN_FUEL_ONLY_WHERE,
|
||||
HYDROGEN_FUEL_ONLY_WHERE_B,
|
||||
HYDROGEN_LOCAL,
|
||||
HYDROGEN_MIN_DATE,
|
||||
HYDROGEN_TABLE,
|
||||
@@ -95,7 +95,7 @@ export function registerHydrogenOverviewRoute(
|
||||
`SELECT YEAR(${HYDROGEN_LOCAL}) AS y,
|
||||
DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime
|
||||
FROM ${HYDROGEN_TABLE}
|
||||
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?
|
||||
WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?
|
||||
GROUP BY YEAR(${HYDROGEN_LOCAL})
|
||||
ORDER BY y DESC`,
|
||||
[HYDROGEN_MIN_DATE],
|
||||
@@ -150,7 +150,7 @@ export function registerHydrogenOverviewRoute(
|
||||
SUM(CASE WHEN vehicle_id IS NOT NULL
|
||||
THEN cost_total ELSE 0 END) AS lingniuBornFee
|
||||
FROM ${HYDROGEN_TABLE}
|
||||
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?${filter.ledgerSql}`,
|
||||
WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?${filter.ledgerSql}`,
|
||||
[year, year, year, year, year, year, year, year,
|
||||
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
||||
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
||||
@@ -197,7 +197,7 @@ export function registerHydrogenOverviewRoute(
|
||||
SUM(b.cost_total) AS fee
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B}
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
|
||||
GROUP BY b.station_id
|
||||
@@ -230,7 +230,7 @@ export function registerHydrogenOverviewRoute(
|
||||
ON CONVERT(d.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
= CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
AND d.STATUS = 'VALID'
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B}
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
|
||||
GROUP BY b.station_id
|
||||
@@ -271,7 +271,7 @@ export function registerHydrogenOverviewRoute(
|
||||
ON CONVERT(d.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
= CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
AND d.STATUS = 'VALID'
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B}
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
|
||||
) r
|
||||
@@ -303,7 +303,7 @@ export function registerHydrogenOverviewRoute(
|
||||
ROUND(SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN cost_total ELSE 0 END), 2) AS customerCost,
|
||||
ROUND(SUM(fee_total), 2) AS revenue
|
||||
FROM ${HYDROGEN_TABLE}
|
||||
WHERE ${HYDROGEN_BASE_WHERE}
|
||||
WHERE ${HYDROGEN_FUEL_ONLY_WHERE}
|
||||
AND ${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql}
|
||||
GROUP BY m
|
||||
@@ -343,7 +343,7 @@ export function registerHydrogenOverviewRoute(
|
||||
SUM(cost_total) AS cost,
|
||||
SUM(fee_total) AS revenue
|
||||
FROM ${HYDROGEN_TABLE}
|
||||
WHERE ${HYDROGEN_BASE_WHERE}
|
||||
WHERE ${HYDROGEN_FUEL_ONLY_WHERE}
|
||||
AND ${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql}
|
||||
GROUP BY name
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import type hydrogenPool from '../../hydrogen-db.js';
|
||||
import type { cached } from './cache.js';
|
||||
import { HYDROGEN_BASE_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.js';
|
||||
import { HYDROGEN_BASE_WHERE, HYDROGEN_BASE_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.js';
|
||||
import { dateRangeClause, enumerateDateRange, resolveDateRange } from './query-model.js';
|
||||
|
||||
export interface HydrogenStationBoardDependencies {
|
||||
@@ -19,6 +19,30 @@ function numberValue(value: unknown): number {
|
||||
return Number(value) || 0;
|
||||
}
|
||||
|
||||
// 单站接口没有租户入参,租户只能由服务端配置决定,不能接受浏览器传值。
|
||||
const HYDROGEN_TENANT_ID = process.env.HYDROGEN_TENANT_ID?.trim() || '000000';
|
||||
const MANUAL_RECHARGE_SOURCE = 'external_recharge_manual';
|
||||
|
||||
const NORMALIZED_PLATE = (column: string) =>
|
||||
`UPPER(REPLACE(REPLACE(REPLACE(TRIM(${column}), ' ', ''), '.', ''), '·', ''))`;
|
||||
const NORMALIZED_PLATE_UNICODE = (column: string) =>
|
||||
`CONVERT(${NORMALIZED_PLATE(column)} USING utf8mb4) COLLATE utf8mb4_unicode_ci`;
|
||||
|
||||
const EXTERNAL_CUSTOMER_MAP_CTE = `
|
||||
WITH external_vehicle_candidates AS (
|
||||
SELECT ${NORMALIZED_PLATE_UNICODE('plate_number')} AS normalizedPlate,
|
||||
MAX(NULLIF(TRIM(actual_user), '')) AS customerName,
|
||||
COUNT(DISTINCT NULLIF(TRIM(actual_user), '')) AS customerCount
|
||||
FROM hydrogen_order_transfer_external_vehicle_user
|
||||
WHERE NULLIF(TRIM(plate_number), '') IS NOT NULL
|
||||
GROUP BY ${NORMALIZED_PLATE_UNICODE('plate_number')}
|
||||
), external_vehicle_map AS (
|
||||
SELECT normalizedPlate, customerName
|
||||
FROM external_vehicle_candidates
|
||||
WHERE customerCount = 1
|
||||
AND customerName IS NOT NULL
|
||||
)`;
|
||||
|
||||
// 单站经营看板只做只读聚合。列表仅返回所选区间内存在有效加氢记录的站点。
|
||||
export function registerHydrogenStationBoardRoute(
|
||||
app: Hono,
|
||||
@@ -48,6 +72,8 @@ export function registerHydrogenStationBoardRoute(
|
||||
LEFT JOIN ${HYDROGEN_TABLE} b
|
||||
ON b.station_id = s.id
|
||||
AND ${HYDROGEN_BASE_WHERE_B}
|
||||
AND b.tenant_id = ?
|
||||
AND COALESCE(b.record_source, '') <> ?
|
||||
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
|
||||
LEFT JOIN common_district dp
|
||||
ON CONVERT(dp.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
@@ -60,7 +86,7 @@ export function registerHydrogenStationBoardRoute(
|
||||
WHERE s.del_flag = '0'
|
||||
GROUP BY s.id, s.station_short_name, s.station_name, dp.NAME, s.province, dc.NAME, s.city
|
||||
ORDER BY kg DESC, name ASC`,
|
||||
[range.start, range.end],
|
||||
[HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, range.start, range.end],
|
||||
);
|
||||
|
||||
const [paymentRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
@@ -82,10 +108,12 @@ export function registerHydrogenStationBoardRoute(
|
||||
COUNT(*) AS recordCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND b.tenant_id = ?
|
||||
AND COALESCE(b.record_source, '') <> ?
|
||||
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
|
||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
|
||||
ORDER BY date ASC`,
|
||||
[range.start, range.end],
|
||||
[HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, range.start, range.end],
|
||||
);
|
||||
const [stationDailyRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT b.station_id AS stationId,
|
||||
@@ -93,10 +121,12 @@ export function registerHydrogenStationBoardRoute(
|
||||
ROUND(SUM(b.amount_kg), 2) AS kg
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND b.tenant_id = ?
|
||||
AND COALESCE(b.record_source, '') <> ?
|
||||
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
|
||||
GROUP BY b.station_id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
|
||||
ORDER BY stationId ASC, date ASC`,
|
||||
[range.start, range.end],
|
||||
[HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, range.start, range.end],
|
||||
);
|
||||
const [summaryPaymentDailyRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date,
|
||||
@@ -140,7 +170,9 @@ export function registerHydrogenStationBoardRoute(
|
||||
kg: dailyKgByStation.get(id)?.get(date) ?? 0,
|
||||
})),
|
||||
};
|
||||
}).filter(station => station.recordCount > 0);
|
||||
}).filter(station => station.recordCount > 0
|
||||
|| station.name.includes('东鹏大道')
|
||||
|| (station.name.includes('佛山南海') && station.name.includes('羚牛')));
|
||||
|
||||
let selected = null;
|
||||
if (stationId) {
|
||||
@@ -152,11 +184,13 @@ export function registerHydrogenStationBoardRoute(
|
||||
COUNT(*) AS recordCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND b.tenant_id = ?
|
||||
AND COALESCE(b.record_source, '') <> ?
|
||||
AND b.station_id = ?
|
||||
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
|
||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
|
||||
ORDER BY date ASC`,
|
||||
[stationId, range.start, range.end],
|
||||
[HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, range.start, range.end],
|
||||
);
|
||||
const [dailyPaymentRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date,
|
||||
@@ -193,19 +227,90 @@ export function registerHydrogenStationBoardRoute(
|
||||
});
|
||||
|
||||
const [customerMonthRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
|
||||
COALESCE(NULLIF(MAX(b.system_customer_name), ''), NULLIF(MAX(b.customer_name), ''), '未关联客户') AS customerName,
|
||||
`${EXTERNAL_CUSTOMER_MAP_CTE}
|
||||
SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
|
||||
COALESCE(m.customerName, NULLIF(TRIM(b.customer_name), ''), NULLIF(TRIM(b.system_customer_name), ''), '未关联客户') AS customerName,
|
||||
ROUND(SUM(b.amount_kg), 2) AS kg,
|
||||
ROUND(SUM(b.cost_total), 2) AS fee,
|
||||
COUNT(*) AS recordCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN external_vehicle_map m
|
||||
ON CONVERT(${NORMALIZED_PLATE('b.license_plate')} USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
= CONVERT(m.normalizedPlate USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
AND (b.vehicle_id IS NULL OR b.vehicle_id = 0)
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND b.tenant_id = ?
|
||||
AND COALESCE(b.record_source, '') <> ?
|
||||
AND b.station_id = ?
|
||||
AND b.${HYDROGEN_LOCAL} >= DATE_SUB(DATE_FORMAT(?, '%Y-%m-01'), INTERVAL 11 MONTH)
|
||||
AND b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)
|
||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m'), COALESCE(b.system_customer_id, b.customer_id, 0)
|
||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m'),
|
||||
COALESCE(m.customerName, NULLIF(TRIM(b.customer_name), ''), NULLIF(TRIM(b.system_customer_name), ''), '未关联客户')
|
||||
ORDER BY month ASC, kg DESC`,
|
||||
[stationId, range.end, range.end],
|
||||
[HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, range.end, range.end],
|
||||
);
|
||||
const [externalCustomerMonthRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`${EXTERNAL_CUSTOMER_MAP_CTE}
|
||||
SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
|
||||
m.customerName,
|
||||
ROUND(SUM(b.amount_kg), 2) AS kg,
|
||||
ROUND(SUM(COALESCE(b.fee_total, b.cost_total)), 2) AS fee,
|
||||
COUNT(*) AS recordCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
INNER JOIN external_vehicle_map m
|
||||
ON ${NORMALIZED_PLATE_UNICODE('b.license_plate')}
|
||||
= m.normalizedPlate
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND b.tenant_id = ?
|
||||
AND COALESCE(b.record_source, '') <> ?
|
||||
AND b.station_id = ?
|
||||
AND (b.vehicle_id IS NULL OR b.vehicle_id = 0)
|
||||
AND b.${HYDROGEN_LOCAL} >= DATE_SUB(DATE_FORMAT(?, '%Y-%m-01'), INTERVAL 11 MONTH)
|
||||
AND b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)
|
||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m'), m.customerName
|
||||
ORDER BY month ASC, kg DESC, m.customerName ASC`,
|
||||
[HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, range.end, range.end],
|
||||
);
|
||||
const [externalReceiptRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT * FROM (
|
||||
SELECT CONCAT('auto:', CAST(id AS CHAR)) AS id,
|
||||
DATE_FORMAT(summary_date, '%Y-%m-%d') AS date,
|
||||
customer_name AS customerName,
|
||||
amount,
|
||||
pay_method AS payMethod,
|
||||
source,
|
||||
spot_record_count AS sourceRecordCount,
|
||||
DATE_FORMAT(COALESCE(refresh_time, update_time), '%Y-%m-%d %H:%i:%s') AS updatedAt
|
||||
FROM hydrogen_external_recharge_daily_summary
|
||||
WHERE tenant_id = ?
|
||||
AND summary_date >= ?
|
||||
AND summary_date <= ?
|
||||
UNION ALL
|
||||
SELECT CONCAT('manual:', CAST(id AS CHAR)) AS id,
|
||||
DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
|
||||
COALESCE(NULLIF(customer_name, ''), '未关联客户') AS customerName,
|
||||
fee_total AS amount,
|
||||
recharge_pay_method AS payMethod,
|
||||
record_source AS source,
|
||||
1 AS sourceRecordCount,
|
||||
DATE_FORMAT(update_time, '%Y-%m-%d %H:%i:%s') AS updatedAt
|
||||
FROM ${HYDROGEN_TABLE}
|
||||
WHERE ${HYDROGEN_BASE_WHERE}
|
||||
AND tenant_id = ?
|
||||
AND record_source = ?
|
||||
AND ${HYDROGEN_LOCAL} >= ?
|
||||
AND ${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)
|
||||
) externalReceipts
|
||||
ORDER BY date DESC, customerName ASC, id ASC`,
|
||||
[
|
||||
HYDROGEN_TENANT_ID,
|
||||
range.start,
|
||||
range.end,
|
||||
HYDROGEN_TENANT_ID,
|
||||
MANUAL_RECHARGE_SOURCE,
|
||||
range.start,
|
||||
range.end,
|
||||
],
|
||||
);
|
||||
selected = {
|
||||
daily,
|
||||
@@ -216,6 +321,28 @@ export function registerHydrogenStationBoardRoute(
|
||||
fee: numberValue(row.fee),
|
||||
recordCount: numberValue(row.recordCount),
|
||||
})),
|
||||
externalCustomerMonths: externalCustomerMonthRows.map(row => ({
|
||||
month: String(row.month),
|
||||
customerName: String(row.customerName),
|
||||
kg: numberValue(row.kg),
|
||||
fee: numberValue(row.fee),
|
||||
recordCount: numberValue(row.recordCount),
|
||||
})),
|
||||
// 快照和手工充值都没有可靠站点归属,不能把客户级进账误报为当前站点收益。
|
||||
externalReceipts: {
|
||||
scope: 'customer',
|
||||
reason: '当前租户全部外部客户进账,不按站点归属,不计入本单站经营收益或现结 KPI。',
|
||||
rows: externalReceiptRows.map(row => ({
|
||||
id: String(row.id),
|
||||
date: String(row.date),
|
||||
customerName: String(row.customerName),
|
||||
amount: numberValue(row.amount),
|
||||
payMethod: String(row.payMethod),
|
||||
source: String(row.source),
|
||||
sourceRecordCount: numberValue(row.sourceRecordCount),
|
||||
updatedAt: typeof row.updatedAt === 'string' ? row.updatedAt : null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,25 @@ interface CacheCall {
|
||||
force: boolean | undefined;
|
||||
}
|
||||
|
||||
test("待核对的汇总和下钻统一从核对上线期初起统计,不影响其他核对范围", async () => {
|
||||
for (const scope of ["unverified", "all", "verified"]) {
|
||||
for (const endpoint of ["overview", "drill"]) {
|
||||
const calls: QueryCall[] = [];
|
||||
const app = new Hono();
|
||||
registerHydrogenBiV2Routes(app, {
|
||||
hydrogenPool: { query: createQueryMock([], calls) },
|
||||
} as unknown as HydrogenBiV2Dependencies);
|
||||
const response = await app.request(`/h2/v2/${endpoint}?year=2026&verifyScope=${scope}`);
|
||||
assert.equal(response.status, 200);
|
||||
const ledgerQueries = calls.filter(({ sql }) => sql.includes("FROM hydrogen_fuel_ledger b"));
|
||||
assert.ok(ledgerQueries.length > 0);
|
||||
for (const { sql } of ledgerQueries) {
|
||||
assert.equal(sql.includes("b.refuel_time >= '2026-05-01'"), scope === "unverified");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function createQueryMock(resultSets: unknown[][], calls: QueryCall[]) {
|
||||
return async (sql: string, params?: unknown) => {
|
||||
calls.push({ sql, params });
|
||||
@@ -116,6 +135,10 @@ test("氢能 BI v2 使用独立真实账本合同,并且不让前端拼接旧
|
||||
assert.match(calls[0].sql, /FROM hydrogen_fuel_ledger/);
|
||||
assert.match(calls[1].sql, /FROM new_hydrogen_site/);
|
||||
assert.match(calls[1].sql, /EXISTS\s*\([\s\S]*amount_kg[\s\S]*> 0/);
|
||||
// 所有真实下钻共用此站点选项池:历史加氢量为正,而非仅当前区间活跃。
|
||||
assert.match(calls[1].sql, /b\.station_id = s\.id/);
|
||||
assert.match(calls[1].sql, /external_recharge_manual/);
|
||||
assert.doesNotMatch(calls[1].sql, /refuel_time\s*[<>]/);
|
||||
});
|
||||
|
||||
test("氢能 BI v2 下钻在日期、站点、客户、车辆及区域上下文中复用同一过滤口径", async () => {
|
||||
@@ -246,13 +269,16 @@ test("氢能 BI v2 利润下钻只保留客户承担订单,并与对客总价
|
||||
} as unknown as HydrogenBiV2Dependencies);
|
||||
|
||||
const response = await v2App.request(
|
||||
"/h2/v2/drill?year=2026&vehicleScope=all&verifyScope=all&groupBy=station&amountScope=customer",
|
||||
"/h2/v2/drill?year=2026&vehicleScope=all&verifyScope=all&groupBy=station&amountScope=customer&pageSize=1&page=2",
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.amountScope, "customer");
|
||||
assert.equal(payload.page.hasMore, true);
|
||||
assert.deepEqual((calls[1].params as unknown[]).slice(-2), [1, 1]);
|
||||
assert.equal(payload.summary.revenue - payload.summary.cost, 60);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[1].sql, /ORDER BY kg DESC, id ASC, name ASC LIMIT \? OFFSET \?/);
|
||||
for (const call of calls) {
|
||||
assert.match(
|
||||
call.sql,
|
||||
@@ -457,7 +483,28 @@ test("氢能 BI v2 月度上下文换算为完整自然月并沿用至明细", a
|
||||
]);
|
||||
});
|
||||
|
||||
test("单站看板仅保留有加氢记录的站点并合并区间现结流水", async () => {
|
||||
test("指定单站即使无记录仍可选择,不伪造加氢量", async () => {
|
||||
const app = new Hono();
|
||||
registerHydrogenStationBoardRoute(app, {
|
||||
hydrogenPool: { query: createQueryMock([
|
||||
[
|
||||
{ id: 341, name: "佛山南海羚牛加氢站", kg: 0, fee: 0, recordCount: 0 },
|
||||
{ id: 342, name: "广州交投东鹏大道加氢站", kg: 0, fee: 0, recordCount: 0 },
|
||||
{ id: 343, name: "其他零业务站", kg: 0, fee: 0, recordCount: 0 },
|
||||
], [], [], [], [],
|
||||
], []) },
|
||||
cached: createCachedMock([]),
|
||||
} as unknown as HydrogenStationBoardDependencies);
|
||||
const response = await app.request("/hydrogen/station-board?startDate=2026-08-16&endDate=2026-08-17");
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.deepEqual(payload.stations.map((station: { id: number }) => station.id), [341, 342]);
|
||||
assert.equal(payload.summary.totalKg, 0);
|
||||
assert.equal(payload.summary.activeStationCount, 0);
|
||||
assert.ok(payload.stations.every((station: { dailyKg: { kg: number }[] }) => station.dailyKg.every(row => row.kg === 0)));
|
||||
});
|
||||
|
||||
test("单站看板过滤非指定零业务站并合并区间现结流水", async () => {
|
||||
const calls: QueryCall[] = [];
|
||||
const cacheCalls: CacheCall[] = [];
|
||||
const stationBoardApp = new Hono();
|
||||
@@ -564,6 +611,99 @@ test("单站看板仅保留有加氢记录的站点并合并区间现结流水",
|
||||
assert.match(calls[4].sql, /GROUP BY DATE_FORMAT\(payment_date/);
|
||||
});
|
||||
|
||||
test("单站外部客户按唯一标准化车牌聚合,进账保持租户客户级口径", async () => {
|
||||
const calls: QueryCall[] = [];
|
||||
const stationBoardApp = new Hono();
|
||||
registerHydrogenStationBoardRoute(stationBoardApp, {
|
||||
hydrogenPool: {
|
||||
query: createQueryMock(
|
||||
[
|
||||
[{ id: 341, name: "佛山南海羚牛加氢站", province: "广东省", city: "佛山市", kg: 10, fee: 300, recordCount: 1 }],
|
||||
[], [], [], [],
|
||||
[], [], [],
|
||||
[
|
||||
{
|
||||
month: "2026-09",
|
||||
customerName: "外部客户 A",
|
||||
kg: null,
|
||||
fee: null,
|
||||
recordCount: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "auto:9007199254740993",
|
||||
date: "2026-09-05",
|
||||
customerName: "外部客户 A",
|
||||
amount: 200,
|
||||
payMethod: "wechat",
|
||||
source: "spot_daily_auto",
|
||||
sourceRecordCount: 2,
|
||||
updatedAt: "2026-09-05 23:59:00",
|
||||
},
|
||||
{
|
||||
id: "manual:1",
|
||||
date: "2026-09-04",
|
||||
customerName: "外部客户 B",
|
||||
amount: 0,
|
||||
payMethod: "corporate",
|
||||
source: "external_recharge_manual",
|
||||
sourceRecordCount: 1,
|
||||
updatedAt: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
calls,
|
||||
),
|
||||
},
|
||||
cached: createCachedMock([]),
|
||||
} as unknown as HydrogenStationBoardDependencies);
|
||||
|
||||
const response = await stationBoardApp.request(
|
||||
"/hydrogen/station-board?stationId=341&startDate=2026-09-01&endDate=2026-09-05",
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.deepEqual(payload.selected.externalCustomerMonths, [{
|
||||
month: "2026-09", customerName: "外部客户 A", kg: 0, fee: 0, recordCount: 1,
|
||||
}]);
|
||||
assert.deepEqual(payload.selected.externalReceipts, {
|
||||
scope: "customer",
|
||||
reason: "当前租户全部外部客户进账,不按站点归属,不计入本单站经营收益或现结 KPI。",
|
||||
rows: [
|
||||
{
|
||||
id: "auto:9007199254740993", date: "2026-09-05", customerName: "外部客户 A", amount: 200,
|
||||
payMethod: "wechat", source: "spot_daily_auto", sourceRecordCount: 2, updatedAt: "2026-09-05 23:59:00",
|
||||
},
|
||||
{
|
||||
id: "manual:1", date: "2026-09-04", customerName: "外部客户 B", amount: 0,
|
||||
payMethod: "corporate", source: "external_recharge_manual", sourceRecordCount: 1, updatedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(calls.length, 10);
|
||||
for (const index of [0, 2, 3, 5, 7, 8, 9]) {
|
||||
assert.match(calls[index].sql, /tenant_id\s*=\s*\?/);
|
||||
assert.ok((calls[index].params as unknown[]).includes("000000"));
|
||||
}
|
||||
for (const index of [0, 2, 3, 5, 7, 8]) {
|
||||
assert.match(calls[index].sql, /record_source, ''\) <> \?/);
|
||||
assert.ok((calls[index].params as unknown[]).includes("external_recharge_manual"));
|
||||
}
|
||||
assert.match(calls[8].sql, /customerCount = 1/);
|
||||
// 自营站客户月度保留全部加氢事实:映射仅做补充,不过滤未匹配或内部车辆。
|
||||
assert.match(calls[7].sql, /LEFT JOIN external_vehicle_map/);
|
||||
assert.match(calls[7].sql, /COALESCE\(m.customerName/);
|
||||
assert.doesNotMatch(calls[7].sql.split('WHERE b.del_flag')[1], /vehicle_id IS NULL/);
|
||||
assert.match(calls[7].sql, /GROUP BY[\s\S]*customerName/);
|
||||
assert.match(calls[8].sql, /CONVERT\(UPPER\(REPLACE/);
|
||||
assert.match(calls[8].sql, /b\.vehicle_id IS NULL OR b\.vehicle_id = 0/);
|
||||
assert.match(calls[9].sql, /UNION ALL/);
|
||||
assert.match(calls[9].sql, /fee_total AS amount/);
|
||||
assert.match(calls[9].sql, /summary_date >= \?/);
|
||||
assert.match(calls[9].sql, /refuel_time < DATE_ADD\(\?, INTERVAL 1 DAY\)/);
|
||||
});
|
||||
|
||||
test("站日现结台账仅读取付款流水,并按最近有数据的 15 天聚合", async () => {
|
||||
const calls: QueryCall[] = [];
|
||||
const cacheCalls: CacheCall[] = [];
|
||||
@@ -783,18 +923,18 @@ test("四个处理器继续使用原缓存语义、数据库、SQL 与参数", a
|
||||
},
|
||||
]);
|
||||
|
||||
// 指纹来自拆分前的 13 条 SQL,连空白符变化也会使测试失败。
|
||||
// 指纹覆盖加氢事实专用过滤与既有查询结构,避免手工充值重新混入统计。
|
||||
assert.deepEqual(
|
||||
[...hydrogenCalls, ...electricCalls].map((call) => sqlHash(call.sql)),
|
||||
[
|
||||
"515acf4dbb2a",
|
||||
"de16af670e96",
|
||||
"1367eddb3b7f",
|
||||
"f125d4dde376",
|
||||
"c547b8e1318e",
|
||||
"0e3dfd8f4b81",
|
||||
"f1f69f275993",
|
||||
"688cfdb7a2bf",
|
||||
"4fa77c56a990",
|
||||
"ac6329507bc2",
|
||||
"e46a92ba851e",
|
||||
"0295b2deee31",
|
||||
"852b5b0b9fe1",
|
||||
"abad6dcf0b2f",
|
||||
"41cc73632c26",
|
||||
"1224ee38f710",
|
||||
"03fd669f8077",
|
||||
"6e4031b10926",
|
||||
"4fc77463294c",
|
||||
@@ -835,6 +975,9 @@ test("四个处理器继续使用原缓存语义、数据库、SQL 与参数", a
|
||||
],
|
||||
);
|
||||
assert.match(hydrogenCalls[7].sql, /b\.vehicle_id IS NULL/);
|
||||
assert.ok(hydrogenCalls.every((call) =>
|
||||
call.sql.includes("external_recharge_manual"),
|
||||
));
|
||||
assert.deepEqual(
|
||||
electricCalls.map((call) => call.params),
|
||||
[undefined, undefined, undefined, undefined, ["2025-01-01", "2025-01-02"]],
|
||||
|
||||
+22
-23
@@ -1382,7 +1382,7 @@ export const EnergyBiBoardApp: React.FC = () => {
|
||||
const [customerId, setCustomerId] = useState<string | null>(null);
|
||||
const [customerLabel, setCustomerLabel] = useState<string | null>(null);
|
||||
const [liveOverview, setLiveOverview] = useState<any>(null);
|
||||
const [liveVerifiedOverview, setLiveVerifiedOverview] = useState<any>(null);
|
||||
const [livePendingOverview, setLivePendingOverview] = useState<any>(null);
|
||||
const [liveMeta, setLiveMeta] = useState<any>(null);
|
||||
const [liveError, setLiveError] = useState<string | null>(null);
|
||||
const [liveLoading, setLiveLoading] = useState(true);
|
||||
@@ -1417,25 +1417,23 @@ export const EnergyBiBoardApp: React.FC = () => {
|
||||
setLiveError(null);
|
||||
setLiveMeta(null);
|
||||
setLiveOverview(null);
|
||||
setLiveVerifiedOverview(null);
|
||||
setLivePendingOverview(null);
|
||||
const vehicleScope = fleetScope === 'own' ? 'lingniu' : fleetScope;
|
||||
Promise.all([
|
||||
fetchH2BiMeta(),
|
||||
fetchH2BiOverview({ year, vehicleScope, verifyScope, regionGranularity: 'city' }),
|
||||
verifyScope === 'all'
|
||||
? fetchH2BiOverview({ year, vehicleScope, verifyScope: 'verified', regionGranularity: 'city' })
|
||||
: Promise.resolve(null),
|
||||
]).then(([meta, overview, verifiedOverview]) => {
|
||||
fetchH2BiOverview({ year, vehicleScope, verifyScope: 'unverified', regionGranularity: 'city' }),
|
||||
]).then(([meta, overview, pendingOverview]) => {
|
||||
if (!active) return;
|
||||
setLiveMeta(meta);
|
||||
setLiveOverview(overview);
|
||||
setLiveVerifiedOverview(verifiedOverview);
|
||||
setLivePendingOverview(pendingOverview);
|
||||
setLiveLoading(false);
|
||||
}).catch((error) => {
|
||||
if (!active) return;
|
||||
setLiveMeta(null);
|
||||
setLiveOverview(null);
|
||||
setLiveVerifiedOverview(null);
|
||||
setLivePendingOverview(null);
|
||||
setLiveError(error instanceof Error ? error.message : String(error));
|
||||
setLiveLoading(false);
|
||||
});
|
||||
@@ -1463,14 +1461,15 @@ export const EnergyBiBoardApp: React.FC = () => {
|
||||
// 全局看板时间范围(单站模式不展示:维度不同,由站内查询日期自管)
|
||||
const timeRangeLabel = '统计时间范围';
|
||||
const timeRangeText = useMemo(() => {
|
||||
if (hostView === 'daily') {
|
||||
// 单站内容由 StationDailyApp 使用 dailyStartDate/dailyEndDate 驱动;不能沿用全局年度 overview 范围。
|
||||
if (boardScope === 'station' || hostView === 'daily') {
|
||||
return `${dailyStartDate} 至 ${dailyEndDate}`;
|
||||
}
|
||||
if (liveOverview?.range?.startDate && liveOverview?.range?.endDate) {
|
||||
return `${liveOverview.range.startDate} 至 ${liveOverview.range.endDate}`;
|
||||
}
|
||||
return `${year}-01-01 至 ${year}-12-31`;
|
||||
}, [hostView, dailyStartDate, dailyEndDate, year, liveOverview]);
|
||||
}, [boardScope, hostView, dailyStartDate, dailyEndDate, year, liveOverview]);
|
||||
const mobileView: HostView = boardScope === 'station' ? 'daily' : hostView;
|
||||
const activeMobileFleet = mobileView === 'daily' ? dailyFleetType : fleetScope;
|
||||
const mobileFilterCount = mobileView === 'daily'
|
||||
@@ -1576,12 +1575,12 @@ export const EnergyBiBoardApp: React.FC = () => {
|
||||
}, [stationRankOpen]);
|
||||
|
||||
const dims = useMemo(() => costDimCards(rows), [rows]);
|
||||
const risk = liveOverview && liveVerifiedOverview
|
||||
const risk = livePendingOverview
|
||||
? {
|
||||
count: Math.max(0, Number(liveOverview.kpis.recordCount || 0) - Number(liveVerifiedOverview.kpis.recordCount || 0)),
|
||||
amount: Math.max(0, Number(liveOverview.kpis.totalCost || 0) - Number(liveVerifiedOverview.kpis.totalCost || 0)),
|
||||
count: Number(livePendingOverview.kpis.recordCount || 0),
|
||||
amount: Number(livePendingOverview.kpis.totalCost || 0),
|
||||
}
|
||||
: unverified(rows);
|
||||
: { count: 0, amount: 0 };
|
||||
const unitProfitYuan = hostKpi.totalKgT > 0
|
||||
? ((hostKpi.profitWan * 10000) / (hostKpi.totalKgT * 1000)).toFixed(2)
|
||||
: '—';
|
||||
@@ -1831,7 +1830,7 @@ export const EnergyBiBoardApp: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="ehb-mobile-primary-filters">
|
||||
<BiYearSelect value={year} onChange={(y) => { setYear(y); clearEntity(); }} />
|
||||
{boardScope === 'global' ? <BiYearSelect value={year} onChange={(y) => { setYear(y); clearEntity(); }} /> : null}
|
||||
<button
|
||||
type="button"
|
||||
style={ACCESSIBLE_CONTROL_STYLE}
|
||||
@@ -1844,15 +1843,15 @@ export const EnergyBiBoardApp: React.FC = () => {
|
||||
<span aria-hidden>●</span>{boardScope === 'station' ? '总览' : hostView === 'overview' ? '总览' : '日期'}
|
||||
<ChevronsUpDown size={14} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
{boardScope === 'global' ? <button
|
||||
type="button"
|
||||
style={ACCESSIBLE_CONTROL_STYLE}
|
||||
className="ehb-mobile-order-chip"
|
||||
onClick={() => { setVerifyScope(verifyScope === 'all' ? 'verified' : 'all'); clearEntity(); }}
|
||||
>
|
||||
{verifyScope === 'all' ? '全量订单' : '仅已核对'}
|
||||
</button>
|
||||
<button
|
||||
</button> : null}
|
||||
{boardScope === 'global' ? <button
|
||||
type="button"
|
||||
style={ACCESSIBLE_CONTROL_STYLE}
|
||||
className="ehb-mobile-fleet-chip"
|
||||
@@ -1863,7 +1862,7 @@ export const EnergyBiBoardApp: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
{activeMobileFleet === 'all' ? '全部车辆' : activeMobileFleet === 'own' ? '羚牛车辆' : '外部车辆'}
|
||||
</button>
|
||||
</button> : null}
|
||||
<button
|
||||
type="button"
|
||||
style={ACCESSIBLE_CONTROL_STYLE}
|
||||
@@ -1876,7 +1875,7 @@ export const EnergyBiBoardApp: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="ehb-mobile-current-range">
|
||||
当前范围:{boardScope === 'global' ? '全部站点' : '当前站点'} · {activeMobileFleet === 'all' ? '全部车辆' : activeMobileFleet === 'own' ? '羚牛车辆' : '外部车辆'} · {mobileView === 'daily' ? dailyRangePreset === 'custom' ? '自定义' : dailyRangePreset === '15days' ? '近15天' : dailyRangePreset === 'week' ? '本周' : '本月' : verifyScope === 'all' ? '全量订单' : '仅已核对订单'}
|
||||
当前范围:{boardScope === 'global' ? '全部站点' : '当前站点'} · {mobileView === 'daily' ? `${dailyStartDate} 至 ${dailyEndDate}` : `${activeMobileFleet === 'all' ? '全部车辆' : activeMobileFleet === 'own' ? '羚牛车辆' : '外部车辆'} · ${verifyScope === 'all' ? '全量订单' : '仅已核对订单'}`}
|
||||
</div>
|
||||
{mobileView === 'daily' ? (
|
||||
<div className="ehb-pill-tabs ehb-mobile-daily-presets"><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === 'week' ? 'is-active' : ''}`} onClick={() => handleDailyPresetChange('week')}>本周</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === 'month' ? 'is-active' : ''}`} onClick={() => handleDailyPresetChange('month')}>本月</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === '15days' ? 'is-active' : ''}`} onClick={() => handleDailyPresetChange('15days')}>近15天</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === 'custom' ? 'is-active' : ''}`} onClick={() => { handleDailyPresetChange('custom'); setFiltersOpen(true); }}>自定义</button></div>
|
||||
@@ -1892,7 +1891,7 @@ export const EnergyBiBoardApp: React.FC = () => {
|
||||
) : (
|
||||
<>
|
||||
<div className="ehb-mobile-date-fields"><BiCustomDatePicker label="开始日期" value={dailyStartDate} onChange={(val) => { setDailyStartDate(val); setDailyRangePreset('custom'); }} /><BiCustomDatePicker label="结束日期" value={dailyEndDate} onChange={(val) => { setDailyEndDate(val); setDailyRangePreset('custom'); }} /></div>
|
||||
<div className="ehb-mobile-filter-field"><span>车辆范围</span><div className="ehb-fleet-segmented"><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'all' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('all')}>全部车辆</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'own' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('own')}>羚牛车辆</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'external' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('external')}>外部车辆</button></div></div>
|
||||
{boardScope === 'global' ? <div className="ehb-mobile-filter-field"><span>车辆范围</span><div className="ehb-fleet-segmented"><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'all' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('all')}>全部车辆</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'own' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('own')}>羚牛车辆</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'external' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('external')}>外部车辆</button></div></div> : null}
|
||||
<button type="button" className="ehb-mobile-refresh" onClick={handleRefreshData} disabled={dailyRefreshing} aria-busy={dailyRefreshing}><RefreshCw size={14} className={dailyRefreshing ? 'is-spinning' : ''} aria-hidden />{dailyRefreshing ? '加载中…' : '刷新数据'}</button>
|
||||
</>
|
||||
)}
|
||||
@@ -2217,7 +2216,7 @@ export const EnergyBiBoardApp: React.FC = () => {
|
||||
<button type="button" className="ehb-diagnosis__item is-action" onClick={() => setKpiDrillType('待核对订单')} title="查看待核对订单">
|
||||
<span>待核对订单</span>
|
||||
<strong className={risk.count > 0 ? 'is-warning' : ''}>{risk.count}笔 · ¥{unverifiedWan}万</strong>
|
||||
<small>查看待核对订单</small>
|
||||
<small>核对上线期初:2026-05-01(含)· 查看待核对订单</small>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
@@ -2236,7 +2235,7 @@ export const EnergyBiBoardApp: React.FC = () => {
|
||||
<div><span>月度环比</span><strong className={liveMonthComparison && liveMonthComparison.value < 0 ? 'is-warning' : 'is-positive'}>{liveMonthComparison ? `${liveMonthComparison.value >= 0 ? '+' : ''}${liveMonthComparison.value.toFixed(1)}%` : '—'}</strong><small>{liveMonthComparison?.label ?? '暂无可比月份'}</small></div>
|
||||
<div><span>单公斤毛利</span><strong>¥{unitProfitYuan}<small>/kg</small></strong><small>按累计加氢量计算</small></div>
|
||||
<button type="button" className="ehb-mobile-diagnosis-action" onClick={() => setKpiDrillType('加氢站加氢量排名')}><span>头部站点占比</span><strong>{top5SharePct}%</strong><small>前5站占总量</small></button>
|
||||
<button type="button" className="ehb-mobile-diagnosis-action" onClick={() => setKpiDrillType('待核对订单')}><span>待核对订单</span><strong className={risk.count > 0 ? 'is-warning' : ''}>{risk.count}笔</strong><small>¥{unverifiedWan}万</small></button>
|
||||
<button type="button" className="ehb-mobile-diagnosis-action" onClick={() => setKpiDrillType('待核对订单')}><span>待核对订单</span><strong className={risk.count > 0 ? 'is-warning' : ''}>{risk.count}笔</strong><small>¥{unverifiedWan}万 · 上线期初 2026-05-01(含)</small></button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
|
||||
+14
-29
@@ -88,18 +88,6 @@ function StationTrend({ vols }: { vols: StationDailyVolumeRow[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StationFilter({ value, rows, onChange }: { value: string; rows: StationOverview[]; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<select className="sd-station-filter-select" value={value} onChange={(e) => onChange(e.target.value)} aria-label="选择当前站点">
|
||||
{rows.map((row) => (
|
||||
<option key={row.id} value={row.id}>
|
||||
{row.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function localIsoDate(date: Date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -144,11 +132,10 @@ export const StationDailyApp: React.FC<StationDailyAppProps> = ({
|
||||
const [updatedAt, setUpdatedAt] = useState('2026-08-12 10:00');
|
||||
const [tick, setTick] = useState(0);
|
||||
const [kpiDrill, setKpiDrill] = useState<KpiDrill>(null);
|
||||
const [stationFilter, setStationFilter] = useState('');
|
||||
const [liveBoard, setLiveBoard] = useState<HydrogenStationBoardResponse | null>(null);
|
||||
const [liveError, setLiveError] = useState<string | null>(null);
|
||||
const [liveLoading, setLiveLoading] = useState(true);
|
||||
const [stationCashBoard, setStationCashBoard] = useState<HydrogenStationBoardResponse | null>(null);
|
||||
const [stationCashBoard, setStationCashBoard] = useState<HydrogenStationBoardResponse[] | null>(null);
|
||||
const [stationCashError, setStationCashError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -213,26 +200,21 @@ export const StationDailyApp: React.FC<StationDailyAppProps> = ({
|
||||
.sort((a, b) => b.rangeKg - a.rangeKg || a.name.localeCompare(b.name, 'zh-CN'));
|
||||
}, [liveBoard, endDate]);
|
||||
|
||||
// 单站入口只保留一条真实站点记录。首次拿到真实列表时按服务端排序后的首站默认选中。
|
||||
const selectedStationId = overviewRows.some((row) => row.id === stationFilter)
|
||||
? stationFilter
|
||||
: (overviewRows[0]?.id ?? '');
|
||||
const boardRows = selectedStationId ? overviewRows.filter((row) => row.id === selectedStationId) : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedStationId && selectedStationId !== stationFilter) setStationFilter(selectedStationId);
|
||||
}, [selectedStationId, stationFilter]);
|
||||
// 自营站列表完整展示,点击其中一站再进入单站详情。
|
||||
const boardRows = overviewRows;
|
||||
const stationIdsKey = overviewRows.map((row) => row.id).sort().join(',');
|
||||
|
||||
// 总览请求的 summary.daily 是全站口径;单站现结日表必须另以真实 stationId 查询,不能按比例拆分。
|
||||
useEffect(() => {
|
||||
if (!selectedStationId) {
|
||||
if (!stationIdsKey) {
|
||||
setStationCashBoard(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setStationCashBoard(null);
|
||||
setStationCashError(null);
|
||||
fetchHydrogenStationBoard({ startDate, endDate, stationId: Number(selectedStationId), force: tick > 0 })
|
||||
Promise.all(stationIdsKey.split(',').map((id) =>
|
||||
fetchHydrogenStationBoard({ startDate, endDate, stationId: Number(id), force: tick > 0 })))
|
||||
.then((result) => {
|
||||
if (active) setStationCashBoard(result);
|
||||
})
|
||||
@@ -240,7 +222,7 @@ export const StationDailyApp: React.FC<StationDailyAppProps> = ({
|
||||
if (active) setStationCashError(reason instanceof Error ? reason.message : '当前站点现结流水加载失败');
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [selectedStationId, startDate, endDate, tick]);
|
||||
}, [stationIdsKey, startDate, endDate, tick]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
return {
|
||||
@@ -266,8 +248,11 @@ export const StationDailyApp: React.FC<StationDailyAppProps> = ({
|
||||
|
||||
/** 当前选中站点的区间日现结;接口成功后才把无进账日期显式为 0。 */
|
||||
const cashDaily = useMemo(() => {
|
||||
if (!stationCashBoard?.selected) return null;
|
||||
const map = new Map(stationCashBoard.selected.daily.map((row) => [row.date, row.paymentAmount]));
|
||||
if (!stationCashBoard || stationCashBoard.some((board) => !board.selected)) return null;
|
||||
const map = new Map<string, number>();
|
||||
for (const board of stationCashBoard) {
|
||||
for (const row of board.selected!.daily) map.set(row.date, (map.get(row.date) ?? 0) + row.paymentAmount);
|
||||
}
|
||||
return calendarDays(startDate, endDate).map((date) => ({ date, amount: map.get(date) || 0 }));
|
||||
}, [stationCashBoard, startDate, endDate]);
|
||||
|
||||
@@ -413,7 +398,7 @@ export const StationDailyApp: React.FC<StationDailyAppProps> = ({
|
||||
<span className="sd-station-board__fullscreen">
|
||||
<MobileListFullscreenButton label="横屏全屏查看站点经营概况" />
|
||||
</span>
|
||||
<StationFilter value={stationFilter} rows={overviewRows} onChange={setStationFilter} />
|
||||
<span className="sd-panel__meta">全部自营站 · {boardRows.length} 站</span>
|
||||
</div>
|
||||
|
||||
<div className="sd-station-single-card">
|
||||
|
||||
+119
-119
@@ -3,7 +3,7 @@
|
||||
* 单站日报明细 · 对齐汇报 Excel 精简表集
|
||||
* 近7日红涨绿跌 · 月环比同色 · 列表最多10条可展开
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ChevronDown, ChevronUp, Download, RefreshCw } from 'lucide-react';
|
||||
import { downloadExcelAoa } from '../../common/download-xls';
|
||||
import { MobileListFullscreenButton } from '../../common/MobileListFullscreenButton';
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
type StationCashIntakeDay,
|
||||
} from '../../common/energy-spot-cash-intake';
|
||||
import { fetchHydrogenStationBoard } from '../../../../modules/energy/api';
|
||||
import { fetchH2BiDrill } from '../../../../modules/energy/hydrogen-bi-v2/api';
|
||||
import { fetchAllH2BiDrillRecords } from '../../../../modules/energy/hydrogen-bi-v2/api';
|
||||
import { PrototypeDrillModal } from '../../../../modules/energy/hydrogen-bi-v2/prototype-real-drills';
|
||||
import { MobileDailyList, MobileCustomerMonthList } from './StationMobileLists';
|
||||
import type { HydrogenStationBoardResponse } from '../../../../modules/energy/types';
|
||||
import {
|
||||
monthTotals,
|
||||
@@ -20,7 +22,7 @@ import {
|
||||
} from './data/mockStationDaily';
|
||||
import { SdCustomerMultiSelect } from './SdCustomerMultiSelect';
|
||||
import { SdDateRangePicker } from './SdDateRangePicker';
|
||||
import { customerMonthLabel, customerMonthRange } from './station-month-range';
|
||||
import { customerMonthLabel, customerMonthRange, dateRangeLabel } from './station-month-range';
|
||||
|
||||
const ROW_LIMIT = 10;
|
||||
|
||||
@@ -111,36 +113,36 @@ export const StationDailyDetailView: React.FC<{
|
||||
const [expandCash, setExpandCash] = useState(false);
|
||||
const [mobileDetailTab, setMobileDetailTab] = useState<'daily' | 'customer' | 'balance' | 'cash'>('daily');
|
||||
const [mobileMonthKey, setMobileMonthKey] = useState<string>('');
|
||||
const [orderDate, setOrderDate] = useState<string | null>(null);
|
||||
const [liveBoard, setLiveBoard] = useState<HydrogenStationBoardResponse | null>(null);
|
||||
const [liveRecords, setLiveRecords] = useState<Record<string, unknown>[]>([]);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const exportController = useRef<AbortController | null>(null);
|
||||
const [liveError, setLiveError] = useState<string | null>(null);
|
||||
const [liveLoading, setLiveLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
exportController.current?.abort();
|
||||
exportController.current = null;
|
||||
setExporting(false);
|
||||
setExportError(null);
|
||||
setOrderDate(null);
|
||||
setLiveError(null);
|
||||
setLiveLoading(true);
|
||||
// 新的站点或日期范围不能沿用旧响应;否则月列已变而数值仍属上一查询。
|
||||
setLiveBoard(null);
|
||||
setLiveRecords([]);
|
||||
Promise.all([
|
||||
fetchHydrogenStationBoard({ startDate: rangeStart, endDate: rangeEnd, stationId: Number(stationId) }),
|
||||
fetchH2BiDrill({
|
||||
year: Number(rangeStart.slice(0, 4)), startDate: rangeStart, endDate: rangeEnd,
|
||||
vehicleScope: 'all', verifyScope: 'all', stationId, groupBy: 'record', page: 1, pageSize: 500,
|
||||
}),
|
||||
]).then(([board, drill]) => {
|
||||
fetchHydrogenStationBoard({ startDate: rangeStart, endDate: rangeEnd, stationId: Number(stationId), force: cashTick > 0 }).then((board) => {
|
||||
if (!active) return;
|
||||
setLiveBoard(board);
|
||||
setLiveRecords(drill.records as Record<string, unknown>[]);
|
||||
setLiveLoading(false);
|
||||
}).catch((reason: unknown) => {
|
||||
if (!active) return;
|
||||
setLiveError(reason instanceof Error ? reason.message : '站点详情加载失败');
|
||||
setLiveLoading(false);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [stationId, rangeStart, rangeEnd, updatedAt]);
|
||||
return () => { active = false; exportController.current?.abort(); };
|
||||
}, [stationId, rangeStart, rangeEnd, updatedAt, cashTick]);
|
||||
|
||||
const liveStation = liveBoard?.stations.find((station) => String(station.id) === String(stationId));
|
||||
const stationName = liveStation?.name || stationId;
|
||||
@@ -192,18 +194,19 @@ export const StationDailyDetailView: React.FC<{
|
||||
return idx > 0 ? sorted[idx - 1].quantityKg : null;
|
||||
}, [asOfVolume, volumeRows]);
|
||||
|
||||
const customerMonthRows = liveBoard?.selected?.customerMonths;
|
||||
const allCustCells = useMemo(
|
||||
() => {
|
||||
const byCustomer = new Map<string, Record<string, number>>();
|
||||
for (const row of liveBoard?.selected?.customerMonths ?? []) {
|
||||
for (const row of customerMonthRows ?? []) {
|
||||
const months = byCustomer.get(row.customerName) ?? {};
|
||||
const monthKey = String(row.month).slice(0, 7);
|
||||
months[monthKey] = row.kg;
|
||||
months[monthKey] = (months[monthKey] ?? 0) + row.kg;
|
||||
byCustomer.set(row.customerName, months);
|
||||
}
|
||||
return [...byCustomer].map(([customerName, months]) => ({ stationId, customerName, months }));
|
||||
},
|
||||
[liveBoard, stationId],
|
||||
[customerMonthRows, stationId],
|
||||
);
|
||||
const customerOptions = useMemo(
|
||||
() => allCustCells.map((c) => c.customerName),
|
||||
@@ -216,37 +219,26 @@ export const StationDailyDetailView: React.FC<{
|
||||
return allCustCells.filter((c) => set.has(c.customerName));
|
||||
}, [allCustCells, selectedCustomers]);
|
||||
|
||||
const feeCells = useMemo(() => {
|
||||
const allowed = new Set(custCells.map((row) => row.customerName));
|
||||
const allFeeCells = useMemo(() => {
|
||||
const byCustomer = new Map<string, Record<string, number>>();
|
||||
for (const row of liveBoard?.selected?.customerMonths ?? []) {
|
||||
if (!allowed.has(row.customerName)) continue;
|
||||
for (const row of customerMonthRows ?? []) {
|
||||
const months = byCustomer.get(row.customerName) ?? {};
|
||||
const monthKey = String(row.month).slice(0, 7);
|
||||
months[monthKey] = row.fee;
|
||||
months[monthKey] = (months[monthKey] ?? 0) + row.fee;
|
||||
byCustomer.set(row.customerName, months);
|
||||
}
|
||||
return [...byCustomer].map(([customerName, months]) => ({ stationId, customerName, months }));
|
||||
}, [custCells, liveBoard, stationId]);
|
||||
}, [customerMonthRows, stationId]);
|
||||
const feeCells = useMemo(() => {
|
||||
const allowed = new Set(custCells.map((row) => row.customerName));
|
||||
return allFeeCells.filter((row) => allowed.has(row.customerName));
|
||||
}, [custCells, allFeeCells]);
|
||||
const kgMonthTot = useMemo(() => monthTotals(custCells, customerMonthKeys), [custCells, customerMonthKeys]);
|
||||
const feeMonthTot = useMemo(() => monthTotals(feeCells, customerMonthKeys), [feeCells, customerMonthKeys]);
|
||||
|
||||
const custVisible = expandCust ? custCells : custCells.slice(0, ROW_LIMIT);
|
||||
const feeVisible = expandCust ? feeCells : feeCells.slice(0, ROW_LIMIT);
|
||||
|
||||
const vehicleRows = useMemo(
|
||||
() => liveRecords.map((row) => ({
|
||||
date: String(row.refuelTime ?? '').slice(0, 10),
|
||||
plateNo: String(row.plateNo ?? '未关联车牌'),
|
||||
customerName: String(row.customerName ?? '未关联客户'),
|
||||
fleet: row.vehicleScope === 'lingniu' ? 'own' : 'external',
|
||||
quantityKg: Number(row.kg) || 0,
|
||||
unitPrice: Number(row.costPrice) || 0,
|
||||
amountYuan: Number(row.cost) || 0,
|
||||
})),
|
||||
[liveRecords],
|
||||
);
|
||||
|
||||
const cashDays: StationCashIntakeDay[] = useMemo(() => {
|
||||
void cashTick;
|
||||
return (liveBoard?.selected?.daily ?? []).filter((row) => row.paymentAmount > 0).map((row) => ({
|
||||
@@ -263,17 +255,15 @@ export const StationDailyDetailView: React.FC<{
|
||||
|
||||
const cashLines = useMemo(
|
||||
() =>
|
||||
cashDays.flatMap((d) =>
|
||||
d.lines.map((l) => ({
|
||||
id: l.id,
|
||||
bizDate: padYmd(d.bizDate),
|
||||
customerName: l.customerName,
|
||||
payMethod: l.payMethod,
|
||||
amount: l.amount,
|
||||
})),
|
||||
),
|
||||
[cashDays],
|
||||
(liveBoard?.selected?.externalReceipts?.rows ?? []).map((row) => ({
|
||||
...row, bizDate: row.date,
|
||||
sourceLabel: row.source === 'spot_daily_auto' ? '现结自动汇总'
|
||||
: row.source === 'external_recharge_manual' ? '手工充值' : `未知来源(${row.source})`,
|
||||
payLabel: row.payMethod === 'corporate' ? '对公转账' : row.payMethod === 'wechat' ? '微信' : row.payMethod || '未注明',
|
||||
})),
|
||||
[liveBoard],
|
||||
);
|
||||
const receiptTotal = cashLines.reduce((sum, row) => sum + row.amount, 0);
|
||||
const cashVisible = expandCash ? cashLines : cashLines.slice(0, ROW_LIMIT);
|
||||
|
||||
const cashTotal = cashDays.reduce((s, d) => s + d.totalAmount, 0);
|
||||
@@ -294,7 +284,30 @@ export const StationDailyDetailView: React.FC<{
|
||||
const maxKg = Math.max(...volumeRows.map((r) => r.quantityKg), 1);
|
||||
const hoverRow = hoverDate ? volumeRows.find((r) => r.date === hoverDate) : null;
|
||||
|
||||
const handleExport = () => {
|
||||
const handleExport = async () => {
|
||||
if (liveLoading || liveError || !liveBoard || exportController.current) return;
|
||||
const controller = new AbortController();
|
||||
exportController.current = controller;
|
||||
setExporting(true);
|
||||
setExportError(null);
|
||||
try {
|
||||
if (!liveBoard?.selected?.customerMonths || !liveBoard?.selected?.externalReceipts) {
|
||||
throw new Error('客户数据未完整返回,暂不导出,请刷新后重试');
|
||||
}
|
||||
const result = await fetchAllH2BiDrillRecords({
|
||||
year: Number(rangeStart.slice(0, 4)), startDate: rangeStart, endDate: rangeEnd,
|
||||
vehicleScope: 'all', verifyScope: 'all', stationId,
|
||||
}, { signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
const vehicleRows = result.records.map((row) => ({
|
||||
date: String(row.time ?? '').slice(0, 10),
|
||||
plateNo: String(row.plateNo ?? '未关联车牌'),
|
||||
customerName: String(row.customerName ?? '未关联客户'),
|
||||
fleet: row.vehicleScope === 'lingniu' ? 'own' : 'external',
|
||||
quantityKg: Number(row.kg) || 0,
|
||||
unitPrice: Number(row.unitPrice) || 0,
|
||||
amountYuan: Number(row.cost) || 0,
|
||||
}));
|
||||
const monthHeads = customerMonthKeys.map(customerMonthLabel);
|
||||
const aoa: (string | number)[][] = [
|
||||
[`${stationName} · 站日报(查询日期 ${asOf})`],
|
||||
@@ -329,13 +342,17 @@ export const StationDailyDetailView: React.FC<{
|
||||
b.balanceYuan,
|
||||
b.remark || '',
|
||||
]),
|
||||
['小计', balanceSubtotal.recharge, balanceSubtotal.prepaid, balanceSubtotal.spot, balanceSubtotal.balance, ''],
|
||||
['未接入完整账户余额,不以零代替缺失数据'],
|
||||
[],
|
||||
['氢费充值/现结进账明细'],
|
||||
['充值日期', '客户', '付款方式', '金额(元)'],
|
||||
...cashLines.map((l) => [l.bizDate, l.customerName, SPOT_PAY_METHOD_LABEL[l.payMethod], l.amount]),
|
||||
['外部客户进账(当前租户全部外部客户,不按站点归属;不计入单站收益)'],
|
||||
['日期', '客户', '付款方式', '金额(元)', '来源', '源记录数', '刷新时间', '记录ID'],
|
||||
...cashLines.map((l) => [l.bizDate, l.customerName, l.payLabel, l.amount, l.sourceLabel, l.sourceRecordCount, l.updatedAt ?? '', l.id]),
|
||||
[],
|
||||
['车辆加氢明细(由汇总派生 · 取证)'],
|
||||
['自营站全部车辆客户加氢月度(不含手工充值)'],
|
||||
['月份', '客户', '加氢量(Kg)', '加氢金额(元)', '记录数'],
|
||||
...(liveBoard?.selected?.customerMonths ?? []).map((r) => [r.month, r.customerName, r.kg, r.fee, r.recordCount]),
|
||||
[],
|
||||
['车辆加氢明细(查询区间全部真实账本记录)', result.records.length],
|
||||
['日期', '车牌', '客户', '归属', '加氢量(Kg)', '单价', '金额(元)'],
|
||||
...vehicleRows.map((r) => [
|
||||
r.date,
|
||||
@@ -348,6 +365,14 @@ export const StationDailyDetailView: React.FC<{
|
||||
]),
|
||||
];
|
||||
downloadExcelAoa(aoa, `站日报取证_${stationName}_查询${asOf}.xlsx`, '站日报取证');
|
||||
} catch (reason) {
|
||||
if (!controller.signal.aborted) setExportError(reason instanceof Error ? reason.message : '导出失败,请重试');
|
||||
} finally {
|
||||
if (exportController.current === controller) {
|
||||
exportController.current = null;
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderMonthCells = (
|
||||
@@ -393,7 +418,7 @@ export const StationDailyDetailView: React.FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sd-detail" data-annotation-id="station-daily-detail">
|
||||
<div className="sd-detail sd-detail--mobile-pilot" data-annotation-id="station-daily-detail">
|
||||
<header className="sd-detail-top">
|
||||
<div className="sd-detail-top__lead">
|
||||
<button type="button" className="sd-btn sd-btn--ghost" onClick={onBack}>
|
||||
@@ -428,12 +453,14 @@ export const StationDailyDetailView: React.FC<{
|
||||
<RefreshCw size={16} aria-hidden />
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" className="sd-btn sd-btn--primary ehb-hide-h5" onClick={handleExport}>
|
||||
<button type="button" className="sd-btn sd-btn--primary ehb-hide-h5" disabled={liveLoading || Boolean(liveError) || exporting} onClick={handleExport}>
|
||||
<Download size={15} aria-hidden />
|
||||
导出取证
|
||||
{exporting ? '正在读取全部记录…' : '导出取证'}
|
||||
</button>
|
||||
{exporting ? <button type="button" className="sd-btn sd-btn--ghost" onClick={() => exportController.current?.abort()}>取消导出</button> : null}
|
||||
</div>
|
||||
</header>
|
||||
{exportError ? <div role="alert" className="ehb-empty">导出失败:{exportError}。未生成文件,请缩小日期范围或重试。</div> : null}
|
||||
|
||||
{liveError ? (
|
||||
<div className="ehb-empty" role="alert">站点详情加载失败:{liveError}</div>
|
||||
@@ -454,29 +481,30 @@ export const StationDailyDetailView: React.FC<{
|
||||
</div>
|
||||
</div>
|
||||
<div className="sd-hero-kpi">
|
||||
<div className="sd-hero-kpi__label">近 10 日加氢</div>
|
||||
<div className="sd-hero-kpi__label">查询区间加氢</div>
|
||||
<div className="sd-hero-kpi__value">
|
||||
{kg(rangeKg)}
|
||||
<span className="sd-unit">Kg</span>
|
||||
</div>
|
||||
<div className="sd-hero-kpi__sub">¥{money(rangeAmt)}</div>
|
||||
<div className="sd-hero-kpi__sub">¥{money(rangeAmt)} · {dateRangeLabel(startDate, end)}</div>
|
||||
</div>
|
||||
<div className="sd-hero-kpi">
|
||||
<div className="sd-hero-kpi__label">本月加氢</div>
|
||||
<div className="sd-hero-kpi__label">区间内本月加氢</div>
|
||||
<div className="sd-hero-kpi__value">
|
||||
{kg(monthKg)}
|
||||
<span className="sd-unit">Kg</span>
|
||||
</div>
|
||||
<div className="sd-hero-kpi__sub">{asOf.slice(0, 7)}</div>
|
||||
<div className="sd-hero-kpi__sub">{asOf.slice(0, 7)} · 仅计入查询区间</div>
|
||||
</div>
|
||||
<div className="sd-hero-kpi">
|
||||
<div className="sd-hero-kpi__label">近 10 日现结</div>
|
||||
<div className="sd-hero-kpi__label">单站现结流水</div>
|
||||
<div className="sd-hero-kpi__value">
|
||||
{money(cashTotal)}
|
||||
<span className="sd-unit">元</span>
|
||||
</div>
|
||||
<div className="sd-hero-kpi__sub">
|
||||
{cashDays.length ? `${cashDays.length} 天有进账` : '0 天有进账'}
|
||||
{cashDays.length ? `${cashDays.length} 天有进账 · ${dateRangeLabel(startDate, end)}` : `0 天有进账 · ${dateRangeLabel(startDate, end)}`}
|
||||
<br />不含客户级充值/进账
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -506,42 +534,13 @@ export const StationDailyDetailView: React.FC<{
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{mobileDetailTab === 'customer' ? (
|
||||
<div className="sd-mobile-month-controls">
|
||||
<select aria-label="月份" value={mobileMonthKey} onChange={(event) => setMobileMonthKey(event.target.value)}>
|
||||
{customerMonthKeys.map((month) => <option key={month} value={month}>{customerMonthLabel(month)}</option>)}
|
||||
</select>
|
||||
<div className="sd-mobile-customer-filter">
|
||||
<SdCustomerMultiSelect options={customerOptions} value={selectedCustomers} onChange={setSelectedCustomers} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className={`sd-panel sd-panel--block sd-mobile-detail-panel ${mobileDetailTab === 'daily' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
|
||||
<h2 className="sd-panel__title">加氢站每日加氢量汇总(近 7 日)</h2>
|
||||
<div className="sd-mobile-record-list sd-mobile-daily-list">
|
||||
{volume7.map((row, index) => {
|
||||
const previous = index > 0 ? volume7[index - 1].quantityKg : null;
|
||||
const delta = previous == null ? null : row.quantityKg - previous;
|
||||
return (
|
||||
<article key={row.date} className="sd-mobile-record">
|
||||
<div className="sd-mobile-record__lead">
|
||||
<strong>{padYmd(row.date).slice(5)}</strong>
|
||||
<span>{row.vehicleCount} 车次</span>
|
||||
</div>
|
||||
<div className="sd-mobile-record__value">
|
||||
<strong>{kg(row.quantityKg)} <small>Kg</small></strong>
|
||||
<span className={stockDeltaClass(row.quantityKg, previous)}>
|
||||
{delta == null ? '首日' : `较前日 ${delta > 0 ? '+' : ''}${kg(delta)}`}
|
||||
<DeltaMark curr={row.quantityKg} prev={previous} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="sd-mobile-record__meta">金额 ¥{money(row.amountYuan)} · 单价 ¥{row.unitPrice}/Kg</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<MobileDailyList key={`${stationId}|${rangeStart}|${rangeEnd}`}
|
||||
rows={volume7} allRows={volumeRows} loading={liveLoading} error={liveError}
|
||||
onOpenOrders={setOrderDate} />
|
||||
<div className="sd-table-scroll">
|
||||
<table className="sd-bi-table sd-bi-table--fill">
|
||||
<thead>
|
||||
@@ -686,22 +685,12 @@ export const StationDailyDetailView: React.FC<{
|
||||
onChange={setSelectedCustomers}
|
||||
/>
|
||||
</div>
|
||||
<div className="sd-mobile-record-list sd-mobile-customer-combined-list">
|
||||
{custVisible.map((customer) => {
|
||||
const feeCustomer = feeCells.find((item) => item.customerName === customer.customerName);
|
||||
const volumeValue = customer.months[mobileMonthKey] ?? 0;
|
||||
const feeValue = feeCustomer?.months[mobileMonthKey] ?? 0;
|
||||
return (
|
||||
<article key={customer.customerName} className="sd-mobile-record">
|
||||
<div className="sd-mobile-record__lead"><strong>{customer.customerName}</strong><span>{customerMonthLabel(mobileMonthKey)}</span></div>
|
||||
<div className="sd-mobile-record__metrics">
|
||||
<div className={`sd-mobile-record__metric is-volume ${volumeValue === 0 ? 'is-zero' : ''}`}><strong>{kg(volumeValue)}</strong><span>Kg</span></div>
|
||||
<div className={`sd-mobile-record__metric is-fee ${feeValue === 0 ? 'is-zero' : ''}`}><strong>¥{money(feeValue)}</strong><span>加氢费</span></div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="sd-panel__meta">自营站全部车辆与客户,包含羚牛及外部车辆;未关联客户保留统计,不含手工充值。</p>
|
||||
<MobileCustomerMonthList key={stationId} months={customerMonthKeys}
|
||||
volumeCustomers={allCustCells} feeCustomers={allFeeCells}
|
||||
month={mobileMonthKey} onMonthChange={setMobileMonthKey}
|
||||
metric={customerMonthlyMetric} onMetricChange={setCustomerMonthlyMetric}
|
||||
loading={liveLoading} error={liveError || (!customerMonthRows ? '客户数据暂不可用' : null)} />
|
||||
<div className="sd-table-scroll sd-table-scroll--matrix sd-desktop-matrix-table">
|
||||
<table className={`sd-bi-table sd-bi-table--matrix ${customerMonthlyMetric === 'fee' ? 'is-amount' : ''}`}>
|
||||
<thead>
|
||||
@@ -727,7 +716,7 @@ export const StationDailyDetailView: React.FC<{
|
||||
{(customerMonthlyMetric === 'volume' ? custCells : feeCells).length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={1 + customerMonthKeys.length} className="ehb-empty-cell">
|
||||
无匹配客户
|
||||
{!customerMonthRows ? '客户数据暂不可用' : '无匹配客户'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -764,6 +753,7 @@ export const StationDailyDetailView: React.FC<{
|
||||
<div className="sd-dual sd-dual--cash sd-dual--ledger">
|
||||
<section className={`sd-panel sd-mobile-detail-panel ${mobileDetailTab === 'balance' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
|
||||
<h2 className="sd-panel__title">客户氢费收支汇总</h2>
|
||||
<p className="sd-panel__meta">完整账户余额未接入:新进账表不能单独推算余额或经营利润。</p>
|
||||
<div className="sd-mobile-record-list sd-mobile-balance-list">
|
||||
{balVisible.map((customer) => (
|
||||
<article key={customer.customerName} className="sd-mobile-record">
|
||||
@@ -832,16 +822,19 @@ export const StationDailyDetailView: React.FC<{
|
||||
<MoreToggle expanded={expandBal} total={balanceRows.length} onToggle={() => setExpandBal((v) => !v)} />
|
||||
</section>
|
||||
|
||||
<section className={`sd-panel sd-mobile-detail-panel ${mobileDetailTab === 'cash' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
|
||||
<div className="sd-panel__head-row">
|
||||
<h2 className="sd-panel__title">氢费充值/现结进账明细</h2>
|
||||
<span className="sd-panel__meta">合计 ¥{money(cashTotal)} · {cashLines.length} 笔</span>
|
||||
<section className={`sd-panel sd-mobile-detail-panel sd-external-receipts ${mobileDetailTab === 'cash' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
|
||||
<div className="sd-panel__head-row sd-external-receipt-head">
|
||||
<h2 className="sd-panel__title">外部客户充值/现结进账</h2>
|
||||
<span className="sd-panel__meta">{liveBoard?.selected?.externalReceipts ? `合计 ¥${money(receiptTotal)} · ${cashLines.length} 条` : '金额暂不可用'}</span>
|
||||
</div>
|
||||
<p className="sd-panel__meta">当前租户全部外部客户,不按站点归属。现结自动汇总已包含在加氢业务中,不重复计入单站现结或收益;充值也不等于利润。</p>
|
||||
{!cashLines.length ? <p className="sd-panel__meta">{liveBoard?.selected?.externalReceipts ? '查询区间暂无客户进账记录' : '客户进账数据暂不可用:接口未返回新数据,请刷新或检查服务版本。'}</p> : null}
|
||||
<div className="sd-mobile-record-list sd-mobile-cash-list">
|
||||
{cashVisible.map((line) => (
|
||||
<article key={line.id} className="sd-mobile-record">
|
||||
<div className="sd-mobile-record__lead"><strong>{line.customerName}</strong><span>{line.bizDate}</span></div>
|
||||
<div className="sd-mobile-record__value"><strong className={businessValueClass(line.amount)}>¥{money(line.amount)}</strong><span className="sd-mobile-pay-tag">{SPOT_PAY_METHOD_LABEL[line.payMethod]}</span></div>
|
||||
<div className="sd-mobile-record__value"><strong className={businessValueClass(line.amount)}>¥{money(line.amount)}</strong><span className="sd-mobile-pay-tag">{line.payLabel}</span></div>
|
||||
<div className="sd-mobile-record__meta">{line.sourceLabel} · {line.sourceRecordCount} 条源记录{line.updatedAt ? ` · 更新 ${line.updatedAt}` : ''}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
@@ -852,14 +845,15 @@ export const StationDailyDetailView: React.FC<{
|
||||
<th>充值日期</th>
|
||||
<th>客户</th>
|
||||
<th>付款方式</th>
|
||||
<th>来源 / 源记录数</th>
|
||||
<th className="is-num">金额(元)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cashLines.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="ehb-empty-cell">
|
||||
本窗暂无进账明细
|
||||
<td colSpan={5} className="ehb-empty-cell">
|
||||
{liveBoard?.selected?.externalReceipts ? '本窗暂无进账明细' : '客户进账数据暂不可用'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -867,7 +861,8 @@ export const StationDailyDetailView: React.FC<{
|
||||
<tr key={l.id}>
|
||||
<td className="is-mono">{l.bizDate}</td>
|
||||
<td title={l.customerName}>{l.customerName}</td>
|
||||
<td>{SPOT_PAY_METHOD_LABEL[l.payMethod]}</td>
|
||||
<td>{l.payLabel}</td>
|
||||
<td>{l.sourceLabel} / {l.sourceRecordCount}</td>
|
||||
<td className={`is-num ${businessValueClass(l.amount)}`}>{money(l.amount)}</td>
|
||||
</tr>
|
||||
))
|
||||
@@ -879,6 +874,11 @@ export const StationDailyDetailView: React.FC<{
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{orderDate ? <PrototypeDrillModal kind="records"
|
||||
label={`${stationName} · ${orderDate} 加氢订单`}
|
||||
query={{ year: Number(orderDate.slice(0, 4)), startDate: orderDate, endDate: orderDate,
|
||||
date: orderDate, stationId, vehicleScope: 'all', verifyScope: 'all' }}
|
||||
onClose={() => setOrderDate(null)} /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import './station-mobile-lists.css';
|
||||
import {
|
||||
customerMonthTotal,
|
||||
customerMetricValue,
|
||||
formatKg,
|
||||
formatMoney,
|
||||
mobileCustomerRows,
|
||||
mobileDailyRows,
|
||||
monthLabel,
|
||||
type CustomerMetric,
|
||||
type StationDailyVolumeRow,
|
||||
type StationMonthlyCustomer,
|
||||
} from './station-mobile-list-model';
|
||||
|
||||
export type { CustomerMetric, StationDailyVolumeRow, StationMonthlyCustomer } from './station-mobile-list-model';
|
||||
|
||||
export interface MobileDailyListProps {
|
||||
/** The selected near-seven-day rows; `allRows` remains the source for actual prior-day comparison. */
|
||||
rows: StationDailyVolumeRow[];
|
||||
allRows: StationDailyVolumeRow[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onOpenOrders: (date: string) => void;
|
||||
}
|
||||
|
||||
export function MobileDailyList({ rows, allRows, loading = false, error = null, onOpenOrders }: MobileDailyListProps) {
|
||||
const dailyRows = useMemo(() => mobileDailyRows(rows, allRows), [rows, allRows]);
|
||||
if (loading) return <section className="sd-mobile-list-pilot" aria-busy="true">日报正在加载…</section>;
|
||||
if (error) return <section className="sd-mobile-list-pilot sd-mobile-list__state is-error" role="alert">日报加载失败:{error}</section>;
|
||||
if (!dailyRows.length) return <section className="sd-mobile-list-pilot sd-mobile-list__state">暂无日报数据</section>;
|
||||
|
||||
return <section className="sd-mobile-list-pilot" aria-label="近七日日报">
|
||||
<p className="sd-mobile-list__hint">最新日期在前 · 点日期展开车次、单价和订单</p>
|
||||
<div className="sd-mobile-list__head sd-mobile-daily__head"><span>日期</span><span>加氢量</span><span>金额</span></div>
|
||||
<div className="sd-mobile-list__rows">
|
||||
{dailyRows.map((row) => {
|
||||
const hasRecord = row.vehicleCount > 0;
|
||||
const delta = row.previousKg == null ? '无前日数据' : `${row.quantityKg - row.previousKg >= 0 ? '+' : ''}${formatKg(row.quantityKg - row.previousKg)} kg`;
|
||||
return <details className="sd-mobile-list__detail" key={row.date}>
|
||||
<summary className="sd-mobile-daily__row">
|
||||
<span><strong>{row.date.slice(5)}</strong><small>{row.date.slice(0, 4)}</small></span>
|
||||
<span className={hasRecord ? '' : 'is-zero'}><strong>{formatKg(row.quantityKg)}</strong><small>kg</small></span>
|
||||
<span className={hasRecord ? '' : 'is-zero'}><strong>¥{formatMoney(row.amountYuan)}</strong>{!hasRecord && <small>无记录</small>}</span>
|
||||
</summary>
|
||||
<div className="sd-mobile-list__expanded">
|
||||
<span>车次:{row.vehicleCount} 次</span><span>单价:¥{formatMoney(row.unitPrice)}/kg</span><span>较昨日:{delta}</span>
|
||||
<button type="button" onClick={() => onOpenOrders(row.date)}>查看当天订单</button>
|
||||
</div>
|
||||
</details>;
|
||||
})}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export interface MobileCustomerMonthListProps {
|
||||
/** Continuous YYYY-MM values, including months with no business records. */
|
||||
months: string[];
|
||||
volumeCustomers: StationMonthlyCustomer[];
|
||||
feeCustomers: StationMonthlyCustomer[];
|
||||
month: string;
|
||||
onMonthChange: (month: string) => void;
|
||||
metric: CustomerMetric;
|
||||
onMetricChange: (metric: CustomerMetric) => void;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function MobileCustomerMonthList({ months, volumeCustomers, feeCustomers, month, onMonthChange, metric, onMetricChange, loading = false, error = null }: MobileCustomerMonthListProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const customers = metric === 'volume' ? volumeCustomers : feeCustomers;
|
||||
const sortedCustomers = useMemo(() => mobileCustomerRows(customers, month, search), [customers, month, search]);
|
||||
const total = useMemo(() => customerMonthTotal(customers, month), [customers, month]);
|
||||
if (loading) return <section className="sd-mobile-list-pilot" aria-busy="true">客户月度数据正在加载…</section>;
|
||||
if (error) return <section className="sd-mobile-list-pilot sd-mobile-list__state is-error" role="alert">客户月度加载失败:{error}</section>;
|
||||
|
||||
return <section className="sd-mobile-list-pilot" aria-label="客户月度排行">
|
||||
<div className="sd-mobile-customer__controls">
|
||||
<select aria-label="选择月份" value={month} onChange={(event) => onMonthChange(event.target.value)}>
|
||||
{months.map((item) => <option value={item} key={item}>{monthLabel(item)}</option>)}
|
||||
</select>
|
||||
<div className="sd-mobile-customer__metric" aria-label="选择指标">
|
||||
<button type="button" aria-pressed={metric === 'volume'} className={metric === 'volume' ? 'is-active' : ''} onClick={() => onMetricChange('volume')}>数量</button>
|
||||
<button type="button" aria-pressed={metric === 'fee'} className={metric === 'fee' ? 'is-active' : ''} onClick={() => onMetricChange('fee')}>金额</button>
|
||||
</div>
|
||||
<strong>{monthLabel(month)}合计:{metric === 'volume' ? `${formatKg(total)} kg` : `¥${formatMoney(total)}`}</strong>
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索全部客户" aria-label="搜索全部客户" />
|
||||
</div>
|
||||
<p className="sd-mobile-list__hint">点客户展开近 12 个月 · 合计不随搜索缩减</p>
|
||||
{!sortedCustomers.length ? <p className="sd-mobile-list__state">{search ? '未找到匹配客户' : '该月暂无客户数据'}</p> : <div className="sd-mobile-list__rows">
|
||||
{sortedCustomers.map((customer) => <details className="sd-mobile-list__detail sd-mobile-customer__detail" key={customer.customerName}>
|
||||
<summary><span>{customer.customerName}</span><strong className={customerMetricValue(customer, month) === 0 ? 'is-zero' : ''}>{metric === 'volume' ? `${formatKg(customerMetricValue(customer, month))} kg` : `¥${formatMoney(customerMetricValue(customer, month))}`}</strong></summary>
|
||||
<div className="sd-mobile-customer__trend" aria-label={`${customer.customerName}十二个月趋势`}>
|
||||
{months.map((item) => {
|
||||
const value = customerMetricValue(customer, item);
|
||||
const max = Math.max(...months.map((key) => customerMetricValue(customer, key)), 0);
|
||||
const width = max > 0 ? `${(value / max) * 100}%` : '0%';
|
||||
return <div key={item}><span>{item}</span><i aria-hidden style={{ width }} className={value === 0 ? 'is-zero' : ''} /><strong>{metric === 'volume' ? `${formatKg(value)} kg` : `¥${formatMoney(value)}`}</strong></div>;
|
||||
})}
|
||||
</div>
|
||||
</details>)}
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { customerMonthTotal, formatMoney, mobileCustomerRows, mobileDailyRows } from './station-mobile-list-model';
|
||||
|
||||
test('日列表倒序且从全量查询读取实际前日值', () => {
|
||||
const rows = [{ date: '2026-08-11', quantityKg: 9, amountYuan: 90, vehicleCount: 1, unitPrice: 10 }];
|
||||
const result = mobileDailyRows(rows, [...rows, { date: '2026-08-10', quantityKg: 7, amountYuan: 70, vehicleCount: 1, unitPrice: 10 }]);
|
||||
assert.equal(result[0].previousKg, 7);
|
||||
});
|
||||
|
||||
test('客户按当前月指标排序,搜索覆盖全部客户且零值保持零', () => {
|
||||
const customers = [
|
||||
{ customerName: '甲运输', months: { '2026-08': 0 } },
|
||||
{ customerName: '乙物流', months: { '2026-08': 12 } },
|
||||
];
|
||||
assert.deepEqual(mobileCustomerRows(customers, '2026-08', '').map((item) => item.customerName), ['乙物流', '甲运输']);
|
||||
assert.equal(customerMonthTotal(customers, '2026-08'), 12);
|
||||
assert.deepEqual(mobileCustomerRows(customers, '2026-08', '甲').map((item) => item.customerName), ['甲运输']);
|
||||
});
|
||||
|
||||
test('日报区分真实前日零和缺失前日,倒序展示不改变源数组', () => {
|
||||
const rows = [
|
||||
{ date: '2026-03-01', quantityKg: 9, amountYuan: 90, vehicleCount: 1, unitPrice: 10 },
|
||||
{ date: '2026-03-02', quantityKg: 0, amountYuan: 0, vehicleCount: 0, unitPrice: 0 },
|
||||
];
|
||||
const result = mobileDailyRows(rows, [...rows, { date: '2026-02-28', quantityKg: 0, amountYuan: 0, vehicleCount: 0, unitPrice: 0 }]);
|
||||
assert.deepEqual(result.map(row => row.date), ['2026-03-02', '2026-03-01']);
|
||||
assert.equal(result[1].previousKg, 0);
|
||||
assert.equal(mobileDailyRows(rows, rows)[1].previousKg, null);
|
||||
assert.equal(rows[0].date, '2026-03-01');
|
||||
});
|
||||
|
||||
test('货币统一保留两位小数,包括真实零', () => {
|
||||
assert.equal(formatMoney(0), '0.00');
|
||||
assert.equal(formatMoney(1234.5), '1,234.50');
|
||||
});
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/** Mobile list derivations are deliberately data-only so the view never invents zeroes. */
|
||||
export interface StationDailyVolumeRow {
|
||||
date: string;
|
||||
quantityKg: number;
|
||||
amountYuan: number;
|
||||
vehicleCount: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export interface StationMonthlyCustomer {
|
||||
customerName: string;
|
||||
months: Record<string, number>;
|
||||
}
|
||||
|
||||
export type CustomerMetric = 'volume' | 'fee';
|
||||
|
||||
export function formatKg(value: number): string {
|
||||
return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export function formatMoney(value: number): string {
|
||||
return Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/** Latest first; allRows supplies the true previous-day quantity even outside the seven-day view. */
|
||||
export function mobileDailyRows(rows: StationDailyVolumeRow[], allRows: StationDailyVolumeRow[]) {
|
||||
const quantitiesByDate = new Map(allRows.map((row) => [row.date, row.quantityKg]));
|
||||
return [...rows]
|
||||
.sort((left, right) => right.date.localeCompare(left.date))
|
||||
.map((row) => {
|
||||
const previous = new Date(`${row.date}T00:00:00Z`);
|
||||
previous.setUTCDate(previous.getUTCDate() - 1);
|
||||
const previousDate = previous.toISOString().slice(0, 10);
|
||||
const previousKg = quantitiesByDate.get(previousDate);
|
||||
return { ...row, previousKg: Number.isFinite(previousKg) ? previousKg : null };
|
||||
});
|
||||
}
|
||||
|
||||
export function customerMetricValue(customer: StationMonthlyCustomer, month: string): number {
|
||||
const value = customer.months[month];
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
export function mobileCustomerRows(
|
||||
customers: StationMonthlyCustomer[],
|
||||
month: string,
|
||||
search: string,
|
||||
): StationMonthlyCustomer[] {
|
||||
const keyword = search.trim().toLocaleLowerCase('zh-CN');
|
||||
return customers
|
||||
.filter((customer) => !keyword || customer.customerName.toLocaleLowerCase('zh-CN').includes(keyword))
|
||||
.sort((left, right) => customerMetricValue(right, month) - customerMetricValue(left, month)
|
||||
|| left.customerName.localeCompare(right.customerName, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function customerMonthTotal(customers: StationMonthlyCustomer[], month: string): number {
|
||||
return customers.reduce((total, customer) => total + customerMetricValue(customer, month), 0);
|
||||
}
|
||||
|
||||
export function monthLabel(month: string): string {
|
||||
const matched = /^(\d{4})-(\d{2})$/.exec(month);
|
||||
return matched ? `${matched[1]}年${Number(matched[2])}月` : month;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/* Pilot is intentionally isolated: the host chooses where to render it. */
|
||||
.sd-mobile-list-pilot { display: none; }
|
||||
.sd-mobile-list__hint { margin: 4px 0 8px; color: #64748b; font-size: 11px; line-height: 1.5; }
|
||||
@media (max-width: 767px) {
|
||||
.sd-mobile-list-pilot { display: block; box-sizing: border-box; width: 100%; color: #24344e; font-family: var(--sd-font, -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif); }
|
||||
.sd-mobile-list__head, .sd-mobile-daily__row { display: grid; grid-template-columns: .82fr .9fr 1.28fr; align-items: center; gap: 7px; }
|
||||
.sd-mobile-list__head { padding: 0 2px 6px; color: #71819a; font-size: 10px; }
|
||||
.sd-mobile-list__head span:not(:first-child), .sd-mobile-daily__row > span:not(:first-child) { text-align: right; }
|
||||
.sd-mobile-list__detail { border-top: 1px solid #edf1f6; }
|
||||
.sd-mobile-list__detail summary { list-style: none; cursor: pointer; min-height: 48px; }
|
||||
.sd-mobile-list__detail summary::-webkit-details-marker { display: none; }
|
||||
.sd-mobile-daily__row > span { display: flex; min-width: 0; align-items: baseline; justify-content: flex-end; gap: 3px; }
|
||||
.sd-mobile-daily__row > span:first-child { justify-content: flex-start; }
|
||||
.sd-mobile-daily__row strong { color: #1e3556; font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.sd-mobile-daily__row small { color: #75859c; font-size: 9px; }
|
||||
.sd-mobile-list-pilot .is-zero { color: #94a3b8; }
|
||||
.sd-mobile-list__expanded { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 0 0 10px; color: #64748b; font-size: 11px; }
|
||||
.sd-mobile-list__expanded span:last-of-type { grid-column: 1 / -1; }
|
||||
.sd-mobile-list__expanded button { grid-column: 1 / -1; min-height: 44px; border: 1px solid #bfdbfe; border-radius: 8px; background: #eff6ff; color: #2563eb; font: inherit; font-weight: 700; }
|
||||
.sd-mobile-list__state { margin: 0; color: #64748b; font-size: 12px; text-align: center; }
|
||||
.sd-mobile-list__state.is-error { color: #b42318; }
|
||||
.sd-mobile-customer__controls { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.sd-mobile-customer__controls select, .sd-mobile-customer__controls input, .sd-mobile-customer__metric button { min-width: 0; min-height: 44px; border: 1px solid #d8e2ef; border-radius: 8px; background: #fff; color: #34445f; font: inherit; font-size: 12px; }
|
||||
.sd-mobile-customer__metric { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
|
||||
.sd-mobile-customer__metric button.is-active { border-color: #2563eb; background: #eff6ff; color: #2563eb; font-weight: 700; }
|
||||
.sd-mobile-customer__controls > strong, .sd-mobile-customer__controls input { grid-column: 1 / -1; }
|
||||
.sd-mobile-customer__controls > strong { color: #1e3556; font-size: 12px; }
|
||||
.sd-mobile-customer__controls input { box-sizing: border-box; padding: 0 10px; }
|
||||
.sd-mobile-customer__detail summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; }
|
||||
.sd-mobile-customer__detail summary span { display: -webkit-box; overflow: hidden; color: #263650; font-size: 12px; font-weight: 650; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.sd-mobile-customer__detail summary strong { color: #1e3556; font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.sd-mobile-customer__trend { display: grid; gap: 4px; padding: 0 0 10px; }
|
||||
.sd-mobile-customer__trend > div { display: grid; grid-template-columns: 50px minmax(0, 1fr) 82px; align-items: center; gap: 6px; color: #71819a; font-size: 10px; }
|
||||
.sd-mobile-customer__trend i { display: block; height: 5px; min-width: 0; border-radius: 99px; background: #60a5fa; }
|
||||
.sd-mobile-customer__trend i.is-zero { width: 0 !important; }
|
||||
.sd-mobile-customer__trend strong { color: #51627b; font-size: 10px; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
}
|
||||
+7
-1
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { customerMonthLabel, customerMonthRange } from './station-month-range';
|
||||
import { customerMonthLabel, customerMonthRange, dateRangeLabel, inclusiveDayCount } from './station-month-range';
|
||||
|
||||
test('客户月度范围保持截至结束日的连续 12 个月,并包含无数据的 5 月', () => {
|
||||
assert.deepEqual(customerMonthRange('2026-08-31'), [
|
||||
@@ -14,3 +14,9 @@ test('完整年月键不会随截止月份漂移', () => {
|
||||
assert.deepEqual(customerMonthRange('2027-03-01', 3), ['2027-01', '2027-02', '2027-03']);
|
||||
assert.equal(customerMonthLabel('2026-05'), '2026年5月');
|
||||
});
|
||||
|
||||
test('日期区间文案反映闭区间实际天数', () => {
|
||||
assert.equal(inclusiveDayCount('2026-08-01', '2026-08-15'), 15);
|
||||
assert.equal(dateRangeLabel('2026-08-01', '2026-08-15'), '2026-08-01 至 2026-08-15(共 15 天)');
|
||||
assert.equal(inclusiveDayCount('2026-08-15', '2026-08-01'), 0);
|
||||
});
|
||||
|
||||
+13
@@ -22,3 +22,16 @@ export function customerMonthLabel(monthKey: string): string {
|
||||
const matched = /^(\d{4})-(\d{2})$/.exec(monthKey);
|
||||
return matched ? `${matched[1]}年${Number(matched[2])}月` : monthKey;
|
||||
}
|
||||
|
||||
/** 闭区间日数;用于让卡片副标题与实际查询口径保持一致。 */
|
||||
export function inclusiveDayCount(start: string, end: string): number {
|
||||
const startTime = Date.parse(`${start}T00:00:00Z`);
|
||||
const endTime = Date.parse(`${end}T00:00:00Z`);
|
||||
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) return 0;
|
||||
return Math.floor((endTime - startTime) / 86_400_000) + 1;
|
||||
}
|
||||
|
||||
export function dateRangeLabel(start: string, end: string): string {
|
||||
const days = inclusiveDayCount(start, end);
|
||||
return days > 0 ? `${start} 至 ${end}(共 ${days} 天)` : `${start} 至 ${end}`;
|
||||
}
|
||||
|
||||
@@ -288,6 +288,11 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel > .sd-panel__head-row.sd-external-receipt-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sd-panel__head-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -2765,6 +2770,77 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
/* Mobile list pilot: compact reading first; original tables remain for reconciliation. */
|
||||
@media (max-width: 767px) {
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub {
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-tabs {
|
||||
display: block !important;
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-tabs__title {
|
||||
font-size: 14px;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-tabs .mobile-list-fullscreen-trigger {
|
||||
min-height: 44px;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel:not(.is-active),
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-trend-panel,
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel > .sd-table-scroll,
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel > .sd-panel__head-row,
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel > .sd-more-btn {
|
||||
display: none;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel.is-active {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-dual--ledger { display: contents; }
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel > .sd-panel__title {
|
||||
padding: 0 0 8px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
/* Non-pilot settlement/intake panels keep their existing truthful tables. */
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-dual--ledger .sd-table-scroll {
|
||||
display: block;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub:not([data-mobile-fullscreen-active="true"]) .sd-external-receipts > .sd-table-scroll {
|
||||
display: none;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub:not([data-mobile-fullscreen-active="true"]) .sd-external-receipts > .sd-mobile-record-list {
|
||||
display: grid !important;
|
||||
gap: 8px;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-external-receipts > .sd-more-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-list-pilot {
|
||||
display: none !important;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-detail-panel.is-active {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-detail-panel.is-active > .sd-table-scroll,
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-detail-panel.is-active > .sd-panel__head-row,
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-detail-panel.is-active > .sd-more-btn {
|
||||
display: block;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-combined-fullscreen-table {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sd-live-loading-overlay {
|
||||
position: fixed;
|
||||
z-index: 480;
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ steps:
|
||||
- main
|
||||
commands: |
|
||||
cd $CI_WORKSPACE
|
||||
npm ci
|
||||
npm ci --registry=https://registry.npmmirror.com/
|
||||
npm run lint
|
||||
npm test
|
||||
npm run build
|
||||
|
||||
Reference in New Issue
Block a user