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

28
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{
ignores: ['dist', 'node_modules']
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
globals: {
...globals.browser
}
},
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_'
}
]
}
}
)

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"typecheck": "tsc -b", "typecheck": "tsc -b",
"lint": "eslint src --ext ts,tsx", "lint": "eslint src",
"test": "vitest", "test": "vitest",
"test:run": "vitest run" "test:run": "vitest run"
}, },
@@ -21,7 +21,6 @@
"radix-ui": "^1.6.1", "radix-ui": "^1.6.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"shadcn": "^4.12.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^2.5.0", "tailwind-merge": "^2.5.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -29,6 +28,7 @@
"vite": "^6.0.0" "vite": "^6.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.5",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@@ -38,10 +38,13 @@
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.16.0", "eslint": "^9.16.0",
"globals": "^16.5.0",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"shadcn": "^4.12.0",
"tailwindcss": "^3.4.16", "tailwindcss": "^3.4.16",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"typescript-eslint": "^8.63.0",
"vitest": "^3.2.7" "vitest": "^3.2.7"
} }
} }

View File

@@ -4,12 +4,15 @@ import { AppThemeProvider } from '@/components/app/AppThemeProvider'
import { AuthProvider, useAuth } from '@/features/auth/AuthProvider' import { AuthProvider, useAuth } from '@/features/auth/AuthProvider'
import { LoginPage } from '@/features/auth/LoginPage' import { LoginPage } from '@/features/auth/LoginPage'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Toaster } from '@/components/ui/sonner'
import { ConceptPreviewRouter } from '@/features/workbench/WorkbenchConceptGallery'
export function App() { export function App() {
return ( return (
<AppThemeProvider> <AppThemeProvider>
<AuthProvider> <AuthProvider>
<AppContent /> <AppContent />
<Toaster position="top-right" richColors />
</AuthProvider> </AuthProvider>
</AppThemeProvider> </AppThemeProvider>
) )
@@ -18,6 +21,10 @@ export function App() {
function AppContent() { function AppContent() {
const { authenticated, loading } = useAuth() const { authenticated, loading } = useAuth()
if (window.location.hash.replace(/^#/, '').startsWith('/concept')) {
return <ConceptPreviewRouter />
}
if (loading) { if (loading) {
return ( return (
<main className="flex min-h-screen items-center justify-center bg-background px-5"> <main className="flex min-h-screen items-center justify-center bg-background px-5">

View File

@@ -1,22 +1,19 @@
import * as React from 'react'
import { AppSidebar } from '@/components/app/AppSidebar' import { AppSidebar } from '@/components/app/AppSidebar'
import { AppTopbar } from '@/components/app/AppTopbar' import { AppTopbar } from '@/components/app/AppTopbar'
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
type AppShellProps = { type AppShellProps = {
children: React.ReactNode children: React.ReactNode
} }
export function AppShell({ children }: AppShellProps) { export function AppShell({ children }: AppShellProps) {
const [mobileNavOpen, setMobileNavOpen] = React.useState(false)
return ( return (
<div className="min-h-screen bg-background text-foreground"> <SidebarProvider>
<AppSidebar mobileOpen={mobileNavOpen} onMobileOpenChange={setMobileNavOpen} /> <AppSidebar />
<div className="min-h-screen lg:pl-72"> <SidebarInset className="min-w-0 bg-background text-foreground">
<AppTopbar onMobileMenuClick={() => setMobileNavOpen(true)} /> <AppTopbar />
{children} {children}
</div> </SidebarInset>
</div> </SidebarProvider>
) )
} }

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) { export function AppPageHeader({ title, description, meta, actions }: AppPageHeaderProps) {
return ( return (
<div className="rounded-lg border border-border/80 bg-surface px-4 py-4 shadow-sm ring-1 ring-border/40"> <header data-slot="app-page-header" className="border-b border-border/80 pb-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0"> <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"> <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} {meta}
</div> </div>
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null} {description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
</div> </div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null} {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div> </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 type * as React from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Separator } from '@/components/ui/separator'
type AppSectionProps = { type AppSectionProps = {
title: string title: string
@@ -11,15 +11,16 @@ type AppSectionProps = {
export function AppSection({ title, description, action, children }: AppSectionProps) { export function AppSection({ title, description, action, children }: AppSectionProps) {
return ( return (
<Card> <section data-slot="app-section" className="grid gap-3">
<CardHeader className="flex flex-row items-start justify-between gap-3 border-b border-border/70 pb-3"> <div className="flex min-h-8 items-start justify-between gap-3">
<div> <div className="grid gap-1">
<CardTitle className="text-sm">{title}</CardTitle> <h2 className="text-sm font-semibold text-foreground">{title}</h2>
{description ? <CardDescription>{description}</CardDescription> : null} {description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div> </div>
{action} {action}
</CardHeader> </div>
<CardContent className="pt-0">{children}</CardContent> <Separator />
</Card> <div>{children}</div>
</section>
) )
} }

View File

@@ -15,13 +15,28 @@ import {
Ticket, Ticket,
UserCog, UserCog,
Users, Users,
UsersRound UsersRound,
Workflow
} from 'lucide-react' } 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 { useAuth } from '@/features/auth/AuthProvider'
import type { AuthMenu } from '@/features/auth/types' import type { AuthMenu } from '@/features/auth/types'
import { cn } from '@/lib/utils'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
const iconMap = { const iconMap = {
audit: ScrollText, audit: ScrollText,
@@ -102,62 +117,41 @@ type SidebarNavContentProps = {
onNavigate?: () => void 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) { function SidebarNavContent({ groups, currentHash, onNavigate }: SidebarNavContentProps) {
return ( return (
<nav className="flex flex-col gap-5 p-4"> <>
{groups.map((group) => ( {groups.map((group) => (
<div key={group.code} className="grid gap-1.5"> <SidebarGroup key={group.code}>
<p className="px-2 text-xs font-medium text-sidebar-foreground/50">{group.title}</p> <SidebarGroupLabel>{group.title}</SidebarGroupLabel>
<div className="grid gap-1"> <SidebarGroupContent>
<SidebarMenu>
{visibleChildren(group).map((item) => { {visibleChildren(group).map((item) => {
const Icon = getIcon(item.icon) 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 ( return (
<a <SidebarMenuItem key={item.code}>
className={cn( <SidebarMenuButton asChild isActive={active} size="lg" tooltip={item.title}>
'flex h-9 items-center gap-2 rounded-md px-2.5 text-sm text-sidebar-foreground/70 transition-colors', <a href={item.path} onClick={onNavigate}>
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', <Icon />
active && 'bg-sidebar-primary text-sidebar-primary-foreground shadow-sm' <span>{item.title}</span>
)} </a>
href={item.path} </SidebarMenuButton>
key={item.code} </SidebarMenuItem>
onClick={onNavigate}
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{item.title}</span>
</a>
) )
})} })}
</div> </SidebarMenu>
</div> </SidebarGroupContent>
</SidebarGroup>
))} ))}
</nav> </>
) )
} }
type AppSidebarProps = { export function AppSidebar() {
mobileOpen?: boolean
onMobileOpenChange?: (open: boolean) => void
}
export function AppSidebar({ mobileOpen = false, onMobileOpenChange }: AppSidebarProps) {
const { user } = useAuth() const { user } = useAuth()
const { setOpenMobile } = useSidebar()
const currentHash = useCurrentHash() const currentHash = useCurrentHash()
const menuGroups = (user?.menus ?? []).filter((menu) => menu.is_visible && menu.is_enabled) const menuGroups = (user?.menus ?? []).filter((menu) => menu.is_visible && menu.is_enabled)
const hasKnowledgeMenu = menuGroups.some( const hasKnowledgeMenu = menuGroups.some(
@@ -166,24 +160,42 @@ export function AppSidebar({ mobileOpen = false, onMobileOpenChange }: AppSideba
const groups = hasKnowledgeMenu ? menuGroups : [...menuGroups, knowledgeNavGroup] const groups = hasKnowledgeMenu ? menuGroups : [...menuGroups, knowledgeNavGroup]
return ( return (
<> <Sidebar collapsible="icon">
<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"> <SidebarHeader className="px-3 py-3">
<SidebarBrand /> <SidebarMenu>
<SidebarNavContent groups={groups} currentHash={currentHash} /> <SidebarMenuItem>
</aside> <SidebarMenuButton asChild size="lg" tooltip="AI 工单平台">
<Sheet open={mobileOpen} onOpenChange={onMobileOpenChange}> <a href="#/">
<SheetContent side="left" className="w-80 gap-0 border-sidebar-border bg-sidebar p-0 text-sidebar-foreground lg:hidden"> <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground">
<SheetHeader className="sr-only"> <Workflow />
<SheetTitle>移动端导航</SheetTitle> </span>
</SheetHeader> <span className="grid min-w-0 flex-1 text-left leading-tight">
<SidebarBrand /> <span className="truncate font-semibold">AI 工单平台</span>
<SidebarNavContent <span className="truncate text-xs text-sidebar-foreground/60">企业服务治理中台</span>
groups={groups} </span>
currentHash={currentHash} </a>
onNavigate={() => onMobileOpenChange?.(false)} </SidebarMenuButton>
/> </SidebarMenuItem>
</SheetContent> </SidebarMenu>
</Sheet> </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 { AppThemeToggle } from '@/components/app/AppThemeToggle'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' 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 { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { useAuth } from '@/features/auth/AuthProvider' import { useAuth } from '@/features/auth/AuthProvider'
type AppTopbarProps = { const routeTitles: Array<{ pattern: RegExp; title: string }> = [
onMobileMenuClick?: () => void { 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 { 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 ( 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"> <div className="flex min-w-0 items-center gap-2 text-sm">
<Button <SidebarTrigger className="-ml-1" aria-label="切换侧边栏" />
variant="ghost" <Separator orientation="vertical" className="mx-1 h-4" />
size="icon"
className="-ml-2 lg:hidden"
aria-label="打开导航"
onClick={onMobileMenuClick}
>
<Menu data-icon="inline-start" />
</Button>
<span className="hidden font-medium text-foreground sm:inline">企业服务治理中台</span> <span className="hidden font-medium text-foreground sm:inline">企业服务治理中台</span>
<ChevronRight className="hidden size-4 text-muted-foreground sm:block" /> <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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-1">
<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>
<AppThemeToggle /> <AppThemeToggle />
<Button variant="ghost" className="hidden gap-2 sm:inline-flex"> <DropdownMenu>
<UserRound data-icon="inline-start" /> <DropdownMenuTrigger asChild>
<span>{user?.display_name || user?.username || '当前用户'}</span> <Button variant="ghost" className="gap-2 px-2 sm:px-3">
</Button> <span className="flex size-7 items-center justify-center rounded-md bg-muted text-foreground">
<Button variant="outline" className="gap-2" onClick={() => void logout()}> <UserRound />
<LogOut data-icon="inline-start" /> </span>
<span className="hidden sm:inline">退出</span> <span className="hidden max-w-32 truncate sm:inline">{user?.display_name || user?.username || '当前用户'}</span>
</Button> <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> </div>
</header> </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()
})
})

View File

@@ -39,7 +39,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", "fixed inset-0 isolate z-50 bg-black/10 backdrop-blur-[2px] duration-100 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className className
)} )}
{...props} {...props}
@@ -61,7 +61,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", "fixed left-1/2 top-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className className
)} )}
{...props} {...props}
@@ -76,7 +76,7 @@ function DialogContent({
> >
<XIcon <XIcon
/> />
<span className="sr-only">Close</span> <span className="sr-only">关闭</span>
</Button> </Button>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
@@ -115,7 +115,7 @@ function DialogFooter({
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close asChild> <DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button> <Button variant="outline">关闭</Button>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</div> </div>

View File

@@ -43,7 +43,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
align={align} align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )} className={cn("z-50 max-h-[--radix-dropdown-menu-content-available-height] w-[--radix-dropdown-menu-trigger-width] min-w-32 origin-[--radix-dropdown-menu-content-transform-origin] overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", className )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
@@ -73,7 +73,7 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive", "group/dropdown-menu-item relative flex cursor-default select-none items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
@@ -95,7 +95,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative flex cursor-default select-none items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-7 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
checked={checked} checked={checked}
@@ -139,7 +139,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative flex cursor-default select-none items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-7 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
@@ -170,7 +170,7 @@ function DropdownMenuLabel({
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7", "px-1.5 py-1 text-xs font-medium text-muted-foreground data-[inset]:pl-7",
className className
)} )}
{...props} {...props}
@@ -226,7 +226,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "flex cursor-default select-none items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-7 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
@@ -244,7 +244,7 @@ function DropdownMenuSubContent({
return ( return (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )} className={cn("z-50 min-w-[96px] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", className )}
{...props} {...props}
/> />
) )

View File

@@ -42,7 +42,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"flex h-8 w-fit select-none items-center justify-between gap-1.5 whitespace-nowrap rounded-md border border-input bg-transparent px-2.5 py-1 text-sm outline-none transition-colors focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50 data-placeholder:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0", "flex h-8 w-fit select-none items-center justify-between gap-1.5 whitespace-nowrap rounded-md border border-input bg-transparent px-2.5 py-1 text-sm outline-none transition-colors focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className
)} )}
{...props} {...props}
@@ -110,7 +110,7 @@ function SelectItem({
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"relative flex w-full cursor-default select-none items-center gap-1.5 rounded-md py-1 pl-1.5 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", "relative flex w-full cursor-default select-none items-center gap-1.5 rounded-md py-1 pl-1.5 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className
)} )}
{...props} {...props}

View File

@@ -15,7 +15,7 @@ function Separator({
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch", "shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
className className
)} )}
{...props} {...props}

View File

@@ -35,7 +35,7 @@ function SheetOverlay({
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", "fixed inset-0 z-50 bg-black/10 backdrop-blur-[2px] duration-100 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className className
)} )}
{...props} {...props}
@@ -60,7 +60,7 @@ function SheetContent({
data-slot="sheet-content" data-slot="sheet-content"
data-side={side} data-side={side}
className={cn( className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10", "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[side=bottom]:data-[state=open]:slide-in-from-bottom-10 data-[side=left]:data-[state=open]:slide-in-from-left-10 data-[side=right]:data-[state=open]:slide-in-from-right-10 data-[side=top]:data-[state=open]:slide-in-from-top-10 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[side=bottom]:data-[state=closed]:slide-out-to-bottom-10 data-[side=left]:data-[state=closed]:slide-out-to-left-10 data-[side=right]:data-[state=closed]:slide-out-to-right-10 data-[side=top]:data-[state=closed]:slide-out-to-top-10",
className className
)} )}
{...props} {...props}
@@ -75,7 +75,7 @@ function SheetContent({
> >
<XIcon <XIcon
/> />
<span className="sr-only">Close</span> <span className="sr-only">关闭</span>
</Button> </Button>
</SheetPrimitive.Close> </SheetPrimitive.Close>
)} )}

View File

@@ -18,6 +18,7 @@ import { Skeleton } from "@/components/ui/skeleton"
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react" import { PanelLeftIcon } from "lucide-react"
@@ -125,23 +126,25 @@ function SidebarProvider({
return ( return (
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
<div <TooltipProvider delayDuration={0}>
data-slot="sidebar-wrapper" <div
style={ data-slot="sidebar-wrapper"
{ style={
"--sidebar-width": SIDEBAR_WIDTH, {
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON, "--sidebar-width": SIDEBAR_WIDTH,
...style, "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
} as React.CSSProperties ...style,
} } as React.CSSProperties
className={cn( }
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", className={cn(
className "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
)} className
{...props} )}
> {...props}
{children} >
</div> {children}
</div>
</TooltipProvider>
</SidebarContext.Provider> </SidebarContext.Provider>
) )
} }
@@ -166,7 +169,7 @@ function Sidebar({
<div <div
data-slot="sidebar" data-slot="sidebar"
className={cn( className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground", "flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className className
)} )}
{...props} {...props}
@@ -184,7 +187,7 @@ function Sidebar({
data-sidebar="sidebar" data-sidebar="sidebar"
data-slot="sidebar" data-slot="sidebar"
data-mobile="true" data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={ style={
{ {
"--sidebar-width": SIDEBAR_WIDTH_MOBILE, "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
@@ -193,8 +196,8 @@ function Sidebar({
side={side} side={side}
> >
<SheetHeader className="sr-only"> <SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle> <SheetTitle>侧边栏导航</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription> <SheetDescription>显示移动端功能导航。</SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div> <div className="flex h-full w-full flex-col">{children}</div>
</SheetContent> </SheetContent>
@@ -215,23 +218,23 @@ function Sidebar({
<div <div
data-slot="sidebar-gap" data-slot="sidebar-gap"
className={cn( className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear", "relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0", "group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180", "group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+1rem)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)" : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)} )}
/> />
<div <div
data-slot="sidebar-container" data-slot="sidebar-container"
data-side={side} data-side={side}
className={cn( className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex", "fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants. // Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+1rem+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", : "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className className
)} )}
{...props} {...props}
@@ -269,7 +272,7 @@ function SidebarTrigger({
{...props} {...props}
> >
<PanelLeftIcon /> <PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">切换侧边栏</span>
</Button> </Button>
) )
} }
@@ -281,10 +284,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
<button <button
data-sidebar="rail" data-sidebar="rail"
data-slot="sidebar-rail" data-slot="sidebar-rail"
aria-label="Toggle Sidebar" aria-label="切换侧边栏"
tabIndex={-1} tabIndex={-1}
onClick={toggleSidebar} onClick={toggleSidebar}
title="Toggle Sidebar" title="切换侧边栏"
className={cn( className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2", "absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
@@ -399,7 +402,7 @@ function SidebarGroupLabel({
data-slot="sidebar-group-label" data-slot="sidebar-group-label"
data-sidebar="group-label" data-sidebar="group-label"
className={cn( className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-none transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className className
)} )}
{...props} {...props}
@@ -419,7 +422,7 @@ function SidebarGroupAction({
data-slot="sidebar-group-action" data-slot="sidebar-group-action"
data-sidebar="group-action" data-sidebar="group-action"
className={cn( className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-none transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className className
)} )}
{...props} {...props}
@@ -464,7 +467,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
} }
const sidebarMenuButtonVariants = cva( const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate", "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-none transition-[width,height,padding] group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{ {
variants: { variants: {
variant: { variant: {
@@ -475,7 +478,7 @@ const sidebarMenuButtonVariants = cva(
size: { size: {
default: "h-8 text-sm", default: "h-8 text-sm",
sm: "h-7 text-xs", sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -551,7 +554,7 @@ function SidebarMenuAction({
data-slot="sidebar-menu-action" data-slot="sidebar-menu-action"
data-sidebar="menu-action" data-sidebar="menu-action"
className={cn( className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-none transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover && showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0", "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className className
@@ -604,7 +607,7 @@ function SidebarMenuSkeleton({
/> />
)} )}
<Skeleton <Skeleton
className="h-4 max-w-(--skeleton-width) flex-1" className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text" data-sidebar="menu-skeleton-text"
style={ style={
{ {
@@ -664,7 +667,7 @@ function SidebarMenuSubButton({
data-size={size} data-size={size}
data-active={isActive} data-active={isActive}
className={cn( className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-none group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className className
)} )}
{...props} {...props}

View File

@@ -16,7 +16,7 @@ function Tabs({
data-slot="tabs" data-slot="tabs"
data-orientation={orientation} data-orientation={orientation}
className={cn( className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col", "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className className
)} )}
{...props} {...props}
@@ -25,7 +25,7 @@ function Tabs({
} }
const tabsListVariants = cva( const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{ {
variants: { variants: {
variant: { variant: {
@@ -63,10 +63,10 @@ function TabsTrigger({
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground", "data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className className
)} )}
{...props} {...props}

View File

@@ -40,7 +40,7 @@ function TooltipContent({
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", "z-50 inline-flex w-fit max-w-xs origin-[--radix-tooltip-content-transform-origin] items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className className
)} )}
{...props} {...props}

View File

@@ -233,7 +233,6 @@ export function TicketListPage() {
<AppPage contentClassName="gap-5"> <AppPage contentClassName="gap-5">
<AppPageHeader <AppPageHeader
title="工单中心" title="工单中心"
description="集中查看、筛选和推进企业技术支持请求。"
meta={hasActiveFilters ? <Badge variant="info">已筛选</Badge> : <Badge variant="outline">全部范围</Badge>} meta={hasActiveFilters ? <Badge variant="info">已筛选</Badge> : <Badge variant="outline">全部范围</Badge>}
actions={ actions={
<Button onClick={() => (window.location.hash = '/tickets/new')}> <Button onClick={() => (window.location.hash = '/tickets/new')}>

View File

@@ -0,0 +1,94 @@
import * as React from 'react'
import { AlertCircle, ArrowLeft, ArrowRight, Bell, Check, ChevronRight, CircleUserRound, Info, Palette, Plus, Search } from 'lucide-react'
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
type StyleKey = 'precision' | 'care' | 'graphite' | 'crystal'
const styles: Array<{ key: StyleKey; name: string; english: string; description: string; colors: string[] }> = [
{ key: 'precision', name: '精密企业', english: 'Precision Enterprise', description: '紧凑、理性、低装饰,适合高频业务操作。', colors: ['#174A7E', '#DDEAF2', '#F5F7FA', '#1B2638'] },
{ key: 'care', name: '温润服务', english: 'Human Care', description: '亲和、柔软、低压力,适合客服长时间使用。', colors: ['#6750A4', '#DFF2EA', '#F7F5FB', '#292535'] },
{ key: 'graphite', name: '石墨焦点', english: 'Graphite Focus', description: '沉稳、高对比、聚焦内容,适合信息密集页面。', colors: ['#2F6BFF', '#263047', '#10141E', '#F2F5FC'] },
{ key: 'crystal', name: '晶透层级', english: 'Crystal Layers', description: '清透、现代、具有空间层级,适合产品化升级。', colors: ['#4C5BD4', '#BDE7F2', '#EEF3FF', '#202945'] },
]
export function ComponentStyleLab() {
const [style, setStyle] = React.useState<StyleKey>('precision')
const selected = styles.find((item) => item.key === style) ?? styles[0]
return (
<main className="component-lab min-h-screen px-4 py-5 text-foreground sm:px-6 lg:px-8" data-style={style}>
<div className="mx-auto flex max-w-[1480px] flex-col gap-5">
<header className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div><Button variant="ghost" size="sm" onClick={() => (window.location.hash = '/concepts')}><ArrowLeft data-icon="inline-start" /> 返回设计入口</Button><div className="mt-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-primary"><Palette /> Component style laboratory</div><h1 className="mt-2 text-3xl font-semibold tracking-[-0.04em]">常用组件风格实验室</h1><p className="mt-2 max-w-2xl text-sm text-muted-foreground">切换左侧风格,观察同一组业务组件在色彩、圆角、阴影、边框和信息密度上的变化。</p></div>
<div className="flex items-center gap-2"><Badge variant="secondary">当前:{selected.name}</Badge><Button><Plus data-icon="inline-start" /> 新建工单</Button></div>
</header>
<div className="grid gap-5 xl:grid-cols-[260px_minmax(0,1fr)]">
<aside className="grid h-fit gap-3 xl:sticky xl:top-5">
{styles.map((item) => (
<button className={style === item.key ? 'style-choice style-choice-active' : 'style-choice'} key={item.key} onClick={() => setStyle(item.key)} type="button">
<div className="flex items-center justify-between"><strong className="text-sm">{item.name}</strong>{style === item.key ? <Check /> : <ChevronRight />}</div>
<span className="mt-1 block text-left text-[10px] uppercase tracking-[0.12em] text-muted-foreground">{item.english}</span>
<p className="mt-3 text-left text-xs leading-5 text-muted-foreground">{item.description}</p>
<div className="mt-3 flex gap-1.5">{item.colors.map((color) => <span className="size-5 rounded-full border border-border" key={color} style={{ backgroundColor: color }} />)}</div>
</button>
))}
</aside>
<div className="grid gap-5">
<section className="style-hero grid gap-5 rounded-xl border border-border p-5 md:grid-cols-[1fr_auto] md:items-center">
<div><span className="text-xs font-medium text-muted-foreground">STYLE 0{styles.findIndex((item) => item.key === style) + 1}</span><h2 className="mt-2 text-2xl font-semibold tracking-[-0.035em]">{selected.name}</h2><p className="mt-2 text-sm text-muted-foreground">{selected.description}</p></div>
<div className="flex flex-wrap gap-2"><Button variant="outline">次要操作</Button><Button variant="secondary">辅助操作</Button><Button>主要操作 <ArrowRight data-icon="inline-end" /></Button></div>
</section>
<section className="grid gap-5 lg:grid-cols-2">
<Card>
<CardHeader><CardTitle>按钮与状态</CardTitle><CardDescription>业务操作层级、状态标签和图标按钮</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-5">
<div className="flex flex-wrap gap-2"><Button>创建工单</Button><Button variant="secondary">保存草稿</Button><Button variant="outline">关联方案</Button><Button variant="ghost">取消</Button><Button variant="destructive">关闭工单</Button><Button size="icon" variant="outline" aria-label="通知"><Bell /></Button></div>
<div className="flex flex-wrap gap-2"><Badge>处理中</Badge><Badge variant="secondary">业务问题</Badge><Badge variant="success">已解决</Badge><Badge variant="warning">待回复</Badge><Badge variant="destructive">紧急</Badge><Badge variant="outline">普通</Badge></div>
<Alert><Info /><AlertTitle>任务查询完成</AlertTitle><AlertDescription>TASK-8821 仍在采集中,最后更新时间为 10:14。</AlertDescription><AlertAction><Button size="xs" variant="outline">查看</Button></AlertAction></Alert>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>表单控件</CardTitle><CardDescription>输入、选择、复选与开关状态</CardDescription></CardHeader>
<CardContent><FieldGroup>
<div className="grid gap-4 sm:grid-cols-2"><Field><FieldLabel htmlFor="lab-customer">提出机构</FieldLabel><Input id="lab-customer" defaultValue="深圳海纳科技有限公司" /></Field><Field><FieldLabel htmlFor="lab-category">问题分类</FieldLabel><Select defaultValue="business"><SelectTrigger className="w-full" id="lab-category"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="business">业务问题</SelectItem><SelectItem value="operation">操作问题</SelectItem><SelectItem value="data">数据问题</SelectItem></SelectGroup></SelectContent></Select></Field></div>
<Field><FieldLabel htmlFor="lab-description">问题描述</FieldLabel><Textarea id="lab-description" defaultValue="客户反馈采集任务长时间没有进度变化。" /></Field>
<div className="grid gap-3 sm:grid-cols-2"><Field orientation="horizontal"><Checkbox defaultChecked id="lab-follow" /><FieldContent><FieldLabel htmlFor="lab-follow">持续跟进</FieldLabel><FieldDescription>进度变化时提醒我</FieldDescription></FieldContent></Field><Field orientation="horizontal"><Switch defaultChecked id="lab-ai" /><FieldContent><FieldLabel htmlFor="lab-ai">AI 辅助</FieldLabel><FieldDescription>推荐相似解决方案</FieldDescription></FieldContent></Field></div>
</FieldGroup></CardContent>
</Card>
</section>
<section className="grid gap-5 lg:grid-cols-[1.15fr_0.85fr]">
<Card className="gap-0 py-0">
<CardHeader className="border-b border-border px-5 py-4"><div className="flex items-center justify-between"><div><CardTitle>工单列表组件</CardTitle><CardDescription>同一内容在不同风格下的信息密度</CardDescription></div><Button size="sm" variant="outline"><Search data-icon="inline-start" /> 搜索</Button></div></CardHeader>
<CardContent className="px-0">{[
['查询申报数据采集进度','深圳海纳科技有限公司','待回复','TASK-8821'],['税局采集任务停留在初始化阶段','广州远望供应链有限公司','处理中','TASK-8794'],['财务人员登录生产环境失败','杭州云帆制造有限公司','待回复','账号 zhangqi'],
].map(([title,customer,status,object],index)=><button className="style-ticket-row grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-b border-border p-4 text-left last:border-0" key={title} type="button"><span className="style-avatar"><CircleUserRound /></span><span className="min-w-0"><strong className="block truncate text-sm">{title}</strong><span className="mt-1 block truncate text-xs text-muted-foreground">{customer} · {object}</span></span><span className="flex items-center gap-2"><Badge variant={index===1?'secondary':'warning'}>{status}</Badge><ChevronRight /></span></button>)}</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>导航与内容卡片</CardTitle><CardDescription>标签页、数据摘要与错误状态</CardDescription></CardHeader>
<CardContent><Tabs defaultValue="overview"><TabsList><TabsTrigger value="overview">概览</TabsTrigger><TabsTrigger value="timeline">时间线</TabsTrigger><TabsTrigger value="solution">解决方案</TabsTrigger></TabsList><TabsContent className="pt-4" value="overview"><div className="grid grid-cols-2 gap-3"><div className="style-stat"><span>待我处理</span><strong>12</strong></div><div className="style-stat"><span>今日解决</span><strong>18</strong></div></div></TabsContent><TabsContent className="pt-4" value="timeline"><p className="text-sm text-muted-foreground">09:52 客户提交问题 · 10:16 返回查询结果</p></TabsContent><TabsContent className="pt-4" value="solution"><p className="text-sm text-muted-foreground">匹配到 3 条可复用解决方案。</p></TabsContent></Tabs><Alert className="mt-4" variant="destructive"><AlertCircle /><AlertTitle>无法获取最新进度</AlertTitle><AlertDescription>上游服务暂时不可用,请稍后重试。</AlertDescription></Alert></CardContent>
<CardFooter className="justify-end"><Button variant="outline">关闭</Button><Button className="ml-2">继续处理</Button></CardFooter>
</Card>
</section>
</div>
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,77 @@
import { ArrowLeft, ArrowRight, Bot, Building2, Check, ChevronRight, CircleCheck, ClipboardList, History, Lightbulb, Link2, MessageSquareText, Paperclip, Save, Search, Send, Sparkles, UserRound } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldSet } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
function go(path: string) { window.location.hash = path }
function ScreenNav({ current, otherPath, otherLabel }: { current: string; otherPath: string; otherLabel: string }) {
return <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4"><Button variant="ghost" size="sm" onClick={() => go('/concepts')}><ArrowLeft data-icon="inline-start" /> 返回设计入口</Button><div className="flex items-center gap-2"><Badge variant="secondary">{current}</Badge><Button variant="outline" size="sm" onClick={() => go(otherPath)}>{otherLabel}<ArrowRight data-icon="inline-end" /></Button></div></div>
}
function Required() { return <span className="text-destructive">*</span> }
export function TicketCreateConcept() {
return (
<main className="ticket-screen min-h-screen px-4 py-5 text-foreground sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-[1380px] flex-col gap-5">
<ScreenNav current="核心页面 · 工单填写" otherPath="/concept/ticket-detail" otherLabel="查看工单详情" />
<header className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between"><div><p className="text-xs font-medium text-muted-foreground">客户问题记录</p><h1 className="mt-1 text-3xl font-semibold tracking-[-0.04em]">新建工单</h1><p className="mt-2 text-sm text-muted-foreground">记录客户遇到的问题,信息越完整,后续查询和解决越高效。</p></div><div className="flex items-center gap-2"><Button variant="outline"><Save data-icon="inline-start" /> 保存草稿</Button><Button><Check data-icon="inline-start" /> 创建工单</Button></div></header>
<div className="grid gap-5 xl:grid-cols-[210px_minmax(0,1fr)_320px]">
<Card className="h-fit"><CardHeader><CardTitle className="text-sm">填写进度</CardTitle><CardDescription>已完成 2 / 4</CardDescription></CardHeader><CardContent className="flex flex-col gap-1">{[
{ icon: CircleCheck, label: '问题分类', state: '已完成' }, { icon: CircleCheck, label: '客户与对象', state: '已完成' }, { icon: ClipboardList, label: '问题描述', state: '正在填写' }, { icon: Paperclip, label: '补充信息', state: '可选' },
].map((step,index)=><div className={index===2?'ticket-step ticket-step-active':'ticket-step'} key={step.label}><span className="ticket-step-icon"><step.icon /></span><span><strong className="block text-xs">{step.label}</strong><span className="text-[11px] text-muted-foreground">{step.state}</span></span></div>)}</CardContent></Card>
<form className="flex flex-col gap-5">
<Card><CardHeader><CardTitle className="flex items-center gap-2"><span className="ticket-section-no">01</span> 问题分类</CardTitle><CardDescription>分类会影响工单流程和后续推荐的处理方案。</CardDescription></CardHeader><CardContent><FieldSet><FieldGroup className="grid gap-4 md:grid-cols-2">
<Field><FieldLabel htmlFor="create-category">问题分类 <Required /></FieldLabel><Select defaultValue="business"><SelectTrigger className="w-full" id="create-category"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="business">业务问题</SelectItem><SelectItem value="operation">操作问题</SelectItem><SelectItem value="data">数据问题</SelectItem><SelectItem value="technical">技术问题</SelectItem></SelectGroup></SelectContent></Select><FieldDescription>采集进度、业务规则或状态确认</FieldDescription></Field>
<Field><FieldLabel htmlFor="create-priority">优先级 <Required /></FieldLabel><Select defaultValue="normal"><SelectTrigger className="w-full" id="create-priority"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="low">低</SelectItem><SelectItem value="normal">普通</SelectItem><SelectItem value="high">高</SelectItem><SelectItem value="urgent">紧急</SelectItem></SelectGroup></SelectContent></Select></Field>
</FieldGroup></FieldSet></CardContent></Card>
<Card><CardHeader><CardTitle className="flex items-center gap-2"><span className="ticket-section-no">02</span> 客户与问题对象</CardTitle><CardDescription>明确是哪家客户、哪个任务或账号遇到了问题。</CardDescription></CardHeader><CardContent><FieldSet><FieldGroup>
<div className="grid gap-4 md:grid-cols-2"><Field><FieldLabel htmlFor="create-org">提出机构 <Required /></FieldLabel><Input id="create-org" defaultValue="深圳海纳科技有限公司" /></Field><Field><FieldLabel htmlFor="create-person">提出人 <Required /></FieldLabel><Input id="create-person" defaultValue="李经理" /></Field></div>
<div className="grid gap-4 md:grid-cols-2"><Field><FieldLabel htmlFor="create-object">问题对象 <Required /></FieldLabel><Input id="create-object" defaultValue="TASK-8821" /><FieldDescription>客户名称、账号、机构或任务编号</FieldDescription></Field><Field><FieldLabel htmlFor="create-env">环境 <Required /></FieldLabel><Select defaultValue="production"><SelectTrigger className="w-full" id="create-env"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="production">生产环境</SelectItem><SelectItem value="test">测试环境</SelectItem><SelectItem value="unknown">待确认</SelectItem></SelectGroup></SelectContent></Select></Field></div>
</FieldGroup></FieldSet></CardContent></Card>
<Card className="ticket-current-section"><CardHeader><CardTitle className="flex items-center gap-2"><span className="ticket-section-no">03</span> 问题描述</CardTitle><CardDescription>使用客户能理解的语言描述问题表现,不需要先判断技术原因。</CardDescription></CardHeader><CardContent><FieldSet><FieldGroup>
<Field><FieldLabel htmlFor="create-title">工单标题 <Required /></FieldLabel><Input id="create-title" defaultValue="查询申报数据采集进度" /></Field>
<Field><FieldLabel htmlFor="create-performance">问题表现 <Required /></FieldLabel><Textarea className="min-h-28" id="create-performance" defaultValue="客户反馈 TASK-8821 从今天早上开始一直显示“采集中”,长时间没有进度变化,希望确认目前处理到哪一步。" /><FieldDescription>建议写明客户看到的状态、发生时间和期望确认的内容。</FieldDescription></Field>
<Field><FieldLabel htmlFor="create-scope">影响范围</FieldLabel><Input id="create-scope" defaultValue="单个客户,影响本期申报数据获取" /></Field>
</FieldGroup></FieldSet></CardContent></Card>
<Card><CardHeader><CardTitle className="flex items-center gap-2"><span className="ticket-section-no">04</span> 补充信息</CardTitle><CardDescription>添加截图、错误信息或客户提供的其他材料。</CardDescription></CardHeader><CardContent><button className="ticket-upload flex min-h-28 w-full flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border text-sm text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring" type="button"><Paperclip /><span>拖放附件到这里,或点击选择文件</span><span className="text-xs">支持截图、日志和文档,单个文件不超过 20 MB</span></button></CardContent><CardFooter className="justify-between"><span className="text-xs text-muted-foreground">必填信息已完整,可以创建工单。</span><Button><Check data-icon="inline-start" /> 创建工单</Button></CardFooter></Card>
</form>
<div className="grid h-fit gap-4 xl:sticky xl:top-5"><Card className="ticket-summary-card"><CardHeader><CardTitle>工单摘要</CardTitle><CardDescription>创建后将按以下信息进入处理队列</CardDescription></CardHeader><CardContent className="flex flex-col gap-4"><div><Badge>业务问题</Badge><Badge className="ml-2" variant="secondary">普通</Badge></div><div><strong className="text-sm">查询申报数据采集进度</strong><p className="mt-2 text-xs leading-5 text-muted-foreground">深圳海纳科技有限公司 · 李经理</p></div><div className="grid grid-cols-2 gap-3">{[['问题对象','TASK-8821'],['环境','生产环境']].map(([label,value])=><div className="rounded-lg bg-background/80 p-3" key={label}><span className="text-[11px] text-muted-foreground">{label}</span><p className="mt-1 text-xs font-medium">{value}</p></div>)}</div></CardContent></Card><Card><CardHeader><div className="flex items-center gap-2 text-xs font-semibold text-primary"><Sparkles /> AI 建单辅助</div><CardTitle className="text-sm">已识别到任务编号</CardTitle><CardDescription>TASK-8821 可以在创建工单后直接查询采集进度。</CardDescription></CardHeader><CardContent><Button className="w-full" variant="outline"><Search data-icon="inline-start" /> 预先查询任务</Button></CardContent></Card></div>
</div>
</div>
</main>
)
}
export function TicketDetailConcept() {
return (
<main className="ticket-screen min-h-screen px-4 py-5 text-foreground sm:px-6 lg:px-8"><div className="mx-auto flex max-w-[1440px] flex-col gap-5">
<ScreenNav current="核心页面 · 工单详情" otherPath="/concept/ticket-create" otherLabel="查看工单填写" />
<header className="grid gap-4 lg:grid-cols-[1fr_auto] lg:items-end"><div><div className="mb-3 flex flex-wrap items-center gap-2"><Badge variant="secondary">业务问题</Badge><Badge variant="destructive">待回复</Badge><span className="text-xs text-muted-foreground">T202607130042 · 创建于 09:52</span></div><h1 className="text-3xl font-semibold tracking-[-0.04em]">查询申报数据采集进度</h1><p className="mt-2 text-sm text-muted-foreground">深圳海纳科技有限公司 · TASK-8821 · 生产环境</p></div><div className="flex flex-wrap gap-2"><Button variant="outline"><Link2 data-icon="inline-start" /> 关联解决方案</Button><Button><Send data-icon="inline-start" /> 回复客户</Button></div></header>
<section className="ticket-status-strip grid overflow-hidden rounded-xl border border-border bg-card sm:grid-cols-4">{[
{label:'已创建',time:'09:52',done:true},{label:'处理中',time:'10:02',done:true},{label:'等待回复客户',time:'当前',done:true},{label:'客户确认 / 关闭',time:'待完成',done:false},
].map((item,index)=><div className="flex items-center gap-3 border-b border-border p-4 last:border-0 sm:border-b-0 sm:border-r" key={item.label}><span className={item.done?'ticket-status-dot ticket-status-done':'ticket-status-dot'}>{item.done?<Check />:index+1}</span><span><strong className="block text-xs">{item.label}</strong><span className="text-[11px] text-muted-foreground">{item.time}</span></span></div>)}</section>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_340px]"><div className="grid gap-5">
<Card><CardHeader><CardTitle>问题概览</CardTitle><CardDescription>客户描述与当前查询结果</CardDescription></CardHeader><CardContent className="grid gap-5"><div className="grid gap-3 sm:grid-cols-3">{[['问题对象','TASK-8821'],['影响范围','单个客户'],['优先级','普通']].map(([label,value])=><div className="rounded-lg bg-muted p-3" key={label}><span className="text-[11px] text-muted-foreground">{label}</span><p className="mt-1 text-sm font-medium">{value}</p></div>)}</div><div><span className="text-xs font-medium">问题表现</span><p className="mt-2 text-sm leading-6">客户反馈任务从今天早上开始一直显示“采集中”,长时间没有进度变化,希望确认目前处理到哪一步。</p></div><div className="ticket-result rounded-xl border border-primary/20 p-4"><div className="flex items-center gap-2 text-xs font-semibold text-primary"><Bot /> 采集任务查询结果 · 10:16</div><p className="mt-3 text-sm leading-6">任务仍在执行,上游税局接口最近一次返回超时;最后有效更新时间为 10:14,当前无需人工重启。</p></div></CardContent></Card>
<Card><CardHeader><CardTitle>处理过程</CardTitle><CardDescription>客户沟通、内部处理和系统查询按时间记录</CardDescription></CardHeader><CardContent><Tabs defaultValue="timeline"><TabsList variant="line"><TabsTrigger value="timeline">时间线</TabsTrigger><TabsTrigger value="comments">内部评论</TabsTrigger><TabsTrigger value="attachments">附件 2</TabsTrigger></TabsList><TabsContent className="pt-5" value="timeline"><div className="ticket-timeline">{[
{icon:UserRound,who:'李经理 · 客户',time:'09:52',title:'提交客户问题',text:'TASK-8821 一直显示采集中,麻烦确认一下当前进度。'},
{icon:Search,who:'王工 · 客服',time:'10:02',title:'开始处理并查询任务',text:'已确认任务编号和生产环境,发起采集进度查询。'},
{icon:Bot,who:'采集任务查询服务',time:'10:16',title:'返回查询结果',text:'任务运行中,上游接口最近一次返回超时。'},
{icon:MessageSquareText,who:'系统建议',time:'10:18',title:'生成客户回复草稿',text:'建议说明上游接口状态,并承诺继续关注任务进度。'},
].map((event)=><div className="ticket-event" key={`${event.time}-${event.title}`}><span className="ticket-event-icon"><event.icon /></span><div><div className="flex flex-wrap items-center gap-2"><strong className="text-sm">{event.title}</strong><span className="text-xs text-muted-foreground">{event.who} · {event.time}</span></div><p className="mt-2 text-sm leading-6 text-muted-foreground">{event.text}</p></div></div>)}</div></TabsContent><TabsContent value="comments"><p className="py-8 text-center text-sm text-muted-foreground">暂无内部评论</p></TabsContent><TabsContent value="attachments"><p className="py-8 text-center text-sm text-muted-foreground">客户截图.png · 任务详情.pdf</p></TabsContent></Tabs></CardContent></Card>
<Card className="ticket-reply-card"><CardHeader><CardTitle>回复客户</CardTitle><CardDescription>内容会作为处理记录保存,并同步更新客户沟通状态。</CardDescription></CardHeader><CardContent><Textarea className="min-h-28 bg-background" defaultValue="您好,已确认 TASK-8821 仍在采集中。目前上游税局接口响应较慢,我们会继续关注任务进度,恢复后第一时间同步给您。" /></CardContent><CardFooter className="justify-between"><Button variant="ghost"><Sparkles data-icon="inline-start" /> 优化措辞</Button><div className="flex gap-2"><Button variant="outline">仅保存记录</Button><Button><Send data-icon="inline-start" /> 回复并继续跟进</Button></div></CardFooter></Card>
</div><aside className="grid h-fit gap-4 xl:sticky xl:top-5">
<Card><CardHeader><CardTitle className="flex items-center gap-2 text-sm"><Building2 /> 客户信息</CardTitle></CardHeader><CardContent className="flex flex-col gap-3"><strong className="text-sm">深圳海纳科技有限公司</strong><div className="flex items-center gap-2 text-xs text-muted-foreground"><UserRound /> 李经理 · 财务负责人</div><Badge className="w-fit" variant="secondary">重点客户</Badge><Button variant="outline" size="sm">查看该客户历史工单</Button></CardContent></Card>
<Card className="ticket-solution-card"><CardHeader><div className="flex items-center gap-2 text-xs font-semibold text-primary"><Lightbulb /> 推荐解决方案</div><CardTitle className="text-sm">采集任务长时间无进度的排查步骤</CardTitle><CardDescription>匹配度 93% · 已复用 28 次</CardDescription></CardHeader><CardContent><p className="text-xs leading-5 text-muted-foreground">先检查任务状态、上游接口响应和最近一次有效日志,再判断是否需要人工重试。</p></CardContent><CardFooter className="flex-col gap-2"><Button className="w-full"><Link2 data-icon="inline-start" /> 关联到当前工单</Button><Button className="w-full" variant="ghost">查看完整方案 <ChevronRight data-icon="inline-end" /></Button></CardFooter></Card>
<Card><CardHeader><CardTitle className="flex items-center gap-2 text-sm"><History /> 下一步</CardTitle></CardHeader><CardContent className="flex flex-col gap-2">{['回复客户当前进度','30 分钟后再次查询','任务恢复后填写解决结果'].map((item,index)=><div className="flex items-center gap-3 rounded-lg border border-border p-3 text-xs" key={item}><span className="flex size-5 items-center justify-center rounded bg-secondary">{index+1}</span>{item}</div>)}</CardContent></Card>
</aside></div>
</div></main>
)
}

View File

@@ -0,0 +1,171 @@
import * as React from 'react'
import {
ArrowLeft,
ArrowRight,
Bot,
Building2,
Check,
ChevronRight,
CircleUserRound,
Clock3,
FileText,
Filter,
Headphones,
History,
LayoutGrid,
Lightbulb,
Link2,
MessageSquareText,
Palette,
Plus,
Search,
Send,
Sparkles,
Tags,
UserRound,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { TicketCreateConcept, TicketDetailConcept } from './TicketScreenConcepts'
import { ComponentStyleLab } from './ComponentStyleLab'
const tickets = [
{ no: 'T202607130042', title: '查询申报数据采集进度', customer: '深圳海纳科技有限公司', object: 'TASK-8821', wait: '等待 24 分钟', status: '待回复', category: '业务问题' },
{ no: 'T202607130039', title: '税局采集任务停留在初始化阶段', customer: '广州远望供应链有限公司', object: 'TASK-8794', wait: '处理 46 分钟', status: '处理中', category: '数据问题' },
{ no: 'T202607130031', title: '财务人员登录生产环境失败', customer: '杭州云帆制造有限公司', object: '账号 zhangqi', wait: '等待 1 小时', status: '待回复', category: '操作问题' },
{ no: 'T202607130028', title: '确认本期申报状态是否成功', customer: '北京启明企业服务有限公司', object: '2026 年 6 月属期', wait: '处理 2 小时', status: '待确认', category: '业务问题' },
]
const solutions = [
{ title: '采集任务长时间无进度的排查步骤', score: '93%', source: '客户支持知识库', used: '已复用 28 次' },
{ title: '税局接口波动时的客户回复模板', score: '87%', source: '解决方案库', used: '已复用 41 次' },
{ title: '采集任务初始化失败处理指引', score: '82%', source: '历史工单', used: '已复用 16 次' },
]
function go(path: string) { window.location.hash = path }
function ConceptNav({ label }: { label: string }) {
return (
<div className="flex items-center justify-between border-b border-border pb-4">
<Button variant="ghost" size="sm" onClick={() => go('/concepts')}><ArrowLeft data-icon="inline-start" /> 三套设计方案</Button>
<Badge variant="secondary">{label}</Badge>
</div>
)
}
export function ConceptPreviewRouter() {
const [route, setRoute] = React.useState(() => window.location.hash.replace(/^#/, '') || '/concepts')
React.useEffect(() => {
const update = () => setRoute(window.location.hash.replace(/^#/, '') || '/concepts')
window.addEventListener('hashchange', update)
return () => window.removeEventListener('hashchange', update)
}, [])
if (route === '/concept') return <SupportDeskConcept />
if (route === '/concept/customer-case') return <CustomerCaseConcept />
if (route === '/concept/knowledge-assist') return <KnowledgeAssistConcept />
if (route === '/concept/ticket-create') return <TicketCreateConcept />
if (route === '/concept/ticket-detail') return <TicketDetailConcept />
if (route === '/concept/components') return <ComponentStyleLab />
return <ConceptGallery />
}
export function ConceptGallery() {
const concepts = [
{ key: 'A', title: '客服处理工作台', desc: '以我的待办、待回复和即将超时为中心,适合客服高频批量处理。', path: '/concept', className: 'support', tags: ['效率优先', '列表清晰', '推荐'] },
{ key: 'B', title: '客户问题工作区', desc: '把客户资料、问题描述、沟通记录和处理动作放在同一视野。', path: '/concept/customer-case', className: 'case', tags: ['上下文完整', '单案处理', '沟通导向'] },
{ key: 'C', title: '知识辅助工作台', desc: '突出相似工单、解决方案复用和 AI 客户回复辅助。', path: '/concept/knowledge-assist', className: 'knowledge', tags: ['知识复用', 'AI 辅助', '方案沉淀'] },
]
return (
<main className="support-gallery min-h-screen px-4 py-8 text-foreground sm:px-6 lg:px-8 lg:py-12">
<div className="mx-auto flex max-w-7xl flex-col gap-8">
<header className="border-b border-border pb-7">
<div className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-primary"><Headphones /> Customer support workspace</div>
<h1 className="text-4xl font-semibold tracking-[-0.05em] sm:text-5xl">客户问题工单 · 三套设计方向</h1>
<p className="mt-4 max-w-3xl text-sm leading-6 text-muted-foreground">围绕 B 端客户问题记录、业务查询、客户回复和解决方案复用设计,不再使用运维监控大盘表达。</p>
</header>
<section className="grid gap-5 lg:grid-cols-3">
{concepts.map((item) => (
<Card className="group gap-0 overflow-hidden py-0 transition-transform duration-300 hover:-translate-y-1" key={item.key}>
<button className="text-left outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={() => go(item.path)} type="button">
<div className={`support-preview support-preview-${item.className} m-2 h-52 rounded-lg border border-border p-4`}>
<div className="flex items-center justify-between text-[10px] font-semibold tracking-[0.16em]"><span>CONCEPT {item.key}</span><span className="size-2 rounded-full bg-current" /></div>
<div className="mt-5 h-4 w-3/5 rounded bg-current opacity-80" /><div className="mt-2 h-2 w-2/5 rounded bg-current opacity-25" />
<div className="mt-5 grid grid-cols-[0.8fr_1.2fr] gap-2"><div className="h-24 rounded border border-current/15 bg-current/10" /><div className="grid gap-2"><div className="rounded border border-current/15 bg-current/10" /><div className="rounded border border-current/15 bg-current/10" /><div className="rounded border border-current/15 bg-current/10" /></div></div>
</div>
<CardHeader className="px-5 pb-3 pt-4"><CardTitle className="flex items-center justify-between text-lg">{item.title}<ArrowRight className="transition-transform group-hover:translate-x-1" /></CardTitle><CardDescription className="min-h-10 leading-5">{item.desc}</CardDescription></CardHeader>
<CardContent className="flex flex-wrap gap-2 px-5 pb-5">{item.tags.map((tag) => <Badge key={tag} variant="secondary">{tag}</Badge>)}</CardContent>
</button>
</Card>
))}
</section>
<section className="grid gap-4 rounded-xl border border-border bg-card p-5 md:grid-cols-[1fr_auto_auto] md:items-center">
<div><h2 className="font-semibold">查看核心业务页面</h2><p className="mt-1 text-sm text-muted-foreground">工作台之外,继续查看完整的工单填写与工单详情设计。</p></div>
<Button variant="outline" onClick={() => go('/concept/ticket-create')}>工单填写 <ArrowRight data-icon="inline-end" /></Button>
<Button onClick={() => go('/concept/ticket-detail')}>工单详情 <ArrowRight data-icon="inline-end" /></Button>
</section>
<Button className="self-start" variant="outline" onClick={() => go('/concept/components')}><Palette data-icon="inline-start" /> 查看组件风格实验室</Button>
</div>
</main>
)
}
export function SupportDeskConcept() {
return (
<main className="support-desk min-h-screen px-4 py-5 text-foreground sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-[1440px] flex-col gap-5">
<ConceptNav label="A · 客服处理工作台" />
<header className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div><p className="text-xs font-medium text-muted-foreground">星期一 · 7 月 13 日</p><h1 className="mt-1 text-3xl font-semibold tracking-[-0.04em]">我的客服工作台</h1><p className="mt-2 text-sm text-muted-foreground">优先回复等待中的客户,再处理新进入的问题。</p></div>
<Button><Plus data-icon="inline-start" /> 记录客户问题</Button>
</header>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{[['待我处理','12','其中 5 项待回复'],['即将超时','3','最短剩余 18 分钟'],['今日已解决','18','业务问题占 72%'],['待客户确认','6','已发送处理结果']].map(([label,value,note], i) => (
<Card className={i === 0 ? 'support-focus-card' : ''} key={label}><CardContent><div className="flex items-center justify-between text-xs text-muted-foreground"><span>{label}</span>{i === 0 ? <CircleUserRound /> : <Clock3 />}</div><strong className="mt-4 block text-3xl tracking-[-0.04em]">{value}</strong><p className="mt-1 text-xs text-muted-foreground">{note}</p></CardContent></Card>
))}
</section>
<div className="grid gap-5 xl:grid-cols-[220px_minmax(0,1fr)]">
<Card className="h-fit"><CardHeader><CardTitle className="text-sm">处理视图</CardTitle></CardHeader><CardContent className="flex flex-col gap-1">{[['我的待办','12'],['待回复客户','5'],['业务问题','9'],['数据问题','4'],['待客户确认','6']].map(([label,count],i)=><Button className="justify-between" key={label} variant={i===0?'secondary':'ghost'}>{label}<Badge variant="secondary">{count}</Badge></Button>)}</CardContent></Card>
<Card className="gap-0 py-0"><CardHeader className="border-b border-border px-5 py-4"><div><CardTitle>优先处理</CardTitle><CardDescription>按等待客户回复时间和承诺时限排序</CardDescription></div><div className="mt-3 flex gap-2"><div className="relative flex-1"><Search className="absolute left-3 top-2.5 text-muted-foreground" /><Input className="pl-9" placeholder="搜索客户、问题、任务编号" /></div><Button variant="outline" size="icon"><Filter /></Button></div></CardHeader><CardContent className="px-0">{tickets.map((ticket,i)=><button className="grid w-full grid-cols-[minmax(0,1fr)_auto] gap-4 border-b border-border px-5 py-4 text-left transition-colors last:border-0 hover:bg-muted/60" key={ticket.no} type="button"><span className="min-w-0"><span className="flex flex-wrap items-center gap-2"><strong className="truncate text-sm">{ticket.title}</strong><Badge variant={i===0?'destructive':'secondary'}>{ticket.status}</Badge><Badge variant="outline">{ticket.category}</Badge></span><span className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"><span><Building2 /> {ticket.customer}</span><span>{ticket.object}</span><span>{ticket.no}</span></span></span><span className="flex items-center gap-3 text-xs text-muted-foreground"><span>{ticket.wait}</span><ChevronRight /></span></button>)}</CardContent></Card>
</div>
</div>
</main>
)
}
export function CustomerCaseConcept() {
return (
<main className="customer-case min-h-screen px-4 py-5 text-foreground sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-[1500px] flex-col gap-4">
<ConceptNav label="B · 客户问题工作区" />
<header className="flex items-center justify-between"><div><h1 className="text-2xl font-semibold tracking-[-0.03em]">客户问题工作区</h1><p className="mt-1 text-xs text-muted-foreground">当前处理 · 4 项</p></div><Button><Plus data-icon="inline-start" /> 新建工单</Button></header>
<div className="grid gap-4 xl:grid-cols-[300px_minmax(0,1fr)_330px]">
<Card className="gap-0 py-0"><CardHeader className="border-b border-border p-4"><div className="relative"><Search className="absolute left-3 top-2.5 text-muted-foreground" /><Input className="pl-9" placeholder="搜索当前问题" /></div></CardHeader><CardContent className="px-0">{tickets.slice(0,3).map((t,i)=><button className={`w-full border-b border-border p-4 text-left ${i===0?'case-selected':'hover:bg-muted/60'}`} key={t.no} type="button"><div className="flex items-start justify-between gap-2"><Badge variant={i===0?'destructive':'secondary'}>{t.status}</Badge><span className="text-[11px] text-muted-foreground">{t.wait}</span></div><strong className="mt-3 block text-sm leading-5">{t.title}</strong><span className="mt-2 block truncate text-xs text-muted-foreground">{t.customer}</span><span className="mt-1 block text-[11px] text-muted-foreground">{t.object}</span></button>)}</CardContent></Card>
<div className="grid gap-4">
<Card><CardHeader><div className="flex flex-wrap items-center gap-2"><Badge variant="destructive">待回复</Badge><Badge variant="outline">业务问题</Badge><span className="text-xs text-muted-foreground">T202607130042</span></div><CardTitle className="pt-2 text-xl">查询申报数据采集进度</CardTitle><CardDescription>客户反馈任务提交后已等待较长时间,希望确认当前处理阶段。</CardDescription></CardHeader><CardContent className="grid gap-3 sm:grid-cols-3">{[['问题对象','TASK-8821'],['环境','生产环境'],['影响范围','单个客户']].map(([l,v])=><div className="rounded-lg bg-muted p-3" key={l}><span className="text-[11px] text-muted-foreground">{l}</span><p className="mt-1 text-sm font-medium">{v}</p></div>)}</CardContent></Card>
<Card><CardHeader><CardTitle className="flex items-center gap-2 text-sm"><MessageSquareText /> 沟通与处理记录</CardTitle></CardHeader><CardContent className="flex flex-col gap-5"><div className="case-message"><span className="case-avatar"><UserRound /></span><div><div className="flex items-center gap-2 text-xs"><strong>李经理 · 客户</strong><span className="text-muted-foreground">09:52</span></div><p className="mt-2 rounded-r-xl rounded-bl-xl bg-muted p-3 text-sm leading-6">TASK-8821 从早上开始一直显示“采集中”,麻烦帮忙确认一下现在到哪一步了?</p></div></div><div className="case-message"><span className="case-avatar case-avatar-system"><Bot /></span><div><div className="flex items-center gap-2 text-xs"><strong>系统查询结果</strong><span className="text-muted-foreground">10:16</span></div><p className="mt-2 rounded-r-xl rounded-bl-xl border border-primary/20 bg-primary/5 p-3 text-sm leading-6">任务仍在执行,上游税局接口最近一次返回超时;最后有效更新时间为 10:14。</p></div></div><div className="rounded-xl border border-border p-3"><textarea className="min-h-20 w-full resize-none bg-transparent text-sm outline-none" defaultValue="您好,已确认任务仍在采集中,目前上游税局接口响应较慢,我们会继续关注任务进度并及时同步给您。" /><div className="mt-3 flex items-center justify-between"><Button variant="ghost" size="sm"><Sparkles data-icon="inline-start" /> 优化回复</Button><Button size="sm"><Send data-icon="inline-start" /> 回复客户</Button></div></div></CardContent></Card>
</div>
<div className="grid content-start gap-4"><Card><CardHeader><CardTitle className="flex items-center gap-2 text-sm"><Building2 /> 客户信息</CardTitle></CardHeader><CardContent className="flex flex-col gap-3 text-sm"><strong>深圳海纳科技有限公司</strong><span className="text-xs text-muted-foreground">联系人:李经理 · 财务负责人</span><span className="text-xs text-muted-foreground">客户等级:重点客户</span><Button variant="outline" size="sm">查看历史问题</Button></CardContent></Card><Card><CardHeader><CardTitle className="flex items-center gap-2 text-sm"><Lightbulb /> 建议下一步</CardTitle></CardHeader><CardContent className="flex flex-col gap-2">{['回复客户当前进度','30 分钟后再次查询','进度恢复后关闭工单'].map((x,i)=><button className="flex items-center gap-3 rounded-lg border border-border p-3 text-left text-xs hover:bg-muted" key={x} type="button"><span className="flex size-5 items-center justify-center rounded bg-secondary">{i+1}</span>{x}</button>)}</CardContent></Card></div>
</div>
</div>
</main>
)
}
export function KnowledgeAssistConcept() {
return (
<main className="knowledge-assist min-h-screen px-4 py-5 text-foreground sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-[1440px] flex-col gap-5">
<ConceptNav label="C · 知识辅助工作台" />
<header className="grid gap-4 lg:grid-cols-[1fr_auto] lg:items-end"><div><div className="mb-2 flex items-center gap-2 text-xs font-semibold text-primary"><Sparkles /> KNOWLEDGE ASSISTED SUPPORT</div><h1 className="text-3xl font-semibold tracking-[-0.04em]">先找到答案,再回复客户</h1><p className="mt-2 text-sm text-muted-foreground">从历史工单、解决方案和知识库中定位最合适的处理方式。</p></div><Button variant="outline" onClick={()=>go('/concepts')}><LayoutGrid data-icon="inline-start" /> 切换设计</Button></header>
<Card className="knowledge-query"><CardContent className="grid gap-3 sm:grid-cols-[1fr_auto]"><div className="relative"><Search className="absolute left-4 top-3.5 text-primary" /><Input className="h-11 bg-background pl-11 text-sm" defaultValue="客户 TASK-8821 采集进度长时间未更新,如何处理?" /></div><Button size="lg"><Sparkles data-icon="inline-start" /> 分析客户问题</Button></CardContent></Card>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.1fr)_minmax(360px,0.9fr)]">
<div className="grid gap-5"><Card><CardHeader><div className="flex items-center justify-between"><div><CardTitle>推荐处理依据</CardTitle><CardDescription>已检索 38 条内容,以下 3 条匹配度最高</CardDescription></div><Badge variant="secondary">耗时 0.8 秒</Badge></div></CardHeader><CardContent className="flex flex-col gap-3">{solutions.map((s,i)=><button className="group grid grid-cols-[auto_1fr_auto] items-start gap-3 rounded-xl border border-border p-4 text-left transition-all hover:border-primary/35 hover:shadow-sm" key={s.title} type="button"><span className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">{i===0?<FileText />:i===1?<MessageSquareText />:<History />}</span><span><strong className="text-sm">{s.title}</strong><span className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground"><span>{s.source}</span><span>·</span><span>{s.used}</span></span></span><Badge variant={i===0?'default':'secondary'}>{s.score}</Badge></button>)}</CardContent></Card><Card><CardHeader><CardTitle className="flex items-center gap-2"><Link2 /> 相似历史工单</CardTitle></CardHeader><CardContent className="grid gap-3 sm:grid-cols-2">{[['T202607030005','税局采集任务停留在初始化阶段','确认税局接口波动,任务恢复后回复客户并关闭。'],['T202607010002','查询申报数据采集进度','查询任务日志后确认仍在采集中。']].map(([no,t,r])=><div className="rounded-xl border border-border p-4" key={no}><span className="text-xs text-muted-foreground">{no}</span><strong className="mt-2 block text-sm">{t}</strong><p className="mt-3 text-xs leading-5 text-muted-foreground">处理结果:{r}</p></div>)}</CardContent></Card></div>
<Card className="knowledge-answer"><CardHeader><div className="flex items-center gap-2 text-xs font-semibold text-primary"><Bot /> AI 辅助处理草稿</div><CardTitle>建议处理方式</CardTitle><CardDescription>基于查询结果和已发布解决方案生成,请确认后使用。</CardDescription></CardHeader><CardContent className="flex flex-col gap-4"><div><span className="text-xs font-medium">排查步骤</span><ol className="mt-3 flex flex-col gap-3">{['查询 TASK-8821 当前状态和最近更新时间','检查最近一次上游接口响应与有效日志','若任务仍在运行,向客户说明并持续关注'].map((x,i)=><li className="flex gap-3 text-sm leading-5" key={x}><span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">{i+1}</span>{x}</li>)}</ol></div><div className="border-t border-border pt-4"><div className="flex items-center justify-between"><span className="text-xs font-medium">客户回复草稿</span><Badge variant="secondary">可编辑</Badge></div><p className="mt-3 rounded-xl bg-background p-4 text-sm leading-6">您好,已确认任务仍在采集中,目前上游税局接口响应较慢。我们会继续关注任务进度,恢复后第一时间同步给您。</p></div><div className="grid grid-cols-2 gap-2"><Button variant="outline"><Tags data-icon="inline-start" /> 关联方案</Button><Button><Check data-icon="inline-start" /> 采用并回复</Button></div><Button className="w-full" variant="ghost"><FileText data-icon="inline-start" /> 保存为解决方案草稿</Button></CardContent></Card>
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,176 @@
import {
Activity,
ArrowUpRight,
Bot,
CheckCircle2,
ChevronRight,
CircleDot,
Clock3,
Plus,
Radar,
ShieldAlert,
Sparkles,
Users,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
const queue = [
{ no: 'TK-20260713-042', title: '核心结算任务连续失败', team: '资金清算组', age: '12 分钟', tone: '紧急' },
{ no: 'TK-20260713-039', title: '企业授信报告无法生成', team: '风险运营组', age: '28 分钟', tone: '高优先' },
{ no: 'TK-20260713-031', title: '客户经理账号权限异常', team: '机构服务组', age: '46 分钟', tone: '处理中' },
]
const pulse = [18, 24, 22, 31, 27, 44, 38, 52, 43, 61, 49, 56]
export function WorkbenchConceptPage() {
return (
<main className="concept-shell min-h-screen px-4 py-5 text-foreground sm:px-6 lg:px-8 lg:py-7">
<div className="mx-auto flex max-w-7xl flex-col gap-5">
<header className="flex flex-col gap-4 border-b border-border/70 pb-5 md:flex-row md:items-end md:justify-between">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
<Radar className="text-primary" />
Service signal · 07 / 13
</div>
<div>
<h1 className="concept-title text-3xl font-semibold tracking-[-0.04em] sm:text-4xl">今日服务态势</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
早上好,王工。当前服务运行平稳,但有 2 项风险需要在下一小时内处理。
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline">查看值班表</Button>
<Button onClick={() => (window.location.hash = '/tickets/new')}>
<Plus data-icon="inline-start" />
创建工单
</Button>
</div>
</header>
<section className="concept-signal-grid grid overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:grid-cols-[1.35fr_repeat(3,0.65fr)]">
<div className="concept-hero flex min-h-48 flex-col justify-between gap-6 p-5 sm:p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-primary-foreground/70">实时服务健康度</span>
<div className="flex items-baseline gap-3">
<strong className="concept-score text-6xl font-semibold tracking-[-0.08em] text-primary-foreground">92</strong>
<span className="text-sm text-primary-foreground/70">/ 100</span>
</div>
</div>
<Badge className="border-primary-foreground/20 bg-primary-foreground/10 text-primary-foreground">运行平稳</Badge>
</div>
<div className="flex h-11 items-end gap-1" aria-label="最近十二小时工单趋势">
{pulse.map((height, index) => (
<span
className="min-w-2 flex-1 rounded-t-sm bg-primary-foreground/20 transition-colors hover:bg-primary-foreground/50"
key={`${height}-${index}`}
style={{ height: `${height}%` }}
/>
))}
</div>
<div className="flex items-center justify-between text-xs text-primary-foreground/70">
<span>过去 12 小时</span>
<span className="flex items-center gap-1 text-primary-foreground"><Activity /> 较昨日改善 8%</span>
</div>
</div>
{[
{ icon: Clock3, label: '待响应', value: '08', note: '3 项临近 SLA' },
{ icon: Users, label: '处理中', value: '23', note: '7 个支持小组' },
{ icon: CheckCircle2, label: '今日解决', value: '47', note: '平均 38 分钟' },
].map((item) => (
<div className="flex min-h-36 flex-col justify-between border-t border-border p-5 lg:min-h-48 lg:border-l lg:border-t-0" key={item.label}>
<div className="flex items-center justify-between text-muted-foreground">
<span className="text-xs font-medium">{item.label}</span>
<item.icon />
</div>
<div>
<strong className="concept-stat text-4xl font-semibold tracking-[-0.05em]">{item.value}</strong>
<p className="mt-2 text-xs text-muted-foreground">{item.note}</p>
</div>
</div>
))}
</section>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.65fr)]">
<Card className="gap-0 py-0">
<CardHeader className="border-b border-border px-5 py-4">
<div>
<CardTitle>优先处置队列</CardTitle>
<CardDescription>按业务影响与 SLA 风险动态排序</CardDescription>
</div>
<CardAction>
<Button variant="ghost" size="sm" onClick={() => (window.location.hash = '/tickets')}>
全部工单 <ArrowUpRight data-icon="inline-end" />
</Button>
</CardAction>
</CardHeader>
<CardContent className="px-0">
<div className="divide-y divide-border">
{queue.map((item, index) => (
<button
className="group grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
key={item.no}
type="button"
>
<span className="concept-index flex size-8 items-center justify-center rounded-md border border-border bg-background text-xs font-semibold text-muted-foreground">
{String(index + 1).padStart(2, '0')}
</span>
<span className="min-w-0">
<span className="flex items-center gap-2">
<strong className="truncate text-sm font-medium">{item.title}</strong>
{index === 0 ? <CircleDot className="text-destructive" /> : null}
</span>
<span className="mt-1 block text-xs text-muted-foreground">{item.no} · {item.team}</span>
</span>
<span className="flex items-center gap-3">
<span className="hidden text-xs text-muted-foreground sm:inline">{item.age}</span>
<Badge variant={index === 0 ? 'destructive' : 'secondary'}>{item.tone}</Badge>
<ChevronRight className="text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</span>
</button>
))}
</div>
</CardContent>
</Card>
<Card className="concept-ai-card border-primary/20 bg-primary/[0.045]">
<CardHeader>
<div className="flex size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
<Bot />
</div>
<CardAction><Badge variant="secondary"><Sparkles /> AI 研判</Badge></CardAction>
<CardTitle className="pt-3">建议优先排查结算任务</CardTitle>
<CardDescription className="leading-5">关联日志中有 4 次相同的数据库连接池耗尽信号。</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="rounded-lg border border-border/70 bg-background/80 p-3">
<div className="flex items-center gap-2 text-xs font-medium"><ShieldAlert className="text-warning" /> 影响判断</div>
<p className="mt-2 text-xs leading-5 text-muted-foreground">可能影响 12 家机构的日终清算,建议 30 分钟内升级至二线。</p>
</div>
<Button className="w-full justify-between" variant="outline">
查看诊断依据 <ArrowUpRight data-icon="inline-end" />
</Button>
</CardContent>
</Card>
</div>
<footer className="flex items-center justify-between border-t border-border/70 pt-4 text-xs text-muted-foreground">
<span>数据更新于 10:42:18</span>
<Button variant="ghost" size="xs" onClick={() => (window.location.hash = '/concepts')}>查看其他设计方向</Button>
</footer>
</div>
</main>
)
}

View File

@@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import '@fontsource-variable/geist'
import { App } from './app/App' import { App } from './app/App'
import './styles/globals.css' import './styles/globals.css'

View File

@@ -79,6 +79,197 @@
background: hsl(var(--background)); background: hsl(var(--background));
color: hsl(var(--foreground)); color: hsl(var(--foreground));
font-family: font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; "Geist Variable", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
letter-spacing: 0;
} }
.concept-shell {
--background: 210 29% 98%;
--foreground: 222 38% 12%;
--card: 0 0% 100%;
--card-foreground: 222 38% 12%;
--muted: 210 24% 94%;
--muted-foreground: 216 13% 42%;
--border: 214 22% 87%;
--primary: 216 71% 29%;
--primary-foreground: 195 100% 96%;
--secondary: 201 38% 92%;
--secondary-foreground: 216 65% 24%;
--ring: 187 77% 38%;
--radius: 0.75rem;
background:
linear-gradient(hsl(var(--border) / 0.28) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--border) / 0.28) 1px, transparent 1px),
hsl(var(--background));
background-size: 32px 32px;
}
.concept-title,
.concept-score,
.concept-stat {
font-variant-numeric: tabular-nums;
}
.concept-hero {
background:
radial-gradient(circle at 86% 18%, hsl(187 77% 48% / 0.28), transparent 26%),
linear-gradient(135deg, hsl(var(--primary)), hsl(222 62% 18%));
}
.gallery-shell {
--background: 216 28% 97%;
--foreground: 222 34% 12%;
--card: 0 0% 100%;
--card-foreground: 222 34% 12%;
--muted: 216 22% 93%;
--muted-foreground: 216 12% 42%;
--border: 214 20% 85%;
--primary: 216 70% 31%;
--primary-foreground: 0 0% 100%;
--secondary: 202 38% 91%;
--secondary-foreground: 216 58% 25%;
--ring: 187 72% 38%;
background: hsl(var(--background));
}
.gallery-preview-signal { color: hsl(216 70% 31%); background: linear-gradient(145deg, hsl(210 35% 98%), hsl(202 35% 91%)); }
.gallery-preview-command { color: hsl(195 90% 72%); background: radial-gradient(circle at 75% 20%, hsl(213 65% 30%), hsl(224 42% 9%) 58%); }
.gallery-preview-collaboration { color: hsl(254 42% 42%); background: radial-gradient(circle at 78% 25%, hsl(167 55% 85%), transparent 28%), hsl(231 50% 96%); }
.command-shell {
--background: 224 42% 7%;
--foreground: 210 36% 96%;
--card: 223 35% 11%;
--card-foreground: 210 36% 96%;
--muted: 222 28% 16%;
--muted-foreground: 215 18% 65%;
--border: 218 28% 21%;
--primary: 193 84% 58%;
--primary-foreground: 224 42% 7%;
--secondary: 216 32% 18%;
--secondary-foreground: 202 70% 85%;
--destructive: 8 86% 62%;
--ring: 193 84% 58%;
color-scheme: dark;
background:
linear-gradient(hsl(var(--border) / 0.25) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--border) / 0.25) 1px, transparent 1px),
radial-gradient(circle at 50% -20%, hsl(214 76% 22%), hsl(var(--background)) 42%);
background-size: 40px 40px, 40px 40px, auto;
}
.command-mark { color: hsl(var(--primary)); background: hsl(var(--primary) / 0.12); box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.3), 0 0 24px hsl(var(--primary) / 0.12); }
.command-pulse { box-shadow: 0 0 0 6px hsl(var(--destructive) / 0.12); animation: command-pulse 2s ease-out infinite; }
.command-timer { border: 1px solid hsl(var(--destructive) / 0.45); background: conic-gradient(hsl(var(--destructive)) 0 38%, hsl(var(--muted)) 38% 100%); box-shadow: inset 0 0 0 7px hsl(var(--card)); }
.command-ai { background: linear-gradient(145deg, hsl(var(--primary) / 0.08), hsl(var(--card)) 60%); }
.collab-shell {
--background: 230 44% 97%;
--foreground: 236 30% 16%;
--card: 0 0% 100%;
--card-foreground: 236 30% 16%;
--muted: 232 30% 92%;
--muted-foreground: 232 13% 43%;
--border: 230 24% 87%;
--primary: 254 43% 43%;
--primary-foreground: 0 0% 100%;
--secondary: 166 40% 90%;
--secondary-foreground: 175 45% 24%;
--ring: 254 43% 43%;
--radius: 1rem;
background: radial-gradient(circle at 82% 4%, hsl(164 57% 87%), transparent 24%), hsl(var(--background));
}
.collab-logo { color: hsl(var(--primary)); background: linear-gradient(145deg, hsl(254 72% 91%), hsl(165 58% 87%)); box-shadow: 0 8px 24px hsl(254 43% 43% / 0.12); }
.collab-welcome { background: linear-gradient(120deg, hsl(0 0% 100%), hsl(232 62% 95%)); box-shadow: 0 18px 50px hsl(236 34% 28% / 0.08); }
.collab-orbit { background: radial-gradient(circle, transparent 31%, hsl(var(--primary) / 0.1) 32%, transparent 33%, transparent 49%, hsl(var(--primary) / 0.08) 50%, transparent 51%); }
.collab-avatar { color: hsl(var(--primary)); background: hsl(var(--primary) / 0.1); }
@keyframes command-pulse {
0% { box-shadow: 0 0 0 0 hsl(var(--destructive) / 0.3); }
70%, 100% { box-shadow: 0 0 0 8px hsl(var(--destructive) / 0); }
}
@media (prefers-reduced-motion: reduce) {
.command-pulse { animation: none; }
}
.support-gallery,
.support-desk {
--background: 214 28% 97%; --foreground: 222 32% 13%; --card: 0 0% 100%; --card-foreground: 222 32% 13%;
--muted: 214 24% 93%; --muted-foreground: 215 13% 41%; --border: 214 20% 85%; --primary: 215 67% 34%;
--primary-foreground: 0 0% 100%; --secondary: 204 42% 91%; --secondary-foreground: 215 55% 25%; --ring: 190 66% 38%;
background: hsl(var(--background));
}
.support-preview-support { color: hsl(215 67% 34%); background: linear-gradient(145deg,hsl(211 45% 96%),hsl(201 42% 89%)); }
.support-preview-case { color: hsl(174 52% 29%); background: linear-gradient(145deg,hsl(170 38% 95%),hsl(188 42% 88%)); }
.support-preview-knowledge { color: hsl(252 48% 43%); background: linear-gradient(145deg,hsl(246 52% 96%),hsl(258 45% 90%)); }
.support-focus-card { color: hsl(var(--primary-foreground)); border-color: hsl(var(--primary)); background: linear-gradient(135deg,hsl(var(--primary)),hsl(207 65% 27%)); }
.support-focus-card .text-muted-foreground { color: hsl(var(--primary-foreground) / .72); }
.support-desk svg, .customer-case svg, .knowledge-assist svg { display: inline-block; vertical-align: -0.15em; }
.customer-case {
--background: 172 25% 97%; --foreground: 191 28% 13%; --card: 0 0% 100%; --card-foreground: 191 28% 13%;
--muted: 170 24% 93%; --muted-foreground: 183 12% 39%; --border: 174 19% 84%; --primary: 174 55% 28%;
--primary-foreground: 0 0% 100%; --secondary: 169 42% 89%; --secondary-foreground: 174 48% 22%; --ring: 174 55% 35%;
background: radial-gradient(circle at 100% 0,hsl(169 48% 88%),transparent 26%),hsl(var(--background));
}
.case-selected { background: hsl(var(--primary) / .08); box-shadow: inset 3px 0 hsl(var(--primary)); }
.case-message { display: grid; grid-template-columns: 32px minmax(0,1fr); gap: .75rem; }
.case-avatar { display: flex; width: 2rem; height: 2rem; align-items: center; justify-content: center; border-radius: 999px; color: hsl(var(--primary)); background: hsl(var(--secondary)); }
.case-avatar-system { color: hsl(var(--primary-foreground)); background: hsl(var(--primary)); }
.knowledge-assist {
--background: 245 32% 97%; --foreground: 243 28% 14%; --card: 0 0% 100%; --card-foreground: 243 28% 14%;
--muted: 244 28% 93%; --muted-foreground: 244 12% 42%; --border: 244 20% 86%; --primary: 252 48% 43%;
--primary-foreground: 0 0% 100%; --secondary: 250 42% 91%; --secondary-foreground: 252 42% 30%; --ring: 252 48% 43%;
background: radial-gradient(circle at 50% -12%,hsl(252 60% 89%),transparent 30%),hsl(var(--background));
}
.knowledge-query { border-color: hsl(var(--primary) / .22); background: linear-gradient(120deg,hsl(var(--primary) / .08),hsl(var(--card))); }
.knowledge-answer { border-color: hsl(var(--primary) / .24); box-shadow: 0 18px 55px hsl(var(--primary) / .08); }
.ticket-screen {
--background: 214 30% 97%; --foreground: 222 32% 13%; --card: 0 0% 100%; --card-foreground: 222 32% 13%;
--muted: 214 23% 93%; --muted-foreground: 215 13% 40%; --border: 214 20% 85%; --primary: 215 67% 34%;
--primary-foreground: 0 0% 100%; --secondary: 202 42% 91%; --secondary-foreground: 215 54% 24%; --ring: 190 66% 38%;
background: radial-gradient(circle at 90% 0,hsl(201 48% 90%),transparent 24%),hsl(var(--background));
}
.ticket-step { display:flex; align-items:center; gap:.75rem; border-radius:.5rem; padding:.65rem; }
.ticket-step-active { color:hsl(var(--primary)); background:hsl(var(--primary) / .08); }
.ticket-step-icon { display:flex; width:1.75rem; height:1.75rem; align-items:center; justify-content:center; border-radius:999px; color:hsl(var(--muted-foreground)); background:hsl(var(--muted)); }
.ticket-step-active .ticket-step-icon { color:hsl(var(--primary-foreground)); background:hsl(var(--primary)); }
.ticket-section-no { display:inline-flex; width:1.75rem; height:1.75rem; align-items:center; justify-content:center; border-radius:.4rem; color:hsl(var(--primary)); background:hsl(var(--primary) / .1); font-size:.65rem; letter-spacing:.04em; }
.ticket-current-section { border-color:hsl(var(--primary) / .3); box-shadow:0 12px 36px hsl(var(--primary) / .06); }
.ticket-upload:hover { color:hsl(var(--primary)); border-color:hsl(var(--primary) / .5); background:hsl(var(--primary) / .03); }
.ticket-summary-card { border-color:hsl(var(--primary) / .2); background:linear-gradient(145deg,hsl(var(--primary) / .08),hsl(var(--card))); }
.ticket-status-dot { display:flex; width:1.75rem; height:1.75rem; flex:none; align-items:center; justify-content:center; border-radius:999px; color:hsl(var(--muted-foreground)); background:hsl(var(--muted)); font-size:.7rem; }
.ticket-status-done { color:hsl(var(--primary-foreground)); background:hsl(var(--primary)); }
.ticket-result { background:linear-gradient(120deg,hsl(var(--primary) / .07),hsl(var(--card))); }
.ticket-timeline { position:relative; display:flex; flex-direction:column; gap:1.5rem; }
.ticket-timeline::before { position:absolute; bottom:1rem; left:1rem; top:1rem; width:1px; content:""; background:hsl(var(--border)); }
.ticket-event { position:relative; display:grid; grid-template-columns:2rem minmax(0,1fr); gap:.875rem; }
.ticket-event-icon { display:flex; width:2rem; height:2rem; align-items:center; justify-content:center; border-radius:999px; color:hsl(var(--primary)); background:hsl(var(--secondary)); box-shadow:0 0 0 4px hsl(var(--card)); }
.ticket-reply-card { border-color:hsl(var(--primary) / .25); background:hsl(var(--primary) / .035); }
.ticket-solution-card { border-color:hsl(var(--primary) / .22); box-shadow:0 14px 42px hsl(var(--primary) / .07); }
.component-lab { transition: background-color .28s ease, color .28s ease; }
.component-lab[data-style="precision"] { --background:214 28% 97%;--foreground:218 35% 16%;--card:0 0% 100%;--card-foreground:218 35% 16%;--muted:210 26% 92%;--muted-foreground:214 13% 39%;--border:212 20% 82%;--primary:210 68% 29%;--primary-foreground:0 0% 100%;--secondary:201 39% 90%;--secondary-foreground:210 52% 24%;--ring:190 65% 36%;--radius:.4rem;background:hsl(var(--background)); }
.component-lab[data-style="care"] { --background:250 30% 97%;--foreground:254 18% 18%;--card:0 0% 100%;--card-foreground:254 18% 18%;--muted:250 26% 93%;--muted-foreground:254 10% 42%;--border:251 20% 86%;--primary:256 35% 48%;--primary-foreground:0 0% 100%;--secondary:157 37% 90%;--secondary-foreground:164 35% 24%;--ring:256 35% 48%;--radius:1rem;background:radial-gradient(circle at 90% 0,hsl(158 47% 88%),transparent 27%),hsl(var(--background)); }
.component-lab[data-style="graphite"] { --background:222 30% 8%;--foreground:218 38% 96%;--card:222 26% 12%;--card-foreground:218 38% 96%;--muted:220 21% 18%;--muted-foreground:217 16% 68%;--border:219 20% 24%;--primary:221 100% 59%;--primary-foreground:0 0% 100%;--secondary:218 28% 20%;--secondary-foreground:216 34% 88%;--ring:221 100% 65%;--destructive:8 78% 59%;--radius:.55rem;color-scheme:dark;background:radial-gradient(circle at 50% -20%,hsl(221 55% 22%),transparent 42%),hsl(var(--background)); }
.component-lab[data-style="crystal"] { --background:222 60% 96%;--foreground:225 36% 20%;--card:0 0% 100%;--card-foreground:225 36% 20%;--muted:219 42% 91%;--muted-foreground:224 14% 42%;--border:216 34% 82%;--primary:235 61% 56%;--primary-foreground:0 0% 100%;--secondary:190 54% 88%;--secondary-foreground:202 50% 24%;--ring:235 61% 56%;--radius:.9rem;background:radial-gradient(circle at 15% 0,hsl(190 65% 84%),transparent 28%),radial-gradient(circle at 92% 15%,hsl(246 65% 88%),transparent 30%),hsl(var(--background)); }
.style-choice { width:100%; border:1px solid hsl(var(--border)); border-radius:calc(var(--radius) + .25rem); background:hsl(var(--card)); padding:1rem; color:hsl(var(--foreground)); transition:transform .2s ease,border-color .2s ease,box-shadow .2s ease; }
.style-choice:hover { transform:translateY(-2px); border-color:hsl(var(--primary) / .4); }
.style-choice-active { border-color:hsl(var(--primary)); box-shadow:0 10px 28px hsl(var(--primary) / .12); }
.style-hero { background:linear-gradient(130deg,hsl(var(--primary) / .1),hsl(var(--card)) 62%); }
.style-avatar { display:flex; width:2rem;height:2rem;align-items:center;justify-content:center;border-radius:calc(var(--radius) + .15rem);color:hsl(var(--primary));background:hsl(var(--secondary)); }
.style-ticket-row { transition:background-color .18s ease; }
.style-ticket-row:hover { background:hsl(var(--muted) / .65); }
.style-stat { display:flex;flex-direction:column;gap:.35rem;border:1px solid hsl(var(--border));border-radius:var(--radius);background:hsl(var(--muted) / .55);padding:1rem;color:hsl(var(--muted-foreground));font-size:.75rem; }
.style-stat strong { color:hsl(var(--foreground));font-size:1.75rem;line-height:1; }
.component-lab[data-style="precision"] [data-slot="card"] { box-shadow:0 2px 8px hsl(215 35% 20% / .05); }
.component-lab[data-style="care"] [data-slot="card"] { border-color:hsl(var(--border) / .7);box-shadow:0 14px 38px hsl(256 28% 32% / .08); }
.component-lab[data-style="care"] [data-slot="button"] { border-radius:999px; }
.component-lab[data-style="graphite"] [data-slot="card"] { box-shadow:inset 0 1px hsl(0 0% 100% / .04),0 18px 45px hsl(222 45% 3% / .3); }
.component-lab[data-style="crystal"] [data-slot="card"],.component-lab[data-style="crystal"] .style-hero { border-color:hsl(0 0% 100% / .7);background:hsl(var(--card) / .72);box-shadow:0 18px 50px hsl(225 45% 32% / .1);backdrop-filter:blur(18px); }
} }

View File

@@ -0,0 +1,34 @@
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: ${POSTGRES_DB:-ai_ticket_platform}
POSTGRES_USER: ${POSTGRES_USER:-ai_ticket}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ai_ticket}
ports:
- "5432:5432"
volumes:
- ./data/postgres:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- ./data/redis:/data
restart: unless-stopped
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minio}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minio123}
ports:
- "9000:9000"
- "9001:9001"
volumes:
- ./data/minio:/data
restart: unless-stopped