初始化:连锁餐饮数字化运营管理平台
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>马兰拉面数字化运营管理平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3487
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "sbrain-client",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.51.1",
|
||||
"axios": "^1.7.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.408.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.24.1",
|
||||
"recharts": "^2.12.7",
|
||||
"tailwind-merge": "^2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Layout } from '@/components/Layout'
|
||||
import { DashboardPage } from '@/pages/DashboardPage'
|
||||
import { TasksPage } from '@/pages/TasksPage'
|
||||
import { TaskDetailPage } from '@/pages/TaskDetailPage'
|
||||
import { StoreDetailPage } from '@/pages/StoreDetailPage'
|
||||
import { MonthlyReviewPage } from '@/pages/MonthlyReviewPage'
|
||||
import { IndicatorsPage } from '@/pages/IndicatorsPage'
|
||||
import { RegionalPage } from '@/pages/RegionalPage'
|
||||
import { StorePage } from '@/pages/StorePage'
|
||||
import { SKUPage } from '@/pages/SKUPage'
|
||||
import { CostPage } from '@/pages/CostPage'
|
||||
import { PlatformPage } from '@/pages/PlatformPage'
|
||||
import { MemberPage } from '@/pages/MemberPage'
|
||||
import { RiskPage } from '@/pages/RiskPage'
|
||||
import { TimePage } from '@/pages/TimePage'
|
||||
import { DataQualityPage } from '@/pages/DataQualityPage'
|
||||
import { OntologyPage } from '@/pages/OntologyPage'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState<any>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('user')
|
||||
if (stored) setUser(JSON.parse(stored))
|
||||
setReady(true)
|
||||
}, [])
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
const handleLogin = useCallback((u: any) => {
|
||||
setUser(u)
|
||||
}, [])
|
||||
|
||||
if (!ready) return null
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
{!token ? (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage onLogin={handleLogin} />} />
|
||||
<Route path="*" element={<Navigate to="/login" />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<Layout user={user} onLogout={handleLogout}>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/regional" element={<RegionalPage />} />
|
||||
<Route path="/store" element={<StorePage />} />
|
||||
<Route path="/tasks" element={<TasksPage />} />
|
||||
<Route path="/tasks/:id" element={<TaskDetailPage />} />
|
||||
<Route path="/stores/:code" element={<StoreDetailPage />} />
|
||||
<Route path="/monthly-review" element={<MonthlyReviewPage />} />
|
||||
<Route path="/sku" element={<SKUPage />} />
|
||||
<Route path="/cost" element={<CostPage />} />
|
||||
<Route path="/platform" element={<PlatformPage />} />
|
||||
<Route path="/member" element={<MemberPage />} />
|
||||
<Route path="/risk" element={<RiskPage />} />
|
||||
<Route path="/time" element={<TimePage />} />
|
||||
<Route path="/data-quality" element={<DataQualityPage />} />
|
||||
<Route path="/indicators" element={<IndicatorsPage />} />
|
||||
<Route path="/ontology" element={<OntologyPage />} />
|
||||
<Route path="/login" element={<Navigate to="/" />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
)}
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cn, priorityColor, riskColor, statusColor, reviewResultColor } from '@/lib/utils'
|
||||
|
||||
interface BadgeProps {
|
||||
text: string
|
||||
type?: 'priority' | 'risk' | 'status' | 'review' | 'default'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Badge({ text, type = 'default', className }: BadgeProps) {
|
||||
const colorClass = type === 'priority'
|
||||
? priorityColor(text)
|
||||
: type === 'risk'
|
||||
? riskColor(text)
|
||||
: type === 'status'
|
||||
? statusColor(text)
|
||||
: type === 'review'
|
||||
? reviewResultColor(text)
|
||||
: 'bg-gray-100 text-gray-700 border border-gray-300'
|
||||
|
||||
return (
|
||||
<span className={cn('inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium', colorClass, className)}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
defaultOpen?: boolean
|
||||
headerRight?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function CollapsibleSection({ title, subtitle, defaultOpen = true, headerRight, children }: CollapsibleSectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<button
|
||||
className="flex w-full items-center justify-between p-4 text-left"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('text-sm font-bold transition-transform', open ? 'rotate-90' : 'rotate-0')}>▶</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold">{title}</h2>
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{headerRight && <div onClick={(e) => e.stopPropagation()}>{headerRight}</div>}
|
||||
</button>
|
||||
{open && <div className="border-t px-4 pb-4 pt-3">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DataTableProps {
|
||||
columns: { key: string; label: string; align?: 'left' | 'right' | 'center'; render?: (row: any) => ReactNode }[]
|
||||
data: any[]
|
||||
onRowClick?: (row: any) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DataTable({ columns, data, onRowClick, className }: DataTableProps) {
|
||||
return (
|
||||
<div className={cn('overflow-x-auto rounded-lg border', className)}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-3 py-2 font-medium text-muted-foreground whitespace-nowrap',
|
||||
col.align === 'right' && 'text-right',
|
||||
col.align === 'center' && 'text-center',
|
||||
!col.align && 'text-left'
|
||||
)}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 py-8 text-center text-muted-foreground">
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={cn(
|
||||
'border-t hover:bg-muted/30',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-3 py-2 whitespace-nowrap',
|
||||
col.align === 'right' && 'text-right',
|
||||
col.align === 'center' && 'text-center'
|
||||
)}
|
||||
>
|
||||
{col.render ? col.render(row) : row[col.key] ?? '-'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
user: { name: string; role: string } | null
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: any
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
interface MenuGroup {
|
||||
title: string
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
const menuGroups: MenuGroup[] = [
|
||||
{
|
||||
title: '经营管理',
|
||||
items: [
|
||||
{ path: '/', label: '总部驾驶舱', icon: LayoutDashboard, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
{ path: '/regional', label: '区域经理', icon: Store, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
{ path: '/store', label: '店长工作台', icon: ClipboardList, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
{ path: '/tasks', label: '任务管理', icon: ClipboardList, roles: ['hq', 'regional', 'store', 'dept'] },
|
||||
{ path: '/monthly-review', label: '月度验收', icon: TrendingUp, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '业务模块',
|
||||
items: [
|
||||
{ path: '/sku', label: '商品SKU', icon: Package, roles: ['hq', 'dept'] },
|
||||
{ path: '/cost', label: '成本库存', icon: DollarSign, roles: ['hq', 'dept'] },
|
||||
{ path: '/platform', label: '平台优惠', icon: ShoppingBag, roles: ['hq', 'dept'] },
|
||||
{ path: '/member', label: '会员复购', icon: Users, roles: ['hq', 'dept'] },
|
||||
{ path: '/risk', label: '风险内控', icon: AlertTriangle, roles: ['hq', 'dept'] },
|
||||
{ path: '/time', label: '时间分析', icon: Clock, roles: ['hq', 'dept'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统管理',
|
||||
items: [
|
||||
{ path: '/data-quality', label: '数据质量', icon: Database, roles: ['hq', 'dept'] },
|
||||
{ path: '/indicators', label: '指标字典', icon: Settings, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
{ path: '/ontology', label: '本体标准', icon: Database, roles: ['hq', 'dept'] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function Layout({ children, user, onLogout }: LayoutProps) {
|
||||
const location = useLocation()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
const visibleGroups = menuGroups.map(g => ({
|
||||
...g,
|
||||
items: g.items.filter((m) => !user || m.roles.includes(user.role)),
|
||||
})).filter(g => g.items.length > 0)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Mobile sidebar toggle */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="fixed left-4 top-4 z-50 rounded-md border p-2 lg:hidden"
|
||||
>
|
||||
{sidebarOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={cn(
|
||||
'fixed left-0 top-0 z-40 h-full w-56 border-r bg-card transition-transform lg:translate-x-0',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}>
|
||||
<div className="flex h-14 items-center border-b px-4">
|
||||
<span className="text-lg font-bold">马兰运营平台</span>
|
||||
</div>
|
||||
<nav className="space-y-4 p-3">
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<p className="mb-1 px-3 text-xs font-medium text-muted-foreground">{group.title}</p>
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = location.pathname === item.path
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
active ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
{user && (
|
||||
<div className="absolute bottom-0 left-0 right-0 border-t p-3">
|
||||
<div className="mb-2 px-3 text-sm">
|
||||
<p className="font-medium">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{user.role}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-56">
|
||||
<main className="min-h-screen p-4 pt-16 lg:pt-4">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Overlay for mobile */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-black/40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export function LoadingSpinner({ text = '加载中...' }: { text?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">{text}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cn, formatNumber } from '@/lib/utils'
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string
|
||||
value: string | number | null | undefined
|
||||
unit?: string
|
||||
format?: 'number' | 'percent' | 'currency'
|
||||
trend?: number
|
||||
description?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MetricCard({ title, value, unit, format = 'number', trend, description, className }: MetricCardProps) {
|
||||
const displayValue = format === 'percent'
|
||||
? value === null || value === undefined ? '-' : `${Number(value).toFixed(2)}%`
|
||||
: format === 'currency'
|
||||
? value === null || value === undefined ? '-' : `¥${formatNumber(value)}`
|
||||
: formatNumber(value)
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-lg border bg-card p-4 shadow-sm', className)}>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
{description && (
|
||||
<span className="group relative inline-flex">
|
||||
<span className="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-muted text-[10px] text-muted-foreground">?</span>
|
||||
<span className="pointer-events-none absolute left-1/2 top-6 z-10 w-48 -translate-x-1/2 rounded-md bg-gray-800 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold">{displayValue}</span>
|
||||
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
{trend !== undefined && (
|
||||
<p className={cn('mt-1 text-xs', trend > 0 ? 'text-green-600' : 'text-red-600')}>
|
||||
{trend > 0 ? '↑' : '↓'} {Math.abs(trend).toFixed(1)}% <span className="text-muted-foreground">环比上周</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
|
||||
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
export function Pagination({ page, pageSize, total, onPageChange }: PaginationProps) {
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const start = (page - 1) * pageSize + 1
|
||||
const end = Math.min(page * pageSize, total)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
第 {start}-{end} 条,共 {total} 条
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={page === 1}
|
||||
className="rounded-md border p-1.5 text-muted-foreground hover:bg-muted disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ChevronsLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="rounded-md border p-1.5 text-muted-foreground hover:bg-muted disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
{getPageNumbers(page, totalPages).map((p, idx) =>
|
||||
p === '...' ? (
|
||||
<span key={`ellipsis-${idx}`} className="px-2 text-xs text-muted-foreground">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p as number)}
|
||||
className={`min-w-[32px] rounded-md border px-2 py-1 text-xs ${
|
||||
page === p ? 'border-primary bg-primary text-primary-foreground' : 'hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="rounded-md border p-1.5 text-muted-foreground hover:bg-muted disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={page === totalPages}
|
||||
className="rounded-md border p-1.5 text-muted-foreground hover:bg-muted disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ChevronsRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getPageNumbers(current: number, total: number): (number | string)[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
|
||||
if (current <= 4) return [1, 2, 3, 4, 5, '...', total]
|
||||
if (current >= total - 3) return [1, '...', total - 4, total - 3, total - 2, total - 1, total]
|
||||
return [1, '...', current - 1, current, current + 1, '...', total]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Skeleton({ className, style }: { className?: string; style?: React.CSSProperties }) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-muted', className)} style={style} />
|
||||
}
|
||||
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="mt-2 h-7 w-32" />
|
||||
<Skeleton className="mt-1 h-3 w-16" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChartSkeleton({ height = 250 }: { height?: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<Skeleton className="mb-3 h-4 w-32" />
|
||||
<Skeleton className="w-full" style={{ height }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<Skeleton className="mb-3 h-4 w-48" />
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,56 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatNumber(n: number | string | null | undefined): string {
|
||||
if (n === null || n === undefined || n === '') return '-'
|
||||
const num = typeof n === 'string' ? parseFloat(n) : n
|
||||
if (isNaN(num)) return '-'
|
||||
return num.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
export function formatPercent(n: number | string | null | undefined): string {
|
||||
if (n === null || n === undefined || n === '') return '-'
|
||||
const num = typeof n === 'string' ? parseFloat(n) : n
|
||||
if (isNaN(num)) return '-'
|
||||
return `${num.toFixed(2)}%`
|
||||
}
|
||||
|
||||
export function formatCurrency(n: number | string | null | undefined): string {
|
||||
if (n === null || n === undefined || n === '') return '-'
|
||||
const num = typeof n === 'string' ? parseFloat(n) : n
|
||||
if (isNaN(num)) return '-'
|
||||
return `¥${num.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
export function priorityColor(priority: string): string {
|
||||
if (priority?.startsWith('P0')) return 'bg-red-100 text-red-700 border-red-300'
|
||||
if (priority?.startsWith('P1')) return 'bg-orange-100 text-orange-700 border-orange-300'
|
||||
if (priority?.startsWith('P2')) return 'bg-yellow-100 text-yellow-700 border-yellow-300'
|
||||
if (priority?.includes('标杆')) return 'bg-green-100 text-green-700 border-green-300'
|
||||
return 'bg-gray-100 text-gray-700 border-gray-300'
|
||||
}
|
||||
|
||||
export function riskColor(level: string): string {
|
||||
if (level === '红色') return 'bg-red-500 text-white'
|
||||
if (level === '黄色') return 'bg-yellow-500 text-white'
|
||||
return 'bg-green-500 text-white'
|
||||
}
|
||||
|
||||
export function statusColor(status: string): string {
|
||||
if (status === '待启动') return 'bg-gray-100 text-gray-600'
|
||||
if (status === '进行中') return 'bg-blue-100 text-blue-700'
|
||||
if (status === '已验收') return 'bg-green-100 text-green-700'
|
||||
if (status === '已回滚') return 'bg-red-100 text-red-700'
|
||||
return 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
export function reviewResultColor(result: string): string {
|
||||
if (result === '达标') return 'bg-green-100 text-green-700 border border-green-300'
|
||||
if (result === '改善中') return 'bg-yellow-100 text-yellow-700 border border-yellow-300'
|
||||
if (result === '未改善') return 'bg-red-100 text-red-700 border border-red-300'
|
||||
return 'bg-gray-100 text-gray-600 border border-gray-300'
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { Badge } from '@/components/Badge'
|
||||
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter } from 'recharts'
|
||||
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
export function CostPage() {
|
||||
const [costPage, setCostPage] = useState(1)
|
||||
const [invPage, setInvPage] = useState(1)
|
||||
const [catPage, setCatPage] = useState(1)
|
||||
|
||||
const { data: costData, isLoading: costLoading } = useQuery({
|
||||
queryKey: ['cost/comparison'],
|
||||
queryFn: () => api.get('/cost/comparison'),
|
||||
})
|
||||
|
||||
const { data: invData, isLoading: invLoading } = useQuery({
|
||||
queryKey: ['cost/inventory'],
|
||||
queryFn: () => api.get('/cost/inventory'),
|
||||
})
|
||||
|
||||
const { data: catData, isLoading: catLoading } = useQuery({
|
||||
queryKey: ['cost/category-benchmark'],
|
||||
queryFn: () => api.get('/cost/category-benchmark'),
|
||||
})
|
||||
|
||||
const costRows = (costData as any)?.data || []
|
||||
const invRows = (invData as any)?.data || []
|
||||
const catRows = (catData as any)?.data || []
|
||||
|
||||
const pageLoading = costLoading || invLoading || catLoading
|
||||
|
||||
const validCostRows = costRows.filter((r: any) => !r.variance_level?.includes('口径异常'))
|
||||
const abnormalRows = costRows.filter((r: any) => r.variance_level?.includes('口径异常'))
|
||||
|
||||
const avgTheoreticalRate = validCostRows.length > 0
|
||||
? validCostRows.reduce((s: number, r: any) => s + Number(r.theoretical_cost_rate_pct || 0), 0) / validCostRows.length
|
||||
: 0
|
||||
const avgActualRate = validCostRows.length > 0
|
||||
? validCostRows.reduce((s: number, r: any) => s + Number(r.actual_food_cost_rate_pct || 0), 0) / validCostRows.length
|
||||
: 0
|
||||
const highVarianceCount = validCostRows.filter((r: any) => Number(r.variance_to_theoretical_pct || 0) > 20).length
|
||||
const abnormalInvCount = invRows.filter((r: any) => Number(r.estimated_inventory_days) > 7 || Number(r.negative_item_lines) > 0).length
|
||||
|
||||
const pagedCost = useMemo(() => costRows.slice((costPage - 1) * PAGE_SIZE, costPage * PAGE_SIZE), [costRows, costPage])
|
||||
const pagedInv = useMemo(() => invRows.slice((invPage - 1) * PAGE_SIZE, invPage * PAGE_SIZE), [invRows, invPage])
|
||||
const pagedCat = useMemo(() => catRows.slice((catPage - 1) * PAGE_SIZE, catPage * PAGE_SIZE), [catRows, catPage])
|
||||
|
||||
const scatterData = validCostRows.map((r: any) => ({
|
||||
name: r.store_name,
|
||||
theoretical: Number(r.theoretical_cost_rate_pct || 0),
|
||||
actual: Number(r.actual_food_cost_rate_pct || 0),
|
||||
variance: Number(r.variance_to_theoretical_pct || 0),
|
||||
}))
|
||||
|
||||
if (pageLoading) {
|
||||
return <LoadingSpinner text="加载成本数据..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">成本与库存管理</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">理论vs实际成本 · 原料分类差异 · 库存效率 · 2026年4月</p>
|
||||
</div>
|
||||
|
||||
{/* 概览指标 */}
|
||||
<CollapsibleSection title="成本概览" subtitle="全门店成本效率汇总">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="平均理论成本率" value={avgTheoreticalRate} format="percent" description="按标准BOM计算的理论食材成本占实收比例(排除口径异常门店)" />
|
||||
<MetricCard title="平均实际成本率" value={avgActualRate} format="percent" description="盘点倒挤的实际食材成本占实收比例(排除口径异常门店)" />
|
||||
<MetricCard title="高偏差门店(>20%)" value={highVarianceCount} format="number" description="实际成本率超理论20%以上的门店数(排除口径异常)" />
|
||||
<MetricCard title="口径异常门店" value={abnormalRows.length} format="number" description="理论成本数据缺失或口径异常,不纳入成本率统计" />
|
||||
</div>
|
||||
{abnormalRows.length > 0 && (
|
||||
<div className="mt-3 rounded-md border border-gray-200 bg-gray-50/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-gray-700">口径异常门店({abnormalRows.length}家):</span>
|
||||
{abnormalRows.map((r: any) => r.store_name).join('、')}
|
||||
— 理论成本数据缺失或口径不一致,需先修复数据再纳入对比
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 成本散点图 */}
|
||||
<CollapsibleSection title="理论 vs 实际成本率" subtitle="每个点代表一家门店,偏离对角线越多成本偏差越大">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" dataKey="theoretical" name="理论成本率" unit="%" tick={{ fontSize: 10 }} domain={[0, 40]} />
|
||||
<YAxis type="number" dataKey="actual" name="实际成本率" unit="%" tick={{ fontSize: 10 }} domain={[0, 50]} />
|
||||
<Tooltip
|
||||
cursor={{ strokeDasharray: '3 3' }}
|
||||
content={({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const d = payload[0].payload
|
||||
return (
|
||||
<div className="rounded border bg-card p-2 text-xs shadow-sm">
|
||||
<p className="font-medium">{d.name}</p>
|
||||
<p>理论: {d.theoretical.toFixed(1)}%</p>
|
||||
<p>实际: {d.actual.toFixed(1)}%</p>
|
||||
<p>偏差: {d.variance > 0 ? '+' : ''}{d.variance.toFixed(1)}%</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
<Scatter data={scatterData} fill="#3b82f6" />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 成本对比表 */}
|
||||
<CollapsibleSection title={`门店成本对比 (${costRows.length})`} subtitle="理论成本 vs 实际倒挤成本,按偏差降序">
|
||||
<Pagination page={costPage} pageSize={PAGE_SIZE} total={costRows.length} onPageChange={setCostPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'theoretical_cost_rate_pct', label: '理论成本率', align: 'right', render: (r) => formatPercent(r.theoretical_cost_rate_pct) },
|
||||
{ key: 'actual_food_cost_rate_pct', label: '实际成本率', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) },
|
||||
{ key: 'variance_to_theoretical_pct', label: '偏差', align: 'right', render: (r) => {
|
||||
const v = Number(r.variance_to_theoretical_pct || 0)
|
||||
return <span className={v > 20 ? 'font-medium text-red-600' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v > 0 ? '+' : ''}{v.toFixed(1)}%</span>
|
||||
}},
|
||||
{ key: 'variance_level', label: '偏差等级', render: (r) => (
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
r.variance_level === '高偏差' ? 'bg-red-100 text-red-700' :
|
||||
r.variance_level === '中偏差' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>{r.variance_level || '-'}</span>
|
||||
)},
|
||||
{ key: 'actual_food_cost', label: '实际食材成本', align: 'right', render: (r) => formatCurrency(r.actual_food_cost) },
|
||||
]}
|
||||
data={pagedCost}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 库存效率 */}
|
||||
<CollapsibleSection title={`库存效率 (${invRows.length})`} subtitle="库存天数、负耗用、异常货品" defaultOpen={false}>
|
||||
<Pagination page={invPage} pageSize={PAGE_SIZE} total={invRows.length} onPageChange={setInvPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'business_type', label: '业态' },
|
||||
{ key: 'scale_tier', label: '规模' },
|
||||
{ key: 'estimated_inventory_days', label: '库存天数', align: 'right', render: (r) => {
|
||||
const d = Number(r.estimated_inventory_days || 0)
|
||||
return <span className={d > 7 ? 'font-medium text-red-600' : d > 4 ? 'text-yellow-600' : 'text-green-600'}>{d.toFixed(1)}天</span>
|
||||
}},
|
||||
{ key: 'ending_inventory_amount', label: '期末库存', align: 'right', render: (r) => formatCurrency(r.ending_inventory_amount) },
|
||||
{ key: 'negative_item_lines', label: '负耗用行数', align: 'center', render: (r) => {
|
||||
const n = Number(r.negative_item_lines || 0)
|
||||
return n > 0 ? <span className="font-medium text-red-600">{n}</span> : <span className="text-muted-foreground">0</span>
|
||||
}},
|
||||
{ key: 'abnormal_item_lines', label: '异常货品行', align: 'center', render: (r) => {
|
||||
const n = Number(r.abnormal_item_lines || 0)
|
||||
return n > 0 ? <span className="font-medium text-yellow-600">{n}</span> : <span className="text-muted-foreground">0</span>
|
||||
}},
|
||||
]}
|
||||
data={pagedInv}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 原料分类差异 */}
|
||||
<CollapsibleSection title={`原料分类成本基准 (${catRows.length})`} subtitle="按财务分类对比同业基准" defaultOpen={false}>
|
||||
<Pagination page={catPage} pageSize={PAGE_SIZE} total={catRows.length} onPageChange={setCatPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'finance_category', label: '财务分类' },
|
||||
{ key: 'actual_category_cost', label: '实际成本', align: 'right', render: (r) => formatCurrency(r.actual_category_cost) },
|
||||
{ key: 'cost_per_10k_sales', label: '每万元成本', align: 'right', render: (r) => formatCurrency(r.cost_per_10k_sales) },
|
||||
{ key: 'peer_median_cost_per_10k', label: '同业中位', align: 'right', render: (r) => formatCurrency(r.peer_median_cost_per_10k) },
|
||||
{ key: 'excess_vs_peer_per_10k', label: '超出同业', align: 'right', render: (r) => {
|
||||
const v = Number(r.excess_vs_peer_per_10k || 0)
|
||||
return v > 0 ? <span className="text-red-600">+{formatCurrency(v)}</span> : <span className="text-green-600">{formatCurrency(v)}</span>
|
||||
}},
|
||||
]}
|
||||
data={pagedCat}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend } from 'recharts'
|
||||
import api from '@/lib/api'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { Badge } from '@/components/Badge'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
const RISK_COLORS = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const [priorityPage, setPriorityPage] = useState(1)
|
||||
|
||||
const { data: overview, isLoading: odLoading } = useQuery({
|
||||
queryKey: ['overview'],
|
||||
queryFn: () => api.get('/overview'),
|
||||
})
|
||||
|
||||
const { data: daily, isLoading: dailyLoading } = useQuery({
|
||||
queryKey: ['overview/daily'],
|
||||
queryFn: () => api.get('/overview/daily'),
|
||||
})
|
||||
|
||||
const { data: riskData, isLoading: riskLoading } = useQuery({
|
||||
queryKey: ['stores/risk'],
|
||||
queryFn: () => api.get('/stores/risk'),
|
||||
})
|
||||
|
||||
const { data: priorityData, isLoading: priorityLoading } = useQuery({
|
||||
queryKey: ['stores/priority'],
|
||||
queryFn: () => api.get('/stores/priority'),
|
||||
})
|
||||
|
||||
const { data: loopHealth, isLoading: loopLoading } = useQuery({
|
||||
queryKey: ['loop-health'],
|
||||
queryFn: () => api.get('/tasks/loop-health'),
|
||||
})
|
||||
|
||||
const pageLoading = odLoading || dailyLoading || riskLoading || priorityLoading || loopLoading
|
||||
|
||||
const od = (overview as any)?.data
|
||||
const riskRows = (riskData as any)?.data || []
|
||||
const priorityRows = (priorityData as any)?.data || []
|
||||
const dailyRows = (daily as any)?.data || []
|
||||
const lh = (loopHealth as any)?.data
|
||||
|
||||
const riskSummary = riskRows.reduce((acc: any, r: any) => {
|
||||
acc[r.risk_level] = (acc[r.risk_level] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const totalStores = riskRows.length
|
||||
const riskPieData = Object.entries(riskSummary).map(([name, value]) => ({
|
||||
name,
|
||||
value: value as number,
|
||||
pct: totalStores > 0 ? Math.round((value as number / totalStores) * 100) : 0,
|
||||
}))
|
||||
|
||||
const prioritySummary = priorityRows.reduce((acc: any, r: any) => {
|
||||
const key = r.action_priority
|
||||
acc[key] = (acc[key] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const p0p1Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0') || s.action_priority?.startsWith('P1'))
|
||||
const pagedP0P1 = useMemo(() => p0p1Stores.slice((priorityPage - 1) * PAGE_SIZE, priorityPage * PAGE_SIZE), [p0p1Stores, priorityPage])
|
||||
|
||||
// 闭环健康度综合得分
|
||||
const loopScore = lh ? (
|
||||
(parseFloat(lh.task_generation_rate) +
|
||||
parseFloat(lh.store_execution_rate) +
|
||||
parseFloat(lh.weekly_check_rate) +
|
||||
parseFloat(lh.monthly_review_rate) +
|
||||
parseFloat(lh.practice_promotion_rate)) / 5
|
||||
) : 0
|
||||
|
||||
// 计算环比趋势:最后7天 vs 前7天
|
||||
const last7 = dailyRows.slice(-7)
|
||||
const prev7 = dailyRows.slice(-14, -7)
|
||||
const sumKey = (arr: any[], key: string) => arr.reduce((s, r) => s + Number(r[key] || 0), 0)
|
||||
const trend = (curr: number, prev: number) => prev > 0 ? Math.round((curr - prev) / prev * 1000) / 10 : 0
|
||||
|
||||
const trendReceived = trend(sumKey(last7, 'received'), sumKey(prev7, 'received'))
|
||||
const trendBills = trend(sumKey(last7, 'bill_count'), sumKey(prev7, 'bill_count'))
|
||||
const trendAvgBill = trend(
|
||||
last7.length > 0 ? sumKey(last7, 'received') / sumKey(last7, 'bill_count') : 0,
|
||||
prev7.length > 0 ? sumKey(prev7, 'received') / sumKey(prev7, 'bill_count') : 0,
|
||||
)
|
||||
|
||||
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">全部门店经营总览 · 2026年4月</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="rounded-md bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">{totalStores} 家门店</span>
|
||||
<span className="rounded-md bg-red-50 px-3 py-1 text-xs font-medium text-red-700">红色 {riskSummary['红色'] || 0}</span>
|
||||
<span className="rounded-md bg-yellow-50 px-3 py-1 text-xs font-medium text-yellow-700">黄色 {riskSummary['黄色'] || 0}</span>
|
||||
<span className="rounded-md bg-green-50 px-3 py-1 text-xs font-medium text-green-700">绿色 {riskSummary['绿色'] || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 经营指标 */}
|
||||
<CollapsibleSection title="经营指标" subtitle="实收、账单数、客单价等核心经营数据">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="实收" value={od?.received} format="currency" trend={trendReceived} description="门店实际收到的金额,扣除优惠后的净收入" />
|
||||
<MetricCard title="账单数" value={od?.bill_count} format="number" trend={trendBills} description="全部门店累计的账单(订单)总数" />
|
||||
<MetricCard title="客单价" value={od?.avg_bill_value} format="currency" trend={trendAvgBill} description="平均每笔账单的实收金额 = 实收 ÷ 账单数" />
|
||||
<MetricCard title="P0+P1门店" value={p0p1Stores.length} format="number" description="需要重点整改的门店数:P0为紧急修复,P1为重点整改" />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 运营指标 */}
|
||||
<CollapsibleSection title="运营指标" subtitle="优惠率、毛利率、会员占比等运营比率">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<MetricCard title="优惠率" value={od?.discount_rate_pct} format="percent" description="优惠总额占消费总额的比例,越高让利越大" />
|
||||
<MetricCard title="理论毛利率" value={od?.theoretical_margin_pct} format="percent" description="理论利润占实收的比例(按标准成本计算),反映定价与成本的关系" />
|
||||
<MetricCard title="会员占比" value={od?.member_share_pct} format="percent" description="会员消费金额占总实收的比例,反映会员运营效果" />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 闭环健康度 */}
|
||||
{lh && (
|
||||
<CollapsibleSection
|
||||
title="闭环健康度"
|
||||
subtitle="任务生成→店长执行→区域检查→月度验收→经验推广"
|
||||
headerRight={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">综合得分</span>
|
||||
<span className={`text-lg font-bold ${loopScore >= 80 ? 'text-green-600' : loopScore >= 60 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{loopScore.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||
{[
|
||||
{ label: '任务生成率', value: lh.task_generation_rate, desc: '系统自动生成的整改任务占应生成任务的比例' },
|
||||
{ label: '店长执行率', value: lh.store_execution_rate, desc: '店长已提交反馈的任务占分配任务的比例' },
|
||||
{ label: '区域周检率', value: lh.weekly_check_rate, desc: '区域经理完成周度检查的比例' },
|
||||
{ label: '月度验收率', value: lh.monthly_review_rate, desc: '月度完成验收的任务比例' },
|
||||
{ label: '经验推广率', value: lh.practice_promotion_rate, desc: '达标经验被推广到其他门店的比例' },
|
||||
].map((item) => {
|
||||
const num = parseFloat(item.value)
|
||||
const isLow = num < 60
|
||||
const isMid = num >= 60 && num < 80
|
||||
return (
|
||||
<div key={item.label} className={`rounded-md border p-3 ${isLow ? 'border-red-200 bg-red-50/50' : isMid ? 'border-yellow-200 bg-yellow-50/50' : 'border-green-200 bg-green-50/50'}`} title={item.desc}>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||
<span className="flex h-3.5 w-3.5 cursor-help items-center justify-center rounded-full bg-muted text-[9px] text-muted-foreground" title={item.desc}>?</span>
|
||||
</div>
|
||||
<p className={`mt-1 text-xl font-bold ${isLow ? 'text-red-600' : isMid ? 'text-yellow-600' : 'text-green-600'}`}>{num.toFixed(1)}%</p>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full ${isLow ? 'bg-red-500' : isMid ? 'bg-yellow-500' : 'bg-green-500'}`}
|
||||
style={{ width: `${Math.min(num, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* 图表区 */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<CollapsibleSection title="日度实收趋势" subtitle="近30天实收变化">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={dailyRows}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="business_date" tickFormatter={(v) => v?.substring(5, 10)} tick={{ fontSize: 10 }} />
|
||||
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<Tooltip
|
||||
labelFormatter={(v) => v?.substring(0, 10)}
|
||||
formatter={(v: any) => formatCurrency(v)}
|
||||
/>
|
||||
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="实收" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="门店风险分级分布" subtitle="按红/黄/绿分级统计">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={riskPieData}
|
||||
cx="50%" cy="50%" outerRadius={80}
|
||||
dataKey="value"
|
||||
label={({ name, value, pct }: any) => `${name} ${value}家 (${pct}%)`}
|
||||
>
|
||||
{riskPieData.map((entry) => (
|
||||
<Cell key={entry.name} fill={RISK_COLORS[entry.name as keyof typeof RISK_COLORS] || '#gray'} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* P0/P1 门店列表 */}
|
||||
<CollapsibleSection
|
||||
title={`P0/P1 重点整改门店 (${p0p1Stores.length})`}
|
||||
subtitle="需紧急修复和重点整改的门店清单"
|
||||
headerRight={
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(prioritySummary).filter(([k]) => k?.startsWith('P0') || k?.startsWith('P1')).map(([k, v]) => (
|
||||
<span key={k} className="text-xs text-muted-foreground">{k}: {v as number}家</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Pagination page={priorityPage} pageSize={PAGE_SIZE} total={p0p1Stores.length} onPageChange={setPriorityPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'action_priority', label: '优先级', render: (r) => <Badge type="priority" text={r.action_priority} /> },
|
||||
{ key: 'problem_count', label: '问题数', align: 'center' },
|
||||
{ key: 'problem_combination', label: '问题组合' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
]}
|
||||
data={pagedP0P1}
|
||||
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
|
||||
export function DataQualityPage() {
|
||||
const { data: qualityData, isLoading } = useQuery({
|
||||
queryKey: ['data-quality'],
|
||||
queryFn: () => api.get('/data-quality'),
|
||||
})
|
||||
|
||||
const dq = (qualityData as any)?.data || {}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner text="加载数据质量检查..." />
|
||||
}
|
||||
|
||||
const totalBills = Number(dq.total_bills || 0)
|
||||
const missingBillNo = Number(dq.missing_bill_no || 0)
|
||||
const missingStoreCode = Number(dq.missing_store_code || 0)
|
||||
const zeroConsumption = Number(dq.zero_consumption || 0)
|
||||
const negativeReceived = Number(dq.negative_received || 0)
|
||||
const storeCount = Number(dq.store_count || 0)
|
||||
const totalDishRecords = Number(dq.total_dish_records || 0)
|
||||
const dishMissingStore = Number(dq.dish_missing_store || 0)
|
||||
const dishMissingDish = Number(dq.dish_missing_dish || 0)
|
||||
|
||||
const billQualityStatus = missingBillNo === 0 && missingStoreCode === 0 ? '正常' : '异常'
|
||||
const dishQualityStatus = dishMissingStore === 0 && dishMissingDish === 0 ? '正常' : '异常'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">数据质量看板</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">账单与菜品明细数据质量检查 · {dq.min_date?.substring(0, 10)} ~ {dq.max_date?.substring(0, 10)}</p>
|
||||
</div>
|
||||
|
||||
{/* 账单数据质量 */}
|
||||
<CollapsibleSection title="账单数据质量" subtitle="原始账单表完整性检查">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="总账单数" value={totalBills} format="number" description="账单事实表总记录数" />
|
||||
<MetricCard title="覆盖门店数" value={storeCount} format="number" description="有交易记录的门店数量" />
|
||||
<MetricCard title="缺失账单号" value={missingBillNo} format="number" description="账单号为空的记录数,应为0" />
|
||||
<MetricCard title="缺失门店编码" value={missingStoreCode} format="number" description="门店编码为空的记录数,应为0" />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="零消费账单" value={zeroConsumption} format="number" description="消费额为0的账单数" />
|
||||
<MetricCard title="负实收账单" value={negativeReceived} format="number" description="实收为负数的账单数" />
|
||||
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<p className="text-xs text-muted-foreground">账单质量状态</p>
|
||||
<p className={`mt-1 text-lg font-bold ${billQualityStatus === '正常' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{billQualityStatus}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{missingBillNo > 0 && (
|
||||
<div className="mt-3 rounded-md border border-yellow-200 bg-yellow-50/50 p-3">
|
||||
<p className="text-xs text-yellow-700">
|
||||
<span className="font-medium">提示:</span>
|
||||
{missingBillNo} 条账单缺失账单号,可能导致重复统计或关联失败,需排查源文件
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{missingStoreCode > 0 && (
|
||||
<div className="mt-3 rounded-md border border-red-200 bg-red-50/50 p-3">
|
||||
<p className="text-xs text-red-700">
|
||||
<span className="font-medium">提示:</span>
|
||||
{missingStoreCode} 条账单缺失门店编码,无法归属门店,需修复映射
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 菜品明细数据质量 */}
|
||||
<CollapsibleSection title="菜品明细数据质量" subtitle="菜品销售明细表完整性检查">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="总菜品记录数" value={totalDishRecords} format="number" description="菜品销售明细表总记录数" />
|
||||
<MetricCard title="缺失门店" value={dishMissingStore} format="number" description="门店编码为空的记录数" />
|
||||
<MetricCard title="缺失菜品名" value={dishMissingDish} format="number" description="菜品名称为空的记录数" />
|
||||
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<p className="text-xs text-muted-foreground">菜品质量状态</p>
|
||||
<p className={`mt-1 text-lg font-bold ${dishQualityStatus === '正常' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{dishQualityStatus}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{dishMissingStore > 0 && (
|
||||
<div className="mt-3 rounded-md border border-red-200 bg-red-50/50 p-3">
|
||||
<p className="text-xs text-red-700">
|
||||
<span className="font-medium">提示:</span>
|
||||
{dishMissingStore} 条菜品明细缺失门店编码,无法进行门店级SKU分析
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 数据治理建议 */}
|
||||
<CollapsibleSection title="数据治理建议" subtitle="基于管理体系的数据质量要求" defaultOpen={false}>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50/50 p-3">
|
||||
<p className="font-medium text-blue-700">统一编码</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">门店、SKU、原料、会员、员工、渠道、活动必须使用稳定编码。名称只能用于展示,不能作为唯一关联键</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50/50 p-3">
|
||||
<p className="font-medium text-blue-700">每次导入自动检查</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">文件齐全性、日期完整性、行数金额对账、映射失败数量、重复账单、空值检查、异常金额、账单与明细对账、销售与成本单位匹配</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50/50 p-3">
|
||||
<p className="font-medium text-blue-700">口径变更管理</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">口径变更必须保留版本和生效日期,不能直接覆盖历史结果。月报出具后如需更正,应记录更正原因和影响金额</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export function IndicatorsPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const { data } = useQuery({
|
||||
queryKey: ['indicators'],
|
||||
queryFn: () => api.get('/tasks/indicators'),
|
||||
})
|
||||
|
||||
const indicators = (data as any)?.data || []
|
||||
const pagedIndicators = useMemo(() => indicators.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [indicators, page])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-bold">指标字典</h1>
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={indicators.length} onPageChange={setPage} />
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'indicator_name', label: '指标名称' },
|
||||
{ key: 'business_definition', label: '业务定义', render: (r) => <span className="text-xs">{r.business_definition}</span> },
|
||||
{ key: 'formula', label: '计算公式', render: (r) => <code className="text-xs">{r.formula}</code> },
|
||||
{ key: 'update_frequency', label: '更新频率', align: 'center' },
|
||||
{ key: 'owner', label: '负责人' },
|
||||
{ key: 'yellow_threshold', label: '黄线', align: 'right' },
|
||||
{ key: 'red_threshold', label: '红线', align: 'right' },
|
||||
{ key: 'version_date', label: '版本日期', render: (r) => r.version_date?.substring(0, 10) },
|
||||
]}
|
||||
data={pagedIndicators}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '@/lib/api'
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin?: (user: any) => void
|
||||
}
|
||||
|
||||
export function LoginPage({ onLogin }: LoginPageProps) {
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('总部管理员')
|
||||
const [password, setPassword] = useState('123')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
const res: any = await api.post('/auth/login', { username, password })
|
||||
localStorage.setItem('token', res.data.token)
|
||||
localStorage.setItem('user', JSON.stringify(res.data.user))
|
||||
onLogin?.(res.data.user)
|
||||
navigate('/')
|
||||
} catch {
|
||||
setError('登录失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30">
|
||||
<div className="w-80 rounded-lg border bg-card p-6 shadow-lg">
|
||||
<h1 className="mb-6 text-center text-xl font-bold">马兰拉面数字化运营平台</h1>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground">用户名</label>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
className="mt-1 w-full rounded-md border px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
可选用户:总部管理员 / 区域经理 / 潘家园店长 / 商品部
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
|
||||
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
export function MemberPage() {
|
||||
const [repeatPage, setRepeatPage] = useState(1)
|
||||
|
||||
const { data: comparisonData, isLoading: cmpLoading } = useQuery({
|
||||
queryKey: ['member/comparison'],
|
||||
queryFn: () => api.get('/member/comparison'),
|
||||
})
|
||||
|
||||
const { data: repeatData, isLoading: repeatLoading } = useQuery({
|
||||
queryKey: ['member/repeat'],
|
||||
queryFn: () => api.get('/member/repeat'),
|
||||
})
|
||||
|
||||
const cmpRows = (comparisonData as any)?.data || []
|
||||
const repeatRows = (repeatData as any)?.data || []
|
||||
|
||||
const pageLoading = cmpLoading || repeatLoading
|
||||
|
||||
const memberRow = cmpRows.find((r: any) => r.customer_type === '会员') || {}
|
||||
const nonMemberRow = cmpRows.find((r: any) => r.customer_type === '非会员') || {}
|
||||
|
||||
const totalBills = Number(memberRow.bill_count || 0) + Number(nonMemberRow.bill_count || 0)
|
||||
const memberBillShare = totalBills > 0 ? (Number(memberRow.bill_count || 0) / totalBills * 100) : 0
|
||||
const totalReceived = Number(memberRow.received || 0) + Number(nonMemberRow.received || 0)
|
||||
const memberRevenueShare = totalReceived > 0 ? (Number(memberRow.received || 0) / totalReceived * 100) : 0
|
||||
|
||||
const avgRepeatRate = repeatRows.length > 0
|
||||
? repeatRows.reduce((s: number, r: any) => s + Number(r.repeat_rate_pct || 0), 0) / repeatRows.length
|
||||
: 0
|
||||
const topRepeatStore = repeatRows.length > 0
|
||||
? repeatRows.reduce((best: any, r: any) => Number(r.repeat_rate_pct || 0) > Number(best?.repeat_rate_pct || 0) ? r : best, null)
|
||||
: null
|
||||
const lowRepeatCount = repeatRows.filter((r: any) => Number(r.repeat_rate_pct || 0) < 30).length
|
||||
|
||||
const cmpChartData = cmpRows.map((r: any) => ({
|
||||
name: r.customer_type,
|
||||
账单数: Number(r.bill_count || 0),
|
||||
实收: Number(r.received || 0),
|
||||
客单价: Number(r.avg_bill_value || 0),
|
||||
}))
|
||||
|
||||
const pieData = [
|
||||
{ name: '会员账单', value: Number(memberRow.bill_count || 0) },
|
||||
{ name: '非会员账单', value: Number(nonMemberRow.bill_count || 0) },
|
||||
]
|
||||
const PIE_COLORS = ['#3b82f6', '#e5e7eb']
|
||||
|
||||
const pagedRepeat = useMemo(() => repeatRows.slice((repeatPage - 1) * PAGE_SIZE, repeatPage * PAGE_SIZE), [repeatRows, repeatPage])
|
||||
|
||||
if (pageLoading) {
|
||||
return <LoadingSpinner text="加载会员数据..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">会员与复购管理</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">会员对比 · 复购率 · 沉睡分析 · 2026年4月</p>
|
||||
</div>
|
||||
|
||||
{/* 概览指标 */}
|
||||
<CollapsibleSection title="会员概览" subtitle="会员渗透与消费对比">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="会员账单占比" value={memberBillShare} format="percent" description="会员账单数占总账单数的比例" />
|
||||
<MetricCard title="会员收入占比" value={memberRevenueShare} format="percent" description="会员实收金额占总实收的比例" />
|
||||
<MetricCard title="平均复购率" value={avgRepeatRate} format="percent" description="所有门店30日复购率的平均值" />
|
||||
<MetricCard title="低复购门店(<30%)" value={lowRepeatCount} format="number" description="复购率低于30%的门店数,需重点改善" />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 会员对比图 */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<CollapsibleSection title="会员 vs 非会员消费对比" subtitle="账单数、实收、客单价对比">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={cmpChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} />
|
||||
<Tooltip formatter={(v: any, name: any) => name === '实收' ? formatCurrency(v) : name === '客单价' ? formatCurrency(v) : formatNumber(v)} />
|
||||
<Legend />
|
||||
<Bar yAxisId="left" dataKey="账单数" fill="#3b82f6" />
|
||||
<Bar yAxisId="left" dataKey="实收" fill="#22c55e" />
|
||||
<Bar yAxisId="right" dataKey="客单价" fill="#eab308" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="会员账单占比" subtitle="会员与非会员账单分布">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
|
||||
label={({ name, value, }: any) => `${name}: ${formatNumber(value)}`}>
|
||||
{pieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i]} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any) => formatNumber(v)} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* 会员对比明细 */}
|
||||
<CollapsibleSection title="会员与非会员指标对比" subtitle="优惠率、毛利率等关键指标">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'customer_type', label: '客户类型' },
|
||||
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
|
||||
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => formatPercent(r.discount_rate_pct) },
|
||||
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
|
||||
]}
|
||||
data={cmpRows}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 门店复购率 */}
|
||||
<CollapsibleSection title={`门店复购率 (${repeatRows.length})`} subtitle="按复购率降序,低于30%需重点改善">
|
||||
<Pagination page={repeatPage} pageSize={PAGE_SIZE} total={repeatRows.length} onPageChange={setRepeatPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'identified_members', label: '识别会员数', align: 'right', render: (r) => formatNumber(r.identified_members) },
|
||||
{ key: 'repeat_members', label: '复购会员数', align: 'right', render: (r) => formatNumber(r.repeat_members) },
|
||||
{ key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => {
|
||||
const v = Number(r.repeat_rate_pct || 0)
|
||||
return <span className={v >= 50 ? 'font-medium text-green-600' : v >= 30 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}%</span>
|
||||
}},
|
||||
{ key: 'avg_orders', label: '人均消费次数', align: 'right', render: (r) => Number(r.avg_orders || 0).toFixed(2) },
|
||||
{ key: 'repeat_revenue_share_pct', label: '复购收入占比', align: 'right', render: (r) => `${Number(r.repeat_revenue_share_pct || 0).toFixed(1)}%` },
|
||||
]}
|
||||
data={pagedRepeat}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Badge } from '@/components/Badge'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
const REVIEW_COLORS = { '达标': '#22c55e', '改善中': '#eab308', '未改善': '#ef4444' }
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export function MonthlyReviewPage() {
|
||||
const [month, setMonth] = useState('2026-05')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['monthly-review', month],
|
||||
queryFn: () => api.get('/tasks/monthly-review', { params: { month } }),
|
||||
})
|
||||
|
||||
const rd = (data as any)?.data
|
||||
const summary = rd?.summary || { total: 0, passed: 0, improving: 0, failed: 0 }
|
||||
const details = rd?.details || []
|
||||
const pagedDetails = useMemo(() => details.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [details, page])
|
||||
|
||||
const pieData = [
|
||||
{ name: '达标', value: summary.passed },
|
||||
{ name: '改善中', value: summary.improving },
|
||||
{ name: '未改善', value: summary.failed },
|
||||
].filter((d) => d.value > 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">月度验收</h1>
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
className="rounded-md border px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 汇总卡片 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="总任务数" value={summary.total} />
|
||||
<MetricCard title="达标" value={summary.passed} />
|
||||
<MetricCard title="改善中" value={summary.improving} />
|
||||
<MetricCard title="未改善" value={summary.failed} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* 饼图 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-3 text-sm font-bold">验收结果分布</h2>
|
||||
{pieData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
|
||||
label={({ name, value }: any) => `${name}: ${value}`}>
|
||||
{pieData.map((d) => <Cell key={d.name} fill={REVIEW_COLORS[d.name as keyof typeof REVIEW_COLORS]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : <div className="py-20 text-center text-muted-foreground">暂无验收数据</div>}
|
||||
</div>
|
||||
|
||||
{/* 经验标准化 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-3 text-sm font-bold">经验标准化</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">达标门店的有效经验可标准化推广</p>
|
||||
<PracticeSection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 明细表 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-3 text-sm font-bold">验收明细</h2>
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={details.length} onPageChange={setPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'problem_indicator', label: '问题指标' },
|
||||
{ key: 'baseline_value', label: '基线', align: 'right' },
|
||||
{ key: 'target_value', label: '目标', align: 'right' },
|
||||
{ key: 'actual_value', label: '实际', align: 'right' },
|
||||
{ key: 'review_result', label: '结果', render: (r) => <Badge type="review" text={r.review_result} /> },
|
||||
]}
|
||||
data={pagedDetails}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PracticeSection() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['practices'],
|
||||
queryFn: () => api.get('/tasks/practices'),
|
||||
})
|
||||
|
||||
const practices = (data as any)?.data || []
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{practices.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无标准化经验</p>
|
||||
) : practices.map((p: any) => (
|
||||
<div key={p.id} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{p.practice_module}</span>
|
||||
<Badge text={p.status} />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">标杆:{p.benchmark_store_name}</p>
|
||||
<p className="mt-1 text-xs">{p.key_actions}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { useState } from 'react'
|
||||
import { Database, Table, Layers, Settings, ChevronRight, Box, ArrowRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type TabType = 'overview' | 'dimensions' | 'facts' | 'enums' | 'metrics'
|
||||
|
||||
const TABLE_LABELS: Record<string, string> = {
|
||||
dim_store: '门店主数据',
|
||||
dim_sku: 'SKU主数据',
|
||||
dim_material: '原料主数据',
|
||||
dim_member: '会员主数据',
|
||||
dim_employee: '员工主数据',
|
||||
dim_channel: '渠道主数据',
|
||||
dim_promotion: '活动主数据',
|
||||
dim_calendar: '日历主数据',
|
||||
dim_supplier: '供应商主数据',
|
||||
fact_bill: '账单事实表',
|
||||
fact_bill_item: '账单明细事实表',
|
||||
fact_payment: '支付事实表',
|
||||
fact_promotion_usage: '优惠使用事实表',
|
||||
fact_platform_order: '平台订单损益表',
|
||||
fact_inventory_snapshot: '库存快照事实表',
|
||||
fact_purchase_receipt: '采购收货事实表',
|
||||
fact_transfer: '调拨事实表',
|
||||
fact_waste: '报损事实表',
|
||||
fact_recipe_bom: 'BOM配方事实表',
|
||||
fact_employee_shift: '员工排班事实表',
|
||||
fact_customer_contact: '会员触达事实表',
|
||||
fact_store_task: '门店任务事实表',
|
||||
enum_risk_level: '风险等级',
|
||||
enum_priority: '任务优先级',
|
||||
enum_task_status: '任务状态',
|
||||
enum_verification_result: '验收结果',
|
||||
enum_sku_type: 'SKU类型',
|
||||
enum_sku_status: 'SKU状态',
|
||||
enum_business_type: '业态类型',
|
||||
enum_channel_group: '渠道分组',
|
||||
enum_metric_direction: '指标方向',
|
||||
enum_member_status: '会员状态',
|
||||
enum_waste_reason: '报损原因',
|
||||
enum_management_quadrant: '管理象限',
|
||||
}
|
||||
|
||||
const labelOf = (name: string) => TABLE_LABELS[name] || name
|
||||
|
||||
export function OntologyPage() {
|
||||
const [tab, setTab] = useState<TabType>('overview')
|
||||
const [selectedTable, setSelectedTable] = useState<string | null>(null)
|
||||
const [metricFilter, setMetricFilter] = useState('全部')
|
||||
|
||||
const { data: overview, isLoading: overviewLoading } = useQuery({
|
||||
queryKey: ['ontology/overview'],
|
||||
queryFn: () => api.get('/tasks/ontology/overview'),
|
||||
enabled: tab === 'overview',
|
||||
})
|
||||
|
||||
const { data: dimData, isLoading: dimLoading } = useQuery({
|
||||
queryKey: ['ontology/dim', selectedTable],
|
||||
queryFn: () => api.get(`/tasks/ontology/dim/${selectedTable}`),
|
||||
enabled: tab === 'dimensions' && !!selectedTable,
|
||||
})
|
||||
|
||||
const { data: factData, isLoading: factLoading } = useQuery({
|
||||
queryKey: ['ontology/fact', selectedTable],
|
||||
queryFn: () => api.get(`/tasks/ontology/fact/${selectedTable}`),
|
||||
enabled: tab === 'facts' && !!selectedTable,
|
||||
})
|
||||
|
||||
const { data: enumData, isLoading: enumLoading } = useQuery({
|
||||
queryKey: ['ontology/enum', selectedTable],
|
||||
queryFn: () => api.get(`/tasks/ontology/enum/${selectedTable}`),
|
||||
enabled: tab === 'enums' && !!selectedTable,
|
||||
})
|
||||
|
||||
const { data: metricsData, isLoading: metricsLoading } = useQuery({
|
||||
queryKey: ['ontology/metrics'],
|
||||
queryFn: () => api.get('/tasks/ontology/metrics'),
|
||||
enabled: tab === 'metrics',
|
||||
})
|
||||
|
||||
const ov = (overview as any)?.data
|
||||
const dim = (dimData as any)?.data
|
||||
const fact = (factData as any)?.data
|
||||
const enumRows = (enumData as any)?.data || []
|
||||
const metrics = (metricsData as any)?.data || []
|
||||
|
||||
if (tab === 'overview' && overviewLoading) return <LoadingSpinner text="加载本体标准概览..." />
|
||||
if (tab === 'dimensions' && dimLoading) return <LoadingSpinner text="加载维度主数据..." />
|
||||
if (tab === 'facts' && factLoading) return <LoadingSpinner text="加载事实表..." />
|
||||
if (tab === 'enums' && enumLoading) return <LoadingSpinner text="加载枚举标准..." />
|
||||
if (tab === 'metrics' && metricsLoading) return <LoadingSpinner text="加载指标标准..." />
|
||||
|
||||
const tabs: { key: TabType; label: string; icon: any }[] = [
|
||||
{ key: 'overview', label: '概览', icon: Database },
|
||||
{ key: 'dimensions', label: '维度主数据', icon: Box },
|
||||
{ key: 'facts', label: '事实表', icon: Table },
|
||||
{ key: 'enums', label: '枚举标准', icon: Layers },
|
||||
{ key: 'metrics', label: '指标标准', icon: Settings },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">本体标准</h1>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map(t => {
|
||||
const Icon = t.icon
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => { setTab(t.key); setSelectedTable(null) }}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors',
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Overview */}
|
||||
{tab === 'overview' && ov && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<StatCard title="维度主数据表" value={ov.dimensions?.length || 0} unit="张" icon={Box} color="text-blue-500" />
|
||||
<StatCard title="事实表" value={ov.facts?.length || 0} unit="张" icon={Table} color="text-green-500" />
|
||||
<StatCard title="枚举标准表" value={ov.enums?.length || 0} unit="张" icon={Layers} color="text-purple-500" />
|
||||
<StatCard title="指标标准" value={ov.metrics?.total || 0} unit={`条 (已标准化 ${ov.metrics?.standardized || 0})`} icon={Settings} color="text-orange-500" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<TableListCard title="维度主数据" tables={ov.dimensions} icon={Box} onOpen={(name) => { setTab('dimensions'); setSelectedTable(name) }} />
|
||||
<TableListCard title="事实表" tables={ov.facts} icon={Table} onOpen={(name) => { setTab('facts'); setSelectedTable(name) }} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<TableListCard title="枚举标准" tables={ov.enums} icon={Layers} onOpen={(name) => { setTab('enums'); setSelectedTable(name) }} />
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-bold"><Settings size={16} /> 指标标准概览</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">总指标数</span>
|
||||
<span className="font-bold">{ov.metrics?.total || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">已标准化</span>
|
||||
<span className="font-bold text-green-600">{ov.metrics?.standardized || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">待标准化</span>
|
||||
<span className="font-bold text-orange-600">{(ov.metrics?.total || 0) - (ov.metrics?.standardized || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">已启用</span>
|
||||
<span className="font-bold text-blue-600">{ov.metrics?.active || 0}</span>
|
||||
</div>
|
||||
<button onClick={() => setTab('metrics')} className="mt-2 flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
查看全部指标 <ChevronRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dimensions */}
|
||||
{tab === 'dimensions' && (
|
||||
<div className="space-y-4">
|
||||
{!selectedTable ? (
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{ov?.dimensions?.map((t: any) => (
|
||||
<TableCard key={t.table_name} name={t.table_name} label={labelOf(t.table_name)} columnCount={t.column_count} rowCount={t.row_count} onClick={() => setSelectedTable(t.table_name)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<button onClick={() => setSelectedTable(null)} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ChevronRight size={14} className="rotate-180" /> 返回列表
|
||||
</button>
|
||||
<h2 className="text-lg font-bold">{labelOf(selectedTable)} <code className="text-sm text-muted-foreground">{selectedTable}</code></h2>
|
||||
<div className="rounded-lg border">
|
||||
<DataTable
|
||||
columns={dim?.columns?.map((c: any) => ({
|
||||
key: c.column_name,
|
||||
label: c.column_name,
|
||||
render: (r: any) => {
|
||||
const val = r[c.column_name]
|
||||
if (val === null || val === undefined) return <span className="text-muted-foreground">—</span>
|
||||
if (typeof val === 'object') return <code className="text-xs">{JSON.stringify(val)}</code>
|
||||
if (c.data_type === 'timestamp with time zone' || c.data_type === 'timestamp without time zone') return <span className="text-xs">{String(val).substring(0, 19)}</span>
|
||||
return <span className="text-xs">{String(val)}</span>
|
||||
}
|
||||
})) || []}
|
||||
data={dim?.rows || []}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">字段定义:</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-left">
|
||||
<th className="pb-2 pr-4 font-medium">字段名</th>
|
||||
<th className="pb-2 pr-4 font-medium">类型</th>
|
||||
<th className="pb-2 pr-4 font-medium">可空</th>
|
||||
<th className="pb-2 font-medium">默认值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dim?.columns?.map((c: any) => (
|
||||
<tr key={c.column_name} className="border-b last:border-0">
|
||||
<td className="py-1.5 pr-4 font-mono">{c.column_name}</td>
|
||||
<td className="py-1.5 pr-4 text-muted-foreground">{c.data_type}</td>
|
||||
<td className="py-1.5 pr-4">{c.is_nullable === 'YES' ? '✓' : <span className="text-red-500">✗</span>}</td>
|
||||
<td className="py-1.5 text-muted-foreground">{c.column_default || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Facts */}
|
||||
{tab === 'facts' && (
|
||||
<div className="space-y-4">
|
||||
{!selectedTable ? (
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{ov?.facts?.map((t: any) => (
|
||||
<TableCard key={t.table_name} name={t.table_name} label={labelOf(t.table_name)} columnCount={t.column_count} rowCount={t.row_count} onClick={() => setSelectedTable(t.table_name)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<button onClick={() => setSelectedTable(null)} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ChevronRight size={14} className="rotate-180" /> 返回列表
|
||||
</button>
|
||||
<h2 className="text-lg font-bold">{labelOf(selectedTable)} <code className="text-sm text-muted-foreground">{selectedTable}</code></h2>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">字段数: <span className="font-bold text-foreground">{fact?.columns?.length || 0}</span></span>
|
||||
<span className="text-muted-foreground">数据量: <span className="font-bold text-foreground">{Number(fact?.total || 0).toLocaleString()}</span> 行</span>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-left">
|
||||
<th className="pb-2 pr-4 font-medium">字段名</th>
|
||||
<th className="pb-2 pr-4 font-medium">类型</th>
|
||||
<th className="pb-2 pr-4 font-medium">可空</th>
|
||||
<th className="pb-2 font-medium">默认值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fact?.columns?.map((c: any) => (
|
||||
<tr key={c.column_name} className="border-b last:border-0">
|
||||
<td className="py-1.5 pr-4 font-mono">{c.column_name}</td>
|
||||
<td className="py-1.5 pr-4 text-muted-foreground">{c.data_type}</td>
|
||||
<td className="py-1.5 pr-4">{c.is_nullable === 'YES' ? '✓' : <span className="text-red-500">✗</span>}</td>
|
||||
<td className="py-1.5 text-muted-foreground">{c.column_default || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enums */}
|
||||
{tab === 'enums' && (
|
||||
<div className="space-y-4">
|
||||
{!selectedTable ? (
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{ov?.enums?.map((t: any) => (
|
||||
<TableCard key={t.table_name} name={t.table_name} label={labelOf(t.table_name)} columnCount={t.column_count} rowCount={t.row_count} onClick={() => setSelectedTable(t.table_name)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<button onClick={() => setSelectedTable(null)} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ChevronRight size={14} className="rotate-180" /> 返回列表
|
||||
</button>
|
||||
<h2 className="text-lg font-bold">{labelOf(selectedTable)} <code className="text-sm text-muted-foreground">{selectedTable}</code></h2>
|
||||
<div className="rounded-lg border">
|
||||
<DataTable
|
||||
columns={Object.keys(enumRows[0] || {}).map(k => ({ key: k, label: k }))}
|
||||
data={enumRows}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metrics */}
|
||||
{tab === 'metrics' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{['全部', '收入', '成本', '优惠', '会员', '商品', '库存', '风险'].map(cat => (
|
||||
<button key={cat} onClick={() => setMetricFilter(cat)} className={cn(
|
||||
'rounded-md border px-3 py-1 text-xs font-medium transition-colors',
|
||||
metricFilter === cat ? 'bg-primary text-primary-foreground border-primary' : 'hover:bg-muted'
|
||||
)}>{cat}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-lg border">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'metric_code', label: '指标编码', render: (r: any) => <code className="text-xs text-blue-600">{r.metric_code || '—'}</code> },
|
||||
{ key: 'indicator_name', label: '指标名称', render: (r: any) => <span className="font-medium">{r.indicator_name}</span> },
|
||||
{ key: 'metric_category', label: '分类', render: (r: any) => r.metric_category ? <span className="rounded bg-muted px-2 py-0.5 text-xs">{r.metric_category}</span> : <span className="text-muted-foreground">—</span> },
|
||||
{ key: 'business_definition', label: '业务定义', render: (r: any) => <span className="text-xs">{r.business_definition}</span> },
|
||||
{ key: 'formula', label: '计算公式', render: (r: any) => <code className="text-xs">{r.formula}</code> },
|
||||
{ key: 'unit', label: '单位', align: 'center' as const },
|
||||
{ key: 'direction', label: '方向', render: (r: any) => r.direction === 'higher_better' ? <span className="text-green-600">↑越高越好</span> : r.direction === 'lower_better' ? <span className="text-red-600">↓越低越好</span> : '—' },
|
||||
{ key: 'owner', label: '负责人', align: 'center' as const },
|
||||
{ key: 'yellow_threshold', label: '黄线', align: 'right' as const, render: (r: any) => r.yellow_threshold ?? '—' },
|
||||
{ key: 'red_threshold', label: '红线', align: 'right' as const, render: (r: any) => r.red_threshold ?? '—' },
|
||||
{ key: 'update_frequency', label: '频率', align: 'center' as const },
|
||||
{ key: 'version', label: '版本', render: (r: any) => <span className="text-xs text-muted-foreground">{r.version || '—'}</span> },
|
||||
]}
|
||||
data={metricFilter === '全部' ? metrics : metrics.filter((m: any) => m.metric_category === metricFilter)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ title, value, unit, icon: Icon, color }: { title: string; value: number; unit: string; icon: any; color: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">{title}</p>
|
||||
<Icon size={16} className={color} />
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">{value}</p>
|
||||
<p className="text-xs text-muted-foreground">{unit}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableListCard({ title, tables, icon: Icon, onOpen }: { title: string; tables: any[]; icon: any; onOpen: (name: string) => void }) {
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-bold"><Icon size={16} /> {title}</h3>
|
||||
<div className="space-y-1">
|
||||
{tables?.map((t) => (
|
||||
<button key={t.table_name} onClick={() => onOpen(t.table_name)} className="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm hover:bg-muted transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs text-muted-foreground">{t.table_name}</code>
|
||||
<span className="text-xs">{labelOf(t.table_name)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{t.column_count}列</span>
|
||||
<span>{Number(t.row_count).toLocaleString()}行</span>
|
||||
<ArrowRight size={12} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCard({ name, label, columnCount, rowCount, onClick }: { name: string; label: string; columnCount: number; rowCount: number; onClick: () => void }) {
|
||||
return (
|
||||
<button onClick={onClick} className="rounded-lg border p-4 text-left hover:border-primary hover:shadow-sm transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{label}</span>
|
||||
<ArrowRight size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
<code className="mt-1 block text-xs text-muted-foreground">{name}</code>
|
||||
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
|
||||
<span>{columnCount} 字段</span>
|
||||
<span>{Number(rowCount).toLocaleString()} 行</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
export function PlatformPage() {
|
||||
const [platformPage, setPlatformPage] = useState(1)
|
||||
const [marketingPage, setMarketingPage] = useState(1)
|
||||
|
||||
const { data: platformData, isLoading: platformLoading } = useQuery({
|
||||
queryKey: ['platform/economics'],
|
||||
queryFn: () => api.get('/platform/economics'),
|
||||
})
|
||||
|
||||
const { data: marketingData, isLoading: marketingLoading } = useQuery({
|
||||
queryKey: ['marketing/plans'],
|
||||
queryFn: () => api.get('/marketing/plans'),
|
||||
})
|
||||
|
||||
const platformRows = (platformData as any)?.data || []
|
||||
const marketingRows = (marketingData as any)?.data || []
|
||||
|
||||
const pageLoading = platformLoading || marketingLoading
|
||||
|
||||
const storesWithPlatform = platformRows.filter((r: any) => Number(r.meituan_received) > 0 || Number(r.taobao_received) > 0 || Number(r.jd_received) > 0)
|
||||
|
||||
const totalMeituan = storesWithPlatform.reduce((s: number, r: any) => s + Number(r.meituan_received || 0), 0)
|
||||
const totalTaobao = storesWithPlatform.reduce((s: number, r: any) => s + Number(r.taobao_received || 0), 0)
|
||||
const totalJd = storesWithPlatform.reduce((s: number, r: any) => s + Number(r.jd_received || 0), 0)
|
||||
const totalMeituanCost = storesWithPlatform.reduce((s: number, r: any) => s + Number(r.meituan_discount || 0) + Number(r.meituan_commission || 0), 0)
|
||||
const totalTaobaoCost = storesWithPlatform.reduce((s: number, r: any) => s + Number(r.taobao_discount || 0) + Number(r.taobao_commission || 0), 0)
|
||||
const totalJdCost = storesWithPlatform.reduce((s: number, r: any) => s + Number(r.jd_discount || 0) + Number(r.jd_commission || 0), 0)
|
||||
|
||||
const totalPlatformReceived = totalMeituan + totalTaobao + totalJd
|
||||
const totalPlatformCost = totalMeituanCost + totalTaobaoCost + totalJdCost
|
||||
const blendedCostRate = totalPlatformReceived > 0 ? (totalPlatformCost / totalPlatformReceived) * 100 : 0
|
||||
|
||||
const highCostStores = storesWithPlatform.filter((r: any) => {
|
||||
const rates = [Number(r.meituan_cost_rate_pct || 0), Number(r.taobao_cost_rate_pct || 0), Number(r.jd_cost_rate_pct || 0)].filter((v: number) => v > 0)
|
||||
return rates.some((v: number) => v >= 38)
|
||||
})
|
||||
|
||||
const platformChartData = [
|
||||
{ name: '美团', 实收: totalMeituan, 折扣: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.meituan_discount || 0), 0), 佣金: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.meituan_commission || 0), 0) },
|
||||
{ name: '淘宝', 实收: totalTaobao, 折扣: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.taobao_discount || 0), 0), 佣金: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.taobao_commission || 0), 0) },
|
||||
{ name: '京东', 实收: totalJd, 折扣: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.jd_discount || 0), 0), 佣金: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.jd_commission || 0), 0) },
|
||||
]
|
||||
|
||||
const pagedPlatform = useMemo(() => storesWithPlatform.slice((platformPage - 1) * PAGE_SIZE, platformPage * PAGE_SIZE), [storesWithPlatform, platformPage])
|
||||
const pagedMarketing = useMemo(() => marketingRows.slice((marketingPage - 1) * PAGE_SIZE, marketingPage * PAGE_SIZE), [marketingRows, marketingPage])
|
||||
|
||||
if (pageLoading) {
|
||||
return <LoadingSpinner text="加载平台数据..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">平台与优惠管理</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">三平台经济性 · 营销方案ROI · 优惠结构 · 2026年4月</p>
|
||||
</div>
|
||||
|
||||
{/* 概览指标 */}
|
||||
<CollapsibleSection title="平台概览" subtitle="三平台合并加权成本率">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="平台总实收" value={totalPlatformReceived} format="currency" description="美团+淘宝+京东三平台实收合计" />
|
||||
<MetricCard title="合并加权成本率" value={blendedCostRate} format="percent" description="(折扣+佣金)÷实收,越高平台让利负担越大" />
|
||||
<MetricCard title="高成本门店(≥38%)" value={highCostStores.length} format="number" description="任一平台成本率≥38%的门店数" />
|
||||
<MetricCard title="营销方案数" value={marketingRows.length} format="number" description="活跃营销方案数量" />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 三平台对比图 */}
|
||||
<CollapsibleSection title="三平台实收与成本对比" subtitle="实收、折扣、佣金金额对比">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={platformChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<Tooltip formatter={(v: any) => formatCurrency(v)} />
|
||||
<Legend />
|
||||
<Bar dataKey="实收" fill="#3b82f6" />
|
||||
<Bar dataKey="折扣" fill="#eab308" />
|
||||
<Bar dataKey="佣金" fill="#ef4444" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 门店平台经济性 */}
|
||||
<CollapsibleSection title={`门店平台经济性 (${storesWithPlatform.length})`} subtitle="各门店三平台实收与成本率">
|
||||
<Pagination page={platformPage} pageSize={PAGE_SIZE} total={storesWithPlatform.length} onPageChange={setPlatformPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'meituan_received', label: '美团实收', align: 'right', render: (r) => formatCurrency(r.meituan_received) },
|
||||
{ key: 'meituan_cost_rate_pct', label: '美团成本率', align: 'right', render: (r) => {
|
||||
const v = Number(r.meituan_cost_rate_pct || 0)
|
||||
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
|
||||
}},
|
||||
{ key: 'taobao_received', label: '淘宝实收', align: 'right', render: (r) => formatCurrency(r.taobao_received) },
|
||||
{ key: 'taobao_cost_rate_pct', label: '淘宝成本率', align: 'right', render: (r) => {
|
||||
const v = Number(r.taobao_cost_rate_pct || 0)
|
||||
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
|
||||
}},
|
||||
{ key: 'jd_received', label: '京东实收', align: 'right', render: (r) => formatCurrency(r.jd_received) },
|
||||
{ key: 'jd_cost_rate_pct', label: '京东成本率', align: 'right', render: (r) => {
|
||||
const v = Number(r.jd_cost_rate_pct || 0)
|
||||
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
|
||||
}},
|
||||
]}
|
||||
data={pagedPlatform}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 营销方案ROI */}
|
||||
<CollapsibleSection title={`营销方案分析 (${marketingRows.length})`} subtitle="各营销方案的订单、实收、优惠率与毛利率">
|
||||
<Pagination page={marketingPage} pageSize={PAGE_SIZE} total={marketingRows.length} onPageChange={setMarketingPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'marketing_plan', label: '营销方案' },
|
||||
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
|
||||
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => {
|
||||
const v = Number(r.discount_rate_pct || 0)
|
||||
return <span className={v >= 25 ? 'font-medium text-red-600' : v >= 15 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span>
|
||||
}},
|
||||
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
|
||||
]}
|
||||
data={pagedMarketing}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '@/lib/api'
|
||||
import { Badge } from '@/components/Badge'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { formatCurrency, formatPercent, riskColor } from '@/lib/utils'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export function RegionalPage() {
|
||||
const navigate = useNavigate()
|
||||
const [riskFilter, setRiskFilter] = useState('')
|
||||
const [riskPage, setRiskPage] = useState(1)
|
||||
const [checkPage, setCheckPage] = useState(1)
|
||||
|
||||
const { data: riskData, isLoading: riskLoading } = useQuery({
|
||||
queryKey: ['stores/risk'],
|
||||
queryFn: () => api.get('/stores/risk'),
|
||||
})
|
||||
|
||||
const { data: weeklyData, isLoading: weeklyLoading } = useQuery({
|
||||
queryKey: ['tasks/weekly-check'],
|
||||
queryFn: () => api.get('/tasks/weekly-check', { params: { month: '2026-05' } }),
|
||||
})
|
||||
|
||||
const { data: priorityData, isLoading: priorityLoading } = useQuery({
|
||||
queryKey: ['stores/priority'],
|
||||
queryFn: () => api.get('/stores/priority'),
|
||||
})
|
||||
|
||||
const riskRows = ((riskData as any)?.data || []).filter((r: any) => !riskFilter || r.risk_level === riskFilter)
|
||||
const weeklyRows = (weeklyData as any)?.data || []
|
||||
const priorityRows = (priorityData as any)?.data || []
|
||||
|
||||
const p0p1 = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0') || s.action_priority?.startsWith('P1'))
|
||||
const needCheck = weeklyRows.filter((w: any) => parseInt(w.consecutive_no_improve_weeks) >= 2)
|
||||
|
||||
const pagedRiskRows = useMemo(() => riskRows.slice((riskPage - 1) * PAGE_SIZE, riskPage * PAGE_SIZE), [riskRows, riskPage])
|
||||
const pagedNeedCheck = useMemo(() => needCheck.slice((checkPage - 1) * PAGE_SIZE, checkPage * PAGE_SIZE), [needCheck, checkPage])
|
||||
|
||||
const pageLoading = riskLoading || weeklyLoading || priorityLoading
|
||||
|
||||
if (pageLoading) {
|
||||
return <LoadingSpinner text="加载区域经理工作台..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-bold">区域经理工作台</h1>
|
||||
|
||||
{/* 汇总卡片 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="红黄门店数" value={riskRows.filter((r: any) => r.risk_level !== '绿色').length} />
|
||||
<MetricCard title="P0+P1门店" value={p0p1.length} />
|
||||
<MetricCard title="需到店检查" value={needCheck.length} />
|
||||
<MetricCard title="周检待录入" value={weeklyRows.filter((w: any) => !w.last_check_date).length} />
|
||||
</div>
|
||||
|
||||
{/* 风险筛选 */}
|
||||
<div className="flex gap-2">
|
||||
{['', '红色', '黄色', '绿色'].map((r) => (
|
||||
<button
|
||||
key={r || 'all'}
|
||||
onClick={() => setRiskFilter(r)}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm ${riskFilter === r ? 'border-primary bg-primary text-primary-foreground' : ''}`}
|
||||
>
|
||||
{r || '全部'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 门店红黄绿列表 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-3 text-sm font-bold">门店风险分级 ({riskRows.length})</h2>
|
||||
<Pagination page={riskPage} pageSize={PAGE_SIZE} total={riskRows.length} onPageChange={setRiskPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'risk_level', label: '风险', render: (r) => <Badge type="risk" text={r.risk_level} /> },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => formatPercent(r.discount_rate_pct) },
|
||||
{ key: 'theoretical_margin_pct', label: '毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
|
||||
{ key: 'anomaly_rate_pct', label: '异常率', align: 'right', render: (r) => formatPercent(r.anomaly_rate_pct) },
|
||||
]}
|
||||
data={pagedRiskRows}
|
||||
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 需到店检查门店 */}
|
||||
{needCheck.length > 0 && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-3 text-sm font-bold text-red-600">需到店检查门店(连续2周未改善)({needCheck.length})</h2>
|
||||
<Pagination page={checkPage} pageSize={PAGE_SIZE} total={needCheck.length} onPageChange={setCheckPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'problem_indicator', label: '问题指标' },
|
||||
{ key: 'consecutive_no_improve_weeks', label: '连续未改善周数', align: 'center' },
|
||||
{ key: 'status', label: '任务状态', render: (r) => <Badge type="status" text={r.status} /> },
|
||||
]}
|
||||
data={pagedNeedCheck}
|
||||
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
export function RiskPage() {
|
||||
const [anomalyPage, setAnomalyPage] = useState(1)
|
||||
const [cashierPage, setCashierPage] = useState(1)
|
||||
const [zeroPage, setZeroPage] = useState(1)
|
||||
const [riskTab, setRiskTab] = useState<'anomaly' | 'zero' | 'cashier'>('anomaly')
|
||||
|
||||
const { data: anomalyData, isLoading: anomalyLoading } = useQuery({
|
||||
queryKey: ['risk/anomaly'],
|
||||
queryFn: () => api.get('/risk/anomaly'),
|
||||
})
|
||||
|
||||
const { data: zeroData, isLoading: zeroLoading } = useQuery({
|
||||
queryKey: ['risk/zero-received'],
|
||||
queryFn: () => api.get('/risk/zero-received'),
|
||||
})
|
||||
|
||||
const { data: cashierData, isLoading: cashierLoading } = useQuery({
|
||||
queryKey: ['risk/cashier'],
|
||||
queryFn: () => api.get('/risk/cashier'),
|
||||
})
|
||||
|
||||
const anomalyRows = (anomalyData as any)?.data || []
|
||||
const zeroRows = (zeroData as any)?.data || []
|
||||
const cashierRows = (cashierData as any)?.data || []
|
||||
|
||||
const pageLoading = anomalyLoading || zeroLoading || cashierLoading
|
||||
|
||||
const totalAnomaly = anomalyRows.length
|
||||
const totalZero = zeroRows.length
|
||||
const highRiskCashiers = cashierRows.filter((r: any) => Number(r.anomaly_rate_pct || 0) >= 10).length
|
||||
const avgAnomalyRate = cashierRows.length > 0
|
||||
? cashierRows.reduce((s: number, r: any) => s + Number(r.anomaly_rate_pct || 0), 0) / cashierRows.length
|
||||
: 0
|
||||
|
||||
const pagedAnomaly = useMemo(() => anomalyRows.slice((anomalyPage - 1) * PAGE_SIZE, anomalyPage * PAGE_SIZE), [anomalyRows, anomalyPage])
|
||||
const pagedZero = useMemo(() => zeroRows.slice((zeroPage - 1) * PAGE_SIZE, zeroPage * PAGE_SIZE), [zeroRows, zeroPage])
|
||||
const pagedCashier = useMemo(() => cashierRows.slice((cashierPage - 1) * PAGE_SIZE, cashierPage * PAGE_SIZE), [cashierRows, cashierPage])
|
||||
|
||||
const cashierChartData = cashierRows.slice(0, 15)
|
||||
|
||||
if (pageLoading) {
|
||||
return <LoadingSpinner text="加载风险数据..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">风险与内控管理</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">异常账单 · 零实收 · 收银风险 · 2026年4月</p>
|
||||
</div>
|
||||
|
||||
{/* 概览指标 */}
|
||||
<CollapsibleSection title="风险概览" subtitle="异常账单与收银操作风险汇总">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="异常账单数" value={totalAnomaly} format="number" description="触发异常规则的账单数量,需逐单核查" />
|
||||
<MetricCard title="零实收账单" value={totalZero} format="number" description="实收为0的账单,可能为全额优惠或操作异常" />
|
||||
<MetricCard title="高风险收银员(≥10%)" value={highRiskCashiers} format="number" description="异常操作率≥10%的收银员数量" />
|
||||
<MetricCard title="平均异常率" value={avgAnomalyRate} format="percent" description="所有收银员的平均异常操作率" />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 收银员风险图 */}
|
||||
<CollapsibleSection title="收银员异常率TOP15" subtitle="异常操作率最高的收银员">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={cashierChartData} layout="vertical" margin={{ left: 60 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" unit="%" tick={{ fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="cashier" tick={{ fontSize: 10 }} width={60} />
|
||||
<Tooltip formatter={(v: any) => `${Number(v).toFixed(2)}%`} />
|
||||
<Bar dataKey="anomaly_rate_pct" fill="#ef4444" name="异常率" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 风险明细表 — Tab切换 */}
|
||||
<CollapsibleSection title="风险明细" subtitle="异常账单 / 零实收 / 收银员风险">
|
||||
<div className="mb-3 flex gap-2">
|
||||
<button onClick={() => setRiskTab('anomaly')} className={`rounded-md border px-3 py-1.5 text-sm ${riskTab === 'anomaly' ? 'border-primary bg-primary text-primary-foreground' : ''}`}>
|
||||
异常账单 ({totalAnomaly})
|
||||
</button>
|
||||
<button onClick={() => setRiskTab('zero')} className={`rounded-md border px-3 py-1.5 text-sm ${riskTab === 'zero' ? 'border-primary bg-primary text-primary-foreground' : ''}`}>
|
||||
零实收 ({totalZero})
|
||||
</button>
|
||||
<button onClick={() => setRiskTab('cashier')} className={`rounded-md border px-3 py-1.5 text-sm ${riskTab === 'cashier' ? 'border-primary bg-primary text-primary-foreground' : ''}`}>
|
||||
收银员风险 ({cashierRows.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{riskTab === 'anomaly' && (
|
||||
<>
|
||||
<Pagination page={anomalyPage} pageSize={PAGE_SIZE} total={anomalyRows.length} onPageChange={setAnomalyPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'bill_no', label: '账单号' },
|
||||
{ key: 'meal_period', label: '餐段' },
|
||||
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
|
||||
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
|
||||
{ key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) },
|
||||
{ key: 'anomaly_reason', label: '异常原因' },
|
||||
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
|
||||
]}
|
||||
data={pagedAnomaly}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{riskTab === 'zero' && (
|
||||
<>
|
||||
<Pagination page={zeroPage} pageSize={PAGE_SIZE} total={zeroRows.length} onPageChange={setZeroPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'bill_no', label: '账单号' },
|
||||
{ key: 'meal_period', label: '餐段' },
|
||||
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
|
||||
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
|
||||
{ key: 'zero_received_type', label: '零实收类型' },
|
||||
{ key: 'cashier', label: '收银员' },
|
||||
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
|
||||
]}
|
||||
data={pagedZero}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{riskTab === 'cashier' && (
|
||||
<>
|
||||
<Pagination page={cashierPage} pageSize={PAGE_SIZE} total={cashierRows.length} onPageChange={setCashierPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'cashier', label: '收银员' },
|
||||
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'anomaly_bills', label: '异常账单数', align: 'right', render: (r) => formatNumber(r.anomaly_bills) },
|
||||
{ key: 'anomaly_rate_pct', label: '异常率', align: 'right', render: (r) => {
|
||||
const v = Number(r.anomaly_rate_pct || 0)
|
||||
return <span className={v >= 10 ? 'font-medium text-red-600' : v >= 5 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(2)}%</span>
|
||||
}},
|
||||
{ key: 'anomaly_consumption', label: '异常消费额', align: 'right', render: (r) => formatCurrency(r.anomaly_consumption) },
|
||||
]}
|
||||
data={pagedCashier}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts'
|
||||
|
||||
const ABC_COLORS = { 'A-核心': '#22c55e', 'B-成长': '#3b82f6', 'C-长尾': '#ef4444' }
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
export function SKUPage() {
|
||||
const [abcFilter, setAbcFilter] = useState('')
|
||||
const [skuPage, setSkuPage] = useState(1)
|
||||
|
||||
const { data: skuData, isLoading: skuLoading } = useQuery({
|
||||
queryKey: ['sku/abc'],
|
||||
queryFn: () => api.get('/sku/abc'),
|
||||
})
|
||||
|
||||
const { data: catData, isLoading: catLoading } = useQuery({
|
||||
queryKey: ['sku/category'],
|
||||
queryFn: () => api.get('/sku/category'),
|
||||
})
|
||||
|
||||
const skuRows = ((skuData as any)?.data || []).filter((r: any) => !abcFilter || r.abc_class === abcFilter)
|
||||
const catRows = (catData as any)?.data || []
|
||||
|
||||
const abcSummary = (skuData as any)?.data?.reduce((acc: any, r: any) => {
|
||||
const k = r.abc_class
|
||||
if (!acc[k]) acc[k] = { sku_count: 0, received: 0, share: 0 }
|
||||
acc[k].sku_count++
|
||||
acc[k].received += Number(r.received_amount || 0)
|
||||
acc[k].share += Number(r.revenue_share_pct || 0)
|
||||
return acc
|
||||
}, {}) || {}
|
||||
|
||||
const abcPieData = Object.entries(abcSummary).map(([name, v]: any) => ({
|
||||
name,
|
||||
value: v.sku_count,
|
||||
received: v.received,
|
||||
share: v.share,
|
||||
}))
|
||||
|
||||
const totalSku = (skuData as any)?.data?.length || 0
|
||||
const totalReceived = Object.values(abcSummary).reduce((s: number, v: any) => s + v.received, 0)
|
||||
const cClassCount = abcSummary['C-长尾']?.sku_count || 0
|
||||
const lowValueSku = ((skuData as any)?.data || []).filter((r: any) => Number(r.received_amount) < 1000).length
|
||||
const singleStoreSku = ((skuData as any)?.data || []).filter((r: any) => Number(r.store_count) <= 1).length
|
||||
|
||||
const pagedSku = useMemo(() => skuRows.slice((skuPage - 1) * PAGE_SIZE, skuPage * PAGE_SIZE), [skuRows, skuPage])
|
||||
|
||||
const pageLoading = skuLoading || catLoading
|
||||
|
||||
if (pageLoading) {
|
||||
return <LoadingSpinner text="加载SKU数据..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">商品SKU管理</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">SKU ABC分析 · 长尾治理 · 品类结构 · 2026年4月</p>
|
||||
</div>
|
||||
|
||||
{/* 概览指标 */}
|
||||
<CollapsibleSection title="SKU概览" subtitle="商品复杂度与销售集中度">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="总SKU数" value={totalSku} format="number" description="所有有销售记录的菜品名称数量" />
|
||||
<MetricCard title="A类SKU(核心)" value={abcSummary['A-核心']?.sku_count || 0} format="number" description="贡献约70%实收的头部SKU" />
|
||||
<MetricCard title="C类SKU(长尾)" value={cClassCount} format="number" description="仅贡献10%实收的长尾SKU,建议治理" />
|
||||
<MetricCard title="低价值SKU(<¥1000)" value={lowValueSku} format="number" description="月实收不足1000元的SKU,优先停用候选" />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* ABC分布图 */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<CollapsibleSection title="SKU ABC分布" subtitle="按实收贡献分层">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={abcPieData}
|
||||
cx="50%" cy="50%" outerRadius={80}
|
||||
dataKey="value"
|
||||
label={({ name, value, share }: any) => `${name}: ${value}个 (${Number(share).toFixed(1)}%)`}
|
||||
>
|
||||
{abcPieData.map((entry) => (
|
||||
<Cell key={entry.name} fill={ABC_COLORS[entry.name as keyof typeof ABC_COLORS] || '#gray'} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any, _n: any, p: any) => [`${v}个SKU, 实收¥${formatNumber(p.payload.received)}`, p.payload.name]} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="品类实收分布" subtitle="按一级品类统计">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={catRows.slice(0, 10)} layout="vertical" margin={{ left: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" tick={{ fontSize: 10 }} tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} />
|
||||
<YAxis type="category" dataKey="category_name" tick={{ fontSize: 10 }} width={80} />
|
||||
<Tooltip formatter={(v: any) => formatCurrency(v)} />
|
||||
<Bar dataKey="amount" fill="#3b82f6" name="实收" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* SKU明细表 */}
|
||||
<CollapsibleSection title={`SKU明细 (${skuRows.length})`} subtitle="可按ABC分类筛选,点击表头排序">
|
||||
<div className="mb-3 flex gap-2">
|
||||
{['', 'A-核心', 'B-成长', 'C-长尾'].map((f) => (
|
||||
<button
|
||||
key={f || 'all'}
|
||||
onClick={() => { setAbcFilter(f); setSkuPage(1) }}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm ${abcFilter === f ? 'border-primary bg-primary text-primary-foreground' : ''}`}
|
||||
>
|
||||
{f || '全部'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Pagination page={skuPage} pageSize={PAGE_SIZE} total={skuRows.length} onPageChange={setSkuPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'dish_name', label: '菜品名称' },
|
||||
{ key: 'category_level1', label: '一级品类' },
|
||||
{ key: 'abc_class', label: 'ABC', render: (r) => (
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
r.abc_class === 'A-核心' ? 'bg-green-100 text-green-700' :
|
||||
r.abc_class === 'B-成长' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>{r.abc_class}</span>
|
||||
)},
|
||||
{ key: 'sales_quadrant', label: '象限' },
|
||||
{ key: 'store_count', label: '覆盖门店', align: 'center' },
|
||||
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
|
||||
{ key: 'received_amount', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_amount) },
|
||||
{ key: 'revenue_share_pct', label: '占比', align: 'right', render: (r) => `${Number(r.revenue_share_pct).toFixed(2)}%` },
|
||||
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => `${Number(r.discount_rate_pct).toFixed(1)}%` },
|
||||
]}
|
||||
data={pagedSku}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 长尾治理建议 */}
|
||||
<CollapsibleSection title="长尾治理建议" subtitle="基于SKU分布的治理方向" defaultOpen={false}>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="rounded-md border border-red-200 bg-red-50/50 p-3">
|
||||
<p className="font-medium text-red-700">首轮停用候选:约 {lowValueSku} 个SKU</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">月实收不足¥1,000的SKU,停用前需确认是否有区域特色或套餐组件</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-yellow-200 bg-yellow-50/50 p-3">
|
||||
<p className="font-medium text-yellow-700">单店专属SKU:{singleStoreSku} 个</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">仅1家门店销售,评估是否为区域特色或可推广</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50/50 p-3">
|
||||
<p className="font-medium text-blue-700">合并/归档候选:约300个套餐/赠品/餐具类</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">转为技术SKU或合并为统一编码,目标有效SKU池800-900个</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import api from '@/lib/api'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { Badge } from '@/components/Badge'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
export function StoreDetailPage() {
|
||||
const { code } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: storeData } = useQuery({
|
||||
queryKey: ['store', code],
|
||||
queryFn: () => api.get(`/stores/${code}`),
|
||||
})
|
||||
|
||||
const { data: dailyData } = useQuery({
|
||||
queryKey: ['store', code, 'daily'],
|
||||
queryFn: () => api.get(`/stores/${code}/daily`),
|
||||
})
|
||||
|
||||
const { data: tasksData } = useQuery({
|
||||
queryKey: ['store', code, 'tasks'],
|
||||
queryFn: () => api.get('/tasks', { params: { store_code: code, month: '2026-05', page_size: 50 } }),
|
||||
})
|
||||
|
||||
const sd = (storeData as any)?.data
|
||||
const daily = ((dailyData as any)?.data) || []
|
||||
const tasks = ((tasksData as any)?.data) || []
|
||||
|
||||
if (!sd) return <div>加载中...</div>
|
||||
const sc = sd.scorecard
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button onClick={() => navigate(-1)} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft size={16} /> 返回
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold">{sc.store_name}</h1>
|
||||
{sd.risk && <Badge type="risk" text={sd.risk.risk_level} />}
|
||||
</div>
|
||||
|
||||
{/* 核心指标 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
|
||||
<MetricCard title="实收" value={sc.received} format="currency" />
|
||||
<MetricCard title="账单数" value={sc.bill_count} format="number" />
|
||||
<MetricCard title="客单价" value={sc.avg_bill_value} format="currency" />
|
||||
<MetricCard title="优惠率" value={sc.discount_rate_pct} format="percent" />
|
||||
<MetricCard title="理论毛利率" value={sc.theoretical_margin_pct} format="percent" />
|
||||
<MetricCard title="会员占比" value={sc.member_bill_share_pct} format="percent" />
|
||||
</div>
|
||||
|
||||
{/* 日度趋势 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-3 text-sm font-bold">日度实收趋势</h2>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={daily}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="business_date" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="实收" />
|
||||
<Line type="monotone" dataKey="avg_bill_value" stroke="#eab308" name="客单价" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 平台经济性 */}
|
||||
{sd.platform && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-3 text-sm font-bold">平台经济性</h2>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="美团实收" value={sd.platform.meituan_received} format="currency" />
|
||||
<MetricCard title="饿了么实收" value={sd.platform.eleme_received} format="currency" />
|
||||
<MetricCard title="抖音实收" value={sd.platform.douyin_received} format="currency" />
|
||||
<MetricCard title="平台加权成本率" value={sd.platform.weighted_cost_rate_pct} format="percent" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 任务列表 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-3 text-sm font-bold">门店任务 ({tasks.length})</h2>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'problem_indicator', label: '问题指标' },
|
||||
{ key: 'priority', label: '优先级', render: (r) => <Badge type="priority" text={r.priority} /> },
|
||||
{ key: 'action_required', label: '行动要求', render: (r) => <span className="text-xs">{r.action_required}</span> },
|
||||
{ key: 'status', label: '状态', render: (r) => <Badge type="status" text={r.status} /> },
|
||||
{ key: 'deadline', label: '截止日', render: (r) => r.deadline?.substring(0, 10) },
|
||||
]}
|
||||
data={tasks}
|
||||
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { Badge } from '@/components/Badge'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function StorePage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [executeText, setExecuteText] = useState('')
|
||||
const [activeTaskId, setActiveTaskId] = useState<number | null>(null)
|
||||
const [storeCode, setStoreCode] = useState('1111')
|
||||
|
||||
const { data: storesData, isLoading: storesLoading } = useQuery({
|
||||
queryKey: ['stores-list'],
|
||||
queryFn: () => api.get('/stores/risk'),
|
||||
})
|
||||
const stores = ((storesData as any)?.data || []).map((s: any) => ({ code: s.store_code, name: s.store_name, risk: s.risk_level, issue: s.primary_issue }))
|
||||
|
||||
const { data: priorityData } = useQuery({
|
||||
queryKey: ['stores-priority-detail'],
|
||||
queryFn: () => api.get('/stores/priority'),
|
||||
})
|
||||
|
||||
const { data: cardData, isLoading: cardLoading } = useQuery({
|
||||
queryKey: ['store', storeCode, 'daily-card'],
|
||||
queryFn: () => api.get(`/tasks/stores/${storeCode}/daily-card`),
|
||||
})
|
||||
|
||||
const { data: tasksData, isLoading: tasksLoading } = useQuery({
|
||||
queryKey: ['store', storeCode, 'tasks'],
|
||||
queryFn: () => api.get('/tasks', { params: { store_code: storeCode, month: '2026-05', page_size: 50 } }),
|
||||
})
|
||||
|
||||
const { data: dailyData, isLoading: dailyLoading } = useQuery({
|
||||
queryKey: ['store', storeCode, 'daily'],
|
||||
queryFn: () => api.get(`/stores/${storeCode}/daily`, { params: { month: '2026-04' } }),
|
||||
})
|
||||
|
||||
const { data: followupData, isLoading: followupLoading } = useQuery({
|
||||
queryKey: ['store', storeCode, 'followup'],
|
||||
queryFn: () => api.get('/tasks/followup'),
|
||||
})
|
||||
|
||||
const card = (cardData as any)?.data
|
||||
const anomalies = card?.anomalies || []
|
||||
const tasks = (tasksData as any)?.data || []
|
||||
const dailyRows = (dailyData as any)?.data || []
|
||||
const followupRows = (followupData as any)?.data || []
|
||||
const followup = followupRows.find((f: any) => f.store_code === storeCode)
|
||||
|
||||
const priorityRows = (priorityData as any)?.data || []
|
||||
const priorityInfo = priorityRows.find((p: any) => p.store_code === storeCode)
|
||||
|
||||
const storeInfo = stores.find((s: any) => s.code === storeCode)
|
||||
const storeName = storeInfo?.name || ''
|
||||
const primaryIssue = storeInfo?.issue || priorityInfo?.problem_combination || '-'
|
||||
|
||||
const todoTasks = tasks.filter((t: any) => t.status === '待启动' || t.status === '进行中')
|
||||
const doneTasks = tasks.filter((t: any) => t.status === '已验收' || t.status === '已回滚')
|
||||
const passCount = tasks.filter((t: any) => t.verification_result === '达标').length
|
||||
const improvingCount = tasks.filter((t: any) => t.verification_result === '改善中').length
|
||||
|
||||
const allAnomalyItems: { module: string; metric: string; value: number; baseline: number; is_anomaly: boolean }[] = []
|
||||
anomalies.forEach((a: any) => {
|
||||
const list = typeof a.anomalies === 'string' ? JSON.parse(a.anomalies) : a.anomalies || []
|
||||
list.forEach((item: any) => {
|
||||
allAnomalyItems.push({ module: a.module, metric: item.metric, value: item.value, baseline: item.baseline, is_anomaly: item.is_anomaly })
|
||||
})
|
||||
})
|
||||
const anomalyCount = allAnomalyItems.filter((i: any) => i.is_anomaly).length
|
||||
|
||||
const executeMutation = useMutation({
|
||||
mutationFn: ({ id, text }: { id: number; text: string }) => api.put(`/tasks/${id}/execute`, { process_evidence: text }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['store', storeCode, 'tasks'] })
|
||||
setExecuteText('')
|
||||
setActiveTaskId(null)
|
||||
},
|
||||
})
|
||||
|
||||
const riskColor = (level: string) => {
|
||||
if (level === '红色') return 'bg-red-100 text-red-700 border-red-300'
|
||||
if (level === '黄色') return 'bg-yellow-100 text-yellow-700 border-yellow-300'
|
||||
return 'bg-green-100 text-green-700 border-green-300'
|
||||
}
|
||||
|
||||
const pageLoading = storesLoading || cardLoading || tasksLoading || dailyLoading || followupLoading
|
||||
|
||||
if (pageLoading) {
|
||||
return <LoadingSpinner text="加载店长工作台..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">店长工作台</h1>
|
||||
<select
|
||||
value={storeCode}
|
||||
onChange={(e) => setStoreCode(e.target.value)}
|
||||
className="rounded-md border px-3 py-1.5 text-sm"
|
||||
>
|
||||
{stores.map((s: any) => (
|
||||
<option key={s.code} value={s.code}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 门店概况 */}
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">门店名称</p>
|
||||
<p className="mt-1 text-lg font-bold">{storeName}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">风险等级</p>
|
||||
<div className="mt-1">
|
||||
{storeInfo?.risk && <span className={`inline-flex items-center rounded-md px-2 py-0.5 text-sm font-medium ${riskColor(storeInfo.risk)}`}>{storeInfo.risk}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">主要问题</p>
|
||||
<p className="mt-1 text-sm font-medium">{primaryIssue}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">任务完成率</p>
|
||||
<p className="mt-1 text-lg font-bold">{tasks.length > 0 ? Math.round(doneTasks.length / tasks.length * 100) : 0}%</p>
|
||||
<p className="text-xs text-muted-foreground">{doneTasks.length}/{tasks.length} 已完成</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 经营概览 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-bold">最新经营概览(5月1日)</h2>
|
||||
{anomalyCount > 0 && (
|
||||
<span className="rounded-md bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">{anomalyCount} 项异常</span>
|
||||
)}
|
||||
</div>
|
||||
{allAnomalyItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无数据</p>
|
||||
) : (
|
||||
<div className="grid gap-2 md:grid-cols-3 lg:grid-cols-6">
|
||||
{allAnomalyItems.map((item, idx) => (
|
||||
<div key={idx} className={`rounded-md border p-3 ${item.is_anomaly ? 'border-red-200 bg-red-50/50' : ''}`}>
|
||||
<p className="text-xs text-muted-foreground">{item.metric}</p>
|
||||
<p className={`mt-1 text-lg font-bold ${item.is_anomaly ? 'text-red-600' : ''}`}>
|
||||
{item.metric === '实收' ? formatCurrency(item.value) : formatNumber(item.value)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">日基准: {item.metric === '实收' ? formatCurrency(item.baseline) : formatNumber(item.baseline)}{item.is_anomaly && <span className="text-red-500"> ⚠</span>}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 日度趋势 + 月度指标对比 */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* 日度实收趋势 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-3 text-sm font-bold">日度实收趋势</h2>
|
||||
{dailyRows.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={dailyRows}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="business_date" tickFormatter={(v) => v?.substring(5, 10)} tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip labelFormatter={(v) => v?.substring(0, 10)} />
|
||||
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="实收" dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : <p className="py-16 text-center text-sm text-muted-foreground">暂无数据</p>}
|
||||
</div>
|
||||
|
||||
{/* 月度指标对比 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-3 text-sm font-bold">月度指标对比</h2>
|
||||
{followup ? (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground">
|
||||
<th className="py-2 text-left font-medium">指标</th>
|
||||
<th className="py-2 text-right font-medium">月基线</th>
|
||||
<th className="py-2 text-right font-medium">目标</th>
|
||||
<th className="py-2 text-right font-medium">实际</th>
|
||||
<th className="py-2 text-right font-medium">达成率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ label: '实收', baseline: Number(followup.baseline_received), target: Number(followup.target_received), actual: Number(followup.actual_received), lower: false, isCurrency: true },
|
||||
{ label: '客单价', baseline: Number(followup.baseline_avg_bill), target: Number(followup.target_avg_bill), actual: Number(followup.actual_avg_bill), lower: false, isCurrency: true },
|
||||
{ label: '优惠率(%)', baseline: Number(followup.baseline_discount_rate), target: Number(followup.target_discount_rate), actual: Number(followup.actual_discount_rate), lower: true, isCurrency: false },
|
||||
{ label: '理论毛利率(%)', baseline: Number(followup.baseline_margin_rate), target: Number(followup.target_margin_rate), actual: Number(followup.actual_margin_rate), lower: false, isCurrency: false },
|
||||
{ label: '异常率(%)', baseline: Number(followup.baseline_anomaly_rate), target: Number(followup.target_anomaly_rate), actual: Number(followup.actual_anomaly_rate), lower: true, isCurrency: false },
|
||||
{ label: '会员占比(%)', baseline: Number(followup.baseline_member_share), target: Number(followup.target_member_share), actual: Number(followup.actual_member_share), lower: false, isCurrency: false },
|
||||
].map((m) => {
|
||||
const achieved = !isNaN(m.actual) && !isNaN(m.target) && (m.lower ? m.actual <= m.target : m.actual >= m.target)
|
||||
const rate = !isNaN(m.actual) && !isNaN(m.baseline) && m.baseline !== 0 ? Math.round((m.actual / m.baseline) * 100) : null
|
||||
const fmt = (v: number) => isNaN(v) ? '-' : m.isCurrency ? formatCurrency(v) : formatNumber(v)
|
||||
return (
|
||||
<tr key={m.label} className="border-b last:border-0">
|
||||
<td className="py-2 text-left">{m.label}</td>
|
||||
<td className="py-2 text-right text-muted-foreground">{fmt(m.baseline)}</td>
|
||||
<td className="py-2 text-right text-blue-600">{fmt(m.target)}</td>
|
||||
<td className={`py-2 text-right font-medium ${achieved ? 'text-green-600' : 'text-red-600'}`}>{fmt(m.actual)}</td>
|
||||
<td className={`py-2 text-right ${achieved ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{rate !== null ? `${rate}%` : '-'}
|
||||
{achieved ? ' ✓' : ' ✗'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <p className="py-16 text-center text-sm text-muted-foreground">暂无数据</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工作重点和行动计划 */}
|
||||
{followup && (followup.work_focus || followup.action_plan) && (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{followup.work_focus && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-2 text-sm font-bold">工作重点</h2>
|
||||
<p className="text-sm text-muted-foreground">{followup.work_focus}</p>
|
||||
</div>
|
||||
)}
|
||||
{followup.action_plan && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-2 text-sm font-bold">行动计划</h2>
|
||||
<p className="text-sm text-muted-foreground">{followup.action_plan}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 待办任务 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-3 text-sm font-bold">待办任务 ({todoTasks.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{todoTasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无待办任务</p>
|
||||
) : todoTasks.map((t: any) => (
|
||||
<div key={t.task_id} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge type="priority" text={t.priority} />
|
||||
<span className="text-sm font-medium">{t.problem_indicator}</span>
|
||||
</div>
|
||||
<Badge type="status" text={t.status} />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t.action_required}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">截止日: {t.deadline?.substring(0, 10)}</p>
|
||||
|
||||
{activeTaskId === t.task_id ? (
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
value={executeText}
|
||||
onChange={(e) => setExecuteText(e.target.value)}
|
||||
placeholder="填写执行结果和过程证据..."
|
||||
className="mb-2 w-full rounded-md border p-2 text-xs"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => executeMutation.mutate({ id: t.task_id, text: executeText })}
|
||||
disabled={!executeText || executeMutation.isPending}
|
||||
className="rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{executeMutation.isPending ? '提交中...' : '提交反馈'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTaskId(null); setExecuteText('') }}
|
||||
className="rounded-md border px-3 py-1 text-xs"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
t.status === '待启动' && (
|
||||
<button
|
||||
onClick={() => setActiveTaskId(t.task_id)}
|
||||
className="mt-2 rounded-md border px-3 py-1 text-xs hover:bg-muted"
|
||||
>
|
||||
填写执行反馈
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已完成任务 */}
|
||||
{doneTasks.length > 0 && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-3 text-sm font-bold">已完成任务 ({doneTasks.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{doneTasks.map((t: any) => (
|
||||
<div key={t.task_id} className="flex items-center justify-between rounded-md border p-3 opacity-70">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge type="priority" text={t.priority} />
|
||||
<span className="text-sm">{t.problem_indicator}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{t.verification_result && <Badge type="review" text={t.verification_result} />}
|
||||
<Badge type="status" text={t.status} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '@/lib/api'
|
||||
import { Badge } from '@/components/Badge'
|
||||
import { useState } from 'react'
|
||||
import { ArrowLeft, CheckCircle, RotateCcw, Clock, User, Target, AlertCircle, FileText, History } from 'lucide-react'
|
||||
|
||||
export function TaskDetailPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [executeText, setExecuteText] = useState('')
|
||||
const [verifyResult, setVerifyResult] = useState('改善中')
|
||||
const [verifyActual, setVerifyActual] = useState('')
|
||||
const [showFormula, setShowFormula] = useState(false)
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['task', id],
|
||||
queryFn: () => api.get(`/tasks/${id}`),
|
||||
})
|
||||
|
||||
const { data: indicatorsData } = useQuery({
|
||||
queryKey: ['indicators'],
|
||||
queryFn: () => api.get('/tasks/indicators'),
|
||||
})
|
||||
|
||||
const td = (data as any)?.data
|
||||
const task = td?.task
|
||||
const logs = td?.logs || []
|
||||
const indicators = (indicatorsData as any)?.data || []
|
||||
const indicator = indicators.find((i: any) => i.indicator_name === task?.problem_indicator)
|
||||
const executeMutation = useMutation({
|
||||
mutationFn: (text: string) => api.put(`/tasks/${id}/execute`, { process_evidence: text }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['task', id] }),
|
||||
})
|
||||
|
||||
const verifyMutation = useMutation({
|
||||
mutationFn: (data: any) => api.put(`/tasks/${id}/verify`, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['task', id] }),
|
||||
})
|
||||
|
||||
const rollbackMutation = useMutation({
|
||||
mutationFn: (reason: string) => api.post(`/tasks/${id}/rollback`, { reason }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['task', id] }),
|
||||
})
|
||||
|
||||
if (!task) return <div className="flex items-center justify-center py-20 text-muted-foreground">加载中...</div>
|
||||
|
||||
const fmt = (v: any) => v === null || v === undefined || v === '' ? '-' : Number(v).toFixed(2)
|
||||
const hasNumericValues = task.current_value !== null && task.current_value !== undefined
|
||||
const isOverTarget = hasNumericValues && task.target_value !== null && Number(task.current_value) <= Number(task.target_value)
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 返回按钮 */}
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} /> 返回列表
|
||||
</button>
|
||||
|
||||
{/* 头部卡片 */}
|
||||
<div className="rounded-xl border bg-card p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{task.store_name}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{task.problem_indicator}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge type="priority" text={task.priority} />
|
||||
<Badge type="status" text={task.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 指标卡 — 数值型指标显示三卡,非数值型显示验收标准 */}
|
||||
{hasNumericValues ? (
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{/* 当前值 */}
|
||||
<div className={`rounded-xl border p-4 ${isOverTarget ? 'border-green-300 bg-green-50/50' : 'border-orange-300 bg-orange-50/50'}`}>
|
||||
<p className="text-xs font-medium text-muted-foreground">当前值</p>
|
||||
<div className="mt-1.5 flex items-baseline gap-1">
|
||||
<span className={`text-2xl font-bold ${isOverTarget ? 'text-green-700' : 'text-orange-700'}`}>{fmt(task.current_value)}</span>
|
||||
{indicator?.update_frequency && <span className="text-xs text-muted-foreground">/ {indicator.update_frequency}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* 基准值 */}
|
||||
<div className="rounded-xl border bg-card p-4 shadow-sm">
|
||||
<p className="text-xs font-medium text-muted-foreground">基准值(4月)</p>
|
||||
<div className="mt-1.5 flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold text-muted-foreground">{fmt(task.benchmark_value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 目标值 */}
|
||||
<div className="rounded-xl border border-blue-200 bg-blue-50/50 p-4">
|
||||
<p className="text-xs font-medium text-muted-foreground">目标值</p>
|
||||
<div className="mt-1.5 flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold text-blue-700">{fmt(task.target_value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-blue-200 bg-blue-50/50 p-4">
|
||||
<p className="text-xs font-medium text-muted-foreground">验收标准</p>
|
||||
<p className="mt-1.5 text-sm font-medium text-blue-700">{task.verification_indicator || '-'}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">此任务为定性指标,按验收标准衡量完成情况</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 指标计算说明 — 可折叠 */}
|
||||
{indicator && (
|
||||
<div className="rounded-xl border bg-card overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowFormula(!showFormula)}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Target size={16} className="text-muted-foreground" />
|
||||
{indicator.indicator_name} — 指标说明
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{showFormula ? '收起 ▲' : '展开 ▼'}</span>
|
||||
</button>
|
||||
{showFormula && (
|
||||
<div className="border-t px-4 py-3 space-y-2 text-sm">
|
||||
<div><span className="text-muted-foreground">业务定义:</span>{indicator.business_definition}</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground whitespace-nowrap">计算公式:</span>
|
||||
<code className="rounded bg-muted px-2 py-0.5 text-xs font-mono">{indicator.formula}</code>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground">
|
||||
<span>数据来源:{indicator.data_source || '-'}</span>
|
||||
<span>更新频率:{indicator.update_frequency}</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span className="rounded bg-yellow-100 px-2 py-0.5 text-yellow-800">黄线 {indicator.yellow_threshold ?? '-'}</span>
|
||||
<span className="rounded bg-red-100 px-2 py-0.5 text-red-800">红线 {indicator.red_threshold ?? '-'}</span>
|
||||
</div>
|
||||
<div className="border-t pt-2 text-xs text-muted-foreground">
|
||||
当前值 = 4月实际值 | 基准值 = 4月基线值 | 目标值 = 需改善到的目标值
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 任务详情卡片 */}
|
||||
<div className="rounded-xl border bg-card p-5 space-y-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold">
|
||||
<FileText size={16} className="text-muted-foreground" /> 任务详情
|
||||
</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">问题描述</p>
|
||||
<p className="text-sm">{task.problem_description || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Target size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">验收指标</p>
|
||||
<p className="text-sm">{task.verification_indicator || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">行动要求</p>
|
||||
<p className="text-sm">{task.action_required || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<User size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">负责人</p>
|
||||
<p className="text-sm">{task.owner}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Clock size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">截止日</p>
|
||||
<p className="text-sm">{task.deadline?.substring(0, 10)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{task.process_evidence && (
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">过程证据</p>
|
||||
<p className="text-sm">{task.process_evidence}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{task.incomplete_reason && (
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle size={16} className="mt-0.5 shrink-0 text-red-500" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">未完成原因</p>
|
||||
<p className="text-sm text-red-600">{task.incomplete_reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{task.verification_result && (
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">验收结果</p>
|
||||
<div className="mt-0.5"><Badge type="status" text={task.verification_result} /></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 执行反馈 */}
|
||||
{task.status === '待启动' && (
|
||||
<div className="rounded-xl border bg-card p-5">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||
<FileText size={16} className="text-muted-foreground" /> 执行反馈
|
||||
</h2>
|
||||
<textarea
|
||||
value={executeText}
|
||||
onChange={(e) => setExecuteText(e.target.value)}
|
||||
placeholder="填写执行结果和过程证据..."
|
||||
className="mb-3 w-full rounded-lg border p-3 text-sm focus:ring-2 focus:ring-primary/20 focus:outline-none"
|
||||
rows={3}
|
||||
/>
|
||||
<button
|
||||
onClick={() => executeMutation.mutate(executeText)}
|
||||
disabled={!executeText || executeMutation.isPending}
|
||||
className="rounded-lg bg-primary px-5 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50 hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{executeMutation.isPending ? '提交中...' : '提交执行反馈'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 月度验收 */}
|
||||
{task.status === '进行中' && (
|
||||
<div className="rounded-xl border bg-card p-5">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||
<CheckCircle size={16} className="text-muted-foreground" /> 月度验收
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{['达标', '改善中', '未改善'].map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setVerifyResult(r)}
|
||||
className={`rounded-lg border px-4 py-2 text-sm transition-colors ${verifyResult === r
|
||||
? r === '达标' ? 'border-green-500 bg-green-500 text-white'
|
||||
: r === '未改善' ? 'border-red-500 bg-red-500 text-white'
|
||||
: 'border-primary bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted'}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={verifyActual}
|
||||
onChange={(e) => setVerifyActual(e.target.value)}
|
||||
placeholder="实际值"
|
||||
className="mb-3 block w-48 rounded-lg border p-2 text-sm focus:ring-2 focus:ring-primary/20 focus:outline-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => verifyMutation.mutate({
|
||||
review_result: verifyResult,
|
||||
actual_value: parseFloat(verifyActual),
|
||||
revenue_stable: true,
|
||||
margin_improved: verifyResult === '达标',
|
||||
customer_stable: true,
|
||||
anomaly_decreased: verifyResult !== '未改善',
|
||||
})}
|
||||
disabled={verifyMutation.isPending}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-5 py-2 text-sm font-medium text-white disabled:opacity-50 hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<CheckCircle size={16} /> 确认验收
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('确认回滚此任务?')) rollbackMutation.mutate('指标改善但收入/顾客恶化') }}
|
||||
disabled={rollbackMutation.isPending}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-red-300 px-5 py-2 text-sm font-medium text-red-600 disabled:opacity-50 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<RotateCcw size={16} /> 回滚
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作日志 */}
|
||||
<div className="rounded-xl border bg-card p-5">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||
<History size={16} className="text-muted-foreground" /> 操作日志
|
||||
</h2>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无操作记录</p>
|
||||
) : (
|
||||
<div className="space-y-0">
|
||||
{logs.map((log: any, idx: number) => (
|
||||
<div key={log.log_id} className="flex items-center gap-3 py-2.5 border-b last:border-0">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-wrap items-center gap-2">
|
||||
<Badge text={log.action} />
|
||||
{log.new_status && <Badge type="status" text={log.new_status} />}
|
||||
<span className="text-xs text-muted-foreground">{log.operator}</span>
|
||||
{log.comment && <span className="text-xs text-muted-foreground">— {log.comment.substring(0, 80)}</span>}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{log.created_at?.substring(0, 19)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '@/lib/api'
|
||||
import { Badge } from '@/components/Badge'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { formatCurrency, formatPercent } from '@/lib/utils'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
const PAGE_SIZE = 5
|
||||
|
||||
export function TasksPage() {
|
||||
const navigate = useNavigate()
|
||||
const [month, setMonth] = useState('2026-05')
|
||||
const [priority, setPriority] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['tasks', month, priority, status],
|
||||
queryFn: () => api.get('/tasks', { params: { month, priority, status, page_size: 200 } }),
|
||||
})
|
||||
|
||||
const tasks = (data as any)?.data || []
|
||||
const total = (data as any)?.meta?.total || 0
|
||||
|
||||
const statusCounts = tasks.reduce((acc: any, t: any) => {
|
||||
acc[t.status] = (acc[t.status] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const groups = useMemo(() =>
|
||||
Array.from(
|
||||
tasks.reduce((acc: Map<string, any>, t: any) => {
|
||||
const key = t.store_code
|
||||
if (!acc.has(key)) acc.set(key, { store_name: t.store_name, store_code: t.store_code, priority: t.priority, tasks: [] })
|
||||
acc.get(key).tasks.push(t)
|
||||
return acc
|
||||
}, new Map()).values()
|
||||
), [tasks])
|
||||
|
||||
const totalGroups = groups.length
|
||||
const pagedGroups = groups.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">任务管理</h1>
|
||||
<span className="text-sm text-muted-foreground">共 {total} 条任务</span>
|
||||
</div>
|
||||
|
||||
{/* 筛选器 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
className="rounded-md border px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<select value={priority} onChange={(e) => setPriority(e.target.value)} className="rounded-md border px-3 py-1.5 text-sm">
|
||||
<option value="">全部优先级</option>
|
||||
<option value="P0-修复数据口径">P0-修复数据口径</option>
|
||||
<option value="P0-综合专项整改">P0-综合专项整改</option>
|
||||
<option value="P1-重点整改">P1-重点整改</option>
|
||||
<option value="P2-单项改善">P2-单项改善</option>
|
||||
<option value="标杆候选">标杆候选</option>
|
||||
<option value="持续跟踪">持续跟踪</option>
|
||||
</select>
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)} className="rounded-md border px-3 py-1.5 text-sm">
|
||||
<option value="">全部状态</option>
|
||||
<option value="待启动">待启动</option>
|
||||
<option value="进行中">进行中</option>
|
||||
<option value="已验收">已验收</option>
|
||||
<option value="已回滚">已回滚</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 状态汇总 */}
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(statusCounts).map(([s, c]: any) => (
|
||||
<Badge key={s} type="status" text={`${s}: ${c}`} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={totalGroups} onPageChange={setPage} />
|
||||
|
||||
{/* 任务列表 — 按门店分组 */}
|
||||
<div className="space-y-3">
|
||||
{pagedGroups.map((group: any) => (
|
||||
<div key={group.store_code} className="rounded-lg border bg-card overflow-hidden">
|
||||
{/* 门店组标题 */}
|
||||
<div
|
||||
className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2 cursor-pointer hover:bg-muted/60"
|
||||
onClick={() => navigate(`/stores/${group.store_code}`)}
|
||||
>
|
||||
<Badge type="priority" text={group.priority} />
|
||||
<span className="text-sm font-bold">{group.store_name}</span>
|
||||
<span className="text-xs text-muted-foreground">{group.tasks.length} 项任务</span>
|
||||
</div>
|
||||
{/* 组内任务表 */}
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'problem_indicator', label: '问题指标' },
|
||||
{ key: 'action_required', label: '行动要求', render: (r) => <span className="text-xs">{r.action_required}</span> },
|
||||
{ key: 'owner', label: '负责人' },
|
||||
{ key: 'deadline', label: '截止日', render: (r) => r.deadline?.substring(0, 10) },
|
||||
{ key: 'status', label: '状态', render: (r) => <Badge type="status" text={r.status} /> },
|
||||
]}
|
||||
data={group.tasks}
|
||||
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
|
||||
className="rounded-none border-0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { formatCurrency, formatNumber } from '@/lib/utils'
|
||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Area, AreaChart } from 'recharts'
|
||||
|
||||
const CHANNEL_COLORS: Record<string, string> = {
|
||||
cash: '#22c55e',
|
||||
alipay: '#3b82f6',
|
||||
wechat: '#06b6d4',
|
||||
meituan: '#f59e0b',
|
||||
unionpay: '#8b5cf6',
|
||||
douyin: '#ec4899',
|
||||
credit: '#ef4444',
|
||||
jd_delivery: '#f97316',
|
||||
meituan_delivery: '#f59e0b',
|
||||
taobao_delivery: '#eab308',
|
||||
}
|
||||
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
cash: '现金',
|
||||
alipay: '支付宝',
|
||||
wechat: '微信',
|
||||
meituan: '美团到店',
|
||||
unionpay: '银联',
|
||||
douyin: '抖音',
|
||||
credit: '挂账',
|
||||
jd_delivery: '京东到家',
|
||||
meituan_delivery: '美团外卖',
|
||||
taobao_delivery: '淘宝外卖',
|
||||
meituan_commission: '美团佣金',
|
||||
taobao_commission: '淘宝佣金',
|
||||
jd_commission: '京东佣金',
|
||||
}
|
||||
|
||||
const channelLabel = (k: string) => CHANNEL_LABELS[k] || k
|
||||
|
||||
export function TimePage() {
|
||||
const { data: weekdayData, isLoading: weekdayLoading } = useQuery({
|
||||
queryKey: ['time/weekday'],
|
||||
queryFn: () => api.get('/time/weekday'),
|
||||
})
|
||||
|
||||
const { data: hourlyData, isLoading: hourlyLoading } = useQuery({
|
||||
queryKey: ['time/hourly'],
|
||||
queryFn: () => api.get('/time/hourly'),
|
||||
})
|
||||
|
||||
const { data: channelData, isLoading: channelLoading } = useQuery({
|
||||
queryKey: ['channel'],
|
||||
queryFn: () => api.get('/channel'),
|
||||
})
|
||||
|
||||
const weekdayRows = (weekdayData as any)?.data || []
|
||||
const hourlyRows = (hourlyData as any)?.data || []
|
||||
const channelRows = (channelData as any)?.data || []
|
||||
|
||||
const pageLoading = weekdayLoading || hourlyLoading || channelLoading
|
||||
|
||||
const peakWeekday = weekdayRows.length > 0
|
||||
? weekdayRows.reduce((best: any, r: any) => Number(r.received || 0) > Number(best?.received || 0) ? r : best, null)
|
||||
: null
|
||||
const lowWeekday = weekdayRows.length > 0
|
||||
? weekdayRows.reduce((low: any, r: any) => Number(r.received || 0) < Number(low?.received || Infinity) ? r : low, null)
|
||||
: null
|
||||
const peakHour = hourlyRows.length > 0
|
||||
? hourlyRows.reduce((best: any, r: any) => Number(r.bill_count || 0) > Number(best?.bill_count || 0) ? r : best, null)
|
||||
: null
|
||||
|
||||
const channelTotals = channelRows.length > 0
|
||||
? Object.keys(channelRows[0]).filter(k => k !== 'business_date' && k !== 'meituan_commission' && k !== 'taobao_commission' && k !== 'jd_commission')
|
||||
.map(k => ({
|
||||
channel: k,
|
||||
total: channelRows.reduce((s: number, r: any) => s + Number(r[k] || 0), 0),
|
||||
}))
|
||||
.filter(c => c.total > 0)
|
||||
.sort((a, b) => b.total - a.total)
|
||||
: []
|
||||
|
||||
if (pageLoading) {
|
||||
return <LoadingSpinner text="加载时间数据..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">时间维度分析</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">星期规律 · 小时分布 · 渠道趋势 · 2026年4月</p>
|
||||
</div>
|
||||
|
||||
{/* 概览指标 */}
|
||||
<CollapsibleSection title="时间概览" subtitle="高峰与低谷识别">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="高峰日" value={peakWeekday?.weekday || '-'} description={`实收最高: ${formatCurrency(peakWeekday?.received)}`} />
|
||||
<MetricCard title="低谷日" value={lowWeekday?.weekday || '-'} description={`实收最低: ${formatCurrency(lowWeekday?.received)}`} />
|
||||
<MetricCard title="高峰时段" value={peakHour ? `${peakHour.closing_hour}:00` : '-'} description={`账单数最多: ${formatNumber(peakHour?.bill_count)}`} />
|
||||
<MetricCard title="渠道数" value={channelTotals.length} format="number" description="有交易记录的支付渠道数量" />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 星期趋势 */}
|
||||
<CollapsibleSection title="星期维度分析" subtitle="各星期实收、账单数、客单价对比">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={weekdayRows} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="weekday" tick={{ fontSize: 12 }} />
|
||||
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} />
|
||||
<Tooltip formatter={(v: any, name: any) => name === '实收' ? formatCurrency(v) : name === '客单价' ? formatCurrency(v) : formatNumber(v)} />
|
||||
<Legend />
|
||||
<Bar yAxisId="left" dataKey="received" fill="#3b82f6" name="实收" />
|
||||
<Bar yAxisId="right" dataKey="bill_count" fill="#22c55e" name="账单数" />
|
||||
<Bar yAxisId="left" dataKey="avg_bill_value" fill="#eab308" name="客单价" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 小时分布 */}
|
||||
<CollapsibleSection title="小时维度分析" subtitle="各时段账单数与实收分布">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<AreaChart data={hourlyRows} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="closing_hour" tickFormatter={(v) => `${v}:00`} tick={{ fontSize: 10 }} />
|
||||
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} />
|
||||
<Tooltip labelFormatter={(v) => `${v}:00`} formatter={(v: any, name: any) => name === '实收' ? formatCurrency(v) : formatNumber(v)} />
|
||||
<Legend />
|
||||
<Area yAxisId="left" type="monotone" dataKey="received" stroke="#3b82f6" fill="#3b82f680" name="实收" />
|
||||
<Area yAxisId="right" type="monotone" dataKey="bill_count" stroke="#22c55e" fill="#22c55e80" name="账单数" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 渠道趋势 */}
|
||||
<CollapsibleSection title="支付渠道趋势" subtitle="各支付方式按日期变化">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={channelRows} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="business_date" tickFormatter={(v) => v?.substring(5, 10)} tick={{ fontSize: 10 }} />
|
||||
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<Tooltip labelFormatter={(v) => v?.substring(0, 10)} formatter={(v: any) => formatCurrency(v)} />
|
||||
<Legend />
|
||||
{channelTotals.slice(0, 6).map(c => (
|
||||
<Line key={c.channel} type="monotone" dataKey={c.channel} name={channelLabel(c.channel)} stroke={CHANNEL_COLORS[c.channel] || '#gray'} dot={false} strokeWidth={1.5} />
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 渠道汇总 */}
|
||||
<CollapsibleSection title="渠道汇总" subtitle="各支付方式总金额" defaultOpen={false}>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{channelTotals.map(c => (
|
||||
<div key={c.channel} className="rounded-md border p-3">
|
||||
<p className="text-xs text-muted-foreground">{channelLabel(c.channel)}</p>
|
||||
<p className="mt-1 text-lg font-bold" style={{ color: CHANNEL_COLORS[c.channel] || '#gray' }}>
|
||||
{formatCurrency(c.total)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user