chore: preserve frontend concept lab

This commit is contained in:
hailin
2026-07-14 13:39:36 +08:00
parent f46369883e
commit 13b0768dfd
33 changed files with 1987 additions and 192 deletions

View File

@@ -0,0 +1,34 @@
import type * as React from 'react'
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
type AppApprovalPanelProps = {
title: string
description: string
state: 'waiting' | 'approved' | 'rejected'
action?: React.ReactNode
}
const stateConfig = {
waiting: { label: '等待确认', variant: 'warning' },
approved: { label: '已确认', variant: 'success' },
rejected: { label: '已拒绝', variant: 'danger' }
} as const
export function AppApprovalPanel({ title, description, state, action }: AppApprovalPanelProps) {
const config = stateConfig[state]
return (
<Alert className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="grid gap-1">
<div className="flex flex-wrap items-center gap-2">
<AlertTitle>{title}</AlertTitle>
<Badge variant={config.variant}>{config.label}</Badge>
</div>
<AlertDescription>{description}</AlertDescription>
</div>
{action ? <AlertAction className="static self-start sm:self-center">{action}</AlertAction> : null}
</Alert>
)
}

View File

@@ -0,0 +1,34 @@
import { Badge } from '@/components/ui/badge'
import { Card, CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import type { DashboardMetric } from '@/features/prototype/types'
type AppMetricCardProps = {
label: string
value: string | number
change: string
tone?: DashboardMetric['tone']
}
const variants = {
default: 'secondary',
success: 'success',
warning: 'warning',
danger: 'danger',
ai: 'ai'
} as const
export function AppMetricCard({ label, value, change, tone = 'default' }: AppMetricCardProps) {
return (
<Card>
<CardHeader>
<CardTitle>{label}</CardTitle>
<CardAction>
<Badge variant={variants[tone]}>{change}</Badge>
</CardAction>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold text-foreground">{value}</p>
</CardContent>
</Card>
)
}

View File

@@ -9,18 +9,17 @@ type AppPageHeaderProps = {
export function AppPageHeader({ title, description, meta, actions }: AppPageHeaderProps) {
return (
<div className="rounded-lg border border-border/80 bg-surface px-4 py-4 shadow-sm ring-1 ring-border/40">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<header data-slot="app-page-header" className="border-b border-border/80 pb-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<div className="mb-2 h-1 w-10 rounded-full bg-primary" />
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold tracking-normal text-foreground">{title}</h1>
<h1 className="text-2xl 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 flex-wrap items-center gap-2">{actions}</div> : null}
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,13 @@
import { FlaskConical } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
export function AppPrototypeNotice() {
return (
<Alert>
<FlaskConical />
<AlertTitle>当前页面使用演示数据</AlertTitle>
<AlertDescription>用于确认产品交互和数据契约,不会保存配置或发起真实调用。</AlertDescription>
</Alert>
)
}

View File

@@ -0,0 +1,30 @@
import { Badge } from '@/components/ui/badge'
const sourceLabels: Record<string, string> = {
knowledge_item: '知识条目',
solution: '解决方案',
ticket: '历史工单',
document: '文档'
}
type AppReferenceCardProps = {
title: string
sourceType: string
content: string
score: number
sourceLabel?: string
}
export function AppReferenceCard({ title, sourceType, content, score, sourceLabel }: AppReferenceCardProps) {
return (
<article className="grid gap-2 rounded-md border border-border bg-background p-3">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium text-foreground">{title}</p>
<Badge variant="secondary">{sourceLabels[sourceType] ?? sourceType}</Badge>
<Badge variant="outline">匹配度 {Math.round(score * 100)}%</Badge>
</div>
<p className="line-clamp-4 whitespace-pre-line text-sm leading-6 text-muted-foreground">{content}</p>
{sourceLabel ? <p className="text-xs text-muted-foreground">来源:{sourceLabel}</p> : null}
</article>
)
}

View File

@@ -0,0 +1,14 @@
import { Badge } from '@/components/ui/badge'
import type { McpTool } from '@/features/prototype/types'
const riskConfig = {
low: { label: '低风险', variant: 'success' },
medium: { label: '中风险', variant: 'warning' },
high: { label: '高风险', variant: 'danger' }
} as const
export function AppRiskBadge({ risk }: { risk: McpTool['risk_level'] }) {
const config = riskConfig[risk]
return <Badge variant={config.variant}>{config.label}</Badge>
}

View File

@@ -1,6 +1,6 @@
import type * as React from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
type AppSectionProps = {
title: string
@@ -11,15 +11,16 @@ type AppSectionProps = {
export function AppSection({ title, description, action, children }: AppSectionProps) {
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-3 border-b border-border/70 pb-3">
<div>
<CardTitle className="text-sm">{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
<section data-slot="app-section" className="grid gap-3">
<div className="flex min-h-8 items-start justify-between gap-3">
<div className="grid gap-1">
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{action}
</CardHeader>
<CardContent className="pt-0">{children}</CardContent>
</Card>
</div>
<Separator />
<div>{children}</div>
</section>
)
}

View File

@@ -15,13 +15,28 @@ import {
Ticket,
UserCog,
Users,
UsersRound
UsersRound,
Workflow
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
useSidebar
} from '@/components/ui/sidebar'
import { useAuth } from '@/features/auth/AuthProvider'
import type { AuthMenu } from '@/features/auth/types'
import { cn } from '@/lib/utils'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
const iconMap = {
audit: ScrollText,
@@ -102,62 +117,41 @@ type SidebarNavContentProps = {
onNavigate?: () => void
}
function SidebarBrand() {
return (
<div className="border-b border-sidebar-border px-5 py-5">
<div className="flex items-center gap-3">
<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>
)
}
function SidebarNavContent({ groups, currentHash, onNavigate }: SidebarNavContentProps) {
return (
<nav className="flex flex-col gap-5 p-4">
<>
{groups.map((group) => (
<div key={group.code} className="grid gap-1.5">
<p className="px-2 text-xs font-medium text-sidebar-foreground/50">{group.title}</p>
<div className="grid gap-1">
<SidebarGroup key={group.code}>
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{visibleChildren(group).map((item) => {
const Icon = getIcon(item.icon)
const active = currentHash === item.path
const route = currentHash.replace(/^#/, '')
const itemRoute = item.path.replace(/^#/, '')
const active = route === itemRoute || (itemRoute !== '/' && route.startsWith(`${itemRoute}/`))
return (
<a
className={cn(
'flex h-9 items-center gap-2 rounded-md px-2.5 text-sm text-sidebar-foreground/70 transition-colors',
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
active && 'bg-sidebar-primary text-sidebar-primary-foreground shadow-sm'
)}
href={item.path}
key={item.code}
onClick={onNavigate}
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{item.title}</span>
</a>
<SidebarMenuItem key={item.code}>
<SidebarMenuButton asChild isActive={active} size="lg" tooltip={item.title}>
<a href={item.path} onClick={onNavigate}>
<Icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</div>
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</nav>
</>
)
}
type AppSidebarProps = {
mobileOpen?: boolean
onMobileOpenChange?: (open: boolean) => void
}
export function AppSidebar({ mobileOpen = false, onMobileOpenChange }: AppSidebarProps) {
export function AppSidebar() {
const { user } = useAuth()
const { setOpenMobile } = useSidebar()
const currentHash = useCurrentHash()
const menuGroups = (user?.menus ?? []).filter((menu) => menu.is_visible && menu.is_enabled)
const hasKnowledgeMenu = menuGroups.some(
@@ -166,24 +160,42 @@ export function AppSidebar({ mobileOpen = false, onMobileOpenChange }: AppSideba
const groups = hasKnowledgeMenu ? menuGroups : [...menuGroups, knowledgeNavGroup]
return (
<>
<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 />
<SidebarNavContent groups={groups} currentHash={currentHash} />
</aside>
<Sheet open={mobileOpen} onOpenChange={onMobileOpenChange}>
<SheetContent side="left" className="w-80 gap-0 border-sidebar-border bg-sidebar p-0 text-sidebar-foreground lg:hidden">
<SheetHeader className="sr-only">
<SheetTitle>移动端导航</SheetTitle>
</SheetHeader>
<SidebarBrand />
<SidebarNavContent
groups={groups}
currentHash={currentHash}
onNavigate={() => onMobileOpenChange?.(false)}
/>
</SheetContent>
</Sheet>
</>
<Sidebar collapsible="icon">
<SidebarHeader className="px-3 py-3">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild size="lg" tooltip="AI 工单平台">
<a href="#/">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground">
<Workflow />
</span>
<span className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate font-semibold">AI 工单平台</span>
<span className="truncate text-xs text-sidebar-foreground/60">企业服务治理中台</span>
</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarNavContent
groups={groups}
currentHash={currentHash}
onNavigate={() => setOpenMobile(false)}
/>
</SidebarContent>
<SidebarFooter>
<div className="flex min-h-10 items-center gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0">
<Bot className="size-4 shrink-0 text-sidebar-foreground/60" />
<div className="flex min-w-0 flex-1 items-center justify-between gap-2 group-data-[collapsible=icon]:hidden">
<span className="truncate text-xs text-sidebar-foreground/60">服务状态</span>
<Badge variant="outline">本地环境</Badge>
</div>
</div>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}

View File

@@ -1,49 +1,91 @@
import { Bell, ChevronRight, LogOut, Menu, UserRound } from 'lucide-react'
import * as React from 'react'
import { ChevronDown, ChevronRight, LogOut, UserRound } from 'lucide-react'
import { AppThemeToggle } from '@/components/app/AppThemeToggle'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { useAuth } from '@/features/auth/AuthProvider'
type AppTopbarProps = {
onMobileMenuClick?: () => void
const routeTitles: Array<{ pattern: RegExp; title: string }> = [
{ pattern: /^\/$/, title: '工作台' },
{ pattern: /^\/tickets$/, title: '工单中心' },
{ pattern: /^\/tickets\/new$/, title: '创建工单' },
{ pattern: /^\/tickets\/\d+$/, title: '工单详情' },
{ pattern: /^\/solutions$/, title: '解决方案库' },
{ pattern: /^\/solutions\/new/, title: '新建解决方案' },
{ pattern: /^\/knowledge$/, title: '知识库' },
{ pattern: /^\/knowledge\/search$/, title: '知识检索' },
{ pattern: /^\/knowledge\/items\/new$/, title: '新建知识条目' },
{ pattern: /^\/system\/users$/, title: '用户管理' },
{ pattern: /^\/system\/user-groups$/, title: '用户组管理' },
{ pattern: /^\/system\/roles$/, title: '角色管理' },
{ pattern: /^\/system\/menus$/, title: '菜单权限' },
{ pattern: /^\/audit$/, title: '审计日志' },
{ pattern: /^\/design-system$/, title: '设计系统' }
]
export function getCurrentPageTitle(hash: string) {
const route = hash.replace(/^#/, '') || '/'
return routeTitles.find((item) => item.pattern.test(route))?.title ?? '当前工作区'
}
export function AppTopbar({ onMobileMenuClick }: AppTopbarProps) {
export function AppTopbar() {
const { logout, user } = useAuth()
const [pageTitle, setPageTitle] = React.useState(() => getCurrentPageTitle(window.location.hash))
React.useEffect(() => {
const handleHashChange = () => setPageTitle(getCurrentPageTitle(window.location.hash))
window.addEventListener('hashchange', handleHashChange)
return () => window.removeEventListener('hashchange', handleHashChange)
}, [])
return (
<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">
<header data-slot="app-topbar" className="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-border/80 bg-background/95 px-4 backdrop-blur sm:px-6 lg:px-8">
<div className="flex min-w-0 items-center gap-2 text-sm">
<Button
variant="ghost"
size="icon"
className="-ml-2 lg:hidden"
aria-label="打开导航"
onClick={onMobileMenuClick}
>
<Menu data-icon="inline-start" />
</Button>
<SidebarTrigger className="-ml-1" aria-label="切换侧边栏" />
<Separator orientation="vertical" className="mx-1 h-4" />
<span className="hidden font-medium text-foreground sm:inline">企业服务治理中台</span>
<ChevronRight className="hidden size-4 text-muted-foreground sm:block" />
<span className="truncate text-muted-foreground">当前工作区</span>
<span className="truncate text-muted-foreground">{pageTitle}</span>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="hidden sm:inline-flex">本地环境</Badge>
<Separator orientation="vertical" className="h-5" />
<Button variant="ghost" size="icon" aria-label="通知">
<Bell data-icon="inline-start" />
</Button>
<div className="flex items-center gap-1">
<AppThemeToggle />
<Button variant="ghost" className="hidden gap-2 sm:inline-flex">
<UserRound data-icon="inline-start" />
<span>{user?.display_name || user?.username || '当前用户'}</span>
</Button>
<Button variant="outline" className="gap-2" onClick={() => void logout()}>
<LogOut data-icon="inline-start" />
<span className="hidden sm:inline">退出</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 px-2 sm:px-3">
<span className="flex size-7 items-center justify-center rounded-md bg-muted text-foreground">
<UserRound />
</span>
<span className="hidden max-w-32 truncate sm:inline">{user?.display_name || user?.username || '当前用户'}</span>
<ChevronDown data-icon="inline-end" className="hidden text-muted-foreground sm:block" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<span className="grid gap-0.5">
<span className="truncate text-sm text-foreground">{user?.display_name || user?.username || '当前用户'}</span>
<span className="truncate text-xs font-normal text-muted-foreground">{user?.username || '未设置账号'}</span>
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onSelect={() => void logout()}>
<LogOut data-icon="inline-start" />
退出登录
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
)

View File

@@ -0,0 +1,47 @@
import { CheckCircle2, CircleAlert, Clock3 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import type { AgentStep } from '@/features/prototype/types'
const statusConfig = {
success: { label: '成功', variant: 'success' },
failed: { label: '失败', variant: 'danger' },
waiting: { label: '等待确认', variant: 'warning' }
} as const
const typeLabels = {
model: '模型',
retrieval: '知识检索',
skill: '技能',
tool: '工具',
approval: '人工确认'
} as const
export function AppTraceTimeline({ items }: { items: AgentStep[] }) {
return (
<ol className="grid gap-3">
{items.map((item) => {
const Icon = item.status === 'success' ? CheckCircle2 : item.status === 'failed' ? CircleAlert : Clock3
const status = statusConfig[item.status]
return (
<li key={item.id} className="grid grid-cols-[28px_minmax(0,1fr)] gap-3 rounded-md border border-border p-3">
<span className="flex size-7 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Icon className="size-4" />
</span>
<div className="grid gap-2">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium text-foreground">{item.step_name}</p>
<Badge variant="outline">{typeLabels[item.step_type]}</Badge>
<Badge variant={status.variant}>{status.label}</Badge>
</div>
<p className="text-sm text-muted-foreground">输入:{item.input_summary}</p>
<p className="text-sm text-foreground">输出:{item.output_summary}</p>
<p className="text-xs text-muted-foreground">耗时 {item.latency_ms} ms</p>
</div>
</li>
)
})}
</ol>
)
}

View File

@@ -0,0 +1,93 @@
import * as React from 'react'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppShell } from '@/app/layout/AppShell'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppSection } from '@/components/app/AppSection'
const logout = vi.fn()
vi.mock('@/features/auth/AuthProvider', () => ({
useAuth: () => ({
logout,
user: {
username: 'admin',
display_name: '平台管理员',
menus: [
{
id: 1,
code: 'ticket_management',
title: '工单管理',
path: '',
icon: 'ticket',
permission_code: '',
is_visible: true,
is_enabled: true,
children: [
{
id: 2,
code: 'tickets',
title: '工单中心',
path: '#/tickets',
icon: 'ticket',
permission_code: 'tickets.view',
is_visible: true,
is_enabled: true,
children: []
}
]
}
]
}
})
}))
beforeEach(() => {
window.location.hash = '#/tickets'
window.innerWidth = 1280
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})
describe('企业后台 UI 底座', () => {
it('使用 shadcn 侧栏骨架并显示当前中文页面', () => {
const { container } = render(
<AppShell>
<div>业务内容</div>
</AppShell>
)
expect(container.querySelector('[data-slot="sidebar-wrapper"]')).toBeInTheDocument()
expect(container.querySelector('[data-slot="sidebar-trigger"]')).toHaveAccessibleName('切换侧边栏')
expect(container.querySelector('[data-slot="app-topbar"]')).toHaveTextContent('工单中心')
expect(screen.getByRole('link', { name: '工单中心' })).toHaveAttribute('href', '#/tickets')
})
it('页面标题和普通分区保持无卡片化层级', () => {
const { container } = render(
<>
<AppPageHeader title="工单中心" actions={<button>新建工单</button>} />
<AppSection title="基础信息">
<p>字段内容</p>
</AppSection>
</>
)
const pageHeader = container.querySelector('[data-slot="app-page-header"]')
const section = container.querySelector('[data-slot="app-section"]')
expect(pageHeader).toBeInTheDocument()
expect(pageHeader).not.toHaveClass('shadow-sm')
expect(section?.tagName).toBe('SECTION')
expect(section?.querySelector('[data-slot="card"]')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,53 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { AppApprovalPanel } from './AppApprovalPanel'
import { AppMetricCard } from './AppMetricCard'
import { AppPrototypeNotice } from './AppPrototypeNotice'
import { AppReferenceCard } from './AppReferenceCard'
import { AppRiskBadge } from './AppRiskBadge'
import { AppTraceTimeline } from './AppTraceTimeline'
describe('Stage A 共享组件', () => {
it('明确标识演示数据', () => {
render(<AppPrototypeNotice />)
expect(screen.getByText('当前页面使用演示数据')).toBeInTheDocument()
})
it('展示指标、风险、引用和执行步骤', () => {
render(
<>
<AppMetricCard label="待处理工单" value="18" change="较昨日增加 3 单" tone="warning" />
<AppRiskBadge risk="high" />
<AppReferenceCard
title="排查步骤"
sourceType="knowledge_item"
content="检查任务状态"
score={0.93}
sourceLabel="客户支持知识库"
/>
<AppTraceTimeline
items={[
{
id: 1,
step_name: '检索知识',
step_type: 'retrieval',
status: 'success',
input_summary: '问题',
output_summary: '命中 2 条',
latency_ms: 180
}
]}
/>
<AppApprovalPanel title="等待人工采纳" description="确认后才能写入业务数据。" state="waiting" />
</>
)
expect(screen.getByText('待处理工单')).toBeInTheDocument()
expect(screen.getByText('高风险')).toBeInTheDocument()
expect(screen.getByText('匹配度 93%')).toBeInTheDocument()
expect(screen.getByText('检索知识')).toBeInTheDocument()
expect(screen.getByText('等待人工采纳')).toBeInTheDocument()
})
})