feat: add enterprise app ui layer

This commit is contained in:
hailin
2026-07-01 11:40:32 +08:00
parent 3b5b614534
commit 52bb354a1b
10 changed files with 240 additions and 11 deletions

View File

@@ -1,7 +1,7 @@
import type * as React from 'react'
import { Sidebar } from './Sidebar'
import { Topbar } from './Topbar'
import { AppSidebar } from '@/components/app/AppSidebar'
import { AppTopbar } from '@/components/app/AppTopbar'
type AppShellProps = {
children: React.ReactNode
@@ -10,9 +10,9 @@ type AppShellProps = {
export function AppShell({ children }: AppShellProps) {
return (
<div className="min-h-screen bg-background text-foreground">
<Sidebar />
<div className="min-h-screen lg:pl-64">
<Topbar />
<AppSidebar />
<div className="min-h-screen lg:pl-72">
<AppTopbar />
{children}
</div>
</div>

View File

@@ -0,0 +1,75 @@
import type * as React from 'react'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { cn } from '@/lib/utils'
export type AppDataTableColumn<Row> = {
key: string
header: React.ReactNode
cell: (row: Row) => React.ReactNode
className?: string
}
type AppDataTableProps<Row> = {
columns: AppDataTableColumn<Row>[]
rows: Row[]
getRowKey: (row: Row) => React.Key
emptyTitle: string
emptyDescription?: string
toolbar?: React.ReactNode
footer?: React.ReactNode
onRowClick?: (row: Row) => void
}
export function AppDataTable<Row>({
columns,
rows,
getRowKey,
emptyTitle,
emptyDescription,
toolbar,
footer,
onRowClick
}: AppDataTableProps<Row>) {
return (
<div className="overflow-hidden rounded-lg border border-border bg-surface">
{toolbar ? <div className="border-b border-border p-3">{toolbar}</div> : null}
{rows.length === 0 ? (
<div className="p-4">
<AppEmptyState title={emptyTitle} description={emptyDescription} />
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead key={column.key} className={column.className}>
{column.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow
key={getRowKey(row)}
className={cn(onRowClick ? 'cursor-pointer' : undefined)}
onClick={() => onRowClick?.(row)}
>
{columns.map((column) => (
<TableCell key={column.key} className={column.className}>
{column.cell(row)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{footer ? <div className="border-t border-border px-3 py-2">{footer}</div> : null}
</div>
)
}

View File

@@ -0,0 +1,15 @@
import type * as React from 'react'
type AppDetailLayoutProps = {
children: React.ReactNode
side: React.ReactNode
}
export function AppDetailLayout({ children, side }: AppDetailLayoutProps) {
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="grid min-w-0 gap-4">{children}</div>
<aside className="grid content-start gap-4">{side}</aside>
</div>
)
}

View File

@@ -1,13 +1,17 @@
import type * as React from 'react'
type AppEmptyStateProps = {
title: string
description?: string
action?: React.ReactNode
}
export function AppEmptyState({ title, description }: AppEmptyStateProps) {
export function AppEmptyState({ title, description, action }: AppEmptyStateProps) {
return (
<div className="rounded-lg border border-dashed border-border bg-surface px-4 py-8 text-center">
<p className="text-sm font-medium text-foreground">{title}</p>
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
{action ? <div className="mt-4 flex justify-center">{action}</div> : null}
</div>
)
}

View File

@@ -0,0 +1,15 @@
import type * as React from 'react'
type AppFilterBarProps = {
children: React.ReactNode
actions?: React.ReactNode
}
export function AppFilterBar({ children, actions }: AppFilterBarProps) {
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">
<div className="grid flex-1 gap-3 md:grid-cols-2 xl:grid-cols-4">{children}</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
)
}

View File

@@ -3,17 +3,21 @@ import type * as React from 'react'
type AppPageHeaderProps = {
title: string
description?: string
meta?: React.ReactNode
actions?: React.ReactNode
}
export function AppPageHeader({ title, description, actions }: AppPageHeaderProps) {
export function AppPageHeader({ title, description, meta, actions }: AppPageHeaderProps) {
return (
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<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>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
)
}

View File

@@ -0,0 +1,25 @@
import type * as React from 'react'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
type AppSectionProps = {
title: string
description?: string
action?: React.ReactNode
children: React.ReactNode
}
export function AppSection({ title, description, action, children }: AppSectionProps) {
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
</div>
{action}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,63 @@
import { Bot, BriefcaseBusiness, Database, Home, Library, Settings, ShieldCheck, Ticket } from 'lucide-react'
import { cn } from '@/lib/utils'
const navGroups = [
{
label: '工作台',
items: [{ href: '#/', label: '运营总览', icon: Home }]
},
{
label: '业务处理',
items: [
{ href: '#/tickets', label: '工单中心', icon: Ticket },
{ href: '#/tickets/new', label: '创建工单', icon: BriefcaseBusiness },
{ href: '#/solutions', label: '解决方案库', icon: Library }
]
},
{
label: '平台能力',
items: [
{ href: '#/knowledge', label: '知识库', icon: Database },
{ href: '#/agents', label: 'Agent 配置', icon: Bot },
{ href: '#/system', label: '系统治理', icon: ShieldCheck },
{ href: '#/design-system', label: '设计系统', icon: Settings }
]
}
]
export function AppSidebar() {
return (
<aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-border bg-surface lg:block">
<div className="border-b border-border px-5 py-4">
<p className="text-sm font-semibold text-foreground">AI 工单平台</p>
<p className="mt-1 text-xs text-muted-foreground">企业技术支持工作台</p>
</div>
<nav className="flex flex-col gap-5 p-4">
{navGroups.map((group) => (
<div key={group.label} className="grid gap-1.5">
<p className="px-2 text-xs font-medium text-muted-foreground">{group.label}</p>
<div className="grid gap-1">
{group.items.map((item) => {
const Icon = item.icon
return (
<a
className={cn(
'flex h-9 items-center gap-2 rounded-md px-2.5 text-sm text-muted-foreground transition-colors',
'hover:bg-muted hover:text-foreground'
)}
href={item.href}
key={item.href}
>
<Icon className="size-4" />
<span>{item.label}</span>
</a>
)
})}
</div>
</div>
))}
</nav>
</aside>
)
}

View File

@@ -10,6 +10,6 @@ const STATUS_LABELS: Record<string, string> = {
}
export function AppStatusBadge({ status }: { status: string }) {
const variant = status === 'resolved' || status === 'closed' ? 'success' : 'info'
const variant = status === 'resolved' || status === 'closed' ? 'success' : status === 'pending_confirm' ? 'warning' : 'info'
return <Badge variant={variant}>{STATUS_LABELS[status] ?? status}</Badge>
}

View File

@@ -0,0 +1,28 @@
import { Bell, ChevronRight, UserRound } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
export function AppTopbar() {
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">
<div className="flex min-w-0 items-center gap-2 text-sm">
<span className="font-medium text-foreground">AI 工单平台</span>
<ChevronRight className="size-4 text-muted-foreground" />
<span className="truncate text-muted-foreground">当前工作区</span>
</div>
<div className="flex items-center gap-3">
<Badge variant="info">本地环境</Badge>
<Separator orientation="vertical" className="h-5" />
<Button variant="ghost" size="icon" aria-label="通知">
<Bell className="size-4" />
</Button>
<Button variant="ghost" className="gap-2">
<UserRound className="size-4" />
<span>系统管理员</span>
</Button>
</div>
</header>
)
}