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

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
+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>
)
}