From 8c31cecb3713a2070bdbf118572dee0a2235f0ce Mon Sep 17 00:00:00 2001 From: OuYingJun1024 <1034284404@qq.com> Date: Sun, 7 Jun 2026 14:14:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=E9=A6=96=E9=A1=B5=E8=BD=AE?= =?UTF-8?q?=E6=92=AD=E7=A7=8D=E5=AD=90=E7=AE=A1=E7=90=86=20+=20=E4=B8=89?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E9=85=8D=E7=BD=AE=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据大盘新增/增强两块运营配置: - 首页轮播种子(新):列表/新增/编辑/批量生成/预览效果;用户名可空(留空自动合成)、金额区间(min~max) - 首页数据配置(增强):自增长「百分比/固定增量」切换、真实值基数偏移、「允许数字下降」(只增不减)开关、下次预计值、立即更新二次确认;倍率上限 1.5 - 依赖 @ant-design/v5-patch-for-react-19(React 19 兼容,providers 顶部 import) - start.sh/stop.sh 跨平台重写;.run-logs/ 加入 .gitignore Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + package-lock.json | 186 +++---- package.json | 2 + src/app/(main)/dashboard/HomeMarqueeSeeds.tsx | 366 +++++++++++++ src/app/(main)/dashboard/HomeStatsConfig.tsx | 501 ++++++++++++++++++ src/app/(main)/dashboard/page.tsx | 4 + src/app/providers.tsx | 4 + start.sh | 124 ++++- stop.sh | 48 +- 9 files changed, 1091 insertions(+), 145 deletions(-) create mode 100644 src/app/(main)/dashboard/HomeMarqueeSeeds.tsx create mode 100644 src/app/(main)/dashboard/HomeStatsConfig.tsx diff --git a/.gitignore b/.gitignore index 416e4c4..c75d099 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ out next-env.d.ts .DS_Store data/ +.run-logs/ diff --git a/package-lock.json b/package-lock.json index 49fb495..a0bf487 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,10 @@ "name": "shaguabijia-admin-web", "version": "0.1.0", "dependencies": { + "@ant-design/cssinjs": "^1.24.0", "@ant-design/icons": "^5.5.2", "@ant-design/nextjs-registry": "^1.0.2", + "@ant-design/v5-patch-for-react-19": "^1.0.3", "antd": "^5.22.0", "axios": "^1.7.7", "dayjs": "^1.11.13", @@ -34,18 +36,17 @@ } }, "node_modules/@ant-design/cssinjs": { - "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", - "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "version": "1.24.0", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", - "@rc-component/util": "^1.4.0", - "clsx": "^2.1.1", + "classnames": "^2.3.1", "csstype": "^3.1.3", + "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { @@ -68,25 +69,6 @@ "react-dom": ">=16.9.0" } }, - "node_modules/@ant-design/cssinjs-utils/node_modules/@ant-design/cssinjs": { - "version": "1.24.0", - "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", - "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.1", - "@emotion/hash": "^0.8.0", - "@emotion/unitless": "^0.7.5", - "classnames": "^2.3.1", - "csstype": "^3.1.3", - "rc-util": "^5.35.0", - "stylis": "^4.3.4" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" - } - }, "node_modules/@ant-design/fast-color": { "version": "2.0.6", "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-2.0.6.tgz", @@ -154,6 +136,20 @@ "react": ">=16.9.0" } }, + "node_modules/@ant-design/v5-patch-for-react-19": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@ant-design/v5-patch-for-react-19/-/v5-patch-for-react-19-1.0.3.tgz", + "integrity": "sha512-iWfZuSUl5kuhqLUw7jJXUQFMMkM7XpW7apmKzQBQHU0cpifYW4A79xIBt9YVO5IBajKpPG5UKP87Ft7Yrw1p/w==", + "license": "MIT", + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "antd": ">=5.22.6", + "react": ">=19.0.0", + "react-dom": ">=19.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", @@ -829,26 +825,10 @@ "node": ">= 10" } }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.19", - "resolved": "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz", - "integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@rc-component/async-validator": { - "version": "5.1.0", - "resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-5.1.0.tgz", - "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-5.1.1.tgz", + "integrity": "sha512-T03+Wk31Kz/28OC+rLlHtSNwD5Io3OWw6rPFPAp898sqALB/XnTrr3trB3mPoj379v0aRaW6t09HUG6dUyHR3g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.24.4" @@ -888,9 +868,9 @@ } }, "node_modules/@rc-component/mini-decimal": { - "version": "1.1.3", - "resolved": "https://registry.npmmirror.com/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz", - "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz", + "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.0" @@ -936,9 +916,9 @@ } }, "node_modules/@rc-component/qrcode": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/@rc-component/qrcode/-/qrcode-1.1.1.tgz", - "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@rc-component/qrcode/-/qrcode-1.1.2.tgz", + "integrity": "sha512-CTXG18eP3sO3gc+96ep9HyVI/RzMup7L59apM/D0wWo1SHRdwOb7xyD4bMbmpu4dPlTch59Kxb8lU7U9ME60fg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.24.7" @@ -992,21 +972,6 @@ "react-dom": ">=16.9.0" } }, - "node_modules/@rc-component/util": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@rc-component/util/-/util-1.11.1.tgz", - "integrity": "sha512-awVlI3ub2vqfqkYxOBc/uQ0efm3jw0wcrhtO/YWLyZfxiKXczKwNbVuhlnyxytDt7H9pbbVQiqr+O6MLATtRYg==", - "license": "MIT", - "peer": true, - "dependencies": { - "is-mobile": "^5.0.0", - "react-is": "^18.2.0" - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - } - }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1017,9 +982,9 @@ } }, "node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "version": "22.19.20", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.20.tgz", + "integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==", "dev": true, "license": "MIT", "dependencies": { @@ -1034,9 +999,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.30", - "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.30.tgz", - "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==", + "version": "18.3.31", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "dev": true, "license": "MIT", "dependencies": { @@ -1131,25 +1096,6 @@ "react-dom": ">=16.9.0" } }, - "node_modules/antd/node_modules/@ant-design/cssinjs": { - "version": "1.24.0", - "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", - "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.1", - "@emotion/hash": "^0.8.0", - "@emotion/unitless": "^0.7.5", - "classnames": "^2.3.1", - "csstype": "^3.1.3", - "rc-util": "^5.35.0", - "stylis": "^4.3.4" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", @@ -1182,9 +1128,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001797", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", "funding": [ { "type": "opencollective", @@ -1213,16 +1159,6 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1503,13 +1439,6 @@ "node": ">= 6" } }, - "node_modules/is-mobile": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/is-mobile/-/is-mobile-5.0.0.tgz", - "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", - "license": "MIT", - "peer": true - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1643,6 +1572,22 @@ } } }, + "node_modules/next/node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.19", + "resolved": "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz", + "integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -2348,9 +2293,9 @@ } }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", "license": "ISC", "optional": true, "bin": { @@ -2490,6 +2435,21 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.19", + "resolved": "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz", + "integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/package.json b/package.json index 5776161..c09af8a 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,10 @@ "lint": "next lint" }, "dependencies": { + "@ant-design/cssinjs": "^1.24.0", "@ant-design/icons": "^5.5.2", "@ant-design/nextjs-registry": "^1.0.2", + "@ant-design/v5-patch-for-react-19": "^1.0.3", "antd": "^5.22.0", "axios": "^1.7.7", "dayjs": "^1.11.13", diff --git a/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx b/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx new file mode 100644 index 0000000..8b18666 --- /dev/null +++ b/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx @@ -0,0 +1,366 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + Button, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Space, + Spin, + Switch, + Table, + message, +} from 'antd'; +import { api, errMsg } from '@/lib/api'; + +interface Seed { + id: number; + masked_user: string | null; + min_cents: number; + max_cents: number; + enabled: boolean; + sort_order: number; + created_at: string | null; +} + +interface FormVals { + masked_user?: string; + min_yuan: number; + max_yuan: number; + sort_order: number; + enabled: boolean; +} + +interface BulkVals { + count: number; + min_yuan: number; + max_yuan: number; + enabled: boolean; +} + +interface PreviewItem { + masked_user: string; + saved_amount_cents: number; + time: string; +} + +const yuan = (cents: number) => (cents / 100).toFixed(2); + +/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「数据大盘」页的一个区块。 */ +export default function HomeMarqueeSeeds() { + const [seeds, setSeeds] = useState([]); + const [loading, setLoading] = useState(true); + + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const [bulkOpen, setBulkOpen] = useState(false); + const [bulkForm] = Form.useForm(); + + const [previewOpen, setPreviewOpen] = useState(false); + const [previewItems, setPreviewItems] = useState([]); + const [previewLoading, setPreviewLoading] = useState(false); + + const load = async () => { + setLoading(true); + try { + const { data } = await api.get('/admin/api/marquee-seeds'); + setSeeds(data); + } finally { + setLoading(false); + } + }; + useEffect(() => { + load(); + }, []); + + // ===== 单条 新增 / 编辑 ===== + const openAdd = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ masked_user: '', min_yuan: 5, max_yuan: 20, sort_order: 0, enabled: true }); + setModalOpen(true); + }; + const openEdit = (s: Seed) => { + setEditing(s); + form.resetFields(); + form.setFieldsValue({ + masked_user: s.masked_user ?? '', + min_yuan: s.min_cents / 100, + max_yuan: s.max_cents / 100, + sort_order: s.sort_order, + enabled: s.enabled, + }); + setModalOpen(true); + }; + const submit = async () => { + const v = await form.validateFields(); + const body = { + masked_user: (v.masked_user ?? '').trim(), // 空串 → 后端清空为「自动合成」 + min_cents: Math.round(v.min_yuan * 100), + max_cents: Math.round(v.max_yuan * 100), + sort_order: v.sort_order ?? 0, + enabled: v.enabled, + }; + try { + if (editing) await api.patch(`/admin/api/marquee-seeds/${editing.id}`, body); + else await api.post('/admin/api/marquee-seeds', body); + message.success('已保存,客户端下次进首页生效'); + setModalOpen(false); + load(); + } catch (e) { + message.error(errMsg(e)); + } + }; + + // ===== 批量生成 ===== + const openBulk = () => { + bulkForm.resetFields(); + bulkForm.setFieldsValue({ count: 10, min_yuan: 5, max_yuan: 30, enabled: true }); + setBulkOpen(true); + }; + const submitBulk = async () => { + const v = await bulkForm.validateFields(); + try { + const { data } = await api.post('/admin/api/marquee-seeds/bulk', { + count: v.count, + min_cents: Math.round(v.min_yuan * 100), + max_cents: Math.round(v.max_yuan * 100), + enabled: v.enabled, + }); + message.success(`已生成 ${data.length} 条种子`); + setBulkOpen(false); + load(); + } catch (e) { + message.error(errMsg(e)); + } + }; + + // ===== 预览实际 feed ===== + const fetchPreview = async () => { + setPreviewLoading(true); + try { + const { data } = await api.get<{ items: PreviewItem[] }>('/admin/api/marquee-seeds/preview?limit=8'); + setPreviewItems(data.items); + } catch (e) { + message.error(errMsg(e)); + } finally { + setPreviewLoading(false); + } + }; + const openPreview = () => { + setPreviewOpen(true); + fetchPreview(); + }; + + const toggle = async (s: Seed, enabled: boolean) => { + try { + await api.patch(`/admin/api/marquee-seeds/${s.id}`, { enabled }); + setSeeds((prev) => prev.map((x) => (x.id === s.id ? { ...x, enabled } : x))); + } catch (e) { + message.error(errMsg(e)); + } + }; + const remove = async (s: Seed) => { + try { + await api.delete(`/admin/api/marquee-seeds/${s.id}`); + message.success('已删除'); + load(); + } catch (e) { + message.error(errMsg(e)); + } + }; + + const columns = [ + { + title: '用户名', + dataIndex: 'masked_user', + render: (u: string | null) => + u ? u : (自动生成), + }, + { + title: '节省(元)', + width: 150, + render: (_: unknown, s: Seed) => + s.min_cents === s.max_cents + ? yuan(s.min_cents) + : `${yuan(s.min_cents)} ~ ${yuan(s.max_cents)}`, + }, + { title: '排序', dataIndex: 'sort_order', width: 70 }, + { + title: '启用', + dataIndex: 'enabled', + width: 80, + render: (_: boolean, s: Seed) => ( + toggle(s, v)} /> + ), + }, + { + title: '操作', + width: 130, + render: (_: unknown, s: Seed) => ( + + openEdit(s)}>编辑 + remove(s)}> + 删除 + + + ), + }, + ]; + + // 金额上限不得小于下限(下限字段名) + const maxGteMin = (minField: string) => ({ getFieldValue }: { getFieldValue: (n: string) => number }) => ({ + validator(_: unknown, value: number) { + const min = getFieldValue(minField); + if (value == null || min == null || value >= min) return Promise.resolve(); + return Promise.reject(new Error('上限不能小于下限')); + }, + }); + + return ( +
+

首页轮播种子

+

+ 首页「用户xxx 比价后节省xx元」轮播的兜底假数据。真实比价记录不足时随机抽取这些补齐;停用的不参与混播。 + 用户名留空则每次展示随机合成;金额为区间则每次随机取值。 +

+ + + + + + + + {/* 新增 / 编辑 */} + setModalOpen(false)} + forceRender + > +
+ + + + + + + + + + + +

固定金额则上下限填一样;区间则每次轮播在区间内随机取值。

+ + + + + + + +
+ + {/* 批量生成 */} + setBulkOpen(false)} + forceRender + > +

+ 一次生成多条「自动生成用户名」的种子(冷启动铺量用)。金额均为下方区间,每条展示时各自随机取值。 +

+
+ + + + + + + + + + + + + + + +
+ + {/* 预览实际 feed */} + setPreviewOpen(false)} + footer={ + + + + + } + > +

+ 客户端实际会看到的混播结果:真实记录会插队,种子随机抽取、金额/用户名每次都不同。点「换一批」可重新随机。 +

+ +
+ {previewItems.map((it, i) => ( +
+ + {it.masked_user} 比价后节省{' '} + {yuan(it.saved_amount_cents)} 元 + + {it.time} +
+ ))} +
+
+
+ + ); +} diff --git a/src/app/(main)/dashboard/HomeStatsConfig.tsx b/src/app/(main)/dashboard/HomeStatsConfig.tsx new file mode 100644 index 0000000..0b446b2 --- /dev/null +++ b/src/app/(main)/dashboard/HomeStatsConfig.tsx @@ -0,0 +1,501 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + Button, + Card, + Checkbox, + Col, + InputNumber, + Popconfirm, + Radio, + Row, + Select, + Space, + Spin, + Tag, + Tooltip, + message, +} from 'antd'; +import { api, errMsg } from '@/lib/api'; + +interface StatItem { + metric: string; // help_users / total_compares / total_saved + label: string; // 帮助用户 / 完成比价 / 累计节省 + unit: string; // 人 / 次 / 分 + mode: 'real' | 'manual' | 'random'; + manual_value: number | null; + random_mult_min: number; // 千分比 1000=1.0 + random_mult_max: number; + random_tick_seconds: number; + random_anchor_minutes: number; // 触发时刻对齐偏移(距 0 点分钟数) + random_kind: 'mult' | 'add'; // 自增长方式:×倍率 / +绝对增量 + random_step_min: number; // 绝对增量区间(基础单位) + random_step_max: number; + real_offset: number; // 真实值基数偏移(基础单位) + allow_decrease: boolean; // 是否允许展示值下降 + random_current: number | null; + random_last_tick_at: string | null; + real_value: number; // 当前真实值(基础单位:total_saved 为分;不含偏移) + updated_at: string | null; +} + +type IntervalUnit = 'minute' | 'hour' | 'day'; +const UNIT_SECONDS: Record = { minute: 60, hour: 3600, day: 86400 }; + +// 间隔秒数 → 数值 + 单位(取最大的整除单位) +function decomposeInterval(sec: number): { num: number; unit: IntervalUnit } { + if (sec % 86400 === 0) return { num: sec / 86400, unit: 'day' }; + if (sec % 3600 === 0) return { num: sec / 3600, unit: 'hour' }; + return { num: Math.max(1, Math.round(sec / 60)), unit: 'minute' }; +} + +// 每个指标的本地编辑态(均以「展示单位」存:倍率用浮点,total_saved 用元) +interface Edit { + mode: 'real' | 'manual' | 'random'; + manual: number | null; // 展示单位 + realOffset: number; // 真实值偏移(展示单位) + growthKind: 'mult' | 'add'; // 自增长方式 + multMin: number; // 1.0 ~ + multMax: number; + stepMin: number; // 绝对增量下限(展示单位) + stepMax: number; + intervalNum: number; // 更新间隔数值 + intervalUnit: IntervalUnit; // 分钟/小时/天 + anchorHour: number; // 单位=天时的「每日时刻」时(0-23) + anchorMinute: number; // 天:每日时刻分 / 小时:每小时第几分(0-59) + allowDecrease: boolean; // 允许展示值下降(默认 false) + initial: number | null; // 切 random 的初始基数(展示单位),留空=用真实值播种 +} + +const isMoney = (metric: string) => metric === 'total_saved'; +const dispUnit = (metric: string) => (isMoney(metric) ? '元' : ''); + +// 基础单位 → 展示单位 +function baseToDisp(metric: string, v: number | null): number | null { + if (v == null) return null; + return isMoney(metric) ? v / 100 : v; +} +// 展示单位 → 基础单位(整数) +function dispToBase(metric: string, v: number | null): number | null { + if (v == null) return null; + return isMoney(metric) ? Math.round(v * 100) : Math.round(v); +} + +function toEdit(it: StatItem): Edit { + const { num, unit } = decomposeInterval(it.random_tick_seconds); + const anchor = it.random_anchor_minutes || 0; + return { + mode: it.mode, + manual: baseToDisp(it.metric, it.manual_value), + realOffset: (baseToDisp(it.metric, it.real_offset) as number) ?? 0, + growthKind: it.random_kind === 'add' ? 'add' : 'mult', + multMin: it.random_mult_min / 1000, + multMax: it.random_mult_max / 1000, + stepMin: (baseToDisp(it.metric, it.random_step_min) as number) ?? 0, + stepMax: (baseToDisp(it.metric, it.random_step_max) as number) ?? 0, + intervalNum: num, + intervalUnit: unit, + anchorHour: Math.floor(anchor / 60), + anchorMinute: anchor % 60, + allowDecrease: it.allow_decrease, + initial: null, + }; +} + +function fmtReal(it: StatItem): string { + const v = baseToDisp(it.metric, it.real_value); + return isMoney(it.metric) ? `${(v as number).toFixed(1)} 元` : `${v} ${it.unit}`; +} + +function fmtDisp(metric: string, base: number, unit: string): string { + const v = baseToDisp(metric, base); + return `${v} ${dispUnit(metric) || unit}`; +} + +// 下次刷新「预计值」(基于已保存配置,经只增不减护栏):自增长给区间,真实/自定义给目标值。 +function predictNext(it: StatItem): string | null { + const cur = it.random_current; + if (cur == null) return null; + const u = dispUnit(it.metric) || it.unit; + const d = (v: number) => baseToDisp(it.metric, v); + if (it.mode === 'random') { + if (it.random_kind === 'add') { + return `${d(cur + it.random_step_min)} ~ ${d(cur + it.random_step_max)} ${u}`; + } + const lo = Math.floor((cur * it.random_mult_min) / 1000); + const hi = Math.floor((cur * it.random_mult_max) / 1000); + return `${d(lo)} ~ ${d(hi)} ${u}`; + } + const target = it.mode === 'real' ? it.real_value + it.real_offset : it.manual_value ?? cur; + const eff = !it.allow_decrease && target < cur ? cur : target; + return `${it.mode === 'real' ? '≈ ' : ''}${d(eff)} ${u}`; +} + +// 下个「更新时刻」(北京时间)文案:边界 = 北京时间 anchor + k*interval(与后端 _refresh 对齐) +function nextRefreshLabel(tickSeconds: number, anchorMinutes: number): string { + const BJ = 8 * 3600; + const interval = Math.max(60, tickSeconds); + const anchorSec = ((anchorMinutes * 60) % interval + interval) % interval; + const bjNow = Math.floor(Date.now() / 1000) + BJ; + const k = Math.floor((bjNow - anchorSec) / interval) + 1; + const nextUtcMs = (anchorSec + k * interval - BJ) * 1000; + return new Date(nextUtcMs).toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); +} + +/** 首页三门面数字(帮助用户/完成比价/累计节省)的展示模式配置。作为「数据大盘」页的一个区块。 */ +export default function HomeStatsConfig() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [edits, setEdits] = useState>({}); + const [saving, setSaving] = useState(null); + + // 首次加载:带 Spin,并初始化输入态 edits。 + const load = async () => { + setLoading(true); + try { + const { data } = await api.get('/admin/api/dashboard-display'); + setItems(data); + setEdits(Object.fromEntries(data.map((i) => [i.metric, toEdit(i)]))); + } finally { + setLoading(false); + } + }; + + // 静默刷新:只更新展示数据(items),不动 edits(不覆盖正在输入的内容)、不弹 Spin。 + const silentLoad = async () => { + try { + const { data } = await api.get('/admin/api/dashboard-display'); + setItems(data); + } catch { + /* 轮询失败忽略,下次再拉 */ + } + }; + + useEffect(() => { + load(); + const timer = setInterval(silentLoad, 3000); // 每 3s 静默刷新展示值 + return () => clearInterval(timer); + }, []); + + const patch = (metric: string, p: Partial) => + setEdits((s) => ({ ...s, [metric]: { ...s[metric], ...p } })); + + // immediate=true:点「立即更新」,保存配置 + 马上刷新展示值(不等更新钟点)。 + const save = async (it: StatItem, immediate = false) => { + const e = edits[it.metric]; + // 自定义不暴露间隔,固定为每天(每日「更新时间」刷新一次);其余模式用所选间隔。 + const intervalSec = + e.mode === 'manual' + ? 86400 + : Math.max(60, Math.round(e.intervalNum) * UNIT_SECONDS[e.intervalUnit]); + const anchorMin = e.anchorHour * 60 + e.anchorMinute; // 更新时间(全天 HH:MM),后端按间隔取相位 + const body: Record = { + mode: e.mode, + random_mult_min: Math.round(e.multMin * 1000), + random_mult_max: Math.round(e.multMax * 1000), + random_tick_seconds: intervalSec, + random_anchor_minutes: anchorMin, + random_kind: e.growthKind, + random_step_min: (dispToBase(it.metric, e.stepMin) as number) ?? 0, + random_step_max: (dispToBase(it.metric, e.stepMax) as number) ?? 0, + real_offset: (dispToBase(it.metric, e.realOffset) as number) ?? 0, + allow_decrease: e.allowDecrease, + }; + if (e.manual != null) body.manual_value = dispToBase(it.metric, e.manual) as number; + if (e.initial != null) body.random_initial = dispToBase(it.metric, e.initial) as number; + if (immediate) body.apply_now = true; + + setSaving(it.metric); + try { + // PATCH 返回更新后的该指标;只就地替换这一项 + 重置它的输入态,不整页 reload(消除闪烁)。 + const { data } = await api.patch(`/admin/api/dashboard-display/${it.metric}`, body); + setItems((prev) => prev.map((x) => (x.metric === it.metric ? data : x))); + setEdits((s) => ({ ...s, [it.metric]: toEdit(data) })); + if (immediate) { + message.success('已立即更新,客户端下次进首页即可见'); + } else { + const lastTickMs = data.random_last_tick_at ? new Date(data.random_last_tick_at).getTime() : 0; + const effectiveNow = Date.now() - lastTickMs < 10_000; + message.success( + effectiveNow + ? '已保存,客户端下次进首页即可见' + : `已保存,展示值将于北京时间 ${nextRefreshLabel(data.random_tick_seconds, data.random_anchor_minutes)} 刷新后生效`, + ); + } + } catch (err) { + message.error(errMsg(err)); + } finally { + setSaving(null); + } + }; + + return ( +
+

首页数据配置

+

+ 客户端首页三个门面数字。每个指标独立选模式:真实值(实时查库,可加基数偏移)/{' '} + 自定义(固定值)/ 自增长(到对齐钟点按倍率或固定增量增长)。 + 默认只增不减(防门面缩水);每次改动进审计日志。 +

+ {loading ? ( + + ) : ( + + {items.map((it) => { + const e = edits[it.metric]; + if (!e) return null; + const u = dispUnit(it.metric) || it.unit; + // manual 下调但未允许下降 → 提示会被护栏挡住 + const manualBlocked = + e.mode === 'manual' && + !e.allowDecrease && + e.manual != null && + it.random_current != null && + (dispToBase(it.metric, e.manual) as number) < it.random_current; + return ( +
+ + {it.label} + + 当前真实值:{fmtReal(it)} + + + } + style={{ height: '100%' }} + > + + patch(it.metric, { mode: ev.target.value })} + optionType="button" + buttonStyle="solid" + > + 真实值 + 自定义 + 自增长 + + + {e.mode === 'manual' && ( +
+ + 固定值: + patch(it.metric, { manual: v })} + style={{ width: 200 }} + /> + {u} + + {manualBlocked && ( +
+ 低于当前展示值,默认不下调(如需下调请勾「允许数字下降」) +
+ )} +
+ )} + + {e.mode === 'real' && ( + + 基数偏移: + patch(it.metric, { realOffset: v ?? 0 })} + style={{ width: 160 }} + /> + {u} + + + + + )} + + {e.mode === 'random' && ( + <> + + 增长方式: + patch(it.metric, { growthKind: ev.target.value })} + optionType="button" + > + 百分比 + 固定增量 + + + {e.growthKind === 'mult' ? ( + + 倍率区间: + patch(it.metric, { multMin: v ?? 1.0 })} + style={{ width: 90 }} + /> + ~ + patch(it.metric, { multMax: v ?? 1.0 })} + style={{ width: 90 }} + /> + + + + + ) : ( + + 每次新增: + patch(it.metric, { stepMin: v ?? 0 })} + style={{ width: 90 }} + /> + ~ + patch(it.metric, { stepMax: v ?? 0 })} + style={{ width: 90 }} + /> + {u} + + + + + )} + + )} + + {/* 到「更新时间」按「更新间隔」刷新一次。自定义不显示间隔(固定每天该时刻)。 */} + {e.mode !== 'manual' && ( + + 更新间隔:每 + patch(it.metric, { intervalNum: v ?? 1 })} + style={{ width: 80 }} + /> +