diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9da98cf..e585d07 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 0697f87..f806a00 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 07a1b07..d106f42 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -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 - } - if (loading) { return (
diff --git a/frontend/src/app/layout/AppShell.tsx b/frontend/src/app/layout/AppShell.tsx index cc70542..73d0c6f 100644 --- a/frontend/src/app/layout/AppShell.tsx +++ b/frontend/src/app/layout/AppShell.tsx @@ -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 ( - + + - + - {children} +
+
+
+ +
+
{children}
+
+ {dockAgent && dockedOpen ? ( + + ) : null} +
+ + + Agent 演示助手显示当前页面相关的本地演示建议。 + + + +
) } + +export function AppShell({ children }: AppShellProps) { + return {children} +} diff --git a/frontend/src/app/route-meta.test.tsx b/frontend/src/app/route-meta.test.tsx new file mode 100644 index 0000000..d5c1a78 --- /dev/null +++ b/frontend/src/app/route-meta.test.tsx @@ -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' } }) + }) +}) diff --git a/frontend/src/app/route-meta.tsx b/frontend/src/app/route-meta.tsx new file mode 100644 index 0000000..482e834 --- /dev/null +++ b/frontend/src/app/route-meta.tsx @@ -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: () => }, + { key: 'profile', pattern: /^\/profile$/, title: '个人中心', description: '查看个人工作情况并维护账号设置', section: '个人中心', navPath: '/profile', breadcrumbs: [{ label: '显示与账户' }], agent: 'on-demand', render: () => }, + { key: 'ticket-create', pattern: /^\/tickets\/new$/, title: '创建工单', description: '记录问题上下文并进入支持流程', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], agent: 'docked', render: () => }, + { key: 'ticket-detail', pattern: /^\/tickets\/(\d+)$/, title: '工单详情', description: '处理状态、方案与协作记录', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], backPath: '/tickets', agent: 'docked', render: (match) => }, + { key: 'tickets', pattern: /^\/tickets(?:\?.*)?$/, title: '工单中心', description: '统一查看、筛选和推进支持工单', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单' }], agent: 'docked', render: () => }, + { key: 'knowledge-search', pattern: /^\/knowledge\/search$/, title: '知识检索', description: '跨知识条目与解决方案检索处理依据', section: '知识与方案', navPath: '/knowledge/search', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => }, + { key: 'knowledge-create', pattern: /^\/knowledge\/items\/new$/, title: '新建知识条目', description: '沉淀可复用的支持知识', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => }, + { key: 'knowledge', pattern: /^\/knowledge$/, title: '知识库', description: '集中管理业务知识、文档与检索范围', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => }, + { key: 'solution-create', pattern: /^\/solutions\/new(?:\?.*)?$/, title: '新建解决方案', description: '将验证过的处理过程沉淀为方案', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '解决方案库', path: '/solutions' }], backPath: '/solutions', agent: 'docked', render: () => }, + { key: 'solutions', pattern: /^\/solutions$/, title: '解决方案库', description: '复用已验证的处置流程和回复依据', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => }, + { key: 'model-providers', pattern: /^\/modelhub\/providers$/, title: '模型服务', description: '配置模型供应商与调用参数', section: 'AI 平台', navPath: '/modelhub/providers', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => }, + { 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) => }, + { key: 'skills', pattern: /^\/skills$/, title: 'Skill 管理', description: '组织可组合的智能处理能力', section: 'AI 平台', navPath: '/skills', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => }, + { 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) => }, + { key: 'agents', pattern: /^\/agents$/, title: 'Agent 管理', description: '管理面向支持场景的智能助手', section: 'AI 平台', navPath: '/agents', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => }, + { 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) => }, + { key: 'agent-runs', pattern: /^\/agent-runs$/, title: 'Agent 运行记录', description: '追踪智能处理任务的执行结果', section: 'AI 平台', navPath: '/agent-runs', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => }, + { key: 'system-users', pattern: /^\/system\/users$/, title: '用户管理', description: '维护平台账号与可用状态', section: '系统治理', navPath: '/system/users', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => }, + { key: 'system-groups', pattern: /^\/system\/user-groups$/, title: '用户组管理', description: '按组织和职责管理授权范围', section: '系统治理', navPath: '/system/user-groups', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => }, + { key: 'system-roles', pattern: /^\/system\/roles$/, title: '角色管理', description: '维护角色与权限集合', section: '系统治理', navPath: '/system/roles', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => }, + { key: 'system-menus', pattern: /^\/system\/menus$/, title: '菜单权限', description: '管理导航可见性和功能权限', section: '系统治理', navPath: '/system/menus', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => }, + { key: 'audit', pattern: /^\/audit$/, title: '审计日志', description: '追踪关键配置与业务操作记录', section: '系统治理', navPath: '/audit', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => }, + { key: 'design-system', pattern: /^\/design-system$/, title: '设计系统预览', description: '检查基础组件和语义状态', section: '系统治理', navPath: '/design-system', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => }, +] + +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) +} diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 57d06ac..e51b0dd 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -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 ( +
+
+
+ + +
+
+ {[0, 1, 2].map((item) => )} +
+ +
+
+ ) } 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 - } - - if (route === '/audit') { - return - } - - if (route === '/system/users') { - return - } - - if (route === '/system/user-groups') { - return - } - - if (route === '/system/roles') { - return - } - - if (route === '/system/menus') { - return - } - - if (route === '/') { - return - } - - if (route === '/modelhub/providers') { - return - } - - if (route === '/skills') { - return - } - - const skillMatch = route.match(/^\/skills\/(\d+)$/) - if (skillMatch) { - return - } - - if (route === '/agents') { - return - } - - const agentMatch = route.match(/^\/agents\/(\d+)$/) - if (agentMatch) { - return - } - - if (route === '/agent-runs') { - return - } - - const runMatch = route.match(/^\/agent-runs\/([0-9a-f-]+)$/i) - if (runMatch) { - return - } - - if (route === '/knowledge') { - return - } - - if (route === '/knowledge/search') { - return - } - - if (route === '/knowledge/items/new') { - return - } - - if (route === '/solutions') { - return - } - - if (route.startsWith('/solutions/new')) { - return - } - - if (route === '/tickets') { - return - } - - if (route === '/tickets/new') { - return - } - - const ticketMatch = route.match(/^\/tickets\/(\d+)$/) - if (ticketMatch) { - return - } - - return + return }>{meta.render(match)} } diff --git a/frontend/src/components/app/AppCreateDialog.tsx b/frontend/src/components/app/AppCreateDialog.tsx new file mode 100644 index 0000000..6e95d8a --- /dev/null +++ b/frontend/src/components/app/AppCreateDialog.tsx @@ -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 + 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 ( + + + +
+
+ {title} + {description} +
+
+
+ {children} + {error ? ( + + + 创建未完成 + {error} + + ) : null} +
+ + + + +
+
+
+ ) +} diff --git a/frontend/src/components/app/AppMetricCard.tsx b/frontend/src/components/app/AppMetricCard.tsx deleted file mode 100644 index 020cbd4..0000000 --- a/frontend/src/components/app/AppMetricCard.tsx +++ /dev/null @@ -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 ( - - - {label} - - {change} - - - -

{value}

-
-
- ) -} diff --git a/frontend/src/components/app/AppPage.tsx b/frontend/src/components/app/AppPage.tsx index 1e95d3b..474de38 100644 --- a/frontend/src/components/app/AppPage.tsx +++ b/frontend/src/components/app/AppPage.tsx @@ -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, 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 ( -
-
+
+ {header ? ( +
+
+ {header} +
+
+ ) : null} +
{children}
diff --git a/frontend/src/components/app/AppPageHeader.tsx b/frontend/src/components/app/AppPageHeader.tsx index ae05340..9cfaf8d 100644 --- a/frontend/src/components/app/AppPageHeader.tsx +++ b/frontend/src/components/app/AppPageHeader.tsx @@ -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 ( -
-
+
+ {showBreadcrumbs ? ( +
+ {route.meta.backPath ? ( + + ) : null} + + + {route.meta.breadcrumbs.map((item, index) => ( + + + {item.path ? ( + {item.label} + ) : ( + {item.label} + )} + + + + ))} + + {breadcrumbLabel ?? title} + + + +
+ ) : null} +
-

{title}

+

{title}

{meta}
{description ?

{description}

: null}
- {actions ?
{actions}
: null} + {actions ?
+ {actions} +
: null}
) diff --git a/frontend/src/components/app/AppRiskBadge.tsx b/frontend/src/components/app/AppRiskBadge.tsx deleted file mode 100644 index e5a6ebe..0000000 --- a/frontend/src/components/app/AppRiskBadge.tsx +++ /dev/null @@ -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 {config.label} -} diff --git a/frontend/src/components/app/AppRouteTabs.test.tsx b/frontend/src/components/app/AppRouteTabs.test.tsx new file mode 100644 index 0000000..74a51c6 --- /dev/null +++ b/frontend/src/components/app/AppRouteTabs.test.tsx @@ -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() + + 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() + + 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() + navigate('/knowledge') + await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2)) + unmount() + + render() + expect(screen.getByRole('tab', { name: '工单详情' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: '知识库' })).toBeInTheDocument() + }) + + it('标签溢出时隐藏原生滚动条,并提供左右切换和全部页面菜单', async () => { + const user = userEvent.setup() + const { container } = render() + 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') + }) +}) diff --git a/frontend/src/components/app/AppRouteTabs.tsx b/frontend/src/components/app/AppRouteTabs.tsx new file mode 100644 index 0000000..721d45d --- /dev/null +++ b/frontend/src/components/app/AppRouteTabs.tsx @@ -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 + 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(null) + const tabRefs = React.useRef(new Map()) + const [scrollState, setScrollState] = React.useState({ + 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 ( + + ) +} diff --git a/frontend/src/components/app/AppSection.tsx b/frontend/src/components/app/AppSection.tsx index 0490050..9db3665 100644 --- a/frontend/src/components/app/AppSection.tsx +++ b/frontend/src/components/app/AppSection.tsx @@ -1,5 +1,6 @@ import type * as React from 'react' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' type AppSectionProps = { @@ -7,9 +8,29 @@ type AppSectionProps = { description?: string action?: React.ReactNode children: React.ReactNode + footer?: React.ReactNode + variant?: 'plain' | 'card' } -export function AppSection({ title, description, action, children }: AppSectionProps) { +export function AppSection({ title, description, action, children, footer, variant = 'plain' }: AppSectionProps) { + if (variant === 'card') { + return ( +
+ + +
+ {title} + {description ? {description} : null} +
+ {action ?
{action}
: null} +
+ {children} + {footer ? {footer} : null} +
+
+ ) + } + return (
diff --git a/frontend/src/components/app/AppSidebar.test.tsx b/frontend/src/components/app/AppSidebar.test.tsx new file mode 100644 index 0000000..b2c9c1a --- /dev/null +++ b/frontend/src/components/app/AppSidebar.test.tsx @@ -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() +} + +function MobileSidebar() { + const { setOpenMobile } = useSidebar() + React.useEffect(() => setOpenMobile(true), [setOpenMobile]) + return +} + +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() + + const link = await screen.findByRole('link', { name: '知识检索' }) + await user.click(link) + await waitFor(() => expect(container.querySelector('[data-slot="sidebar"][data-mobile="true"]')).not.toBeInTheDocument()) + }) +}) diff --git a/frontend/src/components/app/AppSidebar.tsx b/frontend/src/components/app/AppSidebar.tsx index 47f4a25..59d00c0 100644 --- a/frontend/src/components/app/AppSidebar.tsx +++ b/frontend/src/components/app/AppSidebar.tsx @@ -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(activeGroupCode) + + React.useEffect(() => { + setOpenGroupCode(activeGroupCode) + }, [activeGroupCode, currentHash]) + return ( - <> - {groups.map((group) => ( - - {group.title} - - - {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}/`)) + + + 业务导航 + + + + {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 ( - - - + + + + ) - })} - - - - ))} - + } + + const selectedChildCode = activeChildCode(group, currentHash) + const groupActive = Boolean(selectedChildCode) + const groupOpen = openGroupCode === group.code + return ( + { + if (!isMobile && state === 'collapsed') { + setOpen(true) + setOpenGroupCode(group.code) + return + } + setOpenGroupCode(nextOpen ? group.code : null) + }} + > + + + + + + + + {children.map((item) => { + const itemActive = selectedChildCode === item.code + return ( + + + + + + + ) + })} + + + + + ) + })} + + + ) } 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 ( - - + + - - - - - + + + + + - - AI 工单平台 - 企业服务治理中台 + + 智服台 + AI Service Desk @@ -179,23 +266,51 @@ export function AppSidebar() { - + setOpenMobile(false)} /> - -
- -
- 服务状态 - 本地环境 + + + + + setOpenMobile(false)}> + + + {getUserInitials(userName)} + + + + + {userName} + 个人中心 · 账号与设置 + + + + + +
+ 显示与账户 +
+ + +
- ) } diff --git a/frontend/src/components/app/AppThemeToggle.test.tsx b/frontend/src/components/app/AppThemeToggle.test.tsx new file mode 100644 index 0000000..6bbde4e --- /dev/null +++ b/frontend/src/components/app/AppThemeToggle.test.tsx @@ -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() + 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() + }) +}) diff --git a/frontend/src/components/app/AppThemeToggle.tsx b/frontend/src/components/app/AppThemeToggle.tsx index 0387103..1a5e428 100644 --- a/frontend/src/components/app/AppThemeToggle.tsx +++ b/frontend/src/components/app/AppThemeToggle.tsx @@ -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 ( - - - 界面主题 - + + 外观模式 - {themeOptions.map((option) => { + {appearanceOptions.map((option) => { const Icon = option.icon - return ( - - - {option.label} - - ) + return {option.label} })} diff --git a/frontend/src/components/app/AppTopbar.tsx b/frontend/src/components/app/AppTopbar.tsx index d4e0d99..8e6f969 100644 --- a/frontend/src/components/app/AppTopbar.tsx +++ b/frontend/src/components/app/AppTopbar.tsx @@ -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 ( -
-
- - - 企业服务治理中台 - - {pageTitle} -
-
- - - - - - - - - {user?.display_name || user?.username || '当前用户'} - {user?.username || '未设置账号'} - - - - - void logout()}> - - 退出登录 - - - - -
+
+
{meta.section}
+
) } diff --git a/frontend/src/components/app/app-foundation.test.tsx b/frontend/src/components/app/app-foundation.test.tsx index cc7aecd..bec6761 100644 --- a/frontend/src/components/app/app-foundation.test.tsx +++ b/frontend/src/components/app/app-foundation.test.tsx @@ -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( - -
业务内容
-
- ) + const { container } = render(
业务内容
) 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(
业务内容
) + 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( + + }> +

业务内容

+
+
+ ) + + 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(
用户管理内容
) + + 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( + } + > +

表单内容

+
+ ) + + 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( + }> +

详情内容

+
+ ) + + expect(screen.getByRole('link', { name: 'INC-20260714-0042' })).toHaveAttribute('aria-current', 'page') + expect(screen.getByRole('heading', { name: 'VPN 连接失败' })).toBeInTheDocument() + }) }) diff --git a/frontend/src/components/app/prototype-components.test.tsx b/frontend/src/components/app/prototype-components.test.tsx deleted file mode 100644 index b87e3a6..0000000 --- a/frontend/src/components/app/prototype-components.test.tsx +++ /dev/null @@ -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() - - expect(screen.getByText('当前页面使用演示数据')).toBeInTheDocument() - }) - - it('展示指标、风险、引用和执行步骤', () => { - render( - <> - - - - - - - ) - - expect(screen.getByText('待处理工单')).toBeInTheDocument() - expect(screen.getByText('高风险')).toBeInTheDocument() - expect(screen.getByText('匹配度 93%')).toBeInTheDocument() - expect(screen.getByText('检索知识')).toBeInTheDocument() - expect(screen.getByText('等待人工采纳')).toBeInTheDocument() - }) -}) diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..bc63131 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -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) { + return +} + +function AlertDialogContent({ className, ...props }: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function AlertDialogTitle({ className, ...props }: React.ComponentProps) { + return +} + +function AlertDialogDescription({ className, ...props }: React.ComponentProps) { + return +} + +function AlertDialogAction({ className, ...props }: React.ComponentProps) { + return +} + +function AlertDialogCancel({ className, ...props }: React.ComponentProps) { + return +} + +export { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000..99f3ed2 --- /dev/null +++ b/frontend/src/components/ui/avatar.tsx @@ -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 & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + 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 ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
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, +} diff --git a/frontend/src/components/ui/bubble.tsx b/frontend/src/components/ui/bubble.tsx new file mode 100644 index 0000000..792146e --- /dev/null +++ b/frontend/src/components/ui/bubble.tsx @@ -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 ( +
+ ) +} + +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 & { + align?: "start" | "end" + }) { + return ( +
+ ) +} + +function BubbleContent({ + asChild = false, + className, + ...props +}: React.ComponentProps<"div"> & { + asChild?: boolean +}) { + const Comp = asChild ? Slot.Root : "div" + + return ( + + ) +} + +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 ( +
+ ) +} + +export { BubbleGroup, Bubble, BubbleContent, BubbleReactions } diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index e727481..e5faf73 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -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: diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx index 4436c0b..094cc80 100644 --- a/frontend/src/components/ui/card.tsx +++ b/frontend/src/components/ui/card.tsx @@ -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} diff --git a/frontend/src/components/ui/collapsible.tsx b/frontend/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..2f7a4e7 --- /dev/null +++ b/frontend/src/components/ui/collapsible.tsx @@ -0,0 +1,33 @@ +"use client" + +import { Collapsible as CollapsiblePrimitive } from "radix-ui" + +function Collapsible({ + ...props +}: React.ComponentProps) { + return +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 2482c72..a99aa04 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -61,7 +61,7 @@ function DialogContent({ ) { + return ( +
+ ) +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +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) { + return ( +
+ ) +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", + className + )} + {...props} + /> + ) +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +} diff --git a/frontend/src/components/ui/input-group.tsx b/frontend/src/components/ui/input-group.tsx new file mode 100644 index 0000000..c67b8da --- /dev/null +++ b/frontend/src/components/ui/input-group.tsx @@ -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 ( +
[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) { + return ( +
{ + 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) { + return