feat: add enterprise app ui layer
This commit is contained in:
@@ -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>
|
||||
|
||||
75
frontend/src/components/app/AppDataTable.tsx
Normal file
75
frontend/src/components/app/AppDataTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
frontend/src/components/app/AppDetailLayout.tsx
Normal file
15
frontend/src/components/app/AppDetailLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
15
frontend/src/components/app/AppFilterBar.tsx
Normal file
15
frontend/src/components/app/AppFilterBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
25
frontend/src/components/app/AppSection.tsx
Normal file
25
frontend/src/components/app/AppSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
frontend/src/components/app/AppSidebar.tsx
Normal file
63
frontend/src/components/app/AppSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
28
frontend/src/components/app/AppTopbar.tsx
Normal file
28
frontend/src/components/app/AppTopbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user