feat: rebuild support workspace frontend
This commit is contained in:
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
55
frontend/src/app/route-meta.test.tsx
Normal file
55
frontend/src/app/route-meta.test.tsx
Normal 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' } })
|
||||
})
|
||||
})
|
||||
99
frontend/src/app/route-meta.tsx
Normal file
99
frontend/src/app/route-meta.tsx
Normal 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)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
79
frontend/src/components/app/AppCreateDialog.tsx
Normal file
79
frontend/src/components/app/AppCreateDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
93
frontend/src/components/app/AppRouteTabs.test.tsx
Normal file
93
frontend/src/components/app/AppRouteTabs.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
250
frontend/src/components/app/AppRouteTabs.tsx
Normal file
250
frontend/src/components/app/AppRouteTabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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, 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>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppSection({ title, description, action, children }: AppSectionProps) {
|
||||
return (
|
||||
<section data-slot="app-section" className="grid gap-3">
|
||||
<div className="flex min-h-8 items-start justify-between gap-3">
|
||||
|
||||
157
frontend/src/components/app/AppSidebar.test.tsx
Normal file
157
frontend/src/components/app/AppSidebar.test.tsx
Normal 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())
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
19
frontend/src/components/app/AppThemeToggle.test.tsx
Normal file
19
frontend/src/components/app/AppThemeToggle.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
48
frontend/src/components/ui/alert-dialog.tsx
Normal file
48
frontend/src/components/ui/alert-dialog.tsx
Normal 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 }
|
||||
110
frontend/src/components/ui/avatar.tsx
Normal file
110
frontend/src/components/ui/avatar.tsx
Normal 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,
|
||||
}
|
||||
125
frontend/src/components/ui/bubble.tsx
Normal file
125
frontend/src/components/ui/bubble.tsx
Normal 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 }
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
33
frontend/src/components/ui/collapsible.tsx
Normal file
33
frontend/src/components/ui/collapsible.tsx
Normal 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 }
|
||||
@@ -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}
|
||||
|
||||
104
frontend/src/components/ui/empty.tsx
Normal file
104
frontend/src/components/ui/empty.tsx
Normal 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,
|
||||
}
|
||||
70
frontend/src/components/ui/input-group.tsx
Normal file
70
frontend/src/components/ui/input-group.tsx
Normal 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 }
|
||||
@@ -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}
|
||||
|
||||
69
frontend/src/components/ui/marker.tsx
Normal file
69
frontend/src/components/ui/marker.tsx
Normal 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 }
|
||||
57
frontend/src/components/ui/message-scroller.tsx
Normal file
57
frontend/src/components/ui/message-scroller.tsx
Normal 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,
|
||||
}
|
||||
92
frontend/src/components/ui/message.tsx
Normal file
92
frontend/src/components/ui/message.tsx
Normal 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,
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
44
frontend/src/components/ui/toggle-group.tsx
Normal file
44
frontend/src/components/ui/toggle-group.tsx
Normal 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 }
|
||||
30
frontend/src/components/ui/toggle.tsx
Normal file
30
frontend/src/components/ui/toggle.tsx
Normal 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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
158
frontend/src/features/agent-workspace/AgentWorkspacePanel.tsx
Normal file
158
frontend/src/features/agent-workspace/AgentWorkspacePanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
168
frontend/src/features/agent-workspace/AgentWorkspaceProvider.tsx
Normal file
168
frontend/src/features/agent-workspace/AgentWorkspaceProvider.tsx
Normal 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])
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.<code>.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}` }} />}
|
||||
</AppPage>
|
||||
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.<code>.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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
92
frontend/src/features/profile/ProfilePage.test.tsx
Normal file
92
frontend/src/features/profile/ProfilePage.test.tsx
Normal 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'))
|
||||
})
|
||||
})
|
||||
285
frontend/src/features/profile/ProfilePage.tsx
Normal file
285
frontend/src/features/profile/ProfilePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
frontend/src/features/profile/api.ts
Normal file
7
frontend/src/features/profile/api.ts
Normal 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')
|
||||
}
|
||||
16
frontend/src/features/profile/types.ts
Normal file
16
frontend/src/features/profile/types.ts
Normal 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[]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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: '查询任务日志后确认仍在采集中。' }
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -77,6 +77,7 @@ export function SolutionLinkPanel({
|
||||
|
||||
return (
|
||||
<AppSection
|
||||
variant="card"
|
||||
title="关联解决方案"
|
||||
action={
|
||||
<Button
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
94
frontend/src/features/system/UserEditorSheet.test.tsx
Normal file
94
frontend/src/features/system/UserEditorSheet.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
261
frontend/src/features/system/UserEditorSheet.tsx
Normal file
261
frontend/src/features/system/UserEditorSheet.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
button, a, input, textarea, select { -webkit-tap-highlight-color: transparent; }
|
||||
:focus-visible { outline: none; }
|
||||
.font-mono, [data-mono] { font-variant-numeric: tabular-nums; }
|
||||
}
|
||||
|
||||
.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;
|
||||
@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);
|
||||
}
|
||||
|
||||
.concept-title,
|
||||
.concept-score,
|
||||
.concept-stat {
|
||||
font-variant-numeric: tabular-nums;
|
||||
.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);
|
||||
}
|
||||
|
||||
.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%));
|
||||
.scrollbar-panel::-webkit-scrollbar-thumb:active { background-color: hsl(var(--scrollbar-thumb-active) / 0.92); }
|
||||
.scrollbar-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.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));
|
||||
.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);
|
||||
}
|
||||
|
||||
.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;
|
||||
.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);
|
||||
}
|
||||
|
||||
.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));
|
||||
.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);
|
||||
}
|
||||
|
||||
.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); }
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user