feat(dashboard): add boss dashboard H5 and APIs
Implement the mobile dashboard frontend, admin overview APIs, report archive export, and local dev proxy so the boss dashboard can run against real backend data while preserving MSW demos. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
5
dashboard-frontend/.env.development
Normal file
5
dashboard-frontend/.env.development
Normal file
@@ -0,0 +1,5 @@
|
||||
VITE_APP_ENV=development
|
||||
VITE_API_BASE_URL=/api/admin
|
||||
VITE_MOCK_ENABLED=false
|
||||
VITE_APP_TITLE=经营驾驶舱
|
||||
VITE_BUILD_VERSION=local
|
||||
24
dashboard-frontend/.gitignore
vendored
Normal file
24
dashboard-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
122
dashboard-frontend/README.md
Normal file
122
dashboard-frontend/README.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Dashboard Frontend
|
||||
|
||||
独立 H5 经营驾驶舱前端项目。第一阶段只使用本地 Mock 数据,不对接后端 API。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- React 19
|
||||
- TypeScript
|
||||
- Vite
|
||||
- antd-mobile
|
||||
- TanStack Query
|
||||
- Zustand
|
||||
- Axios
|
||||
- ECharts
|
||||
- MSW
|
||||
- Vitest
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
nvm use --delete-prefix v24.14.1
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
默认访问:
|
||||
|
||||
```text
|
||||
http://localhost:5174/h5/dashboard/boss
|
||||
```
|
||||
|
||||
## 第一阶段范围
|
||||
|
||||
- H5 移动端老板驾驶舱首页
|
||||
- 昨日经营核心 KPI
|
||||
- 今日 10:15 / 14:55 节点快报 Mock
|
||||
- 近 7 天交易趋势
|
||||
- 用户、团队、商品排行
|
||||
- 风险预警摘要
|
||||
- 底部 Tab 导航
|
||||
- MSW Mock 数据
|
||||
|
||||
## 校验
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test -- --run
|
||||
pnpm build
|
||||
```
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
22
dashboard-frontend/eslint.config.js
Normal file
22
dashboard-frontend/eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'public/mockServiceWorker.js']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
dashboard-frontend/index.html
Normal file
13
dashboard-frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>dashboard-frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
dashboard-frontend/package.json
Normal file
50
dashboard-frontend/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "dashboard-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"antd-mobile": "^5.42.3",
|
||||
"antd-mobile-icons": "^0.3.0",
|
||||
"axios": "^1.16.0",
|
||||
"echarts": "^6.0.0",
|
||||
"msw": "^2.14.5",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
3461
dashboard-frontend/pnpm-lock.yaml
generated
Normal file
3461
dashboard-frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
dashboard-frontend/public/favicon.svg
Normal file
1
dashboard-frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
dashboard-frontend/public/icons.svg
Normal file
24
dashboard-frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
349
dashboard-frontend/public/mockServiceWorker.js
Normal file
349
dashboard-frontend/public/mockServiceWorker.js
Normal file
@@ -0,0 +1,349 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.14.5'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
27
dashboard-frontend/src/App.tsx
Normal file
27
dashboard-frontend/src/App.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AppProviders } from './app/providers/AppProviders'
|
||||
import { MobileLayout } from './app/layouts/MobileLayout'
|
||||
import { BossDashboardPage } from './features/boss-dashboard/pages/BossDashboardPage'
|
||||
import { DailyReportPage, ProfilePage, RiskCenterPage, TodaySnapshotPage } from './features/boss-dashboard/pages/OperationsPages'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AppProviders>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/h5/dashboard/boss" replace />} />
|
||||
<Route element={<MobileLayout />}>
|
||||
<Route path="/h5/dashboard/boss" element={<BossDashboardPage />} />
|
||||
<Route path="/h5/dashboard/daily-report" element={<DailyReportPage />} />
|
||||
<Route path="/h5/dashboard/today-snapshot" element={<TodaySnapshotPage />} />
|
||||
<Route path="/h5/dashboard/risk-center" element={<RiskCenterPage />} />
|
||||
<Route path="/h5/dashboard/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/h5/dashboard/boss" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AppProviders>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
34
dashboard-frontend/src/app/layouts/MobileLayout.tsx
Normal file
34
dashboard-frontend/src/app/layouts/MobileLayout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { AppOutline, BellOutline, FileOutline, HistogramOutline, UserOutline } from 'antd-mobile-icons'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { SafeArea, TabBar } from 'antd-mobile'
|
||||
|
||||
const tabs = [
|
||||
{ key: '/h5/dashboard/boss', title: '首页', icon: <AppOutline /> },
|
||||
{ key: '/h5/dashboard/daily-report', title: '日报', icon: <FileOutline /> },
|
||||
{ key: '/h5/dashboard/today-snapshot', title: '快报', icon: <HistogramOutline /> },
|
||||
{ key: '/h5/dashboard/risk-center', title: '风险', icon: <BellOutline /> },
|
||||
{ key: '/h5/dashboard/profile', title: '我的', icon: <UserOutline /> },
|
||||
]
|
||||
|
||||
export function MobileLayout() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const activeKey = tabs.find((tab) => location.pathname.startsWith(tab.key))?.key ?? tabs[0].key
|
||||
|
||||
return (
|
||||
<div className="mobile-shell">
|
||||
<main className="mobile-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
<nav className="bottom-nav" aria-label="Dashboard mobile navigation">
|
||||
<TabBar activeKey={activeKey} onChange={(key) => navigate(key)}>
|
||||
{tabs.map((tab) => (
|
||||
<TabBar.Item key={tab.key} icon={tab.icon} title={tab.title} />
|
||||
))}
|
||||
</TabBar>
|
||||
<SafeArea position="bottom" />
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
dashboard-frontend/src/app/providers/AppProviders.tsx
Normal file
26
dashboard-frontend/src/app/providers/AppProviders.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ConfigProvider } from 'antd-mobile'
|
||||
import zhCN from 'antd-mobile/es/locales/zh-CN'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type AppProvidersProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: AppProvidersProps) {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
76
dashboard-frontend/src/components/charts/MiniTrendChart.tsx
Normal file
76
dashboard-frontend/src/components/charts/MiniTrendChart.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as echarts from 'echarts/core'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { BarChart, LineChart } from 'echarts/charts'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import type { TrendPoint } from '../../features/boss-dashboard/types'
|
||||
|
||||
echarts.use([GridComponent, TooltipComponent, LineChart, BarChart, CanvasRenderer])
|
||||
|
||||
type MiniTrendChartProps = {
|
||||
data: TrendPoint[]
|
||||
}
|
||||
|
||||
export function MiniTrendChart({ data }: MiniTrendChartProps) {
|
||||
const chartRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
color: ['#ff5b36', '#ffb000'],
|
||||
grid: { left: 8, right: 8, top: 24, bottom: 18, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
valueFormatter: (value: number | string) => Number(value).toLocaleString('zh-CN'),
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((item) => item.date),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', show: false },
|
||||
{ type: 'value', show: false },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '成交额',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
yAxisIndex: 0,
|
||||
data: data.map((item) => item.amount),
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
lineStyle: { width: 3 },
|
||||
areaStyle: { opacity: 0.08 },
|
||||
},
|
||||
{
|
||||
name: '订单数',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
data: data.map((item) => item.orders),
|
||||
barWidth: 8,
|
||||
borderRadius: 6,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[data],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return undefined
|
||||
const chart = echarts.init(chartRef.current)
|
||||
chart.setOption(option)
|
||||
|
||||
const resize = () => chart.resize()
|
||||
window.addEventListener('resize', resize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize)
|
||||
chart.dispose()
|
||||
}
|
||||
}, [option])
|
||||
|
||||
return <div className="mini-trend-chart" ref={chartRef} aria-label="近 7 天交易趋势图" />
|
||||
}
|
||||
32
dashboard-frontend/src/components/kpi/KpiCard.tsx
Normal file
32
dashboard-frontend/src/components/kpi/KpiCard.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Skeleton } from 'antd-mobile'
|
||||
import { formatMetricValue, formatTrend } from '../../utils/format'
|
||||
import type { KpiMetric } from '../../features/boss-dashboard/types'
|
||||
|
||||
type KpiCardProps = {
|
||||
metric: KpiMetric
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function KpiCard({ metric, loading }: KpiCardProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<article className="kpi-card">
|
||||
<Skeleton.Title animated />
|
||||
<Skeleton.Paragraph lineCount={1} animated />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={`kpi-card kpi-card--${metric.status} ${metric.featured ? 'kpi-card--featured' : ''}`}>
|
||||
<p className="kpi-title">{metric.title}</p>
|
||||
<strong className="kpi-value">{formatMetricValue(metric.value, metric.unit)}</strong>
|
||||
{(metric.trendLabel || metric.trendValue !== undefined) && (
|
||||
<p className="kpi-trend">
|
||||
{metric.trendLabel}
|
||||
{metric.trendValue !== undefined && <span>{formatTrend(metric.trendValue)}</span>}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
18
dashboard-frontend/src/features/boss-dashboard/api.ts
Normal file
18
dashboard-frontend/src/features/boss-dashboard/api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getApiData, getBlob } from '../../services/http/client'
|
||||
import type { DashboardOverview } from './types'
|
||||
|
||||
export const dashboardQueryKeys = {
|
||||
overview: (date?: string) => ['dashboard', 'overview', date ?? 'default'] as const,
|
||||
}
|
||||
|
||||
export function useDashboardOverview(date?: string) {
|
||||
return useQuery({
|
||||
queryKey: dashboardQueryKeys.overview(date),
|
||||
queryFn: () => getApiData<DashboardOverview>(date ? `/dashboard/overview?date=${date}` : '/dashboard/overview'),
|
||||
})
|
||||
}
|
||||
|
||||
export function downloadDailyReportArchive(date?: string) {
|
||||
return getBlob(date ? `/dashboard/daily-report/archive?date=${date}` : '/dashboard/daily-report/archive')
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { RightOutline } from 'antd-mobile-icons'
|
||||
import { formatMoney, formatNumber } from '../../../utils/format'
|
||||
import type { RankItem } from '../types'
|
||||
|
||||
type RankListProps = {
|
||||
title: string
|
||||
items: RankItem[]
|
||||
valueType?: 'money' | 'number'
|
||||
}
|
||||
|
||||
export function RankList({ title, items, valueType = 'money' }: RankListProps) {
|
||||
return (
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Top 3</p>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<button className="text-button" type="button">
|
||||
全部
|
||||
<RightOutline />
|
||||
</button>
|
||||
</div>
|
||||
<div className="rank-list">
|
||||
{items.map((item, index) => (
|
||||
<button className="rank-item" key={item.id} type="button">
|
||||
<span className="rank-index">{index + 1}</span>
|
||||
<span className="rank-content">
|
||||
<strong>{item.name}</strong>
|
||||
<small>{item.description}</small>
|
||||
</span>
|
||||
<span className="rank-value">{valueType === 'money' ? formatMoney(item.value) : formatNumber(item.value)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Tag } from 'antd-mobile'
|
||||
import type { RiskAlert, RiskLevel } from '../types'
|
||||
|
||||
type RiskAlertSectionProps = {
|
||||
risks: RiskAlert[]
|
||||
}
|
||||
|
||||
const levelMeta: Record<RiskLevel, { color: 'danger' | 'warning' | 'default'; label: string }> = {
|
||||
red: { color: 'danger', label: '红色' },
|
||||
yellow: { color: 'warning', label: '黄色' },
|
||||
gray: { color: 'default', label: '灰色' },
|
||||
}
|
||||
|
||||
export function RiskAlertSection({ risks }: RiskAlertSectionProps) {
|
||||
return (
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Risk</p>
|
||||
<h2>风险预警</h2>
|
||||
</div>
|
||||
<span className="risk-count">{risks.length} 条</span>
|
||||
</div>
|
||||
<div className="risk-list">
|
||||
{risks.map((risk) => {
|
||||
const meta = levelMeta[risk.level]
|
||||
return (
|
||||
<button className="risk-item" key={risk.id} type="button">
|
||||
<div className="risk-header">
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<span>{risk.type}</span>
|
||||
<time>{risk.discoveredAt}</time>
|
||||
</div>
|
||||
<strong>{risk.title}</strong>
|
||||
<p>{risk.description}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { CapsuleTabs, Tag } from 'antd-mobile'
|
||||
import { useState } from 'react'
|
||||
import { formatMoney, formatNumber } from '../../../utils/format'
|
||||
import type { SnapshotSlot, TodaySnapshot } from '../types'
|
||||
|
||||
type TodaySnapshotSectionProps = {
|
||||
snapshots: TodaySnapshot[]
|
||||
}
|
||||
|
||||
const statusMap = {
|
||||
pending: { color: 'default', label: '待生成' },
|
||||
success: { color: 'success', label: '已生成' },
|
||||
failed: { color: 'danger', label: '生成失败' },
|
||||
temporary: { color: 'warning', label: '临时数据' },
|
||||
} as const
|
||||
|
||||
export function TodaySnapshotSection({ snapshots }: TodaySnapshotSectionProps) {
|
||||
const [activeSlot, setActiveSlot] = useState<SnapshotSlot>('1015')
|
||||
const activeSnapshot = snapshots.find((snapshot) => snapshot.slot === activeSlot) ?? snapshots[0]
|
||||
const status = statusMap[activeSnapshot.status]
|
||||
|
||||
return (
|
||||
<section className="section-block snapshot-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">今日节点</p>
|
||||
<h2>抢购 / 寄卖快报</h2>
|
||||
</div>
|
||||
<Tag color={status.color}>{status.label}</Tag>
|
||||
</div>
|
||||
|
||||
<CapsuleTabs activeKey={activeSlot} onChange={(key) => setActiveSlot(key as SnapshotSlot)}>
|
||||
{snapshots.map((snapshot) => (
|
||||
<CapsuleTabs.Tab title={snapshot.title.replace(' ', '')} key={snapshot.slot} />
|
||||
))}
|
||||
</CapsuleTabs>
|
||||
|
||||
<div className="snapshot-card">
|
||||
<p className="snapshot-message">{activeSnapshot.message}</p>
|
||||
{activeSnapshot.generatedAt && <p className="snapshot-time">生成时间:{activeSnapshot.generatedAt}</p>}
|
||||
<div className="snapshot-grid">
|
||||
<span>
|
||||
采购用户<strong>{formatNumber(activeSnapshot.purchaseUsers)}人</strong>
|
||||
</span>
|
||||
<span>
|
||||
订单数<strong>{formatNumber(activeSnapshot.orderCount)}单</strong>
|
||||
</span>
|
||||
<span>
|
||||
成交额<strong>{formatMoney(activeSnapshot.dealAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
支付额<strong>{formatMoney(activeSnapshot.paidAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
新增商品<strong>{formatNumber(activeSnapshot.newMerchandiseCount)}件</strong>
|
||||
</span>
|
||||
<span>
|
||||
奖金变化<strong>{formatMoney(Number(activeSnapshot.selfBonusChange) + Number(activeSnapshot.shareBonusChange))}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
104
dashboard-frontend/src/features/boss-dashboard/mock.ts
Normal file
104
dashboard-frontend/src/features/boss-dashboard/mock.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { DashboardOverview } from './types'
|
||||
|
||||
export const dashboardMock: DashboardOverview = {
|
||||
businessDate: '2026-05-10',
|
||||
generatedAt: '2026-05-11 00:10:12',
|
||||
summary: '昨日成交保持稳定,采购用户略有增长;资金风险主要集中在大额待提现和积分比例异常。',
|
||||
kpis: [
|
||||
{ key: 'dealAmount', title: '昨日成交额', value: 1289360.42, unit: '元', trendLabel: '较前日', trendValue: 8.6, status: 'success', featured: true },
|
||||
{ key: 'orderCount', title: '昨日订单数', value: 1842, unit: '单', trendLabel: '较前日', trendValue: 4.1, status: 'success' },
|
||||
{ key: 'purchaseUsers', title: '采购用户', value: 936, unit: '人', trendLabel: '较前日', trendValue: 2.7, status: 'success' },
|
||||
{ key: 'newUsers', title: '新增用户', value: 318, unit: '人', trendLabel: '较前日', trendValue: -3.2, status: 'warning' },
|
||||
{ key: 'newMerchandise', title: '新增寄售商品', value: 472, unit: '件', trendLabel: '较前日', trendValue: 12.4, status: 'success' },
|
||||
{ key: 'selfBonus', title: '个人奖金发放', value: 168230.36, unit: '元', trendLabel: '较前日', trendValue: 6.8, status: 'normal' },
|
||||
{ key: 'shareBonus', title: '推广奖金发放', value: 82460.18, unit: '元', trendLabel: '较前日', trendValue: 1.9, status: 'normal' },
|
||||
{ key: 'pendingAmount', title: '待支付/待结算', value: 95620.11, unit: '元', trendLabel: '需关注', status: 'warning' },
|
||||
],
|
||||
fundPool: [
|
||||
{ key: 'balance', title: '余额总额', value: 728903.22, unit: '元', status: 'normal' },
|
||||
{ key: 'coupon', title: '优惠券总额', value: 391082.88, unit: '元', status: 'normal' },
|
||||
{ key: 'selfBonusPool', title: '个人奖金总额', value: 836942.14, unit: '元', status: 'warning' },
|
||||
{ key: 'shareBonusPool', title: '推广奖金总额', value: 295402.77, unit: '元', status: 'normal' },
|
||||
{ key: 'integral', title: '积分总额', value: 418471.07, unit: '分', status: 'normal' },
|
||||
{ key: 'withdrawPending', title: '待审核提现', value: 63200, unit: '元', status: 'danger' },
|
||||
],
|
||||
snapshots: [
|
||||
{
|
||||
slot: '1015',
|
||||
title: '10:15 上午快报',
|
||||
status: 'success',
|
||||
generatedAt: '2026-05-11 10:15:08',
|
||||
message: '上午抢购节点已完成,上一日寄卖商品消化情况良好,采购用户和成交额略高于昨日同节点。',
|
||||
purchaseUsers: 421,
|
||||
orderCount: 756,
|
||||
dealAmount: 526880.2,
|
||||
paidAmount: 498320.5,
|
||||
newMerchandiseCount: 185,
|
||||
selfBonusChange: 64230.3,
|
||||
shareBonusChange: 31820.1,
|
||||
},
|
||||
{
|
||||
slot: '1455',
|
||||
title: '14:55 下午快报',
|
||||
status: 'pending',
|
||||
message: '下午寄卖/转卖节点尚未生成,预计 14:55 后可查看用户抢购商品的再次上架情况。',
|
||||
purchaseUsers: 0,
|
||||
orderCount: 0,
|
||||
dealAmount: 0,
|
||||
paidAmount: 0,
|
||||
newMerchandiseCount: 0,
|
||||
selfBonusChange: 0,
|
||||
shareBonusChange: 0,
|
||||
},
|
||||
],
|
||||
trends: [
|
||||
{ date: '05-04', amount: 948000, orders: 1390, newUsers: 226, bonus: 186000 },
|
||||
{ date: '05-05', amount: 1024000, orders: 1512, newUsers: 251, bonus: 194000 },
|
||||
{ date: '05-06', amount: 1119000, orders: 1604, newUsers: 287, bonus: 205000 },
|
||||
{ date: '05-07', amount: 1086000, orders: 1542, newUsers: 243, bonus: 198000 },
|
||||
{ date: '05-08', amount: 1198000, orders: 1731, newUsers: 302, bonus: 221000 },
|
||||
{ date: '05-09', amount: 1187200, orders: 1769, newUsers: 329, bonus: 229000 },
|
||||
{ date: '05-10', amount: 1289360, orders: 1842, newUsers: 318, bonus: 250690 },
|
||||
],
|
||||
userRanks: [
|
||||
{ id: 'u1', name: '刘先生', value: 96520, description: '个人奖金 + 推广奖金 + 积分折算', badge: '高价值' },
|
||||
{ id: 'u2', name: '陈女士', value: 81230, description: '昨日采购 12 单', badge: '活跃' },
|
||||
{ id: 'u3', name: '周先生', value: 75880, description: '团队新增 18 人' },
|
||||
],
|
||||
teamRanks: [
|
||||
{ id: 't1', name: '华东一队', value: 386200, description: '成交额第一,团队收益 4.8 万', badge: 'TOP1' },
|
||||
{ id: 't2', name: '苏州团队', value: 318760, description: '采购用户 182 人' },
|
||||
{ id: 't3', name: '扬州团队', value: 287500, description: '新增成员 36 人' },
|
||||
],
|
||||
productRanks: [
|
||||
{ id: 'p1', name: '高端礼盒 A 款', value: 128800, description: '上架 7 天未成交', badge: '滞销' },
|
||||
{ id: 'p2', name: '精选组合 B 款', value: 98600, description: '高货值待成交' },
|
||||
{ id: 'p3', name: '会员专享 C 款', value: 83500, description: '浏览高,成交低' },
|
||||
],
|
||||
risks: [
|
||||
{
|
||||
id: 'r1',
|
||||
level: 'red',
|
||||
type: '资金',
|
||||
title: '大额待审核提现',
|
||||
description: '当前待审核提现 6.32 万,建议今日处理。',
|
||||
discoveredAt: '11:00',
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
level: 'yellow',
|
||||
type: '积分',
|
||||
title: '积分与个人奖金比例异常',
|
||||
description: '发现 3 名用户积分未接近个人奖金的 1/2。',
|
||||
discoveredAt: '10:40',
|
||||
},
|
||||
{
|
||||
id: 'r3',
|
||||
level: 'gray',
|
||||
type: '数据',
|
||||
title: '用户资料不一致',
|
||||
description: 'wa_users 与 eb_user 有 5 条手机号不一致。',
|
||||
discoveredAt: '09:55',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Button, DotLoading, ErrorBlock } from 'antd-mobile'
|
||||
import { KpiCard } from '../../../components/kpi/KpiCard'
|
||||
import { MiniTrendChart } from '../../../components/charts/MiniTrendChart'
|
||||
import { formatMoney } from '../../../utils/format'
|
||||
import { useDashboardOverview } from '../api'
|
||||
import { RankList } from '../components/RankList'
|
||||
import { RiskAlertSection } from '../components/RiskAlertSection'
|
||||
import { TodaySnapshotSection } from '../components/TodaySnapshotSection'
|
||||
|
||||
export function BossDashboardPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="loading-page">
|
||||
<DotLoading color="primary" />
|
||||
<p>正在生成经营简报...</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<section className="error-page">
|
||||
<ErrorBlock status="default" title="驾驶舱加载失败" description="后端接口暂不可用,请确认服务、登录态或接口权限后重试。" />
|
||||
<Button color="primary" onClick={() => void refetch()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const coreKpis = data.kpis.slice(0, 4)
|
||||
const moreKpis = data.kpis.slice(4)
|
||||
|
||||
return (
|
||||
<section className="dashboard-page">
|
||||
<header className="dashboard-hero">
|
||||
<div className="hero-topline">
|
||||
<span>经营驾驶舱</span>
|
||||
<button type="button">上个工作日</button>
|
||||
</div>
|
||||
<p className="eyebrow">数据日期 {data.businessDate}</p>
|
||||
<h1>上个工作日经营简报</h1>
|
||||
<p className="hero-summary">{data.summary}</p>
|
||||
<div className="hero-metric">
|
||||
<span>上个工作日成交额</span>
|
||||
<strong>{formatMoney(data.kpis[0]?.value)}</strong>
|
||||
<small>生成时间:{data.generatedAt}</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="kpi-grid" aria-label="核心经营指标">
|
||||
{coreKpis.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">More</p>
|
||||
<h2>更多经营指标</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{moreKpis.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TodaySnapshotSection snapshots={data.snapshots} />
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Trend</p>
|
||||
<h2>近 7 天交易趋势</h2>
|
||||
</div>
|
||||
</div>
|
||||
<MiniTrendChart data={data.trends} />
|
||||
</section>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Fund</p>
|
||||
<h2>资金池摘要</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{data.fundPool.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RankList title="高价值用户" items={data.userRanks} />
|
||||
<RankList title="团队贡献排行" items={data.teamRanks} />
|
||||
<RankList title="高货值未成交商品" items={data.productRanks} />
|
||||
<RiskAlertSection risks={data.risks} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import { Button, CapsuleTabs, DotLoading, ErrorBlock, Tag, Toast } from 'antd-mobile'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { MiniTrendChart } from '../../../components/charts/MiniTrendChart'
|
||||
import { KpiCard } from '../../../components/kpi/KpiCard'
|
||||
import { formatMoney, formatNumber } from '../../../utils/format'
|
||||
import { downloadDailyReportArchive, useDashboardOverview } from '../api'
|
||||
import type { DashboardOverview, RiskLevel, SnapshotSlot, TodaySnapshot } from '../types'
|
||||
|
||||
const snapshotStatusMeta = {
|
||||
pending: { color: 'default', label: '待生成' },
|
||||
success: { color: 'success', label: '已生成' },
|
||||
failed: { color: 'danger', label: '失败' },
|
||||
temporary: { color: 'warning', label: '临时' },
|
||||
} as const
|
||||
|
||||
const riskLevelMeta: Record<RiskLevel, { color: 'danger' | 'warning' | 'default'; label: string }> = {
|
||||
red: { color: 'danger', label: '红色' },
|
||||
yellow: { color: 'warning', label: '黄色' },
|
||||
gray: { color: 'default', label: '灰色' },
|
||||
}
|
||||
|
||||
const snapshotSlotMeta: Record<
|
||||
SnapshotSlot,
|
||||
{
|
||||
title: string
|
||||
subtitle: string
|
||||
metricLabels: {
|
||||
primaryUsers: string
|
||||
primaryOrders: string
|
||||
amount: string
|
||||
paidAmount: string
|
||||
merchandise: string
|
||||
bonus: string
|
||||
}
|
||||
checklist: string[]
|
||||
}
|
||||
> = {
|
||||
'1015': {
|
||||
title: '上午抢购快报',
|
||||
subtitle: '用户集中抢购上一天用户寄卖的商品,重点看成交、付款和采购用户是否达标。',
|
||||
metricLabels: {
|
||||
primaryUsers: '抢购用户',
|
||||
primaryOrders: '抢购订单',
|
||||
amount: '抢购成交额',
|
||||
paidAmount: '已支付金额',
|
||||
merchandise: '成交商品',
|
||||
bonus: '相关奖金',
|
||||
},
|
||||
checklist: ['抢购成交额是否低于昨日同节点', '采购用户是否异常回落', '付款金额与成交额是否明显偏离', '高货值寄卖商品是否完成消化'],
|
||||
},
|
||||
'1455': {
|
||||
title: '下午寄卖/转卖快报',
|
||||
subtitle: '用户把上午抢到的商品继续寄卖或转卖,重点看新增寄售供给和奖金变化是否正常。',
|
||||
metricLabels: {
|
||||
primaryUsers: '寄卖用户',
|
||||
primaryOrders: '转卖订单',
|
||||
amount: '转卖成交额',
|
||||
paidAmount: '回款金额',
|
||||
merchandise: '新增寄售',
|
||||
bonus: '奖金变化',
|
||||
},
|
||||
checklist: ['抢购商品是否按预期转入寄卖', '新增寄售商品是否满足下午供给', '个人奖金与推广奖金是否同步变化', '转卖回款是否出现异常延迟'],
|
||||
},
|
||||
}
|
||||
|
||||
function QueryState({
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
title,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
refetch: () => void
|
||||
title: string
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="loading-page">
|
||||
<DotLoading color="primary" />
|
||||
<p>正在加载{title}...</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<section className="error-page">
|
||||
<ErrorBlock status="default" title={`${title}加载失败`} description="后端接口暂不可用,请确认服务、登录态或接口权限后重试。" />
|
||||
<Button color="primary" onClick={refetch}>
|
||||
重新加载
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function OperationsHeader({
|
||||
kicker,
|
||||
title,
|
||||
description,
|
||||
extra,
|
||||
}: {
|
||||
kicker: string
|
||||
title: string
|
||||
description: string
|
||||
extra?: string
|
||||
}) {
|
||||
return (
|
||||
<header className="operations-header">
|
||||
<p className="eyebrow">{kicker}</p>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
{extra && <span>{extra}</span>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotDetailCard({ snapshot }: { snapshot: TodaySnapshot }) {
|
||||
const status = snapshotStatusMeta[snapshot.status]
|
||||
const slotMeta = snapshotSlotMeta[snapshot.slot]
|
||||
|
||||
return (
|
||||
<article className="snapshot-detail-card">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">{snapshot.slot}</p>
|
||||
<h2>{slotMeta.title}</h2>
|
||||
</div>
|
||||
<Tag color={status.color}>{status.label}</Tag>
|
||||
</div>
|
||||
<p className="snapshot-detail-subtitle">{slotMeta.subtitle}</p>
|
||||
<p className="snapshot-detail-message">{snapshot.message}</p>
|
||||
{snapshot.generatedAt && <p className="snapshot-time">生成时间:{snapshot.generatedAt}</p>}
|
||||
<div className="snapshot-grid snapshot-grid--wide">
|
||||
<span>
|
||||
{slotMeta.metricLabels.primaryUsers}
|
||||
<strong>{formatNumber(snapshot.purchaseUsers)}人</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.primaryOrders}
|
||||
<strong>{formatNumber(snapshot.orderCount)}单</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.amount}
|
||||
<strong>{formatMoney(snapshot.dealAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.paidAmount}
|
||||
<strong>{formatMoney(snapshot.paidAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.merchandise}
|
||||
<strong>{formatNumber(snapshot.newMerchandiseCount)}件</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.bonus}
|
||||
<strong>{formatMoney(Number(snapshot.selfBonusChange) + Number(snapshot.shareBonusChange))}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function buildDailyReports(data: DashboardOverview) {
|
||||
return data.trends
|
||||
.slice(-4)
|
||||
.reverse()
|
||||
.map((trend, index) => ({
|
||||
...trend,
|
||||
status: index === 0 ? '已生成' : '历史快照',
|
||||
bonusRate: Number(trend.amount) > 0 ? (Number(trend.bonus) / Number(trend.amount)) * 100 : 0,
|
||||
}))
|
||||
}
|
||||
|
||||
export function DailyReportPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const [isArchiving, setIsArchiving] = useState(false)
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="经营日报" />
|
||||
|
||||
if (!data) return state
|
||||
|
||||
const reports = buildDailyReports(data)
|
||||
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
setIsArchiving(true)
|
||||
const blob = await downloadDailyReportArchive(data.businessDate)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `dashboard-daily-report-${data.businessDate}.html`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
Toast.show({ icon: 'success', content: '归档 HTML 已生成' })
|
||||
} catch {
|
||||
Toast.show({ icon: 'fail', content: '归档生成失败,请稍后重试' })
|
||||
} finally {
|
||||
setIsArchiving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Daily Report"
|
||||
title="经营日报"
|
||||
description="按日沉淀成交、订单、用户与奖金变化,方便老板回看最近经营节奏。"
|
||||
extra={`最新数据:${data.businessDate}`}
|
||||
/>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Workday</p>
|
||||
<h2>上个工作日重点</h2>
|
||||
</div>
|
||||
<Tag color="success">已归档</Tag>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{data.kpis.slice(0, 4).map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Trend</p>
|
||||
<h2>最近 7 天趋势</h2>
|
||||
</div>
|
||||
</div>
|
||||
<MiniTrendChart data={data.trends} />
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Archive</p>
|
||||
<h2>日报归档</h2>
|
||||
</div>
|
||||
<button className="text-button" type="button" disabled={isArchiving} onClick={() => void handleArchive()}>
|
||||
{isArchiving ? '生成中...' : '生成归档'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="report-list">
|
||||
{reports.map((report) => (
|
||||
<button className="report-item" key={report.date} type="button">
|
||||
<span>
|
||||
<strong>{report.date}</strong>
|
||||
<small>{report.status}</small>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{formatMoney(report.amount)}</strong>
|
||||
<small>
|
||||
{formatNumber(report.orders)} 单 / 奖金占比 {formatNumber(report.bonusRate, 1)}%
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function TodaySnapshotPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="今日快报" />
|
||||
|
||||
if (!data) return state
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Today Snapshot"
|
||||
title="今日快报"
|
||||
description="10:15 看上一日寄卖商品的抢购结果,14:55 看抢到商品的寄卖/转卖承接情况。"
|
||||
extra="节点状态随 Mock 场景切换"
|
||||
/>
|
||||
|
||||
<section className="section-block snapshot-page-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Timeline</p>
|
||||
<h2>节点快报</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="snapshot-stack">
|
||||
{data.snapshots.map((snapshot) => (
|
||||
<SnapshotDetailCard key={snapshot.slot} snapshot={snapshot} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Checklist</p>
|
||||
<h2>节点检查项</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="check-list">
|
||||
{data.snapshots.flatMap((snapshot) =>
|
||||
snapshotSlotMeta[snapshot.slot].checklist.map((item) => (
|
||||
<span key={`${snapshot.slot}-${item}`}>
|
||||
<strong>{snapshot.slot === '1015' ? '上午' : '下午'}</strong>
|
||||
{item}
|
||||
</span>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function RiskCenterPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const [activeLevel, setActiveLevel] = useState<RiskLevel | 'all'>('all')
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="风险中心" />
|
||||
|
||||
const filteredRisks = useMemo(() => {
|
||||
if (!data) return []
|
||||
if (activeLevel === 'all') return data.risks
|
||||
return data.risks.filter((risk) => risk.level === activeLevel)
|
||||
}, [activeLevel, data])
|
||||
|
||||
if (!data) return state
|
||||
|
||||
const dangerousFunds = data.fundPool.filter((metric) => metric.status === 'warning' || metric.status === 'danger')
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Risk Center"
|
||||
title="风险中心"
|
||||
description="把资金、积分与数据一致性风险集中处理,优先看红色和黄色事项。"
|
||||
extra={`${data.risks.length} 条待关注`}
|
||||
/>
|
||||
|
||||
<section className="risk-summary-grid" aria-label="风险概览">
|
||||
{(['red', 'yellow', 'gray'] as const).map((level) => {
|
||||
const meta = riskLevelMeta[level]
|
||||
const count = data.risks.filter((risk) => risk.level === level).length
|
||||
return (
|
||||
<button className={`risk-summary-card risk-summary-card--${level}`} key={level} type="button" onClick={() => setActiveLevel(level)}>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<strong>{count}</strong>
|
||||
<span>条风险</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<CapsuleTabs activeKey={activeLevel} onChange={(key) => setActiveLevel(key as RiskLevel | 'all')}>
|
||||
<CapsuleTabs.Tab title="全部" key="all" />
|
||||
<CapsuleTabs.Tab title="红色" key="red" />
|
||||
<CapsuleTabs.Tab title="黄色" key="yellow" />
|
||||
<CapsuleTabs.Tab title="灰色" key="gray" />
|
||||
</CapsuleTabs>
|
||||
<div className="risk-list">
|
||||
{filteredRisks.map((risk) => {
|
||||
const meta = riskLevelMeta[risk.level]
|
||||
return (
|
||||
<button className="risk-item" key={risk.id} type="button">
|
||||
<div className="risk-header">
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<span>{risk.type}</span>
|
||||
<time>{risk.discoveredAt}</time>
|
||||
</div>
|
||||
<strong>{risk.title}</strong>
|
||||
<p>{risk.description}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Fund Watch</p>
|
||||
<h2>资金池关注项</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{dangerousFunds.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfilePage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="我的" />
|
||||
|
||||
if (!data) return state
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Profile"
|
||||
title="我的"
|
||||
description="展示当前驾驶舱权限、数据环境与演示版本,方便联调时确认口径。"
|
||||
extra="老板驾驶舱 H5"
|
||||
/>
|
||||
|
||||
<section className="profile-card">
|
||||
<div className="profile-avatar" aria-hidden="true">
|
||||
老
|
||||
</div>
|
||||
<div>
|
||||
<h2>老板视角</h2>
|
||||
<p>可查看经营概览、今日快报、排行与风险预警。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Environment</p>
|
||||
<h2>数据环境</h2>
|
||||
</div>
|
||||
<Tag color="warning">Mock</Tag>
|
||||
</div>
|
||||
<div className="info-list">
|
||||
<span>
|
||||
<small>数据日期</small>
|
||||
<strong>{data.businessDate}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>生成时间</small>
|
||||
<strong>{data.generatedAt}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>API 模式</small>
|
||||
<strong>{import.meta.env.VITE_MOCK_ENABLED === 'false' ? '真实接口' : 'Mock 演示'}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Permissions</p>
|
||||
<h2>权限模块</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="check-list">
|
||||
<span>经营概览:可见</span>
|
||||
<span>资金池摘要:可见</span>
|
||||
<span>风险预警:可见</span>
|
||||
<span>导出能力:待接入</span>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
72
dashboard-frontend/src/features/boss-dashboard/types.ts
Normal file
72
dashboard-frontend/src/features/boss-dashboard/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export type MetricStatus = 'normal' | 'success' | 'warning' | 'danger'
|
||||
|
||||
export type SnapshotStatus = 'pending' | 'success' | 'failed' | 'temporary'
|
||||
|
||||
export type SnapshotSlot = '1015' | '1455'
|
||||
|
||||
export type KpiMetric = {
|
||||
key: string
|
||||
title: string
|
||||
value: number | string | null
|
||||
unit?: string
|
||||
trendLabel?: string
|
||||
trendValue?: number | string
|
||||
status: MetricStatus
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
export type TodaySnapshot = {
|
||||
slot: SnapshotSlot
|
||||
title: string
|
||||
status: SnapshotStatus
|
||||
generatedAt?: string
|
||||
message: string
|
||||
purchaseUsers: number
|
||||
orderCount: number
|
||||
dealAmount: number | string
|
||||
paidAmount: number | string
|
||||
newMerchandiseCount: number
|
||||
selfBonusChange: number | string
|
||||
shareBonusChange: number | string
|
||||
}
|
||||
|
||||
export type TrendPoint = {
|
||||
date: string
|
||||
amount: number | string
|
||||
orders: number
|
||||
newUsers: number
|
||||
bonus: number | string
|
||||
}
|
||||
|
||||
export type RankItem = {
|
||||
id: string
|
||||
name: string
|
||||
value: number | string
|
||||
description: string
|
||||
badge?: string
|
||||
}
|
||||
|
||||
export type RiskLevel = 'red' | 'yellow' | 'gray'
|
||||
|
||||
export type RiskAlert = {
|
||||
id: string
|
||||
level: RiskLevel
|
||||
type: string
|
||||
title: string
|
||||
description: string
|
||||
discoveredAt: string
|
||||
}
|
||||
|
||||
export type DashboardOverview = {
|
||||
businessDate: string
|
||||
generatedAt: string
|
||||
summary: string
|
||||
kpis: KpiMetric[]
|
||||
fundPool: KpiMetric[]
|
||||
snapshots: TodaySnapshot[]
|
||||
trends: TrendPoint[]
|
||||
userRanks: RankItem[]
|
||||
teamRanks: RankItem[]
|
||||
productRanks: RankItem[]
|
||||
risks: RiskAlert[]
|
||||
}
|
||||
18
dashboard-frontend/src/features/common/PlaceholderPage.tsx
Normal file
18
dashboard-frontend/src/features/common/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Empty } from 'antd-mobile'
|
||||
|
||||
type PlaceholderPageProps = {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function PlaceholderPage({ title, description }: PlaceholderPageProps) {
|
||||
return (
|
||||
<section className="placeholder-page">
|
||||
<div className="mobile-page-header">
|
||||
<p className="eyebrow">经营驾驶舱</p>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
<Empty description={description} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
589
dashboard-frontend/src/index.css
Normal file
589
dashboard-frontend/src/index.css
Normal file
@@ -0,0 +1,589 @@
|
||||
:root {
|
||||
--bg: #fff6f1;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f6f9fb;
|
||||
--text: #132033;
|
||||
--muted: #6b7a90;
|
||||
--border: rgba(19, 32, 51, 0.08);
|
||||
--primary: #ff5b36;
|
||||
--primary-deep: #f04a2a;
|
||||
--primary-soft: #fff0eb;
|
||||
--success: #14a46c;
|
||||
--warning: #ffb000;
|
||||
--danger: #dc2626;
|
||||
--shadow: 0 16px 40px rgba(255, 91, 54, 0.14);
|
||||
--radius-xl: 28px;
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 14px;
|
||||
font-family:
|
||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--adm-color-primary: var(--primary);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 91, 54, 0.2), transparent 28rem),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid rgba(255, 91, 54, 0.72);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: min(100%, 430px);
|
||||
min-height: 100svh;
|
||||
margin: 0 auto;
|
||||
background: var(--bg);
|
||||
box-shadow: 0 0 0 1px rgba(19, 32, 51, 0.04);
|
||||
}
|
||||
|
||||
.mobile-shell {
|
||||
min-height: 100svh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-main {
|
||||
min-height: 100svh;
|
||||
padding-bottom: calc(74px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
width: min(100%, 430px);
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-top: 1px solid var(--border);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.dashboard-page {
|
||||
padding: 14px 14px 24px;
|
||||
}
|
||||
|
||||
.dashboard-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
color: #fff;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 91, 54, 0.98), rgba(255, 139, 82, 0.92)),
|
||||
radial-gradient(circle at 90% 10%, rgba(255, 176, 0, 0.42), transparent 18rem);
|
||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero-topline,
|
||||
.section-title-row,
|
||||
.risk-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-topline span,
|
||||
.eyebrow,
|
||||
.section-kicker {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-topline button {
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.dashboard-hero h1 {
|
||||
margin: 18px 0 8px;
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-summary {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-metric {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.hero-metric span,
|
||||
.hero-metric small {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hero-metric strong {
|
||||
display: block;
|
||||
margin: 6px 0;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
min-height: 112px;
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.kpi-card--featured {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.kpi-title,
|
||||
.kpi-trend,
|
||||
.section-kicker,
|
||||
.snapshot-time,
|
||||
.rank-content small,
|
||||
.risk-item p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
line-height: 1.08;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.kpi-trend {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kpi-trend span {
|
||||
margin-left: 6px;
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.kpi-card--success .kpi-trend span {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.kpi-card--warning .kpi-trend span {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.kpi-card--danger .kpi-value,
|
||||
.kpi-card--danger .kpi-trend span {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.section-block {
|
||||
margin-top: 14px;
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.section-title-row h2 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.compact-section .kpi-grid {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.snapshot-section .adm-capsule-tabs {
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.snapshot-card {
|
||||
padding: 14px;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.snapshot-message {
|
||||
margin: 0 0 8px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.snapshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.snapshot-grid span {
|
||||
min-height: 64px;
|
||||
padding: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.snapshot-grid strong {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.mini-trend-chart {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.text-button,
|
||||
.rank-item,
|
||||
.risk-item {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text-button:disabled {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.rank-list,
|
||||
.risk-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.rank-item,
|
||||
.risk-item {
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.rank-item {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rank-index {
|
||||
display: inline-grid;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
background: var(--text);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.rank-content strong,
|
||||
.rank-content small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rank-value {
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.risk-count {
|
||||
color: var(--danger);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.risk-header {
|
||||
justify-content: flex-start;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.risk-header time {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.risk-item strong {
|
||||
display: block;
|
||||
margin: 10px 0 4px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.risk-item p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.placeholder-page,
|
||||
.loading-page,
|
||||
.error-page {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.mobile-page-header h1 {
|
||||
margin: 4px 0 24px;
|
||||
}
|
||||
|
||||
.operations-page {
|
||||
padding: 14px 14px 24px;
|
||||
}
|
||||
|
||||
.operations-header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
color: #fff;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 91, 54, 0.98), rgba(255, 139, 82, 0.92)),
|
||||
radial-gradient(circle at 90% 10%, rgba(255, 176, 0, 0.42), transparent 18rem);
|
||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.operations-header h1 {
|
||||
margin: 12px 0 8px;
|
||||
font-size: 28px;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.operations-header p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.operations-header .eyebrow {
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.operations-header span {
|
||||
display: inline-flex;
|
||||
margin-top: 16px;
|
||||
padding: 7px 12px;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.report-list,
|
||||
.check-list,
|
||||
.info-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.report-item {
|
||||
display: grid;
|
||||
grid-template-columns: 82px 1fr;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
text-align: left;
|
||||
background: var(--surface-soft);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.report-item span,
|
||||
.info-list span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.report-item small,
|
||||
.info-list small,
|
||||
.profile-card p {
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.snapshot-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.snapshot-detail-card {
|
||||
padding: 14px;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.snapshot-detail-subtitle {
|
||||
margin: 12px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.snapshot-detail-message {
|
||||
margin: 10px 0 8px;
|
||||
font-weight: 700;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.snapshot-grid--wide {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.check-list span,
|
||||
.info-list span {
|
||||
padding: 12px;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.check-list span {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.check-list strong {
|
||||
margin-right: 8px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.risk-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.risk-summary-card {
|
||||
display: grid;
|
||||
min-height: 104px;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.risk-summary-card strong {
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.risk-summary-card span:last-child {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.risk-summary-card--red strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.risk-summary-card--yellow strong {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.risk-summary-card--gray strong {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
display: grid;
|
||||
grid-template-columns: 58px 1fr;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.profile-card h2,
|
||||
.profile-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(145deg, var(--primary), var(--warning));
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.loading-page,
|
||||
.error-page {
|
||||
display: grid;
|
||||
min-height: 60svh;
|
||||
place-content: center;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
20
dashboard-frontend/src/main.tsx
Normal file
20
dashboard-frontend/src/main.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'antd-mobile/es/global'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
const startApp = async () => {
|
||||
if (import.meta.env.VITE_MOCK_ENABLED !== 'false') {
|
||||
const { worker } = await import('./services/mock/browser')
|
||||
await worker.start({ onUnhandledRequest: 'bypass' })
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
|
||||
void startApp()
|
||||
26
dashboard-frontend/src/services/http/client.ts
Normal file
26
dashboard-frontend/src/services/http/client.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export const httpClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL ?? '',
|
||||
timeout: 8000,
|
||||
})
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
code: number
|
||||
message?: string
|
||||
msg?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function getApiData<T>(url: string): Promise<T> {
|
||||
const response = await httpClient.get<ApiResponse<T>>(url)
|
||||
if (response.data.code !== 0 && response.data.code !== 200) {
|
||||
throw new Error(response.data.msg ?? response.data.message ?? '接口请求失败')
|
||||
}
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function getBlob(url: string): Promise<Blob> {
|
||||
const response = await httpClient.get<Blob>(url, { responseType: 'blob' })
|
||||
return response.data
|
||||
}
|
||||
4
dashboard-frontend/src/services/mock/browser.ts
Normal file
4
dashboard-frontend/src/services/mock/browser.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { setupWorker } from 'msw/browser'
|
||||
import { handlers } from './handlers'
|
||||
|
||||
export const worker = setupWorker(...handlers)
|
||||
58
dashboard-frontend/src/services/mock/handlers.ts
Normal file
58
dashboard-frontend/src/services/mock/handlers.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { dashboardMock } from '../../features/boss-dashboard/mock'
|
||||
|
||||
function buildArchiveHtml() {
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>经营日报归档 - ${dashboardMock.businessDate}</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #132033; background: #fff6f1; }
|
||||
main { max-width: 820px; margin: 0 auto; padding: 28px 18px 40px; }
|
||||
header { color: #fff; padding: 26px; border-radius: 0 0 28px 28px; background: linear-gradient(145deg, #ff5b36, #ff8b52); }
|
||||
section { margin-top: 16px; padding: 18px; background: #fff; border-radius: 24px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
article { padding: 14px; border-radius: 18px; background: #f6f9fb; }
|
||||
small { color: #6b7a90; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<p>Daily Report Archive</p>
|
||||
<h1>经营日报归档</h1>
|
||||
<p>${dashboardMock.summary}</p>
|
||||
<small>数据日期:${dashboardMock.businessDate} / 生成时间:${dashboardMock.generatedAt}</small>
|
||||
</header>
|
||||
<section>
|
||||
<h2>核心经营指标</h2>
|
||||
<div class="grid">
|
||||
${dashboardMock.kpis
|
||||
.map((metric) => `<article><small>${metric.title}</small><h3>${metric.value}${metric.unit ?? ''}</h3><small>${metric.trendLabel ?? ''}</small></article>`)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
export const handlers = [
|
||||
http.get('/api/admin/dashboard/overview', () => {
|
||||
return HttpResponse.json({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: dashboardMock,
|
||||
})
|
||||
}),
|
||||
http.get('/api/admin/dashboard/daily-report/archive', () => {
|
||||
return new HttpResponse(buildArchiveHtml(), {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="dashboard-daily-report-${dashboardMock.businessDate}.html"`,
|
||||
},
|
||||
})
|
||||
}),
|
||||
]
|
||||
22
dashboard-frontend/src/utils/format.test.ts
Normal file
22
dashboard-frontend/src/utils/format.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatMetricValue, formatMoney, formatNumber, formatTrend } from './format'
|
||||
|
||||
describe('format helpers', () => {
|
||||
it('formats money with yuan symbol and two decimals', () => {
|
||||
expect(formatMoney(1289360.4)).toBe('¥1,289,360.40')
|
||||
})
|
||||
|
||||
it('formats metric values based on unit', () => {
|
||||
expect(formatMetricValue(418471.07, '分')).toBe('418,471.070')
|
||||
expect(formatMetricValue(936, '人')).toBe('936人')
|
||||
})
|
||||
|
||||
it('uses placeholder for empty values', () => {
|
||||
expect(formatNumber(null)).toBe('--')
|
||||
})
|
||||
|
||||
it('adds plus sign for positive trend values', () => {
|
||||
expect(formatTrend(8.6)).toBe('+8.6%')
|
||||
expect(formatTrend(-3.2)).toBe('-3.2%')
|
||||
})
|
||||
})
|
||||
33
dashboard-frontend/src/utils/format.ts
Normal file
33
dashboard-frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export function formatMoney(value: number | string | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') return '--'
|
||||
const numberValue = Number(value)
|
||||
if (Number.isNaN(numberValue)) return String(value)
|
||||
return `¥${numberValue.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | string | null | undefined, digits = 0): string {
|
||||
if (value === null || value === undefined || value === '') return '--'
|
||||
const numberValue = Number(value)
|
||||
if (Number.isNaN(numberValue)) return String(value)
|
||||
return numberValue.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatMetricValue(value: number | string | null, unit?: string): string {
|
||||
if (unit === '元') return formatMoney(value)
|
||||
if (unit === '分') return formatNumber(value, 3)
|
||||
return `${formatNumber(value)}${unit ?? ''}`
|
||||
}
|
||||
|
||||
export function formatTrend(value?: number | string): string {
|
||||
if (value === undefined || value === '') return ''
|
||||
const numberValue = Number(value)
|
||||
if (Number.isNaN(numberValue)) return String(value)
|
||||
const prefix = numberValue > 0 ? '+' : ''
|
||||
return `${prefix}${numberValue.toFixed(1)}%`
|
||||
}
|
||||
25
dashboard-frontend/tsconfig.app.json
Normal file
25
dashboard-frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
dashboard-frontend/tsconfig.json
Normal file
7
dashboard-frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
dashboard-frontend/tsconfig.node.json
Normal file
24
dashboard-frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
17
dashboard-frontend/vite.config.ts
Normal file
17
dashboard-frontend/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:30032',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user