This commit is contained in:
+72
-28
@@ -1,13 +1,17 @@
|
||||
import { useState, useEffect, useMemo, type ComponentType } from 'react';
|
||||
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Building2, ShieldCheck } from 'lucide-react';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { DemoModeProvider } from './Blur';
|
||||
import FeedbackFab from './FeedbackFab';
|
||||
import { cn } from '../lib/cn';
|
||||
import { LoadingState } from './ui/surface';
|
||||
|
||||
export interface ModuleConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ComponentType<{ size?: number; className?: string }>;
|
||||
component: ComponentType;
|
||||
component: ElementType;
|
||||
}
|
||||
|
||||
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id) */
|
||||
@@ -58,6 +62,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
|
||||
|
||||
const { user } = useAuth();
|
||||
const activeLabel = modules.find((m) => m.id === activeModule)?.label ?? '业务看板';
|
||||
const watermarkText = useMemo(() => {
|
||||
const name = user?.userName || '未登录';
|
||||
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
|
||||
@@ -66,7 +71,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
|
||||
return (
|
||||
<DemoModeProvider enabled={false}>
|
||||
<div className="flex min-h-screen">
|
||||
<div className="enterprise-grid-bg flex min-h-screen">
|
||||
{/* 全局水印 */}
|
||||
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
||||
<div className="absolute inset-0" style={{
|
||||
@@ -75,35 +80,63 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
}} />
|
||||
</div>
|
||||
{/* Web 侧边栏 (md 及以上) */}
|
||||
<nav className="hidden md:flex flex-col items-center w-16 bg-white border-r border-gray-100 fixed top-0 left-0 h-full z-50 py-6 gap-2">
|
||||
{modules.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = m.id === activeModule;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => switchModule(m.id)}
|
||||
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Icon size={22} />
|
||||
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<nav className="fixed left-0 top-0 z-50 hidden h-full w-20 flex-col items-center border-r border-white/10 bg-slate-950 px-2 py-4 text-white shadow-[12px_0_40px_rgba(15,23,42,0.16)] md:flex">
|
||||
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-blue-600 shadow-lg shadow-blue-950/20">
|
||||
<Building2 size={22} />
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-center gap-2">
|
||||
{modules.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = m.id === activeModule;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => switchModule(m.id)}
|
||||
className={cn(
|
||||
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
|
||||
isActive
|
||||
? 'text-white'
|
||||
: 'text-slate-400 hover:bg-white/8 hover:text-white',
|
||||
)}
|
||||
title={m.label}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="desktop-shell-active"
|
||||
className="absolute inset-0 rounded-2xl bg-blue-600 shadow-lg shadow-blue-950/30"
|
||||
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
|
||||
/>
|
||||
) : null}
|
||||
<Icon size={21} className="relative mb-1" />
|
||||
<span className="relative leading-tight">{m.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
|
||||
<ShieldCheck size={18} />
|
||||
</div>
|
||||
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 内容区 */}
|
||||
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
|
||||
{ActiveComponent && <ActiveComponent />}
|
||||
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
|
||||
<div className="pointer-events-none fixed left-20 right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex">
|
||||
<span className="text-slate-600">羚牛氢能 BI</span>
|
||||
<span className="mx-2 text-slate-300">/</span>
|
||||
<span>{activeLabel}</span>
|
||||
</div>
|
||||
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
|
||||
{ActiveComponent && <ActiveComponent />}
|
||||
</Suspense>
|
||||
<FeedbackFab module={activeModule} />
|
||||
</main>
|
||||
|
||||
{/* 移动端底部导航 (md 以下) */}
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-50">
|
||||
<nav className="fixed inset-x-0 bottom-0 z-50 border-t border-white/70 bg-white/92 px-3 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-18px_40px_rgba(15,23,42,0.08)] backdrop-blur-xl md:hidden">
|
||||
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
|
||||
{modules.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = m.id === activeModule;
|
||||
@@ -111,13 +144,24 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => switchModule(m.id)}
|
||||
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
|
||||
className={cn(
|
||||
'relative flex min-h-12 flex-col items-center justify-center gap-0.5 rounded-2xl text-[10px] font-black transition-colors',
|
||||
isActive ? 'text-blue-700' : 'text-slate-400',
|
||||
)}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className="text-[10px] mt-1">{m.label}</span>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="mobile-shell-active"
|
||||
className="absolute inset-0 rounded-2xl bg-blue-50 ring-1 ring-blue-100"
|
||||
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
|
||||
/>
|
||||
) : null}
|
||||
<Icon size={20} className="relative" />
|
||||
<span className="relative">{m.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</DemoModeProvider>
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useState, type ComponentType, type ReactNode } from 'react';
|
||||
import { AlertCircle, Info, Loader2, SearchX, X } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
export type SurfaceIcon = ComponentType<{ size?: number; className?: string }>;
|
||||
|
||||
export function PageFrame({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
eyebrow,
|
||||
meta,
|
||||
actions,
|
||||
children,
|
||||
maxWidth = 'max-w-6xl',
|
||||
compactInfo = false,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: SurfaceIcon;
|
||||
eyebrow?: string;
|
||||
meta?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
maxWidth?: string;
|
||||
compactInfo?: boolean;
|
||||
}) {
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--app-bg)] text-slate-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||
<div className={cn(maxWidth, 'mx-auto flex flex-col gap-4 pb-20 md:pb-6')}>
|
||||
<motion.header
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28, ease: 'easeOut' }}
|
||||
className={cn(
|
||||
'relative rounded-2xl border border-white/70 bg-white/86 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl',
|
||||
compactInfo ? 'overflow-visible' : 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-blue-600 via-cyan-400 to-emerald-400" />
|
||||
{compactInfo ? (
|
||||
<>
|
||||
<div className="flex min-h-[48px] items-center gap-2 px-3 py-1.5 md:min-h-[54px] md:gap-3 md:px-4 md:py-2">
|
||||
{Icon ? (
|
||||
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100 md:h-9 md:w-9">
|
||||
<Icon size={17} />
|
||||
</span>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{eyebrow ? <span className="shrink-0 text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
|
||||
{meta ? <span className="hidden truncate text-[11px] font-bold text-slate-400 sm:inline">{meta}</span> : null}
|
||||
</div>
|
||||
<h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1>
|
||||
</div>
|
||||
{actions ? <div className="hidden shrink-0 items-center gap-2 md:flex">{actions}</div> : null}
|
||||
{subtitle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInfoOpen(open => !open)}
|
||||
className={cn(
|
||||
'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-slate-400 transition-colors md:h-9 md:w-9',
|
||||
infoOpen ? 'border-blue-100 bg-blue-50 text-blue-600' : 'border-slate-100 bg-slate-50 hover:bg-blue-50 hover:text-blue-600',
|
||||
)}
|
||||
aria-label={infoOpen ? '收起页面说明' : '展开页面说明'}
|
||||
title={infoOpen ? '收起说明' : '页面说明'}
|
||||
>
|
||||
{infoOpen ? <X size={16} /> : <Info size={16} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{infoOpen && subtitle ? (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="absolute left-0 right-0 top-full z-40 mt-2 overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-xl md:static md:mt-0 md:rounded-none md:border-x-0 md:border-b-0 md:shadow-none"
|
||||
>
|
||||
<div className="px-4 py-3 text-xs font-bold leading-relaxed text-slate-500 md:text-sm">
|
||||
{subtitle}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 p-4 md:flex-row md:items-end md:justify-between md:p-5">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{Icon ? (
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
|
||||
<Icon size={17} />
|
||||
</span>
|
||||
) : null}
|
||||
{eyebrow ? <span className="text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
|
||||
{meta ? <span className="text-[11px] font-bold text-slate-400">{meta}</span> : null}
|
||||
</div>
|
||||
<h1 className="truncate text-xl font-black tracking-tight text-slate-950 md:text-2xl">{title}</h1>
|
||||
{subtitle ? <p className="mt-2 max-w-3xl text-xs font-bold leading-relaxed text-slate-500 md:text-sm">{subtitle}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</motion.header>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SurfaceCard({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn('rounded-2xl border border-slate-100 bg-white shadow-sm', className)}>
|
||||
{(title || subtitle || actions) && (
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-slate-100 px-4 py-3">
|
||||
<div>
|
||||
{title ? <h2 className="text-sm font-black text-slate-900">{title}</h2> : null}
|
||||
{subtitle ? <p className="mt-1 text-[11px] font-bold text-slate-400">{subtitle}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricTile({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
helper,
|
||||
icon: Icon,
|
||||
tone = 'blue',
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
unit?: string;
|
||||
helper?: string;
|
||||
icon?: SurfaceIcon;
|
||||
tone?: 'blue' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||
}) {
|
||||
const toneClass = {
|
||||
blue: 'bg-blue-50 text-blue-600 ring-blue-100',
|
||||
emerald: 'bg-emerald-50 text-emerald-600 ring-emerald-100',
|
||||
amber: 'bg-amber-50 text-amber-600 ring-amber-100',
|
||||
rose: 'bg-rose-50 text-rose-600 ring-rose-100',
|
||||
slate: 'bg-slate-100 text-slate-600 ring-slate-200',
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-black text-slate-400">{label}</div>
|
||||
<div className="mt-2 flex items-end gap-1">
|
||||
<span className="truncate text-2xl font-black tracking-tight text-slate-950">{value}</span>
|
||||
{unit ? <span className="pb-1 text-[11px] font-black text-slate-400">{unit}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{Icon ? (
|
||||
<span className={cn('inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1', toneClass)}>
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{helper ? <div className="mt-3 text-[11px] font-bold leading-relaxed text-slate-500">{helper}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SegmentedNav<T extends string>({
|
||||
tabs,
|
||||
active,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
|
||||
active: T;
|
||||
onChange: (id: T) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('rounded-2xl border border-white/70 bg-white/90 p-1 shadow-sm backdrop-blur-xl', className)}>
|
||||
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}>
|
||||
{tabs.map(({ id, label, icon: Icon }) => {
|
||||
const isActive = active === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
className={cn(
|
||||
'relative flex min-h-10 items-center justify-center gap-1.5 rounded-xl px-2 text-[12px] font-black transition-colors',
|
||||
isActive ? 'text-blue-700' : 'text-slate-400 hover:bg-slate-50 hover:text-slate-600',
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="segmented-active-pill"
|
||||
className="absolute inset-0 rounded-xl bg-blue-50 shadow-sm ring-1 ring-blue-100"
|
||||
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
|
||||
/>
|
||||
) : null}
|
||||
{Icon ? <Icon size={15} className="relative" /> : null}
|
||||
<span className="relative truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonBlock({ className }: { className?: string }) {
|
||||
return <div className={cn('overflow-hidden rounded-xl bg-slate-100 shimmer', className)} />;
|
||||
}
|
||||
|
||||
export function LoadingState({ label = '数据加载中' }: { label?: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
|
||||
<Loader2 className="mx-auto mb-3 animate-spin text-blue-500" size={22} />
|
||||
<div className="text-xs font-black text-slate-500">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title = '暂无数据',
|
||||
description = '换个筛选条件或稍后再试',
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
|
||||
<SearchX className="mx-auto mb-3 text-slate-300" size={26} />
|
||||
<div className="text-sm font-black text-slate-700">{title}</div>
|
||||
<div className="mt-1 text-xs font-bold text-slate-400">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-rose-100 bg-rose-50 p-4 text-rose-700 shadow-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle size={18} className="mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-black">加载失败</div>
|
||||
<div className="mt-1 text-xs font-bold leading-relaxed">{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FadeIn({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22, ease: 'easeOut' }}
|
||||
className={cn('w-full min-w-0', className)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user