chore: preserve frontend concept lab
This commit is contained in:
34
frontend/src/components/app/AppApprovalPanel.tsx
Normal file
34
frontend/src/components/app/AppApprovalPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
frontend/src/components/app/AppMetricCard.tsx
Normal file
34
frontend/src/components/app/AppMetricCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
13
frontend/src/components/app/AppPrototypeNotice.tsx
Normal file
13
frontend/src/components/app/AppPrototypeNotice.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
frontend/src/components/app/AppReferenceCard.tsx
Normal file
30
frontend/src/components/app/AppReferenceCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
frontend/src/components/app/AppRiskBadge.tsx
Normal file
14
frontend/src/components/app/AppRiskBadge.tsx
Normal 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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
47
frontend/src/components/app/AppTraceTimeline.tsx
Normal file
47
frontend/src/components/app/AppTraceTimeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
frontend/src/components/app/app-foundation.test.tsx
Normal file
93
frontend/src/components/app/app-foundation.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
53
frontend/src/components/app/prototype-components.test.tsx
Normal file
53
frontend/src/components/app/prototype-components.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user