style: elevate enterprise admin visual foundation

This commit is contained in:
hailin
2026-07-02 15:52:23 +08:00
parent a16e0be5b1
commit ce423bd90b
11 changed files with 85 additions and 58 deletions

View File

@@ -1,5 +1,7 @@
import type * as React from 'react' import type * as React from 'react'
import { Card, CardContent } from '@/components/ui/card'
type AppFilterBarProps = { type AppFilterBarProps = {
children: React.ReactNode children: React.ReactNode
actions?: React.ReactNode actions?: React.ReactNode
@@ -7,9 +9,11 @@ type AppFilterBarProps = {
export function AppFilterBar({ children, actions }: AppFilterBarProps) { export function AppFilterBar({ children, actions }: AppFilterBarProps) {
return ( return (
<div className="flex flex-col gap-3 rounded-lg border border-border bg-surface p-3 lg:flex-row lg:items-center lg:justify-between"> <Card className="py-0">
<div className="grid flex-1 gap-3 md:grid-cols-2 xl:grid-cols-4">{children}</div> <CardContent className="flex flex-col gap-3 p-3 lg:flex-row lg:items-center lg:justify-between">
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null} <div className="grid flex-1 gap-3 md:grid-cols-2 xl:grid-cols-4">{children}</div>
</div> {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</CardContent>
</Card>
) )
} }

View File

@@ -16,8 +16,8 @@ const maxWidthClass: Record<NonNullable<AppPageProps['maxWidth']>, string> = {
export function AppPage({ children, className, contentClassName, maxWidth = '7xl' }: AppPageProps) { export function AppPage({ children, className, contentClassName, maxWidth = '7xl' }: AppPageProps) {
return ( return (
<main className={cn('min-h-screen bg-background px-4 py-4 text-foreground sm:px-6 lg:py-6', className)}> <main className={cn('min-h-screen bg-background px-4 py-5 text-foreground sm:px-6 lg:px-8 lg:py-7', className)}>
<div className={cn('mx-auto flex flex-col gap-4', maxWidthClass[maxWidth], contentClassName)}> <div className={cn('mx-auto flex flex-col gap-5', maxWidthClass[maxWidth], contentClassName)}>
{children} {children}
</div> </div>
</main> </main>

View File

@@ -9,15 +9,18 @@ type AppPageHeaderProps = {
export function AppPageHeader({ title, description, meta, actions }: AppPageHeaderProps) { export function AppPageHeader({ title, description, meta, actions }: AppPageHeaderProps) {
return ( return (
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> <div className="rounded-lg border border-border/80 bg-surface px-4 py-4 shadow-sm ring-1 ring-border/40">
<div> <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="flex flex-wrap items-center gap-2"> <div className="min-w-0">
<h1 className="text-xl font-semibold tracking-normal text-foreground">{title}</h1> <div className="mb-2 h-1 w-10 rounded-full bg-primary" />
{meta} <div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold tracking-normal text-foreground">{title}</h1>
{meta}
</div>
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
</div> </div>
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null} {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div> </div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div> </div>
) )
} }

View File

@@ -12,14 +12,14 @@ type AppSectionProps = {
export function AppSection({ title, description, action, children }: AppSectionProps) { export function AppSection({ title, description, action, children }: AppSectionProps) {
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-start justify-between gap-3"> <CardHeader className="flex flex-row items-start justify-between gap-3 border-b border-border/70 pb-3">
<div> <div>
<CardTitle className="text-sm">{title}</CardTitle> <CardTitle className="text-sm">{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null} {description ? <CardDescription>{description}</CardDescription> : null}
</div> </div>
{action} {action}
</CardHeader> </CardHeader>
<CardContent>{children}</CardContent> <CardContent className="pt-0">{children}</CardContent>
</Card> </Card>
) )
} }

View File

@@ -65,9 +65,16 @@ type SidebarNavContentProps = {
function SidebarBrand() { function SidebarBrand() {
return ( return (
<div className="border-b border-border px-5 py-4"> <div className="border-b border-sidebar-border px-5 py-5">
<p className="text-sm font-semibold text-foreground">AI 工单平台</p> <div className="flex items-center gap-3">
<p className="mt-1 text-xs text-muted-foreground">企业技术支持工作台</p> <div className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground shadow-sm">
<Bot className="size-5" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-sidebar-foreground">AI 工单平台</p>
<p className="mt-0.5 truncate text-xs text-sidebar-foreground/60">企业服务治理中台</p>
</div>
</div>
</div> </div>
) )
} }
@@ -77,7 +84,7 @@ function SidebarNavContent({ groups, currentHash, onNavigate }: SidebarNavConten
<nav className="flex flex-col gap-5 p-4"> <nav className="flex flex-col gap-5 p-4">
{groups.map((group) => ( {groups.map((group) => (
<div key={group.code} className="grid gap-1.5"> <div key={group.code} className="grid gap-1.5">
<p className="px-2 text-xs font-medium text-muted-foreground">{group.title}</p> <p className="px-2 text-xs font-medium text-sidebar-foreground/50">{group.title}</p>
<div className="grid gap-1"> <div className="grid gap-1">
{visibleChildren(group).map((item) => { {visibleChildren(group).map((item) => {
const Icon = getIcon(item.icon) const Icon = getIcon(item.icon)
@@ -85,16 +92,16 @@ function SidebarNavContent({ groups, currentHash, onNavigate }: SidebarNavConten
return ( return (
<a <a
className={cn( className={cn(
'flex h-9 items-center gap-2 rounded-md px-2.5 text-sm text-muted-foreground transition-colors', 'flex h-9 items-center gap-2 rounded-md px-2.5 text-sm text-sidebar-foreground/70 transition-colors',
'hover:bg-muted hover:text-foreground', 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
active && 'bg-muted text-foreground' active && 'bg-sidebar-primary text-sidebar-primary-foreground shadow-sm'
)} )}
href={item.path} href={item.path}
key={item.code} key={item.code}
onClick={onNavigate} onClick={onNavigate}
> >
<Icon className="size-4" /> <Icon className="size-4 shrink-0" />
<span>{item.title}</span> <span className="truncate">{item.title}</span>
</a> </a>
) )
})} })}
@@ -117,12 +124,12 @@ export function AppSidebar({ mobileOpen = false, onMobileOpenChange }: AppSideba
return ( return (
<> <>
<aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-border bg-surface lg:block"> <aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-sidebar-border bg-sidebar text-sidebar-foreground shadow-xl lg:block">
<SidebarBrand /> <SidebarBrand />
<SidebarNavContent groups={groups} currentHash={currentHash} /> <SidebarNavContent groups={groups} currentHash={currentHash} />
</aside> </aside>
<Sheet open={mobileOpen} onOpenChange={onMobileOpenChange}> <Sheet open={mobileOpen} onOpenChange={onMobileOpenChange}>
<SheetContent side="left" className="w-80 gap-0 p-0 lg:hidden"> <SheetContent side="left" className="w-80 gap-0 border-sidebar-border bg-sidebar p-0 text-sidebar-foreground lg:hidden">
<SheetHeader className="sr-only"> <SheetHeader className="sr-only">
<SheetTitle>移动端导航</SheetTitle> <SheetTitle>移动端导航</SheetTitle>
</SheetHeader> </SheetHeader>

View File

@@ -13,7 +13,7 @@ export function AppTopbar({ onMobileMenuClick }: AppTopbarProps) {
const { logout, user } = useAuth() const { logout, user } = useAuth()
return ( return (
<header className="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-border bg-surface/95 px-6 backdrop-blur"> <header className="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-border/80 bg-surface/95 px-4 shadow-sm backdrop-blur sm:px-6 lg:px-8">
<div className="flex min-w-0 items-center gap-2 text-sm"> <div className="flex min-w-0 items-center gap-2 text-sm">
<Button <Button
variant="ghost" variant="ghost"
@@ -24,23 +24,23 @@ export function AppTopbar({ onMobileMenuClick }: AppTopbarProps) {
> >
<Menu data-icon="inline-start" /> <Menu data-icon="inline-start" />
</Button> </Button>
<span className="font-medium text-foreground">AI 工单平台</span> <span className="hidden font-medium text-foreground sm:inline">企业服务治理中台</span>
<ChevronRight className="size-4 text-muted-foreground" /> <ChevronRight className="hidden size-4 text-muted-foreground sm:block" />
<span className="truncate text-muted-foreground">当前工作区</span> <span className="truncate text-muted-foreground">当前工作区</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Badge variant="info">本地环境</Badge> <Badge variant="outline" className="hidden sm:inline-flex">本地环境</Badge>
<Separator orientation="vertical" className="h-5" /> <Separator orientation="vertical" className="h-5" />
<Button variant="ghost" size="icon" aria-label="通知"> <Button variant="ghost" size="icon" aria-label="通知">
<Bell data-icon="inline-start" /> <Bell data-icon="inline-start" />
</Button> </Button>
<Button variant="ghost" className="gap-2"> <Button variant="ghost" className="hidden gap-2 sm:inline-flex">
<UserRound data-icon="inline-start" /> <UserRound data-icon="inline-start" />
<span>{user?.display_name || user?.username || '当前用户'}</span> <span>{user?.display_name || user?.username || '当前用户'}</span>
</Button> </Button>
<Button variant="outline" className="gap-2" onClick={() => void logout()}> <Button variant="outline" className="gap-2" onClick={() => void logout()}>
<LogOut data-icon="inline-start" /> <LogOut data-icon="inline-start" />
<span>退出</span> <span className="hidden sm:inline">退出</span>
</Button> </Button>
</div> </div>
</header> </header>

View File

@@ -12,7 +12,7 @@ function Card({
data-slot="card" data-slot="card"
data-size={size} data-size={size}
className={cn( className={cn(
"flex flex-col gap-4 overflow-hidden rounded-lg border border-border bg-card py-4 text-sm text-card-foreground shadow-sm", "flex flex-col gap-4 overflow-hidden rounded-lg border border-border/80 bg-card py-4 text-sm text-card-foreground shadow-sm ring-1 ring-border/40",
className className
)} )}
{...props} {...props}

View File

@@ -32,7 +32,7 @@ export function TicketFilterBar({
<AppFilterBar <AppFilterBar
actions={ actions={
<Button variant="outline" onClick={onReset}> <Button variant="outline" onClick={onReset}>
<RotateCcw className="size-4" /> <RotateCcw data-icon="inline-start" />
重置筛选 重置筛选
</Button> </Button>
} }

View File

@@ -1,5 +1,6 @@
import { AlertTriangle, Clock3, Flame, RotateCcw } from 'lucide-react' import { AlertTriangle, Clock3, Flame, RotateCcw } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import type { Ticket } from './types' import type { Ticket } from './types'
@@ -11,25 +12,25 @@ type TicketMetricCardsProps = {
const metricConfig = [ const metricConfig = [
{ {
label: '待处理', label: '待处理',
description: '需要分派或开始处理', badge: '分派',
icon: Clock3, icon: Clock3,
value: (tickets: Ticket[]) => tickets.filter((ticket) => ticket.status === 'pending').length value: (tickets: Ticket[]) => tickets.filter((ticket) => ticket.status === 'pending').length
}, },
{ {
label: '处理中', label: '处理中',
description: '技术支持正在跟进', badge: '跟进',
icon: RotateCcw, icon: RotateCcw,
value: (tickets: Ticket[]) => tickets.filter((ticket) => ticket.status === 'in_progress').length value: (tickets: Ticket[]) => tickets.filter((ticket) => ticket.status === 'in_progress').length
}, },
{ {
label: '待确认', label: '待确认',
description: '等待业务侧确认结果', badge: '确认',
icon: AlertTriangle, icon: AlertTriangle,
value: (tickets: Ticket[]) => tickets.filter((ticket) => ticket.status === 'pending_confirm').length value: (tickets: Ticket[]) => tickets.filter((ticket) => ticket.status === 'pending_confirm').length
}, },
{ {
label: '高优先级', label: '高优先级',
description: '高或紧急优先级', badge: '风险',
icon: Flame, icon: Flame,
value: (tickets: Ticket[]) => value: (tickets: Ticket[]) =>
tickets.filter((ticket) => ticket.priority === 'high' || ticket.priority === 'urgent').length tickets.filter((ticket) => ticket.priority === 'high' || ticket.priority === 'urgent').length
@@ -42,18 +43,19 @@ export function TicketMetricCards({ tickets }: TicketMetricCardsProps) {
{metricConfig.map((metric) => { {metricConfig.map((metric) => {
const Icon = metric.icon const Icon = metric.icon
return ( return (
<Card key={metric.label}> <Card key={metric.label} className="relative">
<CardHeader className="grid grid-cols-[minmax(0,1fr)_auto] items-start"> <CardHeader className="grid grid-cols-[minmax(0,1fr)_auto] items-start">
<div> <div className="min-w-0">
<CardTitle className="text-sm">{metric.label}</CardTitle> <CardTitle className="text-sm">{metric.label}</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">{metric.description}</p> <Badge variant="outline" className="mt-2">{metric.badge}</Badge>
</div> </div>
<div className="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground"> <div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary ring-1 ring-primary/15">
<Icon className="size-4" /> <Icon className="size-4" />
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="grid gap-2">
<p className="text-2xl font-semibold text-foreground">{metric.value(tickets)}</p> <p className="text-3xl font-semibold tabular-nums text-foreground">{metric.value(tickets)}</p>
<p className="text-xs text-muted-foreground">当前列表范围</p>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -4,13 +4,13 @@
@layer base { @layer base {
:root { :root {
--background: 210 20% 98%; --background: 213 24% 97%;
--foreground: 222 25% 12%; --foreground: 222 28% 11%;
--surface: 0 0% 100%; --surface: 0 0% 100%;
--muted: 210 18% 94%; --muted: 214 22% 93%;
--muted-foreground: 215 12% 42%; --muted-foreground: 215 14% 38%;
--border: 214 18% 86%; --border: 214 18% 84%;
--primary: 213 52% 32%; --primary: 214 56% 30%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--success: 151 55% 36%; --success: 151 55% 36%;
--warning: 38 86% 48%; --warning: 38 86% 48%;
@@ -19,7 +19,7 @@
--ai-accent: 186 52% 36%; --ai-accent: 186 52% 36%;
--radius: 0.5rem; --radius: 0.5rem;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 222 25% 12%; --card-foreground: 222 28% 11%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 222 25% 12%; --popover-foreground: 222 25% 12%;
--secondary: 210 18% 94%; --secondary: 210 18% 94%;
@@ -29,13 +29,13 @@
--destructive: 0 70% 50%; --destructive: 0 70% 50%;
--input: 214 18% 86%; --input: 214 18% 86%;
--ring: 213 52% 32%; --ring: 213 52% 32%;
--sidebar: 0 0% 100%; --sidebar: 222 32% 13%;
--sidebar-foreground: 222 25% 12%; --sidebar-foreground: 214 24% 92%;
--sidebar-primary: 213 52% 32%; --sidebar-primary: 186 52% 42%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 210 18% 94%; --sidebar-accent: 219 26% 20%;
--sidebar-accent-foreground: 222 25% 12%; --sidebar-accent-foreground: 0 0% 100%;
--sidebar-border: 214 18% 86%; --sidebar-border: 219 24% 24%;
--sidebar-ring: 213 52% 32%; --sidebar-ring: 213 52% 32%;
} }

View File

@@ -41,7 +41,18 @@ const config: Config = {
warning: 'hsl(var(--warning))', warning: 'hsl(var(--warning))',
danger: 'hsl(var(--danger))', danger: 'hsl(var(--danger))',
info: 'hsl(var(--info))', info: 'hsl(var(--info))',
ai: 'hsl(var(--ai-accent))' ai: 'hsl(var(--ai-accent))',
'ai-accent': 'hsl(var(--ai-accent))',
sidebar: {
DEFAULT: 'hsl(var(--sidebar))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
}
}, },
borderRadius: { borderRadius: {
lg: 'var(--radius)', lg: 'var(--radius)',