feat: rebuild support workspace frontend

This commit is contained in:
hailin
2026-07-16 17:20:45 +08:00
parent fcb8121f0a
commit e90648e129
95 changed files with 4353 additions and 2165 deletions

View File

@@ -10,6 +10,7 @@
"dependencies": {
"@fontsource-variable/geist": "^5.2.9",
"@radix-ui/react-slot": "^1.1.0",
"@shadcn/react": "^0.2.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
@@ -3642,6 +3643,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@shadcn/react": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@shadcn/react/-/react-0.2.1.tgz",
"integrity": "sha512-5krgi3dRMKb5jH6a+qPzVJUy/54s0kKE4Rw4LjDfLqOdVQTWKUgxWf1kW8r912I0jX/Lzxqc+pgjkjWxUIK5BQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": ">=19",
"react": ">=19"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",

View File

@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"preview": "vite preview",
"build": "tsc -b && vite build",
"typecheck": "tsc -b",
"lint": "eslint src",
@@ -14,6 +15,7 @@
"dependencies": {
"@fontsource-variable/geist": "^5.2.9",
"@radix-ui/react-slot": "^1.1.0",
"@shadcn/react": "^0.2.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",

View File

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

View File

@@ -1,19 +1,77 @@
import * as React from 'react'
import { useCurrentRoute } from '@/app/route-meta'
import { AppRouteTabs } from '@/components/app/AppRouteTabs'
import { AppSidebar } from '@/components/app/AppSidebar'
import { AppTopbar } from '@/components/app/AppTopbar'
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { SidebarInset, SidebarProvider, useSidebar } from '@/components/ui/sidebar'
import { AgentWorkspaceLauncher } from '@/features/agent-workspace/AgentWorkspaceLauncher'
import { AgentWorkspacePanel } from '@/features/agent-workspace/AgentWorkspacePanel'
import { AgentWorkspaceProvider, useAgentWorkspace } from '@/features/agent-workspace/AgentWorkspaceProvider'
type AppShellProps = {
children: React.ReactNode
type AppShellProps = { children: React.ReactNode }
function ResponsiveSidebarController() {
const { setOpen } = useSidebar()
const setOpenRef = React.useRef(setOpen)
React.useEffect(() => {
setOpenRef.current = setOpen
}, [setOpen])
React.useEffect(() => {
const query = window.matchMedia('(min-width: 1536px)')
const apply = () => setOpenRef.current(query.matches)
apply()
query.addEventListener('change', apply)
return () => query.removeEventListener('change', apply)
}, [])
return null
}
export function AppShell({ children }: AppShellProps) {
function AppShellContent({ children }: AppShellProps) {
const { meta } = useCurrentRoute()
const { dockedOpen, setDockedOpen, sheetOpen, setSheetOpen } = useAgentWorkspace()
const dockAgent = meta.agent === 'docked'
return (
<SidebarProvider>
<SidebarProvider defaultOpen={false} style={{ '--sidebar-width': '13.5rem', '--sidebar-width-icon': '4rem' } as React.CSSProperties}>
<ResponsiveSidebarController />
<AppSidebar />
<SidebarInset className="min-w-0 bg-background text-foreground">
<SidebarInset className="h-svh min-w-0 overflow-hidden bg-background text-foreground">
<AppTopbar />
{children}
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<header
data-slot="app-global-header"
className="z-20 shrink-0 border-b bg-card/95 px-4 backdrop-blur sm:px-5 lg:px-6"
>
<AppRouteTabs />
</header>
<div data-slot="app-shell-scroll" className="scrollbar-page min-h-0 flex-1 overflow-y-auto">{children}</div>
</div>
{dockAgent && dockedOpen ? (
<aside
data-slot="app-agent-dock"
className="hidden h-full w-[344px] shrink-0 border-l bg-background min-[1280px]:block min-[1536px]:w-[368px]"
>
<AgentWorkspacePanel onClose={() => setDockedOpen(false)} />
</aside>
) : null}
</div>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent side="right" className="w-full max-w-[390px] gap-0 p-0 sm:max-w-[390px]">
<SheetHeader className="sr-only"><SheetTitle>Agent 演示助手</SheetTitle><SheetDescription>显示当前页面相关的本地演示建议。</SheetDescription></SheetHeader>
<AgentWorkspacePanel compact />
</SheetContent>
</Sheet>
<AgentWorkspaceLauncher />
</SidebarInset>
</SidebarProvider>
)
}
export function AppShell({ children }: AppShellProps) {
return <AgentWorkspaceProvider><AppShellContent>{children}</AppShellContent></AgentWorkspaceProvider>
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { matchRoute, routeMeta } from './route-meta'
describe('RouteMeta', () => {
it('正式路由标题唯一且不包含概念实验室', () => {
const keys = routeMeta.map((route) => route.key)
const titles = routeMeta.map((route) => route.title)
expect(new Set(keys).size).toBe(keys.length)
expect(new Set(titles).size).toBe(titles.length)
expect(keys.some((key) => key.includes('concept'))).toBe(false)
})
it('核心业务默认停靠 Agent,治理页按需打开', () => {
expect(matchRoute('/tickets/42').meta.agent).toBe('docked')
expect(matchRoute('/knowledge').meta.agent).toBe('docked')
expect(matchRoute('/system/users').meta.agent).toBe('on-demand')
expect(matchRoute('/modelhub/providers').meta.agent).toBe('on-demand')
})
it('正式页面具备唯一上下文层级,子页面使用确定性返回路径', () => {
const workbench = matchRoute('/').meta
const ticketList = matchRoute('/tickets').meta
const ticketCreate = matchRoute('/tickets/new').meta
const ticketDetail = matchRoute('/tickets/42').meta
const knowledgeCreate = matchRoute('/knowledge/items/new').meta
const solutionCreate = matchRoute('/solutions/new').meta
const skillDetail = matchRoute('/skills/7').meta
const agentDetail = matchRoute('/agents/8').meta
const runDetail = matchRoute('/agent-runs/123e4567-e89b-12d3-a456-426614174000').meta
expect(workbench.breadcrumbs).toEqual([])
expect(routeMeta.filter((route) => route.key !== 'workbench').every((route) => route.breadcrumbs.length > 0)).toBe(true)
expect(ticketList.breadcrumbs).toEqual([{ label: '工单' }])
expect(ticketCreate.breadcrumbs).toEqual([{ label: '工单中心', path: '/tickets' }])
expect(ticketCreate.backPath).toBeUndefined()
expect(ticketDetail.backPath).toBe('/tickets')
expect(knowledgeCreate.backPath).toBe('/knowledge')
expect(solutionCreate.backPath).toBe('/solutions')
expect(skillDetail.backPath).toBe('/skills')
expect(agentDetail.backPath).toBe('/agents')
expect(runDetail.backPath).toBe('/agent-runs')
})
it('动态页面仍能得到可用于打开标签的稳定标题和路径', () => {
expect(matchRoute('/tickets/42')).toMatchObject({ route: '/tickets/42', meta: { title: '工单详情' } })
expect(matchRoute('/knowledge/search').meta.title).toBe('知识检索')
expect(matchRoute('/solutions/new').meta.title).toBe('新建解决方案')
expect(matchRoute('/system/roles').meta.title).toBe('角色管理')
})
it('未知地址回退到工作台规范路径,避免创建无效页签', () => {
expect(matchRoute('/not-found')).toMatchObject({ route: '/', meta: { key: 'workbench' } })
})
})

View File

@@ -0,0 +1,99 @@
import * as React from 'react'
export type AgentDisplayMode = 'docked' | 'on-demand'
export type RouteSection = '工作台' | '工单' | '知识与方案' | 'AI 平台' | '系统治理' | '个人中心'
export type RouteBreadcrumb = {
label: string
path?: string
}
export type RouteNavigationTab = {
label: string
path: string
}
export type RouteMeta = {
key: string
pattern: RegExp
title: string
description: string
section: RouteSection
navPath: string
breadcrumbs: RouteBreadcrumb[]
backPath?: string
agent: AgentDisplayMode
render: (params: RegExpMatchArray) => React.ReactNode
}
const WorkbenchPage = React.lazy(() => import('@/features/workbench/WorkbenchPage').then((module) => ({ default: module.WorkbenchPage })))
const TicketListPage = React.lazy(() => import('@/features/tickets/TicketListPage').then((module) => ({ default: module.TicketListPage })))
const TicketCreatePage = React.lazy(() => import('@/features/tickets/TicketCreatePage').then((module) => ({ default: module.TicketCreatePage })))
const TicketDetailPage = React.lazy(() => import('@/features/tickets/TicketDetailPage').then((module) => ({ default: module.TicketDetailPage })))
const KnowledgeBaseListPage = React.lazy(() => import('@/features/knowledge/KnowledgeBaseListPage').then((module) => ({ default: module.KnowledgeBaseListPage })))
const KnowledgeSearchPage = React.lazy(() => import('@/features/knowledge/KnowledgeSearchPage').then((module) => ({ default: module.KnowledgeSearchPage })))
const KnowledgeItemEditorPage = React.lazy(() => import('@/features/knowledge/KnowledgeItemEditorPage').then((module) => ({ default: module.KnowledgeItemEditorPage })))
const SolutionListPage = React.lazy(() => import('@/features/solutions/SolutionListPage').then((module) => ({ default: module.SolutionListPage })))
const SolutionCreatePage = React.lazy(() => import('@/features/solutions/SolutionCreatePage').then((module) => ({ default: module.SolutionCreatePage })))
const ModelProviderPage = React.lazy(() => import('@/features/modelhub/ModelProviderPage').then((module) => ({ default: module.ModelProviderPage })))
const SkillListPage = React.lazy(() => import('@/features/skills/SkillListPage').then((module) => ({ default: module.SkillListPage })))
const SkillDetailPage = React.lazy(() => import('@/features/skills/SkillDetailPage').then((module) => ({ default: module.SkillDetailPage })))
const AgentListPage = React.lazy(() => import('@/features/agents/AgentListPage').then((module) => ({ default: module.AgentListPage })))
const AgentDetailPage = React.lazy(() => import('@/features/agents/AgentDetailPage').then((module) => ({ default: module.AgentDetailPage })))
const AgentRunListPage = React.lazy(() => import('@/features/agents/AgentRunListPage').then((module) => ({ default: module.AgentRunListPage })))
const AgentRunDetailPage = React.lazy(() => import('@/features/agents/AgentRunDetailPage').then((module) => ({ default: module.AgentRunDetailPage })))
const SystemUsersPage = React.lazy(() => import('@/features/system/SystemUsersPage').then((module) => ({ default: module.SystemUsersPage })))
const SystemUserGroupsPage = React.lazy(() => import('@/features/system/SystemUserGroupsPage').then((module) => ({ default: module.SystemUserGroupsPage })))
const SystemRolesPage = React.lazy(() => import('@/features/system/SystemRolesPage').then((module) => ({ default: module.SystemRolesPage })))
const SystemMenusPage = React.lazy(() => import('@/features/system/SystemMenusPage').then((module) => ({ default: module.SystemMenusPage })))
const AuditLogPage = React.lazy(() => import('@/features/system/AuditLogPage').then((module) => ({ default: module.AuditLogPage })))
const DesignSystemPreview = React.lazy(() => import('@/features/design-system/DesignSystemPreview').then((module) => ({ default: module.DesignSystemPreview })))
const ProfilePage = React.lazy(() => import('@/features/profile/ProfilePage').then((module) => ({ default: module.ProfilePage })))
export const routeMeta: RouteMeta[] = [
{ key: 'workbench', pattern: /^\/$/, title: '工作台', description: '聚合待办、风险与最近处理进展', section: '工作台', navPath: '/', breadcrumbs: [], agent: 'docked', render: () => <WorkbenchPage /> },
{ key: 'profile', pattern: /^\/profile$/, title: '个人中心', description: '查看个人工作情况并维护账号设置', section: '个人中心', navPath: '/profile', breadcrumbs: [{ label: '显示与账户' }], agent: 'on-demand', render: () => <ProfilePage /> },
{ key: 'ticket-create', pattern: /^\/tickets\/new$/, title: '创建工单', description: '记录问题上下文并进入支持流程', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], agent: 'docked', render: () => <TicketCreatePage /> },
{ key: 'ticket-detail', pattern: /^\/tickets\/(\d+)$/, title: '工单详情', description: '处理状态、方案与协作记录', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], backPath: '/tickets', agent: 'docked', render: (match) => <TicketDetailPage ticketId={Number(match[1])} /> },
{ key: 'tickets', pattern: /^\/tickets(?:\?.*)?$/, title: '工单中心', description: '统一查看、筛选和推进支持工单', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单' }], agent: 'docked', render: () => <TicketListPage /> },
{ key: 'knowledge-search', pattern: /^\/knowledge\/search$/, title: '知识检索', description: '跨知识条目与解决方案检索处理依据', section: '知识与方案', navPath: '/knowledge/search', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <KnowledgeSearchPage /> },
{ key: 'knowledge-create', pattern: /^\/knowledge\/items\/new$/, title: '新建知识条目', description: '沉淀可复用的支持知识', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => <KnowledgeItemEditorPage /> },
{ key: 'knowledge', pattern: /^\/knowledge$/, title: '知识库', description: '集中管理业务知识、文档与检索范围', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <KnowledgeBaseListPage /> },
{ key: 'solution-create', pattern: /^\/solutions\/new(?:\?.*)?$/, title: '新建解决方案', description: '将验证过的处理过程沉淀为方案', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '解决方案库', path: '/solutions' }], backPath: '/solutions', agent: 'docked', render: () => <SolutionCreatePage /> },
{ key: 'solutions', pattern: /^\/solutions$/, title: '解决方案库', description: '复用已验证的处置流程和回复依据', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <SolutionListPage /> },
{ key: 'model-providers', pattern: /^\/modelhub\/providers$/, title: '模型服务', description: '配置模型供应商与调用参数', section: 'AI 平台', navPath: '/modelhub/providers', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <ModelProviderPage /> },
{ key: 'skill-detail', pattern: /^\/skills\/(\d+)$/, title: 'Skill 详情', description: '维护能力定义、版本和输入输出', section: 'AI 平台', navPath: '/skills', breadcrumbs: [{ label: 'Skill 管理', path: '/skills' }], backPath: '/skills', agent: 'on-demand', render: (match) => <SkillDetailPage skillId={Number(match[1])} /> },
{ key: 'skills', pattern: /^\/skills$/, title: 'Skill 管理', description: '组织可组合的智能处理能力', section: 'AI 平台', navPath: '/skills', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <SkillListPage /> },
{ key: 'agent-detail', pattern: /^\/agents\/(\d+)$/, title: 'Agent 详情', description: '配置编排、知识边界与运行参数', section: 'AI 平台', navPath: '/agents', breadcrumbs: [{ label: 'Agent 管理', path: '/agents' }], backPath: '/agents', agent: 'on-demand', render: (match) => <AgentDetailPage agentId={Number(match[1])} /> },
{ key: 'agents', pattern: /^\/agents$/, title: 'Agent 管理', description: '管理面向支持场景的智能助手', section: 'AI 平台', navPath: '/agents', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <AgentListPage /> },
{ key: 'agent-run-detail', pattern: /^\/agent-runs\/([0-9a-f-]+)$/i, title: '运行记录详情', description: '查看一次 Agent 运行的输入、输出与状态', section: 'AI 平台', navPath: '/agent-runs', breadcrumbs: [{ label: 'Agent 运行记录', path: '/agent-runs' }], backPath: '/agent-runs', agent: 'on-demand', render: (match) => <AgentRunDetailPage publicId={match[1]} /> },
{ key: 'agent-runs', pattern: /^\/agent-runs$/, title: 'Agent 运行记录', description: '追踪智能处理任务的执行结果', section: 'AI 平台', navPath: '/agent-runs', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <AgentRunListPage /> },
{ key: 'system-users', pattern: /^\/system\/users$/, title: '用户管理', description: '维护平台账号与可用状态', section: '系统治理', navPath: '/system/users', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemUsersPage /> },
{ key: 'system-groups', pattern: /^\/system\/user-groups$/, title: '用户组管理', description: '按组织和职责管理授权范围', section: '系统治理', navPath: '/system/user-groups', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemUserGroupsPage /> },
{ key: 'system-roles', pattern: /^\/system\/roles$/, title: '角色管理', description: '维护角色与权限集合', section: '系统治理', navPath: '/system/roles', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemRolesPage /> },
{ key: 'system-menus', pattern: /^\/system\/menus$/, title: '菜单权限', description: '管理导航可见性和功能权限', section: '系统治理', navPath: '/system/menus', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemMenusPage /> },
{ key: 'audit', pattern: /^\/audit$/, title: '审计日志', description: '追踪关键配置与业务操作记录', section: '系统治理', navPath: '/audit', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <AuditLogPage /> },
{ key: 'design-system', pattern: /^\/design-system$/, title: '设计系统预览', description: '检查基础组件和语义状态', section: '系统治理', navPath: '/design-system', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <DesignSystemPreview /> },
]
export function normalizeRoute(hash = window.location.hash) {
return hash.replace(/^#/, '') || '/'
}
export function matchRoute(route: string) {
for (const meta of routeMeta) {
const match = route.match(meta.pattern)
if (match) return { route, meta, match }
}
return { route: '/', meta: routeMeta[0], match: '/'.match(/^\/$/) as RegExpMatchArray }
}
export function useCurrentRoute() {
const [route, setRoute] = React.useState(normalizeRoute)
React.useEffect(() => {
const handleHashChange = () => setRoute(normalizeRoute())
window.addEventListener('hashchange', handleHashChange)
return () => window.removeEventListener('hashchange', handleHashChange)
}, [])
return matchRoute(route)
}

View File

@@ -1,132 +1,31 @@
import * as React from 'react'
import { DesignSystemPreview } from '@/features/design-system/DesignSystemPreview'
import { KnowledgeBaseListPage } from '@/features/knowledge/KnowledgeBaseListPage'
import { KnowledgeItemEditorPage } from '@/features/knowledge/KnowledgeItemEditorPage'
import { KnowledgeSearchPage } from '@/features/knowledge/KnowledgeSearchPage'
import { SolutionCreatePage } from '@/features/solutions/SolutionCreatePage'
import { SolutionListPage } from '@/features/solutions/SolutionListPage'
import { AuditLogPage } from '@/features/system/AuditLogPage'
import { SystemMenusPage } from '@/features/system/SystemMenusPage'
import { SystemRolesPage } from '@/features/system/SystemRolesPage'
import { SystemUserGroupsPage } from '@/features/system/SystemUserGroupsPage'
import { SystemUsersPage } from '@/features/system/SystemUsersPage'
import { TicketCreatePage } from '@/features/tickets/TicketCreatePage'
import { TicketDetailPage } from '@/features/tickets/TicketDetailPage'
import { TicketListPage } from '@/features/tickets/TicketListPage'
import { WorkbenchPage } from '@/features/workbench/WorkbenchPage'
import { ModelProviderPage } from '@/features/modelhub/ModelProviderPage'
import { SkillListPage } from '@/features/skills/SkillListPage'
import { SkillDetailPage } from '@/features/skills/SkillDetailPage'
import { AgentListPage } from '@/features/agents/AgentListPage'
import { AgentDetailPage } from '@/features/agents/AgentDetailPage'
import { AgentRunListPage } from '@/features/agents/AgentRunListPage'
import { AgentRunDetailPage } from '@/features/agents/AgentRunDetailPage'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { useCurrentRoute } from '@/app/route-meta'
function getRoute() {
return window.location.hash.replace(/^#/, '') || '/'
function PageSkeleton() {
return (
<main className="min-h-screen bg-background px-4 py-5 sm:px-6">
<div className="grid gap-5">
<div className="grid gap-2">
<Skeleton className="h-8 w-44" />
<Skeleton className="h-4 w-72 max-w-full" />
</div>
<div className="grid gap-3 sm:grid-cols-3">
{[0, 1, 2].map((item) => <Skeleton key={item} className="h-24" />)}
</div>
<Card><CardContent className="grid gap-3 p-4"><Skeleton className="h-9 w-full" /><Skeleton className="h-52 w-full" /></CardContent></Card>
</div>
</main>
)
}
export function Router() {
const [route, setRoute] = React.useState(getRoute)
const { meta, match } = useCurrentRoute()
React.useEffect(() => {
const handleHashChange = () => setRoute(getRoute())
window.addEventListener('hashchange', handleHashChange)
return () => window.removeEventListener('hashchange', handleHashChange)
}, [])
document.title = `${meta.title} · AI 工单平台`
}, [meta.title])
if (route === '/design-system') {
return <DesignSystemPreview />
}
if (route === '/audit') {
return <AuditLogPage />
}
if (route === '/system/users') {
return <SystemUsersPage />
}
if (route === '/system/user-groups') {
return <SystemUserGroupsPage />
}
if (route === '/system/roles') {
return <SystemRolesPage />
}
if (route === '/system/menus') {
return <SystemMenusPage />
}
if (route === '/') {
return <WorkbenchPage />
}
if (route === '/modelhub/providers') {
return <ModelProviderPage />
}
if (route === '/skills') {
return <SkillListPage />
}
const skillMatch = route.match(/^\/skills\/(\d+)$/)
if (skillMatch) {
return <SkillDetailPage skillId={Number(skillMatch[1])} />
}
if (route === '/agents') {
return <AgentListPage />
}
const agentMatch = route.match(/^\/agents\/(\d+)$/)
if (agentMatch) {
return <AgentDetailPage agentId={Number(agentMatch[1])} />
}
if (route === '/agent-runs') {
return <AgentRunListPage />
}
const runMatch = route.match(/^\/agent-runs\/([0-9a-f-]+)$/i)
if (runMatch) {
return <AgentRunDetailPage publicId={runMatch[1]} />
}
if (route === '/knowledge') {
return <KnowledgeBaseListPage />
}
if (route === '/knowledge/search') {
return <KnowledgeSearchPage />
}
if (route === '/knowledge/items/new') {
return <KnowledgeItemEditorPage />
}
if (route === '/solutions') {
return <SolutionListPage />
}
if (route.startsWith('/solutions/new')) {
return <SolutionCreatePage />
}
if (route === '/tickets') {
return <TicketListPage />
}
if (route === '/tickets/new') {
return <TicketCreatePage />
}
const ticketMatch = route.match(/^\/tickets\/(\d+)$/)
if (ticketMatch) {
return <TicketDetailPage ticketId={Number(ticketMatch[1])} />
}
return <WorkbenchPage />
return <React.Suspense fallback={<PageSkeleton />}>{meta.render(match)}</React.Suspense>
}

View File

@@ -0,0 +1,79 @@
import type * as React from 'react'
import { CircleAlert, LoaderCircle, Plus } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
type AppCreateDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
submitLabel: string
onSubmit: React.FormEventHandler<HTMLFormElement>
children: React.ReactNode
pending?: boolean
submitDisabled?: boolean
error?: string
className?: string
bodyClassName?: string
}
export function AppCreateDialog({
open,
onOpenChange,
title,
description,
submitLabel,
onSubmit,
children,
pending = false,
submitDisabled = false,
error,
className,
bodyClassName
}: AppCreateDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn('overflow-hidden p-0 sm:max-w-lg', className)}>
<DialogHeader className="border-b border-border/70 bg-gradient-to-r from-primary/[0.08] via-card to-secondary/55 px-6 py-5 pr-12">
<div className="mb-1 flex size-10 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-sm ring-1 ring-primary/10">
<Plus className="size-5" aria-hidden="true" />
</div>
<DialogTitle className="text-lg">{title}</DialogTitle>
<DialogDescription className="leading-relaxed">{description}</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit}>
<div className={cn('scrollbar-panel max-h-[65vh] overflow-y-auto px-6 py-5', bodyClassName)}>
{children}
{error ? (
<Alert variant="destructive" className="mt-4">
<CircleAlert />
<AlertTitle>创建未完成</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
</div>
<DialogFooter className="m-0 rounded-none border-t border-border/70 bg-muted/35 px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
取消
</Button>
<Button type="submit" disabled={pending || submitDisabled}>
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" /> : <Plus data-icon="inline-start" />}
{pending ? '正在创建…' : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,34 +0,0 @@
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

@@ -4,20 +4,36 @@ import { cn } from '@/lib/utils'
type AppPageProps = {
children: React.ReactNode
header?: React.ReactNode
className?: string
contentClassName?: string
maxWidth?: '6xl' | '7xl'
maxWidth?: '6xl' | '7xl' | 'contained' | 'fluid'
}
const maxWidthClass: Record<NonNullable<AppPageProps['maxWidth']>, string> = {
'6xl': 'max-w-6xl',
'7xl': 'max-w-7xl'
'7xl': 'max-w-7xl',
contained: 'max-w-5xl',
fluid: 'max-w-none'
}
export function AppPage({ children, className, contentClassName, maxWidth = '7xl' }: AppPageProps) {
export function AppPage({ children, header, className, contentClassName, maxWidth = 'fluid' }: AppPageProps) {
return (
<main className={cn('min-h-screen bg-background px-4 py-5 text-foreground sm:px-6 lg:px-8 lg:py-7', className)}>
<div className={cn('mx-auto flex flex-col gap-5', maxWidthClass[maxWidth], contentClassName)}>
<main className={cn('min-h-full bg-background text-foreground', className)}>
{header ? (
<div
data-slot="app-page-header-surface"
className="border-b bg-card"
>
<div className={cn('mx-auto px-4 py-3.5 sm:px-5 lg:px-6 lg:py-4', maxWidthClass[maxWidth])}>
{header}
</div>
</div>
) : null}
<div
data-slot="app-page-content"
className={cn('mx-auto flex flex-col gap-5 px-4 py-4 sm:px-5 lg:px-6 lg:py-5', maxWidthClass[maxWidth], contentClassName)}
>
{children}
</div>
</main>

View File

@@ -1,24 +1,73 @@
import type * as React from 'react'
import * as React from 'react'
import { ArrowLeft } from 'lucide-react'
import { useCurrentRoute } from '@/app/route-meta'
import { Button } from '@/components/ui/button'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from '@/components/ui/breadcrumb'
type AppPageHeaderProps = {
title: string
description?: string
breadcrumbLabel?: string
meta?: React.ReactNode
actions?: React.ReactNode
}
export function AppPageHeader({ title, description, meta, actions }: AppPageHeaderProps) {
export function AppPageHeader({ title, description, breadcrumbLabel, meta, actions }: AppPageHeaderProps) {
const route = useCurrentRoute()
const showBreadcrumbs = route.meta.breadcrumbs.length > 0
const parentBreadcrumb = route.meta.breadcrumbs[route.meta.breadcrumbs.length - 1]
return (
<header data-slot="app-page-header" className="border-b border-border/80 pb-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<header data-slot="app-page-header" className="flex min-h-14 flex-col justify-center gap-2.5">
{showBreadcrumbs ? (
<div className="flex min-w-0 items-center gap-2">
{route.meta.backPath ? (
<Button asChild variant="ghost" size="sm">
<a href={`#${route.meta.backPath}`} aria-label={`返回${parentBreadcrumb?.label ?? '上一页'}`}>
<ArrowLeft data-icon="inline-start" />
返回
</a>
</Button>
) : null}
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap text-xs">
{route.meta.breadcrumbs.map((item, index) => (
<React.Fragment key={`${item.label}-${item.path ?? index}`}>
<BreadcrumbItem className="min-w-0">
{item.path ? (
<BreadcrumbLink className="truncate" href={`#${item.path}`}>{item.label}</BreadcrumbLink>
) : (
<span className="truncate">{item.label}</span>
)}
</BreadcrumbItem>
<BreadcrumbSeparator />
</React.Fragment>
))}
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate font-medium">{breadcrumbLabel ?? title}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
) : null}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-normal text-foreground">{title}</h1>
<h1 className="text-xl font-semibold tracking-[-0.025em] text-foreground lg:text-2xl">{title}</h1>
{meta}
</div>
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">
{actions}
</div> : null}
</div>
</header>
)

View File

@@ -1,14 +0,0 @@
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

@@ -0,0 +1,93 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AppRouteTabs } from '@/components/app/AppRouteTabs'
function navigate(path: string) {
act(() => {
window.location.hash = path
window.dispatchEvent(new HashChangeEvent('hashchange'))
})
}
beforeEach(() => {
window.sessionStorage.clear()
window.location.hash = '#/tickets/42'
})
afterEach(() => cleanup())
describe('已打开页面标签', () => {
it('只显示实际打开过的页面,并允许关闭非当前标签', async () => {
const user = userEvent.setup()
render(<AppRouteTabs />)
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(1))
expect(screen.getByRole('tab', { name: '工单详情' })).toHaveAttribute('data-state', 'active')
navigate('/knowledge')
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2))
expect(screen.getByRole('tab', { name: '知识库' })).toHaveAttribute('data-state', 'active')
await user.click(screen.getByRole('button', { name: '关闭工单详情(/tickets/42)' }))
expect(screen.queryByRole('tab', { name: '工单详情' })).not.toBeInTheDocument()
expect(window.location.hash).toBe('#/knowledge')
})
it('关闭当前标签后切换到相邻页面,并在关闭最后一个标签时回到工作台', async () => {
const user = userEvent.setup()
render(<AppRouteTabs />)
navigate('/knowledge')
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2))
await user.click(screen.getByRole('button', { name: '关闭知识库(/knowledge)' }))
expect(window.location.hash).toBe('#/tickets/42')
navigate('/tickets/42')
await waitFor(() => expect(screen.getByRole('tab', { name: '工单详情' })).toHaveAttribute('data-state', 'active'))
await user.click(screen.getByRole('button', { name: '关闭工单详情(/tickets/42)' }))
expect(window.location.hash).toBe('#/')
navigate('/')
await waitFor(() => expect(screen.getByRole('tab', { name: '工作台' })).toHaveAttribute('data-state', 'active'))
})
it('刷新式重新挂载后保留本次会话已打开的标签', async () => {
const { unmount } = render(<AppRouteTabs />)
navigate('/knowledge')
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2))
unmount()
render(<AppRouteTabs />)
expect(screen.getByRole('tab', { name: '工单详情' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: '知识库' })).toBeInTheDocument()
})
it('标签溢出时隐藏原生滚动条,并提供左右切换和全部页面菜单', async () => {
const user = userEvent.setup()
const { container } = render(<AppRouteTabs />)
navigate('/knowledge')
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2))
const scrollArea = container.querySelector('[data-slot="app-route-tabs-scroll"]') as HTMLDivElement
const scrollBy = vi.fn()
Object.defineProperties(scrollArea, {
clientWidth: { configurable: true, value: 320 },
scrollWidth: { configurable: true, value: 720 },
scrollLeft: { configurable: true, value: 0, writable: true },
scrollBy: { configurable: true, value: scrollBy }
})
fireEvent.scroll(scrollArea)
const nextButton = await screen.findByRole('button', { name: '向右查看已打开页面' })
expect(scrollArea).toHaveClass('scrollbar-hidden', 'scroll-fade-x')
expect(screen.getByRole('button', { name: '向左查看已打开页面' })).toBeDisabled()
await user.click(nextButton)
expect(scrollBy).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'smooth' }))
await user.click(screen.getByRole('button', { name: '查看全部已打开页面,共 2 个' }))
expect(screen.getByRole('menuitem', { name: /工单详情/ })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: /知识库.*当前/ })).toHaveAttribute('aria-current', 'page')
})
})

View File

@@ -0,0 +1,250 @@
import * as React from 'react'
import { Check, ChevronLeft, ChevronRight, Ellipsis, X } from 'lucide-react'
import { useCurrentRoute, type RouteNavigationTab } from '@/app/route-meta'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
const STORAGE_KEY = 'ai-ticket-platform.opened-route-tabs.v2'
const MAX_OPEN_TABS = 12
const SCROLL_EPSILON = 2
type TabScrollState = {
overflow: boolean
canScrollLeft: boolean
canScrollRight: boolean
}
function readOpenedTabs(): RouteNavigationTab[] {
try {
const stored: unknown = JSON.parse(window.sessionStorage.getItem(STORAGE_KEY) ?? '[]')
if (!Array.isArray(stored)) return []
return stored.filter((item): item is RouteNavigationTab => {
if (!item || typeof item !== 'object') return false
const tab = item as Partial<RouteNavigationTab>
return typeof tab.label === 'string' && typeof tab.path === 'string' && tab.path.startsWith('/')
}).slice(-MAX_OPEN_TABS)
} catch {
return []
}
}
function saveOpenedTabs(tabs: RouteNavigationTab[]) {
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(tabs))
}
function addOpenedTab(tabs: RouteNavigationTab[], tab: RouteNavigationTab) {
const existing = tabs.findIndex((item) => item.path === tab.path)
if (existing >= 0) {
return tabs.map((item, index) => index === existing ? { ...item, label: tab.label } : item)
}
return [...tabs, tab].slice(-MAX_OPEN_TABS)
}
export function AppRouteTabs() {
const { route, meta } = useCurrentRoute()
const [openedTabs, setOpenedTabs] = React.useState(readOpenedTabs)
const scrollAreaRef = React.useRef<HTMLDivElement>(null)
const tabRefs = React.useRef(new Map<string, HTMLDivElement>())
const [scrollState, setScrollState] = React.useState<TabScrollState>({
overflow: false,
canScrollLeft: false,
canScrollRight: false
})
const syncScrollState = React.useCallback(() => {
const element = scrollAreaRef.current
if (!element) return
const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth)
setScrollState({
overflow: maxScrollLeft > SCROLL_EPSILON,
canScrollLeft: element.scrollLeft > SCROLL_EPSILON,
canScrollRight: element.scrollLeft < maxScrollLeft - SCROLL_EPSILON
})
}, [])
React.useEffect(() => {
setOpenedTabs((current) => {
const next = addOpenedTab(current, { label: meta.title, path: route })
saveOpenedTabs(next)
return next
})
}, [meta.title, route])
React.useLayoutEffect(() => {
const element = scrollAreaRef.current
if (!element) return
syncScrollState()
element.addEventListener('scroll', syncScrollState, { passive: true })
window.addEventListener('resize', syncScrollState)
const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(syncScrollState)
resizeObserver?.observe(element)
return () => {
element.removeEventListener('scroll', syncScrollState)
window.removeEventListener('resize', syncScrollState)
resizeObserver?.disconnect()
}
}, [openedTabs.length, syncScrollState])
React.useLayoutEffect(() => {
const frame = window.requestAnimationFrame(() => {
const element = scrollAreaRef.current
const activeTab = tabRefs.current.get(route)
if (!element || !activeTab) return
const tabStart = activeTab.offsetLeft
const tabEnd = tabStart + activeTab.offsetWidth
const viewportStart = element.scrollLeft
const viewportEnd = viewportStart + element.clientWidth
let nextScrollLeft = viewportStart
if (tabStart < viewportStart) nextScrollLeft = Math.max(0, tabStart - 16)
if (tabEnd > viewportEnd) nextScrollLeft = tabEnd - element.clientWidth + 16
if (nextScrollLeft !== viewportStart) {
if (typeof element.scrollTo === 'function') element.scrollTo({ left: nextScrollLeft, behavior: 'smooth' })
else element.scrollLeft = nextScrollLeft
}
syncScrollState()
})
return () => window.cancelAnimationFrame(frame)
}, [openedTabs, route, scrollState.overflow, syncScrollState])
const closeTab = (path: string) => {
const closedIndex = openedTabs.findIndex((tab) => tab.path === path)
const next = openedTabs.filter((tab) => tab.path !== path)
saveOpenedTabs(next)
setOpenedTabs(next)
if (path !== route) return
const fallback = next[Math.max(0, closedIndex - 1)] ?? next[0]
window.location.hash = fallback?.path ?? '/'
}
const scrollTabs = (direction: -1 | 1) => {
const element = scrollAreaRef.current
if (!element) return
const distance = Math.max(240, element.clientWidth * 0.65) * direction
if (typeof element.scrollBy === 'function') element.scrollBy({ left: distance, behavior: 'smooth' })
else element.scrollLeft += distance
}
return (
<nav data-slot="app-route-tabs" aria-label="已打开页面" className="flex min-w-0 items-center gap-0.5">
{scrollState.overflow ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="向左查看已打开页面"
disabled={!scrollState.canScrollLeft}
onClick={() => scrollTabs(-1)}
>
<ChevronLeft />
</Button>
) : null}
<Tabs
className="min-w-0 flex-1 gap-0"
value={route}
onValueChange={(path) => {
if (path !== route) window.location.hash = path
}}
>
<TabsList
ref={scrollAreaRef}
variant="line"
data-slot="app-route-tabs-scroll"
data-can-scroll-left={scrollState.canScrollLeft}
data-can-scroll-right={scrollState.canScrollRight}
className="scrollbar-hidden scroll-fade-x h-10 w-full max-w-none justify-start gap-1 overflow-x-auto overflow-y-hidden px-0"
onWheel={(event) => {
if (!scrollState.overflow || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) return
event.preventDefault()
event.currentTarget.scrollLeft += event.deltaY
}}
>
{openedTabs.map((tab) => (
<div
key={tab.path}
ref={(node) => {
if (node) tabRefs.current.set(tab.path, node)
else tabRefs.current.delete(tab.path)
}}
className="group/tab relative flex flex-none items-center"
>
<TabsTrigger value={tab.path} className="max-w-52 flex-none px-3.5 pr-8">
<span className="truncate">{tab.label}</span>
</TabsTrigger>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute right-1 opacity-60 group-hover/tab:opacity-100 focus-visible:opacity-100"
aria-label={`关闭${tab.label}(${tab.path})`}
title={`关闭${tab.label}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
closeTab(tab.path)
}}
>
<X />
</Button>
</div>
))}
</TabsList>
</Tabs>
{scrollState.overflow ? (
<>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="向右查看已打开页面"
disabled={!scrollState.canScrollRight}
onClick={() => scrollTabs(1)}
>
<ChevronRight />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`查看全部已打开页面,共 ${openedTabs.length} 个`}>
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="scrollbar-panel max-h-80 w-64">
<DropdownMenuLabel>已打开页面 · {openedTabs.length}</DropdownMenuLabel>
<DropdownMenuGroup>
{openedTabs.map((tab) => (
<DropdownMenuItem
key={tab.path}
aria-current={tab.path === route ? 'page' : undefined}
onSelect={() => {
if (tab.path !== route) window.location.hash = tab.path
}}
>
{tab.path === route ? <Check /> : <span aria-hidden="true" className="size-4" />}
<span className="truncate">{tab.label}</span>
{tab.path === route ? <DropdownMenuShortcut>当前</DropdownMenuShortcut> : null}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</>
) : null}
</nav>
)
}

View File

@@ -1,5 +1,6 @@
import type * as React from 'react'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
type AppSectionProps = {
@@ -7,9 +8,29 @@ type AppSectionProps = {
description?: string
action?: React.ReactNode
children: React.ReactNode
footer?: React.ReactNode
variant?: 'plain' | 'card'
}
export function AppSection({ title, description, action, children }: AppSectionProps) {
export function AppSection({ title, description, action, children, footer, variant = 'plain' }: AppSectionProps) {
if (variant === 'card') {
return (
<section data-slot="app-section">
<Card className="gap-0 py-0">
<CardHeader className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b bg-muted/30 py-3.5">
<div className="grid gap-1">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
{description ? <CardDescription className="text-xs leading-5">{description}</CardDescription> : null}
</div>
{action ? <div className="self-start">{action}</div> : null}
</CardHeader>
<CardContent className="p-4">{children}</CardContent>
{footer ? <CardFooter className="justify-between gap-3 bg-muted/45 px-4 py-3">{footer}</CardFooter> : null}
</Card>
</section>
)
}
return (
<section data-slot="app-section" className="grid gap-3">
<div className="flex min-h-8 items-start justify-between gap-3">

View File

@@ -0,0 +1,157 @@
import * as React from 'react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AppSidebar } from '@/components/app/AppSidebar'
import { SidebarProvider, useSidebar } from '@/components/ui/sidebar'
import type { AuthMenu } from '@/features/auth/types'
const logout = vi.fn()
vi.mock('@/features/auth/AuthProvider', () => ({
useAuth: () => ({
logout,
user: {
username: 'admin',
display_name: '平台管理员',
menus: [
menu(1, 'workbench', '工作台', '#/', 'home'),
menu(2, 'business', '工单', '', 'ticket', [
menu(21, 'business.tickets', '工单中心', '#/tickets', 'ticket'),
menu(22, 'business.ticket_create', '创建工单', '#/tickets/new', 'briefcase')
]),
menu(3, 'knowledge', '知识与方案', '', 'book_open', [
menu(31, 'platform.knowledge', '知识库', '#/knowledge', 'database'),
menu(32, 'knowledge.search', '知识检索', '#/knowledge/search', 'search')
]),
menu(4, 'empty', '空分组', '', 'settings'),
{ ...menu(5, 'hidden', '隐藏分组', '', 'settings', [menu(51, 'hidden.child', '隐藏页面', '#/hidden', 'settings')]), is_visible: false }
]
}
})
}))
function menu(id: number, code: string, title: string, path: string, icon: string, children: AuthMenu[] = []): AuthMenu {
return {
id,
code,
title,
path,
icon,
permission_code: '',
is_visible: true,
is_enabled: true,
children
}
}
function renderSidebar(defaultOpen = true) {
return render(<SidebarProvider defaultOpen={defaultOpen}><AppSidebar /></SidebarProvider>)
}
function MobileSidebar() {
const { setOpenMobile } = useSidebar()
React.useEffect(() => setOpenMobile(true), [setOpenMobile])
return <AppSidebar />
}
beforeEach(() => {
vi.stubGlobal('ResizeObserver', class {
observe() {}
unobserve() {}
disconnect() {}
})
window.location.hash = '#/knowledge/search'
window.innerWidth = 1280
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes('max-width') ? window.innerWidth < 1024 : false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})
afterEach(() => cleanup())
describe('两级侧栏导航', () => {
it('工作台直接打开,当前页面所属一级分类自动展开并精确高亮二级页面', async () => {
renderSidebar()
expect(screen.getByRole('link', { name: '工作台' })).toHaveAttribute('href', '#/')
expect(screen.queryByText('运营总览')).not.toBeInTheDocument()
expect(screen.queryByText('空分组')).not.toBeInTheDocument()
expect(screen.queryByText('隐藏分组')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '知识与方案' })).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('link', { name: '知识检索' })).toHaveAttribute('data-active', 'true')
expect(screen.getByRole('link', { name: '知识库' })).toHaveAttribute('data-active', 'false')
})
it('只允许一个一级分类展开,并允许手动收起当前分类', async () => {
const user = userEvent.setup()
renderSidebar()
await user.click(screen.getByRole('button', { name: '工单' }))
expect(screen.getByRole('button', { name: '工单' })).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('button', { name: '知识与方案' })).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByRole('link', { name: '工单中心' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '工单' }))
expect(screen.getByRole('button', { name: '工单' })).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('link', { name: '工单中心' })).not.toBeInTheDocument()
})
it('图标侧栏点击一级分类后展开完整侧栏并显示对应二级菜单', async () => {
const user = userEvent.setup()
const { container } = renderSidebar(false)
expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute('data-state', 'collapsed')
const knowledgeButton = screen.getByRole('button', { name: '知识与方案' })
expect(knowledgeButton).toHaveClass('group-data-[collapsible=icon]:mx-auto')
expect(knowledgeButton).toHaveClass('group-data-[collapsible=icon]:!p-0', 'group-data-[collapsible=icon]:justify-center')
expect(knowledgeButton).not.toHaveClass('group-data-[collapsible=icon]:!p-2')
expect(knowledgeButton.closest('[data-sidebar="menu-item"]')).toHaveClass(
'group-data-[collapsible=icon]:w-full',
'group-data-[collapsible=icon]:justify-center'
)
await user.click(knowledgeButton)
await waitFor(() => expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute('data-state', 'expanded'))
expect(screen.getByRole('link', { name: '知识检索' })).toBeInTheDocument()
})
it('桌面端始终提供明确的导航栏收起和展开按钮', async () => {
const user = userEvent.setup()
const { container } = renderSidebar()
await user.click(screen.getByRole('button', { name: '收起导航栏' }))
await waitFor(() => expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute('data-state', 'collapsed'))
await user.click(screen.getByRole('button', { name: '展开导航栏' }))
await waitFor(() => expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute('data-state', 'expanded'))
})
it('用户卡片在展开和图标侧栏中都能打开个人中心', async () => {
const { container } = renderSidebar()
const profileLink = screen.getByRole('link', { name: '打开个人中心' })
expect(profileLink).toHaveAttribute('href', '#/profile')
expect(screen.getByText('个人中心 · 账号与设置')).toBeInTheDocument()
await userEvent.setup().click(screen.getByRole('button', { name: '收起导航栏' }))
await waitFor(() => expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute('data-state', 'collapsed'))
expect(screen.getByRole('link', { name: '打开个人中心' })).toBeInTheDocument()
})
it('移动端点击二级页面后关闭侧栏', async () => {
const user = userEvent.setup()
window.innerWidth = 390
const { container } = render(<SidebarProvider><MobileSidebar /></SidebarProvider>)
const link = await screen.findByRole('link', { name: '知识检索' })
await user.click(link)
await waitFor(() => expect(container.querySelector('[data-slot="sidebar"][data-mobile="true"]')).not.toBeInTheDocument())
})
})

View File

@@ -3,10 +3,12 @@ import {
Bot,
BookOpen,
BriefcaseBusiness,
ChevronRight,
ClipboardList,
Database,
Home,
Library,
LogOut,
Menu,
ScrollText,
Search,
@@ -19,7 +21,10 @@ import {
Workflow
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { AppThemeToggle } from '@/components/app/AppThemeToggle'
import { Avatar, AvatarBadge, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import {
Sidebar,
SidebarContent,
@@ -31,12 +36,16 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarSeparator,
SidebarTrigger,
useSidebar
} from '@/components/ui/sidebar'
import { useAuth } from '@/features/auth/AuthProvider'
import type { AuthMenu } from '@/features/auth/types'
import { cn } from '@/lib/utils'
const iconMap = {
audit: ScrollText,
@@ -76,39 +85,22 @@ function visibleChildren(menu: AuthMenu) {
return menu.children.filter((item) => item.is_visible && item.is_enabled)
}
const knowledgeNavGroup: AuthMenu = {
id: -100,
code: 'knowledge_builtin',
title: '知识库',
path: '#/knowledge',
icon: 'book_open',
permission_code: 'knowledge.view',
is_visible: true,
is_enabled: true,
children: [
{
id: -101,
code: 'knowledge_bases',
title: '知识库',
path: '#/knowledge',
icon: 'book_open',
permission_code: 'knowledge.view',
is_visible: true,
is_enabled: true,
children: []
},
{
id: -102,
code: 'knowledge_search',
title: '知识检索',
path: '#/knowledge/search',
icon: 'search',
permission_code: 'knowledge.view',
is_visible: true,
is_enabled: true,
children: []
}
]
function routeMatches(menuPath: string, currentHash: string) {
if (!menuPath) return false
const route = currentHash.replace(/^#/, '')
const menuRoute = menuPath.replace(/^#/, '')
return route === menuRoute || (menuRoute !== '/' && route.startsWith(`${menuRoute}/`))
}
function activeChildCode(menu: AuthMenu, currentHash: string) {
return visibleChildren(menu)
.filter((item) => routeMatches(item.path, currentHash))
.sort((left, right) => right.path.length - left.path.length)[0]?.code
}
function getUserInitials(name: string) {
const normalizedName = name.trim()
return normalizedName ? normalizedName.slice(0, 2).toUpperCase() : 'AI'
}
type SidebarNavContentProps = {
@@ -118,60 +110,155 @@ type SidebarNavContentProps = {
}
function SidebarNavContent({ groups, currentHash, onNavigate }: SidebarNavContentProps) {
const { isMobile, setOpen, state } = useSidebar()
const activeGroupCode = groups.find((group) => {
const children = visibleChildren(group)
return children.length > 0 && Boolean(activeChildCode(group, currentHash))
})?.code ?? null
const [openGroupCode, setOpenGroupCode] = React.useState<string | null>(activeGroupCode)
React.useEffect(() => {
setOpenGroupCode(activeGroupCode)
}, [activeGroupCode, currentHash])
return (
<>
{groups.map((group) => (
<SidebarGroup key={group.code}>
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
<SidebarGroup className="px-2 py-3">
<SidebarGroupLabel className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-sidebar-foreground/45">
业务导航
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{visibleChildren(group).map((item) => {
const Icon = getIcon(item.icon)
const route = currentHash.replace(/^#/, '')
const itemRoute = item.path.replace(/^#/, '')
const active = route === itemRoute || (itemRoute !== '/' && route.startsWith(`${itemRoute}/`))
<SidebarMenu className="gap-1.5">
{groups.map((group) => {
const children = visibleChildren(group)
const Icon = getIcon(group.icon)
if (group.path && children.length === 0) {
const groupActive = routeMatches(group.path, currentHash)
return (
<SidebarMenuItem key={item.code}>
<SidebarMenuButton asChild isActive={active} size="lg" tooltip={item.title}>
<a href={item.path} onClick={onNavigate}>
<SidebarMenuItem key={group.code} className="group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:w-full group-data-[collapsible=icon]:justify-center">
<SidebarMenuButton
asChild
isActive={groupActive}
size="lg"
tooltip={group.title}
className="relative h-10 rounded-lg data-[active=true]:font-semibold group-data-[collapsible=icon]:mx-auto"
>
<a href={group.path} onClick={onNavigate}>
<span
aria-hidden="true"
className={cn(
'absolute -left-2 h-5 w-0.5 rounded-r-full bg-sidebar-primary transition-opacity group-data-[collapsible=icon]:hidden',
groupActive ? 'opacity-100' : 'opacity-0'
)}
/>
<Icon />
<span>{item.title}</span>
<span className="max-w-40 overflow-hidden whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out motion-reduce:transition-none group-data-[collapsible=icon]:max-w-0 group-data-[collapsible=icon]:-translate-x-1 group-data-[collapsible=icon]:opacity-0">{group.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
)
}
const selectedChildCode = activeChildCode(group, currentHash)
const groupActive = Boolean(selectedChildCode)
const groupOpen = openGroupCode === group.code
return (
<Collapsible
key={group.code}
asChild
open={groupOpen}
onOpenChange={(nextOpen) => {
if (!isMobile && state === 'collapsed') {
setOpen(true)
setOpenGroupCode(group.code)
return
}
setOpenGroupCode(nextOpen ? group.code : null)
}}
>
<SidebarMenuItem className="group/collapsible group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:w-full group-data-[collapsible=icon]:justify-center">
<CollapsibleTrigger asChild>
<SidebarMenuButton
isActive={groupActive}
size="lg"
tooltip={group.title}
aria-label={group.title}
className="relative h-10 rounded-lg data-[active=true]:font-semibold group-data-[collapsible=icon]:mx-auto"
>
<span
aria-hidden="true"
className={cn(
'absolute -left-2 h-5 w-0.5 rounded-r-full bg-sidebar-primary transition-opacity group-data-[collapsible=icon]:hidden',
groupActive ? 'opacity-100' : 'opacity-0'
)}
/>
<Icon />
<span className="max-w-40 overflow-hidden whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out motion-reduce:transition-none group-data-[collapsible=icon]:max-w-0 group-data-[collapsible=icon]:-translate-x-1 group-data-[collapsible=icon]:opacity-0">{group.title}</span>
<ChevronRight className="ml-auto size-3.5 text-sidebar-foreground/45 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub className="my-1 gap-0.5 border-sidebar-border py-1">
{children.map((item) => {
const itemActive = selectedChildCode === item.code
return (
<SidebarMenuSubItem key={item.code}>
<SidebarMenuSubButton
asChild
isActive={itemActive}
className="relative h-8 rounded-md pl-6 data-[active=true]:font-medium"
>
<a href={item.path} onClick={onNavigate}>
<span
aria-hidden="true"
className={cn(
'absolute left-2 size-1.5 rounded-full border transition-colors',
itemActive
? 'border-sidebar-primary bg-sidebar-primary'
: 'border-sidebar-border bg-sidebar'
)}
/>
<span>{item.title}</span>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)
})}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</>
)
}
export function AppSidebar() {
const { user } = useAuth()
const { setOpenMobile } = useSidebar()
const { logout, user } = useAuth()
const { setOpenMobile, state } = useSidebar()
const currentHash = useCurrentHash()
const menuGroups = (user?.menus ?? []).filter((menu) => menu.is_visible && menu.is_enabled)
const hasKnowledgeMenu = menuGroups.some(
(group) => group.code.startsWith('knowledge') || visibleChildren(group).some((item) => item.path.startsWith('#/knowledge'))
)
const groups = hasKnowledgeMenu ? menuGroups : [...menuGroups, knowledgeNavGroup]
const groups = (user?.menus ?? []).filter((menu) => {
if (!menu.is_visible || !menu.is_enabled) return false
return Boolean(menu.path) || visibleChildren(menu).length > 0
})
const userName = user?.display_name || user?.username || '当前用户'
return (
<Sidebar collapsible="icon">
<SidebarHeader className="px-3 py-3">
<Sidebar collapsible="icon" className="border-sidebar-border">
<SidebarHeader className="px-3 pb-3 pt-4 group-data-[collapsible=icon]:px-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild size="lg" tooltip="AI 工单平台">
<a href="#/">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground">
<Workflow />
<SidebarMenuItem className="group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:w-full group-data-[collapsible=icon]:justify-center">
<SidebarMenuButton asChild size="lg" tooltip="智服台" className="h-12 rounded-xl group-data-[collapsible=icon]:mx-auto">
<a href="#/" aria-label="返回工作台">
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-sidebar-primary text-sidebar-primary-foreground shadow-sm shadow-primary/20 transition-[width,height,border-radius] [transition-duration:240ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:rounded-lg">
<Workflow className="size-5" />
</span>
<span className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate font-semibold">AI 工单平台</span>
<span className="truncate text-xs text-sidebar-foreground/60">企业服务治理中台</span>
<span className="grid max-w-40 min-w-0 flex-1 gap-0.5 overflow-hidden text-left leading-tight transition-[max-width,opacity,transform] duration-200 ease-out motion-reduce:transition-none group-data-[collapsible=icon]:max-w-0 group-data-[collapsible=icon]:flex-none group-data-[collapsible=icon]:-translate-x-1 group-data-[collapsible=icon]:opacity-0">
<span className="truncate text-[15px] font-semibold tracking-tight">智服台</span>
<span className="truncate text-[10px] font-medium uppercase tracking-[0.12em] text-sidebar-foreground/45">AI Service Desk</span>
</span>
</a>
</SidebarMenuButton>
@@ -179,23 +266,51 @@ export function AppSidebar() {
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarContent className="scrollbar-panel">
<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>
<SidebarFooter className="border-t border-sidebar-border p-2.5 group-data-[collapsible=icon]:p-2">
<SidebarMenu>
<SidebarMenuItem className="group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:justify-center">
<SidebarMenuButton
asChild
size="lg"
tooltip="个人中心"
isActive={routeMatches('/profile', currentHash)}
className="h-auto min-h-12 rounded-xl border border-sidebar-border bg-sidebar-accent/35 p-2 group-data-[collapsible=icon]:size-10 group-data-[collapsible=icon]:border-0 group-data-[collapsible=icon]:bg-transparent group-data-[collapsible=icon]:p-0"
>
<a href="#/profile" aria-label="打开个人中心" onClick={() => setOpenMobile(false)}>
<Avatar>
<AvatarFallback className="bg-sidebar-primary/10 font-semibold text-sidebar-primary">
{getUserInitials(userName)}
</AvatarFallback>
<AvatarBadge className="bg-success" aria-label="在线" />
</Avatar>
<span className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<span className="block truncate text-xs font-semibold">{userName}</span>
<span className="mt-0.5 block truncate text-[10px] text-sidebar-foreground/55">个人中心 · 账号与设置</span>
</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<div className="flex items-center justify-between gap-2 px-1 group-data-[collapsible=icon]:flex-col group-data-[collapsible=icon]:px-0">
<span className="text-[10px] font-medium text-sidebar-foreground/45 group-data-[collapsible=icon]:hidden">显示与账户</span>
<div className="flex items-center gap-1 group-data-[collapsible=icon]:flex-col">
<SidebarTrigger
className="hidden min-[1024px]:inline-flex"
aria-label={state === 'expanded' ? '收起导航栏' : '展开导航栏'}
title={state === 'expanded' ? '收起导航栏' : '展开导航栏'}
/>
<AppThemeToggle />
<Button variant="ghost" size="icon" aria-label="退出登录" onClick={() => void logout()}><LogOut /></Button>
</div>
</div>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}

View File

@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { AppThemeProvider } from './AppThemeProvider'
import { AppThemeToggle } from './AppThemeToggle'
describe('明暗模式', () => {
it('只提供亮色、暗色和跟随系统', async () => {
const user = userEvent.setup()
render(<AppThemeProvider><AppThemeToggle /></AppThemeProvider>)
await user.click(screen.getByRole('button', { name: /外观模式/ }))
expect(screen.getByRole('menuitemradio', { name: '亮色' })).toBeInTheDocument()
expect(screen.getByRole('menuitemradio', { name: '暗色' })).toBeInTheDocument()
expect(screen.getByRole('menuitemradio', { name: '跟随系统' })).toBeInTheDocument()
expect(screen.queryByText('温润服务')).not.toBeInTheDocument()
expect(screen.queryByText('精密企业')).not.toBeInTheDocument()
})
})

View File

@@ -9,11 +9,10 @@ import {
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
const themeOptions = [
const appearanceOptions = [
{ value: 'light', label: '亮色', icon: Sun },
{ value: 'dark', label: '暗色', icon: Moon },
{ value: 'system', label: '跟随系统', icon: Laptop }
@@ -21,29 +20,23 @@ const themeOptions = [
export function AppThemeToggle() {
const { theme = 'system', setTheme } = useTheme()
const activeTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[2]
const ActiveIcon = activeTheme.icon
const active = appearanceOptions.find((option) => option.value === theme) ?? appearanceOptions[2]
const ActiveIcon = active.icon
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={`主题:${activeTheme.label}`}>
<ActiveIcon data-icon="inline-start" />
<Button variant="ghost" size="icon" aria-label={`外观模式:${active.label}`}>
<ActiveIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuLabel>界面主题</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>外观模式</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
{themeOptions.map((option) => {
{appearanceOptions.map((option) => {
const Icon = option.icon
return (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<Icon data-icon="inline-start" />
{option.label}
</DropdownMenuRadioItem>
)
return <DropdownMenuRadioItem key={option.value} value={option.value}><Icon data-icon="inline-start" />{option.label}</DropdownMenuRadioItem>
})}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>

View File

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

View File

@@ -1,10 +1,13 @@
import * as React from 'react'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AppShell } from '@/app/layout/AppShell'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppSection } from '@/components/app/AppSection'
import { AGENT_DOCKED_OPEN_STORAGE_KEY } from '@/features/agent-workspace/AgentWorkspaceProvider'
const logout = vi.fn()
@@ -44,6 +47,12 @@ vi.mock('@/features/auth/AuthProvider', () => ({
}))
beforeEach(() => {
vi.stubGlobal('ResizeObserver', class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
})
window.sessionStorage.clear()
window.location.hash = '#/tickets'
window.innerWidth = 1280
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
@@ -58,17 +67,21 @@ beforeEach(() => {
}))
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('企业后台 UI 底座', () => {
it('使用 shadcn 侧栏骨架并显示当前中文页面', () => {
const { container } = render(
<AppShell>
<div>业务内容</div>
</AppShell>
)
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(within(container.querySelector('[data-slot="app-topbar"]') as HTMLElement).getByRole('button', { name: '打开导航' })).toBeInTheDocument()
expect(container.querySelector('[data-slot="app-topbar"]')).toHaveTextContent('工单')
expect(container.querySelector('[data-slot="app-global-header"]')).toHaveClass('shrink-0', 'border-b')
expect(container.querySelector('[data-slot="app-shell-scroll"]')).toHaveClass('overflow-y-auto', 'scrollbar-page')
expect(within(container.querySelector('[data-slot="app-global-header"]') as HTMLElement).getAllByRole('tab')).toHaveLength(1)
expect(screen.getByRole('link', { name: '工单中心' })).toHaveAttribute('href', '#/tickets')
})
@@ -90,4 +103,114 @@ describe('企业后台 UI 底座', () => {
expect(section?.tagName).toBe('SECTION')
expect(section?.querySelector('[data-slot="card"]')).not.toBeInTheDocument()
})
it('1920px 下允许通过明确按钮持续切换侧栏状态,不渲染误导性的缩放条', async () => {
const user = userEvent.setup()
window.innerWidth = 1920
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query === '(min-width: 1536px)',
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn()
}))
const { container } = render(<AppShell><div>业务内容</div></AppShell>)
const sidebar = container.querySelector('[data-slot="sidebar"]')
await waitFor(() => expect(sidebar).toHaveAttribute('data-state', 'expanded'))
await user.click(screen.getByRole('button', { name: '收起导航栏' }))
await waitFor(() => expect(sidebar).toHaveAttribute('data-state', 'collapsed'))
expect(within(container).queryByRole('button', { name: '切换侧边栏' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '展开导航栏' }))
await waitFor(() => expect(sidebar).toHaveAttribute('data-state', 'expanded'))
})
it('桌面 Agent 助手可关闭,并可从全局悬浮入口重新打开', async () => {
const user = userEvent.setup()
window.innerWidth = 1440
window.sessionStorage.setItem(AGENT_DOCKED_OPEN_STORAGE_KEY, 'false')
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query === '(min-width: 1280px)',
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn()
}))
const { container } = render(
<AppShell>
<AppPage header={<AppPageHeader title="工单中心" />}>
<p>业务内容</p>
</AppPage>
</AppShell>
)
expect(container.querySelector('[data-slot="app-agent-dock"]')).not.toBeInTheDocument()
expect(container.querySelector('[data-slot="agent-workspace-launcher"]')).toHaveClass('fixed', 'rounded-full')
await user.click(screen.getByRole('button', { name: '打开 Agent 助手' }))
await waitFor(() => expect(container.querySelector('[data-slot="app-agent-dock"]')).toBeInTheDocument())
expect(window.sessionStorage.getItem(AGENT_DOCKED_OPEN_STORAGE_KEY)).toBe('true')
expect(screen.queryByRole('button', { name: '打开 Agent 助手' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '关闭 Agent 助手' }))
await waitFor(() => expect(container.querySelector('[data-slot="app-agent-dock"]')).not.toBeInTheDocument())
expect(window.sessionStorage.getItem(AGENT_DOCKED_OPEN_STORAGE_KEY)).toBe('false')
expect(screen.getByRole('button', { name: '打开 Agent 助手' })).toBeInTheDocument()
})
it('治理页面同样显示全局 Agent 入口,并通过 Sheet 打开', async () => {
const user = userEvent.setup()
window.location.hash = '#/system/users'
const { container } = render(<AppShell><div>用户管理内容</div></AppShell>)
await user.click(screen.getByRole('button', { name: '打开 Agent 助手' }))
expect(await screen.findByRole('dialog')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Agent 助手' })).toBeInTheDocument()
expect(container.querySelector('[data-slot="app-agent-dock"]')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '打开 Agent 助手' })).not.toBeInTheDocument()
})
it('页面上下文栏独立于正文,且不与固定全站页眉重复粘附', () => {
window.location.hash = '#/tickets/new'
const { container } = render(
<AppPage
header={<AppPageHeader title="创建工单" description="记录问题上下文" />}
>
<p>表单内容</p>
</AppPage>
)
const surface = container.querySelector('[data-slot="app-page-header-surface"]')
const content = container.querySelector('[data-slot="app-page-content"]')
expect(surface).toHaveClass('border-b', 'bg-card')
expect(surface).not.toHaveClass('min-[1024px]:sticky')
expect(surface).toHaveTextContent('工单中心')
expect(surface).toHaveTextContent('创建工单')
expect(screen.queryByRole('link', { name: '返回工单中心' })).not.toBeInTheDocument()
expect(within(surface as HTMLElement).getByRole('link', { name: '工单中心' })).toHaveAttribute('href', '#/tickets')
expect(content).toHaveTextContent('表单内容')
expect(surface?.contains(content)).toBe(false)
})
it('详情页允许使用实体编号覆盖当前面包屑节点', () => {
window.location.hash = '#/tickets/42'
render(
<AppPage header={<AppPageHeader title="VPN 连接失败" breadcrumbLabel="INC-20260714-0042" />}>
<p>详情内容</p>
</AppPage>
)
expect(screen.getByRole('link', { name: 'INC-20260714-0042' })).toHaveAttribute('aria-current', 'page')
expect(screen.getByRole('heading', { name: 'VPN 连接失败' })).toBeInTheDocument()
})
})

View File

@@ -1,53 +0,0 @@
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

@@ -0,0 +1,48 @@
import * as React from 'react'
import { AlertDialog as AlertDialogPrimitive } from 'radix-ui'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return <AlertDialogPrimitive.Overlay data-slot="alert-dialog-overlay" className={cn('fixed inset-0 z-50 bg-black/20 backdrop-blur-[1px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', className)} {...props} />
}
function AlertDialogContent({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content data-slot="alert-dialog-content" className={cn('fixed left-1/2 top-1/2 z-50 grid w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border bg-popover p-5 text-popover-foreground shadow-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', className)} {...props} />
</AlertDialogPortal>
)
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="alert-dialog-header" className={cn('flex flex-col gap-2 text-center sm:text-left', className)} {...props} />
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="alert-dialog-footer" className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
}
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return <AlertDialogPrimitive.Title data-slot="alert-dialog-title" className={cn('text-base font-semibold', className)} {...props} />
}
function AlertDialogDescription({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return <AlertDialogPrimitive.Description data-slot="alert-dialog-description" className={cn('text-sm text-muted-foreground', className)} {...props} />
}
function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return <Button asChild><AlertDialogPrimitive.Action data-slot="alert-dialog-action" className={className} {...props} /></Button>
}
function AlertDialogCancel({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return <Button asChild variant="outline"><AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" className={className} {...props} /></Button>
}
export { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger }

View File

@@ -0,0 +1,110 @@
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -0,0 +1,125 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end"
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
)
}
function BubbleContent({
asChild = false,
className,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="bubble-content"
className={cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
className
)}
{...props}
/>
)
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
}
)
function BubbleReactions({
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<"div"> & {
align?: "start" | "end"
side?: "top" | "bottom"
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
)
}
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }

View File

@@ -5,11 +5,11 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md border border-transparent text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap rounded-lg border border-transparent text-sm font-medium outline-none transition-all focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
default: "bg-primary text-primary-foreground shadow-sm shadow-primary/15 hover:-translate-y-px hover:bg-primary/90 hover:shadow-md hover:shadow-primary/20",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:

View File

@@ -12,7 +12,7 @@ function Card({
data-slot="card"
data-size={size}
className={cn(
"flex flex-col gap-4 overflow-hidden rounded-lg border border-border/80 bg-card py-4 text-sm text-card-foreground shadow-sm ring-1 ring-border/40",
"flex flex-col gap-4 overflow-hidden rounded-lg border border-border bg-card py-4 text-sm text-card-foreground shadow-[0_6px_20px_hsl(var(--foreground)/0.045)]",
className
)}
{...props}

View File

@@ -0,0 +1,33 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -61,7 +61,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"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",
"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-2xl bg-popover p-4 text-sm text-popover-foreground shadow-2xl shadow-foreground/15 ring-1 ring-foreground/10 duration-150 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
)}
{...props}

View File

@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}

View File

@@ -0,0 +1,70 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex min-h-9 w-full min-w-0 items-center rounded-lg border border-input bg-card/80 shadow-sm shadow-foreground/[0.02] transition-colors outline-none has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/15 has-[[data-slot][aria-invalid=true]]:border-destructive has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start": "order-first pl-2",
"inline-end": "order-last pr-2",
"block-start": "order-first w-full justify-start px-3 pt-2",
"block-end": "order-last w-full justify-start px-3 pb-2",
},
},
defaultVariants: { align: "inline-start" },
}
)
function InputGroupAddon({ className, align = "inline-start", ...props }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(event) => {
if ((event.target as HTMLElement).closest("button")) return
;(event.currentTarget.parentElement?.querySelector("input, textarea") as HTMLInputElement | HTMLTextAreaElement | null)?.focus()
}}
{...props}
/>
)
}
function InputGroupButton({ className, type = "button", variant = "ghost", size = "icon-sm", ...props }: React.ComponentProps<typeof Button>) {
return <Button type={type} variant={variant} size={size} className={cn("shrink-0 shadow-none", className)} {...props} />
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return <span className={cn("flex items-center gap-2 text-sm text-muted-foreground", className)} {...props} />
}
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
return <Input data-slot="input-group-control" className={cn("flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:bg-transparent focus-visible:ring-0", className)} {...props} />
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
return <Textarea data-slot="input-group-control" className={cn("flex-1 resize-none rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:bg-transparent focus-visible:ring-0", className)} {...props} />
}
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea }

View File

@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-base outline-none transition-colors file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 md:text-sm",
"h-9 w-full min-w-0 rounded-lg border border-input bg-card/80 px-3 py-1 text-base shadow-sm shadow-foreground/[0.02] outline-none transition-all file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:bg-card focus-visible:ring-3 focus-visible:ring-ring/15 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 md:text-sm",
className
)}
{...props}

View File

@@ -0,0 +1,69 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const markerVariants = cva(
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
{
variants: {
variant: {
default: "",
separator:
"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
border: "border-b border-border pb-2",
},
},
}
)
function Marker({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof markerVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="marker"
data-variant={variant}
className={cn(markerVariants({ variant, className }))}
{...props}
/>
)
}
function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-icon"
aria-hidden="true"
className={cn(
"size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-content"
className={cn(
"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export { Marker, MarkerIcon, MarkerContent, markerVariants }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller"
import { ArrowDown } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
function MessageScrollerProvider(props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>) {
return <MessageScrollerPrimitive.Provider {...props} />
}
function MessageScroller({ className, ...props }: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
return <MessageScrollerPrimitive.Root data-slot="message-scroller" className={cn("group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden", className)} {...props} />
}
function MessageScrollerViewport({ className, ...props }: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
return <MessageScrollerPrimitive.Viewport data-slot="message-scroller-viewport" className={cn("size-full min-h-0 min-w-0 overflow-y-auto overscroll-contain", className)} {...props} />
}
function MessageScrollerContent({ className, ...props }: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
return <MessageScrollerPrimitive.Content data-slot="message-scroller-content" className={cn("flex h-max min-h-full flex-col gap-4", className)} {...props} />
}
function MessageScrollerItem({ className, scrollAnchor = false, ...props }: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
return <MessageScrollerPrimitive.Item data-slot="message-scroller-item" scrollAnchor={scrollAnchor} className={cn("min-w-0 shrink-0", className)} {...props} />
}
function MessageScrollerButton({ direction = "end", className, children, render, variant = "secondary", size = "icon-sm", ...props }: React.ComponentProps<typeof MessageScrollerPrimitive.Button> & Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<MessageScrollerPrimitive.Button
data-slot="message-scroller-button"
direction={direction}
className={cn("absolute inset-x-1/2 bottom-3 -translate-x-1/2 transition-opacity data-[active=false]:pointer-events-none data-[active=false]:opacity-0", className)}
render={render ?? <Button variant={variant} size={size} />}
{...props}
>
{children ?? <><ArrowDown /><span className="sr-only">滚动到最新消息</span></>}
</MessageScrollerPrimitive.Button>
)
}
export {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
}

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
function Message({
className,
align = "start",
...props
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="message"
data-align={align}
className={cn(
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
className
)}
{...props}
/>
)
}
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-avatar"
className={cn(
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
className
)}
{...props}
/>
)
}
function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-content"
className={cn(
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
className
)}
{...props}
/>
)
}
function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-header"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
className
)}
{...props}
/>
)
}
function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-footer"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
className
)}
{...props}
/>
)
}
export {
MessageGroup,
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
}

View File

@@ -42,7 +42,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
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-9 w-fit select-none items-center justify-between gap-1.5 whitespace-nowrap rounded-lg border border-input bg-card/80 px-3 py-1 text-sm shadow-sm shadow-foreground/[0.02] outline-none transition-all focus-visible:border-ring focus-visible:bg-card focus-visible:ring-3 focus-visible:ring-ring/15 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
@@ -67,7 +67,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-96 min-w-36 overflow-x-hidden overflow-y-auto rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-border", className)}
className={cn("relative z-50 max-h-96 min-w-36 overflow-x-hidden overflow-y-auto rounded-xl bg-popover text-popover-foreground shadow-xl shadow-foreground/10 ring-1 ring-border", className)}
position={position}
align={align}
{...props}

View File

@@ -218,7 +218,7 @@ function Sidebar({
<div
data-slot="sidebar-gap"
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"relative w-[--sidebar-width] bg-transparent transition-[width] [transition-duration:240ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
@@ -230,7 +230,7 @@ function Sidebar({
data-slot="sidebar-container"
data-side={side}
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] [transition-duration:240ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none 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.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+1rem+2px)]"
@@ -467,7 +467,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
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-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",
"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,gap,margin,background-color,color] [transition-duration:240ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!gap-0 group-data-[collapsible=icon]:!p-0 group-data-[collapsible=icon]:justify-center 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: {
variant: {
@@ -478,7 +478,7 @@ const sidebarMenuButtonVariants = cva(
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
lg: "h-12 text-sm",
},
},
defaultVariants: {

View File

@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"flex min-h-24 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 md:text-sm",
"flex min-h-24 w-full rounded-lg border border-input bg-card/80 px-3 py-2 text-base shadow-sm shadow-foreground/[0.02] outline-none transition-all placeholder:text-muted-foreground focus-visible:border-ring focus-visible:bg-card focus-visible:ring-3 focus-visible:ring-ring/15 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 md:text-sm",
className
)}
{...props}

View File

@@ -0,0 +1,44 @@
import * as React from 'react'
import { type VariantProps } from 'class-variance-authority'
import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui'
import { toggleVariants } from '@/components/ui/toggle'
import { cn } from '@/lib/utils'
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants> & { spacing?: number }>({
size: 'default', variant: 'default', spacing: 2
})
function ToggleGroup({ className, variant, size, spacing = 2, children, ...props }:
React.ComponentProps<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants> & { spacing?: number }) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ '--gap': spacing } as React.CSSProperties}
className={cn('group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg', className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({ className, children, variant = 'default', size = 'default', ...props }:
React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-spacing={context.spacing}
className={cn(toggleVariants({ variant: context.variant || variant, size: context.size || size }), 'shrink-0', className)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,30 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Toggle as TogglePrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
const toggleVariants = cva(
'group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4',
{
variants: {
variant: {
default: 'bg-transparent',
outline: 'border border-input bg-transparent hover:bg-muted'
},
size: {
default: 'h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
sm: 'h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5',
lg: 'h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2'
}
},
defaultVariants: { variant: 'default', size: 'default' }
}
)
function Toggle({ className, variant = 'default', size = 'default', ...props }:
React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
return <TogglePrimitive.Root data-slot="toggle" className={cn(toggleVariants({ variant, size, className }))} {...props} />
}
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,61 @@
import * as React from 'react'
import { Bot } from 'lucide-react'
import { useCurrentRoute } from '@/app/route-meta'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useAgentWorkspace } from './AgentWorkspaceProvider'
const DESKTOP_AGENT_QUERY = '(min-width: 1280px)'
function useCanDockAgent() {
const [canDock, setCanDock] = React.useState(() => window.matchMedia(DESKTOP_AGENT_QUERY).matches)
React.useEffect(() => {
const query = window.matchMedia(DESKTOP_AGENT_QUERY)
const update = () => setCanDock(query.matches)
update()
query.addEventListener('change', update)
return () => query.removeEventListener('change', update)
}, [])
return canDock
}
export function AgentWorkspaceLauncher() {
const { meta } = useCurrentRoute()
const { dockedOpen, setDockedOpen, sheetOpen, setSheetOpen } = useAgentWorkspace()
const canDock = useCanDockAgent()
const dockActive = meta.agent === 'docked' && canDock && dockedOpen
if (dockActive || sheetOpen) return null
const openAgent = () => {
if (meta.agent === 'docked' && canDock) {
setDockedOpen(true)
return
}
setSheetOpen(true)
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
data-slot="agent-workspace-launcher"
type="button"
size="icon-lg"
className="fixed bottom-20 right-4 z-30 size-12 rounded-full shadow-lg motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-90 sm:bottom-6 sm:right-6"
onClick={openAgent}
aria-label="打开 Agent 助手"
>
<Bot />
</Button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={8}>打开 Agent 助手</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,37 @@
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AgentWorkspacePanel } from './AgentWorkspacePanel'
import { AgentWorkspaceProvider } from './AgentWorkspaceProvider'
afterEach(() => cleanup())
describe('Agent 本地演示面板', () => {
it('发送消息不产生网络请求并持续显示演示标识', async () => {
window.location.hash = '#/tickets'
const fetchSpy = vi.spyOn(window, 'fetch')
const user = userEvent.setup()
render(<AgentWorkspaceProvider><div className="h-[700px]"><AgentWorkspacePanel /></div></AgentWorkspaceProvider>)
await user.type(screen.getByRole('textbox', { name: '向 Agent 输入演示问题' }), '如何排查超时?')
await user.click(screen.getByRole('button', { name: '发送演示消息' }))
expect(await screen.findByText('如何排查超时?')).toBeInTheDocument()
expect(screen.getAllByText('演示').length).toBeGreaterThan(0)
expect(fetchSpy).not.toHaveBeenCalled()
fetchSpy.mockRestore()
})
it('重新挂载后不会恢复用户消息', async () => {
window.location.hash = '#/knowledge'
const user = userEvent.setup()
const first = render(<AgentWorkspaceProvider><div className="h-[700px]"><AgentWorkspacePanel /></div></AgentWorkspaceProvider>)
await user.type(screen.getByRole('textbox', { name: '向 Agent 输入演示问题' }), '临时消息')
await user.click(screen.getByRole('button', { name: '发送演示消息' }))
expect(screen.getByText('临时消息')).toBeInTheDocument()
first.unmount()
render(<AgentWorkspaceProvider><div className="h-[700px]"><AgentWorkspacePanel /></div></AgentWorkspaceProvider>)
expect(screen.queryByText('临时消息')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,158 @@
import * as React from 'react'
import { Bot, Check, ChevronDown, Clipboard, FileText, Link2, ListChecks, PanelRightClose, Send, Sparkles } from 'lucide-react'
import { toast } from 'sonner'
import { Avatar, AvatarBadge, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Bubble, BubbleContent } from '@/components/ui/bubble'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from '@/components/ui/input-group'
import { Marker, MarkerContent } from '@/components/ui/marker'
import { Message, MessageAvatar, MessageContent, MessageFooter, MessageHeader } from '@/components/ui/message'
import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport } from '@/components/ui/message-scroller'
import { Separator } from '@/components/ui/separator'
import { useAgentWorkspace } from './AgentWorkspaceProvider'
function AgentSection({ title, icon: Icon, children, defaultOpen = true }: { title: string; icon: React.ComponentType<{ className?: string }>; children: React.ReactNode; defaultOpen?: boolean }) {
return (
<Collapsible defaultOpen={defaultOpen} className="rounded-lg border bg-card">
<CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-xs font-semibold text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/40">
<Icon className="size-4 text-primary" />
<span className="flex-1">{title}</span>
<ChevronDown className="size-3.5 text-muted-foreground transition-transform [[data-state=closed]_&]:-rotate-90" />
</CollapsibleTrigger>
<CollapsibleContent><Separator /><div className="p-3">{children}</div></CollapsibleContent>
</Collapsible>
)
}
function usePanelScrollEdges() {
const scrollRef = React.useRef<HTMLDivElement>(null)
const contentRef = React.useRef<HTMLDivElement>(null)
const [edges, setEdges] = React.useState({ canScrollUp: false, canScrollDown: false })
const syncEdges = React.useCallback(() => {
const element = scrollRef.current
if (!element) return
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight)
setEdges({
canScrollUp: element.scrollTop > 2,
canScrollDown: element.scrollTop < maxScrollTop - 2
})
}, [])
React.useLayoutEffect(() => {
const element = scrollRef.current
if (!element) return
syncEdges()
element.addEventListener('scroll', syncEdges, { passive: true })
const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(syncEdges)
resizeObserver?.observe(element)
if (contentRef.current) resizeObserver?.observe(contentRef.current)
return () => {
element.removeEventListener('scroll', syncEdges)
resizeObserver?.disconnect()
}
}, [syncEdges])
return { scrollRef, contentRef, edges }
}
export function AgentWorkspacePanel({ compact = false, onClose }: { compact?: boolean; onClose?: () => void }) {
const { context, fixture, messages, sendMessage } = useAgentWorkspace()
const [draft, setDraft] = React.useState('')
const { scrollRef, contentRef, edges } = usePanelScrollEdges()
function handleSend() {
if (!draft.trim()) return
sendMessage(draft)
setDraft('')
}
async function copyReply() {
await navigator.clipboard.writeText(fixture.reply)
toast.success('建议回复已复制')
}
return (
<section aria-label="Agent 演示助手" className="flex h-full min-h-0 flex-col bg-background">
<header className="flex items-center gap-3 border-b bg-card px-4 py-3">
<Avatar size="lg"><AvatarFallback className="bg-primary/10 text-primary"><Bot /></AvatarFallback><AvatarBadge><span className="sr-only">在线</span></AvatarBadge></Avatar>
<div className="min-w-0 flex-1"><div className="flex items-center gap-2"><h2 className="text-sm font-semibold">Agent 助手</h2><Badge variant="info">演示</Badge></div><p className="mt-0.5 text-xs text-muted-foreground">本地交互 · 不写入业务数据</p></div>
{onClose ? (
<Button type="button" variant="ghost" size="icon-sm" onClick={onClose} aria-label="关闭 Agent 助手" title="关闭 Agent 助手">
<PanelRightClose />
</Button>
) : <Sparkles className="size-4 text-primary" />}
</header>
<div className="border-b bg-card px-4 py-3">
<div className="flex items-center justify-between gap-2"><p className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">当前上下文</p><Badge variant="outline">{context.entityType || context.routeKey}</Badge></div>
<p className="mt-1.5 truncate text-sm font-medium">{context.title}</p>
{context.status ? <p className="mt-1 text-xs text-muted-foreground">状态:{context.status}</p> : null}
</div>
<div
ref={scrollRef}
data-can-scroll-up={edges.canScrollUp}
data-can-scroll-down={edges.canScrollDown}
className="scrollbar-panel scroll-fade-y min-h-0 flex-1 overflow-y-auto p-3"
>
<div ref={contentRef} className="flex flex-col gap-2.5">
<div className={compact ? 'h-48' : 'h-56'}>
<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport className="scrollbar-panel">
<MessageScrollerContent className="px-1 py-2">
<Marker variant="separator"><MarkerContent>当前页面会话</MarkerContent></Marker>
{messages.map((message) => (
<MessageScrollerItem key={message.id} messageId={message.id} scrollAnchor={message.role === 'user'}>
<Message align={message.role === 'user' ? 'end' : 'start'}>
{message.role === 'assistant' ? <MessageAvatar><Avatar size="sm"><AvatarFallback><Bot /></AvatarFallback></Avatar></MessageAvatar> : null}
<MessageContent>
<MessageHeader>{message.role === 'assistant' ? 'Agent 助手 · 演示' : '你'}</MessageHeader>
<Bubble variant={message.role === 'user' ? 'default' : 'outline'} align={message.role === 'user' ? 'end' : 'start'}><BubbleContent>{message.content}</BubbleContent></Bubble>
<MessageFooter>{message.time}</MessageFooter>
</MessageContent>
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</div>
<AgentSection title="可能原因" icon={Sparkles}>
<ol className="grid gap-2">{fixture.causes.map((item, index) => <li key={item} className="flex gap-2 text-xs leading-5"><span className="font-mono text-primary">{index + 1}.</span><span>{item}</span></li>)}</ol>
</AgentSection>
<AgentSection title="推荐排查步骤" icon={ListChecks}>
<ol className="grid gap-2">{fixture.steps.map((item, index) => <li key={item} className="flex gap-2 text-xs leading-5"><span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10 font-mono text-[10px] text-primary">{index + 1}</span><span>{item}</span></li>)}</ol>
</AgentSection>
<AgentSection title="建议回复" icon={Clipboard}>
<p className="text-xs leading-5 text-muted-foreground">{fixture.reply}</p>
<Button className="mt-3 w-full" variant="outline" size="sm" onClick={() => void copyReply()}><Clipboard data-icon="inline-start" />复制回复</Button>
</AgentSection>
<AgentSection title="相关记录" icon={Link2} defaultOpen={false}>
<div className="grid gap-2">{fixture.related.map((item) => <div key={item.label} className="flex items-center gap-2 text-xs"><Check className="size-3.5 text-success" /><span className="flex-1">{item.label}</span><span className="text-muted-foreground">{item.meta}</span></div>)}</div>
</AgentSection>
<AgentSection title="引用来源" icon={FileText} defaultOpen={false}>
<div className="grid gap-2">{fixture.sources.map((item, index) => <div key={item.label} className="rounded-md bg-primary/5 p-2 text-xs ring-1 ring-primary/10"><div className="flex items-center gap-2"><span className="font-mono text-primary">[{index + 1}]</span><span className="font-medium">{item.label}</span></div><p className="mt-1 pl-6 text-muted-foreground">{item.meta}</p></div>)}</div>
</AgentSection>
</div>
</div>
<footer className="border-t bg-card p-3">
<InputGroup className="items-end">
<InputGroupTextarea aria-label="向 Agent 输入演示问题" className="min-h-16" placeholder="输入问题,按按钮发送(演示)" value={draft} onChange={(event) => setDraft(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); handleSend() } }} />
<InputGroupAddon align="block-end" className="justify-between"><span className="text-[11px] font-normal">刷新页面后清空</span><InputGroupButton aria-label="发送演示消息" disabled={!draft.trim()} onClick={handleSend}><Send /></InputGroupButton></InputGroupAddon>
</InputGroup>
</footer>
</section>
)
}

View File

@@ -0,0 +1,168 @@
import * as React from 'react'
import { useCurrentRoute } from '@/app/route-meta'
export type AgentPanelContext = {
routeKey: string
entityType?: string
entityId?: string | number
title: string
status?: string
metadata?: Record<string, string>
}
export type AgentMessage = {
id: string
role: 'assistant' | 'user'
content: string
time: string
}
export type AgentPanelFixture = {
messages: AgentMessage[]
causes: string[]
steps: string[]
reply: string
related: Array<{ label: string; meta: string }>
sources: Array<{ label: string; meta: string }>
}
type AgentWorkspaceValue = {
context: AgentPanelContext
fixture: AgentPanelFixture
messages: AgentMessage[]
dockedOpen: boolean
setDockedOpen: (open: boolean) => void
sheetOpen: boolean
setSheetOpen: (open: boolean) => void
setPageContext: (context: AgentPanelContext | null) => void
sendMessage: (content: string) => void
}
export const AGENT_DOCKED_OPEN_STORAGE_KEY = 'ai-ticket-platform.agent-docked-open.v1'
function getInitialDockedOpen() {
if (typeof window === 'undefined') return true
try {
return window.sessionStorage.getItem(AGENT_DOCKED_OPEN_STORAGE_KEY) !== 'false'
} catch {
return true
}
}
const AgentWorkspaceContext = React.createContext<AgentWorkspaceValue | null>(null)
function nowLabel() {
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date())
}
function buildFixture(context: AgentPanelContext): AgentPanelFixture {
const id = context.entityId ? ` ${context.entityId}` : ''
if (context.routeKey.startsWith('ticket')) {
return {
messages: [{ id: 'welcome', role: 'assistant', content: `我已读取当前工单${id}在页面中展示的信息。可以按下方步骤进行排查,本面板不会修改工单数据。`, time: nowLabel() }],
causes: ['问题上下文或复现条件仍不完整', '依赖服务出现超时或短时不可用', '权限、配置或环境信息与预期不一致'],
steps: ['核对问题对象、环境、时间范围与错误信息', '检查最近一次有效日志和依赖服务状态', '根据验证结果更新处理记录并选择下一流程状态'],
reply: '您好,我们已收到并开始排查该问题。目前正在核对相关环境与服务状态,有明确进展后会第一时间同步给您。',
related: [{ label: '相似工单排查记录', meta: '演示关联 · 3 条' }, { label: '同类问题解决方案', meta: '演示匹配 · 2 条' }],
sources: [{ label: '工单页面已展示字段', meta: '当前页面' }, { label: '通用技术支持排查规范', meta: '演示知识' }]
}
}
if (context.routeKey.startsWith('knowledge') || context.routeKey.startsWith('solution')) {
return {
messages: [{ id: 'welcome', role: 'assistant', content: '我可以基于当前页面展示的知识范围,帮助整理检索思路、引用依据和可复用步骤。', time: nowLabel() }],
causes: ['关键词与知识条目使用的术语不一致', '检索范围或知识库边界过窄', '条目缺少问题表现、适用环境或验证结果'],
steps: ['先使用错误码、系统名和问题表现组合检索', '比较来源、更新时间与适用范围', '将有效结论整理为可验证的处理步骤'],
reply: '已根据现有知识整理出处理依据。建议先完成环境与错误信息核对,再按引用步骤逐项验证。',
related: [{ label: '相关解决方案', meta: '演示匹配 · 4 条' }, { label: '最近更新知识', meta: '演示关联 · 5 条' }],
sources: [{ label: '当前知识库范围', meta: '页面上下文' }, { label: '已发布解决方案', meta: '演示引用' }]
}
}
if (context.routeKey === 'workbench') {
return {
messages: [{ id: 'welcome', role: 'assistant', content: '我已汇总当前工作台中的待办与风险指标,可帮助你安排今天的处理顺序。', time: nowLabel() }],
causes: ['待处理工单尚未完成分派', '高优先级工单需要优先确认影响范围', '待确认工单可能因回复信息不足而停滞'],
steps: ['先处理高优先级和即将超时工单', '补齐待处理工单的责任人与问题上下文', '集中确认已给出方案但仍待反馈的工单'],
reply: '建议优先处理高风险待办,再跟进待确认事项;完成后复查最近更新中是否仍有未分派工单。',
related: [{ label: '我的待办', meta: '当前工作台' }, { label: '最近处理', meta: '当前工作台' }],
sources: [{ label: '工作台指标', meta: '页面上下文' }, { label: '工单优先级规则', meta: '演示知识' }]
}
}
return {
messages: [{ id: 'welcome', role: 'assistant', content: `当前位于“${context.title}”。我可以解释页面结构与配置关系,但不会提交或更改任何配置。`, time: nowLabel() }],
causes: ['配置项之间可能存在依赖关系', '权限范围可能影响页面可见内容', '运行记录与当前配置版本可能不一致'],
steps: ['核对当前对象与启用状态', '确认依赖项、权限和版本信息', '在保存前检查影响范围并保留审计说明'],
reply: '这是配置与治理页面的演示建议,请在核对影响范围和权限后再执行真实操作。',
related: [{ label: '相关配置', meta: '演示关联' }],
sources: [{ label: '当前页面字段', meta: '页面上下文' }]
}
}
function deterministicReply(context: AgentPanelContext, content: string) {
const keyword = content.trim().slice(0, 28)
if (context.routeKey.startsWith('ticket')) return `已记录你的问题“${keyword}”。建议先核对工单中的环境、问题对象和最近更新时间,再按排查步骤逐项验证。`
if (context.routeKey.startsWith('knowledge') || context.routeKey.startsWith('solution')) return `针对“${keyword}”,建议扩大同义词和错误码组合,并优先核对来源时间与适用范围。`
if (context.routeKey === 'workbench') return `针对“${keyword}”,建议先从高优先级、即将超时和待确认三组任务中确定今天的处理顺序。`
return `已结合“${context.title}”页面理解“${keyword}”。这是本地演示建议,不会读取额外数据或修改配置。`
}
export function AgentWorkspaceProvider({ children }: { children: React.ReactNode }) {
const { meta } = useCurrentRoute()
const defaultContext = React.useMemo<AgentPanelContext>(() => ({ routeKey: meta.key, title: meta.title }), [meta.key, meta.title])
const [pageContext, setPageContext] = React.useState<AgentPanelContext | null>(null)
const context = pageContext?.routeKey === meta.key ? pageContext : defaultContext
const fixture = React.useMemo(() => buildFixture(context), [context])
const [messages, setMessages] = React.useState<AgentMessage[]>(fixture.messages)
const [dockedOpen, setDockedOpenState] = React.useState(getInitialDockedOpen)
const [sheetOpen, setSheetOpen] = React.useState(false)
const setDockedOpen = React.useCallback((open: boolean) => {
setDockedOpenState(open)
try {
window.sessionStorage.setItem(AGENT_DOCKED_OPEN_STORAGE_KEY, String(open))
} catch {
// 浏览器禁用 sessionStorage 时,仅保留当前页面内存状态。
}
}, [])
React.useEffect(() => {
setPageContext(null)
setMessages(buildFixture(defaultContext).messages)
setSheetOpen(false)
}, [defaultContext])
const sendMessage = React.useCallback((content: string) => {
const normalized = content.trim()
if (!normalized) return
const stamp = Date.now()
setMessages((current) => [
...current,
{ id: `user-${stamp}`, role: 'user', content: normalized, time: nowLabel() },
{ id: `assistant-${stamp}`, role: 'assistant', content: deterministicReply(context, normalized), time: nowLabel() }
])
}, [context])
const value = React.useMemo(
() => ({ context, fixture, messages, dockedOpen, setDockedOpen, sheetOpen, setSheetOpen, setPageContext, sendMessage }),
[context, dockedOpen, fixture, messages, sendMessage, setDockedOpen, sheetOpen]
)
return <AgentWorkspaceContext.Provider value={value}>{children}</AgentWorkspaceContext.Provider>
}
export function useAgentWorkspace() {
const value = React.useContext(AgentWorkspaceContext)
if (!value) throw new Error('useAgentWorkspace 必须在 AgentWorkspaceProvider 内使用')
return value
}
export function useOptionalAgentWorkspace() {
return React.useContext(AgentWorkspaceContext)
}
export function useAgentPageContext(context: AgentPanelContext | null) {
const workspace = useAgentWorkspace()
React.useEffect(() => {
workspace.setPageContext(context)
return () => workspace.setPageContext(null)
}, [context, workspace.setPageContext])
}

View File

@@ -1,5 +1,5 @@
import * as React from 'react'
import { ArrowDown, ArrowLeft, ArrowUp, CopyPlus, Play, Plus, Save, Send, Trash2 } from 'lucide-react'
import { ArrowDown, ArrowUp, CopyPlus, Play, Plus, Save, Send, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { AppEmptyState } from '@/components/app/AppEmptyState'
@@ -88,10 +88,9 @@ export function AgentDetailPage({ agentId }: { agentId: number }) {
async function rollback(version: number) { try { await rollbackAgent(agentId, version); toast.success(`已回滚到 v${version}`); await load() } catch (reason) { toast.error(reason instanceof Error ? reason.message : '回滚失败') } }
async function run() { try { const result = await startAgentRun(agentId, { version: testVersion === 'draft' ? 'draft' : Number(testVersion), input: parseJsonObject(testInput, '测试输入') }); toast.success('Agent 运行已进入队列'); window.location.hash = `/agent-runs/${result.public_id}` } catch (reason) { toast.error(reason instanceof Error ? reason.message : '启动运行失败') } }
if (error) return <AppPage><AppEmptyState title="无法打开 Agent" description={error} /></AppPage>
if (!detail) return <AppPage><div className="h-64 animate-pulse rounded-md bg-muted" /></AppPage>
return <AppPage>
<AppPageHeader title={detail.name} description={`${detail.code} · 有序步骤执行,任一步失败后立即终止。`} meta={detail.active_version ? <Badge variant="success">当前 v{detail.active_version}</Badge> : <Badge variant="secondary">尚未发布</Badge>} actions={<><Button variant="outline" onClick={() => { window.location.hash = '/agents' }}><ArrowLeft data-icon="inline-start" />返回</Button>{draft ? <><Button variant="outline" onClick={() => void save()}><Save data-icon="inline-start" />保存草稿</Button><Button onClick={() => void publish()}><Send data-icon="inline-start" />发布 v{draft.version}</Button></> : <Button onClick={() => void createDraft()}><CopyPlus data-icon="inline-start" />创建新草稿</Button>}</>} />
if (error) return <AppPage header={<AppPageHeader title="Agent 详情" />}><AppEmptyState title="无法打开 Agent" description={error} /></AppPage>
if (!detail) return <AppPage header={<AppPageHeader title="Agent 详情" />}><div className="h-64 animate-pulse rounded-md bg-muted" /></AppPage>
return <AppPage header={<AppPageHeader title={detail.name} breadcrumbLabel={detail.code} description={`${detail.code} · 有序步骤执行,任一步失败后立即终止。`} meta={detail.active_version ? <Badge variant="success">当前 v{detail.active_version}</Badge> : <Badge variant="secondary">尚未发布</Badge>} actions={<>{draft ? <><Button variant="outline" onClick={() => void save()}><Save data-icon="inline-start" />保存草稿</Button><Button onClick={() => void publish()}><Send data-icon="inline-start" />发布 v{draft.version}</Button></> : <Button onClick={() => void createDraft()}><CopyPlus data-icon="inline-start" />创建新草稿</Button>}</>} />}>
<Tabs defaultValue="basic"><TabsList variant="line" className="max-w-full overflow-x-auto"><TabsTrigger value="basic">基础与模型</TabsTrigger><TabsTrigger value="steps">步骤</TabsTrigger><TabsTrigger value="schema">输入 Schema</TabsTrigger><TabsTrigger value="test">测试</TabsTrigger><TabsTrigger value="versions">版本</TabsTrigger></TabsList>
<TabsContent value="basic" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-5"><FieldGroup className="grid gap-3 md:grid-cols-2"><AppField label="名称"><Input value={meta.name} onChange={(e) => setMeta({ ...meta, name: e.target.value })} /></AppField><AppField label="描述"><Input value={meta.description} onChange={(e) => setMeta({ ...meta, description: e.target.value })} /></AppField><AppField label="模型"><Select value={modelId} onValueChange={setModelId}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{deployments.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.display_name}</SelectItem>)}</SelectContent></Select></AppField><AppField label="Temperature 覆盖" description="留空使用模型默认值"><Input type="number" min="0" max="2" step="0.1" value={temperature} onChange={(e) => setTemperature(e.target.value)} /></AppField><AppField label="最大输出 Token 覆盖" description="留空使用模型默认值"><Input type="number" min="1" max="32768" value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)} /></AppField></FieldGroup><AppField label="系统 Prompt"><Textarea className="min-h-40" value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} /></AppField></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
<TabsContent value="steps" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-3">{steps.map((step, index) => <Card key={`${index}-${step.code}`}><CardHeader className="flex-row items-center justify-between"><div><CardTitle className="text-sm">{index + 1}. {step.code}</CardTitle><CardDescription>输入映射后传递给 Skill</CardDescription></div><div className="flex gap-1"><Button variant="ghost" size="icon-sm" disabled={index === 0} onClick={() => moveStep(index, -1)}><ArrowUp /><span className="sr-only">上移</span></Button><Button variant="ghost" size="icon-sm" disabled={index === steps.length - 1} onClick={() => moveStep(index, 1)}><ArrowDown /><span className="sr-only">下移</span></Button><Button variant="ghost" size="icon-sm" disabled={steps.length === 1} onClick={() => setSteps((current) => current.filter((_, i) => i !== index))}><Trash2 /><span className="sr-only">删除</span></Button></div></CardHeader><CardContent className="grid gap-3 md:grid-cols-2"><AppField label="步骤代码"><Input value={step.code} onChange={(e) => updateStep(index, { code: e.target.value })} /></AppField><AppField label="Skill 版本"><Select value={step.skill_version_id} onValueChange={(value) => updateStep(index, { skill_version_id: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{skills.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.definitionName} · v{item.version}({item.status})</SelectItem>)}</SelectContent></Select></AppField><div className="md:col-span-2"><JsonEditor id={`agent-step-mapping-${index}`} label="输入映射" value={step.mapping} onChange={(value) => updateStep(index, { mapping: value })} rows={6} /></div></CardContent></Card>)}<Button variant="outline" disabled={steps.length >= 10} onClick={() => setSteps((current) => [...current, { code: `step_${current.length + 1}`, skill_version_id: String(skills[0]?.id ?? ''), mapping: '{}' }])}><Plus data-icon="inline-start" />添加步骤</Button></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>

View File

@@ -1,24 +1,25 @@
import * as React from 'react'
import { Plus, X } from 'lucide-react'
import { History, Plus, X } from 'lucide-react'
import { toast } from 'sonner'
import { AppCreateDialog } from '@/components/app/AppCreateDialog'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppField } from '@/components/app/AppField'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppSection } from '@/components/app/AppSection'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { MobileEditGuard } from '@/features/ai-platform/components/MobileEditGuard'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { listDeployments } from '@/features/modelhub/api'
import type { ModelDeployment } from '@/features/modelhub/types'
import { getSkill, listSkills } from '@/features/skills/api'
import type { SkillVersion } from '@/features/skills/types'
import { useIsMobile } from '@/hooks/use-mobile'
import { formatDateTime } from '@/lib/format'
import { createAgent, listAgents } from './api'
@@ -27,12 +28,22 @@ import type { AgentDefinition } from './types'
type StepForm = { code: string; skill_version_id: string; mapping: string }
type SkillOption = SkillVersion & { definitionName: string }
const initialStep: StepForm = {
code: 'step_1',
skill_version_id: '',
mapping: '{\n "problem": "$input.problem"\n}'
}
export function AgentListPage() {
const mobile = useIsMobile()
const [items, setItems] = React.useState<AgentDefinition[]>([])
const [deployments, setDeployments] = React.useState<ModelDeployment[]>([])
const [skills, setSkills] = React.useState<SkillOption[]>([])
const [form, setForm] = React.useState({ code: '', name: '', description: '', model_deployment_id: '' })
const [steps, setSteps] = React.useState<StepForm[]>([{ code: 'step_1', skill_version_id: '', mapping: '{\n "problem": "$input.problem"\n}' }])
const [steps, setSteps] = React.useState<StepForm[]>([initialStep])
const [createOpen, setCreateOpen] = React.useState(false)
const [creating, setCreating] = React.useState(false)
const [createError, setCreateError] = React.useState('')
const [error, setError] = React.useState('')
const load = React.useCallback(async () => {
@@ -40,22 +51,52 @@ export function AgentListPage() {
const [nextAgents, nextDeployments, definitions] = await Promise.all([listAgents(), listDeployments(), listSkills()])
const details = await Promise.all(definitions.map((item) => getSkill(item.id)))
const options = details.flatMap((item) => item.versions.map((version) => ({ ...version, definitionName: item.name })))
setItems(nextAgents); setDeployments(nextDeployments); setSkills(options)
setItems(nextAgents)
setDeployments(nextDeployments)
setSkills(options)
setForm((current) => ({ ...current, model_deployment_id: current.model_deployment_id || String(nextDeployments[0]?.id ?? '') }))
setSteps((current) => current.map((step) => ({ ...step, skill_version_id: step.skill_version_id || String(options[0]?.id ?? '') })))
setError('')
} catch (reason) { setError(reason instanceof Error ? reason.message : '加载 Agent 失败') }
} catch (reason) {
setError(reason instanceof Error ? reason.message : '加载 Agent 失败')
}
}, [])
React.useEffect(() => { void load() }, [load])
function updateStep(index: number, patch: Partial<StepForm>) { setSteps((current) => current.map((step, stepIndex) => stepIndex === index ? { ...step, ...patch } : step)) }
function updateStep(index: number, patch: Partial<StepForm>) {
setSteps((current) => current.map((step, stepIndex) => stepIndex === index ? { ...step, ...patch } : step))
}
async function handleCreate(event: React.FormEvent) {
function openCreateDialog() {
if (mobile) {
toast.info('Agent 流程配置请使用桌面端完成')
return
}
setCreateOpen(true)
}
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setCreating(true)
setCreateError('')
try {
const created = await createAgent({ ...form, model_deployment_id: Number(form.model_deployment_id), system_prompt: '你是技术支持分析助手。必须返回符合当前 Skill 输出 Schema 的 JSON。', input_schema: { type: 'object', properties: { problem: { type: 'string' }, environment: { type: 'string' } }, required: ['problem'], additionalProperties: false }, example_input: { problem: '采集任务长时间没有更新', environment: '生产环境' }, steps: steps.map((step) => ({ code: step.code, skill_version_id: Number(step.skill_version_id), input_mapping: JSON.parse(step.mapping) as object })) })
toast.success('Agent 草稿已创建'); window.location.hash = `/agents/${created.id}`
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '创建失败') }
const created = await createAgent({
...form,
model_deployment_id: Number(form.model_deployment_id),
system_prompt: '你是技术支持分析助手。必须返回符合当前 Skill 输出 Schema 的 JSON。',
input_schema: { type: 'object', properties: { problem: { type: 'string' }, environment: { type: 'string' } }, required: ['problem'], additionalProperties: false },
example_input: { problem: '采集任务长时间没有更新', environment: '生产环境' },
steps: steps.map((step) => ({ code: step.code, skill_version_id: Number(step.skill_version_id), input_mapping: JSON.parse(step.mapping) as object }))
})
toast.success('Agent 草稿已创建')
setCreateOpen(false)
window.location.hash = `/agents/${created.id}`
} catch (reason) {
setCreateError(reason instanceof Error ? reason.message : '创建失败')
} finally {
setCreating(false)
}
}
const columns: AppDataTableColumn<AgentDefinition>[] = [
@@ -65,13 +106,97 @@ export function AgentListPage() {
{ key: 'updated', header: '更新时间', cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.updated_at)}</span> }
]
return <AppPage>
<AppPageHeader title="Agent 管理" description="把已定义的 Skill 组合为有序、失败即终止的执行流程。" actions={<Button variant="outline" onClick={() => { window.location.hash = '/agent-runs' }}>查看运行记录</Button>} />
<MobileEditGuard><AppSection title="新建 Agent 草稿" description="首版最多 10 个有序步骤,映射源仅允许 $input.* 与 $steps.&lt;code&gt;.output.*。"><form className="grid gap-4" onSubmit={handleCreate}>
<FieldGroup className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"><AppField label="代码" required><Input required pattern="[a-z][a-z0-9_]*" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></AppField><AppField label="名称" required><Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></AppField><AppField label="描述"><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></AppField><AppField label="模型" required><Select value={form.model_deployment_id} onValueChange={(value) => setForm({ ...form, model_deployment_id: value })}><SelectTrigger className="w-full"><SelectValue placeholder="选择模型" /></SelectTrigger><SelectContent>{deployments.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.display_name}</SelectItem>)}</SelectContent></Select></AppField></FieldGroup>
<div className="grid gap-3">{steps.map((step, index) => <Card key={index}><CardHeader className="flex-row items-center justify-between"><CardTitle className="text-sm">步骤 {index + 1}</CardTitle>{steps.length > 1 ? <Button type="button" variant="ghost" size="icon-sm" onClick={() => setSteps((current) => current.filter((_, i) => i !== index))}><X /><span className="sr-only">删除步骤</span></Button> : null}</CardHeader><CardContent><FieldGroup className="grid gap-3 md:grid-cols-[1fr_1.5fr_2fr]"><AppField label="步骤代码" required><Input required value={step.code} onChange={(e) => updateStep(index, { code: e.target.value })} /></AppField><AppField label="Skill 版本" required><Select value={step.skill_version_id} onValueChange={(value) => updateStep(index, { skill_version_id: value })}><SelectTrigger className="w-full"><SelectValue placeholder="选择 Skill" /></SelectTrigger><SelectContent>{skills.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.definitionName} · v{item.version}({item.status})</SelectItem>)}</SelectContent></Select></AppField><AppField label="输入映射 JSON" required><Input className="font-mono text-xs" value={step.mapping.replace(/\s+/g, ' ')} onChange={(e) => updateStep(index, { mapping: e.target.value })} /></AppField></FieldGroup></CardContent></Card>)}</div>
<div className="flex justify-between"><Button type="button" variant="outline" disabled={steps.length >= 10} onClick={() => setSteps((current) => [...current, { code: `step_${current.length + 1}`, skill_version_id: String(skills[0]?.id ?? ''), mapping: '{}' }])}><Plus data-icon="inline-start" />添加步骤</Button><Button type="submit" disabled={!deployments.length || !skills.length}><Plus data-icon="inline-start" />创建 Agent</Button></div>
</form></AppSection></MobileEditGuard>
{error ? <AppEmptyState title="无法加载 Agent" description={error} /> : <AppDataTable columns={columns} rows={items} getRowKey={(item) => item.id} emptyTitle="暂无 Agent" emptyDescription="请先准备模型配置和 Skill,再创建 Agent。" onRowClick={(item) => { window.location.hash = `/agents/${item.id}` }} />}
return (
<AppPage
header={<AppPageHeader
title="Agent 管理"
description="把已定义的 Skill 组合为有序、失败即终止的执行流程。"
actions={
<>
<Button variant="outline" onClick={() => { window.location.hash = '/agent-runs' }}>
<History data-icon="inline-start" />查看运行记录
</Button>
<Button onClick={openCreateDialog}>
<Plus data-icon="inline-start" />新建 Agent
</Button>
</>
}
/>}
>
<AppCreateDialog
open={createOpen}
onOpenChange={(open) => { setCreateOpen(open); if (!open) setCreateError('') }}
title="新建 Agent 草稿"
description="配置基础信息和执行步骤。首版最多支持 10 个有序步骤。"
submitLabel="创建 Agent"
onSubmit={handleCreate}
pending={creating}
submitDisabled={!deployments.length || !skills.length}
error={createError}
className="sm:max-w-4xl"
bodyClassName="grid gap-5"
>
<FieldGroup className="grid gap-5 md:grid-cols-2">
<AppField label="代码" description="仅支持小写字母、数字和下划线。" required>
<Input autoFocus required pattern="[a-z][a-z0-9_]*" placeholder="例如:collection_support" value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} />
</AppField>
<AppField label="名称" required>
<Input required placeholder="例如:采集问题分析助手" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
</AppField>
<AppField label="描述">
<Input placeholder="说明 Agent 的处理场景" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
</AppField>
<AppField label="模型" required>
<Select value={form.model_deployment_id} onValueChange={(value) => setForm({ ...form, model_deployment_id: value })}>
<SelectTrigger className="w-full"><SelectValue placeholder="选择模型" /></SelectTrigger>
<SelectContent><SelectGroup>{deployments.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.display_name}</SelectItem>)}</SelectGroup></SelectContent>
</Select>
</AppField>
</FieldGroup>
<div className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="font-medium">执行步骤</h3>
<p className="mt-1 text-xs text-muted-foreground">映射源仅允许 $input.* 与 $steps.&lt;code&gt;.output.*。</p>
</div>
<Button type="button" variant="outline" size="sm" disabled={steps.length >= 10} onClick={() => setSteps((current) => [...current, { code: `step_${current.length + 1}`, skill_version_id: String(skills[0]?.id ?? ''), mapping: '{}' }])}>
<Plus data-icon="inline-start" />添加步骤
</Button>
</div>
{steps.map((step, index) => (
<Card key={index} className="bg-muted/20 shadow-none">
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm">步骤 {index + 1}</CardTitle>
{steps.length > 1 ? (
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setSteps((current) => current.filter((_, stepIndex) => stepIndex !== index))}>
<X /><span className="sr-only">删除步骤</span>
</Button>
) : null}
</CardHeader>
<CardContent>
<FieldGroup className="grid gap-4 md:grid-cols-[1fr_1.4fr_2fr]">
<AppField label="步骤代码" required><Input required value={step.code} onChange={(event) => updateStep(index, { code: event.target.value })} /></AppField>
<AppField label="Skill 版本" required>
<Select value={step.skill_version_id} onValueChange={(value) => updateStep(index, { skill_version_id: value })}>
<SelectTrigger className="w-full"><SelectValue placeholder="选择 Skill" /></SelectTrigger>
<SelectContent><SelectGroup>{skills.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.definitionName} · v{item.version}({item.status})</SelectItem>)}</SelectGroup></SelectContent>
</Select>
</AppField>
<AppField label="输入映射 JSON" required>
<Textarea className="min-h-20 font-mono text-xs" value={step.mapping} onChange={(event) => updateStep(index, { mapping: event.target.value })} />
</AppField>
</FieldGroup>
</CardContent>
</Card>
))}
</div>
</AppCreateDialog>
{error ? <AppEmptyState title="无法加载 Agent" description={error} /> : (
<AppDataTable columns={columns} rows={items} getRowKey={(item) => item.id} emptyTitle="暂无 Agent" emptyDescription="请先准备模型配置和 Skill,再创建 Agent。" onRowClick={(item) => { window.location.hash = `/agents/${item.id}` }} />
)}
</AppPage>
)
}

View File

@@ -1,5 +1,5 @@
import * as React from 'react'
import { ArrowLeft, Ban, Braces, Clock3, Coins, RotateCw } from 'lucide-react'
import { Ban, Braces, Clock3, Coins, RotateCw } from 'lucide-react'
import { toast } from 'sonner'
import { AppEmptyState } from '@/components/app/AppEmptyState'
@@ -41,11 +41,10 @@ export function AgentRunDetailPage({ publicId }: { publicId: string }) {
async function cancel() { try { setRun(await cancelAgentRun(publicId)); toast.success('取消请求已提交') } catch (reason) { toast.error(reason instanceof Error ? reason.message : '取消失败') } }
if (error) return <AppPage><AppEmptyState title="无法打开运行详情" description={error} /></AppPage>
if (!run) return <AppPage><div className="h-72 animate-pulse rounded-md bg-muted" /></AppPage>
if (error) return <AppPage header={<AppPageHeader title="运行记录详情" />}><AppEmptyState title="无法打开运行详情" description={error} /></AppPage>
if (!run) return <AppPage header={<AppPageHeader title="运行记录详情" />}><div className="h-72 animate-pulse rounded-md bg-muted" /></AppPage>
const selected = run.steps.find((step) => step.id === selectedStepId) ?? null
return <AppPage maxWidth="7xl">
<AppPageHeader title={run.agent_name} description={`运行 ${run.public_id} · 创建于 ${formatDateTime(run.created_at)}`} meta={<div className="flex gap-1"><StatusBadge status={run.status} />{run.sandbox ? <Badge variant="warning">草稿沙箱</Badge> : <Badge variant="secondary">发布 v{run.agent_version}</Badge>}</div>} actions={<><Button variant="outline" onClick={() => { window.location.hash = '/agent-runs' }}><ArrowLeft data-icon="inline-start" />返回</Button><Button variant="outline" onClick={() => void load()}><RotateCw data-icon="inline-start" />刷新</Button>{['queued', 'running'].includes(run.status) ? <Button variant="destructive" onClick={() => void cancel()}><Ban data-icon="inline-start" />尽力取消</Button> : null}</>} />
return <AppPage maxWidth="7xl" header={<AppPageHeader title={run.agent_name} breadcrumbLabel={run.public_id} description={`运行 ${run.public_id} · 创建于 ${formatDateTime(run.created_at)}`} meta={<div className="flex gap-1"><StatusBadge status={run.status} />{run.sandbox ? <Badge variant="warning">草稿沙箱</Badge> : <Badge variant="secondary">发布 v{run.agent_version}</Badge>}</div>} actions={<><Button variant="outline" onClick={() => void load()}><RotateCw data-icon="inline-start" />刷新</Button>{['queued', 'running'].includes(run.status) ? <Button variant="destructive" onClick={() => void cancel()}><Ban data-icon="inline-start" />尽力取消</Button> : null}</>} />}>
{run.error_message ? <Alert variant="destructive"><AlertTitle>{run.error_code || '运行失败'}</AlertTitle><AlertDescription>{run.error_message}</AlertDescription></Alert> : null}
<div className="grid gap-4 lg:grid-cols-[280px_minmax(0,1fr)_300px]">
<Card className="gap-0 py-0"><CardHeader className="border-b border-border py-4"><CardTitle className="text-sm">执行脊柱</CardTitle><CardDescription>{run.steps.length}/{run.total_steps} 个步骤已创建</CardDescription></CardHeader><CardContent className="max-h-[640px] overflow-auto p-2"><ol className="grid gap-1">{run.steps.map((step) => <li key={step.id}><button type="button" onClick={() => setSelectedStepId(step.id)} className={cn('grid w-full grid-cols-[28px_minmax(0,1fr)] gap-2 rounded-md p-2 text-left transition-colors hover:bg-muted', selectedStepId === step.id && 'bg-muted')}><span className={cn('flex size-7 items-center justify-center rounded-full border text-xs font-medium', step.status === 'succeeded' && 'border-success/40 bg-success/10 text-success', step.status === 'failed' && 'border-destructive/40 bg-destructive/10 text-destructive')}>{step.order}</span><span className="min-w-0"><span className="block truncate font-medium">{step.name}</span><span className="mt-1 flex items-center justify-between gap-2"><span className="font-mono text-xs text-muted-foreground">{step.code}</span><StatusBadge status={step.status} /></span></span></button>{step.order < run.steps.length ? <span className="ml-[21px] block h-3 border-l border-border" /> : null}</li>)}</ol></CardContent></Card>

View File

@@ -26,5 +26,5 @@ export function AgentRunListPage() {
{ key: 'usage', header: 'Token / 耗时', cell: (item) => <span className="text-muted-foreground">{item.input_tokens + item.output_tokens} · {item.duration_ms} ms</span> },
{ key: 'created', header: '创建时间', cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.created_at)}</span> }
]
return <AppPage><AppPageHeader title="Agent 运行记录" description="查看沙箱与正式版本的执行结果、Token、耗时和版本快照。" actions={<><Button variant="outline" onClick={() => { window.location.hash = '/agents' }}>Agent 管理</Button><Button variant="outline" onClick={() => void load()}><RefreshCw data-icon="inline-start" />刷新</Button></>} />{error ? <AppEmptyState title="无法加载运行记录" description={error} /> : <AppDataTable columns={columns} rows={items} getRowKey={(item) => item.public_id} emptyTitle="暂无运行记录" emptyDescription="从 Agent 详情的测试页启动一次运行。" onRowClick={(item) => { window.location.hash = `/agent-runs/${item.public_id}` }} />}</AppPage>
return <AppPage header={<AppPageHeader title="Agent 运行记录" description="查看沙箱与正式版本的执行结果、Token、耗时和版本快照。" actions={<><Button variant="outline" onClick={() => { window.location.hash = '/agents' }}>Agent 管理</Button><Button variant="outline" onClick={() => void load()}><RefreshCw data-icon="inline-start" />刷新</Button></>} />}>{error ? <AppEmptyState title="无法加载运行记录" description={error} /> : <AppDataTable columns={columns} rows={items} getRowKey={(item) => item.public_id} emptyTitle="暂无运行记录" emptyDescription="从 Agent 详情的测试页启动一次运行。" onRowClick={(item) => { window.location.hash = `/agent-runs/${item.public_id}` }} />}</AppPage>
}

View File

@@ -1,18 +1,18 @@
import * as React from 'react'
import { AlertCircle, LockKeyhole, UserRound } from 'lucide-react'
import { AlertCircle, CheckCircle2, LockKeyhole, Sparkles, UserRound, Workflow } from 'lucide-react'
import { AppField } from '@/components/app/AppField'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { useAuth } from './AuthProvider'
export function LoginPage() {
const { login } = useAuth()
const [username, setUsername] = React.useState('admin')
const [password, setPassword] = React.useState('admin12345')
const [username, setUsername] = React.useState('')
const [password, setPassword] = React.useState('')
const [error, setError] = React.useState('')
const [submitting, setSubmitting] = React.useState(false)
@@ -20,89 +20,37 @@ export function LoginPage() {
event.preventDefault()
setError('')
setSubmitting(true)
try {
await login(username, password)
} catch {
setError('登录失败,请检查账号、密码或后端服务状态。')
} finally {
setSubmitting(false)
}
try { await login(username, password) }
catch { setError('登录失败,请检查账号、密码或后端服务状态。') }
finally { setSubmitting(false) }
}
return (
<main className="min-h-screen bg-background text-foreground">
<div className="grid min-h-screen lg:grid-cols-[minmax(0,1fr)_440px]">
<section className="hidden border-r border-border bg-surface px-10 py-8 lg:flex lg:flex-col lg:justify-between">
<div>
<div className="inline-flex h-10 items-center rounded-md border border-border bg-background px-3 text-sm font-semibold">
AI 工单平台
</div>
<div className="mt-20 max-w-2xl">
<p className="text-sm font-medium text-primary">企业后台治理基座</p>
<h1 className="mt-4 text-4xl font-semibold leading-tight tracking-normal text-foreground">
统一登录、菜单权限与工单运营入口
</h1>
<p className="mt-4 max-w-xl text-base leading-7 text-muted-foreground">
面向企业技术支持团队,先建立稳定的管理后台体验,再承接工单、知识库和智能处理能力。
</p>
</div>
</div>
<div className="grid max-w-xl grid-cols-3 gap-3 text-sm">
{['用户与角色', '菜单权限', '工单治理'].map((item) => (
<div key={item} className="rounded-md border border-border bg-background px-3 py-2 text-muted-foreground">
{item}
</div>
))}
</div>
<div className="grid min-h-screen lg:grid-cols-[minmax(0,1fr)_480px]">
<section className="relative hidden overflow-hidden bg-primary px-12 py-10 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 opacity-20 [background-image:radial-gradient(circle_at_20%_20%,white_0,transparent_34%),linear-gradient(120deg,transparent_45%,white_46%,transparent_47%)]" />
<div className="relative flex items-center gap-3"><span className="flex size-10 items-center justify-center rounded-xl bg-white/15 ring-1 ring-white/25"><Workflow /></span><div><p className="font-semibold">智服台</p><p className="text-xs text-white/70">AI 技术支持工作台</p></div></div>
<div className="relative max-w-2xl"><span className="inline-flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs ring-1 ring-white/20"><Sparkles className="size-3.5" />知识与工单协同</span><h1 className="mt-5 text-4xl font-semibold leading-tight tracking-[-0.035em]">让每一次技术支持<br />都有清晰依据与下一步</h1><p className="mt-5 max-w-xl text-base leading-7 text-white/72">统一管理工单、解决方案和知识资产,通过结构化 Agent 工作区辅助排查与沟通。</p></div>
<div className="relative grid max-w-2xl grid-cols-3 gap-3">{['集中处理待办', '复用解决方案', '追踪审计记录'].map((item) => <div key={item} className="flex items-center gap-2 rounded-lg bg-white/10 px-3 py-3 text-sm ring-1 ring-white/15"><CheckCircle2 className="size-4" />{item}</div>)}</div>
</section>
<section className="flex min-h-screen items-center justify-center px-5 py-8">
<Card className="w-full max-w-[400px]">
<CardHeader>
<CardTitle>登录平台</CardTitle>
</CardHeader>
<CardContent>
<section className="flex min-h-screen items-center justify-center px-5 py-8 sm:px-10">
<div className="w-full max-w-[400px]">
<div className="mb-7 flex items-center gap-3 lg:hidden"><span className="flex size-10 items-center justify-center rounded-xl bg-primary text-primary-foreground"><Workflow /></span><div><p className="font-semibold">智服台</p><p className="text-xs text-muted-foreground">AI 技术支持工作台</p></div></div>
<Card className="gap-5 py-6 app-panel-shadow">
<CardHeader className="px-6"><CardTitle className="text-xl">登录工作台</CardTitle><CardDescription>使用企业账号进入技术支持工作区</CardDescription></CardHeader>
<CardContent className="px-6">
<form className="grid gap-4" onSubmit={handleSubmit}>
{error ? (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>无法登录</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<AppField label="账号" htmlFor="username" invalid={Boolean(error)} required>
<div className="relative">
<UserRound className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="username"
autoComplete="username"
aria-invalid={Boolean(error)}
className="pl-9"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</div>
</AppField>
<AppField label="密码" htmlFor="password" invalid={Boolean(error)} required>
<div className="relative">
<LockKeyhole className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="password"
type="password"
autoComplete="current-password"
aria-invalid={Boolean(error)}
className="pl-9"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</div>
</AppField>
<Button type="submit" className="mt-1 w-full" disabled={submitting}>
{submitting ? '登录中...' : '登录'}
</Button>
{error ? <Alert variant="destructive"><AlertCircle className="size-4" /><AlertTitle>无法登录</AlertTitle><AlertDescription>{error}</AlertDescription></Alert> : null}
<AppField label="账号" htmlFor="username" invalid={Boolean(error)} required><div className="relative"><UserRound className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input id="username" autoComplete="username" aria-invalid={Boolean(error)} className="pl-9" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="请输入账号" /></div></AppField>
<AppField label="密码" htmlFor="password" invalid={Boolean(error)} required><div className="relative"><LockKeyhole className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input id="password" type="password" autoComplete="current-password" aria-invalid={Boolean(error)} className="pl-9" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" /></div></AppField>
<Button type="submit" size="lg" className="mt-1 w-full" disabled={submitting || !username || !password}>{submitting ? '登录中...' : '登录'}</Button>
{import.meta.env.DEV ? <div className="rounded-lg bg-muted px-3 py-2 text-xs leading-5 text-muted-foreground"><strong className="text-foreground">本地开发演示:</strong> admin / admin12345</div> : null}
</form>
</CardContent>
</Card>
</div>
</section>
</div>
</main>

View File

@@ -6,6 +6,20 @@ export async function getCurrentUser() {
return apiRequest<CurrentUser>('/accounts/auth/me')
}
export function updateCurrentUser(payload: { display_name?: string; email?: string }) {
return apiRequest<CurrentUser>('/accounts/auth/me', {
method: 'PATCH',
body: JSON.stringify(payload)
})
}
export function changePassword(currentPassword: string, newPassword: string) {
return apiRequest<{ status: string }>('/accounts/auth/change-password', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
})
}
export async function login(username: string, password: string) {
return apiRequest<CurrentUser>('/accounts/auth/login', {
method: 'POST',

View File

@@ -30,6 +30,8 @@ export type CurrentUser = {
username: string
display_name: string
email: string
last_login: string | null
date_joined: string
is_superuser: boolean
roles: AuthRole[]
user_groups: AuthUserGroup[]

View File

@@ -34,11 +34,14 @@ const previewColumns: AppDataTableColumn<PreviewRow>[] = [
export function DesignSystemPreview() {
return (
<AppPage maxWidth="6xl" contentClassName="gap-6">
<AppPageHeader
<AppPage
maxWidth="6xl"
contentClassName="gap-6"
header={<AppPageHeader
title="设计系统预览"
actions={<Button>创建工单</Button>}
/>
/>}
>
<Card>
<CardHeader>

View File

@@ -1,15 +1,19 @@
import * as React from 'react'
import { Plus, Search } from 'lucide-react'
import { BookOpen, Grid2X2, List, Plus, Search } from 'lucide-react'
import { AppCreateDialog } from '@/components/app/AppCreateDialog'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppField } from '@/components/app/AppField'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppSection } from '@/components/app/AppSection'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
import { Textarea } from '@/components/ui/textarea'
import { useAgentPageContext } from '@/features/agent-workspace/AgentWorkspaceProvider'
import { formatDateTime } from '@/lib/format'
import { createKnowledgeBase, listKnowledgeBases } from './api'
@@ -64,7 +68,18 @@ const columns: AppDataTableColumn<KnowledgeBase>[] = [
export function KnowledgeBaseListPage() {
const [bases, setBases] = React.useState<KnowledgeBase[]>([])
const [form, setForm] = React.useState<KnowledgeBaseForm>(emptyForm)
const [createOpen, setCreateOpen] = React.useState(false)
const [creating, setCreating] = React.useState(false)
const [createError, setCreateError] = React.useState('')
const [error, setError] = React.useState('')
const [query, setQuery] = React.useState('')
const [viewMode, setViewMode] = React.useState<'grid' | 'list'>('grid')
const visibleBases = React.useMemo(() => {
const keyword = query.trim().toLowerCase()
return keyword ? bases.filter((base) => `${base.name} ${base.description} ${base.scope}`.toLowerCase().includes(keyword)) : bases
}, [bases, query])
const agentContext = React.useMemo(() => ({ routeKey: 'knowledge', entityType: 'knowledge-base-list', title: `知识库 · ${bases.length} 个`, status: error ? '加载异常' : '已同步页面数据', metadata: { 启用: String(bases.filter((base) => base.enabled).length), 当前显示: String(visibleBases.length) } }), [bases, error, visibleBases.length])
useAgentPageContext(agentContext)
async function load() {
try {
@@ -81,34 +96,66 @@ export function KnowledgeBaseListPage() {
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setCreating(true)
setCreateError('')
try {
await createKnowledgeBase(form)
setForm(emptyForm)
setCreateOpen(false)
await load()
} catch (reason) {
setCreateError(reason instanceof Error ? reason.message : '创建知识库失败')
} finally {
setCreating(false)
}
}
return (
<AppPage contentClassName="gap-5">
<AppPageHeader
<AppPage
contentClassName="gap-5"
header={<AppPageHeader
title="知识库"
description="集中管理支持知识、文档范围与检索入口"
actions={
<div className="flex gap-2">
<Button variant="outline" onClick={() => (window.location.hash = '/knowledge/search')}>
<Search data-icon="inline-start" />
知识检索
</Button>
<Button onClick={() => (window.location.hash = '/knowledge/items/new')}>
<Button variant="outline" onClick={() => (window.location.hash = '/knowledge/items/new')}>
<Plus data-icon="inline-start" />
新建知识条目
</Button>
<Button onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
新增知识库
</Button>
</div>
}
/>
/>}
>
<AppSection title="新建知识库">
<form className="grid gap-4 md:grid-cols-[1fr_1fr_1fr_auto] md:items-end" onSubmit={handleCreate}>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
{[['知识库总数', bases.length], ['已启用', bases.filter((base) => base.enabled).length], ['当前结果', visibleBases.length]].map(([label, value], index) => <Card key={String(label)} className={index === 2 ? 'hidden lg:flex' : ''}><CardContent className="flex items-center gap-3 px-4"><span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary"><BookOpen className="size-4" /></span><span><span className="block text-xs text-muted-foreground">{label}</span><strong className="mt-1 block text-2xl font-semibold tabular-nums">{value}</strong></span></CardContent></Card>)}
</div>
<Card className="py-0"><CardContent className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center"><InputGroup className="flex-1"><InputGroupAddon><Search /></InputGroupAddon><InputGroupInput value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索知识库、范围或描述" /></InputGroup><div className="flex rounded-lg border bg-muted/30 p-0.5"><Button variant={viewMode === 'grid' ? 'secondary' : 'ghost'} size="icon-sm" aria-label="卡片视图" onClick={() => setViewMode('grid')}><Grid2X2 /></Button><Button variant={viewMode === 'list' ? 'secondary' : 'ghost'} size="icon-sm" aria-label="列表视图" onClick={() => setViewMode('list')}><List /></Button></div></CardContent></Card>
<AppCreateDialog
open={createOpen}
onOpenChange={(open) => { setCreateOpen(open); if (!open) setCreateError('') }}
title="新增知识库"
description="按业务领域建立知识边界,后续可持续维护文档和知识条目。"
submitLabel="创建知识库"
onSubmit={handleCreate}
pending={creating}
error={createError}
>
<div className="grid gap-5">
<AppField label="名称" htmlFor="knowledge-base-name" required>
<Input
id="knowledge-base-name"
autoFocus
required
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
@@ -123,29 +170,30 @@ export function KnowledgeBaseListPage() {
placeholder="例如:数据采集"
/>
</AppField>
<AppField label="描述" htmlFor="knowledge-base-description">
<Input
<AppField label="描述" htmlFor="knowledge-base-description" description="简要说明适用场景,方便支持人员判断是否使用。">
<Textarea
id="knowledge-base-description"
value={form.description}
onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))}
placeholder="知识库用途说明"
/>
</AppField>
<Button type="submit">创建知识库</Button>
</form>
</AppSection>
</div>
</AppCreateDialog>
{error ? <AppEmptyState title="无法加载知识库" description={error} /> : null}
{!error ? (
{!error && viewMode === 'list' ? (
<AppDataTable
columns={columns}
rows={bases}
rows={visibleBases}
getRowKey={(base) => base.id}
emptyTitle="暂无知识库"
emptyDescription="创建知识库后,可继续维护文档、知识条目和检索内容。"
footer={<p className="text-sm text-muted-foreground">共 {bases.length} 个知识库</p>}
footer={<p className="text-sm text-muted-foreground">当前显示 {visibleBases.length} 个知识库</p>}
/>
) : null}
{!error && viewMode === 'grid' ? <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{visibleBases.map((base) => <Card key={base.id} className="app-card-hover"><CardHeader><div className="flex items-start justify-between gap-3"><span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary"><BookOpen className="size-4" /></span><Badge variant={base.enabled ? 'success' : 'secondary'}>{base.enabled ? '已启用' : '已停用'}</Badge></div><CardTitle className="mt-2">{base.name}</CardTitle><CardDescription className="line-clamp-2 min-h-10">{base.description || '暂无描述'}</CardDescription></CardHeader><CardContent><Badge variant="outline">{base.scope || '未设置范围'}</Badge></CardContent><CardFooter className="justify-between text-xs text-muted-foreground"><span>{formatDateTime(base.updated_at)}</span><Button variant="ghost" size="sm" onClick={() => (window.location.hash = '/knowledge/search')}>检索<Search data-icon="inline-end" /></Button></CardFooter></Card>)}</div> : null}
{!error && visibleBases.length === 0 ? <AppEmptyState title="未找到知识库" description="请调整搜索关键词,或创建新的知识库。" /> : null}
</AppPage>
)
}

View File

@@ -67,8 +67,11 @@ export function KnowledgeItemEditorPage() {
}
return (
<AppPage maxWidth="6xl" contentClassName="gap-5">
<AppPageHeader title="新建知识条目" />
<AppPage
maxWidth="6xl"
contentClassName="gap-5"
header={<AppPageHeader title="新建知识条目" />}
>
{error ? <AppEmptyState title="知识条目操作失败" description={error} /> : null}
<form className="grid gap-4" onSubmit={handleSubmit}>

View File

@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useAgentPageContext } from '@/features/agent-workspace/AgentWorkspaceProvider'
import { listKnowledgeBases, searchKnowledge } from './api'
import type { KnowledgeBase, KnowledgeSearchResult } from './types'
@@ -29,6 +30,8 @@ export function KnowledgeSearchPage() {
const [searched, setSearched] = React.useState(false)
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState('')
const agentContext = React.useMemo(() => ({ routeKey: 'knowledge-search', entityType: 'knowledge-search', title: query.trim() ? `检索:${query.trim().slice(0, 24)}` : '知识检索', status: loading ? '检索中' : searched ? `找到 ${results.length} 条` : '等待检索', metadata: { 检索范围: knowledgeBaseId === 'all' ? '全部知识库' : knowledgeBaseId } }), [knowledgeBaseId, loading, query, results.length, searched])
useAgentPageContext(agentContext)
React.useEffect(() => {
listKnowledgeBases()
@@ -56,15 +59,17 @@ export function KnowledgeSearchPage() {
}
return (
<AppPage maxWidth="6xl" contentClassName="gap-5">
<AppPageHeader
<AppPage
contentClassName="gap-5"
header={<AppPageHeader
title="知识检索"
actions={
<Button variant="outline" onClick={() => (window.location.hash = '/knowledge/items/new')}>
新建知识条目
</Button>
}
/>
/>}
>
<AppSection title="检索条件">
<form className="grid gap-4 md:grid-cols-[220px_1fr_auto]" onSubmit={handleSearch}>
@@ -110,7 +115,7 @@ export function KnowledgeSearchPage() {
<CardContent className="grid gap-3 p-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold text-foreground">{item.title}</h2>
<Badge variant="secondary">{sourceLabels[item.source_type] ?? item.source_type}</Badge>
<Badge variant="secondary">{sourceLabels[item.source_type] ?? '其他来源'}</Badge>
<Badge variant="outline">匹配度 {item.score.toFixed(2)}</Badge>
</div>
<p className="line-clamp-4 whitespace-pre-line text-sm leading-6 text-muted-foreground">{item.content}</p>

View File

@@ -2,6 +2,7 @@ import * as React from 'react'
import { Activity, KeyRound, Pencil, Plus, RefreshCw, Settings2 } from 'lucide-react'
import { toast } from 'sonner'
import { AppCreateDialog } from '@/components/app/AppCreateDialog'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppField } from '@/components/app/AppField'
@@ -14,12 +15,12 @@ import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { MobileEditGuard } from '@/features/ai-platform/components/MobileEditGuard'
import { StatusBadge } from '@/features/ai-platform/status'
import { useIsMobile } from '@/hooks/use-mobile'
import { formatDateTime } from '@/lib/format'
import { createDeployment, createProvider, getModelTest, listDeployments, listProviders, testDeployment, updateDeployment, updateProvider } from './api'
@@ -29,12 +30,19 @@ const providerInitial = { name: '', base_url: '', api_key: '', default_timeout_s
const deploymentInitial = { provider_id: '', display_name: '', model_name: '', default_temperature: '0.2', max_output_tokens: '2048' }
export function ModelProviderPage() {
const mobile = useIsMobile()
const [providers, setProviders] = React.useState<ModelProvider[]>([])
const [deployments, setDeployments] = React.useState<ModelDeployment[]>([])
const [loading, setLoading] = React.useState(true)
const [error, setError] = React.useState('')
const [providerForm, setProviderForm] = React.useState(providerInitial)
const [deploymentForm, setDeploymentForm] = React.useState(deploymentInitial)
const [providerCreateOpen, setProviderCreateOpen] = React.useState(false)
const [deploymentCreateOpen, setDeploymentCreateOpen] = React.useState(false)
const [creatingProvider, setCreatingProvider] = React.useState(false)
const [creatingDeployment, setCreatingDeployment] = React.useState(false)
const [providerCreateError, setProviderCreateError] = React.useState('')
const [deploymentCreateError, setDeploymentCreateError] = React.useState('')
const [editing, setEditing] = React.useState<ModelProvider | null>(null)
const [editingDeployment, setEditingDeployment] = React.useState<ModelDeployment | null>(null)
const [testing, setTesting] = React.useState<ModelDeployment | null>(null)
@@ -65,14 +73,37 @@ export function ModelProviderPage() {
return () => window.clearTimeout(timer)
}, [testResult])
async function handleCreateProvider(event: React.FormEvent) {
function openProviderCreateDialog() {
if (mobile) {
toast.info('模型渠道配置请使用桌面端完成')
return
}
setProviderCreateOpen(true)
}
function openDeploymentCreateDialog() {
if (mobile) {
toast.info('模型配置请使用桌面端完成')
return
}
setDeploymentCreateOpen(true)
}
async function handleCreateProvider(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setCreatingProvider(true)
setProviderCreateError('')
try {
await createProvider({ ...providerForm, default_timeout_seconds: Number(providerForm.default_timeout_seconds) })
setProviderForm(providerInitial)
setProviderCreateOpen(false)
toast.success('模型渠道已创建')
await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '创建失败') }
} catch (reason) {
setProviderCreateError(reason instanceof Error ? reason.message : '创建失败')
} finally {
setCreatingProvider(false)
}
}
async function handleUpdateProvider() {
@@ -86,14 +117,21 @@ export function ModelProviderPage() {
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '保存失败') }
}
async function handleCreateDeployment(event: React.FormEvent) {
async function handleCreateDeployment(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setCreatingDeployment(true)
setDeploymentCreateError('')
try {
await createDeployment({ ...deploymentForm, provider_id: Number(deploymentForm.provider_id), default_temperature: Number(deploymentForm.default_temperature), max_output_tokens: Number(deploymentForm.max_output_tokens), enabled: true })
setDeploymentForm((current) => ({ ...deploymentInitial, provider_id: current.provider_id }))
setDeploymentCreateOpen(false)
toast.success('模型配置已创建')
await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '创建失败') }
} catch (reason) {
setDeploymentCreateError(reason instanceof Error ? reason.message : '创建失败')
} finally {
setCreatingDeployment(false)
}
}
async function handleUpdateDeployment() {
@@ -134,37 +172,92 @@ export function ModelProviderPage() {
]
return (
<AppPage>
<AppPageHeader title="模型渠道" description="管理 OpenAI-compatible 渠道、凭证与文本模型。凭证只显示脱敏结果。" actions={<Button variant="outline" onClick={() => void load()}><RefreshCw data-icon="inline-start" />刷新</Button>} />
<AppPage
header={<AppPageHeader
title="模型中心"
description="管理 OpenAI-compatible 渠道、凭证与文本模型。凭证只显示脱敏结果。"
actions={<Button variant="outline" onClick={() => void load()}><RefreshCw data-icon="inline-start" />刷新</Button>}
/>}
>
{error ? <AppEmptyState title="无法加载模型配置" description={error} /> : null}
{loading ? <div className="grid gap-3"><Skeleton className="h-32" /><Skeleton className="h-56" /></div> : null}
{!loading && !error ? <>
<MobileEditGuard>
<AppSection title="添加渠道" description="主密钥缺失时,后端会拒绝保存 API Key。">
<form onSubmit={handleCreateProvider}><FieldGroup className="grid gap-3 md:grid-cols-2 xl:grid-cols-5 xl:items-end">
<AppField label="渠道名称" htmlFor="provider-name" required><Input id="provider-name" required value={providerForm.name} onChange={(e) => setProviderForm({ ...providerForm, name: e.target.value })} placeholder="内部 OpenAI" /></AppField>
<AppField label="Base URL" htmlFor="provider-url" required><Input id="provider-url" required value={providerForm.base_url} onChange={(e) => setProviderForm({ ...providerForm, base_url: e.target.value })} placeholder="https://.../v1" /></AppField>
<AppField label="API Key" htmlFor="provider-key" required><Input id="provider-key" required type="password" value={providerForm.api_key} onChange={(e) => setProviderForm({ ...providerForm, api_key: e.target.value })} /></AppField>
<AppField label="超时(秒)" htmlFor="provider-timeout"><Input id="provider-timeout" type="number" min="1" max="300" value={providerForm.default_timeout_seconds} onChange={(e) => setProviderForm({ ...providerForm, default_timeout_seconds: e.target.value })} /></AppField>
<Button type="submit"><Plus data-icon="inline-start" />创建渠道</Button>
</FieldGroup></form>
</AppSection>
</MobileEditGuard>
{!loading && !error ? (
<>
<AppSection
title="模型渠道"
description="维护服务地址、访问凭证和请求超时。"
action={<Button size="sm" onClick={openProviderCreateDialog}><Plus data-icon="inline-start" />新增渠道</Button>}
>
<AppDataTable columns={providerColumns} rows={providers} getRowKey={(item) => item.id} emptyTitle="暂无模型渠道" emptyDescription="先配置真实渠道,再创建模型配置。" />
<MobileEditGuard>
<AppSection title="添加模型配置">
<form onSubmit={handleCreateDeployment}><FieldGroup className="grid gap-3 md:grid-cols-2 xl:grid-cols-6 xl:items-end">
<AppField label="渠道" required><Select value={deploymentForm.provider_id} onValueChange={(value) => setDeploymentForm({ ...deploymentForm, provider_id: value })}><SelectTrigger className="w-full"><SelectValue placeholder="选择渠道" /></SelectTrigger><SelectContent>{providers.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.name}</SelectItem>)}</SelectContent></Select></AppField>
<AppField label="显示名称" htmlFor="deployment-display" required><Input id="deployment-display" required value={deploymentForm.display_name} onChange={(e) => setDeploymentForm({ ...deploymentForm, display_name: e.target.value })} /></AppField>
<AppField label="模型名" htmlFor="deployment-model" required><Input id="deployment-model" required value={deploymentForm.model_name} onChange={(e) => setDeploymentForm({ ...deploymentForm, model_name: e.target.value })} /></AppField>
<AppField label="Temperature" htmlFor="deployment-temperature"><Input id="deployment-temperature" type="number" min="0" max="2" step="0.1" value={deploymentForm.default_temperature} onChange={(e) => setDeploymentForm({ ...deploymentForm, default_temperature: e.target.value })} /></AppField>
<AppField label="最大输出" htmlFor="deployment-tokens"><Input id="deployment-tokens" type="number" min="1" max="32768" value={deploymentForm.max_output_tokens} onChange={(e) => setDeploymentForm({ ...deploymentForm, max_output_tokens: e.target.value })} /></AppField>
<Button type="submit" disabled={!providers.length}><Plus data-icon="inline-start" />创建模型</Button>
</FieldGroup></form>
</AppSection>
</MobileEditGuard>
<AppDataTable columns={deploymentColumns} rows={deployments} getRowKey={(item) => item.id} emptyTitle="暂无模型配置" />
</> : null}
<AppSection
title="模型配置"
description="选择渠道并配置业务可识别的模型名称和默认生成参数。"
action={<Button size="sm" onClick={openDeploymentCreateDialog} disabled={!providers.length}><Plus data-icon="inline-start" />新增模型</Button>}
>
<AppDataTable columns={deploymentColumns} rows={deployments} getRowKey={(item) => item.id} emptyTitle="暂无模型配置" emptyDescription={providers.length ? '新增模型配置后即可供 Skill 和 Agent 使用。' : '请先新增一个模型渠道。'} />
</AppSection>
</>
) : null}
<AppCreateDialog
open={providerCreateOpen}
onOpenChange={(open) => { setProviderCreateOpen(open); if (!open) setProviderCreateError('') }}
title="新增模型渠道"
description="连接一个兼容 OpenAI API 的模型服务。访问凭证将由后端安全保存。"
submitLabel="创建渠道"
onSubmit={handleCreateProvider}
pending={creatingProvider}
error={providerCreateError}
>
<FieldGroup>
<AppField label="渠道名称" htmlFor="provider-name" required>
<Input id="provider-name" autoFocus required value={providerForm.name} onChange={(event) => setProviderForm({ ...providerForm, name: event.target.value })} placeholder="例如:内部 OpenAI" />
</AppField>
<AppField label="Base URL" htmlFor="provider-url" description="填写包含版本路径的完整服务地址。" required>
<Input id="provider-url" required type="url" value={providerForm.base_url} onChange={(event) => setProviderForm({ ...providerForm, base_url: event.target.value })} placeholder="https://api.example.com/v1" />
</AppField>
<AppField label="API Key" htmlFor="provider-key" description="保存后仅显示脱敏结果。" required>
<Input id="provider-key" required type="password" autoComplete="new-password" value={providerForm.api_key} onChange={(event) => setProviderForm({ ...providerForm, api_key: event.target.value })} placeholder="请输入访问凭证" />
</AppField>
<AppField label="请求超时(秒)" htmlFor="provider-timeout">
<Input id="provider-timeout" type="number" min="1" max="300" value={providerForm.default_timeout_seconds} onChange={(event) => setProviderForm({ ...providerForm, default_timeout_seconds: event.target.value })} />
</AppField>
</FieldGroup>
</AppCreateDialog>
<AppCreateDialog
open={deploymentCreateOpen}
onOpenChange={(open) => { setDeploymentCreateOpen(open); if (!open) setDeploymentCreateError('') }}
title="新增模型配置"
description="配置业务显示名称、实际模型名和默认生成参数。"
submitLabel="创建模型"
onSubmit={handleCreateDeployment}
pending={creatingDeployment}
submitDisabled={!providers.length}
error={deploymentCreateError}
>
<FieldGroup className="sm:grid sm:grid-cols-2">
<AppField label="模型渠道" required>
<Select value={deploymentForm.provider_id} onValueChange={(value) => setDeploymentForm({ ...deploymentForm, provider_id: value })}>
<SelectTrigger className="w-full"><SelectValue placeholder="选择渠道" /></SelectTrigger>
<SelectContent><SelectGroup>{providers.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.name}</SelectItem>)}</SelectGroup></SelectContent>
</Select>
</AppField>
<AppField label="显示名称" htmlFor="deployment-display" required>
<Input id="deployment-display" autoFocus required value={deploymentForm.display_name} onChange={(event) => setDeploymentForm({ ...deploymentForm, display_name: event.target.value })} placeholder="例如:客服分析模型" />
</AppField>
<AppField label="实际模型名" htmlFor="deployment-model" description="填写渠道可识别的模型标识。" required>
<Input id="deployment-model" required value={deploymentForm.model_name} onChange={(event) => setDeploymentForm({ ...deploymentForm, model_name: event.target.value })} placeholder="例如:gpt-4.1-mini" />
</AppField>
<AppField label="Temperature" htmlFor="deployment-temperature" description="0 更稳定,数值越高结果越发散。">
<Input id="deployment-temperature" type="number" min="0" max="2" step="0.1" value={deploymentForm.default_temperature} onChange={(event) => setDeploymentForm({ ...deploymentForm, default_temperature: event.target.value })} />
</AppField>
<AppField label="最大输出 Token" htmlFor="deployment-tokens">
<Input id="deployment-tokens" type="number" min="1" max="32768" value={deploymentForm.max_output_tokens} onChange={(event) => setDeploymentForm({ ...deploymentForm, max_output_tokens: event.target.value })} />
</AppField>
</FieldGroup>
</AppCreateDialog>
<Dialog open={Boolean(editing)} onOpenChange={(open) => !open && setEditing(null)}><DialogContent><DialogHeader><DialogTitle>编辑模型渠道</DialogTitle></DialogHeader>{editing ? <FieldGroup>
<AppField label="渠道名称"><Input value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} /></AppField>

View File

@@ -0,0 +1,92 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ProfilePage } from './ProfilePage'
const mocks = vi.hoisted(() => ({
refresh: vi.fn(),
updateCurrentUser: vi.fn(),
changePassword: vi.fn(),
getProfileStats: vi.fn(),
setTheme: vi.fn()
}))
vi.mock('@/features/auth/AuthProvider', () => ({
useAuth: () => ({
refresh: mocks.refresh,
user: {
id: 7,
username: 'support',
display_name: '支持专员',
email: 'support@example.com',
last_login: '2026-07-16T08:00:00+08:00',
date_joined: '2026-01-02T09:00:00+08:00',
is_superuser: false,
roles: [{ id: 1, name: '一线支持' }],
user_groups: [],
permissions: [],
menus: []
}
})
}))
vi.mock('@/features/auth/api', () => ({
updateCurrentUser: mocks.updateCurrentUser,
changePassword: mocks.changePassword
}))
vi.mock('./api', () => ({ getProfileStats: mocks.getProfileStats }))
vi.mock('next-themes', () => ({ useTheme: () => ({ theme: 'system', setTheme: mocks.setTheme }) }))
beforeEach(() => {
window.location.hash = '#/profile'
mocks.refresh.mockResolvedValue(undefined)
mocks.updateCurrentUser.mockResolvedValue({})
mocks.changePassword.mockResolvedValue({ status: 'ok' })
mocks.getProfileStats.mockResolvedValue({
created_total: 12,
active_assigned: 3,
completed_30d: 8,
completion_rate: 80,
recent_activity: [{ id: 1, ticket_id: 22, ticket_no: 'TK202607160001', ticket_title: '支付回调异常', event_type: 'updated', created_at: '2026-07-16T08:30:00+08:00' }]
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('个人中心', () => {
it('展示真实工作统计并提供精确工单筛选入口', async () => {
render(<ProfilePage />)
expect(await screen.findByText('12')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText('80%')).toBeInTheDocument()
expect(screen.getByText('支付回调异常').closest('a')).toHaveAttribute('href', '#/tickets/22')
expect(screen.getByText('当前待我处理').closest('a')).toHaveAttribute('href', '#/tickets?view=assigned_to_me&status_group=active')
})
it('保存个人资料后刷新认证信息,并校验修改密码输入', async () => {
const user = userEvent.setup()
render(<ProfilePage />)
await screen.findByText('12')
await user.click(screen.getByRole('tab', { name: /个人资料/ }))
const nameInput = screen.getByLabelText('显示名称')
await user.clear(nameInput)
await user.type(nameInput, '高级支持专员')
await user.click(screen.getByRole('button', { name: '保存资料' }))
await waitFor(() => expect(mocks.updateCurrentUser).toHaveBeenCalledWith({ display_name: '高级支持专员', email: 'support@example.com' }))
expect(mocks.refresh).toHaveBeenCalled()
await user.click(screen.getByRole('tab', { name: /安全设置/ }))
await user.type(screen.getByLabelText('当前密码'), 'Old-Harbor-42')
await user.type(screen.getByLabelText('新密码'), 'New-Harbor-Light-84')
await user.type(screen.getByLabelText('确认新密码'), 'New-Harbor-Light-84')
await user.click(screen.getByRole('button', { name: '修改密码' }))
await waitFor(() => expect(mocks.changePassword).toHaveBeenCalledWith('Old-Harbor-42', 'New-Harbor-Light-84'))
})
})

View File

@@ -0,0 +1,285 @@
import * as React from 'react'
import type { LucideIcon } from 'lucide-react'
import {
Activity,
CheckCircle2,
ClipboardList,
KeyRound,
Laptop,
LoaderCircle,
Mail,
Moon,
ShieldCheck,
Sun,
UserRound
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { toast } from 'sonner'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Avatar, AvatarBadge, AvatarFallback } from '@/components/ui/avatar'
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, FieldError, FieldGroup, FieldLabel, FieldTitle } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { useAuth } from '@/features/auth/AuthProvider'
import { changePassword, updateCurrentUser } from '@/features/auth/api'
import { getProfileStats } from './api'
import type { ProfileStats } from './types'
const activityLabels: Record<string, string> = {
created: '创建了工单',
updated: '更新了工单',
status_change: '推进了工单状态',
commented: '添加了处理记录',
attachment_added: '添加了附件'
}
function formatDateTime(value: string | null | undefined) {
if (!value) return '暂无记录'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
}).format(new Date(value))
}
function getInitials(name: string) {
const value = name.trim()
return value ? value.slice(0, 2).toUpperCase() : 'AI'
}
type StatItem = {
label: string
value: string
description: string
href: string
icon: LucideIcon
}
export function ProfilePage() {
const { user, refresh } = useAuth()
const { theme = 'system', setTheme } = useTheme()
const [stats, setStats] = React.useState<ProfileStats | null>(null)
const [statsError, setStatsError] = React.useState('')
const [statsLoading, setStatsLoading] = React.useState(true)
const [displayName, setDisplayName] = React.useState(user?.display_name ?? '')
const [email, setEmail] = React.useState(user?.email ?? '')
const [profilePending, setProfilePending] = React.useState(false)
const [profileError, setProfileError] = React.useState('')
const [currentPassword, setCurrentPassword] = React.useState('')
const [newPassword, setNewPassword] = React.useState('')
const [confirmPassword, setConfirmPassword] = React.useState('')
const [passwordPending, setPasswordPending] = React.useState(false)
const [passwordError, setPasswordError] = React.useState('')
const loadStats = React.useCallback(() => {
setStatsLoading(true)
getProfileStats()
.then((data) => {
setStats(data)
setStatsError('')
})
.catch((reason: unknown) => setStatsError(reason instanceof Error ? reason.message : '个人统计加载失败'))
.finally(() => setStatsLoading(false))
}, [])
React.useEffect(() => loadStats(), [loadStats])
React.useEffect(() => {
setDisplayName(user?.display_name ?? '')
setEmail(user?.email ?? '')
}, [user?.display_name, user?.email])
if (!user) return null
const statItems: StatItem[] = [
{ label: '我创建的工单', value: String(stats?.created_total ?? 0), description: '全部历史记录', href: '#/tickets?view=created_by_me', icon: ClipboardList },
{ label: '当前待我处理', value: String(stats?.active_assigned ?? 0), description: '未解决与未关闭', href: '#/tickets?view=assigned_to_me&status_group=active', icon: Activity },
{ label: '近 30 天完成', value: String(stats?.completed_30d ?? 0), description: '我负责的工单', href: '#/tickets?view=assigned_to_me&status_group=completed&resolved_within_days=30', icon: CheckCircle2 },
{ label: '负责工单完成率', value: `${stats?.completion_rate ?? 0}%`, description: '已解决或已关闭', href: '#/tickets?view=assigned_to_me&status_group=completed', icon: ShieldCheck }
]
async function saveProfile(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setProfilePending(true)
setProfileError('')
try {
await updateCurrentUser({ display_name: displayName.trim(), email: email.trim() })
await refresh()
toast.success('个人资料已保存')
} catch (reason) {
setProfileError(reason instanceof Error ? reason.message : '个人资料保存失败')
} finally {
setProfilePending(false)
}
}
async function savePassword(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (newPassword.length < 8) {
setPasswordError('新密码至少需要 8 位')
return
}
if (newPassword !== confirmPassword) {
setPasswordError('两次输入的新密码不一致')
return
}
setPasswordPending(true)
setPasswordError('')
try {
await changePassword(currentPassword, newPassword)
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
toast.success('密码已修改,其他旧会话将失效')
} catch (reason) {
setPasswordError(reason instanceof Error ? reason.message : '密码修改失败')
} finally {
setPasswordPending(false)
}
}
return (
<AppPage
maxWidth="7xl"
header={<AppPageHeader title="个人中心" description="查看个人工作情况,维护账号资料与安全设置。" meta={<Badge variant="outline">个人工作区</Badge>} />}
>
<div className="grid items-start gap-5 lg:grid-cols-[280px_minmax(0,1fr)]">
<Card className="lg:sticky lg:top-5">
<CardHeader className="items-center text-center">
<Avatar className="size-20">
<AvatarFallback className="text-xl font-semibold">{getInitials(user.display_name || user.username)}</AvatarFallback>
<AvatarBadge className="bg-success" aria-label="在线" />
</Avatar>
<CardTitle className="mt-2">{user.display_name || user.username}</CardTitle>
<CardDescription className="font-mono">@{user.username}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap justify-center gap-1.5">
{user.roles.length ? user.roles.map((role) => <Badge key={role.id} variant="secondary">{role.name}</Badge>) : <Badge variant="outline">未配置角色</Badge>}
{user.is_superuser ? <Badge variant="info">超级管理员</Badge> : null}
</div>
<dl className="grid gap-3 text-xs">
<div className="flex items-start justify-between gap-3"><dt className="text-muted-foreground">邮箱</dt><dd className="min-w-0 truncate text-right">{user.email || '未填写'}</dd></div>
<div className="flex items-start justify-between gap-3"><dt className="text-muted-foreground">加入时间</dt><dd className="text-right tabular-nums">{formatDateTime(user.date_joined)}</dd></div>
<div className="flex items-start justify-between gap-3"><dt className="text-muted-foreground">最近登录</dt><dd className="text-right tabular-nums">{formatDateTime(user.last_login)}</dd></div>
</dl>
</CardContent>
<CardFooter className="justify-center text-xs text-muted-foreground">账号信息由系统统一管理</CardFooter>
</Card>
<Tabs defaultValue="overview" className="min-w-0">
<TabsList variant="line" className="scrollbar-hidden w-full justify-start overflow-x-auto border-b px-1">
<TabsTrigger value="overview"><Activity />工作概览</TabsTrigger>
<TabsTrigger value="profile"><UserRound />个人资料</TabsTrigger>
<TabsTrigger value="security"><KeyRound />安全设置</TabsTrigger>
<TabsTrigger value="appearance"><Sun />外观偏好</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="flex flex-col gap-5 pt-3">
<Card className="gap-0 py-0">
<CardHeader className="border-b py-4">
<CardTitle>我的处理面板</CardTitle>
<CardDescription>统计基于你实际创建或负责的工单,可直接进入对应范围。</CardDescription>
</CardHeader>
{statsLoading ? (
<CardContent className="grid gap-3 py-5 sm:grid-cols-2 xl:grid-cols-4">
{[0, 1, 2, 3].map((item) => <Skeleton key={item} className="h-24" />)}
</CardContent>
) : statsError ? (
<CardContent className="py-5"><Alert variant="destructive"><AlertTitle>统计加载失败</AlertTitle><AlertDescription>{statsError}</AlertDescription></Alert><Button className="mt-3" variant="outline" onClick={loadStats}>重新加载</Button></CardContent>
) : (
<CardContent className="grid divide-y p-0 sm:grid-cols-2 sm:divide-x sm:divide-y-0 xl:grid-cols-4">
{statItems.map((item) => {
const Icon = item.icon
return (
<a key={item.label} href={item.href} className="group flex min-h-32 flex-col gap-3 p-5 outline-none transition-colors hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
<div className="flex items-center justify-between gap-3 text-muted-foreground"><span className="text-xs font-medium">{item.label}</span><Icon className="size-4" /></div>
<strong className="font-mono text-3xl font-semibold tracking-tight tabular-nums">{item.value}</strong>
<span className="text-xs text-muted-foreground group-hover:text-foreground">{item.description} · 查看工单</span>
</a>
)
})}
</CardContent>
)}
</Card>
<Card>
<CardHeader><CardTitle>最近参与</CardTitle><CardDescription>你最近在工单中的更新、评论与状态操作。</CardDescription></CardHeader>
<CardContent>
{stats?.recent_activity.length ? (
<ol className="flex flex-col gap-1">
{stats.recent_activity.map((item) => (
<li key={item.id}>
<a href={`#/tickets/${item.ticket_id}`} className="flex items-start gap-3 rounded-lg px-3 py-2.5 outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring">
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary" />
<span className="min-w-0 flex-1"><span className="block truncate font-medium">{item.ticket_title}</span><span className="mt-0.5 block text-xs text-muted-foreground">{activityLabels[item.event_type] ?? '参与了工单处理'} · <span className="font-mono">{item.ticket_no}</span></span></span>
<time className="shrink-0 text-xs text-muted-foreground tabular-nums">{formatDateTime(item.created_at)}</time>
</a>
</li>
))}
</ol>
) : <AppEmptyState title="暂无处理记录" description="当你更新或推进工单后,最近活动会显示在这里。" />}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="profile" className="pt-3">
<Card>
<CardHeader><CardTitle>个人资料</CardTitle><CardDescription>修改后会同步更新侧边栏和业务页面中的显示名称。</CardDescription></CardHeader>
<form onSubmit={saveProfile}>
<CardContent>
<FieldGroup>
<Field data-disabled><FieldLabel htmlFor="profile-username">登录账号</FieldLabel><Input id="profile-username" value={user.username} disabled /><FieldDescription>登录账号是稳定标识,创建后不可修改。</FieldDescription></Field>
<Field data-invalid={Boolean(profileError)}><FieldLabel htmlFor="profile-display-name">显示名称</FieldLabel><Input id="profile-display-name" value={displayName} onChange={(event) => setDisplayName(event.target.value)} aria-invalid={Boolean(profileError)} required /></Field>
<Field data-invalid={Boolean(profileError)}><FieldLabel htmlFor="profile-email">邮箱</FieldLabel><Input id="profile-email" type="email" value={email} onChange={(event) => setEmail(event.target.value)} aria-invalid={Boolean(profileError)} placeholder="name@company.com" /><FieldError>{profileError}</FieldError></Field>
</FieldGroup>
</CardContent>
<CardFooter className="mt-4 justify-end"><Button type="submit" disabled={profilePending}>{profilePending ? <LoaderCircle className="animate-spin" data-icon="inline-start" /> : <Mail data-icon="inline-start" />}{profilePending ? '保存中' : '保存资料'}</Button></CardFooter>
</form>
</Card>
</TabsContent>
<TabsContent value="security" className="pt-3">
<Card>
<CardHeader><CardTitle>修改密码</CardTitle><CardDescription>修改成功后保留当前会话,其他使用旧密码建立的会话将失效。</CardDescription></CardHeader>
<form onSubmit={savePassword}>
<CardContent>
<FieldGroup>
<Field data-invalid={Boolean(passwordError)}><FieldLabel htmlFor="profile-current-password">当前密码</FieldLabel><Input id="profile-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} aria-invalid={Boolean(passwordError)} required /></Field>
<Field data-invalid={Boolean(passwordError)}><FieldLabel htmlFor="profile-new-password">新密码</FieldLabel><Input id="profile-new-password" type="password" autoComplete="new-password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} aria-invalid={Boolean(passwordError)} required /><FieldDescription>至少 8 位,避免纯数字、常见密码或与账号过度相似。</FieldDescription></Field>
<Field data-invalid={Boolean(passwordError)}><FieldLabel htmlFor="profile-confirm-password">确认新密码</FieldLabel><Input id="profile-confirm-password" type="password" autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} aria-invalid={Boolean(passwordError)} required /><FieldError>{passwordError}</FieldError></Field>
</FieldGroup>
</CardContent>
<CardFooter className="mt-4 justify-end"><Button type="submit" disabled={passwordPending}>{passwordPending ? <LoaderCircle className="animate-spin" data-icon="inline-start" /> : <KeyRound data-icon="inline-start" />}{passwordPending ? '修改中' : '修改密码'}</Button></CardFooter>
</form>
</Card>
</TabsContent>
<TabsContent value="appearance" className="pt-3">
<Card>
<CardHeader><CardTitle>外观偏好</CardTitle><CardDescription>外观设置仅保存在当前浏览器中。</CardDescription></CardHeader>
<CardContent>
<Field orientation="responsive">
<FieldTitle>界面主题</FieldTitle>
<FieldDescription>选择适合当前环境的显示方式。</FieldDescription>
<ToggleGroup type="single" value={theme} variant="outline" size="lg" onValueChange={(value) => value && setTheme(value)} aria-label="界面主题">
<ToggleGroupItem value="light"><Sun data-icon="inline-start" />亮色</ToggleGroupItem>
<ToggleGroupItem value="dark"><Moon data-icon="inline-start" />暗色</ToggleGroupItem>
<ToggleGroupItem value="system"><Laptop data-icon="inline-start" />跟随系统</ToggleGroupItem>
</ToggleGroup>
</Field>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</AppPage>
)
}

View File

@@ -0,0 +1,7 @@
import { apiRequest } from '@/lib/api'
import type { ProfileStats } from './types'
export function getProfileStats() {
return apiRequest<ProfileStats>('/accounts/auth/me/stats')
}

View File

@@ -0,0 +1,16 @@
export type ProfileActivity = {
id: number
ticket_id: number
ticket_no: string
ticket_title: string
event_type: string
created_at: string
}
export type ProfileStats = {
created_total: number
active_assigned: number
completed_30d: number
completion_rate: number
recent_activity: ProfileActivity[]
}

View File

@@ -1,30 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
agentRuns,
agents,
dashboardMetrics,
knowledgeReferences,
mcpTools,
modelProviders,
skills
} from './mockData'
describe('Stage A 演示数据', () => {
it('提供所有产品模块所需数据', () => {
expect(dashboardMetrics).toHaveLength(4)
expect(modelProviders.length).toBeGreaterThanOrEqual(2)
expect(agents).toHaveLength(3)
expect(skills.length).toBeGreaterThanOrEqual(3)
expect(agentRuns.every((run) => run.steps.length > 0)).toBe(true)
})
it('保持可信边界和引用完整性', () => {
const writeTools = mcpTools.filter((tool) => tool.access_mode === 'write')
expect(writeTools.length).toBeGreaterThan(0)
expect(writeTools.every((tool) => tool.requires_human_confirm)).toBe(true)
expect(knowledgeReferences.length).toBeGreaterThan(0)
expect(knowledgeReferences.every((reference) => reference.score >= 0 && reference.score <= 1)).toBe(true)
})
})

View File

@@ -1,81 +0,0 @@
import type {
AgentDefinition,
AgentRun,
DashboardMetric,
KnowledgeReference,
McpServer,
McpTool,
ModelProvider,
ModelTestResult,
SimilarTicket,
SkillDefinition,
ToolCall
} from './types'
export const dashboardMetrics: DashboardMetric[] = [
{ id: 'pending', label: '待处理工单', value: '18', change: '较昨日增加 3 单', trend: 'up', tone: 'warning' },
{ id: 'resolved', label: '今日已完成', value: '42', change: '完成率 78%', trend: 'up', tone: 'success' },
{ id: 'knowledge', label: '可复用方案', value: '126', change: '本周新增 8 条', trend: 'up', tone: 'default' },
{ id: 'agent', label: '智能体成功率', value: '96.4%', change: '最近 24 小时', trend: 'flat', tone: 'ai' }
]
export const modelProviders: ModelProvider[] = [
{ id: 1, name: '企业 OpenAI 兼容渠道', protocol: 'OpenAI-compatible', base_url: 'https://llm.example.com/v1', credential_masked: 'sk-****82F1', enabled: true, health: 'healthy', model_count: 4, last_checked_at: '2026-07-10T08:32:00+08:00' },
{ id: 2, name: '内部推理服务', protocol: 'OpenAI-compatible', base_url: 'http://model-gateway.internal/v1', credential_masked: 'token-****19AC', enabled: true, health: 'degraded', model_count: 2, last_checked_at: '2026-07-10T08:28:00+08:00' }
]
export const modelTestResults: ModelTestResult[] = [
{ id: 1, provider_name: '企业 OpenAI 兼容渠道', model_name: 'support-large', prompt: '分析采集进度长期不更新的可能原因', output: '建议先核对任务状态、最近一次采集日志和税局接口响应。', latency_ms: 1280, input_tokens: 86, output_tokens: 42, status: 'success', created_at: '2026-07-10T09:10:00+08:00' },
{ id: 2, provider_name: '内部推理服务', model_name: 'support-small', prompt: '生成客户回复草稿', output: '已确认任务仍在采集中,我们会继续关注上游接口状态。', latency_ms: 760, input_tokens: 64, output_tokens: 31, status: 'success', created_at: '2026-07-10T09:06:00+08:00' }
]
export const agents: AgentDefinition[] = [
{ id: 1, code: 'ticket_intake', name: '建单智能体', purpose: '补全工单字段、识别分类并追问缺失信息', enabled: true, model_name: 'support-small', knowledge_scopes: ['客户支持知识库'], skill_count: 2, tool_count: 0, updated_at: '2026-07-10T08:20:00+08:00' },
{ id: 2, code: 'support_analysis', name: '支持分析智能体', purpose: '检索知识、推荐方案并生成排查步骤', enabled: true, model_name: 'support-large', knowledge_scopes: ['客户支持知识库', '解决方案库'], skill_count: 3, tool_count: 2, updated_at: '2026-07-10T08:24:00+08:00' },
{ id: 3, code: 'knowledge_draft', name: '知识沉淀智能体', purpose: '从已解决工单生成待审核知识草稿', enabled: false, model_name: 'support-large', knowledge_scopes: ['解决方案库'], skill_count: 1, tool_count: 0, updated_at: '2026-07-10T08:26:00+08:00' }
]
export const agentRuns: AgentRun[] = [
{ id: 1001, agent_name: '支持分析智能体', ticket_no: 'T202607100006', trigger_user: '张工', status: 'waiting_confirm', output_summary: '已生成 3 条排查建议和 1 份客户回复草稿。', duration_ms: 2180, created_at: '2026-07-10T09:18:00+08:00', steps: [
{ id: 1, step_name: '检索相关知识', step_type: 'retrieval', status: 'success', input_summary: '采集进度长期不更新', output_summary: '命中 2 条知识和 1 个解决方案', latency_ms: 180 },
{ id: 2, step_name: '查询采集任务', step_type: 'tool', status: 'success', input_summary: '任务编号 TASK-8821', output_summary: '任务运行中,上游接口最近一次返回超时', latency_ms: 420 },
{ id: 3, step_name: '生成支持分析', step_type: 'model', status: 'success', input_summary: '工单、知识引用与工具结果', output_summary: '生成原因分析、排查步骤和回复草稿', latency_ms: 1580 },
{ id: 4, step_name: '等待人工采纳', step_type: 'approval', status: 'waiting', input_summary: 'AI 支持分析结果', output_summary: '等待技术人员确认', latency_ms: 0 }
] },
{ id: 1002, agent_name: '建单智能体', ticket_no: '草稿', trigger_user: '李娜', status: 'success', output_summary: '已识别为快速咨询并补全问题对象。', duration_ms: 940, created_at: '2026-07-10T09:12:00+08:00', steps: [
{ id: 5, step_name: '抽取工单字段', step_type: 'skill', status: 'success', input_summary: '客户自然语言描述', output_summary: '识别分类、对象和环境', latency_ms: 120 },
{ id: 6, step_name: '生成字段草稿', step_type: 'model', status: 'success', input_summary: '结构化字段', output_summary: '生成可确认的建单草稿', latency_ms: 820 }
] }
]
export const skills: SkillDefinition[] = [
{ id: 1, name: '工单字段抽取', scenario: 'field_extract', version: 'v1.3', enabled: true, prompt_template: '从用户描述中抽取工单分类、问题对象、问题表现、环境和关联系统。', input_schema: '{ "description": "string" }', output_schema: '{ "category_code": "string", "problem_object": "string" }', updated_at: '2026-07-10T08:00:00+08:00' },
{ id: 2, name: '缺失信息追问', scenario: 'missing_info', version: 'v1.1', enabled: true, prompt_template: '根据已填写字段判断处理工单仍缺少哪些必要信息。', input_schema: '{ "ticket_draft": "object" }', output_schema: '{ "questions": "string[]" }', updated_at: '2026-07-10T08:02:00+08:00' },
{ id: 3, name: '解决方案推荐', scenario: 'solution_recommend', version: 'v1.5', enabled: true, prompt_template: '根据工单内容和检索结果推荐可复用解决方案,并说明匹配原因。', input_schema: '{ "ticket": "object", "references": "object[]" }', output_schema: '{ "recommendations": "object[]" }', updated_at: '2026-07-10T08:04:00+08:00' }
]
export const mcpServers: McpServer[] = [
{ id: 1, name: '采集任务查询服务', endpoint: 'http://collector-mcp.internal', auth_type: '令牌', connection_state: 'connected', tool_count: 3, last_sync_at: '2026-07-10T08:40:00+08:00' },
{ id: 2, name: '日志检索服务', endpoint: 'http://logs-mcp.internal', auth_type: '令牌', connection_state: 'connected', tool_count: 2, last_sync_at: '2026-07-10T08:36:00+08:00' }
]
export const mcpTools: McpTool[] = [
{ id: 1, server_name: '采集任务查询服务', name: '查询采集进度', description: '按任务编号查询当前阶段和最近更新时间', access_mode: 'read', risk_level: 'low', requires_human_confirm: false, enabled: true },
{ id: 2, server_name: '日志检索服务', name: '检索任务日志', description: '按任务编号读取脱敏日志摘要', access_mode: 'read', risk_level: 'medium', requires_human_confirm: false, enabled: true },
{ id: 3, server_name: '采集任务查询服务', name: '重启采集任务', description: '重新触发指定采集任务', access_mode: 'write', risk_level: 'high', requires_human_confirm: true, enabled: false }
]
export const toolCalls: ToolCall[] = [
{ id: 1, tool_name: '查询采集进度', agent_name: '支持分析智能体', input_summary: 'TASK-8821', output_summary: '运行中,最近更新时间 09:16', status: 'success', duration_ms: 420, created_at: '2026-07-10T09:18:20+08:00' },
{ id: 2, tool_name: '重启采集任务', agent_name: '支持分析智能体', input_summary: 'TASK-8821', output_summary: '非只读工具不允许自动执行', status: 'blocked', duration_ms: 8, created_at: '2026-07-10T09:18:40+08:00' }
]
export const knowledgeReferences: KnowledgeReference[] = [
{ id: 1, title: '采集任务长时间无进度的排查步骤', source_type: 'knowledge_item', content: '先检查任务状态、上游接口响应和最近一次有效日志,再判断是否需要人工重试。', score: 0.93, source_label: '客户支持知识库' },
{ id: 2, title: '税局接口不稳定时的客户回复模板', source_type: 'solution', content: '确认任务仍在执行后,向客户说明上游接口状态并持续关注采集进度。', score: 0.87, source_label: '解决方案库' }
]
export const similarTickets: SimilarTicket[] = [
{ id: 5, ticket_no: 'T202607030005', title: '税局采集任务停留在初始化阶段', status: 'closed', similarity: 0.91, resolution: '确认税局接口波动,任务恢复后回复客户并关闭。' },
{ id: 2, ticket_no: 'T202607010002', title: '查询申报数据采集进度', status: 'resolved', similarity: 0.84, resolution: '查询任务日志后确认仍在采集中。' }
]

View File

@@ -1,130 +0,0 @@
export type DashboardMetric = {
id: string
label: string
value: string
change: string
trend: 'up' | 'down' | 'flat'
tone: 'default' | 'success' | 'warning' | 'danger' | 'ai'
}
export type ModelProvider = {
id: number
name: string
protocol: 'OpenAI-compatible'
base_url: string
credential_masked: string
enabled: boolean
health: 'healthy' | 'degraded' | 'unchecked'
model_count: number
last_checked_at: string
}
export type ModelTestResult = {
id: number
provider_name: string
model_name: string
prompt: string
output: string
latency_ms: number
input_tokens: number
output_tokens: number
status: 'success' | 'failed'
created_at: string
}
export type AgentDefinition = {
id: number
code: 'ticket_intake' | 'support_analysis' | 'knowledge_draft'
name: string
purpose: string
enabled: boolean
model_name: string
knowledge_scopes: string[]
skill_count: number
tool_count: number
updated_at: string
}
export type AgentStep = {
id: number
step_name: string
step_type: 'model' | 'retrieval' | 'skill' | 'tool' | 'approval'
status: 'success' | 'failed' | 'waiting'
input_summary: string
output_summary: string
latency_ms: number
}
export type AgentRun = {
id: number
agent_name: string
ticket_no: string
trigger_user: string
status: 'success' | 'failed' | 'running' | 'waiting_confirm'
output_summary: string
duration_ms: number
created_at: string
steps: AgentStep[]
}
export type SkillDefinition = {
id: number
name: string
scenario: 'field_extract' | 'missing_info' | 'solution_recommend' | 'reply_draft' | 'knowledge_draft'
version: string
enabled: boolean
prompt_template: string
input_schema: string
output_schema: string
updated_at: string
}
export type McpServer = {
id: number
name: string
endpoint: string
auth_type: '无认证' | '令牌'
connection_state: 'connected' | 'disconnected' | 'error'
tool_count: number
last_sync_at: string
}
export type McpTool = {
id: number
server_name: string
name: string
description: string
access_mode: 'read' | 'write'
risk_level: 'low' | 'medium' | 'high'
requires_human_confirm: boolean
enabled: boolean
}
export type ToolCall = {
id: number
tool_name: string
agent_name: string
input_summary: string
output_summary: string
status: 'success' | 'failed' | 'blocked'
duration_ms: number
created_at: string
}
export type KnowledgeReference = {
id: number
title: string
source_type: 'knowledge_item' | 'solution' | 'ticket' | 'document'
content: string
score: number
source_label: string
}
export type SimilarTicket = {
id: number
ticket_no: string
title: string
status: 'resolved' | 'closed'
similarity: number
resolution: string
}

View File

@@ -1,5 +1,5 @@
import * as React from 'react'
import { ArrowLeft, CopyPlus, Save, Send } from 'lucide-react'
import { CopyPlus, Save, Send } from 'lucide-react'
import { toast } from 'sonner'
import { AppEmptyState } from '@/components/app/AppEmptyState'
@@ -68,10 +68,9 @@ export function SkillDetailPage({ skillId }: { skillId: number }) {
try { await rollbackSkill(skillId, version); toast.success(`已回滚到 v${version}`); await load() } catch (reason) { toast.error(reason instanceof Error ? reason.message : '回滚失败') }
}
if (error) return <AppPage><AppEmptyState title="无法打开 Skill" description={error} /></AppPage>
if (!detail) return <AppPage><div className="h-52 animate-pulse rounded-md bg-muted" /></AppPage>
return <AppPage>
<AppPageHeader title={detail.name} description={`${detail.code} · 发布版本不可变,编辑操作始终写入草稿。`} meta={detail.active_version ? <Badge variant="success">当前 v{detail.active_version}</Badge> : <Badge variant="secondary">尚未发布</Badge>} actions={<><Button variant="outline" onClick={() => { window.location.hash = '/skills' }}><ArrowLeft data-icon="inline-start" />返回</Button>{draft ? <><Button variant="outline" onClick={() => void save()}><Save data-icon="inline-start" />保存草稿</Button><Button onClick={() => void publish()}><Send data-icon="inline-start" />发布 v{draft.version}</Button></> : <Button onClick={() => void createDraft()}><CopyPlus data-icon="inline-start" />创建新草稿</Button>}</>} />
if (error) return <AppPage header={<AppPageHeader title="Skill 详情" />}><AppEmptyState title="无法打开 Skill" description={error} /></AppPage>
if (!detail) return <AppPage header={<AppPageHeader title="Skill 详情" />}><div className="h-52 animate-pulse rounded-md bg-muted" /></AppPage>
return <AppPage header={<AppPageHeader title={detail.name} breadcrumbLabel={detail.code} description={`${detail.code} · 发布版本不可变,编辑操作始终写入草稿。`} meta={detail.active_version ? <Badge variant="success">当前 v{detail.active_version}</Badge> : <Badge variant="secondary">尚未发布</Badge>} actions={<>{draft ? <><Button variant="outline" onClick={() => void save()}><Save data-icon="inline-start" />保存草稿</Button><Button onClick={() => void publish()}><Send data-icon="inline-start" />发布 v{draft.version}</Button></> : <Button onClick={() => void createDraft()}><CopyPlus data-icon="inline-start" />创建新草稿</Button>}</>} />}>
<Tabs defaultValue="editor"><TabsList variant="line"><TabsTrigger value="editor">草稿编辑</TabsTrigger><TabsTrigger value="versions">版本记录</TabsTrigger></TabsList>
<TabsContent value="editor" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-5">
<AppSection title={`基础信息 · 草稿 v${draft.version}`}><FieldGroup className="grid gap-3 md:grid-cols-2"><AppField label="名称"><Input value={meta.name} onChange={(e) => setMeta({ ...meta, name: e.target.value })} /></AppField><AppField label="描述"><Input value={meta.description} onChange={(e) => setMeta({ ...meta, description: e.target.value })} /></AppField></FieldGroup></AppSection>

View File

@@ -2,25 +2,29 @@ import * as React from 'react'
import { Plus } from 'lucide-react'
import { toast } from 'sonner'
import { AppCreateDialog } from '@/components/app/AppCreateDialog'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppField } from '@/components/app/AppField'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppSection } from '@/components/app/AppSection'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { MobileEditGuard } from '@/features/ai-platform/components/MobileEditGuard'
import { useIsMobile } from '@/hooks/use-mobile'
import { formatDateTime } from '@/lib/format'
import { createSkill, listSkills } from './api'
import type { SkillDefinition } from './types'
export function SkillListPage() {
const mobile = useIsMobile()
const [items, setItems] = React.useState<SkillDefinition[]>([])
const [form, setForm] = React.useState({ code: '', name: '', description: '' })
const [createOpen, setCreateOpen] = React.useState(false)
const [creating, setCreating] = React.useState(false)
const [createError, setCreateError] = React.useState('')
const [error, setError] = React.useState('')
const load = React.useCallback(async () => {
@@ -28,13 +32,28 @@ export function SkillListPage() {
}, [])
React.useEffect(() => { void load() }, [load])
async function handleCreate(event: React.FormEvent) {
function openCreateDialog() {
if (mobile) {
toast.info('Skill 配置请使用桌面端完成')
return
}
setCreateOpen(true)
}
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setCreating(true)
setCreateError('')
try {
const created = await createSkill({ ...form, prompt_template: '请根据以下输入返回 JSON:\n{{ input.problem }}', input_schema: { type: 'object', properties: { problem: { type: 'string' } }, required: ['problem'], additionalProperties: false }, output_schema: { type: 'object' }, example_input: { problem: '示例问题' } })
toast.success('Skill 草稿已创建')
setCreateOpen(false)
window.location.hash = `/skills/${created.id}`
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '创建失败') }
} catch (reason) {
setCreateError(reason instanceof Error ? reason.message : '创建失败')
} finally {
setCreating(false)
}
}
const columns: AppDataTableColumn<SkillDefinition>[] = [
@@ -44,14 +63,39 @@ export function SkillListPage() {
{ key: 'updated', header: '更新时间', cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.updated_at)}</span> }
]
return <AppPage>
<AppPageHeader title="Skill 管理" description="以受限模板和 JSON Schema 定义可复用的模型步骤。" />
<MobileEditGuard><AppSection title="新建 Skill" description="创建后进入草稿编辑器完成 Prompt、输入和输出契约。"><form onSubmit={handleCreate}><FieldGroup className="grid gap-3 md:grid-cols-[1fr_1fr_2fr_auto] md:items-end">
<AppField label="代码" htmlFor="skill-code" required><Input id="skill-code" required pattern="[a-z][a-z0-9_]*" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} placeholder="problem_structuring" /></AppField>
<AppField label="名称" htmlFor="skill-name" required><Input id="skill-name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></AppField>
<AppField label="描述" htmlFor="skill-description"><Input id="skill-description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></AppField>
<Button type="submit"><Plus data-icon="inline-start" />创建 Skill</Button>
</FieldGroup></form></AppSection></MobileEditGuard>
return (
<AppPage
header={<AppPageHeader
title="Skill 管理"
description="以受限模板和 JSON Schema 定义可复用的模型步骤。"
actions={<Button onClick={openCreateDialog}><Plus data-icon="inline-start" />新建 Skill</Button>}
/>}
>
<AppCreateDialog
open={createOpen}
onOpenChange={(open) => { setCreateOpen(open); if (!open) setCreateError('') }}
title="新建 Skill"
description="先创建基础信息,随后进入草稿编辑器配置 Prompt、输入与输出契约。"
submitLabel="创建 Skill"
onSubmit={handleCreate}
pending={creating}
error={createError}
>
<FieldGroup>
<AppField label="代码" htmlFor="skill-code" description="仅支持小写字母、数字和下划线。" required>
<Input id="skill-code" autoFocus required pattern="[a-z][a-z0-9_]*" value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} placeholder="例如:problem_structuring" />
</AppField>
<AppField label="名称" htmlFor="skill-name" required>
<Input id="skill-name" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="例如:业务问题结构化" />
</AppField>
<AppField label="描述" htmlFor="skill-description" description="说明该 Skill 解决的问题和适用范围。">
<Input id="skill-description" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="例如:将客户问题整理为标准字段" />
</AppField>
</FieldGroup>
</AppCreateDialog>
{error ? <AppEmptyState title="无法加载 Skill" description={error} /> : <AppDataTable columns={columns} rows={items} getRowKey={(item) => item.id} emptyTitle="暂无 Skill" emptyDescription="先创建一个 Skill 草稿。" onRowClick={(item) => { window.location.hash = `/skills/${item.id}` }} />}
</AppPage>
)
}

View File

@@ -1,5 +1,5 @@
import * as React from 'react'
import { ArrowLeft, Save } from 'lucide-react'
import { Save } from 'lucide-react'
import { AppField } from '@/components/app/AppField'
import { AppPage } from '@/components/app/AppPage'
@@ -78,16 +78,13 @@ export function SolutionCreatePage() {
}
return (
<AppPage maxWidth="6xl" contentClassName="grid gap-5">
<AppPageHeader
<AppPage
maxWidth="6xl"
contentClassName="grid gap-5"
header={<AppPageHeader
title="新建解决方案"
actions={
<Button variant="outline" onClick={() => (window.location.hash = sourceTicketId ? `/tickets/${sourceTicketId}` : '/solutions')}>
<ArrowLeft className="size-4" />
返回
</Button>
}
/>
/>}
>
{error ? (
<Alert variant="destructive">

View File

@@ -77,6 +77,7 @@ export function SolutionLinkPanel({
return (
<AppSection
variant="card"
title="关联解决方案"
action={
<Button

View File

@@ -1,5 +1,5 @@
import * as React from 'react'
import { Plus } from 'lucide-react'
import { Grid2X2, Lightbulb, List, Plus } from 'lucide-react'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState'
@@ -7,11 +7,12 @@ import { AppFilterBar } from '@/components/app/AppFilterBar'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { formatDateTime } from '@/lib/format'
import { useAgentPageContext } from '@/features/agent-workspace/AgentWorkspaceProvider'
import { listSolutions } from './api'
import type { Solution } from './types'
@@ -59,6 +60,7 @@ export function SolutionListPage() {
const [status, setStatus] = React.useState('all')
const [q, setQ] = React.useState('')
const [error, setError] = React.useState('')
const [viewMode, setViewMode] = React.useState<'grid' | 'list'>('grid')
React.useEffect(() => {
listSolutions(status === 'all' ? {} : { status })
@@ -72,18 +74,22 @@ export function SolutionListPage() {
return `${solution.title} ${solution.problem_signature} ${solution.solution}`.toLowerCase().includes(keyword)
})
: solutions
const agentContext = React.useMemo(() => ({ routeKey: 'solutions', entityType: 'solution-list', title: `解决方案库 · ${solutions.length} 条`, status: error ? '加载异常' : '已同步页面数据', metadata: { 已发布: String(solutions.filter((item) => item.status === 'published').length), 当前显示: String(visibleSolutions.length) } }), [error, solutions, visibleSolutions.length])
useAgentPageContext(agentContext)
return (
<AppPage>
<AppPageHeader
<AppPage
header={<AppPageHeader
title="解决方案库"
description="沉淀经验证的处理步骤、回复依据与适用范围"
actions={
<Button onClick={() => (window.location.hash = '/solutions/new')}>
<Plus data-icon="inline-start" />
新建方案
</Button>
}
/>
/>}
>
<div className="grid gap-3 md:grid-cols-3">
<Card>
@@ -110,7 +116,7 @@ export function SolutionListPage() {
</Card>
</div>
<AppFilterBar actions={<Button variant="outline" onClick={() => { setQ(''); setStatus('all') }}>重置筛选</Button>}>
<AppFilterBar actions={<><div className="flex rounded-lg border bg-muted/30 p-0.5"><Button variant={viewMode === 'grid' ? 'secondary' : 'ghost'} size="icon-sm" aria-label="卡片视图" onClick={() => setViewMode('grid')}><Grid2X2 /></Button><Button variant={viewMode === 'list' ? 'secondary' : 'ghost'} size="icon-sm" aria-label="列表视图" onClick={() => setViewMode('list')}><List /></Button></div><Button variant="outline" onClick={() => { setQ(''); setStatus('all') }}>重置筛选</Button></>}>
<Input placeholder="搜索解决方案" value={q} onChange={(event) => setQ(event.target.value)} />
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-full">
@@ -127,7 +133,7 @@ export function SolutionListPage() {
</AppFilterBar>
{error ? <AppEmptyState title="无法加载解决方案" description={error} /> : null}
{!error ? (
{!error && viewMode === 'list' ? (
<AppDataTable
columns={columns}
rows={visibleSolutions}
@@ -137,6 +143,7 @@ export function SolutionListPage() {
footer={<p className="text-sm text-muted-foreground">当前显示 {visibleSolutions.length} 条方案</p>}
/>
) : null}
{!error && viewMode === 'grid' ? <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{visibleSolutions.map((solution) => <Card key={solution.id} className="app-card-hover"><CardHeader><div className="flex items-start justify-between gap-3"><span className="flex size-9 items-center justify-center rounded-lg bg-warning/10 text-warning"><Lightbulb className="size-4" /></span><Badge variant={solution.status === 'published' ? 'success' : 'secondary'}>{STATUS_LABELS[solution.status] ?? '未知状态'}</Badge></div><CardTitle className="mt-2">{solution.title}</CardTitle><CardDescription className="line-clamp-2 min-h-10">{solution.problem_signature || '暂无问题特征'}</CardDescription></CardHeader><CardContent className="flex flex-wrap gap-2"><Badge variant="outline">{solution.related_system || '未设置系统'}</Badge><Badge variant="info">引用 {solution.usage_count}</Badge></CardContent><CardFooter className="justify-between text-xs text-muted-foreground"><span>{formatDateTime(solution.updated_at)}</span><Button variant="ghost" size="sm" onClick={() => (window.location.hash = `/solutions/new?source=${solution.id}`)}>复用方案</Button></CardFooter></Card>)}</div> : null}
</AppPage>
)
}

View File

@@ -66,8 +66,7 @@ export function AuditLogPage() {
]
return (
<AppPage>
<AppPageHeader title="操作审计" />
<AppPage header={<AppPageHeader title="操作审计" />}>
<AppFilterBar>
<Input placeholder="搜索模块、动作、对象或结果" value={query} onChange={(event) => setQuery(event.target.value)} />

View File

@@ -80,8 +80,7 @@ export function SystemMenusPage() {
]
return (
<AppPage>
<AppPageHeader title="菜单权限" />
<AppPage header={<AppPageHeader title="菜单权限" />}>
<AppFilterBar>
<Input placeholder="搜索菜单、路径或权限码" value={query} onChange={(event) => setQuery(event.target.value)} />

View File

@@ -1,11 +1,12 @@
import * as React from 'react'
import { Plus } from 'lucide-react'
import { AppCreateDialog } from '@/components/app/AppCreateDialog'
import { AppField } from '@/components/app/AppField'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppSection } from '@/components/app/AppSection'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
@@ -20,6 +21,9 @@ export function SystemRolesPage() {
const [roles, setRoles] = React.useState<RoleDetail[]>([])
const [permissions, setPermissions] = React.useState<Permission[]>([])
const [roleName, setRoleName] = React.useState('')
const [createOpen, setCreateOpen] = React.useState(false)
const [creating, setCreating] = React.useState(false)
const [createError, setCreateError] = React.useState('')
const [editingRole, setEditingRole] = React.useState<RoleDetail | null>(null)
const [error, setError] = React.useState('')
@@ -40,9 +44,18 @@ export function SystemRolesPage() {
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setCreating(true)
setCreateError('')
try {
await createRole({ name: roleName, permission_codes: [] })
setRoleName('')
setCreateOpen(false)
await load()
} catch (reason) {
setCreateError(reason instanceof Error ? reason.message : '创建角色失败')
} finally {
setCreating(false)
}
}
const columns: AppDataTableColumn<RoleDetail>[] = [
@@ -74,19 +87,30 @@ export function SystemRolesPage() {
]
return (
<AppPage>
<AppPageHeader title="角色权限" />
<AppPage
header={<AppPageHeader
title="角色权限"
description="按岗位职责组合权限,再授权给用户。"
actions={<Button onClick={() => setCreateOpen(true)}><Plus data-icon="inline-start" />新增角色</Button>}
/>}
>
<AppSection title="新增角色">
<form onSubmit={handleCreate}>
<FieldGroup className="grid gap-3 md:grid-cols-[minmax(0,360px)_auto] md:items-end">
<AppField label="角色名称" htmlFor="system-role-name" required>
<Input id="system-role-name" value={roleName} onChange={(event) => setRoleName(event.target.value)} required />
<AppCreateDialog
open={createOpen}
onOpenChange={(open) => { setCreateOpen(open); if (!open) setCreateError('') }}
title="新增角色"
description="先创建角色名称,随后在列表中配置具体权限。"
submitLabel="创建角色"
onSubmit={handleCreate}
pending={creating}
error={createError}
>
<FieldGroup>
<AppField label="角色名称" htmlFor="system-role-name" description="使用容易理解的岗位或职责名称。" required>
<Input id="system-role-name" autoFocus placeholder="例如:采集问题支持专员" value={roleName} onChange={(event) => setRoleName(event.target.value)} required />
</AppField>
<Button type="submit" className="w-full md:w-auto">创建角色</Button>
</FieldGroup>
</form>
</AppSection>
</AppCreateDialog>
{error ? <AppEmptyState title="无法加载角色" description={error} /> : null}
{!error ? (

View File

@@ -1,11 +1,12 @@
import * as React from 'react'
import { Plus } from 'lucide-react'
import { AppCreateDialog } from '@/components/app/AppCreateDialog'
import { AppField } from '@/components/app/AppField'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppSection } from '@/components/app/AppSection'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { FieldGroup } from '@/components/ui/field'
@@ -19,6 +20,9 @@ import type { UserGroup } from './types'
export function SystemUserGroupsPage() {
const [groups, setGroups] = React.useState<UserGroup[]>([])
const [form, setForm] = React.useState({ name: '', code: '', description: '' })
const [createOpen, setCreateOpen] = React.useState(false)
const [creating, setCreating] = React.useState(false)
const [createError, setCreateError] = React.useState('')
const [error, setError] = React.useState('')
const load = React.useCallback(async () => {
@@ -36,9 +40,18 @@ export function SystemUserGroupsPage() {
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setCreating(true)
setCreateError('')
try {
await createUserGroup({ ...form, is_active: true })
setForm({ name: '', code: '', description: '' })
setCreateOpen(false)
await load()
} catch (reason) {
setCreateError(reason instanceof Error ? reason.message : '创建用户组失败')
} finally {
setCreating(false)
}
}
async function toggleActive(group: UserGroup, checked: boolean) {
@@ -82,25 +95,36 @@ export function SystemUserGroupsPage() {
]
return (
<AppPage>
<AppPageHeader title="用户组管理" />
<AppPage
header={<AppPageHeader
title="用户组管理"
description="按业务团队组织用户,便于统一分配数据与功能权限。"
actions={<Button onClick={() => setCreateOpen(true)}><Plus data-icon="inline-start" />新增用户组</Button>}
/>}
>
<AppSection title="新增用户组">
<form onSubmit={handleCreate}>
<FieldGroup className="grid gap-3 md:grid-cols-[1fr_1fr_2fr_auto] md:items-end">
<AppCreateDialog
open={createOpen}
onOpenChange={(open) => { setCreateOpen(open); if (!open) setCreateError('') }}
title="新增用户组"
description="创建一个清晰、可识别的业务用户组。"
submitLabel="创建用户组"
onSubmit={handleCreate}
pending={creating}
error={createError}
>
<FieldGroup className="grid gap-5">
<AppField label="用户组名称" htmlFor="system-group-name" required>
<Input id="system-group-name" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} required />
<Input id="system-group-name" autoFocus placeholder="例如:技术支持一组" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} required />
</AppField>
<AppField label="编码" htmlFor="system-group-code" required>
<Input id="system-group-code" value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} required />
<AppField label="编码" htmlFor="system-group-code" description="建议使用小写字母、数字和下划线,创建后不宜修改。" required>
<Input id="system-group-code" placeholder="例如:support_team_1" value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} required />
</AppField>
<AppField label="说明" htmlFor="system-group-description">
<Input id="system-group-description" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
<Input id="system-group-description" placeholder="说明该用户组负责的业务范围" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
</AppField>
<Button type="submit" className="w-full md:w-auto">创建用户组</Button>
</FieldGroup>
</form>
</AppSection>
</AppCreateDialog>
{error ? <AppEmptyState title="无法加载用户组" description={error} /> : null}
{!error ? (

View File

@@ -1,22 +1,22 @@
import * as React from 'react'
import { Plus } from 'lucide-react'
import { AppCreateDialog } from '@/components/app/AppCreateDialog'
import { AppField } from '@/components/app/AppField'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppFilterBar } from '@/components/app/AppFilterBar'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppSection } from '@/components/app/AppSection'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Field, FieldGroup, FieldLabel, FieldLegend, FieldSet } from '@/components/ui/field'
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { useAuth } from '@/features/auth/AuthProvider'
import { createUser, listRoles, listUserGroups, listUsers, setUserGroups, setUserRoles, updateUser } from './api'
import { createUser, listRoles, listUserGroups, listUsers } from './api'
import type { RoleDetail, SystemUser, UserGroup } from './types'
import { UserEditorSheet } from './UserEditorSheet'
type UserForm = {
username: string
@@ -33,17 +33,30 @@ const emptyForm: UserForm = {
}
export function SystemUsersPage() {
const { user } = useAuth()
const [users, setUsers] = React.useState<SystemUser[]>([])
const [roles, setRoles] = React.useState<RoleDetail[]>([])
const [groups, setGroups] = React.useState<UserGroup[]>([])
const [query, setQuery] = React.useState('')
const [form, setForm] = React.useState<UserForm>(emptyForm)
const [createOpen, setCreateOpen] = React.useState(false)
const [creating, setCreating] = React.useState(false)
const [createError, setCreateError] = React.useState('')
const [editingUser, setEditingUser] = React.useState<SystemUser | null>(null)
const [error, setError] = React.useState('')
const isSuperuser = Boolean(user?.is_superuser)
const canManage = isSuperuser || Boolean(user?.permissions.includes('system.user.manage'))
const canViewRoles = isSuperuser || Boolean(user?.permissions.includes('system.role.view'))
const canViewGroups = isSuperuser || Boolean(user?.permissions.includes('system.user_group.view'))
const canManageAccess = canManage && canViewRoles && canViewGroups
const load = React.useCallback(async () => {
try {
const [nextUsers, nextRoles, nextGroups] = await Promise.all([listUsers(), listRoles(), listUserGroups()])
const [nextUsers, nextRoles, nextGroups] = await Promise.all([
listUsers(),
canViewRoles ? listRoles() : Promise.resolve([]),
canViewGroups ? listUserGroups() : Promise.resolve([])
])
setUsers(nextUsers)
setRoles(nextRoles)
setGroups(nextGroups)
@@ -51,7 +64,7 @@ export function SystemUsersPage() {
} catch (reason) {
setError(reason instanceof Error ? reason.message : '加载失败')
}
}, [])
}, [canViewGroups, canViewRoles])
React.useEffect(() => {
void load()
@@ -65,14 +78,18 @@ export function SystemUsersPage() {
async function handleCreateUser(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setCreating(true)
setCreateError('')
try {
await createUser({ ...form, is_active: true })
setForm(emptyForm)
setCreateOpen(false)
await load()
} catch (reason) {
setCreateError(reason instanceof Error ? reason.message : '创建用户失败')
} finally {
setCreating(false)
}
async function toggleUserActive(user: SystemUser, checked: boolean) {
await updateUser(user.id, { is_active: checked })
await load()
}
const columns: AppDataTableColumn<SystemUser>[] = [
@@ -114,13 +131,7 @@ export function SystemUsersPage() {
{
key: 'active',
header: '状态',
cell: (user) => (
<Switch
checked={user.is_active}
onClick={(event) => event.stopPropagation()}
onCheckedChange={(checked) => void toggleUserActive(user, checked)}
/>
)
cell: (user) => user.is_active ? <Badge variant="success">已启用</Badge> : <Badge variant="destructive">已停用</Badge>
},
{
key: 'actions',
@@ -133,35 +144,46 @@ export function SystemUsersPage() {
setEditingUser(user)
}}
>
授权
{canManage ? '编辑' : '查看'}
</Button>
)
}
]
return (
<AppPage>
<AppPageHeader title="用户管理" />
<AppPage
header={<AppPageHeader
title="用户管理"
description="维护内部账号、用户组与角色授权。"
actions={canManage ? <Button onClick={() => setCreateOpen(true)}><Plus data-icon="inline-start" />新增用户</Button> : undefined}
/>}
>
<AppSection title="新增用户">
<form onSubmit={handleCreateUser}>
<FieldGroup className="grid gap-3 md:grid-cols-[1fr_1fr_1fr_1fr_auto] md:items-end">
<AppCreateDialog
open={createOpen}
onOpenChange={(open) => { setCreateOpen(open); if (!open) setCreateError('') }}
title="新增用户"
description="创建内部使用账号,创建后可在列表中继续配置角色和用户组。"
submitLabel="创建用户"
onSubmit={handleCreateUser}
pending={creating}
error={createError}
>
<FieldGroup className="grid gap-5 sm:grid-cols-2">
<AppField label="账号" htmlFor="system-user-username" required>
<Input id="system-user-username" value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} required />
<Input id="system-user-username" autoFocus autoComplete="off" placeholder="例如:zhangsan" value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} required />
</AppField>
<AppField label="显示名称" htmlFor="system-user-display-name">
<Input id="system-user-display-name" value={form.display_name} onChange={(event) => setForm({ ...form, display_name: event.target.value })} />
<Input id="system-user-display-name" placeholder="例如:张三" value={form.display_name} onChange={(event) => setForm({ ...form, display_name: event.target.value })} />
</AppField>
<AppField label="邮箱" htmlFor="system-user-email">
<Input id="system-user-email" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} />
<AppField label="邮箱" htmlFor="system-user-email" description="用于接收通知与找回账号。">
<Input id="system-user-email" type="email" placeholder="name@company.com" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} />
</AppField>
<AppField label="初始密码" htmlFor="system-user-password" required>
<Input id="system-user-password" type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} required />
<AppField label="初始密码" htmlFor="system-user-password" description="建议首次登录后立即修改。" required>
<Input id="system-user-password" type="password" autoComplete="new-password" placeholder="请输入初始密码" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} required />
</AppField>
<Button type="submit" className="w-full md:w-auto">创建用户</Button>
</FieldGroup>
</form>
</AppSection>
</AppCreateDialog>
<AppFilterBar>
<Input placeholder="搜索账号、姓名或邮箱" value={query} onChange={(event) => setQuery(event.target.value)} />
@@ -175,95 +197,11 @@ export function SystemUsersPage() {
getRowKey={(user) => user.id}
emptyTitle="暂无用户"
footer={<p className="text-sm text-muted-foreground">当前显示 {filteredUsers.length} 个用户</p>}
onRowClick={setEditingUser}
/>
) : null}
<UserAccessDialog
user={editingUser}
roles={roles}
groups={groups}
onClose={() => setEditingUser(null)}
onSaved={load}
/>
{user ? <UserEditorSheet user={editingUser} currentUser={user} roles={roles} groups={groups} canManage={canManage} canManageAccess={canManageAccess} onClose={() => setEditingUser(null)} onSaved={load} /> : null}
</AppPage>
)
}
function UserAccessDialog({
user,
roles,
groups,
onClose,
onSaved
}: {
user: SystemUser | null
roles: RoleDetail[]
groups: UserGroup[]
onClose: () => void
onSaved: () => Promise<void>
}) {
const [roleIds, setRoleIds] = React.useState<number[]>([])
const [groupIds, setGroupIds] = React.useState<number[]>([])
React.useEffect(() => {
setRoleIds(user?.roles.map((role) => role.id) ?? [])
setGroupIds(user?.user_groups.map((group) => group.id) ?? [])
}, [user])
async function handleSave() {
if (!user) return
await Promise.all([setUserRoles(user.id, roleIds), setUserGroups(user.id, groupIds)])
await onSaved()
onClose()
}
return (
<Dialog open={Boolean(user)} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>用户授权{user ? `:${user.display_name || user.username}` : ''}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
<FieldSet>
<FieldLegend>角色</FieldLegend>
<FieldGroup data-slot="checkbox-group" className="grid max-h-64 gap-2 overflow-auto rounded-md border border-border p-3">
{roles.map((role) => (
<Field key={role.id} orientation="horizontal">
<Checkbox
id={`access-role-${role.id}`}
checked={roleIds.includes(role.id)}
onCheckedChange={(checked) =>
setRoleIds((current) => checked ? [...current, role.id] : current.filter((id) => id !== role.id))
}
/>
<FieldLabel htmlFor={`access-role-${role.id}`}>{role.name}</FieldLabel>
</Field>
))}
</FieldGroup>
</FieldSet>
<FieldSet>
<FieldLegend>用户组</FieldLegend>
<FieldGroup data-slot="checkbox-group" className="grid max-h-64 gap-2 overflow-auto rounded-md border border-border p-3">
{groups.map((group) => (
<Field key={group.id} orientation="horizontal">
<Checkbox
id={`access-group-${group.id}`}
checked={groupIds.includes(group.id)}
onCheckedChange={(checked) =>
setGroupIds((current) => checked ? [...current, group.id] : current.filter((id) => id !== group.id))
}
/>
<FieldLabel htmlFor={`access-group-${group.id}`}>{group.name}</FieldLabel>
</Field>
))}
</FieldGroup>
</FieldSet>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>取消</Button>
<Button onClick={() => void handleSave()}>保存授权</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,94 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { UserEditorSheet } from './UserEditorSheet'
const mocks = vi.hoisted(() => ({
updateUser: vi.fn(),
setUserRoles: vi.fn(),
setUserGroups: vi.fn(),
resetUserPassword: vi.fn(),
onSaved: vi.fn(),
onClose: vi.fn()
}))
vi.mock('./api', () => ({
updateUser: mocks.updateUser,
setUserRoles: mocks.setUserRoles,
setUserGroups: mocks.setUserGroups,
resetUserPassword: mocks.resetUserPassword
}))
const target = {
id: 9,
username: 'target',
display_name: '目标用户',
email: 'target@example.com',
is_active: true,
is_staff: false,
is_superuser: false,
roles: [{ id: 1, name: '支持角色' }],
user_groups: []
}
const currentUser = {
id: 1,
username: 'admin',
display_name: '管理员',
email: 'admin@example.com',
last_login: null,
date_joined: '2026-01-01T00:00:00Z',
is_superuser: true,
roles: [],
user_groups: [],
permissions: ['system.user.manage'],
menus: []
}
beforeEach(() => {
vi.stubGlobal('ResizeObserver', class {
observe() {}
unobserve() {}
disconnect() {}
})
mocks.updateUser.mockResolvedValue(target)
mocks.setUserRoles.mockResolvedValue(target)
mocks.setUserGroups.mockResolvedValue(target)
mocks.resetUserPassword.mockResolvedValue(target)
mocks.onSaved.mockResolvedValue(undefined)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('用户编辑抽屉', () => {
it('在同一抽屉中编辑基本信息和授权', async () => {
const user = userEvent.setup()
render(<UserEditorSheet user={target} currentUser={currentUser} roles={[{ id: 1, name: '支持角色', permission_codes: [] }, { id: 2, name: '审计角色', permission_codes: [] }]} groups={[]} canManage canManageAccess onClose={mocks.onClose} onSaved={mocks.onSaved} />)
expect(screen.getByRole('dialog', { name: '目标用户' })).toBeInTheDocument()
const displayName = screen.getByLabelText('显示名称')
await user.clear(displayName)
await user.type(displayName, '已编辑用户')
await user.click(screen.getByRole('button', { name: '保存基本信息' }))
await waitFor(() => expect(mocks.updateUser).toHaveBeenCalledWith(9, { display_name: '已编辑用户', email: 'target@example.com', is_active: true }))
await user.click(screen.getByRole('tab', { name: /角色与用户组/ }))
await user.click(screen.getByLabelText('审计角色'))
await user.click(screen.getByRole('button', { name: '保存授权' }))
await waitFor(() => expect(mocks.setUserRoles).toHaveBeenCalledWith(9, [1, 2]))
})
it('当前登录账号只能前往个人中心修改密码', async () => {
const self = { ...target, id: currentUser.id, username: currentUser.username }
const user = userEvent.setup()
render(<UserEditorSheet user={self} currentUser={currentUser} roles={[]} groups={[]} canManage canManageAccess onClose={mocks.onClose} onSaved={mocks.onSaved} />)
await user.click(screen.getByRole('tab', { name: /安全设置/ }))
expect(screen.getByRole('link', { name: '打开个人中心' })).toHaveAttribute('href', '#/profile')
expect(screen.queryByRole('button', { name: '重置密码' })).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,261 @@
import * as React from 'react'
import { KeyRound, LoaderCircle, LockKeyhole, Save, ShieldCheck, UserRound } from 'lucide-react'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type { CurrentUser } from '@/features/auth/types'
import { resetUserPassword, setUserGroups, setUserRoles, updateUser } from './api'
import type { RoleDetail, SystemUser, UserGroup } from './types'
type UserEditorSheetProps = {
user: SystemUser | null
currentUser: CurrentUser
roles: RoleDetail[]
groups: UserGroup[]
canManage: boolean
canManageAccess: boolean
onClose: () => void
onSaved: () => Promise<void>
}
function sameIds(left: number[], right: number[]) {
return [...left].sort((a, b) => a - b).join(',') === [...right].sort((a, b) => a - b).join(',')
}
export function UserEditorSheet({ user, currentUser, roles, groups, canManage, canManageAccess, onClose, onSaved }: UserEditorSheetProps) {
const [displayName, setDisplayName] = React.useState('')
const [email, setEmail] = React.useState('')
const [active, setActive] = React.useState(true)
const [roleIds, setRoleIds] = React.useState<number[]>([])
const [groupIds, setGroupIds] = React.useState<number[]>([])
const [savedBasic, setSavedBasic] = React.useState({ displayName: '', email: '', active: true })
const [savedRoleIds, setSavedRoleIds] = React.useState<number[]>([])
const [savedGroupIds, setSavedGroupIds] = React.useState<number[]>([])
const [newPassword, setNewPassword] = React.useState('')
const [confirmPassword, setConfirmPassword] = React.useState('')
const [pending, setPending] = React.useState<'basic' | 'access' | 'password' | null>(null)
const [error, setError] = React.useState('')
const [discardOpen, setDiscardOpen] = React.useState(false)
const [resetConfirmOpen, setResetConfirmOpen] = React.useState(false)
React.useEffect(() => {
const nextDisplayName = user?.display_name ?? ''
const nextEmail = user?.email ?? ''
const nextActive = user?.is_active ?? true
const nextRoleIds = user?.roles.map((role) => role.id) ?? []
const nextGroupIds = user?.user_groups.map((group) => group.id) ?? []
setDisplayName(nextDisplayName)
setEmail(nextEmail)
setActive(nextActive)
setRoleIds(nextRoleIds)
setGroupIds(nextGroupIds)
setSavedBasic({ displayName: nextDisplayName, email: nextEmail, active: nextActive })
setSavedRoleIds(nextRoleIds)
setSavedGroupIds(nextGroupIds)
setNewPassword('')
setConfirmPassword('')
setError('')
}, [user])
const basicDirty = displayName !== savedBasic.displayName || email !== savedBasic.email || active !== savedBasic.active
const accessDirty = !sameIds(roleIds, savedRoleIds) || !sameIds(groupIds, savedGroupIds)
const passwordDirty = Boolean(newPassword || confirmPassword)
const dirty = basicDirty || accessDirty || passwordDirty
const protectedSuperuser = Boolean(user?.is_superuser && !currentUser.is_superuser)
const editable = canManage && !protectedSuperuser
const selfAccount = user?.id === currentUser.id
function requestClose() {
if (dirty) setDiscardOpen(true)
else onClose()
}
async function saveBasic(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!user || !editable) return
setPending('basic')
setError('')
try {
await updateUser(user.id, { display_name: displayName.trim(), email: email.trim(), is_active: active })
setDisplayName(displayName.trim())
setEmail(email.trim())
setSavedBasic({ displayName: displayName.trim(), email: email.trim(), active })
await onSaved()
toast.success('用户资料已保存')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '用户资料保存失败')
} finally {
setPending(null)
}
}
async function saveAccess() {
if (!user || !editable || !canManageAccess) return
setPending('access')
setError('')
try {
await Promise.all([setUserRoles(user.id, roleIds), setUserGroups(user.id, groupIds)])
setSavedRoleIds(roleIds)
setSavedGroupIds(groupIds)
await onSaved()
toast.success('用户授权已保存')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '用户授权保存失败')
} finally {
setPending(null)
}
}
function preparePasswordReset(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (newPassword.length < 8) {
setError('新密码至少需要 8 位')
return
}
if (newPassword !== confirmPassword) {
setError('两次输入的新密码不一致')
return
}
setError('')
setResetConfirmOpen(true)
}
async function confirmPasswordReset() {
if (!user || !editable || selfAccount) return
setPending('password')
setError('')
try {
await resetUserPassword(user.id, newPassword)
setNewPassword('')
setConfirmPassword('')
setResetConfirmOpen(false)
await onSaved()
toast.success('用户密码已重置')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '密码重置失败')
} finally {
setPending(null)
}
}
return (
<>
<Sheet open={Boolean(user)} onOpenChange={(open) => !open && requestClose()}>
<SheetContent side="right" className="w-full max-w-[560px] gap-0 sm:max-w-[560px]">
<SheetHeader className="border-b pr-12">
<div className="flex flex-wrap items-center gap-2">
<SheetTitle>{displayName || user?.username || '用户详情'}</SheetTitle>
{active ? <Badge variant="success">已启用</Badge> : <Badge variant="destructive">已停用</Badge>}
{user?.is_superuser ? <Badge variant="info">超级管理员</Badge> : null}
</div>
<SheetDescription>账号 {user?.username} · 查看并维护资料、授权和账号安全。</SheetDescription>
</SheetHeader>
{protectedSuperuser ? <Alert className="m-4 mb-0"><LockKeyhole /><AlertTitle>该账号受保护</AlertTitle><AlertDescription>只有超级管理员可以修改其他超级管理员账号。</AlertDescription></Alert> : null}
{error ? <Alert variant="destructive" className="m-4 mb-0"><AlertTitle>操作未完成</AlertTitle><AlertDescription>{error}</AlertDescription></Alert> : null}
<Tabs defaultValue="basic" className="min-h-0 flex-1 gap-0 overflow-hidden">
<TabsList variant="line" className="mx-4 mt-3 w-[calc(100%-2rem)] justify-start border-b">
<TabsTrigger value="basic"><UserRound />基本信息</TabsTrigger>
<TabsTrigger value="access"><ShieldCheck />角色与用户组</TabsTrigger>
<TabsTrigger value="security"><KeyRound />安全设置</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="scrollbar-panel overflow-y-auto p-4">
<form className="flex flex-col gap-5" onSubmit={saveBasic}>
<FieldGroup>
<Field data-disabled><FieldLabel htmlFor="admin-user-username">登录账号</FieldLabel><Input id="admin-user-username" value={user?.username ?? ''} disabled /><FieldDescription>账号作为稳定标识,不支持修改。</FieldDescription></Field>
<Field data-disabled={!editable}><FieldLabel htmlFor="admin-user-display-name">显示名称</FieldLabel><Input id="admin-user-display-name" value={displayName} onChange={(event) => setDisplayName(event.target.value)} disabled={!editable} /></Field>
<Field data-disabled={!editable}><FieldLabel htmlFor="admin-user-email">邮箱</FieldLabel><Input id="admin-user-email" type="email" value={email} onChange={(event) => setEmail(event.target.value)} disabled={!editable} /></Field>
<Field orientation="horizontal" data-disabled={!editable || selfAccount}>
<div className="min-w-0 flex-1"><FieldLabel htmlFor="admin-user-active">账号状态</FieldLabel><FieldDescription>{selfAccount ? '不能在后台停用当前登录账号。' : '停用后该用户将无法继续登录。'}</FieldDescription></div>
<Switch id="admin-user-active" checked={active} onCheckedChange={setActive} disabled={!editable || selfAccount} />
</Field>
</FieldGroup>
{editable ? <Button type="submit" className="self-end" disabled={!basicDirty || pending !== null}>{pending === 'basic' ? <LoaderCircle className="animate-spin" data-icon="inline-start" /> : <Save data-icon="inline-start" />}{pending === 'basic' ? '保存中' : '保存基本信息'}</Button> : null}
</form>
</TabsContent>
<TabsContent value="access" className="scrollbar-panel overflow-y-auto p-4">
<div className="flex flex-col gap-5">
{!canManageAccess ? <Alert><AlertTitle>授权信息只读</AlertTitle><AlertDescription>当前账号没有查看或维护角色、用户组列表的权限。</AlertDescription></Alert> : null}
<FieldSet>
<FieldLegend>角色</FieldLegend>
<FieldDescription>角色决定用户能够执行的系统操作。</FieldDescription>
<FieldGroup className="mt-3 grid gap-2 sm:grid-cols-2">
{roles.length ? roles.map((role) => (
<Field key={role.id} orientation="horizontal" data-disabled={!editable || !canManageAccess}>
<Checkbox id={`editor-role-${role.id}`} checked={roleIds.includes(role.id)} disabled={!editable || !canManageAccess} onCheckedChange={(checked) => setRoleIds((current) => checked ? [...current, role.id] : current.filter((id) => id !== role.id))} />
<FieldLabel htmlFor={`editor-role-${role.id}`} className="font-normal">{role.name}</FieldLabel>
</Field>
)) : <p className="text-sm text-muted-foreground">没有可显示的角色。</p>}
</FieldGroup>
</FieldSet>
<FieldSet>
<FieldLegend>用户组</FieldLegend>
<FieldDescription>用户组用于表达组织或职责归属。</FieldDescription>
<FieldGroup className="mt-3 grid gap-2 sm:grid-cols-2">
{groups.length ? groups.map((group) => (
<Field key={group.id} orientation="horizontal" data-disabled={!editable || !canManageAccess}>
<Checkbox id={`editor-group-${group.id}`} checked={groupIds.includes(group.id)} disabled={!editable || !canManageAccess} onCheckedChange={(checked) => setGroupIds((current) => checked ? [...current, group.id] : current.filter((id) => id !== group.id))} />
<FieldLabel htmlFor={`editor-group-${group.id}`} className="font-normal">{group.name}</FieldLabel>
</Field>
)) : <p className="text-sm text-muted-foreground">没有可显示的用户组。</p>}
</FieldGroup>
</FieldSet>
{editable && canManageAccess ? <Button className="self-end" disabled={!accessDirty || pending !== null} onClick={() => void saveAccess()}>{pending === 'access' ? <LoaderCircle className="animate-spin" data-icon="inline-start" /> : <ShieldCheck data-icon="inline-start" />}{pending === 'access' ? '保存中' : '保存授权'}</Button> : null}
</div>
</TabsContent>
<TabsContent value="security" className="scrollbar-panel overflow-y-auto p-4">
{selfAccount ? (
<Alert><KeyRound /><AlertTitle>请在个人中心修改密码</AlertTitle><AlertDescription>管理员重置入口不能用于当前登录账号,以避免绕过原密码校验。</AlertDescription><Button asChild className="mt-3" variant="outline"><a href="#/profile" onClick={onClose}>打开个人中心</a></Button></Alert>
) : (
<form className="flex flex-col gap-5" onSubmit={preparePasswordReset}>
<FieldGroup>
<Field data-disabled={!editable} data-invalid={Boolean(error)}><FieldLabel htmlFor="admin-user-new-password">新密码</FieldLabel><Input id="admin-user-new-password" type="password" autoComplete="new-password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} disabled={!editable} aria-invalid={Boolean(error)} required /><FieldDescription>至少 8 位,不能使用纯数字、常见密码或与账号过度相似。</FieldDescription></Field>
<Field data-disabled={!editable} data-invalid={Boolean(error)}><FieldLabel htmlFor="admin-user-confirm-password">确认新密码</FieldLabel><Input id="admin-user-confirm-password" type="password" autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} disabled={!editable} aria-invalid={Boolean(error)} required /><FieldError>{error}</FieldError></Field>
</FieldGroup>
{editable ? <Button type="submit" variant="destructive" className="self-end" disabled={pending !== null}><KeyRound data-icon="inline-start" />重置密码</Button> : null}
</form>
)}
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
<AlertDialog open={discardOpen} onOpenChange={setDiscardOpen}>
<AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>放弃未保存的修改?</AlertDialogTitle><AlertDialogDescription>关闭后,当前抽屉中尚未保存的资料、授权或密码输入会丢失。</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel>继续编辑</AlertDialogCancel><AlertDialogAction onClick={onClose}>放弃修改</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={resetConfirmOpen} onOpenChange={setResetConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>确认重置该用户密码?</AlertDialogTitle><AlertDialogDescription>用户现有的其他登录会话将在后续请求时失效。新密码不会出现在审计日志中。</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel>取消</AlertDialogCancel><AlertDialogAction onClick={() => void confirmPasswordReset()}>{pending === 'password' ? '重置中' : '确认重置'}</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -33,6 +33,13 @@ export function updateUser(
})
}
export function resetUserPassword(userId: number, password: string) {
return apiRequest<SystemUser>(`/accounts/users/${userId}/reset-password`, {
method: 'POST',
body: JSON.stringify({ password })
})
}
export function setUserRoles(userId: number, roleIds: number[]) {
return apiRequest<SystemUser>(`/accounts/users/${userId}/roles`, {
method: 'POST',

View File

@@ -30,8 +30,21 @@ export function TicketComments({ ticketId, onCommentAdded }: TicketCommentsProps
}
return (
<AppSection title="评论与协作">
<form className="grid gap-3" onSubmit={handleSubmit}>
<form onSubmit={handleSubmit}>
<AppSection
variant="card"
title="评论与协作"
description="选择内容的可见范围后再保存。"
footer={
<>
<span className="text-xs text-muted-foreground">{commentMode === 'public' ? '用户可以看到此回复' : '仅内部成员可见'}</span>
<Button type="submit" variant="secondary" disabled={!content.trim()}>
{commentMode === 'public' ? '发表用户回复' : '保存内部备注'}
</Button>
</>
}
>
<div className="grid gap-3">
<Select value={commentMode} onValueChange={setCommentMode}>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder="选择回复类型" />
@@ -50,12 +63,8 @@ export function TicketComments({ ticketId, onCommentAdded }: TicketCommentsProps
/>
{lastComment ? <p className="text-sm text-muted-foreground">最新回复:{lastComment}</p> : null}
{!lastComment ? <p className="text-sm text-muted-foreground">暂无评论</p> : null}
<div className="flex justify-end">
<Button type="submit" variant="secondary">
{commentMode === 'public' ? '发表用户回复' : '保存内部备注'}
</Button>
</div>
</form>
</AppSection>
</form>
)
}

View File

@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { toast } from 'sonner'
import { createTicket } from './api'
import { TICKET_CATEGORIES } from './ticketCategories'
@@ -60,9 +61,11 @@ export function TicketCreatePage() {
}
return (
<AppPage maxWidth="6xl" contentClassName="grid gap-5">
<AppPageHeader title="创建工单" />
<form className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]" onSubmit={handleSubmit}>
<AppPage
contentClassName="grid gap-5"
header={<AppPageHeader title="创建工单" description="按提出方、问题描述和影响范围记录完整上下文" actions={<><Button type="button" variant="ghost" onClick={() => (window.location.hash = '/tickets')}>取消</Button><Button type="button" variant="outline" onClick={() => toast('暂存入口为视觉预留,当前不会保存数据')}>暂存</Button></>} />}
>
<form id="ticket-create-form" className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]" onSubmit={handleSubmit}>
<div className="grid gap-4">
{error ? (
<Alert variant="destructive">
@@ -72,72 +75,7 @@ export function TicketCreatePage() {
</Alert>
) : null}
<AppSection title="基础信息">
<div className="grid gap-4 md:grid-cols-2">
<AppField label="问题分类" required>
<Select
value={form.category_code}
onValueChange={(value) => updateField('category_code', value as TicketInput['category_code'])}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="请选择问题分类" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TICKET_CATEGORIES.map((category) => (
<SelectItem key={category.code} value={category.code}>
{category.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</AppField>
<AppField label="工单标题" htmlFor="ticket-title" required>
<Input
id="ticket-title"
required
placeholder="例如:查询客户采集进度"
value={form.title}
onChange={(event) => updateField('title', event.target.value)}
/>
</AppField>
</div>
</AppSection>
<AppSection title="问题信息">
<div className="grid gap-4 md:grid-cols-2">
<AppField label="问题表现" htmlFor="ticket-problem-performance" required>
<Input
id="ticket-problem-performance"
required
placeholder="例如:采集进度长时间未更新"
value={form.problem_performance}
onChange={(event) => updateField('problem_performance', event.target.value)}
/>
</AppField>
<AppField label="问题对象" htmlFor="ticket-problem-object" required>
<Input
id="ticket-problem-object"
required
placeholder="例如:客户名称、账号、机构或任务编号"
value={form.problem_object}
onChange={(event) => updateField('problem_object', event.target.value)}
/>
</AppField>
</div>
<AppField label="问题描述" htmlFor="ticket-description" required>
<Textarea
id="ticket-description"
required
placeholder="请填写问题背景、期望结果、已知线索或需要支持团队确认的内容。"
value={form.description}
onChange={(event) => updateField('description', event.target.value)}
/>
</AppField>
</AppSection>
<AppSection title="提出信息">
<AppSection variant="card" title="提出方与对象" description="先确认由谁提出,以及具体影响到哪个业务对象。">
<div className="grid gap-4 md:grid-cols-2">
<AppField label="提出人" htmlFor="ticket-submitter-name" required>
<Input
@@ -157,26 +95,33 @@ export function TicketCreatePage() {
onChange={(event) => updateField('submitter_org', event.target.value)}
/>
</AppField>
<AppField label="问题对象" htmlFor="ticket-problem-object" required>
<Input
id="ticket-problem-object"
required
placeholder="客户、账号、机构或任务编号"
value={form.problem_object}
onChange={(event) => updateField('problem_object', event.target.value)}
/>
</AppField>
<AppField label="环境" required>
<Select value={form.environment} onValueChange={(value) => updateField('environment', value)}><SelectTrigger className="w-full"><SelectValue placeholder="请选择问题环境" /></SelectTrigger><SelectContent><SelectGroup>{ENVIRONMENT_OPTIONS.map((environment) => <SelectItem key={environment} value={environment}>{environment}</SelectItem>)}</SelectGroup></SelectContent></Select>
</AppField>
</div>
</AppSection>
<AppSection title="影响与环境">
<AppSection variant="card" title="问题描述" description="描述用户看到的现象、发生时间与期望结果。">
<div className="grid gap-4 md:grid-cols-2">
<AppField label="优先级" required>
<Select value={form.priority} onValueChange={(value) => updateField('priority', value as TicketInput['priority'])}>
<SelectTrigger className="w-full">
<SelectValue placeholder="请选择优先级" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="low">低</SelectItem>
<SelectItem value="medium">中</SelectItem>
<SelectItem value="high">高</SelectItem>
<SelectItem value="urgent">紧急</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</AppField>
<AppField label="工单标题" htmlFor="ticket-title" required><Input id="ticket-title" required placeholder="例如:查询客户采集进度" value={form.title} onChange={(event) => updateField('title', event.target.value)} /></AppField>
<AppField label="问题表现" htmlFor="ticket-problem-performance" required><Input id="ticket-problem-performance" required placeholder="例如:采集进度长时间未更新" value={form.problem_performance} onChange={(event) => updateField('problem_performance', event.target.value)} /></AppField>
</div>
<AppField label="问题描述" htmlFor="ticket-description" required><Textarea id="ticket-description" required placeholder="请填写问题背景、期望结果、已知线索或需要支持团队确认的内容。" value={form.description} onChange={(event) => updateField('description', event.target.value)} /></AppField>
</AppSection>
<AppSection variant="card" title="分类与影响" description="用于确定处理队列、响应顺序和影响范围。">
<div className="grid gap-4 md:grid-cols-2">
<AppField label="问题分类" required><Select value={form.category_code} onValueChange={(value) => updateField('category_code', value as TicketInput['category_code'])}><SelectTrigger className="w-full"><SelectValue placeholder="请选择问题分类" /></SelectTrigger><SelectContent><SelectGroup>{TICKET_CATEGORIES.map((category) => <SelectItem key={category.code} value={category.code}>{category.label}</SelectItem>)}</SelectGroup></SelectContent></Select></AppField>
<AppField label="优先级" required><Select value={form.priority} onValueChange={(value) => updateField('priority', value as TicketInput['priority'])}><SelectTrigger className="w-full"><SelectValue placeholder="请选择优先级" /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="low">低</SelectItem><SelectItem value="medium">中</SelectItem><SelectItem value="high">高</SelectItem><SelectItem value="urgent">紧急</SelectItem></SelectGroup></SelectContent></Select></AppField>
<AppField label="影响范围" htmlFor="ticket-impact-scope">
<Input
id="ticket-impact-scope"
@@ -195,41 +140,32 @@ export function TicketCreatePage() {
onChange={(event) => updateField('related_system', event.target.value)}
/>
</AppField>
<AppField label="环境" required>
<Select value={form.environment} onValueChange={(value) => updateField('environment', value)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="请选择问题环境" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ENVIRONMENT_OPTIONS.map((environment) => (
<SelectItem key={environment} value={environment}>
{environment}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</AppField>
</div>
</AppSection>
<AppSection title="提交确认">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-start gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="mt-0.5 size-4" />
<span>请确认分类、对象和描述已足够支持处理人员判断下一步动作。</span>
</div>
<Button type="submit" disabled={submitting}>
<AppSection
variant="card"
title="提交确认"
description="提交后工单将进入对应的支持处理队列。"
footer={
<>
<span className="text-xs text-muted-foreground">确认无误后提交</span>
<Button className="hidden md:inline-flex" type="submit" disabled={submitting}>
<Send data-icon="inline-start" />
{submitting ? '提交中...' : '提交工单'}
</Button>
</>
}
>
<div className="flex items-start gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="mt-0.5 size-4" />
<span>请确认分类、对象和描述已足够支持处理人员判断下一步动作。</span>
</div>
</AppSection>
</div>
<div className="grid content-start gap-4">
<AppSection title="建单提示">
<AppSection variant="card" title="建单提示">
<div className="grid gap-3 text-sm text-muted-foreground">
<div className="flex gap-2">
<Lightbulb className="mt-0.5 size-4" />
@@ -246,16 +182,10 @@ export function TicketCreatePage() {
</div>
</AppSection>
<AppSection
title="AI 解决方案"
action={<Badge variant="ai">即将接入</Badge>}
>
<p className="text-sm text-muted-foreground">
后续阶段将由 AI 识别问题后自动回填,当前不作为人工必填项。
</p>
</AppSection>
<AppSection variant="card" title="Agent 辅助" action={<Badge variant="ai">演示</Badge>}><p className="text-sm text-muted-foreground">右侧助手会根据当前页面字段提供排查结构,但不会提交或保存工单。</p></AppSection>
</div>
</form>
<div className="sticky bottom-3 rounded-lg border bg-card/95 p-3 shadow-lg backdrop-blur md:hidden"><Button form="ticket-create-form" type="submit" className="w-full" disabled={submitting}><Send data-icon="inline-start" />{submitting ? '提交中...' : '提交工单'}</Button></div>
</AppPage>
)
}

View File

@@ -1,5 +1,5 @@
import * as React from 'react'
import { Bot, CheckCircle2, CircleDot, Clock3, Send, XCircle } from 'lucide-react'
import { CheckCircle2, CircleDot, Clock3, Send, XCircle } from 'lucide-react'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppDetailLayout } from '@/components/app/AppDetailLayout'
@@ -10,8 +10,8 @@ import { AppSection } from '@/components/app/AppSection'
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { useAgentPageContext } from '@/features/agent-workspace/AgentWorkspaceProvider'
import { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel'
import { selectDefaultTicketSolution } from '@/features/solutions/solutionSelection'
import type { TicketSolution } from '@/features/solutions/types'
@@ -33,14 +33,6 @@ const STATUS_ACTIONS: Partial<Record<TicketStatus, { label: string; icon: React.
closed: { label: '关闭工单', icon: XCircle }
}
const STATUS_ACTION_HINTS: Partial<Record<TicketStatus, string>> = {
pending: '补充信息完成后进入支持队列。',
in_progress: '确认接手并开始技术排查。',
pending_confirm: '已给出处理结果,等待业务侧确认。',
resolved: '业务确认问题已解决,保留后续关闭入口。',
closed: '关闭代表流程结束,不再继续处理。'
}
function getStatusActionLabel(currentStatus: TicketStatus, targetStatus: TicketStatus) {
if (targetStatus === 'closed' && currentStatus !== 'resolved') {
return '取消工单'
@@ -67,6 +59,11 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
const [transitionDialogOpen, setTransitionDialogOpen] = React.useState(false)
const [transitionError, setTransitionError] = React.useState('')
const [linkedSolutions, setLinkedSolutions] = React.useState<TicketSolution[]>([])
const agentContext = React.useMemo(() => ticket ? ({
routeKey: 'ticket-detail', entityType: 'ticket', entityId: ticket.ticket_no, title: ticket.title, status: ticket.status,
metadata: { 优先级: ticket.priority, 关联系统: ticket.related_system || '未填写', 问题对象: ticket.problem_object || '未填写' }
}) : null, [ticket])
useAgentPageContext(agentContext)
React.useEffect(() => {
refreshTicket()
@@ -115,7 +112,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
if (error) {
return (
<AppPage maxWidth="6xl">
<AppPage maxWidth="6xl" header={<AppPageHeader title="工单详情" />}>
<AppEmptyState title="无法加载工单" description={error} />
</AppPage>
)
@@ -123,7 +120,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
if (!ticket) {
return (
<AppPage maxWidth="6xl">
<AppPage maxWidth="6xl" header={<AppPageHeader title="工单详情" />}>
<AppEmptyState title="正在加载工单" />
</AppPage>
)
@@ -141,10 +138,12 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
: ''
return (
<AppPage maxWidth="6xl">
<AppPageHeader
<AppPage
maxWidth="6xl"
header={<AppPageHeader
title={ticket.title}
description={ticket.ticket_no}
breadcrumbLabel={ticket.ticket_no}
meta={
<>
<AppStatusBadge status={ticket.status} />
@@ -169,38 +168,9 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
})}
</div>
}
/>
<TicketOverviewCard ticket={ticket} />
{nextStatuses.length ? (
<Card>
<CardContent className="grid gap-3 p-4 md:grid-cols-2">
{nextStatuses.map((status) => {
const action = STATUS_ACTIONS[status]
const Icon = action?.icon ?? CircleDot
return (
<button
key={status}
type="button"
className="flex min-h-20 items-start gap-3 rounded-md border border-border bg-background p-3 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={() => openTransition(status)}
/>}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Icon className="size-4" />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">
{getStatusActionLabel(ticket.status, status)}
</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
{STATUS_ACTION_HINTS[status] ?? '记录本次处理动作并推进流程。'}
</span>
</span>
</button>
)
})}
</CardContent>
</Card>
) : null}
<TicketOverviewCard ticket={ticket} />
<AppDetailLayout
side={
<>
@@ -213,20 +183,10 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
refreshEvents()
}}
/>
<AppSection title="AI 支持分析" action={<Badge variant="ai">后续接入</Badge>}>
<div className="grid gap-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Bot className="size-4" />
智能排查占位
</div>
<Separator />
<p className="text-sm text-muted-foreground">后续阶段将在这里展示排查建议、相似工单和引用来源。</p>
</div>
</AppSection>
</>
}
>
<AppSection title="问题描述">
<AppSection variant="card" title="问题描述">
<div className="grid gap-4">
<div>
<p className="text-xs font-medium text-muted-foreground">问题分类</p>
@@ -242,7 +202,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
</div>
</div>
</AppSection>
<AppSection title="解决方案">
<AppSection variant="card" title="解决方案">
{ticket.resolution ? (
<div className="grid gap-3">
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.resolution}</p>
@@ -289,8 +249,12 @@ function TicketOverviewCard({ ticket }: { ticket: Ticket }) {
]
return (
<Card>
<CardContent className="grid gap-4 p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
<Card className="gap-0 py-0">
<CardHeader className="border-b bg-muted/30 py-3.5">
<CardTitle className="text-sm font-semibold">工单概览</CardTitle>
<CardDescription className="text-xs">提出方、责任人与业务上下文</CardDescription>
</CardHeader>
<CardContent className="p-4">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{items.map((item) => (
<div key={item.label} className="min-w-0">
@@ -299,11 +263,11 @@ function TicketOverviewCard({ ticket }: { ticket: Ticket }) {
</div>
))}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock3 className="size-4" />
创建于 {formatDateTime(ticket.created_at)}
</div>
</CardContent>
<CardFooter className="justify-between bg-muted/45 px-4 py-3 text-xs text-muted-foreground">
<span className="flex items-center gap-2"><Clock3 className="size-4" />创建于 {formatDateTime(ticket.created_at)}</span>
<span className="font-mono">{ticket.ticket_no}</span>
</CardFooter>
</Card>
)
}

View File

@@ -1,6 +1,7 @@
import * as React from 'react'
import { ArrowRight, Clock3, Plus } from 'lucide-react'
import { ArrowRight, Clock3, Filter, Plus } from 'lucide-react'
import { useCurrentRoute } from '@/app/route-meta'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppPage } from '@/components/app/AppPage'
@@ -19,7 +20,9 @@ import {
PaginationPrevious
} from '@/components/ui/pagination'
import { Skeleton } from '@/components/ui/skeleton'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useAgentPageContext } from '@/features/agent-workspace/AgentWorkspaceProvider'
import { formatDateTime } from '@/lib/format'
import { cn } from '@/lib/utils'
@@ -31,6 +34,24 @@ import type { Ticket, TicketListResponse, TicketListView } from './types'
const PAGE_SIZE = 20
function getInitialView(): TicketListView {
const query = window.location.hash.split('?')[1] || ''
const value = new URLSearchParams(query).get('view')
return viewTabs.some((item) => item.value === value) ? value as TicketListView : 'all'
}
function getInitialStatusGroup() {
const query = window.location.hash.split('?')[1] || ''
const value = new URLSearchParams(query).get('status_group')
return value === 'active' || value === 'completed' ? value : ''
}
function getInitialResolvedWithinDays() {
const query = window.location.hash.split('?')[1] || ''
const value = Number(new URLSearchParams(query).get('resolved_within_days'))
return Number.isFinite(value) && value > 0 ? value : undefined
}
function formatTicketTableDate(value: string) {
return new Intl.DateTimeFormat('zh-CN', {
month: 'numeric',
@@ -146,6 +167,7 @@ const columns: AppDataTableColumn<Ticket>[] = [
]
export function TicketListPage() {
const { route } = useCurrentRoute()
const [result, setResult] = React.useState<TicketListResponse>({
items: [],
page: 1,
@@ -153,8 +175,10 @@ export function TicketListPage() {
total: 0,
total_pages: 0
})
const [view, setView] = React.useState<TicketListView>('all')
const [view, setView] = React.useState<TicketListView>(getInitialView)
const [status, setStatus] = React.useState('all')
const [statusGroup, setStatusGroup] = React.useState<'active' | 'completed' | ''>(getInitialStatusGroup)
const [resolvedWithinDays, setResolvedWithinDays] = React.useState<number | undefined>(getInitialResolvedWithinDays)
const [categoryCode, setCategoryCode] = React.useState('all')
const [priority, setPriority] = React.useState('all')
const [relatedSystem, setRelatedSystem] = React.useState('')
@@ -162,6 +186,24 @@ export function TicketListPage() {
const [error, setError] = React.useState('')
const [loading, setLoading] = React.useState(true)
const [page, setPage] = React.useState(1)
const [filterOpen, setFilterOpen] = React.useState(false)
const agentContext = React.useMemo(() => ({
routeKey: 'tickets', entityType: 'ticket-list', title: `工单中心 · ${result.total} 条`, status: loading ? '加载中' : '已同步页面数据',
metadata: { 当前页: String(result.page), 待办数: String(result.items.filter((ticket) => ticket.status === 'pending').length) }
}), [loading, result])
useAgentPageContext(agentContext)
React.useEffect(() => {
const params = new URLSearchParams(route.split('?')[1] || '')
const nextView = params.get('view')
const nextStatusGroup = params.get('status_group')
const nextResolvedWithinDays = Number(params.get('resolved_within_days'))
setView(viewTabs.some((item) => item.value === nextView) ? nextView as TicketListView : 'all')
setStatusGroup(nextStatusGroup === 'active' || nextStatusGroup === 'completed' ? nextStatusGroup : '')
setResolvedWithinDays(Number.isFinite(nextResolvedWithinDays) && nextResolvedWithinDays > 0 ? nextResolvedWithinDays : undefined)
setPage(1)
}, [route])
React.useEffect(() => {
setLoading(true)
@@ -170,6 +212,8 @@ export function TicketListPage() {
page_size: PAGE_SIZE,
view,
status: status === 'all' ? '' : status,
status_group: statusGroup,
resolved_within_days: resolvedWithinDays,
category_code: categoryCode === 'all' ? '' : categoryCode,
priority: priority === 'all' ? '' : priority,
related_system: relatedSystem,
@@ -181,10 +225,12 @@ export function TicketListPage() {
})
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
.finally(() => setLoading(false))
}, [categoryCode, page, priority, q, relatedSystem, status, view])
}, [categoryCode, page, priority, q, relatedSystem, resolvedWithinDays, status, statusGroup, view])
function resetFilters() {
setStatus('all')
setStatusGroup('')
setResolvedWithinDays(undefined)
setCategoryCode('all')
setPriority('all')
setRelatedSystem('')
@@ -227,11 +273,12 @@ export function TicketListPage() {
setPage(target)
}
const hasActiveFilters = status !== 'all' || categoryCode !== 'all' || priority !== 'all' || relatedSystem.trim() || q.trim()
const hasActiveFilters = status !== 'all' || statusGroup !== '' || Boolean(resolvedWithinDays) || categoryCode !== 'all' || priority !== 'all' || relatedSystem.trim() || q.trim()
return (
<AppPage contentClassName="gap-5">
<AppPageHeader
<AppPage
contentClassName="gap-5"
header={<AppPageHeader
title="工单中心"
meta={hasActiveFilters ? <Badge variant="info">已筛选</Badge> : <Badge variant="outline">全部范围</Badge>}
actions={
@@ -240,7 +287,8 @@ export function TicketListPage() {
新建工单
</Button>
}
/>
/>}
>
<TicketMetricCards tickets={result.items} />
@@ -262,19 +310,13 @@ export function TicketListPage() {
</CardContent>
</Card>
<TicketFilterBar
q={q}
status={status}
categoryCode={categoryCode}
priority={priority}
relatedSystem={relatedSystem}
onQChange={updateQuery}
onStatusChange={updateStatus}
onCategoryCodeChange={updateCategoryCode}
onPriorityChange={updatePriority}
onRelatedSystemChange={updateRelatedSystem}
onReset={resetFilters}
/>
<div className="hidden md:block"><TicketFilterBar q={q} status={status} categoryCode={categoryCode} priority={priority} relatedSystem={relatedSystem} onQChange={updateQuery} onStatusChange={updateStatus} onCategoryCodeChange={updateCategoryCode} onPriorityChange={updatePriority} onRelatedSystemChange={updateRelatedSystem} onReset={resetFilters} /></div>
<div className="md:hidden">
<Sheet open={filterOpen} onOpenChange={setFilterOpen}>
<SheetTrigger asChild><Button variant="outline" className="w-full"><Filter data-icon="inline-start" />筛选工单{hasActiveFilters ? <Badge variant="info">已启用</Badge> : null}</Button></SheetTrigger>
<SheetContent side="right" className="w-[92vw] sm:max-w-md"><SheetHeader><SheetTitle>筛选工单</SheetTitle><SheetDescription>筛选条件与桌面端保持一致。</SheetDescription></SheetHeader><div className="overflow-y-auto px-4 pb-6"><TicketFilterBar q={q} status={status} categoryCode={categoryCode} priority={priority} relatedSystem={relatedSystem} onQChange={updateQuery} onStatusChange={updateStatus} onCategoryCodeChange={updateCategoryCode} onPriorityChange={updatePriority} onRelatedSystemChange={updateRelatedSystem} onReset={resetFilters} /></div></SheetContent>
</Sheet>
</div>
{error ? <AppEmptyState title="无法加载工单" description={error} /> : null}
{loading && !error ? (
@@ -287,7 +329,8 @@ export function TicketListPage() {
</Card>
) : null}
{!error && !loading ? (
<AppDataTable
<>
<div className="hidden md:block"><AppDataTable
columns={columns}
rows={result.items}
getRowKey={(ticket) => ticket.id}
@@ -331,8 +374,23 @@ export function TicketListPage() {
}
tableClassName="table-fixed min-w-[980px]"
onRowClick={(ticket) => (window.location.hash = `/tickets/${ticket.id}`)}
/>
/></div>
<div className="grid gap-3 md:hidden">
{result.items.length ? result.items.map((ticket) => <TicketMobileCard key={ticket.id} ticket={ticket} onClick={() => (window.location.hash = `/tickets/${ticket.id}`)} />) : <AppEmptyState title="暂无工单" description="调整筛选条件,或创建第一张工单后再查看。" />}
<Card><CardContent className="grid gap-3 p-3"><p className="text-center text-xs text-muted-foreground">共 {result.total} 条 · 第 {result.page} / {Math.max(result.total_pages, 1)} 页</p><div className="grid grid-cols-2 gap-2"><Button variant="outline" disabled={result.page <= 1} onClick={() => goToPage(result.page - 1)}>上一页</Button><Button variant="outline" disabled={result.page >= result.total_pages} onClick={() => goToPage(result.page + 1)}>下一页</Button></div></CardContent></Card>
</div>
</>
) : null}
</AppPage>
)
}
function TicketMobileCard({ ticket, onClick }: { ticket: Ticket; onClick: () => void }) {
return (
<button type="button" className="rounded-lg border bg-card p-4 text-left shadow-sm outline-none transition-colors hover:border-primary/30 focus-visible:ring-2 focus-visible:ring-ring/40" onClick={onClick}>
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><p className="font-mono text-[11px] text-primary">{ticket.ticket_no}</p><h2 className="mt-1 line-clamp-2 text-sm font-semibold">{ticket.title}</h2></div><AppPriorityBadge priority={ticket.priority} /></div>
<p className="mt-3 line-clamp-2 text-xs leading-5 text-muted-foreground">{ticket.problem_performance || ticket.description || '暂无问题描述'}</p>
<div className="mt-3 flex flex-wrap items-center gap-2"><AppStatusBadge status={ticket.status} /><Badge variant="secondary">{getTicketCategoryLabel(ticket.category_code)}</Badge><span className="ml-auto text-[11px] text-muted-foreground">{formatTicketTableDate(ticket.updated_at)}</span></div>
</button>
)
}

View File

@@ -1,7 +1,6 @@
import { AppField } from '@/components/app/AppField'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import type { Ticket } from './types'
@@ -11,11 +10,11 @@ type TicketMetaPanelProps = {
export function TicketMetaPanel({ ticket }: TicketMetaPanelProps) {
return (
<Card>
<CardHeader>
<CardTitle>处理信息</CardTitle>
<Card className="gap-0 py-0">
<CardHeader className="border-b bg-muted/30 py-3.5">
<CardTitle className="text-sm font-semibold">处理信息</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="grid gap-3">
<AppField label="运行环境">
<span className="text-sm text-foreground">{ticket.environment || '未填写'}</span>
@@ -23,13 +22,12 @@ export function TicketMetaPanel({ ticket }: TicketMetaPanelProps) {
<AppField label="影响范围">
<span className="text-sm text-foreground">{ticket.impact_scope || '未填写'}</span>
</AppField>
<Separator />
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium text-muted-foreground">SLA</span>
<Badge variant="outline">后续接入</Badge>
</div>
</div>
</CardContent>
<CardFooter className="justify-between bg-muted/45 px-4 py-3">
<span className="text-xs font-medium text-muted-foreground">SLA</span>
<Badge variant="outline">后续接入</Badge>
</CardFooter>
</Card>
)
}

View File

@@ -39,23 +39,23 @@ const metricConfig = [
export function TicketMetricCards({ tickets }: TicketMetricCardsProps) {
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="grid grid-cols-2 gap-2 md:gap-3 xl:grid-cols-4">
{metricConfig.map((metric) => {
const Icon = metric.icon
return (
<Card key={metric.label} className="relative">
<CardHeader className="grid grid-cols-[minmax(0,1fr)_auto] items-start">
<Card key={metric.label} className="relative gap-2 py-3">
<CardHeader className="grid grid-cols-[minmax(0,1fr)_auto] items-start px-3">
<div className="min-w-0">
<CardTitle className="text-sm">{metric.label}</CardTitle>
<Badge variant="outline" className="mt-2">{metric.badge}</Badge>
<Badge variant="outline" className="mt-1 hidden sm:inline-flex">{metric.badge}</Badge>
</div>
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary ring-1 ring-primary/15">
<div className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary ring-1 ring-primary/15">
<Icon className="size-4" />
</div>
</CardHeader>
<CardContent className="grid gap-2">
<p className="text-3xl font-semibold tabular-nums text-foreground">{metric.value(tickets)}</p>
<p className="text-xs text-muted-foreground">当前列表范围</p>
<CardContent className="grid gap-1 px-3">
<p className="text-2xl font-semibold tabular-nums text-foreground">{metric.value(tickets)}</p>
<p className="hidden text-xs text-muted-foreground sm:block">当前列表范围</p>
</CardContent>
</Card>
)

View File

@@ -82,7 +82,7 @@ type TicketStatusTimelineProps = {
export function TicketStatusTimeline({ ticket, events }: TicketStatusTimelineProps) {
return (
<AppSection title="处理记录">
<AppSection variant="card" title="处理记录">
<div className="grid gap-4">
<div className="grid gap-3 rounded-md border border-border bg-background p-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="min-w-0">

View File

@@ -71,6 +71,8 @@ export type TicketListParams = {
related_system?: string
assignee_id?: number
view?: TicketListView
status_group?: 'active' | 'completed' | ''
resolved_within_days?: number
}
export type TicketInput = {

View File

@@ -1,94 +0,0 @@
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

@@ -1,77 +0,0 @@
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

@@ -1,171 +0,0 @@
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

@@ -1,176 +0,0 @@
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,5 @@
import * as React from 'react'
import { ArrowRight, Plus } from 'lucide-react'
import { AlertTriangle, ArrowRight, CheckCircle2, Clock3, Plus, RotateCcw } from 'lucide-react'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState'
@@ -10,119 +10,52 @@ import { AppSection } from '@/components/app/AppSection'
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { formatDateTime } from '@/lib/format'
import { useAgentPageContext } from '@/features/agent-workspace/AgentWorkspaceProvider'
import { listTickets } from '@/features/tickets/api'
import type { Ticket } from '@/features/tickets/types'
import { formatDateTime } from '@/lib/format'
const columns: AppDataTableColumn<Ticket>[] = [
{
key: 'ticket_no',
header: '编号',
cell: (ticket) => <span className="font-medium text-foreground">{ticket.ticket_no}</span>
},
{
key: 'title',
header: '标题',
cell: (ticket) => <span className="line-clamp-1">{ticket.title}</span>
},
{
key: 'status',
header: '状态',
cell: (ticket) => <AppStatusBadge status={ticket.status} />
},
{
key: 'priority',
header: '优先级',
cell: (ticket) => <AppPriorityBadge priority={ticket.priority} />
},
{
key: 'created_at',
header: '创建时间',
cell: (ticket) => <span className="text-muted-foreground">{formatDateTime(ticket.created_at)}</span>
}
{ key: 'ticket_no', header: '编号', cell: (ticket) => <span className="font-mono text-xs font-medium text-primary">{ticket.ticket_no}</span> },
{ key: 'title', header: '标题', cell: (ticket) => <span className="line-clamp-1 font-medium">{ticket.title}</span> },
{ key: 'status', header: '状态', cell: (ticket) => <AppStatusBadge status={ticket.status} /> },
{ key: 'priority', header: '优先级', cell: (ticket) => <AppPriorityBadge priority={ticket.priority} /> },
{ key: 'created_at', header: '最近更新', cell: (ticket) => <span className="text-xs text-muted-foreground">{formatDateTime(ticket.updated_at)}</span> }
]
const metricDefinitions = [
{ label: '我的待办', description: '待处理与处理中', icon: Clock3, href: '/tickets?view=assigned_to_me', value: (tickets: Ticket[]) => tickets.filter((item) => item.status === 'pending' || item.status === 'in_progress').length },
{ label: '即将超时', description: '高优先级未完成', icon: AlertTriangle, href: '/tickets?view=high_priority', value: (tickets: Ticket[]) => tickets.filter((item) => (item.priority === 'high' || item.priority === 'urgent') && !['resolved', 'closed'].includes(item.status)).length },
{ label: '待确认', description: '等待业务侧反馈', icon: RotateCcw, href: '/tickets', value: (tickets: Ticket[]) => tickets.filter((item) => item.status === 'pending_confirm').length },
{ label: '最近完成', description: '已解决与已关闭', icon: CheckCircle2, href: '/tickets', value: (tickets: Ticket[]) => tickets.filter((item) => item.status === 'resolved' || item.status === 'closed').length }
]
export function WorkbenchPage() {
const [tickets, setTickets] = React.useState<Ticket[]>([])
const [error, setError] = React.useState('')
React.useEffect(() => {
listTickets()
.then((response) => setTickets(response.items))
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
}, [])
React.useEffect(() => { listTickets().then((response) => setTickets(response.items)).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败')) }, [])
const metrics = [
{ label: '待处理工单', value: tickets.filter((ticket) => ticket.status === 'pending').length },
{ label: '处理中', value: tickets.filter((ticket) => ticket.status === 'in_progress').length },
{ label: '高优先级', value: tickets.filter((ticket) => ticket.priority === 'high' || ticket.priority === 'urgent').length },
{ label: '已解决', value: tickets.filter((ticket) => ticket.status === 'resolved' || ticket.status === 'closed').length }
]
const recentTickets = tickets.slice(0, 6)
const metrics = React.useMemo(() => metricDefinitions.map((metric) => ({ ...metric, count: metric.value(tickets) })), [tickets])
const agentContext = React.useMemo(() => ({ routeKey: 'workbench', entityType: 'workbench', title: '今日支持工作台', status: error ? '数据加载异常' : '已同步页面数据', metadata: Object.fromEntries(metrics.map((item) => [item.label, String(item.count)])) }), [error, metrics])
useAgentPageContext(agentContext)
return (
<AppPage contentClassName="grid gap-5">
<AppPageHeader
title="今日工作台"
actions={
<Button onClick={() => (window.location.hash = '/tickets/new')}>
<Plus data-icon="inline-start" />
新建工单
</Button>
}
/>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{metrics.map((metric) => (
<Card key={metric.label}>
<CardContent>
<p className="text-sm text-muted-foreground">{metric.label}</p>
<p className="mt-2 text-2xl font-semibold text-foreground">{metric.value}</p>
</CardContent>
</Card>
))}
</div>
{error ? <AppEmptyState title="无法加载工作台数据" description={error} /> : null}
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<AppSection
title="最近工单"
action={
<Button variant="ghost" onClick={() => (window.location.hash = '/tickets')}>
查看全部
<ArrowRight data-icon="inline-end" />
</Button>
}
<AppPage
contentClassName="grid gap-5"
header={<AppPageHeader title="今日工作台" description="聚焦需要立即处理的支持事项" actions={<Button onClick={() => (window.location.hash = '/tickets/new')}><Plus data-icon="inline-start" />新建工单</Button>} />}
>
<AppDataTable
columns={columns}
rows={recentTickets}
getRowKey={(ticket) => ticket.id}
emptyTitle="暂无工单"
emptyDescription="创建第一张工单后,最近动态会显示在这里。"
onRowClick={(ticket) => (window.location.hash = `/tickets/${ticket.id}`)}
/>
</AppSection>
<AppSection title="快捷入口">
<div className="grid gap-2">
<Button className="justify-between" variant="secondary" onClick={() => (window.location.hash = '/tickets/new')}>
创建工单
<ArrowRight data-icon="inline-end" />
</Button>
<Button className="justify-between" variant="outline" onClick={() => (window.location.hash = '/tickets')}>
工单中心
<ArrowRight data-icon="inline-end" />
</Button>
<Button className="justify-between" variant="outline" onClick={() => (window.location.hash = '/solutions')}>
解决方案库
<ArrowRight data-icon="inline-end" />
</Button>
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4 lg:gap-3">
{metrics.map((metric) => { const Icon = metric.icon; return (
<button key={metric.label} type="button" className="text-left outline-none" onClick={() => (window.location.hash = metric.href)}>
<Card className="app-card-hover h-full gap-2 py-3"><CardContent className="flex items-start gap-3 px-3 lg:px-4"><span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"><Icon className="size-4" /></span><span className="min-w-0 flex-1"><span className="block text-xs text-muted-foreground">{metric.label}</span><strong className="mt-1 block text-2xl font-semibold tabular-nums">{metric.count}</strong><span className="mt-1 hidden text-[11px] text-muted-foreground sm:block">{metric.description}</span></span><ArrowRight className="size-3.5 text-muted-foreground" /></CardContent></Card>
</button>
)})}
</div>
{error ? <AppEmptyState title="无法加载工作台数据" description={error} /> : null}
<AppSection title="最近处理" description="按更新时间展示最近工单" action={<Button variant="ghost" onClick={() => (window.location.hash = '/tickets')}>查看全部<ArrowRight data-icon="inline-end" /></Button>}>
<AppDataTable columns={columns} rows={tickets.slice(0, 8)} getRowKey={(ticket) => ticket.id} emptyTitle="暂无工单" emptyDescription="创建第一张工单后,最近动态会显示在这里。" onRowClick={(ticket) => (window.location.hash = `/tickets/${ticket.id}`)} />
</AppSection>
</div>
</AppPage>
)
}

View File

@@ -1,6 +1,6 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
const MOBILE_BREAKPOINT = 1024
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)

View File

@@ -1,4 +1,4 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000/api'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api'
let csrfToken: string | null = null

View File

@@ -4,272 +4,175 @@
@layer base {
:root {
--background: 213 24% 97%;
--foreground: 222 28% 11%;
--background: 216 38% 97%;
--foreground: 221 38% 15%;
--surface: 0 0% 100%;
--muted: 214 22% 93%;
--muted-foreground: 215 14% 38%;
--border: 214 18% 84%;
--primary: 214 56% 30%;
--primary-foreground: 0 0% 100%;
--success: 151 55% 36%;
--warning: 38 86% 48%;
--danger: 0 70% 50%;
--info: 204 76% 44%;
--ai-accent: 186 52% 36%;
--radius: 0.5rem;
--card: 0 0% 100%;
--card-foreground: 222 28% 11%;
--card-foreground: 221 38% 15%;
--popover: 0 0% 100%;
--popover-foreground: 222 25% 12%;
--secondary: 210 18% 94%;
--secondary-foreground: 222 25% 12%;
--accent: 210 18% 94%;
--accent-foreground: 222 25% 12%;
--destructive: 0 70% 50%;
--input: 214 18% 86%;
--ring: 213 52% 32%;
--popover-foreground: 221 38% 15%;
--muted: 216 30% 95%;
--muted-foreground: 216 15% 43%;
--border: 213 32% 92%;
--input: 213 32% 88%;
--primary: 221 83% 53%;
--primary-foreground: 0 0% 100%;
--secondary: 216 100% 96%;
--secondary-foreground: 221 70% 38%;
--accent: 216 100% 96%;
--accent-foreground: 221 70% 38%;
--destructive: 0 72% 51%;
--success: 156 76% 36%;
--warning: 38 92% 50%;
--danger: 0 72% 51%;
--info: 221 83% 53%;
--ai-accent: 260 80% 60%;
--ring: 221 83% 53%;
--radius: 0.625rem;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222 28% 11%;
--sidebar-primary: 214 56% 30%;
--sidebar-foreground: 221 38% 15%;
--sidebar-primary: 221 83% 53%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 214 22% 93%;
--sidebar-accent-foreground: 222 28% 11%;
--sidebar-border: 214 18% 84%;
--sidebar-ring: 213 52% 32%;
--sidebar-accent: 216 100% 96%;
--sidebar-accent-foreground: 221 83% 53%;
--sidebar-border: 213 32% 92%;
--sidebar-ring: 221 83% 53%;
--scrollbar-thumb: 214 18% 68%;
--scrollbar-thumb-hover: 214 18% 52%;
--scrollbar-thumb-active: 221 83% 53%;
}
.dark {
--background: 222 28% 8%;
--foreground: 214 24% 94%;
--surface: 222 26% 11%;
--muted: 219 22% 17%;
--muted-foreground: 215 14% 70%;
--border: 219 20% 24%;
--primary: 186 52% 46%;
--primary-foreground: 222 28% 8%;
--success: 151 55% 46%;
--warning: 38 86% 58%;
--danger: 0 70% 60%;
--info: 204 76% 58%;
--ai-accent: 186 52% 46%;
--card: 222 26% 11%;
--card-foreground: 214 24% 94%;
--popover: 222 26% 11%;
--popover-foreground: 214 24% 94%;
--secondary: 219 22% 17%;
--secondary-foreground: 214 24% 94%;
--accent: 219 22% 17%;
--accent-foreground: 214 24% 94%;
--destructive: 0 70% 60%;
--input: 219 20% 24%;
--ring: 186 52% 46%;
--sidebar: 222 32% 11%;
--sidebar-foreground: 214 24% 92%;
--sidebar-primary: 186 52% 46%;
--sidebar-primary-foreground: 222 28% 8%;
--sidebar-accent: 219 26% 20%;
--sidebar-accent-foreground: 0 0% 100%;
--sidebar-border: 219 24% 24%;
--sidebar-ring: 186 52% 46%;
--background: 221 32% 8%;
--foreground: 214 32% 95%;
--surface: 221 28% 11%;
--card: 221 28% 11%;
--card-foreground: 214 32% 95%;
--popover: 221 28% 11%;
--popover-foreground: 214 32% 95%;
--muted: 220 23% 16%;
--muted-foreground: 215 16% 68%;
--border: 219 21% 22%;
--input: 219 21% 25%;
--primary: 217 91% 64%;
--primary-foreground: 222 40% 9%;
--secondary: 219 25% 18%;
--secondary-foreground: 214 32% 92%;
--accent: 219 25% 18%;
--accent-foreground: 214 32% 95%;
--destructive: 0 76% 64%;
--success: 156 65% 48%;
--warning: 38 92% 58%;
--danger: 0 76% 64%;
--info: 217 91% 64%;
--ai-accent: 260 88% 72%;
--ring: 217 91% 64%;
--sidebar: 221 30% 10%;
--sidebar-foreground: 214 32% 94%;
--sidebar-primary: 217 91% 64%;
--sidebar-primary-foreground: 222 40% 9%;
--sidebar-accent: 219 25% 18%;
--sidebar-accent-foreground: 214 32% 95%;
--sidebar-border: 219 21% 22%;
--sidebar-ring: 217 91% 64%;
--scrollbar-thumb: 216 17% 42%;
--scrollbar-thumb-hover: 216 17% 58%;
--scrollbar-thumb-active: 217 91% 64%;
}
* {
border-color: hsl(var(--border));
scrollbar-color: hsl(var(--scrollbar-thumb) / 0.48) transparent;
scrollbar-width: thin;
}
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
min-width: 48px;
min-height: 48px;
border: 3px solid transparent;
border-radius: 999px;
background-color: hsl(var(--scrollbar-thumb) / 0.48);
background-clip: content-box;
transition: background-color 160ms ease;
}
*::-webkit-scrollbar-thumb:hover {
border-width: 2px;
background-color: hsl(var(--scrollbar-thumb-hover) / 0.72);
}
*::-webkit-scrollbar-thumb:active {
background-color: hsl(var(--scrollbar-thumb-active) / 0.92);
}
*::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
*::-webkit-scrollbar-corner { background: transparent; }
html { color-scheme: light; }
html.dark { color-scheme: dark; }
body {
margin: 0;
min-width: 320px;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family:
"Geist Variable", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-family: "Geist Variable", "PingFang SC", "Microsoft YaHei", 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); }
button, a, input, textarea, select { -webkit-tap-highlight-color: transparent; }
:focus-visible { outline: none; }
.font-mono, [data-mono] { font-variant-numeric: tabular-nums; }
}
@layer utilities {
.app-panel-shadow { box-shadow: 0 8px 28px hsl(221 38% 15% / 0.06); }
.app-card-hover { transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; }
.app-card-hover:hover { border-color: hsl(var(--primary) / 0.24); box-shadow: 0 10px 28px hsl(var(--foreground) / 0.07); transform: translateY(-1px); }
.scrollbar-page { scrollbar-gutter: stable; }
.scrollbar-panel { scrollbar-color: hsl(var(--scrollbar-thumb) / 0.34) transparent; }
.scrollbar-panel:hover,
.scrollbar-panel:focus-within { scrollbar-color: hsl(var(--scrollbar-thumb) / 0.52) transparent; }
.scrollbar-panel::-webkit-scrollbar { width: 7px; height: 7px; }
.scrollbar-panel::-webkit-scrollbar-thumb {
min-width: 28px;
min-height: 28px;
border-width: 2px;
background-color: hsl(var(--scrollbar-thumb) / 0.34);
}
.scrollbar-panel:hover::-webkit-scrollbar-thumb,
.scrollbar-panel:focus-within::-webkit-scrollbar-thumb { background-color: hsl(var(--scrollbar-thumb) / 0.52); }
.scrollbar-panel::-webkit-scrollbar-thumb:hover {
border-width: 1px;
background-color: hsl(var(--scrollbar-thumb-hover) / 0.72);
}
.scrollbar-panel::-webkit-scrollbar-thumb:active { background-color: hsl(var(--scrollbar-thumb-active) / 0.92); }
.scrollbar-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hidden::-webkit-scrollbar { display: none; }
.scroll-fade-x[data-can-scroll-left="true"][data-can-scroll-right="true"] {
-webkit-mask-image: linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent);
mask-image: linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent);
}
.scroll-fade-x[data-can-scroll-left="true"][data-can-scroll-right="false"] {
-webkit-mask-image: linear-gradient(to right, transparent, black 20px);
mask-image: linear-gradient(to right, transparent, black 20px);
}
.scroll-fade-x[data-can-scroll-left="false"][data-can-scroll-right="true"] {
-webkit-mask-image: linear-gradient(to right, black calc(100% - 20px), transparent);
mask-image: linear-gradient(to right, black calc(100% - 20px), transparent);
}
.scroll-fade-y[data-can-scroll-up="true"][data-can-scroll-down="true"] {
-webkit-mask-image: linear-gradient(to bottom, transparent, black 16px, black calc(100% - 16px), transparent);
mask-image: linear-gradient(to bottom, transparent, black 16px, black calc(100% - 16px), transparent);
}
.scroll-fade-y[data-can-scroll-up="true"][data-can-scroll-down="false"] {
-webkit-mask-image: linear-gradient(to bottom, transparent, black 16px);
mask-image: linear-gradient(to bottom, transparent, black 16px);
}
.scroll-fade-y[data-can-scroll-up="false"][data-can-scroll-down="true"] {
-webkit-mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);
mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
}

View File

@@ -1 +1,15 @@
import '@testing-library/jest-dom/vitest'
import { vi } from 'vitest'
if (!window.matchMedia) {
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()
}))
}

View File

@@ -10,7 +10,7 @@
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,

View File

@@ -1,6 +1,24 @@
import react from '@vitejs/plugin-react'
import path from 'node:path'
import { defineConfig } from 'vite'
import { defineConfig, type ProxyOptions } from 'vite'
const apiProxyTarget = process.env.API_PROXY_TARGET ?? 'http://localhost:8000'
const apiProxyOrigin = new URL(process.env.API_PROXY_ORIGIN ?? apiProxyTarget)
function createApiProxy(): Record<string, string | ProxyOptions> {
return {
'/api': {
target: apiProxyTarget,
changeOrigin: true,
configure(proxy) {
proxy.on('proxyReq', (proxyRequest) => {
proxyRequest.setHeader('host', apiProxyOrigin.host)
proxyRequest.setHeader('origin', apiProxyOrigin.origin)
})
}
}
}
}
export default defineConfig({
plugins: [react()],
@@ -10,6 +28,13 @@ export default defineConfig({
}
},
server: {
port: 5173
host: '0.0.0.0',
port: 5173,
proxy: createApiProxy()
},
preview: {
host: '0.0.0.0',
port: 4173,
proxy: createApiProxy()
}
})