初始化:连锁餐饮数字化运营管理平台
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { cn, priorityColor, riskColor, statusColor, reviewResultColor } from '@/lib/utils'
|
||||
|
||||
interface BadgeProps {
|
||||
text: string
|
||||
type?: 'priority' | 'risk' | 'status' | 'review' | 'default'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Badge({ text, type = 'default', className }: BadgeProps) {
|
||||
const colorClass = type === 'priority'
|
||||
? priorityColor(text)
|
||||
: type === 'risk'
|
||||
? riskColor(text)
|
||||
: type === 'status'
|
||||
? statusColor(text)
|
||||
: type === 'review'
|
||||
? reviewResultColor(text)
|
||||
: 'bg-gray-100 text-gray-700 border border-gray-300'
|
||||
|
||||
return (
|
||||
<span className={cn('inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium', colorClass, className)}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
defaultOpen?: boolean
|
||||
headerRight?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function CollapsibleSection({ title, subtitle, defaultOpen = true, headerRight, children }: CollapsibleSectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<button
|
||||
className="flex w-full items-center justify-between p-4 text-left"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('text-sm font-bold transition-transform', open ? 'rotate-90' : 'rotate-0')}>▶</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold">{title}</h2>
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{headerRight && <div onClick={(e) => e.stopPropagation()}>{headerRight}</div>}
|
||||
</button>
|
||||
{open && <div className="border-t px-4 pb-4 pt-3">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DataTableProps {
|
||||
columns: { key: string; label: string; align?: 'left' | 'right' | 'center'; render?: (row: any) => ReactNode }[]
|
||||
data: any[]
|
||||
onRowClick?: (row: any) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DataTable({ columns, data, onRowClick, className }: DataTableProps) {
|
||||
return (
|
||||
<div className={cn('overflow-x-auto rounded-lg border', className)}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-3 py-2 font-medium text-muted-foreground whitespace-nowrap',
|
||||
col.align === 'right' && 'text-right',
|
||||
col.align === 'center' && 'text-center',
|
||||
!col.align && 'text-left'
|
||||
)}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 py-8 text-center text-muted-foreground">
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={cn(
|
||||
'border-t hover:bg-muted/30',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-3 py-2 whitespace-nowrap',
|
||||
col.align === 'right' && 'text-right',
|
||||
col.align === 'center' && 'text-center'
|
||||
)}
|
||||
>
|
||||
{col.render ? col.render(row) : row[col.key] ?? '-'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
user: { name: string; role: string } | null
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: any
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
interface MenuGroup {
|
||||
title: string
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
const menuGroups: MenuGroup[] = [
|
||||
{
|
||||
title: '经营管理',
|
||||
items: [
|
||||
{ path: '/', label: '总部驾驶舱', icon: LayoutDashboard, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
{ path: '/regional', label: '区域经理', icon: Store, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
{ path: '/store', label: '店长工作台', icon: ClipboardList, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
{ path: '/tasks', label: '任务管理', icon: ClipboardList, roles: ['hq', 'regional', 'store', 'dept'] },
|
||||
{ path: '/monthly-review', label: '月度验收', icon: TrendingUp, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '业务模块',
|
||||
items: [
|
||||
{ path: '/sku', label: '商品SKU', icon: Package, roles: ['hq', 'dept'] },
|
||||
{ path: '/cost', label: '成本库存', icon: DollarSign, roles: ['hq', 'dept'] },
|
||||
{ path: '/platform', label: '平台优惠', icon: ShoppingBag, roles: ['hq', 'dept'] },
|
||||
{ path: '/member', label: '会员复购', icon: Users, roles: ['hq', 'dept'] },
|
||||
{ path: '/risk', label: '风险内控', icon: AlertTriangle, roles: ['hq', 'dept'] },
|
||||
{ path: '/time', label: '时间分析', icon: Clock, roles: ['hq', 'dept'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统管理',
|
||||
items: [
|
||||
{ path: '/data-quality', label: '数据质量', icon: Database, roles: ['hq', 'dept'] },
|
||||
{ path: '/indicators', label: '指标字典', icon: Settings, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
{ path: '/ontology', label: '本体标准', icon: Database, roles: ['hq', 'dept'] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function Layout({ children, user, onLogout }: LayoutProps) {
|
||||
const location = useLocation()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
const visibleGroups = menuGroups.map(g => ({
|
||||
...g,
|
||||
items: g.items.filter((m) => !user || m.roles.includes(user.role)),
|
||||
})).filter(g => g.items.length > 0)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Mobile sidebar toggle */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="fixed left-4 top-4 z-50 rounded-md border p-2 lg:hidden"
|
||||
>
|
||||
{sidebarOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={cn(
|
||||
'fixed left-0 top-0 z-40 h-full w-56 border-r bg-card transition-transform lg:translate-x-0',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}>
|
||||
<div className="flex h-14 items-center border-b px-4">
|
||||
<span className="text-lg font-bold">马兰运营平台</span>
|
||||
</div>
|
||||
<nav className="space-y-4 p-3">
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<p className="mb-1 px-3 text-xs font-medium text-muted-foreground">{group.title}</p>
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = location.pathname === item.path
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
active ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
{user && (
|
||||
<div className="absolute bottom-0 left-0 right-0 border-t p-3">
|
||||
<div className="mb-2 px-3 text-sm">
|
||||
<p className="font-medium">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{user.role}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-56">
|
||||
<main className="min-h-screen p-4 pt-16 lg:pt-4">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Overlay for mobile */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-black/40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export function LoadingSpinner({ text = '加载中...' }: { text?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">{text}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cn, formatNumber } from '@/lib/utils'
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string
|
||||
value: string | number | null | undefined
|
||||
unit?: string
|
||||
format?: 'number' | 'percent' | 'currency'
|
||||
trend?: number
|
||||
description?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MetricCard({ title, value, unit, format = 'number', trend, description, className }: MetricCardProps) {
|
||||
const displayValue = format === 'percent'
|
||||
? value === null || value === undefined ? '-' : `${Number(value).toFixed(2)}%`
|
||||
: format === 'currency'
|
||||
? value === null || value === undefined ? '-' : `¥${formatNumber(value)}`
|
||||
: formatNumber(value)
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-lg border bg-card p-4 shadow-sm', className)}>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
{description && (
|
||||
<span className="group relative inline-flex">
|
||||
<span className="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-muted text-[10px] text-muted-foreground">?</span>
|
||||
<span className="pointer-events-none absolute left-1/2 top-6 z-10 w-48 -translate-x-1/2 rounded-md bg-gray-800 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold">{displayValue}</span>
|
||||
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
{trend !== undefined && (
|
||||
<p className={cn('mt-1 text-xs', trend > 0 ? 'text-green-600' : 'text-red-600')}>
|
||||
{trend > 0 ? '↑' : '↓'} {Math.abs(trend).toFixed(1)}% <span className="text-muted-foreground">环比上周</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
|
||||
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
export function Pagination({ page, pageSize, total, onPageChange }: PaginationProps) {
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const start = (page - 1) * pageSize + 1
|
||||
const end = Math.min(page * pageSize, total)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
第 {start}-{end} 条,共 {total} 条
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={page === 1}
|
||||
className="rounded-md border p-1.5 text-muted-foreground hover:bg-muted disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ChevronsLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="rounded-md border p-1.5 text-muted-foreground hover:bg-muted disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
{getPageNumbers(page, totalPages).map((p, idx) =>
|
||||
p === '...' ? (
|
||||
<span key={`ellipsis-${idx}`} className="px-2 text-xs text-muted-foreground">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p as number)}
|
||||
className={`min-w-[32px] rounded-md border px-2 py-1 text-xs ${
|
||||
page === p ? 'border-primary bg-primary text-primary-foreground' : 'hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="rounded-md border p-1.5 text-muted-foreground hover:bg-muted disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={page === totalPages}
|
||||
className="rounded-md border p-1.5 text-muted-foreground hover:bg-muted disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ChevronsRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getPageNumbers(current: number, total: number): (number | string)[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
|
||||
if (current <= 4) return [1, 2, 3, 4, 5, '...', total]
|
||||
if (current >= total - 3) return [1, '...', total - 4, total - 3, total - 2, total - 1, total]
|
||||
return [1, '...', current - 1, current, current + 1, '...', total]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Skeleton({ className, style }: { className?: string; style?: React.CSSProperties }) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-muted', className)} style={style} />
|
||||
}
|
||||
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="mt-2 h-7 w-32" />
|
||||
<Skeleton className="mt-1 h-3 w-16" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChartSkeleton({ height = 250 }: { height?: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<Skeleton className="mb-3 h-4 w-32" />
|
||||
<Skeleton className="w-full" style={{ height }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<Skeleton className="mb-3 h-4 w-48" />
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user