feat(dashboard): 首页轮播种子管理 + 三统计配置增强
数据大盘新增/增强两块运营配置: - 首页轮播种子(新):列表/新增/编辑/批量生成/预览效果;用户名可空(留空自动合成)、金额区间(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) <noreply@anthropic.com>
This commit is contained in:
@@ -6,3 +6,4 @@ out
|
||||
next-env.d.ts
|
||||
.DS_Store
|
||||
data/
|
||||
.run-logs/
|
||||
|
||||
Generated
+73
-113
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Seed[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Seed | null>(null);
|
||||
const [form] = Form.useForm<FormVals>();
|
||||
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [bulkForm] = Form.useForm<BulkVals>();
|
||||
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewItems, setPreviewItems] = useState<PreviewItem[]>([]);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<Seed[]>('/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<Seed[]>('/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 : <span style={{ color: '#bbb' }}>(自动生成)</span>,
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<Switch size="small" checked={s.enabled} onChange={(v) => toggle(s, v)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 130,
|
||||
render: (_: unknown, s: Seed) => (
|
||||
<Space>
|
||||
<a onClick={() => openEdit(s)}>编辑</a>
|
||||
<Popconfirm title="删除该种子?" onConfirm={() => remove(s)}>
|
||||
<a style={{ color: '#cf1322' }}>删除</a>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 金额上限不得小于下限(下限字段名)
|
||||
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 (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h3 style={{ marginBottom: 4 }}>首页轮播种子</h3>
|
||||
<p style={{ color: '#999', marginTop: 0 }}>
|
||||
首页「用户xxx 比价后节省xx元」轮播的兜底假数据。真实比价记录不足时随机抽取这些补齐;停用的不参与混播。
|
||||
用户名留空则每次展示随机合成;金额为区间则每次随机取值。
|
||||
</p>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button type="primary" size="small" onClick={openAdd}>
|
||||
新增种子
|
||||
</Button>
|
||||
<Button size="small" onClick={openBulk}>
|
||||
批量生成
|
||||
</Button>
|
||||
<Button size="small" onClick={openPreview}>
|
||||
预览效果
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={seeds}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
{/* 新增 / 编辑 */}
|
||||
<Modal
|
||||
title={editing ? '编辑种子' : '新增种子'}
|
||||
open={modalOpen}
|
||||
onOk={submit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
forceRender
|
||||
>
|
||||
<Form form={form} layout="vertical" preserve={false}>
|
||||
<Form.Item
|
||||
name="masked_user"
|
||||
label="用户名(脱敏)"
|
||||
extra="留空则每次展示随机生成脱敏用户名(推荐,永不穿帮);也可固定填写,如 用户********a52"
|
||||
>
|
||||
<Input placeholder="留空 = 自动生成" allowClear />
|
||||
</Form.Item>
|
||||
<Space size="large" align="start">
|
||||
<Form.Item name="min_yuan" label="节省下限(元)" rules={[{ required: true, message: '必填' }]}>
|
||||
<InputNumber min={0} step={0.01} precision={2} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="max_yuan"
|
||||
label="节省上限(元)"
|
||||
rules={[{ required: true, message: '必填' }, maxGteMin('min_yuan')]}
|
||||
>
|
||||
<InputNumber min={0} step={0.01} precision={2} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<p style={{ color: '#999', marginTop: -8 }}>固定金额则上下限填一样;区间则每次轮播在区间内随机取值。</p>
|
||||
<Form.Item name="sort_order" label="排序(仅后台列表用)">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 批量生成 */}
|
||||
<Modal
|
||||
title="批量生成种子"
|
||||
open={bulkOpen}
|
||||
onOk={submitBulk}
|
||||
onCancel={() => setBulkOpen(false)}
|
||||
forceRender
|
||||
>
|
||||
<p style={{ color: '#999', marginTop: 0 }}>
|
||||
一次生成多条「自动生成用户名」的种子(冷启动铺量用)。金额均为下方区间,每条展示时各自随机取值。
|
||||
</p>
|
||||
<Form form={bulkForm} layout="vertical" preserve={false}>
|
||||
<Form.Item name="count" label="数量" rules={[{ required: true, message: '必填' }]}>
|
||||
<InputNumber min={1} max={200} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Space size="large" align="start">
|
||||
<Form.Item name="min_yuan" label="节省下限(元)" rules={[{ required: true, message: '必填' }]}>
|
||||
<InputNumber min={0} step={0.01} precision={2} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="max_yuan"
|
||||
label="节省上限(元)"
|
||||
rules={[{ required: true, message: '必填' }, maxGteMin('min_yuan')]}
|
||||
>
|
||||
<InputNumber min={0} step={0.01} precision={2} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 预览实际 feed */}
|
||||
<Modal
|
||||
title="预览轮播效果"
|
||||
open={previewOpen}
|
||||
onCancel={() => setPreviewOpen(false)}
|
||||
footer={
|
||||
<Space>
|
||||
<Button size="small" onClick={fetchPreview}>
|
||||
换一批
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={() => setPreviewOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<p style={{ color: '#999', marginTop: 0 }}>
|
||||
客户端实际会看到的混播结果:真实记录会插队,种子随机抽取、金额/用户名每次都不同。点「换一批」可重新随机。
|
||||
</p>
|
||||
<Spin spinning={previewLoading}>
|
||||
<div style={{ minHeight: 120 }}>
|
||||
{previewItems.map((it, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '7px 4px',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{it.masked_user} 比价后节省{' '}
|
||||
<b style={{ color: '#fa541c' }}>{yuan(it.saved_amount_cents)} 元</b>
|
||||
</span>
|
||||
<span style={{ color: '#999' }}>{it.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<IntervalUnit, number> = { 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<StatItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [edits, setEdits] = useState<Record<string, Edit>>({});
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
|
||||
// 首次加载:带 Spin,并初始化输入态 edits。
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<StatItem[]>('/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<StatItem[]>('/admin/api/dashboard-display');
|
||||
setItems(data);
|
||||
} catch {
|
||||
/* 轮询失败忽略,下次再拉 */
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = setInterval(silentLoad, 3000); // 每 3s 静默刷新展示值
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const patch = (metric: string, p: Partial<Edit>) =>
|
||||
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<string, number | string | boolean> = {
|
||||
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<StatItem>(`/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 (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h3 style={{ marginBottom: 4 }}>首页数据配置</h3>
|
||||
<p style={{ color: '#999', marginTop: 0 }}>
|
||||
客户端首页三个门面数字。每个指标独立选模式:<b>真实值</b>(实时查库,可加基数偏移)/{' '}
|
||||
<b>自定义</b>(固定值)/ <b>自增长</b>(到对齐钟点按倍率或固定增量增长)。
|
||||
默认<b>只增不减</b>(防门面缩水);每次改动进审计日志。
|
||||
</p>
|
||||
{loading ? (
|
||||
<Spin style={{ display: 'block', marginTop: 24 }} />
|
||||
) : (
|
||||
<Row gutter={[16, 16]} align="top">
|
||||
{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 (
|
||||
<Col key={it.metric} xs={24} md={12} xl={8}>
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<span>
|
||||
{it.label}
|
||||
<span style={{ color: '#999', fontWeight: 400, marginLeft: 8, fontSize: 13 }}>
|
||||
当前真实值:{fmtReal(it)}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Radio.Group
|
||||
value={e.mode}
|
||||
onChange={(ev) => patch(it.metric, { mode: ev.target.value })}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="real">真实值</Radio.Button>
|
||||
<Radio.Button value="manual">自定义</Radio.Button>
|
||||
<Radio.Button value="random">自增长</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
{e.mode === 'manual' && (
|
||||
<div>
|
||||
<Space>
|
||||
<span>固定值:</span>
|
||||
<InputNumber
|
||||
value={e.manual}
|
||||
min={0}
|
||||
onChange={(v) => patch(it.metric, { manual: v })}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<span style={{ color: '#666' }}>{u}</span>
|
||||
</Space>
|
||||
{manualBlocked && (
|
||||
<div style={{ color: '#fa8c16', fontSize: 12, marginTop: 4 }}>
|
||||
低于当前展示值,默认不下调(如需下调请勾「允许数字下降」)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{e.mode === 'real' && (
|
||||
<Space wrap>
|
||||
<span>基数偏移:</span>
|
||||
<InputNumber
|
||||
value={e.realOffset}
|
||||
min={0}
|
||||
onChange={(v) => patch(it.metric, { realOffset: v ?? 0 })}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<span style={{ color: '#666' }}>{u}</span>
|
||||
<Tooltip title="展示值 = 真实值 + 此基数。冷启动期真实数字小,加个体面的基数,且仍随真实增长。">
|
||||
<span style={{ color: '#999', cursor: 'help' }}>ⓘ</span>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{e.mode === 'random' && (
|
||||
<>
|
||||
<Space wrap>
|
||||
<span>增长方式:</span>
|
||||
<Radio.Group
|
||||
value={e.growthKind}
|
||||
size="small"
|
||||
onChange={(ev) => patch(it.metric, { growthKind: ev.target.value })}
|
||||
optionType="button"
|
||||
>
|
||||
<Radio.Button value="mult">百分比</Radio.Button>
|
||||
<Radio.Button value="add">固定增量</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
{e.growthKind === 'mult' ? (
|
||||
<Space wrap>
|
||||
<span>倍率区间:</span>
|
||||
<InputNumber
|
||||
value={e.multMin}
|
||||
min={1.0}
|
||||
max={1.5}
|
||||
step={0.01}
|
||||
onChange={(v) => patch(it.metric, { multMin: v ?? 1.0 })}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<span>~</span>
|
||||
<InputNumber
|
||||
value={e.multMax}
|
||||
min={1.0}
|
||||
max={1.5}
|
||||
step={0.01}
|
||||
onChange={(v) => patch(it.metric, { multMax: v ?? 1.0 })}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<Tooltip title="倍率 1.0~1.5,每次更新随机取区间内一个倍率相乘(只增不减)。">
|
||||
<span style={{ color: '#999', cursor: 'help' }}>ⓘ</span>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
) : (
|
||||
<Space wrap>
|
||||
<span>每次新增:</span>
|
||||
<InputNumber
|
||||
value={e.stepMin}
|
||||
min={0}
|
||||
onChange={(v) => patch(it.metric, { stepMin: v ?? 0 })}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<span>~</span>
|
||||
<InputNumber
|
||||
value={e.stepMax}
|
||||
min={0}
|
||||
onChange={(v) => patch(it.metric, { stepMax: v ?? 0 })}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<span style={{ color: '#666' }}>{u}</span>
|
||||
<Tooltip title="每个更新周期随机 +[下限,上限](绝对增量),比百分比更可控、更像真实日增。">
|
||||
<span style={{ color: '#999', cursor: 'help' }}>ⓘ</span>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 到「更新时间」按「更新间隔」刷新一次。自定义不显示间隔(固定每天该时刻)。 */}
|
||||
{e.mode !== 'manual' && (
|
||||
<Space wrap>
|
||||
<span>更新间隔:每</span>
|
||||
<InputNumber
|
||||
value={e.intervalNum}
|
||||
min={1}
|
||||
onChange={(v) => patch(it.metric, { intervalNum: v ?? 1 })}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<Select
|
||||
value={e.intervalUnit}
|
||||
style={{ width: 90 }}
|
||||
onChange={(unit) => patch(it.metric, { intervalUnit: unit as IntervalUnit })}
|
||||
options={[
|
||||
{ value: 'minute', label: '分钟' },
|
||||
{ value: 'hour', label: '小时' },
|
||||
{ value: 'day', label: '天' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
<Space wrap>
|
||||
<span>更新时间:</span>
|
||||
<InputNumber
|
||||
value={e.anchorHour}
|
||||
min={0}
|
||||
max={23}
|
||||
onChange={(v) => patch(it.metric, { anchorHour: v ?? 0 })}
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
<span>时</span>
|
||||
<InputNumber
|
||||
value={e.anchorMinute}
|
||||
min={0}
|
||||
max={59}
|
||||
onChange={(v) => patch(it.metric, { anchorMinute: v ?? 0 })}
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
<span>分</span>
|
||||
<Tooltip title="到北京时间这个钟点(按间隔重复)刷新一次。按天=每日该时刻;按时/分=该时刻在周期内的相位。">
|
||||
<span style={{ color: '#999', cursor: 'help' }}>ⓘ</span>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
{e.mode === 'random' && (
|
||||
<Space wrap>
|
||||
<span>初始基数:</span>
|
||||
<InputNumber
|
||||
value={e.initial}
|
||||
min={0}
|
||||
onChange={(v) => patch(it.metric, { initial: v })}
|
||||
style={{ width: 200 }}
|
||||
placeholder="留空=用真实值播种"
|
||||
/>
|
||||
<span style={{ color: '#666' }}>{u}</span>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
checked={e.allowDecrease}
|
||||
onChange={(ev) => patch(it.metric, { allowDecrease: ev.target.checked })}
|
||||
>
|
||||
允许数字下降{' '}
|
||||
<span style={{ color: '#999' }}>(默认关闭 = 只增不减,防门面缩水)</span>
|
||||
</Checkbox>
|
||||
|
||||
<Space direction="vertical" size={2} style={{ color: '#888', fontSize: 12 }}>
|
||||
<Space wrap size="large">
|
||||
<span>
|
||||
当前展示值:
|
||||
{it.random_current == null ? '未启动' : fmtDisp(it.metric, it.random_current, it.unit)}
|
||||
</span>
|
||||
{it.updated_at && (
|
||||
<span style={{ color: '#bbb' }}>
|
||||
更新于 {new Date(it.updated_at).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
{predictNext(it) && (
|
||||
<span>
|
||||
下次预计:{predictNext(it)}
|
||||
<span style={{ color: '#bbb' }}>
|
||||
{' '}(北京 {nextRefreshLabel(it.random_tick_seconds, it.random_anchor_minutes)} 刷新)
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Button type="primary" loading={saving === it.metric} onClick={() => save(it)}>
|
||||
保存
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="立即更新会马上推进一次(自增长走一档),不等更新时间。确定?"
|
||||
onConfirm={() => save(it, true)}
|
||||
>
|
||||
<Button loading={saving === it.metric}>立即更新</Button>
|
||||
</Popconfirm>
|
||||
<Tag color={it.mode === 'real' ? 'green' : it.mode === 'manual' ? 'orange' : 'blue'}>
|
||||
线上当前:
|
||||
{it.mode === 'real' ? '真实值' : it.mode === 'manual' ? '自定义' : '自增长'}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { useEffect, useState } from 'react';
|
||||
import { Alert, Card, Col, Row, Spin, Statistic } from 'antd';
|
||||
import { api } from '@/lib/api';
|
||||
import type { DashboardOverview } from '@/lib/types';
|
||||
import HomeStatsConfig from './HomeStatsConfig';
|
||||
import HomeMarqueeSeeds from './HomeMarqueeSeeds';
|
||||
|
||||
const yuan = (cents: number) => `¥${(cents / 100).toFixed(2)}`;
|
||||
|
||||
@@ -53,6 +55,8 @@ export default function DashboardPage() {
|
||||
{!data.cps.available && (
|
||||
<Alert style={{ marginTop: 16 }} type="info" showIcon message={`CPS 收入:${data.cps.note}`} />
|
||||
)}
|
||||
<HomeStatsConfig />
|
||||
<HomeMarqueeSeeds />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
'use client';
|
||||
|
||||
// React 19 兼容补丁:Next 15 App Router 运行时用 React 19,修掉 antd v5 静态方法
|
||||
// (message/notification/Modal)的兼容问题并消除 "[antd: compatible] support React 16~18" 告警。
|
||||
// 必须在引入 antd 之前 import。
|
||||
import '@ant-design/v5-patch-for-react-19';
|
||||
import { AntdRegistry } from '@ant-design/nextjs-registry';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
|
||||
@@ -1,27 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
# 后台一键启动运营后台:确保 PG + 后端(8771) + 前端(3001),日志落 /tmp。
|
||||
# 停止用 ./stop.sh。详见 docs/本地启动指南.md
|
||||
set -e
|
||||
# 一键启动傻瓜比价本地联调:用户侧 API(8770) + 运营后台 API(8771) + 运营后台前端(3001)。
|
||||
# 跨平台:Windows(Git Bash)与 macOS 通用。停止用 ./stop.sh。
|
||||
#
|
||||
# 路径相对本脚本推导(本脚本在 shaguabijia-admin-web 内,server 是它的同级目录),
|
||||
# 不再写死绝对路径——两个仓库放在同一父目录下即可。
|
||||
#
|
||||
# 可用环境变量覆盖:
|
||||
# SHAGUA_CONDA_ENV 指定 conda 环境名(默认自动从 shaguabijia-app-server / price 里挑)
|
||||
# NODE_DIR 指定含 node 的目录(默认自动探测;Windows 上找 ~/nodejs/node-v*-win-x64)
|
||||
|
||||
SERVER_DIR="$HOME/Desktop/codes/shaguabijia-app-server"
|
||||
WEB_DIR="$HOME/Desktop/codes/shaguabijia-admin-web"
|
||||
# 不用 set -e:端口探测等命令正常会返回非零,逐步自己判错。
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WEB_DIR="$SCRIPT_DIR"
|
||||
WORKSPACE="$(dirname "$SCRIPT_DIR")"
|
||||
SERVER_DIR="$WORKSPACE/shaguabijia-app-server"
|
||||
LOG_DIR="$SCRIPT_DIR/.run-logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# 1. 确保本机 PG 在跑(已启动则 no-op)
|
||||
brew services start postgresql@16 >/dev/null 2>&1 || true
|
||||
case "$(uname -s)" in
|
||||
MINGW* | MSYS* | CYGWIN*) IS_WIN=1 ;;
|
||||
*) IS_WIN=0 ;;
|
||||
esac
|
||||
|
||||
# 2. 后端(conda 环境 price)
|
||||
cd "$SERVER_DIR"
|
||||
# shellcheck disable=SC1091
|
||||
source "$HOME/miniconda3/etc/profile.d/conda.sh"
|
||||
conda activate price
|
||||
nohup uvicorn app.admin.main:admin_app --port 8771 --reload > /tmp/admin-api.log 2>&1 &
|
||||
echo "✅ 后端 8771 已起 (日志: tail -f /tmp/admin-api.log)"
|
||||
# ---- 工具:端口占用判断 / 按端口杀进程(stop.sh 复用同款逻辑) ----
|
||||
port_in_use() {
|
||||
local p="$1"
|
||||
if [ "$IS_WIN" = "1" ]; then
|
||||
netstat -ano 2>/dev/null | grep -E "[:.]$p[[:space:]].*LISTENING" >/dev/null 2>&1
|
||||
else
|
||||
lsof -ti "tcp:$p" >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
# 3. 前端
|
||||
cd "$WEB_DIR"
|
||||
nohup npm run dev > /tmp/admin-web.log 2>&1 &
|
||||
echo "✅ 前端 3001 已起 (日志: tail -f /tmp/admin-web.log)"
|
||||
# ---- 1. (仅 macOS + 有 brew + 后端用 PG 时) 尽力拉起 PostgreSQL;sqlite 下无需,失败不致命 ----
|
||||
if [ "$IS_WIN" = "0" ] && command -v brew >/dev/null 2>&1; then
|
||||
if grep -qi "postgresql" "$SERVER_DIR/.env" 2>/dev/null; then
|
||||
brew services start postgresql@16 >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- 2. 定位并激活 conda 环境 ----
|
||||
CONDA_SH=""
|
||||
for c in "$HOME/miniconda3/etc/profile.d/conda.sh" "$HOME/anaconda3/etc/profile.d/conda.sh" \
|
||||
"/c/Users/$USER/miniconda3/etc/profile.d/conda.sh" "/opt/homebrew/Caskroom/miniconda/base/etc/profile.d/conda.sh"; do
|
||||
[ -f "$c" ] && { CONDA_SH="$c"; break; }
|
||||
done
|
||||
[ -z "$CONDA_SH" ] && { echo "❌ 找不到 conda.sh,请确认装了 miniconda/anaconda"; exit 1; }
|
||||
# shellcheck disable=SC1090
|
||||
source "$CONDA_SH"
|
||||
|
||||
ENV="${SHAGUA_CONDA_ENV:-}"
|
||||
if [ -z "$ENV" ]; then
|
||||
for cand in shaguabijia-app-server price; do
|
||||
if conda env list | awk '{print $1}' | grep -qx "$cand"; then ENV="$cand"; break; fi
|
||||
done
|
||||
fi
|
||||
[ -z "$ENV" ] && { echo "❌ 没找到可用 conda 环境(试过 shaguabijia-app-server / price),用 SHAGUA_CONDA_ENV 指定"; exit 1; }
|
||||
conda activate "$ENV" || { echo "❌ conda activate $ENV 失败"; exit 1; }
|
||||
python -c "import uvicorn" 2>/dev/null || { echo "❌ 环境 '$ENV' 里没装 uvicorn,换个环境(SHAGUA_CONDA_ENV=...)再跑"; exit 1; }
|
||||
echo "🐍 conda 环境: $ENV"
|
||||
|
||||
# ---- 3. 定位 node(已在 PATH 直接用;否则探测常见安装位) ----
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
CANDS=("$NODE_DIR")
|
||||
for g in "$HOME"/nodejs/node-v*-win-x64 "$HOME"/nodejs/node-v*-darwin-* "/c/Program Files/nodejs" "/usr/local/bin" "/opt/homebrew/bin"; do
|
||||
CANDS+=("$g")
|
||||
done
|
||||
for d in "${CANDS[@]}"; do
|
||||
[ -n "$d" ] || continue
|
||||
if [ -x "$d/node.exe" ] || [ -x "$d/node" ]; then export PATH="$d:$PATH"; break; fi
|
||||
done
|
||||
fi
|
||||
command -v node >/dev/null 2>&1 || { echo "❌ 找不到 node。装好后若仍找不到,用 NODE_DIR=<含 node 的目录> 指定"; exit 1; }
|
||||
echo "🟢 node: $(node -v)"
|
||||
echo ""
|
||||
|
||||
# ---- 4. 起后端两个服务 + 前端;已在跑的端口跳过 ----
|
||||
# 默认不加 --reload:reload 会派生「监督+工作」两进程,stop.sh 按 PID 只杀得掉监督、工作进程
|
||||
# 仍占端口(Windows 上尤甚)。要热重载就 `RELOAD=1 bash start.sh`(届时改完代码用 stop.sh 可能
|
||||
# 残留工作进程,需手动按端口清)。
|
||||
RELOAD_FLAG=""
|
||||
[ "${RELOAD:-0}" = "1" ] && RELOAD_FLAG="--reload"
|
||||
|
||||
start_uvicorn() {
|
||||
local name="$1" app="$2" port="$3" host="$4"
|
||||
if port_in_use "$port"; then echo "↺ $name 已在 :$port(跳过)"; return; fi
|
||||
cd "$SERVER_DIR" || exit 1
|
||||
nohup python -m uvicorn "$app" --host "$host" --port "$port" $RELOAD_FLAG \
|
||||
> "$LOG_DIR/$name.log" 2>&1 &
|
||||
echo "$!" > "$LOG_DIR/$name.pid"
|
||||
echo "✅ $name 起在 :$port (日志 $LOG_DIR/$name.log)"
|
||||
}
|
||||
|
||||
# 用户侧绑 0.0.0.0,真机/模拟器可经局域网 IP 访问;后台只本机用,绑 127.0.0.1
|
||||
start_uvicorn "user-api" "app.main:app" 8770 "0.0.0.0"
|
||||
start_uvicorn "admin-api" "app.admin.main:admin_app" 8771 "127.0.0.1"
|
||||
|
||||
if port_in_use 3001; then
|
||||
echo "↺ admin-web 已在 :3001(跳过)"
|
||||
else
|
||||
cd "$WEB_DIR" || exit 1
|
||||
nohup npm run dev > "$LOG_DIR/admin-web.log" 2>&1 &
|
||||
echo "$!" > "$LOG_DIR/admin-web.pid"
|
||||
echo "✅ admin-web 起在 :3001 (日志 $LOG_DIR/admin-web.log)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "⏳ 等几秒编译,然后开 http://localhost:3001 (admin / admin12345)"
|
||||
echo " 停止: ./stop.sh"
|
||||
echo "⏳ 前端首次编译要几秒,然后开 👉 http://localhost:3001 (admin / admin12345)"
|
||||
echo " 用户侧 API: http://127.0.0.1:8770 | 后台 API: http://127.0.0.1:8771/admin/docs"
|
||||
echo " 看日志: tail -f $LOG_DIR/admin-web.log | 停止: bash \"$SCRIPT_DIR/stop.sh\""
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# 停掉运营后台的 后端(8771) + 前端(3001)。PG 不停(开机自启,别的服务也可能用)。
|
||||
# 详见 docs/本地启动指南.md
|
||||
# 停止 start.sh 起的三个服务:用户侧 API(8770) + 后台 API(8771) + 前端(3001)。
|
||||
# 双保险:先按 start.sh 落的 PID 文件杀,再按端口兜底(Windows 走 PowerShell,mac 走 lsof)。
|
||||
# (PG 若由 brew 自启,本脚本不停它。)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOG_DIR="$SCRIPT_DIR/.run-logs"
|
||||
|
||||
if lsof -ti:8771 >/dev/null 2>&1; then
|
||||
lsof -ti:8771 | xargs kill && echo "✅ 后端(8771)已停"
|
||||
else
|
||||
echo "— 后端(8771)未在跑"
|
||||
fi
|
||||
case "$(uname -s)" in
|
||||
MINGW* | MSYS* | CYGWIN*) IS_WIN=1 ;;
|
||||
*) IS_WIN=0 ;;
|
||||
esac
|
||||
|
||||
if lsof -ti:3001 >/dev/null 2>&1; then
|
||||
lsof -ti:3001 | xargs kill && echo "✅ 前端(3001)已停"
|
||||
else
|
||||
echo "— 前端(3001)未在跑"
|
||||
fi
|
||||
# 1) 按 PID 文件杀(start.sh 启动时落的 MSYS/进程 PID)
|
||||
for name in user-api admin-api admin-web; do
|
||||
pf="$LOG_DIR/$name.pid"
|
||||
[ -f "$pf" ] || continue
|
||||
pid="$(cat "$pf" 2>/dev/null)"
|
||||
[ -n "$pid" ] && kill -9 "$pid" 2>/dev/null && echo " $name: 杀 PID $pid"
|
||||
rm -f "$pf"
|
||||
done
|
||||
|
||||
# 2) 按端口兜底(catch 掉 reload 子进程 / 孤儿 node 等)
|
||||
kill_port() {
|
||||
local p="$1"
|
||||
if [ "$IS_WIN" = "1" ]; then
|
||||
# 用 PowerShell 的 Get-NetTCPConnection→Stop-Process,比 netstat+taskkill 可靠
|
||||
powershell.exe -NoProfile -Command \
|
||||
"Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue | Select-Object -Expand OwningProcess -Unique | ForEach-Object { try { Stop-Process -Id \$_ -Force -ErrorAction Stop; 'killed pid '+\$_+' on :$p' } catch {} }" \
|
||||
2>/dev/null | tr -d '\r'
|
||||
else
|
||||
lsof -ti "tcp:$p" 2>/dev/null | xargs -r kill -9 2>/dev/null && echo " killed listener on :$p"
|
||||
fi
|
||||
}
|
||||
|
||||
for port in 8770 8771 3001; do
|
||||
kill_port "$port"
|
||||
done
|
||||
|
||||
echo "✅ 已停止(用户侧 8770 / 后台 8771 / 前端 3001)。"
|
||||
|
||||
Reference in New Issue
Block a user