diff --git a/.gitignore b/.gitignore index 3ee4448..c9b9501 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ server/dukang-api/.env.production.local server/dukang-api/.env.staging server/dukang-api/.env.staging.local deploy/deploy.env +deploy/.staging-mysql.pass +deploy/_ops-*.log +deploy/_ops-*.sh + .DS_Store coverage/ *.tsbuildinfo diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json index 04ab1b0..4c86b6a 100644 --- a/apps/admin-web/package.json +++ b/apps/admin-web/package.json @@ -13,6 +13,8 @@ "@dukang/shared-types": "workspace:*", "antd": "^5.22.0", "dayjs": "^1.11.13", + "echarts": "^6.1.0", + "echarts-for-react": "^3.0.6", "element-china-area-data": "^6.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index f44955d..d7f5c9c 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -88,6 +88,51 @@ export type DashboardStats = { ordersByStatus: Array<{ status: string; count: number }>; }; +export type DashboardAnalytics = { + summary: { + users: number; + orders: number; + payingUsers: number; + partners: number; + stores: number; + redeems: number; + redeemAmount: number; + }; + byDate: Array<{ + date: string; + users: number; + orders: number; + partners: number; + stores: number; + redeems: number; + redeemAmount: number; + }>; + byCity: Array<{ + cityId: string; + cityName: string; + users: number; + orders: number; + partners: number; + stores: number; + redeems: number; + redeemAmount: number; + }>; + byPromo: Array<{ + promoCodeId: string | null; + code: string; + name: string; + users: number; + orders: number; + }>; + byPartner: Array<{ + partnerAccountId: string; + companyName: string; + stores: number; + redeems: number; + redeemAmount: number; + }>; +}; + export type SystemVersion = { id: string; gitTag: string | null; diff --git a/apps/admin-web/src/pages/DashboardPage.tsx b/apps/admin-web/src/pages/DashboardPage.tsx index 6bf200f..041a715 100644 --- a/apps/admin-web/src/pages/DashboardPage.tsx +++ b/apps/admin-web/src/pages/DashboardPage.tsx @@ -1,17 +1,22 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { - Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message, + Button, Card, Col, DatePicker, Descriptions, Form, Modal, Row, Select, Space, Statistic, Table, Typography, message, } from 'antd'; import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons'; +import dayjs, { type Dayjs } from 'dayjs'; +import ReactECharts from 'echarts-for-react'; +import type { EChartsOption } from 'echarts'; import { request, + type DashboardAnalytics, type DashboardStats, type DeployTriggerResult, type HqProfile, + type Paginated, type SystemVersion, } from '../lib/api'; -import { fmtTime } from '../lib/constants'; +import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; const STATUS_LABELS: Record = { PENDING_PAY: '待付款', @@ -31,6 +36,26 @@ const DEPLOYED_BY_LABELS: Record = { admin: 'Admin 发布', }; +type CityOption = { id: string; name: string; code: string }; +type PromoOption = { id: string; code: string; name: string }; + +type AnalyticsFilters = { + range: [Dayjs, Dayjs]; + cityId?: string; + promoCodeId?: string; + partnerAccountId?: string; +}; + +function buildAnalyticsQs(f: AnalyticsFilters) { + const qs = new URLSearchParams(); + qs.set('dateFrom', f.range[0].format('YYYY-MM-DD')); + qs.set('dateTo', f.range[1].format('YYYY-MM-DD')); + if (f.cityId) qs.set('cityId', f.cityId); + if (f.promoCodeId) qs.set('promoCodeId', f.promoCodeId); + if (f.partnerAccountId) qs.set('partnerAccountId', f.partnerAccountId); + return qs.toString(); +} + export default function DashboardPage() { const [stats, setStats] = useState(null); const [version, setVersion] = useState(null); @@ -40,6 +65,21 @@ export default function DashboardPage() { const [deploying, setDeploying] = useState(false); const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; + const [filterForm] = Form.useForm<{ + range: [Dayjs, Dayjs]; + cityId?: string; + promoCodeId?: string; + partnerAccountId?: string; + }>(); + const [filters, setFilters] = useState({ + range: [dayjs().subtract(29, 'day'), dayjs()], + }); + const [analytics, setAnalytics] = useState(null); + const [analyticsLoading, setAnalyticsLoading] = useState(true); + const [cities, setCities] = useState([]); + const [promos, setPromos] = useState([]); + const [partners, setPartners] = useState>([]); + const loadVersion = useCallback(() => { setVersionLoading(true); return request('/admin/dashboard/version') @@ -48,6 +88,17 @@ export default function DashboardPage() { .finally(() => setVersionLoading(false)); }, []); + const loadAnalytics = useCallback((f: AnalyticsFilters) => { + setAnalyticsLoading(true); + return request(`/admin/dashboard/analytics?${buildAnalyticsQs(f)}`) + .then(setAnalytics) + .catch((e) => { + message.error(e instanceof Error ? e.message : '加载统计失败'); + setAnalytics(null); + }) + .finally(() => setAnalyticsLoading(false)); + }, []); + useEffect(() => { request('/admin/dashboard/stats') .then(setStats) @@ -64,8 +115,64 @@ export default function DashboardPage() { .catch(() => { setVersionLoading(false); }); + void request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) + .then((res) => setCities(res.items ?? [])) + .catch(() => setCities([])); + void request>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) + .then((res) => setPromos(res.items ?? [])) + .catch(() => setPromos([])); + void request>( + `/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`, + ) + .then((res) => setPartners(res.items ?? [])) + .catch(() => setPartners([])); }, [loadVersion]); + useEffect(() => { + void loadAnalytics(filters); + }, [filters, loadAnalytics]); + + function applyFilters(values: { + range?: [Dayjs, Dayjs]; + cityId?: string; + promoCodeId?: string; + partnerAccountId?: string; + }) { + const next: AnalyticsFilters = { + range: values.range ?? filters.range, + cityId: values.cityId || undefined, + promoCodeId: values.promoCodeId || undefined, + partnerAccountId: values.partnerAccountId || undefined, + }; + setFilters(next); + } + + function setCityFilter(cityId: string) { + const next = { ...filters, cityId: cityId === filters.cityId ? undefined : cityId }; + filterForm.setFieldsValue({ cityId: next.cityId }); + setFilters(next); + } + + function setPromoFilter(promoCodeId: string) { + const key = promoCodeId === 'null' || promoCodeId === '' ? 'none' : promoCodeId; + const next = { + ...filters, + promoCodeId: key === filters.promoCodeId ? undefined : key, + }; + filterForm.setFieldsValue({ promoCodeId: next.promoCodeId }); + setFilters(next); + } + + function setPartnerFilter(partnerAccountId: string) { + const next = { + ...filters, + partnerAccountId: + partnerAccountId === filters.partnerAccountId ? undefined : partnerAccountId, + }; + filterForm.setFieldsValue({ partnerAccountId: next.partnerAccountId }); + setFilters(next); + } + function handleDeploy() { Modal.confirm({ title: '确认发布更新?', @@ -89,6 +196,176 @@ export default function DashboardPage() { const shortSha = version?.commitId ? version.commitId.slice(0, 7) : '—'; + const ordersDrillQs = useMemo(() => { + const qs = new URLSearchParams(); + qs.set('createdFrom', filters.range[0].format('YYYY-MM-DD')); + qs.set('createdTo', filters.range[1].format('YYYY-MM-DD')); + if (filters.cityId && filters.cityId !== 'none') qs.set('cityId', filters.cityId); + return qs.toString(); + }, [filters]); + + const byDateOption = useMemo(() => { + const rows = analytics?.byDate ?? []; + return { + tooltip: { trigger: 'axis' }, + legend: { data: ['新增用户', '订单数'] }, + grid: { left: 40, right: 20, top: 40, bottom: 40 }, + xAxis: { + type: 'category', + data: rows.map((r) => r.date.slice(5)), + axisLabel: { rotate: rows.length > 14 ? 45 : 0 }, + }, + yAxis: { type: 'value', minInterval: 1 }, + series: [ + { name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) }, + { name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) }, + ], + }; + }, [analytics]); + + const byCityOption = useMemo(() => { + const rows = analytics?.byCity ?? []; + return { + tooltip: { trigger: 'axis' }, + legend: { data: ['用户', '订单'] }, + grid: { left: 48, right: 20, top: 40, bottom: 48 }, + xAxis: { + type: 'category', + data: rows.map((r) => r.cityName), + axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 }, + }, + yAxis: { type: 'value', minInterval: 1 }, + series: [ + { name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 }, + { name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 }, + ], + }; + }, [analytics]); + + const byPromoOption = useMemo(() => { + const rows = analytics?.byPromo ?? []; + return { + tooltip: { trigger: 'axis' }, + legend: { data: ['用户', '订单'] }, + grid: { left: 48, right: 20, top: 40, bottom: 64 }, + xAxis: { + type: 'category', + data: rows.map((r) => r.name || r.code), + axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 }, + }, + yAxis: { type: 'value', minInterval: 1 }, + series: [ + { name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 }, + { name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 }, + ], + }; + }, [analytics]); + + const opsByDateOption = useMemo(() => { + const rows = analytics?.byDate ?? []; + return { + tooltip: { trigger: 'axis' }, + legend: { data: ['新增合伙人', '新签门店', '核销笔数', '核销金额'] }, + grid: { left: 48, right: 48, top: 48, bottom: 40 }, + xAxis: { + type: 'category', + data: rows.map((r) => r.date.slice(5)), + axisLabel: { rotate: rows.length > 14 ? 45 : 0 }, + }, + yAxis: [ + { type: 'value', name: '数量', minInterval: 1 }, + { type: 'value', name: '金额', minInterval: 1 }, + ], + series: [ + { name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) }, + { name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) }, + { name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) }, + { + name: '核销金额', + type: 'line', + smooth: true, + yAxisIndex: 1, + data: rows.map((r) => r.redeemAmount), + }, + ], + }; + }, [analytics]); + + const opsByCityOption = useMemo(() => { + const rows = analytics?.byCity ?? []; + return { + tooltip: { trigger: 'axis' }, + legend: { data: ['合伙人', '门店', '核销笔数'] }, + grid: { left: 48, right: 20, top: 40, bottom: 48 }, + xAxis: { + type: 'category', + data: rows.map((r) => r.cityName), + axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 }, + }, + yAxis: { type: 'value', minInterval: 1 }, + series: [ + { name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 }, + { name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 }, + { name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 }, + ], + }; + }, [analytics]); + + const opsByPartnerOption = useMemo(() => { + const rows = analytics?.byPartner ?? []; + return { + tooltip: { trigger: 'axis' }, + legend: { data: ['门店', '核销笔数', '核销金额'] }, + grid: { left: 48, right: 48, top: 40, bottom: 64 }, + xAxis: { + type: 'category', + data: rows.map((r) => r.companyName), + axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 }, + }, + yAxis: [ + { type: 'value', name: '数量', minInterval: 1 }, + { type: 'value', name: '金额' }, + ], + series: [ + { name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 }, + { name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 }, + { + name: '核销金额', + type: 'line', + yAxisIndex: 1, + data: rows.map((r) => r.redeemAmount), + }, + ], + }; + }, [analytics]); + + const redeemByCityOption = useMemo(() => { + const rows = analytics?.byCity ?? []; + return { + tooltip: { trigger: 'axis' }, + legend: { data: ['核销笔数', '核销金额'] }, + grid: { left: 48, right: 48, top: 40, bottom: 48 }, + xAxis: { + type: 'category', + data: rows.map((r) => r.cityName), + axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 }, + }, + yAxis: [ + { type: 'value', name: '笔数', minInterval: 1 }, + { type: 'value', name: '金额' }, + ], + series: [ + { name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 36 }, + { + name: '核销金额', + type: 'line', + yAxisIndex: 1, + data: rows.map((r) => r.redeemAmount), + }, + ], + }; + }, [analytics]); + return (
数据概览 @@ -176,6 +453,237 @@ export default function DashboardPage() { + } + size="small" + onClick={() => void loadAnalytics(filters)} + > + 刷新 + + } + > +
+ + + + + ({ value: p.id, label: `${p.name}(${p.code})` })), + ]} + /> + + +