初始化:连锁餐饮数字化运营管理平台

This commit is contained in:
freedakgmail
2026-07-26 22:48:08 +08:00
commit a7874d79b5
67 changed files with 14391 additions and 0 deletions
+12
View File
@@ -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>
+3487
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+88
View File
@@ -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>
)
}
+25
View File
@@ -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>
)
}
+68
View File
@@ -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>
)
}
+143
View File
@@ -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>
)
}
+10
View File
@@ -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>
)
}
+44
View File
@@ -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>
)
}
+76
View File
@@ -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]
}
+37
View File
@@ -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>
)
}
+56
View File
@@ -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;
}
+27
View File
@@ -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
+56
View File
@@ -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'
}
+10
View File
@@ -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>
)
+200
View File
@@ -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实际成本 · · · 20264</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>
)
}
+246
View File
@@ -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"> · 20264</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>
)
}
+118
View File
@@ -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>
)
}
+38
View File
@@ -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>
)
}
+64
View File
@@ -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>
)
}
+153
View File
@@ -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"> · · · 20264</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>
)
}
+124
View File
@@ -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>
)
}
+393
View File
@@ -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>
)
}
+147
View File
@@ -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 · · 20264</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>
)
}
+117
View File
@@ -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>
)
}
+168
View File
@@ -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"> · · · 20264</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>
)
}
+170
View File
@@ -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分析 · · · 20264</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,000SKU</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>
)
}
+103
View File
@@ -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>
)
}
+319
View File
@@ -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">51</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>
)
}
+323
View File
@@ -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>
)
}
+119
View File
@@ -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>
)
}
+167
View File
@@ -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"> · · · 20264</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>
)
}
+45
View File
@@ -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: [],
}
+22
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -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,
},
},
},
})