V3.0: 门店分级与风险等级页面、数据填充脚本、后端API

- 新增V3.0数据库表结构(12-15)和数据填充脚本(16)
- 新增后端路由: store-grade, enterprise, product, intelligence, alert, scheduler, target
- 新增前端页面: StoreGradePage, EnterprisePage, ProductLifecyclePage, IntelligencePage, AlertManagementPage, DataImportPage, SchedulerManagementPage, TargetManagementPage
- 门店分级: 自动分级算法(达成率40%+毛利率25%+会员15%+风险20%)
- 风险等级: 基于mv_store_risk_rating模型展示风险分布和原因
- 弹窗: 雷达图+详细指标+分级原因+风险原因(primary_issue)
- FilterableTable: filterOptions支持{value,label}对象格式
This commit is contained in:
freedakgmail
2026-08-05 20:09:39 +08:00
parent 814abf4a68
commit d04e837d9c
30 changed files with 5052 additions and 144 deletions
+16
View File
@@ -38,6 +38,14 @@ import { EmployeePerformancePage } from '@/pages/EmployeePerformancePage'
import { InventoryTurnoverPage } from '@/pages/InventoryTurnoverPage'
import { TenantManagementPage } from './pages/TenantManagementPage'
import { TenantUsersPage } from './pages/TenantUsersPage'
import { TargetManagementPage } from './pages/TargetManagementPage'
import { AlertManagementPage } from './pages/AlertManagementPage'
import { SchedulerManagementPage } from './pages/SchedulerManagementPage'
import { StoreGradePage } from './pages/StoreGradePage'
import { ProductLifecyclePage } from './pages/ProductLifecyclePage'
import { DataImportPage } from './pages/DataImportPage'
import { EnterprisePage } from './pages/EnterprisePage'
import { IntelligencePage } from './pages/IntelligencePage'
import { RoleRoute } from './components/RoleRoute'
const queryClient = new QueryClient({
@@ -117,6 +125,14 @@ export default function App() {
<Route path="/inventory-turnover" element={<RoleRoute roles={['hq','dept']}><InventoryTurnoverPage /></RoleRoute>} />
<Route path="/tenant-management" element={<RoleRoute roles={['platform_admin']}><TenantManagementPage /></RoleRoute>} />
<Route path="/tenant-users" element={<RoleRoute roles={['hq']}><TenantUsersPage /></RoleRoute>} />
<Route path="/target" element={<RoleRoute roles={['hq','dept','regional']}><TargetManagementPage /></RoleRoute>} />
<Route path="/alert" element={<RoleRoute roles={['hq','dept','regional','store']}><AlertManagementPage /></RoleRoute>} />
<Route path="/scheduler" element={<RoleRoute roles={['hq']}><SchedulerManagementPage /></RoleRoute>} />
<Route path="/store-grade" element={<RoleRoute roles={['hq','dept','regional']}><StoreGradePage /></RoleRoute>} />
<Route path="/product-lifecycle" element={<RoleRoute roles={['hq','dept']}><ProductLifecyclePage /></RoleRoute>} />
<Route path="/data-import" element={<RoleRoute roles={['hq','dept']}><DataImportPage /></RoleRoute>} />
<Route path="/enterprise" element={<RoleRoute roles={['hq','dept']}><EnterprisePage /></RoleRoute>} />
<Route path="/intelligence" element={<RoleRoute roles={['hq','dept']}><IntelligencePage /></RoleRoute>} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Layout>
+1 -1
View File
@@ -33,7 +33,7 @@ interface FilterableTableProps {
filter?: string
onFilterChange?: (filter: string) => void
filterButtons?: { key: string; label: string }[]
filterOptions?: string[]
filterOptions?: (string | { value: string; label: string })[]
searchKeys?: string[]
searchPlaceholder?: string
serverSearchValue?: string
+14 -1
View File
@@ -1,6 +1,6 @@
import { ReactNode, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon, Building2 } from 'lucide-react'
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon, Building2, Target, Bell, Timer, TrafficCone, Boxes, Download, Briefcase, Cpu } from 'lucide-react'
import { cn } from '@/lib/utils'
import { StatusBanner } from '@/components/StatusBanner'
@@ -90,6 +90,19 @@ const menuGroups: MenuGroup[] = [
{ path: '/ontology', label: '本体标准', icon: Receipt, roles: ['hq', 'dept'] },
],
},
{
title: 'V3.0 系统闭环',
items: [
{ path: '/target', label: '目标管理', icon: Target, roles: ['hq', 'dept', 'regional'] },
{ path: '/store-grade', label: '门店分级', icon: TrafficCone, roles: ['hq', 'dept', 'regional'] },
{ path: '/alert', label: '预警管理', icon: Bell, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/scheduler', label: '调度管理', icon: Timer, roles: ['hq'] },
{ path: '/product-lifecycle', label: '产品生命周期', icon: Boxes, roles: ['hq', 'dept'] },
{ path: '/data-import', label: '数据采集', icon: Download, roles: ['hq', 'dept'] },
{ path: '/enterprise', label: '企业协同', icon: Briefcase, roles: ['hq', 'dept'] },
{ path: '/intelligence', label: '智能升级', icon: Cpu, roles: ['hq', 'dept'] },
],
},
{
title: '平台管理',
items: [
+170
View File
@@ -0,0 +1,170 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { Tabs } from '@/components/Tabs'
import { formatNumber } from '@/lib/utils'
import { useState } from 'react'
export function AlertManagementPage() {
const [tab, setTab] = useState('logs')
const queryClient = useQueryClient()
const { data: overviewData, isLoading: overviewLoading } = useQuery({
queryKey: ['alert/overview'],
queryFn: () => api.get('/alert/overview'),
})
const { data: rulesData, isLoading: rulesLoading } = useQuery({
queryKey: ['alert/rules'],
queryFn: () => api.get('/alert/rules'),
})
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['alert/logs'],
queryFn: () => api.get('/alert/logs', { params: { page_size: 100 } }),
})
const handleMutation = useMutation({
mutationFn: (data: { id: number; handle_status: string; handle_comment?: string }) =>
api.patch(`/alert/logs/${data.id}`, { handle_status: data.handle_status, handle_comment: data.handle_comment }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['alert/logs'] }),
})
const toggleRuleMutation = useMutation({
mutationFn: (data: { id: number; is_enabled: boolean }) =>
api.patch(`/alert/rules/${data.id}`, { is_enabled: data.is_enabled }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['alert/rules'] }),
})
const overview = (overviewData as any)?.data || {}
const rules = (rulesData as any)?.data || []
const logs = (logsData as any)?.data || []
const pageLoading = overviewLoading && tab === 'logs'
if (pageLoading) {
return <LoadingSpinner text="加载预警数据..." />
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · </p>
</div>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="近30天预警总数" value={overview.total_alerts || 0} format="number" description="最近30天触发的预警总数" />
<MetricCard title="红灯预警" value={overview.red_alerts || 0} format="number" description="严重预警数量" status={overview.red_alerts > 0 ? 'bad' : 'good'} />
<MetricCard title="待处理预警" value={overview.pending_alerts || 0} format="number" description="尚未处理的预警" status={overview.pending_alerts > 0 ? 'warn' : 'good'} />
<MetricCard title="今日预警" value={overview.today_alerts || 0} format="number" description="今日触发的预警数" />
</div>
<Tabs
tabs={[
{ key: 'logs', label: '预警日志' },
{ key: 'rules', label: '预警规则' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'logs' && (
<CollapsibleSection title={`预警日志 (${logs.length})`} subtitle="按触发时间降序">
{logsLoading ? <LoadingSpinner text="加载预警日志..." /> : (
<FilterableTable
data={logs}
filterKey="severity"
filterLabel="全部级别"
sortOptions={[
{ key: 'triggered_at', label: '触发时间' },
{ key: 'metric_value', label: '指标值' },
{ key: 'severity', label: '级别' },
]}
defaultSort="triggered_at"
defaultOrder="desc"
columns={[
{ key: 'triggered_at', label: '触发时间', render: (r) => (r.triggered_at || '').substring(0, 19).replace('T', ' ') },
{ key: 'rule_name', label: '规则名称' },
{ key: 'store_code', label: '门店编码' },
{ key: 'store_name', label: '门店名称' },
{ key: 'metric_value', label: '指标值', align: 'right', render: (r) => Number(r.metric_value || 0).toFixed(2) },
{ key: 'threshold', label: '阈值', align: 'right', render: (r) => Number(r.threshold || 0).toFixed(2) },
{ key: 'severity', label: '级别', align: 'center', render: (r) => {
const colors: Record<string, string> = { red: 'bg-red-100 text-red-700', yellow: 'bg-yellow-100 text-yellow-700' }
const labels: Record<string, string> = { red: '红灯', yellow: '黄灯' }
return <span className={`rounded px-2 py-0.5 text-xs ${colors[r.severity] || ''}`}>{labels[r.severity] || r.severity}</span>
}},
{ key: 'handle_status', label: '处理状态', align: 'center', render: (r) => {
const colors: Record<string, string> = { pending: 'bg-gray-100 text-gray-600', handling: 'bg-blue-100 text-blue-700', resolved: 'bg-green-100 text-green-700', ignored: 'bg-gray-100 text-gray-500' }
const labels: Record<string, string> = { pending: '待处理', handling: '处理中', resolved: '已解决', ignored: '已忽略' }
return <span className={`rounded px-2 py-0.5 text-xs ${colors[r.handle_status] || ''}`}>{labels[r.handle_status] || r.handle_status}</span>
}},
{ key: 'actions', label: '操作', align: 'center', render: (r) => (
<div className="flex gap-1">
{r.handle_status === 'pending' && (
<>
<button
onClick={() => handleMutation.mutate({ id: r.id, handle_status: 'resolved', handle_comment: '已处理' })}
className="rounded bg-green-100 px-2 py-0.5 text-xs text-green-700 hover:bg-green-200"
></button>
<button
onClick={() => handleMutation.mutate({ id: r.id, handle_status: 'ignored', handle_comment: '已忽略' })}
className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-200"
></button>
</>
)}
</div>
)},
]}
/>
)}
</CollapsibleSection>
)}
{tab === 'rules' && (
<CollapsibleSection title={`预警规则 (${rules.length})`} subtitle="规则配置与启停">
{rulesLoading ? <LoadingSpinner text="加载预警规则..." /> : (
<FilterableTable
data={rules}
sortOptions={[
{ key: 'rule_name', label: '规则名称' },
{ key: 'metric', label: '指标' },
{ key: 'threshold', label: '阈值' },
]}
defaultSort="rule_name"
defaultOrder="asc"
columns={[
{ key: 'rule_name', label: '规则名称' },
{ key: 'description', label: '描述' },
{ key: 'metric', label: '监控指标' },
{ key: 'operator', label: '比较符', align: 'center' },
{ key: 'threshold', label: '阈值', align: 'right' },
{ key: 'severity', label: '级别', align: 'center', render: (r) => {
const colors: Record<string, string> = { red: 'bg-red-100 text-red-700', yellow: 'bg-yellow-100 text-yellow-700' }
const labels: Record<string, string> = { red: '红灯', yellow: '黄灯' }
return <span className={`rounded px-2 py-0.5 text-xs ${colors[r.severity] || ''}`}>{labels[r.severity] || r.severity}</span>
}},
{ key: 'check_interval', label: '检查频率', align: 'center' },
{ key: 'push_targets', label: '推送对象' },
{ key: 'is_enabled', label: '状态', align: 'center', render: (r) => (
<button
onClick={() => toggleRuleMutation.mutate({ id: r.id, is_enabled: !r.is_enabled })}
className={`rounded px-2 py-0.5 text-xs ${r.is_enabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}
>
{r.is_enabled ? '启用中' : '已停用'}
</button>
)},
]}
/>
)}
</CollapsibleSection>
)}
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatNumber } from '@/lib/utils'
const IMPORT_TYPES = ['POS', '外卖', '库存', '费用', '薪资']
export function DataImportPage() {
const queryClient = useQueryClient()
const { data: logData, isLoading } = useQuery({
queryKey: ['product/import-logs'],
queryFn: () => api.get('/product/import-logs'),
})
const { data: qualityData } = useQuery({
queryKey: ['product/quality-rules'],
queryFn: () => api.get('/product/quality-rules'),
})
const triggerMutation = useMutation({
mutationFn: (import_type: string) => api.post('/product/import/trigger', { import_type }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['product/import-logs'] }),
})
if (isLoading) return <LoadingSpinner text="加载数据导入日志..." />
const logs = (logData as any)?.data || []
const rules = (qualityData as any)?.data || []
const successCount = logs.filter((l: any) => l.status === 'success').length
const failedCount = logs.filter((l: any) => l.status === 'failed').length
const runningCount = logs.filter((l: any) => l.status === 'running').length
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground">ETL管道 · · </p>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="导入记录" value={logs.length} format="number" description="总导入次数" />
<MetricCard title="成功" value={successCount} format="number" description="成功导入次数" status="good" />
<MetricCard title="失败" value={failedCount} format="number" description="失败导入次数" status="bad" />
<MetricCard title="进行中" value={runningCount} format="number" description="正在执行的任务" status="warn" />
</div>
<CollapsibleSection title="手动触发导入" subtitle="选择数据源进行导入">
<div className="flex flex-wrap gap-2">
{IMPORT_TYPES.map(type => (
<button
key={type}
onClick={() => triggerMutation.mutate(type)}
disabled={triggerMutation.isPending}
className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
>
{type}
</button>
))}
</div>
</CollapsibleSection>
<CollapsibleSection title={`导入日志 (${logs.length})`} subtitle="按时间降序">
<FilterableTable
data={logs}
filterKey="import_type"
filterLabel="全部类型"
sortOptions={[
{ key: 'created_at', label: '创建时间' },
{ key: 'records_count', label: '记录数' },
]}
defaultSort="created_at"
defaultOrder="desc"
columns={[
{ key: 'import_type', label: '导入类型' },
{ key: 'status', label: '状态', align: 'center', render: (r) => {
const colors: Record<string, string> = { success: 'text-green-600', failed: 'text-red-600', running: 'text-yellow-600', pending: 'text-blue-600' }
return <span className={`font-bold ${colors[r.status] || ''}`}>{r.status}</span>
}},
{ key: 'records_count', label: '记录数', align: 'right', render: (r) => formatNumber(r.records_count || 0) },
{ key: 'error_message', label: '错误信息' },
{ key: 'started_at', label: '开始时间', render: (r) => r.started_at ? (r.started_at as string).substring(0, 19).replace('T', ' ') : '-' },
{ key: 'completed_at', label: '完成时间', render: (r) => r.completed_at ? (r.completed_at as string).substring(0, 19).replace('T', ' ') : '-' },
]}
/>
</CollapsibleSection>
<CollapsibleSection title={`数据质量规则 (${rules.length})`}>
<FilterableTable
data={rules}
filterKey="rule_type"
filterLabel="全部规则"
sortOptions={[{ key: 'table_name', label: '表名' }, { key: 'rule_type', label: '规则类型' }]}
defaultSort="table_name"
defaultOrder="asc"
columns={[
{ key: 'rule_name', label: '规则名称' },
{ key: 'table_name', label: '表名' },
{ key: 'column_name', label: '字段' },
{ key: 'rule_type', label: '规则类型', align: 'center' },
{ key: 'is_enabled', label: '启用', align: 'center', render: (r) => r.is_enabled ? '✅' : '❌' },
]}
/>
</CollapsibleSection>
</div>
)
}
+509
View File
@@ -0,0 +1,509 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { Tabs } from '@/components/Tabs'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
const GRADE_COLORS: Record<string, string> = { '战略': '#22c55e', '优选': '#3b82f6', '合格': '#eab308', '淘汰': '#ef4444' }
export function EnterprisePage() {
const [tab, setTab] = useState('supplier')
const queryClient = useQueryClient()
const { data: supplierData, isLoading: supplierLoading } = useQuery({
queryKey: ['enterprise/suppliers'],
queryFn: () => api.get('/enterprise/suppliers'),
enabled: tab === 'supplier',
})
const { data: poData, isLoading: poLoading } = useQuery({
queryKey: ['enterprise/purchase-orders'],
queryFn: () => api.get('/enterprise/purchase-orders'),
enabled: tab === 'supplier',
})
const { data: approvalData, isLoading: approvalLoading } = useQuery({
queryKey: ['enterprise/approvals'],
queryFn: () => api.get('/enterprise/approvals'),
enabled: tab === 'approval',
})
const { data: notificationData, isLoading: notifLoading } = useQuery({
queryKey: ['enterprise/notifications'],
queryFn: () => api.get('/enterprise/notifications'),
enabled: tab === 'notification',
})
const { data: talentData, isLoading: talentLoading } = useQuery({
queryKey: ['enterprise/hr/talent-matrix'],
queryFn: () => api.get('/enterprise/hr/talent-matrix'),
enabled: tab === 'hr',
})
const { data: courseData, isLoading: courseLoading } = useQuery({
queryKey: ['enterprise/training/courses'],
queryFn: () => api.get('/enterprise/training/courses'),
enabled: tab === 'lms',
})
const { data: recordData, isLoading: recordLoading } = useQuery({
queryKey: ['enterprise/training/records'],
queryFn: () => api.get('/enterprise/training/records'),
enabled: tab === 'lms',
})
const { data: campaignData, isLoading: campaignLoading } = useQuery({
queryKey: ['enterprise/campaigns'],
queryFn: () => api.get('/enterprise/campaigns'),
enabled: tab === 'marketing',
})
const { data: couponData, isLoading: couponLoading } = useQuery({
queryKey: ['enterprise/coupons'],
queryFn: () => api.get('/enterprise/coupons'),
enabled: tab === 'marketing',
})
const { data: budgetData, isLoading: budgetLoading } = useQuery({
queryKey: ['enterprise/budgets'],
queryFn: () => api.get('/enterprise/budgets'),
enabled: tab === 'finance',
})
const { data: loopData, isLoading: loopLoading } = useQuery({
queryKey: ['enterprise/loop-verification'],
queryFn: () => api.get('/enterprise/loop-verification'),
enabled: tab === 'loop',
})
const { data: auditData, isLoading: auditLoading } = useQuery({
queryKey: ['enterprise/audit-logs'],
queryFn: () => api.get('/enterprise/audit-logs'),
enabled: tab === 'audit',
})
const approveMutation = useMutation({
mutationFn: ({ id, result, comment }: { id: number; result: string; comment?: string }) =>
api.post(`/enterprise/approvals/${id}/approve`, { result, comment }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['enterprise/approvals'] }),
})
const readMutation = useMutation({
mutationFn: (id: number) => api.patch(`/enterprise/notifications/${id}/read`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['enterprise/notifications'] }),
})
if ((tab === 'supplier' && supplierLoading) || (tab === 'approval' && approvalLoading) || (tab === 'notification' && notifLoading) || (tab === 'hr' && talentLoading) || (tab === 'lms' && courseLoading) || (tab === 'marketing' && campaignLoading) || (tab === 'finance' && budgetLoading) || (tab === 'loop' && loopLoading) || (tab === 'audit' && auditLoading)) {
return <LoadingSpinner text="加载企业协同数据..." />
}
const suppliers = (supplierData as any)?.data || []
const purchaseOrders = (poData as any)?.data || []
const approvals = (approvalData as any)?.data || []
const notifications = (notificationData as any)?.data || []
const talents = (talentData as any)?.data || []
const courses = (courseData as any)?.data || []
const records = (recordData as any)?.data || []
const campaigns = (campaignData as any)?.data || []
const coupons = (couponData as any)?.data || []
const budgets = (budgetData as any)?.data || []
const loops = (loopData as any)?.data || []
const auditLogs = (auditData as any)?.data || []
const gradeDist = suppliers.reduce((acc: Record<string, number>, r: any) => {
acc[r.grade] = (acc[r.grade] || 0) + 1
return acc
}, {})
const gradePieData = Object.entries(gradeDist).map(([name, value]) => ({ name, value }))
const quadrantDist = talents.reduce((acc: Record<string, number>, r: any) => {
acc[r.quadrant] = (acc[r.quadrant] || 0) + 1
return acc
}, {})
const pendingApprovals = approvals.filter((a: any) => a.status === 'pending').length
const unreadNotifications = notifications.filter((n: any) => !n.is_read).length
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · · HR · · · · · </p>
</div>
<Tabs tabs={[
{ key: 'supplier', label: '供应链SRM' },
{ key: 'approval', label: '审批流' },
{ key: 'notification', label: '消息中心' },
{ key: 'hr', label: 'HR人才' },
{ key: 'lms', label: '培训LMS' },
{ key: 'marketing', label: '营销MA' },
{ key: 'finance', label: '财务ERP' },
{ key: 'loop', label: '闭环验证' },
{ key: 'audit', label: '审计日志' },
]} active={tab} onChange={setTab} />
{tab === 'supplier' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="供应商总数" value={suppliers.length} format="number" />
<MetricCard title="战略供应商" value={gradeDist['战略'] || 0} format="number" status="good" />
<MetricCard title="采购订单" value={purchaseOrders.length} format="number" />
<MetricCard title="淘汰供应商" value={gradeDist['淘汰'] || 0} format="number" status="bad" />
</div>
{gradePieData.length > 0 && (
<CollapsibleSection title="供应商分级分布">
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={gradePieData} cx="50%" cy="50%" outerRadius={60} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${value}`}</tspan>}>
{gradePieData.map((e, i) => <Cell key={i} fill={GRADE_COLORS[e.name] || '#999'} />)}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
)}
<CollapsibleSection title={`供应商列表 (${suppliers.length})`}>
<FilterableTable
data={suppliers}
filterKey="grade"
filterLabel="全部分级"
sortOptions={[{ key: 'overall_score', label: '综合分' }, { key: 'supplier_name', label: '名称' }]}
defaultSort="overall_score"
defaultOrder="desc"
columns={[
{ key: 'supplier_code', label: '编码' },
{ key: 'supplier_name', label: '名称' },
{ key: 'category', label: '品类' },
{ key: 'grade', label: '分级', align: 'center', render: (r) => {
const colors: Record<string, string> = { '战略': 'text-green-600', '优选': 'text-blue-600', '合格': 'text-yellow-600', '淘汰': 'text-red-600' }
return <span className={`font-bold ${colors[r.grade] || ''}`}>{r.grade}</span>
}},
{ key: 'overall_score', label: '综合分', align: 'right', render: (r) => r.overall_score ? Number(r.overall_score).toFixed(1) : '-' },
{ key: 'delivery_on_time_rate', label: '准时率', align: 'right', render: (r) => r.delivery_on_time_rate ? formatPercent(r.delivery_on_time_rate) : '-' },
{ key: 'contact_person', label: '联系人' },
]}
/>
</CollapsibleSection>
<CollapsibleSection title={`采购订单 (${purchaseOrders.length})`}>
<FilterableTable
data={purchaseOrders}
filterKey="status"
filterLabel="全部状态"
sortOptions={[{ key: 'created_at', label: '创建时间' }, { key: 'total_amount', label: '金额' }]}
defaultSort="created_at"
defaultOrder="desc"
columns={[
{ key: 'po_number', label: 'PO编号' },
{ key: 'supplier_code', label: '供应商' },
{ key: 'store_code', label: '门店' },
{ key: 'order_date', label: '下单日期', render: (r) => (r.order_date || '').toString().substring(0, 10) },
{ key: 'total_amount', label: '金额', align: 'right', render: (r) => formatCurrency(r.total_amount) },
{ key: 'status', label: '状态', align: 'center' },
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'approval' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="审批总数" value={approvals.length} format="number" />
<MetricCard title="待审批" value={pendingApprovals} format="number" status="warn" />
<MetricCard title="已审批" value={approvals.length - pendingApprovals} format="number" status="good" />
</div>
<CollapsibleSection title={`审批列表 (${approvals.length})`}>
<FilterableTable
data={approvals}
filterKey="status"
filterLabel="全部状态"
sortOptions={[{ key: 'created_at', label: '创建时间' }, { key: 'request_type', label: '类型' }]}
defaultSort="created_at"
defaultOrder="desc"
columns={[
{ key: 'request_type', label: '类型' },
{ key: 'applicant_name', label: '申请人' },
{ key: 'store_code', label: '门店' },
{ key: 'current_step', label: '当前步骤', align: 'center', render: (r) => `${r.current_step}/${r.total_steps}` },
{ key: 'status', label: '状态', align: 'center', render: (r) => {
const colors: Record<string, string> = { approved: 'text-green-600', pending: 'text-yellow-600', rejected: 'text-red-600' }
return <span className={`font-bold ${colors[r.status] || ''}`}>{r.status}</span>
}},
{ key: 'created_at', label: '创建时间', render: (r) => (r.created_at || '').substring(0, 19).replace('T', ' ') },
{ key: 'action', label: '操作', align: 'center', render: (r) => r.status === 'pending' ? (
<div className="flex gap-1">
<button onClick={() => approveMutation.mutate({ id: r.id, result: 'approved' })} className="rounded bg-green-600 px-2 py-0.5 text-xs text-white"></button>
<button onClick={() => approveMutation.mutate({ id: r.id, result: 'rejected' })} className="rounded bg-red-600 px-2 py-0.5 text-xs text-white"></button>
</div>
) : '-' },
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'notification' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="消息总数" value={notifications.length} format="number" />
<MetricCard title="未读" value={unreadNotifications} format="number" status="warn" />
<MetricCard title="已读" value={notifications.length - unreadNotifications} format="number" status="good" />
</div>
<CollapsibleSection title={`消息列表 (${notifications.length})`}>
<FilterableTable
data={notifications}
filterKey="notification_type"
filterLabel="全部类型"
sortOptions={[{ key: 'created_at', label: '时间' }]}
defaultSort="created_at"
defaultOrder="desc"
columns={[
{ key: 'title', label: '标题', render: (r) => <span className={r.is_read ? '' : 'font-bold'}>{r.title}</span> },
{ key: 'notification_type', label: '类型', align: 'center' },
{ key: 'content', label: '内容' },
{ key: 'is_read', label: '状态', align: 'center', render: (r) => r.is_read ? '已读' : <span className="text-yellow-600 font-bold"></span> },
{ key: 'created_at', label: '时间', render: (r) => (r.created_at || '').substring(0, 19).replace('T', ' ') },
{ key: 'action', label: '操作', align: 'center', render: (r) => !r.is_read ? (
<button onClick={() => readMutation.mutate(r.id)} className="rounded bg-primary px-2 py-0.5 text-xs text-primary-foreground"></button>
) : '-' },
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'hr' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="人才总数" value={talents.length} format="number" />
<MetricCard title="明星员工" value={quadrantDist['star'] || 0} format="number" status="good" />
<MetricCard title="核心员工" value={quadrantDist['core'] || 0} format="number" />
<MetricCard title="潜力员工" value={quadrantDist['potential'] || 0} format="number" status="warn" />
</div>
<CollapsibleSection title="人才九宫格" subtitle="绩效×潜力">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={Object.entries(quadrantDist).map(([k, v]) => ({ name: k, count: v }))} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis dataKey="name" type="category" />
<Tooltip />
<Bar dataKey="count" fill="#3b82f6" name="人数" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title={`人才列表 (${talents.length})`}>
<FilterableTable
data={talents}
filterKey="quadrant"
filterLabel="全部象限"
sortOptions={[{ key: 'performance_score', label: '绩效' }, { key: 'potential_score', label: '潜力' }]}
defaultSort="performance_score"
defaultOrder="desc"
columns={[
{ key: 'employee_name', label: '员工' },
{ key: 'store_name', label: '门店' },
{ key: 'performance_score', label: '绩效分', align: 'right', render: (r) => Number(r.performance_score || 0).toFixed(1) },
{ key: 'potential_score', label: '潜力分', align: 'right', render: (r) => Number(r.potential_score || 0).toFixed(1) },
{ key: 'quadrant', label: '象限', align: 'center', render: (r) => {
const labels: Record<string, string> = { star: '明星', core: '核心', potential: '潜力', ordinary: '普通' }
const colors: Record<string, string> = { star: 'text-green-600', core: 'text-blue-600', potential: 'text-yellow-600', ordinary: 'text-gray-600' }
return <span className={`font-bold ${colors[r.quadrant] || ''}`}>{labels[r.quadrant] || r.quadrant}</span>
}},
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'lms' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="课程总数" value={courses.length} format="number" />
<MetricCard title="学习记录" value={records.length} format="number" />
<MetricCard title="已完成" value={records.filter((r: any) => r.completion_status === '已完成').length} format="number" status="good" />
<MetricCard title="未完成" value={records.filter((r: any) => r.completion_status !== '已完成').length} format="number" status="warn" />
</div>
<CollapsibleSection title={`课程列表 (${courses.length})`}>
<FilterableTable
data={courses}
filterKey="course_type"
filterLabel="全部类型"
sortOptions={[{ key: 'course_name', label: '课程名' }, { key: 'created_at', label: '创建时间' }]}
defaultSort="created_at"
defaultOrder="desc"
columns={[
{ key: 'course_name', label: '课程名称' },
{ key: 'course_type', label: '类型', align: 'center' },
{ key: 'exam_enabled', label: '考试', align: 'center', render: (r) => r.exam_enabled ? '是' : '否' },
{ key: 'pass_score', label: '及格分', align: 'right' },
{ key: 'created_at', label: '创建时间', render: (r) => (r.created_at || '').substring(0, 10) },
]}
/>
</CollapsibleSection>
<CollapsibleSection title={`学习记录 (${records.length})`}>
<FilterableTable
data={records}
filterKey="completion_status"
filterLabel="全部状态"
sortOptions={[{ key: 'employee_name', label: '员工' }, { key: 'progress_pct', label: '进度' }]}
defaultSort="progress_pct"
defaultOrder="desc"
columns={[
{ key: 'employee_name', label: '员工' },
{ key: 'store_code', label: '门店' },
{ key: 'progress_pct', label: '进度', align: 'right', render: (r) => formatPercent(r.progress_pct || 0) },
{ key: 'completion_status', label: '状态', align: 'center', render: (r) => {
const colors: Record<string, string> = { '已完成': 'text-green-600', '进行中': 'text-blue-600', '未开始': 'text-gray-600', '未通过': 'text-red-600' }
return <span className={colors[r.completion_status] || ''}>{r.completion_status}</span>
}},
{ key: 'exam_score', label: '考试成绩', align: 'right', render: (r) => r.exam_score ?? '-' },
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'marketing' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="活动总数" value={campaigns.length} format="number" />
<MetricCard title="进行中" value={campaigns.filter((c: any) => c.status === 'active').length} format="number" status="good" />
<MetricCard title="券码总数" value={coupons.length} format="number" />
<MetricCard title="已核销" value={coupons.filter((c: any) => c.status === 'used').length} format="number" />
</div>
<CollapsibleSection title={`营销活动 (${campaigns.length})`}>
<FilterableTable
data={campaigns}
filterKey="status"
filterLabel="全部状态"
sortOptions={[{ key: 'created_at', label: '创建时间' }, { key: 'budget', label: '预算' }]}
defaultSort="created_at"
defaultOrder="desc"
columns={[
{ key: 'campaign_name', label: '活动名称' },
{ key: 'campaign_type', label: '类型' },
{ key: 'start_date', label: '开始', render: (r) => (r.start_date || '').substring(0, 10) },
{ key: 'end_date', label: '结束', render: (r) => (r.end_date || '').substring(0, 10) },
{ key: 'budget', label: '预算', align: 'right', render: (r) => formatCurrency(r.budget) },
{ key: 'roi', label: 'ROI', align: 'right', render: (r) => r.roi ? `${Number(r.roi).toFixed(2)}x` : '-' },
{ key: 'status', label: '状态', align: 'center' },
]}
/>
</CollapsibleSection>
<CollapsibleSection title={`券码管理 (${coupons.length})`}>
<FilterableTable
data={coupons}
filterKey="status"
filterLabel="全部状态"
sortOptions={[{ key: 'created_at', label: '创建时间' }, { key: 'face_value', label: '面值' }]}
defaultSort="created_at"
defaultOrder="desc"
columns={[
{ key: 'coupon_code', label: '券码' },
{ key: 'coupon_type', label: '类型' },
{ key: 'face_value', label: '面值', align: 'right', render: (r) => formatCurrency(r.face_value) },
{ key: 'min_spend', label: '门槛', align: 'right', render: (r) => formatCurrency(r.min_spend) },
{ key: 'expiry_date', label: '过期日期', render: (r) => (r.expiry_date || '').substring(0, 10) },
{ key: 'status', label: '状态', align: 'center', render: (r) => {
const colors: Record<string, string> = { unused: 'text-blue-600', used: 'text-green-600', expired: 'text-gray-600', refunded: 'text-red-600' }
return <span className={colors[r.status] || ''}>{r.status}</span>
}},
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'finance' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="预算总数" value={budgets.length} format="number" />
<MetricCard title="总预算" value={budgets.reduce((s: number, b: any) => s + Number(b.budget_amount || 0), 0)} format="currency" />
<MetricCard title="总实际" value={budgets.reduce((s: number, b: any) => s + Number(b.actual_amount || 0), 0)} format="currency" />
</div>
<CollapsibleSection title={`预算管理 (${budgets.length})`}>
<FilterableTable
data={budgets}
filterKey="budget_type"
filterLabel="全部类型"
sortOptions={[{ key: 'month', label: '月份' }, { key: 'budget_amount', label: '预算' }, { key: 'execution_rate', label: '执行率' }]}
defaultSort="month"
defaultOrder="desc"
columns={[
{ key: 'year', label: '年度', align: 'center' },
{ key: 'month', label: '月份', render: (r) => r.month ? (r.month as string).substring(0, 10) : '-' },
{ key: 'store_code', label: '门店' },
{ key: 'department', label: '部门' },
{ key: 'budget_type', label: '类型', align: 'center' },
{ key: 'budget_amount', label: '预算', align: 'right', render: (r) => formatCurrency(r.budget_amount) },
{ key: 'actual_amount', label: '实际', align: 'right', render: (r) => formatCurrency(r.actual_amount) },
{ key: 'execution_rate', label: '执行率', align: 'right', render: (r) => formatPercent(r.execution_rate || 0) },
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'loop' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="验证总数" value={loops.length} format="number" />
<MetricCard title="已完成" value={loops.filter((l: any) => l.status === 'completed').length} format="number" status="good" />
<MetricCard title="需升级" value={loops.filter((l: any) => l.status === 'escalated').length} format="number" status="bad" />
</div>
<CollapsibleSection title={`闭环验证 (${loops.length})`}>
<FilterableTable
data={loops}
filterKey="status"
filterLabel="全部状态"
sortOptions={[{ key: 'check_date', label: '检查日期' }, { key: 'completion_rate', label: '完成率' }]}
defaultSort="check_date"
defaultOrder="desc"
columns={[
{ key: 'loop_name', label: '闭环名称' },
{ key: 'store_code', label: '门店' },
{ key: 'check_date', label: '检查日期', render: (r) => (r.check_date || '').substring(0, 10) },
{ key: 'status', label: '状态', align: 'center', render: (r) => {
const colors: Record<string, string> = { completed: 'text-green-600', open: 'text-yellow-600', escalated: 'text-red-600' }
return <span className={`font-bold ${colors[r.status] || ''}`}>{r.status}</span>
}},
{ key: 'completion_rate', label: '完成率', align: 'right', render: (r) => formatPercent(r.completion_rate || 0) },
{ key: 'cycle_days', label: '周期(天)', align: 'right' },
{ key: 'bottleneck', label: '瓶颈环节' },
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'audit' && (
<CollapsibleSection title={`审计日志 (${auditLogs.length})`}>
<FilterableTable
data={auditLogs}
filterKey="action"
filterLabel="全部操作"
sortOptions={[{ key: 'created_at', label: '时间' }, { key: 'user_name', label: '用户' }]}
defaultSort="created_at"
defaultOrder="desc"
columns={[
{ key: 'user_name', label: '用户' },
{ key: 'user_role', label: '角色' },
{ key: 'action', label: '操作', align: 'center' },
{ key: 'resource_type', label: '资源类型' },
{ key: 'resource_id', label: '资源ID' },
{ key: 'created_at', label: '时间', render: (r) => (r.created_at || '').substring(0, 19).replace('T', ' ') },
]}
/>
</CollapsibleSection>
)}
</div>
)
}
+243
View File
@@ -0,0 +1,243 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { Tabs } from '@/components/Tabs'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, LineChart, Line } from 'recharts'
export function IntelligencePage() {
const [tab, setTab] = useState('iot')
const { data: deviceData, isLoading: deviceLoading } = useQuery({
queryKey: ['intelligence/iot/devices'],
queryFn: () => api.get('/intelligence/iot/devices'),
enabled: tab === 'iot',
})
const { data: forecastData, isLoading: forecastLoading } = useQuery({
queryKey: ['intelligence/ai/forecasts'],
queryFn: () => api.get('/intelligence/ai/forecasts'),
enabled: tab === 'ai',
})
const { data: roiData, isLoading: roiLoading } = useQuery({
queryKey: ['intelligence/strategy/roi'],
queryFn: () => api.get('/intelligence/strategy/roi'),
enabled: tab === 'strategy',
})
const { data: brandData, isLoading: brandLoading } = useQuery({
queryKey: ['intelligence/strategy/brand-assets'],
queryFn: () => api.get('/intelligence/strategy/brand-assets'),
enabled: tab === 'strategy',
})
const { data: realtimeData, isLoading: realtimeLoading } = useQuery({
queryKey: ['intelligence/realtime/dashboard'],
queryFn: () => api.get('/intelligence/realtime/dashboard'),
enabled: tab === 'realtime',
})
if ((tab === 'iot' && deviceLoading) || (tab === 'ai' && forecastLoading) || (tab === 'strategy' && roiLoading) || (tab === 'realtime' && realtimeLoading)) {
return <LoadingSpinner text="加载智能升级数据..." />
}
const devices = (deviceData as any)?.data || []
const forecasts = (forecastData as any)?.data || []
const rois = (roiData as any)?.data || []
const brands = (brandData as any)?.data || []
const realtime = (realtimeData as any)?.data || []
const onlineDevices = devices.filter((d: any) => d.status === 'online').length
const alarmDevices = devices.filter((d: any) => d.status === 'alarm').length
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground">IoT设备 · AI预测 · · </p>
</div>
<Tabs tabs={[
{ key: 'iot', label: 'IoT设备' },
{ key: 'ai', label: 'AI预测' },
{ key: 'realtime', label: '实时看板' },
{ key: 'strategy', label: '战略驾驶舱' },
]} active={tab} onChange={setTab} />
{tab === 'iot' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="设备总数" value={devices.length} format="number" />
<MetricCard title="在线" value={onlineDevices} format="number" status="good" />
<MetricCard title="告警" value={alarmDevices} format="number" status="bad" />
<MetricCard title="离线" value={devices.length - onlineDevices - alarmDevices} format="number" status="warn" />
</div>
<CollapsibleSection title={`IoT设备列表 (${devices.length})`}>
<FilterableTable
data={devices}
filterKey="device_type"
filterLabel="全部类型"
sortOptions={[{ key: 'device_code', label: '编码' }, { key: 'updated_at', label: '更新时间' }]}
defaultSort="updated_at"
defaultOrder="desc"
columns={[
{ key: 'device_code', label: '设备编码' },
{ key: 'device_name', label: '设备名称' },
{ key: 'device_type', label: '类型' },
{ key: 'store_code', label: '门店' },
{ key: 'location', label: '位置' },
{ key: 'status', label: '状态', align: 'center', render: (r) => {
const colors: Record<string, string> = { online: 'text-green-600', offline: 'text-gray-600', alarm: 'text-red-600' }
return <span className={`font-bold ${colors[r.status] || ''}`}>{r.status}</span>
}},
{ key: 'last_reading_at', label: '最后读数', render: (r) => r.last_reading_at ? (r.last_reading_at as string).substring(0, 19).replace('T', ' ') : '-' },
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'ai' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="预测总数" value={forecasts.length} format="number" />
<MetricCard title="预测类型" value={new Set(forecasts.map((f: any) => f.forecast_type)).size} format="number" />
<MetricCard title="模型数" value={new Set(forecasts.map((f: any) => f.model_type)).size} format="number" />
</div>
{forecasts.length > 0 && (
<CollapsibleSection title="预测趋势" subtitle="按目标日期">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={forecasts.slice(0, 30).reverse().map((f: any) => ({
date: (f.target_date || '').substring(0, 10),
predicted: Number(f.predicted_value || 0),
lower: Number(f.confidence_lower || 0),
upper: Number(f.confidence_upper || 0),
}))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" fontSize={10} />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="predicted" stroke="#3b82f6" name="预测值" />
<Line type="monotone" dataKey="lower" stroke="#94a3b8" name="下界" strokeDasharray="5 5" />
<Line type="monotone" dataKey="upper" stroke="#94a3b8" name="上界" strokeDasharray="5 5" />
</LineChart>
</ResponsiveContainer>
</CollapsibleSection>
)}
<CollapsibleSection title={`预测列表 (${forecasts.length})`}>
<FilterableTable
data={forecasts}
filterKey="forecast_type"
filterLabel="全部类型"
sortOptions={[{ key: 'target_date', label: '目标日期' }, { key: 'predicted_value', label: '预测值' }]}
defaultSort="target_date"
defaultOrder="desc"
columns={[
{ key: 'forecast_type', label: '类型' },
{ key: 'target_date', label: '目标日期', render: (r) => (r.target_date || '').substring(0, 10) },
{ key: 'store_code', label: '门店' },
{ key: 'predicted_value', label: '预测值', align: 'right', render: (r) => formatNumber(r.predicted_value) },
{ key: 'confidence_lower', label: '下界', align: 'right', render: (r) => formatNumber(r.confidence_lower) },
{ key: 'confidence_upper', label: '上界', align: 'right', render: (r) => formatNumber(r.confidence_upper) },
{ key: 'model_type', label: '模型', align: 'center' },
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'realtime' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="今日门店数" value={realtime.length} format="number" />
<MetricCard title="今日总营收" value={realtime.reduce((s: number, r: any) => s + Number(r.today_revenue || 0), 0)} format="currency" />
<MetricCard title="今日总单数" value={realtime.reduce((s: number, r: any) => s + Number(r.bill_count || 0), 0)} format="number" />
</div>
{realtime.length > 0 && (
<CollapsibleSection title="今日营收排行" subtitle="实时数据">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={realtime.slice(0, 20).map((r: any) => ({
store: r.store_code,
revenue: Number(r.today_revenue || 0),
}))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="store" fontSize={10} />
<YAxis />
<Tooltip formatter={(v: any) => [formatCurrency(v), '营收']} />
<Bar dataKey="revenue" fill="#3b82f6" name="今日营收" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
)}
<CollapsibleSection title={`实时看板 (${realtime.length})`}>
<FilterableTable
data={realtime}
sortOptions={[{ key: 'today_revenue', label: '今日营收' }, { key: 'bill_count', label: '单数' }]}
defaultSort="today_revenue"
defaultOrder="desc"
columns={[
{ key: 'store_code', label: '门店' },
{ key: 'today_revenue', label: '今日营收', align: 'right', render: (r) => formatCurrency(r.today_revenue) },
{ key: 'bill_count', label: '单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'avg_transaction', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_transaction) },
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'strategy' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="门店ROI数" value={rois.length} format="number" />
<MetricCard title="平均回本月数" value={rois.length > 0 ? rois.reduce((s: number, r: any) => s + Number(r.payback_months || 0), 0) / rois.length : 0} format="number" />
<MetricCard title="品牌追踪天数" value={brands.length} format="number" />
<MetricCard title="最新NPS" value={brands[0]?.nps_score || 0} format="number" status={(brands[0]?.nps_score || 0) >= 50 ? 'good' : 'warn'} />
</div>
{brands.length > 0 && (
<CollapsibleSection title="品牌资产趋势" subtitle="NPS + 搜索指数">
<ResponsiveContainer width="100%" height={250}>
<LineChart data={brands.slice(0, 30).reverse().map((b: any) => ({
date: (b.tracking_date || '').substring(0, 10),
nps: Number(b.nps_score || 0),
search: Number(b.search_index || 0),
}))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" fontSize={10} />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="nps" stroke="#22c55e" name="NPS" />
<Line type="monotone" dataKey="search" stroke="#3b82f6" name="搜索指数" />
</LineChart>
</ResponsiveContainer>
</CollapsibleSection>
)}
<CollapsibleSection title={`门店ROI (${rois.length})`}>
<FilterableTable
data={rois}
sortOptions={[{ key: 'payback_months', label: '回本月数' }, { key: 'roi_pct', label: 'ROI' }, { key: 'store_name', label: '门店' }]}
defaultSort="payback_months"
defaultOrder="asc"
columns={[
{ key: 'store_code', label: '门店编码' },
{ key: 'store_name', label: '门店名称' },
{ key: 'initial_investment', label: '初始投资', align: 'right', render: (r) => formatCurrency(r.initial_investment) },
{ key: 'monthly_revenue', label: '月营收', align: 'right', render: (r) => formatCurrency(r.monthly_revenue) },
{ key: 'monthly_profit', label: '月利润', align: 'right', render: (r) => formatCurrency(r.monthly_profit) },
{ key: 'payback_months', label: '回本月数', align: 'right', render: (r) => r.payback_months ? `${Number(r.payback_months).toFixed(1)}` : '-' },
{ key: 'roi_pct', label: 'ROI', align: 'right', render: (r) => formatPercent(r.roi_pct) },
]}
/>
</CollapsibleSection>
</>
)}
</div>
)
}
+209
View File
@@ -0,0 +1,209 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { Tabs } from '@/components/Tabs'
import { formatNumber, formatPercent } from '@/lib/utils'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
const STAGE_COLORS: Record<string, string> = { '爬坡': '#3b82f6', '成熟': '#22c55e', '衰退': '#eab308', '淘汰': '#ef4444' }
export function ProductLifecyclePage() {
const [tab, setTab] = useState('lifecycle')
const [stage, setStage] = useState('')
const queryClient = useQueryClient()
const { data: productData, isLoading: productLoading } = useQuery({
queryKey: ['product/products', stage],
queryFn: () => api.get('/product/products', { params: stage ? { stage } : {} }),
})
const { data: reviewOverview, isLoading: reviewLoading } = useQuery({
queryKey: ['product/reviews/overview'],
queryFn: () => api.get('/product/reviews/overview'),
})
const { data: reviewData, isLoading: reviewListLoading } = useQuery({
queryKey: ['product/reviews'],
queryFn: () => api.get('/product/reviews', { params: { page: 1, page_size: 100 } }),
})
const trackMutation = useMutation({
mutationFn: (id: number) => api.post(`/product/products/${id}/track`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['product/products'] }),
})
if (tab === 'lifecycle' && productLoading) return <LoadingSpinner text="加载产品生命周期数据..." />
if (tab === 'reviews' && reviewLoading) return <LoadingSpinner text="加载评价数据..." />
const products = (productData as any)?.data || []
const reviews = (reviewData as any)?.data || []
const overview = (reviewOverview as any)?.data || {}
const stageDistribution = products.reduce((acc: Record<string, number>, r: any) => {
acc[r.lifecycle_stage] = (acc[r.lifecycle_stage] || 0) + 1
return acc
}, {})
const stagePieData = Object.entries(stageDistribution).map(([name, value]) => ({ name, value }))
const categoryData = [
{ name: '口味', value: Number(overview.taste_count || 0) },
{ name: '服务', value: Number(overview.service_count || 0) },
{ name: '环境', value: Number(overview.env_count || 0) },
{ name: '异物', value: Number(overview.foreign_matter_count || 0) },
{ name: '分量', value: Number(overview.portion_count || 0) },
].filter(d => d.value > 0)
const totalReviews = Number(overview.total_reviews || 0)
const positiveRate = totalReviews > 0 ? (Number(overview.positive || 0) / totalReviews * 100) : 0
const avgRating = Number(overview.avg_rating || 0)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground">SKU生命周期追踪 · NLP归因 · </p>
</div>
</div>
<Tabs tabs={[
{ key: 'lifecycle', label: '产品生命周期' },
{ key: 'reviews', label: '评价分析' },
]} active={tab} onChange={setTab} />
{tab === 'lifecycle' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="产品总数" value={products.length} format="number" description="在追踪的SKU数量" />
<MetricCard title="爬坡期" value={stageDistribution['爬坡'] || 0} format="number" description="上市7天内" status="warn" />
<MetricCard title="成熟期" value={stageDistribution['成熟'] || 0} format="number" description="上市7-90天" status="good" />
<MetricCard title="衰退期" value={stageDistribution['衰退'] || 0} format="number" description="上市90天+" status="bad" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="生命周期阶段分布" subtitle="各阶段SKU数量">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={stagePieData} cx="50%" cy="50%" outerRadius={70} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${value}`}</tspan>}>
{stagePieData.map((entry, i) => <Cell key={i} fill={STAGE_COLORS[entry.name] || '#999'} />)}
</Pie>
<Tooltip formatter={(v: any) => [formatNumber(v), 'SKU数']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="各阶段产品列表" subtitle="按上市日期排序">
<FilterableTable
data={products}
filterKey="lifecycle_stage"
filterLabel="全部阶段"
sortOptions={[
{ key: 'launch_date', label: '上市日期' },
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'lifecycle_stage', label: '阶段' },
]}
defaultSort="launch_date"
defaultOrder="desc"
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'sku_name', label: 'SKU名称' },
{ key: 'launch_date', label: '上市日期', render: (r) => (r.launch_date || '').toString().substring(0, 10) },
{ key: 'lifecycle_stage', label: '阶段', align: 'center', render: (r) => {
const colors: Record<string, string> = { '爬坡': 'text-blue-600', '成熟': 'text-green-600', '衰退': 'text-yellow-600', '淘汰': 'text-red-600' }
return <span className={`font-bold ${colors[r.lifecycle_stage] || ''}`}>{r.lifecycle_stage}</span>
}},
{ key: 'survival_status', label: '存活状态', align: 'center' },
{ key: 'first_week_report_date', label: '首周战报', render: (r) => r.first_week_report_date ? (r.first_week_report_date as string).substring(0, 10) : '-' },
{ key: 'day90_report_date', label: '90天判定', render: (r) => r.day90_report_date ? (r.day90_report_date as string).substring(0, 10) : '-' },
{ key: 'action', label: '操作', align: 'center', render: (r) => (
<button onClick={() => trackMutation.mutate(r.id)} className="rounded bg-primary px-2 py-0.5 text-xs text-primary-foreground"></button>
)},
]}
/>
</CollapsibleSection>
</div>
</>
)}
{tab === 'reviews' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="评价总数" value={totalReviews} format="number" description="所有来源评价" />
<MetricCard title="平均评分" value={avgRating} format="number" description="满分5分" status={avgRating >= 4 ? 'good' : avgRating >= 3 ? 'warn' : 'bad'} />
<MetricCard title="好评率" value={positiveRate} format="percent" description="正面评价占比" status={positiveRate >= 80 ? 'good' : positiveRate >= 60 ? 'warn' : 'bad'} />
<MetricCard title="差评数" value={Number(overview.negative || 0)} format="number" description="负面评价数" status="bad" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="评价归因分布" subtitle="NLP自动分类">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={categoryData} cx="50%" cy="50%" outerRadius={70} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${value}`}</tspan>}>
{categoryData.map((_, i) => <Cell key={i} fill={['#ef4444', '#3b82f6', '#22c55e', '#eab308', '#a855f7'][i]} />)}
</Pie>
<Tooltip formatter={(v: any) => [formatNumber(v), '条']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="评价趋势" subtitle="按日期统计">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={reviews.slice(0, 30).reverse().map((r: any) => ({
date: (r.review_date || '').substring(0, 10),
rating: Number(r.rating || 0),
}))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" fontSize={10} />
<YAxis domain={[0, 5]} />
<Tooltip />
<Legend />
<Bar dataKey="rating" fill="#3b82f6" name="评分" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
<CollapsibleSection title={`评价明细 (${reviews.length})`} subtitle="按日期降序">
<FilterableTable
data={reviews}
filterKey="nlp_category"
filterLabel="全部分类"
sortOptions={[
{ key: 'review_date', label: '评价日期' },
{ key: 'rating', label: '评分' },
]}
defaultSort="review_date"
defaultOrder="desc"
searchKeys={['content', 'store_name', 'sku_name']}
searchPlaceholder="搜索评价内容..."
columns={[
{ key: 'review_date', label: '日期', render: (r) => (r.review_date || '').toString().substring(0, 10) },
{ key: 'review_source', label: '来源' },
{ key: 'store_name', label: '门店' },
{ key: 'sku_name', label: 'SKU' },
{ key: 'rating', label: '评分', align: 'center', render: (r) => {
const colors: Record<number, string> = { 1: 'text-red-600', 2: 'text-red-500', 3: 'text-yellow-600', 4: 'text-blue-600', 5: 'text-green-600' }
return <span className={`font-bold ${colors[r.rating] || ''}`}>{r.rating}</span>
}},
{ key: 'nlp_category', label: '归因', align: 'center' },
{ key: 'nlp_sentiment', label: '情感', align: 'center', render: (r) => {
const colors: Record<string, string> = { '正面': 'text-green-600', '负面': 'text-red-600', '中性': 'text-yellow-600' }
return <span className={colors[r.nlp_sentiment] || ''}>{r.nlp_sentiment}</span>
}},
{ key: 'content', label: '评价内容' },
]}
/>
</CollapsibleSection>
</>
)}
</div>
)
}
@@ -0,0 +1,134 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { useState } from 'react'
export function SchedulerManagementPage() {
const queryClient = useQueryClient()
const [editingId, setEditingId] = useState<number | null>(null)
const [editCron, setEditCron] = useState('')
const { data: tasksData, isLoading } = useQuery({
queryKey: ['scheduler/tasks'],
queryFn: () => api.get('/scheduler'),
})
const toggleMutation = useMutation({
mutationFn: (data: { id: number; is_enabled: boolean }) =>
api.patch(`/scheduler/${data.id}`, { is_enabled: data.is_enabled }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scheduler/tasks'] }),
})
const updateCronMutation = useMutation({
mutationFn: (data: { id: number; cron_expr: string }) =>
api.patch(`/scheduler/${data.id}`, { cron_expr: data.cron_expr }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scheduler/tasks'] })
setEditingId(null)
},
})
const triggerMutation = useMutation({
mutationFn: (id: number) => api.post(`/scheduler/${id}/trigger`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scheduler/tasks'] }),
})
const tasks = (tasksData as any)?.data || []
const enabledCount = tasks.filter((t: any) => t.is_enabled).length
const successCount = tasks.filter((t: any) => t.last_status === 'success').length
const failedCount = tasks.filter((t: any) => t.last_status === 'failed').length
const runningCount = tasks.filter((t: any) => t.last_status === 'running').length
if (isLoading) {
return <LoadingSpinner text="加载调度任务..." />
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · </p>
</div>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总任务数" value={tasks.length} format="number" description="所有注册的调度任务" />
<MetricCard title="启用中" value={enabledCount} format="number" description="当前启用的任务数" status="good" />
<MetricCard title="成功执行" value={successCount} format="number" description="上次执行成功的任务数" status="good" />
<MetricCard title="执行失败" value={failedCount} format="number" description="上次执行失败的任务数" status={failedCount > 0 ? 'bad' : 'good'} />
</div>
<CollapsibleSection title={`调度任务列表 (${tasks.length})`} subtitle="查看、启停、手动触发调度任务">
<FilterableTable
data={tasks}
sortOptions={[
{ key: 'task_name', label: '任务名' },
{ key: 'last_run_at', label: '上次执行' },
{ key: 'last_status', label: '状态' },
]}
defaultSort="task_name"
defaultOrder="asc"
columns={[
{ key: 'task_name', label: '任务名称' },
{ key: 'description', label: '描述' },
{ key: 'cron_expr', label: 'Cron表达式', render: (r) => (
editingId === r.id ? (
<div className="flex gap-1">
<input
type="text"
value={editCron}
onChange={(e) => setEditCron(e.target.value)}
className="w-32 rounded border px-2 py-0.5 text-xs"
/>
<button
onClick={() => updateCronMutation.mutate({ id: r.id, cron_expr: editCron })}
className="rounded bg-primary px-2 py-0.5 text-xs text-primary-foreground"
></button>
<button
onClick={() => setEditingId(null)}
className="rounded bg-muted px-2 py-0.5 text-xs"
></button>
</div>
) : (
<span className="font-mono text-xs">{r.cron_expr}</span>
)
)},
{ key: 'is_enabled', label: '启用', align: 'center', render: (r) => (
<button
onClick={() => toggleMutation.mutate({ id: r.id, is_enabled: !r.is_enabled })}
className={`rounded px-2 py-0.5 text-xs ${r.is_enabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}
>
{r.is_enabled ? '启用中' : '已停用'}
</button>
)},
{ key: 'last_status', label: '上次状态', align: 'center', render: (r) => {
if (!r.last_status) return <span className="text-xs text-muted-foreground"></span>
const colors: Record<string, string> = { success: 'bg-green-100 text-green-700', failed: 'bg-red-100 text-red-700', running: 'bg-blue-100 text-blue-700' }
const labels: Record<string, string> = { success: '成功', failed: '失败', running: '运行中' }
return <span className={`rounded px-2 py-0.5 text-xs ${colors[r.last_status] || ''}`}>{labels[r.last_status] || r.last_status}</span>
}},
{ key: 'last_run_at', label: '上次执行时间', render: (r) => r.last_run_at ? (r.last_run_at as string).substring(0, 19).replace('T', ' ') : '-' },
{ key: 'actions', label: '操作', align: 'center', render: (r) => (
<div className="flex gap-1">
<button
onClick={() => triggerMutation.mutate(r.id)}
disabled={triggerMutation.isPending}
className="rounded bg-primary px-2 py-0.5 text-xs text-primary-foreground hover:opacity-80"
></button>
<button
onClick={() => { setEditingId(r.id); setEditCron(r.cron_expr) }}
className="rounded bg-muted px-2 py-0.5 text-xs"
></button>
</div>
)},
]}
/>
</CollapsibleSection>
</div>
)
}
+241
View File
@@ -0,0 +1,241 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar } from 'recharts'
const GRADE_COLORS: Record<string, string> = { A: '#22c55e', B: '#3b82f6', C: '#eab308', D: '#ef4444' }
const RISK_COLORS: Record<string, string> = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
export function StoreGradePage() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [selectedStore, setSelectedStore] = useState<any>(null)
const queryClient = useQueryClient()
const { data: gradeData, isLoading: gradeLoading } = useQuery({
queryKey: ['store-grade/grades', month],
queryFn: () => api.get('/store-grade/grades', { params: { month: month + '-01' } }),
})
const autoGradeMutation = useMutation({
mutationFn: () => api.post('/store-grade/auto-grade', null, { params: { month: month + '-01' } }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['store-grade/grades'] })
},
})
const gradeRows = (gradeData as any)?.data || []
const gradeDistribution = gradeRows.reduce((acc: Record<string, number>, r: any) => {
acc[r.grade] = (acc[r.grade] || 0) + 1
return acc
}, {})
const gradePieData = Object.entries(gradeDistribution).map(([name, value]) => ({ name: `${name}`, value }))
const riskDistribution = gradeRows.reduce((acc: Record<string, number>, r: any) => {
acc[r.risk_level] = (acc[r.risk_level] || 0) + 1
return acc
}, {})
const riskPieData = [
{ name: '红色', value: riskDistribution['红色'] || 0 },
{ name: '黄色', value: riskDistribution['黄色'] || 0 },
{ name: '绿色', value: riskDistribution['绿色'] || 0 },
]
const avgScore = gradeRows.length > 0
? gradeRows.reduce((s: number, r: any) => s + Number(r.overall_score || 0), 0) / gradeRows.length
: 0
const aGradeCount = gradeDistribution.A || 0
const dGradeCount = gradeDistribution.D || 0
const redRiskCount = riskDistribution['红色'] || 0
if (gradeLoading) {
return <LoadingSpinner text="加载门店分级数据..." />
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · {month}</p>
</div>
<div className="flex items-center gap-2">
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="rounded border px-2 py-1 text-sm"
/>
<button
onClick={() => autoGradeMutation.mutate()}
disabled={autoGradeMutation.isPending}
className="rounded bg-primary px-3 py-1 text-sm text-primary-foreground disabled:opacity-50"
>
{autoGradeMutation.isPending ? '分级中...' : '自动分级'}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="门店总数" value={gradeRows.length} format="number" description="参与分级的门店总数" />
<MetricCard title="平均综合分" value={avgScore} format="number" description="所有门店综合评分平均值" status={avgScore >= 75 ? 'good' : avgScore >= 60 ? 'warn' : 'bad'} />
<MetricCard title="A级门店" value={aGradeCount} format="number" description="综合分≥85的优秀门店" status="good" />
<MetricCard title="高风险门店" value={redRiskCount} format="number" description="风险等级红色的门店" status={redRiskCount > 0 ? 'bad' : 'good'} />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="门店分级分布" subtitle="综合分=达成率40%+毛利率25%+会员占比15%+风险20% | A≥85 · B≥70 · C≥50 · D<50">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={gradePieData} cx="50%" cy="50%" outerRadius={70} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${value}`}</tspan>}>
{gradePieData.map((entry, i) => <Cell key={i} fill={GRADE_COLORS[entry.name[0]] || '#999'} />)}
</Pie>
<Tooltip formatter={(v: any) => [formatNumber(v), '门店数']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="风险等级分布" subtitle="风险模型基于毛利率、优惠率、异常率等指标综合评估">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={riskPieData} cx="50%" cy="50%" outerRadius={70} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${value}`}</tspan>}>
{riskPieData.map((entry, i) => <Cell key={i} fill={RISK_COLORS[entry.name] || '#999'} />)}
</Pie>
<Tooltip formatter={(v: any) => [formatNumber(v), '门店数']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
<CollapsibleSection title={`门店分级明细 (${gradeRows.length})`} subtitle="综合分=达成率40%+毛利率25%+会员占比15%+风险20% | A≥85 · B≥70 · C≥50 · D<50">
<FilterableTable
data={gradeRows}
filterKey="grade"
filterLabel="全部分级"
sortOptions={[
{ key: 'overall_score', label: '综合分' },
{ key: 'revenue_achievement_pct', label: '达成率' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="overall_score"
defaultOrder="desc"
onRowClick={(r) => setSelectedStore(r)}
columns={[
{ key: 'store_code', label: '门店编码' },
{ key: 'store_name', label: '门店名称' },
{ key: 'region', label: '区域' },
{ key: 'grade', label: '分级', align: 'center', render: (r) => {
const colors: Record<string, string> = { A: 'text-green-600', B: 'text-blue-600', C: 'text-yellow-600', D: 'text-red-600' }
return <span className={`font-bold ${colors[r.grade] || ''}`}>{r.grade}</span>
}},
{ key: 'risk_level', label: '风险', align: 'center', render: (r) => {
const colors: Record<string, string> = { '红色': 'bg-red-500 text-white', '黄色': 'bg-yellow-500 text-white', '绿色': 'bg-green-500 text-white' }
return <span className={`rounded px-2 py-0.5 text-xs ${colors[r.risk_level] || ''}`}>{r.risk_level || '-'}</span>
}},
{ key: 'overall_score', label: '综合分', align: 'right', render: (r) => Number(r.overall_score || 0).toFixed(1) },
{ key: 'revenue_achievement_pct', label: '营收达成率', align: 'right', render: (r) => {
const v = Number(r.revenue_achievement_pct || 0)
return <span className={v >= 100 ? 'text-green-600' : v >= 80 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}%</span>
}},
{ key: 'reason', label: '分级原因' },
]}
/>
</CollapsibleSection>
{selectedStore && <StoreDetailModal store={selectedStore} onClose={() => setSelectedStore(null)} />}
</div>
)
}
const GRADE_DIMENSIONS = [
{ key: 'revenue_achievement_pct', label: '营收达成', max: 100 },
{ key: 'overall_score', label: '综合分', max: 100 },
{ key: 'margin_pct', label: '毛利率', max: 50 },
{ key: 'anomaly_rate', label: '异常率', max: 20, inverse: true },
]
function StoreDetailModal({ store, onClose }: { store: any; onClose: () => void }) {
const gradeColor = GRADE_COLORS[store.grade] || '#999'
const riskColor = RISK_COLORS[store.risk_level] || '#999'
const radarData = GRADE_DIMENSIONS.map(d => {
const raw = Number(store[d.key] || 0)
const pct = d.inverse ? Math.max(0, 100 - (raw / d.max) * 100) : Math.min((raw / d.max) * 100, 100)
return { dimension: d.label, score: Math.round(pct), fullMark: 100 }
})
const metrics = [
{ label: '门店编码', value: store.store_code },
{ label: '区域', value: store.region || '-' },
{ label: '分级', value: store.grade, color: gradeColor },
{ label: '综合分', value: Number(store.overall_score || 0).toFixed(1) },
{ label: '营收达成率', value: `${Number(store.revenue_achievement_pct || 0).toFixed(1)}%` },
{ label: '营收', value: formatCurrency(store.revenue) },
{ label: '毛利率', value: formatPercent(store.margin_pct) },
{ label: '优惠率', value: store.discount_rate != null ? `${Number(store.discount_rate).toFixed(1)}%` : '-' },
{ label: '异常率', value: store.anomaly_rate != null ? `${Number(store.anomaly_rate).toFixed(1)}%` : '-' },
{ label: '会员占比', value: store.member_pct != null ? `${Number(store.member_pct).toFixed(1)}%` : '-' },
{ label: '客单价', value: formatCurrency(store.avg_bill_value) },
{ label: '活跃天数', value: store.active_days != null ? `${store.active_days}` : '-' },
{ label: '风险等级', value: store.risk_level || '-', color: riskColor },
]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<h2 className="text-lg font-bold">{store.store_name}</h2>
<span className="rounded px-2 py-0.5 text-xs font-bold text-white" style={{ background: gradeColor }}>{store.grade}</span>
<span className="rounded px-2 py-0.5 text-xs font-medium text-white" style={{ background: riskColor }}>
{store.risk_level || '-'}
</span>
</div>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-xl"></button>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<ResponsiveContainer width="100%" height={250}>
<RadarChart data={radarData}>
<PolarGrid />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 10 }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={{ fontSize: 8 }} />
<Radar dataKey="score" name="得分" stroke={gradeColor} fill={gradeColor} fillOpacity={0.3} />
<Tooltip formatter={(v: any) => [v, '得分']} />
</RadarChart>
</ResponsiveContainer>
</div>
<div className="space-y-2">
{metrics.map(m => (
<div key={m.label} className="flex items-center justify-between border-b pb-1 text-sm">
<span className="text-muted-foreground">{m.label}</span>
<span className="font-medium" style={m.color ? { color: m.color } : undefined}>{m.value}</span>
</div>
))}
{store.reason && (
<div className="pt-2 text-xs text-muted-foreground">
<span className="font-medium"></span>{store.reason}
</div>
)}
{store.primary_issue && (
<div className="pt-1 text-xs text-muted-foreground">
<span className="font-medium"></span>{store.primary_issue}
</div>
)}
</div>
</div>
</div>
</div>
)
}
+321
View File
@@ -0,0 +1,321 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { Tabs } from '@/components/Tabs'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
const GRADE_COLORS: Record<string, string> = { A: '#22c55e', B: '#3b82f6', C: '#eab308', D: '#ef4444' }
export function TargetManagementPage() {
const [tab, setTab] = useState('overview')
const [year, setYear] = useState(new Date().getFullYear())
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const queryClient = useQueryClient()
const { data: annualData, isLoading: annualLoading } = useQuery({
queryKey: ['target/annual', year],
queryFn: () => api.get('/target/annual', { params: { year } }),
})
const { data: regionalData, isLoading: regionalLoading } = useQuery({
queryKey: ['target/regional', year],
queryFn: () => api.get('/target/regional', { params: { year } }),
})
const { data: storeMonthlyData, isLoading: storeLoading } = useQuery({
queryKey: ['target/store-monthly', month],
queryFn: () => api.get('/target/store-monthly', { params: { month: month + '-01' } }),
})
const { data: achievementData, isLoading: achievementLoading } = useQuery({
queryKey: ['target/achievement', month],
queryFn: () => api.get('/target/achievement', { params: { month: month + '-01' } }),
})
const decomposeMutation = useMutation({
mutationFn: (data: { year: number; month?: string }) =>
api.post('/target/decompose/annual-to-store', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['target/store-monthly'] })
queryClient.invalidateQueries({ queryKey: ['target/achievement'] })
},
})
const dailyDecomposeMutation = useMutation({
mutationFn: (data: { month: string }) =>
api.post('/target/decompose/monthly-to-daily', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['target/daily'] })
},
})
const annualRows = (annualData as any)?.data || []
const regionalRows = (regionalData as any)?.data || []
const storeRows = (storeMonthlyData as any)?.data || []
const achievementRows = (achievementData as any)?.data || []
const revenueTarget = annualRows.find((r: any) => r.metric === 'revenue')
const profitTarget = annualRows.find((r: any) => r.metric === 'profit')
const storeCountTarget = annualRows.find((r: any) => r.metric === 'store_count')
const memberCountTarget = annualRows.find((r: any) => r.metric === 'member_count')
const totalStoreTarget = storeRows.reduce((s: number, r: any) => s + Number(r.revenue_target || 0), 0)
const avgAchievement = achievementRows.length > 0
? achievementRows.reduce((s: number, r: any) => s + Number(r.achievement_rate || 0), 0) / achievementRows.length
: 0
const achievedCount = achievementRows.filter((r: any) => Number(r.achievement_rate || 0) >= 100).length
const lowAchievementCount = achievementRows.filter((r: any) => Number(r.achievement_rate || 0) < 80).length
const gradeDistribution = storeRows.reduce((acc: Record<string, number>, r: any) => {
const g = r.grade || 'B'
acc[g] = (acc[g] || 0) + 1
return acc
}, {})
const gradePieData = Object.entries(gradeDistribution).map(([name, value]) => ({ name: `${name}`, value }))
const achievementChartData = achievementRows.slice(0, 20).map((r: any) => ({
name: r.store_name?.length > 6 ? r.store_name.slice(0, 6) : r.store_name,
达成率: Number(r.achievement_rate || 0),
}))
const pageLoading = annualLoading || regionalLoading || storeLoading || achievementLoading
if (pageLoading && tab === 'overview') {
return <LoadingSpinner text="加载目标管理数据..." />
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · · </p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="w-20 rounded border px-2 py-1 text-sm"
/>
<span className="text-sm text-muted-foreground"></span>
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="rounded border px-2 py-1 text-sm"
/>
</div>
</div>
<Tabs
tabs={[
{ key: 'overview', label: '目标概览' },
{ key: 'annual', label: '年度目标' },
{ key: 'regional', label: '区域拆解' },
{ key: 'store', label: '门店月度' },
{ key: 'achievement', label: '达成率' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'overview' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="年度营收目标" value={revenueTarget?.target_value || 0} format="currency" description={`${year}年年度战略营收目标`} />
<MetricCard title="年度利润目标" value={profitTarget?.target_value || 0} format="currency" description={`${year}年年度战略利润目标`} />
<MetricCard title="门店月度目标总额" value={totalStoreTarget} format="currency" description={`${month}月所有门店营收目标合计`} />
<MetricCard title="平均达成率" value={avgAchievement} format="percent" description="所有门店达成率的平均值" status={avgAchievement >= 90 ? 'good' : avgAchievement >= 70 ? 'warn' : 'bad'} />
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="目标门店数" value={storeCountTarget?.target_value || 0} format="number" description={`${year}年目标门店数`} />
<MetricCard title="目标会员数" value={memberCountTarget?.target_value || 0} format="number" description={`${year}年目标会员数`} />
<MetricCard title="达标门店数" value={achievedCount} format="number" description="达成率≥100%的门店数" status="good" />
<MetricCard title="低达成门店(<80%)" value={lowAchievementCount} format="number" description="达成率<80%的门店数,需重点关注" status={lowAchievementCount > 0 ? 'bad' : 'good'} />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="门店达成率TOP20" subtitle="按达成率降序">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={achievementChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-30} textAnchor="end" height={60} />
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => [`${Number(v).toFixed(1)}%`, '达成率']} />
<Legend />
<Bar dataKey="达成率" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="门店分级分布" subtitle="A/B/C/D级门店数量">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={gradePieData} cx="50%" cy="50%" outerRadius={70} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${value}`}</tspan>}>
{gradePieData.map((entry, i) => <Cell key={i} fill={GRADE_COLORS[entry.name[0]] || '#999'} />)}
</Pie>
<Tooltip formatter={(v: any) => [formatNumber(v), '门店数']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
<CollapsibleSection title="目标拆解操作" subtitle="自动拆解年度目标到门店月度">
<div className="flex gap-3">
<button
onClick={() => decomposeMutation.mutate({ year, month: month + '-01' })}
disabled={decomposeMutation.isPending}
className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
>
{decomposeMutation.isPending ? '拆解中...' : `年度→门店月度拆解 (${month})`}
</button>
<button
onClick={() => dailyDecomposeMutation.mutate({ month: month + '-01' })}
disabled={dailyDecomposeMutation.isPending}
className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
>
{dailyDecomposeMutation.isPending ? '拆解中...' : `月度→日目标拆解 (${month})`}
</button>
{decomposeMutation.data && (
<span className="self-center text-sm text-green-600">
: {(decomposeMutation.data as any)?.data?.stores || 0}
</span>
)}
</div>
</CollapsibleSection>
</>
)}
{tab === 'annual' && (
<CollapsibleSection title="年度战略目标" subtitle={`${year}年战略目标设定`}>
<FilterableTable
data={annualRows}
sortOptions={[
{ key: 'metric', label: '指标' },
{ key: 'target_value', label: '目标值' },
]}
defaultSort="metric"
defaultOrder="asc"
columns={[
{ key: 'year', label: '年份', align: 'center' },
{ key: 'metric', label: '指标', render: (r) => {
const labels: Record<string, string> = { revenue: '营收', profit: '利润', store_count: '门店数', member_count: '会员数' }
return labels[r.metric] || r.metric
}},
{ key: 'target_value', label: '目标值', align: 'right', render: (r) => {
if (r.metric === 'revenue' || r.metric === 'profit') return formatCurrency(r.target_value)
return formatNumber(r.target_value)
}},
{ key: 'description', label: '说明' },
{ key: 'created_by', label: '创建人' },
]}
/>
</CollapsibleSection>
)}
{tab === 'regional' && (
<CollapsibleSection title="区域年度目标" subtitle={`${year}年各区域目标拆解`}>
<FilterableTable
data={regionalRows}
sortOptions={[
{ key: 'region', label: '区域' },
{ key: 'revenue_target', label: '营收目标' },
{ key: 'weight_pct', label: '权重' },
]}
defaultSort="region"
defaultOrder="asc"
columns={[
{ key: 'region', label: '区域' },
{ key: 'revenue_target', label: '营收目标', align: 'right', render: (r) => formatCurrency(r.revenue_target) },
{ key: 'profit_target', label: '利润目标', align: 'right', render: (r) => formatCurrency(r.profit_target) },
{ key: 'store_count_target', label: '门店数目标', align: 'right', render: (r) => formatNumber(r.store_count_target) },
{ key: 'member_count_target', label: '会员数目标', align: 'right', render: (r) => formatNumber(r.member_count_target) },
{ key: 'weight_pct', label: '权重', align: 'right', render: (r) => formatPercent(r.weight_pct) },
]}
/>
</CollapsibleSection>
)}
{tab === 'store' && (
<CollapsibleSection title={`门店月度目标 (${storeRows.length})`} subtitle={`${month}月各门店目标`}>
<FilterableTable
data={storeRows}
filterKey="region"
filterLabel="全部区域"
sortOptions={[
{ key: 'store_code', label: '门店编码' },
{ key: 'revenue_target', label: '营收目标' },
{ key: 'grade', label: '分级' },
]}
defaultSort="store_code"
defaultOrder="asc"
columns={[
{ key: 'store_code', label: '门店编码' },
{ key: 'store_name', label: '门店名称' },
{ key: 'region', label: '区域' },
{ key: 'grade', label: '分级', align: 'center', render: (r) => {
const colors: Record<string, string> = { A: 'text-green-600', B: 'text-blue-600', C: 'text-yellow-600', D: 'text-red-600' }
return <span className={`font-bold ${colors[r.grade] || ''}`}>{r.grade}</span>
}},
{ key: 'revenue_target', label: '营收目标', align: 'right', render: (r) => formatCurrency(r.revenue_target) },
{ key: 'bill_count_target', label: '账单数目标', align: 'right', render: (r) => formatNumber(r.bill_count_target) },
{ key: 'avg_bill_value_target', label: '客单价目标', align: 'right', render: (r) => formatCurrency(r.avg_bill_value_target) },
{ key: 'member_penetration_target', label: '会员渗透率', align: 'right', render: (r) => formatPercent(r.member_penetration_target) },
{ key: 'cost_rate_target', label: '成本率', align: 'right', render: (r) => formatPercent(r.cost_rate_target) },
]}
/>
</CollapsibleSection>
)}
{tab === 'achievement' && (
<CollapsibleSection title={`门店达成率 (${achievementRows.length})`} subtitle={`${month}月各门店目标达成率`}>
{achievementLoading ? <LoadingSpinner text="加载达成率数据..." /> : (
<FilterableTable
data={achievementRows}
filterKey="region"
filterLabel="全部区域"
sortOptions={[
{ key: 'achievement_rate', label: '达成率' },
{ key: 'revenue_target', label: '目标' },
{ key: 'actual_revenue', label: '实际' },
{ key: 'bill_achievement_rate', label: '账单达成率' },
]}
defaultSort="achievement_rate"
defaultOrder="desc"
columns={[
{ key: 'store_code', label: '门店编码' },
{ key: 'store_name', label: '门店名称' },
{ key: 'region', label: '区域' },
{ key: 'grade', label: '分级', align: 'center', render: (r) => {
const colors: Record<string, string> = { A: 'text-green-600', B: 'text-blue-600', C: 'text-yellow-600', D: 'text-red-600' }
return <span className={`font-bold ${colors[r.grade] || ''}`}>{r.grade}</span>
}},
{ key: 'revenue_target', label: '营收目标', align: 'right', render: (r) => formatCurrency(r.revenue_target) },
{ key: 'actual_revenue', label: '实际营收', align: 'right', render: (r) => formatCurrency(r.actual_revenue) },
{ key: 'achievement_rate', label: '达成率', align: 'right', render: (r) => {
const v = Number(r.achievement_rate || 0)
return <span className={v >= 100 ? 'font-medium text-green-600' : v >= 80 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}%</span>
}},
{ key: 'bill_count_target', label: '账单目标', align: 'right', render: (r) => formatNumber(r.bill_count_target) },
{ key: 'actual_bill_count', label: '实际账单', align: 'right', render: (r) => formatNumber(r.actual_bill_count) },
{ key: 'bill_achievement_rate', label: '账单达成率', align: 'right', render: (r) => {
const v = Number(r.bill_achievement_rate || 0)
return <span className={v >= 100 ? 'text-green-600' : v >= 80 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}%</span>
}},
]}
/>
)}
</CollapsibleSection>
)}
</div>
)
}