feat: add enterprise identity and channel integrations

This commit is contained in:
hailin
2026-07-27 19:03:42 +08:00
parent d93cb75c10
commit e69368a2f3
44 changed files with 5045 additions and 69 deletions

View File

@@ -33,6 +33,7 @@ const TicketDetailPage = React.lazy(() => import('@/features/tickets/TicketDetai
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 KnowledgeGovernancePage = React.lazy(() => import('@/features/knowledge/KnowledgeGovernancePage').then((module) => ({ default: module.KnowledgeGovernancePage })))
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 })))
@@ -42,10 +43,13 @@ const AgentListPage = React.lazy(() => import('@/features/agents/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 ToolManagementPage = React.lazy(() => import('@/features/tools/ToolManagementPage').then((module) => ({ default: module.ToolManagementPage })))
const ChannelManagementPage = React.lazy(() => import('@/features/channels/ChannelManagementPage').then((module) => ({ default: module.ChannelManagementPage })))
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 SystemIdentityPage = React.lazy(() => import('@/features/system/SystemIdentityPage').then((module) => ({ default: module.SystemIdentityPage })))
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 })))
@@ -53,11 +57,12 @@ const ProfilePage = React.lazy(() => import('@/features/profile/ProfilePage').th
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-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-governance', pattern: /^\/knowledge\/governance$/, title: '知识治理', description: '审核资产、追踪版本与查看影响范围', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => <KnowledgeGovernancePage /> },
{ 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 /> },
@@ -68,10 +73,13 @@ export const routeMeta: RouteMeta[] = [
{ 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: 'tools', pattern: /^\/tools$/, title: '工具治理', description: '统一管理 Tool、MCP、授权和调用风险', section: 'AI 平台', navPath: '/tools', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <ToolManagementPage /> },
{ key: 'channels', pattern: /^\/channels$/, title: '渠道接入', description: '管理服务群消息的幂等收发与回执', section: 'AI 平台', navPath: '/channels', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <ChannelManagementPage /> },
{ 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: 'system-identity', pattern: /^\/system\/identity$/, title: '企业身份', description: '管理单点登录、外部身份与组织同步', section: '系统治理', navPath: '/system/identity', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemIdentityPage /> },
{ 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 /> },
]

View File

@@ -22,6 +22,7 @@ type AppCreateDialogProps = {
onSubmit: React.FormEventHandler<HTMLFormElement>
children: React.ReactNode
pending?: boolean
pendingLabel?: string
submitDisabled?: boolean
error?: string
className?: string
@@ -37,6 +38,7 @@ export function AppCreateDialog({
onSubmit,
children,
pending = false,
pendingLabel = '正在创建…',
submitDisabled = false,
error,
className,
@@ -69,7 +71,7 @@ export function AppCreateDialog({
</Button>
<Button type="submit" disabled={pending || submitDisabled}>
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" /> : <Plus data-icon="inline-start" />}
{pending ? '正在创建…' : submitLabel}
{pending ? pendingLabel : submitLabel}
</Button>
</DialogFooter>
</form>

View File

@@ -12,11 +12,19 @@ function navigate(path: string) {
}
beforeEach(() => {
vi.stubGlobal('ResizeObserver', class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
})
window.sessionStorage.clear()
window.location.hash = '#/tickets/42'
})
afterEach(() => cleanup())
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('已打开页面标签', () => {
it('只显示实际打开过的页面,并允许关闭非当前标签', async () => {
@@ -64,6 +72,47 @@ describe('已打开页面标签', () => {
expect(screen.getByRole('tab', { name: '知识库' })).toBeInTheDocument()
})
it('右键标签可以关闭其左侧和右侧标签', async () => {
const user = userEvent.setup()
render(<AppRouteTabs />)
navigate('/knowledge')
navigate('/solutions')
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(3))
fireEvent.contextMenu(screen.getByRole('tab', { name: '知识库' }))
await user.click(await screen.findByRole('menuitem', { name: '关闭左侧标签' }))
expect(screen.queryByRole('tab', { name: '工单详情' })).not.toBeInTheDocument()
expect(screen.getByRole('tab', { name: '知识库' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: '解决方案库' })).toBeInTheDocument()
fireEvent.contextMenu(screen.getByRole('tab', { name: '知识库' }))
await user.click(await screen.findByRole('menuitem', { name: '关闭右侧标签' }))
expect(screen.queryByRole('tab', { name: '解决方案库' })).not.toBeInTheDocument()
expect(screen.getAllByRole('tab')).toHaveLength(1)
expect(window.location.hash).toBe('#/knowledge')
})
it('批量菜单可以关闭其他标签和关闭全部标签', async () => {
const user = userEvent.setup()
render(<AppRouteTabs />)
navigate('/knowledge')
navigate('/solutions')
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(3))
await user.click(screen.getByRole('button', { name: '查看全部已打开页面,共 3 个' }))
await user.click(screen.getByRole('menuitem', { name: '关闭其他标签' }))
expect(screen.getAllByRole('tab')).toHaveLength(1)
expect(screen.getByRole('tab', { name: '解决方案库' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '查看全部已打开页面,共 1 个' }))
await user.click(screen.getByRole('menuitem', { name: '关闭全部标签' }))
expect(screen.getAllByRole('tab')).toHaveLength(1)
expect(screen.getByRole('tab', { name: '工作台' })).toBeInTheDocument()
expect(window.location.hash).toBe('#/')
})
it('标签溢出时隐藏原生滚动条,并提供左右切换和全部页面菜单', async () => {
const user = userEvent.setup()
const { container } = render(<AppRouteTabs />)

View File

@@ -1,5 +1,5 @@
import * as React from 'react'
import { Check, ChevronLeft, ChevronRight, Ellipsis, X } from 'lucide-react'
import { Check, ChevronLeft, ChevronRight, Ellipsis, Layers2, PanelLeftClose, PanelRightClose, Trash2, X } from 'lucide-react'
import { useCurrentRoute, type RouteNavigationTab } from '@/app/route-meta'
import { Button } from '@/components/ui/button'
@@ -9,14 +9,26 @@ import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-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
const WORKBENCH_TAB: RouteNavigationTab = { label: '工作台', path: '/' }
type CloseScope = 'left' | 'right' | 'others' | 'all'
type TabScrollState = {
overflow: boolean
@@ -60,6 +72,7 @@ export function AppRouteTabs() {
canScrollLeft: false,
canScrollRight: false
})
const activeTabIndex = openedTabs.findIndex((tab) => tab.path === route)
const syncScrollState = React.useCallback(() => {
const element = scrollAreaRef.current
@@ -124,13 +137,33 @@ export function AppRouteTabs() {
const closeTab = (path: string) => {
const closedIndex = openedTabs.findIndex((tab) => tab.path === path)
const next = openedTabs.filter((tab) => tab.path !== path)
const remaining = openedTabs.filter((tab) => tab.path !== path)
const next = remaining.length ? remaining : [WORKBENCH_TAB]
saveOpenedTabs(next)
setOpenedTabs(next)
if (path !== route) return
const fallback = next[Math.max(0, closedIndex - 1)] ?? next[0]
window.location.hash = fallback?.path ?? '/'
const fallback = remaining[Math.max(0, closedIndex - 1)] ?? remaining[0] ?? WORKBENCH_TAB
window.location.hash = fallback.path
}
const closeTabs = (anchorPath: string, scope: CloseScope) => {
const anchorIndex = openedTabs.findIndex((tab) => tab.path === anchorPath)
if (anchorIndex < 0 && scope !== 'all') return
let remaining: RouteNavigationTab[]
if (scope === 'left') remaining = openedTabs.slice(anchorIndex)
else if (scope === 'right') remaining = openedTabs.slice(0, anchorIndex + 1)
else if (scope === 'others') remaining = [openedTabs[anchorIndex]]
else remaining = []
const next = remaining.length ? remaining : [WORKBENCH_TAB]
saveOpenedTabs(next)
setOpenedTabs(next)
if (next.some((tab) => tab.path === route)) return
const anchor = next.find((tab) => tab.path === anchorPath)
window.location.hash = anchor?.path ?? WORKBENCH_TAB.path
}
const scrollTabs = (direction: -1 | 1) => {
@@ -175,39 +208,53 @@ export function AppRouteTabs() {
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>
{openedTabs.map((tab, tabIndex) => (
<ContextMenu key={tab.path}>
<ContextMenuTrigger asChild>
<div
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>
</ContextMenuTrigger>
<ContextMenuContent className="w-44">
<ContextMenuGroup>
<ContextMenuItem onSelect={() => closeTab(tab.path)}><X />关闭当前标签</ContextMenuItem>
<ContextMenuItem disabled={tabIndex === 0} onSelect={() => closeTabs(tab.path, 'left')}><PanelLeftClose />关闭左侧标签</ContextMenuItem>
<ContextMenuItem disabled={tabIndex === openedTabs.length - 1} onSelect={() => closeTabs(tab.path, 'right')}><PanelRightClose />关闭右侧标签</ContextMenuItem>
<ContextMenuItem disabled={openedTabs.length <= 1} onSelect={() => closeTabs(tab.path, 'others')}><Layers2 />关闭其他标签</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem onSelect={() => closeTabs(tab.path, 'all')}><Trash2 />关闭全部标签</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
))}
</TabsList>
</Tabs>
{scrollState.overflow ? (
<>
<Button
type="button"
variant="ghost"
@@ -218,33 +265,40 @@ export function AppRouteTabs() {
>
<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}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`查看全部已打开页面,共 ${openedTabs.length} 个`} title="管理已打开页面">
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="scrollbar-panel max-h-80 w-64">
<DropdownMenuGroup>
<DropdownMenuLabel>已打开页面 · {openedTabs.length}</DropdownMenuLabel>
{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>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>批量关闭</DropdownMenuLabel>
<DropdownMenuItem disabled={activeTabIndex <= 0} onSelect={() => closeTabs(route, 'left')}><PanelLeftClose />关闭左侧标签</DropdownMenuItem>
<DropdownMenuItem disabled={activeTabIndex < 0 || activeTabIndex === openedTabs.length - 1} onSelect={() => closeTabs(route, 'right')}><PanelRightClose />关闭右侧标签</DropdownMenuItem>
<DropdownMenuItem disabled={openedTabs.length <= 1} onSelect={() => closeTabs(route, 'others')}><Layers2 />关闭其他标签</DropdownMenuItem>
<DropdownMenuItem onSelect={() => closeTabs(route, 'all')}><Trash2 />关闭全部标签</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</nav>
)
}

View File

@@ -10,6 +10,7 @@ import {
Library,
LogOut,
Menu,
MessageSquareMore,
ScrollText,
Search,
Settings,
@@ -56,6 +57,7 @@ const iconMap = {
home: Home,
library: Library,
menu: Menu,
message: MessageSquareMore,
roles: UserCog,
search: Search,
settings: Settings,

View File

@@ -0,0 +1,55 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LoginPage } from './LoginPage'
const mocks = vi.hoisted(() => ({
login: vi.fn(),
listIdentityLoginProviders: vi.fn(),
startIdentityLogin: vi.fn()
}))
vi.mock('./AuthProvider', () => ({
useAuth: () => ({ login: mocks.login })
}))
vi.mock('./api', () => ({
listIdentityLoginProviders: mocks.listIdentityLoginProviders,
startIdentityLogin: mocks.startIdentityLogin
}))
describe('LoginPage enterprise identity', () => {
afterEach(cleanup)
beforeEach(() => {
vi.clearAllMocks()
window.location.hash = ''
mocks.listIdentityLoginProviders.mockResolvedValue([
{
slug: 'corp',
name: '企业统一身份',
button_label: '使用企业账号登录',
provider_type: 'oidc'
}
])
})
it('shows enabled OIDC providers and starts enterprise login', async () => {
render(<LoginPage />)
const ssoButton = await screen.findByRole('button', { name: '使用企业账号登录' })
fireEvent.click(ssoButton)
expect(mocks.startIdentityLogin).toHaveBeenCalledWith('corp')
expect(screen.getByText('或使用本地账号')).toBeInTheDocument()
})
it('surfaces callback error without hiding local login', async () => {
window.location.hash = '#/login?auth_error=企业登录失败'
render(<LoginPage />)
await waitFor(() => expect(screen.getByText('企业登录失败')).toBeInTheDocument())
expect(screen.getByPlaceholderText('请输入账号')).toBeInTheDocument()
expect(screen.getByPlaceholderText('请输入密码')).toBeInTheDocument()
})
})

View File

@@ -1,13 +1,15 @@
import * as React from 'react'
import { AlertCircle, CheckCircle2, LockKeyhole, Sparkles, UserRound, Workflow } from 'lucide-react'
import { AlertCircle, Building2, 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, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { FieldSeparator } from '@/components/ui/field'
import { useAuth } from './AuthProvider'
import { listIdentityLoginProviders, startIdentityLogin, type IdentityLoginProvider } from './api'
export function LoginPage() {
const { login } = useAuth()
@@ -15,6 +17,14 @@ export function LoginPage() {
const [password, setPassword] = React.useState('')
const [error, setError] = React.useState('')
const [submitting, setSubmitting] = React.useState(false)
const [providers, setProviders] = React.useState<IdentityLoginProvider[]>([])
React.useEffect(() => {
void listIdentityLoginProviders().then(setProviders).catch(() => setProviders([]))
const query = window.location.hash.split('?')[1]
const authError = query ? new URLSearchParams(query).get('auth_error') : ''
if (authError) setError(authError)
}, [])
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
@@ -43,6 +53,24 @@ export function LoginPage() {
<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}
{providers.length ? (
<div className="flex flex-col gap-3">
{providers.map((provider) => (
<Button
key={provider.slug}
type="button"
size="lg"
variant="outline"
className="w-full justify-start"
onClick={() => startIdentityLogin(provider.slug)}
>
<Building2 data-icon="inline-start" />
{provider.button_label}
</Button>
))}
<FieldSeparator>或使用本地账号</FieldSeparator>
</div>
) : 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>

View File

@@ -1,7 +1,14 @@
import { apiRequest } from '@/lib/api'
import { apiRequest, apiUrl } from '@/lib/api'
import type { CurrentUser } from './types'
export type IdentityLoginProvider = {
slug: string
name: string
button_label: string
provider_type: 'oidc'
}
export async function getCurrentUser() {
return apiRequest<CurrentUser>('/accounts/auth/me')
}
@@ -32,3 +39,12 @@ export async function logout() {
method: 'POST'
})
}
export function listIdentityLoginProviders() {
return apiRequest<IdentityLoginProvider[]>('/identity/auth/providers')
}
export function startIdentityLogin(slug: string, returnTo = '/') {
const query = new URLSearchParams({ return_to: returnTo })
window.location.assign(apiUrl(`/identity/auth/oidc/${encodeURIComponent(slug)}/start?${query}`))
}

View File

@@ -0,0 +1,109 @@
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 { ChannelManagementPage } from './ChannelManagementPage'
import {
listChannelEndpoints,
listChannelMessages,
retryChannelMessage
} from './api'
vi.mock('@/features/auth/AuthProvider', () => ({
useAuth: () => ({
user: {
is_superuser: true,
permissions: ['channel.view', 'channel.send', 'channel.manage']
}
})
}))
vi.mock('./api', () => ({
listChannelEndpoints: vi.fn(),
listChannelMessages: vi.fn(),
createChannelEndpoint: vi.fn(),
updateChannelEndpoint: vi.fn(),
sendChannelMessage: vi.fn(),
retryChannelMessage: vi.fn()
}))
const endpoint = {
id: 1,
public_id: 'endpoint-public-id',
name: '一线服务群',
adapter_type: 'generic_webhook' as const,
outbound_url: 'https://channel.example/messages',
webhook_secret_set: true,
outbound_token_set: true,
enabled: true,
created_at: '2026-07-27T08:00:00Z',
updated_at: '2026-07-27T08:00:00Z'
}
const failedMessage = {
public_id: 'message-public-id',
endpoint_id: 1,
endpoint_name: '一线服务群',
conversation_public_id: 'conversation-public-id',
external_conversation_id: 'group-001',
ticket_id: 42,
customer_reply_task_id: 15,
reply_revision: 2,
direction: 'outbound' as const,
external_message_id: '',
idempotency_key: 'reply-001',
sender: '支持工程师',
message_type: 'text',
text: '正在处理中,请稍候。',
payload: {},
status: 'failed' as const,
error_message: '上游连接超时',
occurred_at: null,
sent_at: null,
delivered_at: null,
created_at: '2026-07-27T08:10:00Z',
updated_at: '2026-07-27T08:10:00Z'
}
describe('ChannelManagementPage', () => {
afterEach(cleanup)
beforeEach(() => {
vi.mocked(listChannelEndpoints).mockResolvedValue([endpoint])
vi.mocked(listChannelMessages).mockResolvedValue([failedMessage])
vi.mocked(retryChannelMessage).mockResolvedValue({
...failedMessage,
status: 'queued',
error_message: ''
})
})
it('展示渠道消息并允许失败出站重试', async () => {
const user = userEvent.setup()
render(<ChannelManagementPage />)
expect(await screen.findByText('正在处理中,请稍候。')).toBeInTheDocument()
expect(screen.getAllByText('一线服务群').length).toBeGreaterThan(0)
expect(screen.getByText('上游连接超时')).toBeInTheDocument()
expect(screen.getByText(/回复任务 #15 v2/)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '发送消息' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() => {
expect(retryChannelMessage).toHaveBeenCalledWith('message-public-id')
})
})
it('展示适配器凭证状态和 Webhook 地址', async () => {
const user = userEvent.setup()
render(<ChannelManagementPage />)
await screen.findByText('正在处理中,请稍候。')
await user.click(screen.getByRole('tab', { name: '适配器' }))
expect(screen.getByText('签名密钥:已配置')).toBeInTheDocument()
expect(screen.getByText('出站凭证:已配置')).toBeInTheDocument()
expect(screen.getByText(/endpoint-public-id/)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,414 @@
import * as React from 'react'
import {
ArrowDownToLine,
ArrowUpFromLine,
CircleAlert,
Copy,
KeyRound,
Plus,
RefreshCw,
RotateCcw,
Webhook
} 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useAuth } from '@/features/auth/AuthProvider'
import { formatDateTime } from '@/lib/format'
import {
createChannelEndpoint,
listChannelEndpoints,
listChannelMessages,
retryChannelMessage,
updateChannelEndpoint
} from './api'
import type { ChannelEndpoint, ChannelMessage, ChannelMessageStatus } from './types'
const endpointInitial = {
name: '',
outbound_url: '',
webhook_secret: '',
outbound_token: ''
}
const STATUS_META: Record<ChannelMessageStatus, {
label: string
variant: 'secondary' | 'info' | 'success' | 'danger' | 'warning'
}> = {
received: { label: '已接收', variant: 'info' },
queued: { label: '待投递', variant: 'warning' },
sent: { label: '已发送', variant: 'success' },
delivered: { label: '已送达', variant: 'success' },
failed: { label: '失败', variant: 'danger' }
}
export function ChannelManagementPage() {
const { user } = useAuth()
const [endpoints, setEndpoints] = React.useState<ChannelEndpoint[]>([])
const [messages, setMessages] = React.useState<ChannelMessage[]>([])
const [loading, setLoading] = React.useState(true)
const [error, setError] = React.useState('')
const [busy, setBusy] = React.useState('')
const [endpointFilter, setEndpointFilter] = React.useState('all')
const [directionFilter, setDirectionFilter] = React.useState('all')
const [createOpen, setCreateOpen] = React.useState(false)
const [endpointForm, setEndpointForm] = React.useState(endpointInitial)
const hasPermission = React.useCallback((permission: string) => Boolean(
user?.is_superuser || user?.permissions.includes(permission)
), [user])
const canManage = hasPermission('channel.manage')
const canSend = hasPermission('channel.send') && hasPermission('ticket.reply')
const load = React.useCallback(async () => {
setLoading(true)
try {
const endpointId = endpointFilter === 'all' ? undefined : Number(endpointFilter)
const direction = directionFilter === 'all' ? undefined : directionFilter
const [nextEndpoints, nextMessages] = await Promise.all([
listChannelEndpoints(),
listChannelMessages({ endpoint_id: endpointId, direction })
])
setEndpoints(nextEndpoints)
setMessages(nextMessages)
setError('')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '渠道数据加载失败')
} finally {
setLoading(false)
}
}, [directionFilter, endpointFilter])
React.useEffect(() => {
void load()
}, [load])
async function runAction(key: string, action: () => Promise<unknown>, success: string) {
setBusy(key)
try {
await action()
toast.success(success)
await load()
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : '操作未完成')
} finally {
setBusy('')
}
}
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setBusy('create')
try {
await createChannelEndpoint({ ...endpointForm, enabled: true })
setEndpointForm(endpointInitial)
setCreateOpen(false)
toast.success('渠道适配器已创建')
await load()
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : '渠道创建失败')
} finally {
setBusy('')
}
}
const messageColumns: AppDataTableColumn<ChannelMessage>[] = [
{
key: 'direction',
header: '方向',
cell: (message) => (
<Badge variant={message.direction === 'inbound' ? 'info' : 'ai'}>
{message.direction === 'inbound' ? <ArrowDownToLine /> : <ArrowUpFromLine />}
{message.direction === 'inbound' ? '入站' : '出站'}
</Badge>
)
},
{
key: 'channel',
header: '渠道 / 会话',
cell: (message) => (
<div>
<p className="font-medium">{message.endpoint_name}</p>
<p className="max-w-48 truncate text-xs text-muted-foreground">
{message.external_conversation_id}
</p>
</div>
)
},
{
key: 'message',
header: '消息',
cell: (message) => (
<div className="max-w-xl">
<p className="line-clamp-2 text-sm">{message.text}</p>
<p className="mt-1 text-xs text-muted-foreground">
{message.sender || '平台'}{message.ticket_id ? ` · 工单 #${message.ticket_id}` : ''}
{message.customer_reply_task_id
? ` · 回复任务 #${message.customer_reply_task_id} v${message.reply_revision}`
: ''}
</p>
</div>
)
},
{
key: 'status',
header: '状态',
cell: (message) => (
<div>
<Badge variant={STATUS_META[message.status].variant}>{STATUS_META[message.status].label}</Badge>
{message.error_message ? (
<p className="mt-1 max-w-44 truncate text-xs text-danger" title={message.error_message}>
{message.error_message}
</p>
) : null}
</div>
)
},
{
key: 'time',
header: '时间',
cell: (message) => (
<span className="whitespace-nowrap text-xs text-muted-foreground">
{formatDateTime(message.occurred_at || message.created_at)}
</span>
)
},
{
key: 'action',
header: '',
cell: (message) => message.direction === 'outbound' && message.status === 'failed' && canSend ? (
<Button
size="sm"
variant="outline"
disabled={busy === `retry-${message.public_id}`}
onClick={() => void runAction(
`retry-${message.public_id}`,
() => retryChannelMessage(message.public_id),
'消息已重新进入投递队列'
)}
>
<RotateCcw data-icon="inline-start" />重试
</Button>
) : null
}
]
if (!hasPermission('channel.view')) {
return (
<AppPage header={<AppPageHeader title="渠道接入" description="管理服务群消息的幂等收发和回执。" />}>
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>没有查看权限</AlertTitle>
<AlertDescription>请联系管理员授予“查看渠道消息”权限。</AlertDescription>
</Alert>
</AppPage>
)
}
const inboundCount = messages.filter((message) => message.direction === 'inbound').length
const outboundCount = messages.filter((message) => message.direction === 'outbound').length
const failedCount = messages.filter((message) => message.status === 'failed').length
const metricCards: Array<{ label: string; value: number; icon: typeof KeyRound }> = [
{ label: '已启用渠道', value: endpoints.filter((endpoint) => endpoint.enabled).length, icon: KeyRound },
{ label: '入站消息', value: inboundCount, icon: ArrowDownToLine },
{ label: '出站消息', value: outboundCount, icon: ArrowUpFromLine },
{ label: '失败待处理', value: failedCount, icon: CircleAlert }
]
return (
<AppPage
maxWidth="7xl"
contentClassName="gap-5"
header={(
<AppPageHeader
title="渠道接入"
description="用统一 Webhook 契约接入服务群,保证入站去重、出站幂等和回执可追溯。"
meta={<Badge variant="ai"><Webhook />Generic Webhook</Badge>}
actions={(
<>
<Button variant="outline" onClick={() => void load()} disabled={loading}>
<RefreshCw data-icon="inline-start" />刷新
</Button>
{canManage ? (
<Button onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />接入渠道
</Button>
) : null}
</>
)}
/>
)}
>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{metricCards.map(({ label, value, icon: Icon }) => (
<Card key={label} size="sm">
<CardHeader>
<CardDescription>{label}</CardDescription>
<div className="flex items-center justify-between">
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
<Icon className="size-5 text-muted-foreground" />
</div>
</CardHeader>
</Card>
))}
</div>
{error ? (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>渠道数据加载失败</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{loading ? (
<div className="grid gap-3"><Skeleton className="h-10 w-72" /><Skeleton className="h-96" /></div>
) : (
<Tabs defaultValue="messages">
<TabsList variant="line">
<TabsTrigger value="messages">消息时间线</TabsTrigger>
<TabsTrigger value="endpoints">适配器</TabsTrigger>
</TabsList>
<TabsContent value="messages" className="mt-4">
<AppSection title="最近收发" description="最多展示最近 100 条消息;失败出站可沿用原幂等键重试。">
<AppDataTable
columns={messageColumns}
rows={messages}
getRowKey={(message) => message.public_id}
emptyTitle="暂无渠道消息"
emptyDescription="签名 Webhook 收到消息后会显示在这里。"
toolbar={(
<div className="flex flex-wrap gap-2">
<Select value={endpointFilter} onValueChange={setEndpointFilter}>
<SelectTrigger className="w-52"><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup>
<SelectItem value="all">全部渠道</SelectItem>
{endpoints.map((endpoint) => (
<SelectItem key={endpoint.id} value={String(endpoint.id)}>{endpoint.name}</SelectItem>
))}
</SelectGroup></SelectContent>
</Select>
<Select value={directionFilter} onValueChange={setDirectionFilter}>
<SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup>
<SelectItem value="all">全部方向</SelectItem>
<SelectItem value="inbound">仅入站</SelectItem>
<SelectItem value="outbound">仅出站</SelectItem>
</SelectGroup></SelectContent>
</Select>
</div>
)}
/>
</AppSection>
</TabsContent>
<TabsContent value="endpoints" className="mt-4">
<div className="grid gap-4 lg:grid-cols-2">
{endpoints.length === 0 ? (
<div className="lg:col-span-2">
<AppEmptyState title="还没有渠道适配器" description="创建适配器后,将签名 Webhook 地址配置到服务群机器人。" />
</div>
) : endpoints.map((endpoint) => {
const webhookUrl = `${window.location.origin}/api/channels/webhooks/${endpoint.public_id}/messages`
return (
<Card key={endpoint.id}>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{endpoint.name}</CardTitle>
<CardDescription>{endpoint.adapter_type}</CardDescription>
</div>
<Badge variant={endpoint.enabled ? 'success' : 'secondary'}>
{endpoint.enabled ? '已启用' : '已停用'}
</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="rounded-lg bg-muted/45 p-3">
<p className="text-xs text-muted-foreground">入站 Webhook</p>
<div className="mt-1 flex items-center gap-2">
<code className="min-w-0 flex-1 truncate text-xs">{webhookUrl}</code>
<Button
size="icon"
variant="ghost"
aria-label={`复制 ${endpoint.name} Webhook`}
onClick={() => void navigator.clipboard.writeText(webhookUrl).then(() => toast.success('Webhook 已复制'))}
>
<Copy />
</Button>
</div>
</div>
<div className="grid gap-2 text-sm sm:grid-cols-2">
<span>签名密钥:{endpoint.webhook_secret_set ? '已配置' : '未配置'}</span>
<span>出站凭证:{endpoint.outbound_token_set ? '已配置' : '未配置'}</span>
</div>
<p className="truncate text-xs text-muted-foreground">
出站地址:{endpoint.outbound_url || '未配置'}
</p>
{canManage ? (
<div className="flex items-center justify-between border-t pt-3">
<span className="text-sm">接收与发送</span>
<Switch
checked={endpoint.enabled}
disabled={busy === `endpoint-${endpoint.id}`}
onCheckedChange={(enabled) => void runAction(
`endpoint-${endpoint.id}`,
() => updateChannelEndpoint(endpoint.id, { enabled }),
enabled ? '渠道已启用' : '渠道已停用'
)}
/>
</div>
) : null}
</CardContent>
</Card>
)
})}
</div>
</TabsContent>
</Tabs>
)}
<AppCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
title="接入 Generic Webhook"
description="入站请求使用 HMAC-SHA256 签名;访问凭证保存后不再回显。"
submitLabel="创建适配器"
pending={busy === 'create'}
onSubmit={handleCreate}
>
<FieldGroup>
<AppField label="渠道名称" htmlFor="channel-name" required>
<Input id="channel-name" required autoFocus value={endpointForm.name} onChange={(event) => setEndpointForm({ ...endpointForm, name: event.target.value })} placeholder="例如:一线服务群" />
</AppField>
<AppField label="出站地址" htmlFor="channel-url" required>
<Input id="channel-url" required type="url" value={endpointForm.outbound_url} onChange={(event) => setEndpointForm({ ...endpointForm, outbound_url: event.target.value })} placeholder="https://channel.example/messages" />
</AppField>
<AppField label="Webhook 签名密钥" htmlFor="channel-secret" required>
<Input id="channel-secret" required type="password" autoComplete="new-password" value={endpointForm.webhook_secret} onChange={(event) => setEndpointForm({ ...endpointForm, webhook_secret: event.target.value })} />
</AppField>
<AppField label="出站 Bearer Token" htmlFor="channel-token">
<Input id="channel-token" type="password" autoComplete="new-password" value={endpointForm.outbound_token} onChange={(event) => setEndpointForm({ ...endpointForm, outbound_token: event.target.value })} />
</AppField>
</FieldGroup>
</AppCreateDialog>
</AppPage>
)
}

View File

@@ -0,0 +1,115 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CustomerReplyTask } from '@/features/tickets/types'
import { ChannelReplyDialog } from './ChannelReplyDialog'
import { listChannelEndpoints, sendChannelMessage } from './api'
import type { ChannelEndpoint, ChannelMessage } from './types'
vi.mock('./api', () => ({
listChannelEndpoints: vi.fn(),
sendChannelMessage: vi.fn()
}))
const endpoint: ChannelEndpoint = {
id: 8,
public_id: 'endpoint-8',
name: '客户服务群',
adapter_type: 'generic_webhook',
outbound_url: 'https://channel.example/messages',
webhook_secret_set: true,
outbound_token_set: true,
enabled: true,
created_at: '2026-07-27T08:00:00Z',
updated_at: '2026-07-27T08:00:00Z'
}
const task: CustomerReplyTask = {
id: 15,
ticket_id: 7,
support_analysis_id: 9,
status: 'draft',
draft_text: '我们正在检查订单服务连接池状态。',
final_text: '连接池已恢复,请重新尝试。',
revision: 3,
created_by_id: 3,
updated_by_id: 3,
approved_by_id: null,
approved_at: null,
sent_by_id: null,
sent_at: null,
created_at: '2026-07-27T08:00:05Z',
updated_at: '2026-07-27T08:03:00Z',
revisions: []
}
const queuedMessage = {
public_id: 'message-15-v3',
endpoint_id: endpoint.id,
endpoint_name: endpoint.name,
conversation_public_id: 'conversation-1',
external_conversation_id: 'group-001',
ticket_id: task.ticket_id,
customer_reply_task_id: task.id,
reply_revision: task.revision,
direction: 'outbound',
external_message_id: '',
idempotency_key: 'customer-reply-15-v3-test',
sender: '支持工程师',
message_type: 'text',
text: task.final_text,
payload: {},
status: 'queued',
error_message: '',
occurred_at: null,
sent_at: null,
delivered_at: null,
created_at: '2026-07-27T08:04:00Z',
updated_at: '2026-07-27T08:04:00Z'
} satisfies ChannelMessage
describe('ChannelReplyDialog', () => {
afterEach(cleanup)
beforeEach(() => {
vi.mocked(listChannelEndpoints).mockResolvedValue([endpoint])
vi.mocked(sendChannelMessage).mockResolvedValue(queuedMessage)
})
it('只提交冻结回复任务与当前版本,不接受任意文本', async () => {
const onQueued = vi.fn().mockResolvedValue(undefined)
const onOpenChange = vi.fn()
render(
<ChannelReplyDialog
task={task}
open
onOpenChange={onOpenChange}
onQueued={onQueued}
/>
)
expect((await screen.findAllByText('客户服务群')).length).toBeGreaterThan(0)
fireEvent.change(screen.getByLabelText('外部会话 ID'), {
target: { value: 'group-001' }
})
const submit = screen.getByRole('button', { name: '冻结版本并发送' })
await waitFor(() => expect(submit).toBeEnabled())
fireEvent.click(submit)
await waitFor(() => {
expect(sendChannelMessage).toHaveBeenCalledWith(
endpoint.id,
expect.objectContaining({
external_conversation_id: 'group-001',
customer_reply_task_id: task.id,
expected_revision: task.revision
})
)
})
const payload = vi.mocked(sendChannelMessage).mock.calls[0][1]
expect(payload).not.toHaveProperty('text')
expect(onQueued).toHaveBeenCalledWith(queuedMessage)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})

View File

@@ -0,0 +1,198 @@
import * as React from 'react'
import { CircleAlert, Send } from 'lucide-react'
import { toast } from 'sonner'
import { AppField } from '@/components/app/AppField'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import type { CustomerReplyTask } from '@/features/tickets/types'
import { listChannelEndpoints, sendChannelMessage } from './api'
import type { ChannelEndpoint, ChannelMessage } from './types'
function stableHash(value: string) {
let hash = 2166136261
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
return (hash >>> 0).toString(16).padStart(8, '0')
}
export function ChannelReplyDialog({
task,
open,
onOpenChange,
onQueued
}: {
task: CustomerReplyTask | null
open: boolean
onOpenChange: (open: boolean) => void
onQueued: (message: ChannelMessage) => Promise<void> | void
}) {
const [endpoints, setEndpoints] = React.useState<ChannelEndpoint[]>([])
const [endpointId, setEndpointId] = React.useState('')
const [conversationId, setConversationId] = React.useState('')
const [loading, setLoading] = React.useState(false)
const [sending, setSending] = React.useState(false)
const [error, setError] = React.useState('')
const selectedEndpointId = endpointId || String(endpoints[0]?.id ?? '')
React.useEffect(() => {
if (!open) return
setLoading(true)
setError('')
listChannelEndpoints()
.then((items) => {
const enabled = items.filter((item) => item.enabled)
setEndpoints(enabled)
setEndpointId((current) => current || String(enabled[0]?.id ?? ''))
})
.catch((reason: unknown) => {
setEndpoints([])
setError(reason instanceof Error ? reason.message : '无法加载可用渠道')
})
.finally(() => setLoading(false))
}, [open])
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!task) return
const normalizedConversation = conversationId.trim()
const numericEndpointId = Number(selectedEndpointId)
if (!numericEndpointId || !normalizedConversation) return
setSending(true)
setError('')
try {
const message = await sendChannelMessage(numericEndpointId, {
external_conversation_id: normalizedConversation,
customer_reply_task_id: task.id,
expected_revision: task.revision,
idempotency_key: [
'customer-reply',
task.id,
`v${task.revision}`,
stableHash(`${numericEndpointId}:${normalizedConversation}`)
].join('-')
})
await onQueued(message)
toast.success('客户回复已进入渠道投递队列')
setConversationId('')
onOpenChange(false)
} catch (reason) {
setError(reason instanceof Error ? reason.message : '客户回复未能进入渠道队列')
} finally {
setSending(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>通过渠道发送客户回复</DialogTitle>
<DialogDescription>
发送后将冻结当前回复版本;只有渠道送达回执会自动确认客户已收到。
</DialogDescription>
</DialogHeader>
{task ? (
<form id="channel-reply-form" onSubmit={handleSubmit}>
<FieldGroup>
<Alert>
<Send />
<AlertTitle>即将发送回复任务 #{task.id}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<Badge variant="outline" className="w-fit">冻结版本 v{task.revision}</Badge>
<span className="line-clamp-4 whitespace-pre-wrap">{task.final_text}</span>
</AlertDescription>
</Alert>
<AppField label="发送渠道" required>
<Select
value={selectedEndpointId}
disabled={loading || sending}
onValueChange={setEndpointId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={loading ? '正在加载渠道' : '选择已启用渠道'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{endpoints.map((endpoint) => (
<SelectItem key={endpoint.id} value={String(endpoint.id)}>
{endpoint.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</AppField>
<AppField
label="外部会话 ID"
htmlFor="channel-reply-conversation"
required
description="会话首次关联后不能改绑到其他工单。"
>
<Input
id="channel-reply-conversation"
aria-label="外部会话 ID"
required
autoFocus
disabled={sending}
value={conversationId}
onChange={(event) => setConversationId(event.target.value)}
placeholder="例如:group-20260727-001"
/>
</AppField>
{!loading && endpoints.length === 0 ? (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>没有可用渠道</AlertTitle>
<AlertDescription>请先在“渠道接入”中创建并启用适配器。</AlertDescription>
</Alert>
) : null}
{error ? (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>渠道发送未完成</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
</FieldGroup>
</form>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>
取消
</Button>
<Button
type="submit"
form="channel-reply-form"
disabled={!task || !selectedEndpointId || !conversationId.trim() || sending}
>
<Send data-icon="inline-start" />
{sending ? '正在排队' : '冻结版本并发送'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,68 @@
import { apiRequest } from '@/lib/api'
import type { ChannelEndpoint, ChannelMessage } from './types'
export function listChannelEndpoints() {
return apiRequest<ChannelEndpoint[]>('/channels/endpoints')
}
export function createChannelEndpoint(payload: {
name: string
outbound_url: string
webhook_secret: string
outbound_token: string
enabled: boolean
}) {
return apiRequest<ChannelEndpoint>('/channels/endpoints', {
method: 'POST',
body: JSON.stringify({ ...payload, adapter_type: 'generic_webhook' })
})
}
export function updateChannelEndpoint(
endpointId: number,
payload: Partial<Pick<ChannelEndpoint, 'name' | 'outbound_url' | 'enabled'>> & {
webhook_secret?: string
outbound_token?: string
}
) {
return apiRequest<ChannelEndpoint>(`/channels/endpoints/${endpointId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
}
export function listChannelMessages(filters?: {
endpoint_id?: number
direction?: string
status?: string
}) {
const search = new URLSearchParams()
if (filters?.endpoint_id) search.set('endpoint_id', String(filters.endpoint_id))
if (filters?.direction) search.set('direction', filters.direction)
if (filters?.status) search.set('status', filters.status)
const suffix = search.size ? `?${search.toString()}` : ''
return apiRequest<ChannelMessage[]>(`/channels/messages${suffix}`)
}
export function sendChannelMessage(
endpointId: number,
payload: {
external_conversation_id: string
idempotency_key: string
customer_reply_task_id: number
expected_revision: number
payload?: Record<string, unknown>
}
) {
return apiRequest<ChannelMessage>(`/channels/endpoints/${endpointId}/messages`, {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function retryChannelMessage(publicId: string) {
return apiRequest<ChannelMessage>(`/channels/messages/${publicId}/retry`, {
method: 'POST'
})
}

View File

@@ -0,0 +1,40 @@
export type ChannelEndpoint = {
id: number
public_id: string
name: string
adapter_type: 'generic_webhook'
outbound_url: string
webhook_secret_set: boolean
outbound_token_set: boolean
enabled: boolean
created_at: string
updated_at: string
}
export type ChannelMessageDirection = 'inbound' | 'outbound'
export type ChannelMessageStatus = 'received' | 'queued' | 'sent' | 'delivered' | 'failed'
export type ChannelMessage = {
public_id: string
endpoint_id: number
endpoint_name: string
conversation_public_id: string | null
external_conversation_id: string
ticket_id: number | null
customer_reply_task_id: number | null
reply_revision: number | null
direction: ChannelMessageDirection
external_message_id: string
idempotency_key: string
sender: string
message_type: string
text: string
payload: Record<string, unknown>
status: ChannelMessageStatus
error_message: string
occurred_at: string | null
sent_at: string | null
delivered_at: string | null
created_at: string
updated_at: string
}

View File

@@ -0,0 +1,396 @@
import * as React from 'react'
import { CircleAlert, LoaderCircle, Save } 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 { Field, 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 { createIdentityProvider, updateIdentityProvider } from './api'
import type { IdentityProvider, IdentityProviderInput } from './types'
type ProviderForm = IdentityProviderInput & {
slug: string
provider_type: 'oidc' | 'ldap'
server_uri: string
base_dn: string
bind_dn: string
user_filter: string
}
const emptyForm: ProviderForm = {
slug: '',
name: '',
provider_type: 'oidc',
enabled: false,
button_label: '',
issuer: '',
metadata_url: '',
client_id: '',
credential: '',
scopes: ['openid', 'profile', 'email'],
claim_mappings: {
subject: 'sub',
username: 'preferred_username',
email: 'email',
display_name: 'name',
groups: 'groups'
},
configuration: {},
auto_create_users: true,
sync_profile: true,
sync_groups: true,
server_uri: '',
base_dn: '',
bind_dn: '',
user_filter: ''
}
type IdentityProviderDialogProps = {
open: boolean
provider: IdentityProvider | null
onOpenChange: (open: boolean) => void
onSaved: () => Promise<void>
}
export function IdentityProviderDialog({
open,
provider,
onOpenChange,
onSaved
}: IdentityProviderDialogProps) {
const [form, setForm] = React.useState<ProviderForm>(emptyForm)
const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState('')
React.useEffect(() => {
if (!open) return
if (!provider) {
setForm(emptyForm)
setError('')
return
}
setForm({
...emptyForm,
slug: provider.slug,
name: provider.name,
provider_type: provider.provider_type,
enabled: provider.enabled,
button_label: provider.button_label,
issuer: provider.issuer,
metadata_url: provider.metadata_url,
client_id: provider.client_id,
scopes: provider.scopes,
claim_mappings: {
...emptyForm.claim_mappings,
...provider.claim_mappings
},
configuration: provider.configuration,
auto_create_users: provider.auto_create_users,
sync_profile: provider.sync_profile,
sync_groups: provider.sync_groups,
server_uri: String(provider.configuration.server_uri ?? ''),
base_dn: String(provider.configuration.base_dn ?? ''),
bind_dn: String(provider.configuration.bind_dn ?? ''),
user_filter: String(provider.configuration.user_filter ?? '')
})
setError('')
}, [open, provider])
function setClaim(key: string, value: string) {
setForm((current) => ({
...current,
claim_mappings: { ...current.claim_mappings, [key]: value }
}))
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setPending(true)
setError('')
const configuration = form.provider_type === 'ldap'
? {
...form.configuration,
server_uri: form.server_uri.trim(),
base_dn: form.base_dn.trim(),
bind_dn: form.bind_dn.trim(),
user_filter: form.user_filter.trim()
}
: form.configuration
const payload: IdentityProviderInput = {
name: form.name.trim(),
enabled: form.enabled,
button_label: form.button_label.trim(),
issuer: form.issuer.trim(),
metadata_url: form.metadata_url.trim(),
client_id: form.client_id.trim(),
credential: form.credential?.trim() || undefined,
scopes: form.scopes,
claim_mappings: form.claim_mappings,
configuration,
auto_create_users: form.auto_create_users,
sync_profile: form.sync_profile,
sync_groups: form.sync_groups
}
try {
if (provider) {
await updateIdentityProvider(provider.id, payload)
} else {
await createIdentityProvider({
...payload,
slug: form.slug.trim(),
provider_type: form.provider_type
})
}
onOpenChange(false)
await onSaved()
} catch (reason) {
setError(reason instanceof Error ? reason.message : '身份提供方保存失败')
} finally {
setPending(false)
}
}
const oidc = form.provider_type === 'oidc'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 sm:max-w-2xl">
<DialogHeader className="border-b bg-muted/35 px-6 py-5 pr-12">
<DialogTitle>{provider ? '编辑身份提供方' : '新增身份提供方'}</DialogTitle>
<DialogDescription>
密钥只在保存时提交,页面与审计日志不会返回明文。
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="scrollbar-panel max-h-[68vh] overflow-y-auto px-6 py-5">
<FieldGroup>
<div className="grid gap-5 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="identity-name">名称</FieldLabel>
<Input
id="identity-name"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
placeholder="例如:公司统一身份"
required
/>
</Field>
<Field data-disabled={Boolean(provider)}>
<FieldLabel htmlFor="identity-slug">稳定标识</FieldLabel>
<Input
id="identity-slug"
value={form.slug}
onChange={(event) => setForm({ ...form, slug: event.target.value })}
placeholder="corp-identity"
disabled={Boolean(provider)}
required
/>
</Field>
</div>
<Field data-disabled={Boolean(provider)}>
<FieldLabel>协议类型</FieldLabel>
<Select
value={form.provider_type}
onValueChange={(value: 'oidc' | 'ldap') => setForm({ ...form, provider_type: value })}
disabled={Boolean(provider)}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="oidc">OpenID Connect</SelectItem>
<SelectItem value="ldap">LDAP 目录适配器</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>
LDAP 首批提供配置与目录同步适配边界;浏览器单点登录仅由 OIDC 提供。
</FieldDescription>
</Field>
{oidc ? (
<>
<div className="grid gap-5 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="identity-issuer">Issuer</FieldLabel>
<Input
id="identity-issuer"
type="url"
value={form.issuer}
onChange={(event) => setForm({ ...form, issuer: event.target.value })}
placeholder="https://idp.example.com"
/>
</Field>
<Field>
<FieldLabel htmlFor="identity-metadata">发现文档地址</FieldLabel>
<Input
id="identity-metadata"
type="url"
value={form.metadata_url}
onChange={(event) => setForm({ ...form, metadata_url: event.target.value })}
placeholder="留空时由 Issuer 推导"
/>
</Field>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="identity-client-id">Client ID</FieldLabel>
<Input
id="identity-client-id"
value={form.client_id}
onChange={(event) => setForm({ ...form, client_id: event.target.value })}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="identity-credential">
Client secret {provider?.credential_set ? `(${provider.credential_masked})` : ''}
</FieldLabel>
<Input
id="identity-credential"
type="password"
autoComplete="new-password"
value={form.credential}
onChange={(event) => setForm({ ...form, credential: event.target.value })}
placeholder={provider?.credential_set ? '留空保留原密钥' : '输入密钥'}
required={!provider?.credential_set && form.enabled}
/>
</Field>
</div>
<Field>
<FieldLabel htmlFor="identity-button-label">登录按钮文案</FieldLabel>
<Input
id="identity-button-label"
value={form.button_label}
onChange={(event) => setForm({ ...form, button_label: event.target.value })}
placeholder={`使用 ${form.name || '企业账号'} 登录`}
/>
</Field>
<div className="grid gap-5 sm:grid-cols-2">
{[
['subject', 'Subject 声明'],
['username', '账号声明'],
['email', '邮箱声明'],
['display_name', '显示名称声明'],
['groups', '组织声明']
].map(([key, label]) => (
<Field key={key}>
<FieldLabel htmlFor={`identity-claim-${key}`}>{label}</FieldLabel>
<Input
id={`identity-claim-${key}`}
value={form.claim_mappings[key] ?? ''}
onChange={(event) => setClaim(key, event.target.value)}
required
/>
</Field>
))}
</div>
</>
) : (
<>
<Field>
<FieldLabel htmlFor="identity-server-uri">LDAP 服务地址</FieldLabel>
<Input
id="identity-server-uri"
value={form.server_uri}
onChange={(event) => setForm({ ...form, server_uri: event.target.value })}
placeholder="ldaps://directory.example.com"
required={form.enabled}
/>
</Field>
<div className="grid gap-5 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="identity-base-dn">Base DN</FieldLabel>
<Input id="identity-base-dn" value={form.base_dn} onChange={(event) => setForm({ ...form, base_dn: event.target.value })} />
</Field>
<Field>
<FieldLabel htmlFor="identity-bind-dn">Bind DN</FieldLabel>
<Input id="identity-bind-dn" value={form.bind_dn} onChange={(event) => setForm({ ...form, bind_dn: event.target.value })} />
</Field>
</div>
<Field>
<FieldLabel htmlFor="identity-user-filter">用户过滤器</FieldLabel>
<Input id="identity-user-filter" value={form.user_filter} onChange={(event) => setForm({ ...form, user_filter: event.target.value })} placeholder="(objectClass=person)" />
</Field>
<Field>
<FieldLabel htmlFor="identity-bind-password">
Bind password {provider?.credential_set ? `(${provider.credential_masked})` : ''}
</FieldLabel>
<Input
id="identity-bind-password"
type="password"
autoComplete="new-password"
value={form.credential}
onChange={(event) => setForm({ ...form, credential: event.target.value })}
placeholder={provider?.credential_set ? '留空保留原密码' : '支持匿名绑定时可留空'}
/>
</Field>
</>
)}
<Field orientation="horizontal">
<div className="min-w-0 flex-1">
<FieldLabel htmlFor="identity-auto-create">自动创建本地账号</FieldLabel>
<FieldDescription>首次识别到稳定外部身份时创建无本地密码的账号。</FieldDescription>
</div>
<Switch id="identity-auto-create" checked={form.auto_create_users} onCheckedChange={(checked) => setForm({ ...form, auto_create_users: checked })} />
</Field>
<Field orientation="horizontal">
<div className="min-w-0 flex-1">
<FieldLabel htmlFor="identity-sync-profile">同步姓名与邮箱</FieldLabel>
<FieldDescription>企业目录中的最新资料会覆盖对应本地字段。</FieldDescription>
</div>
<Switch id="identity-sync-profile" checked={form.sync_profile} onCheckedChange={(checked) => setForm({ ...form, sync_profile: checked })} />
</Field>
<Field orientation="horizontal">
<div className="min-w-0 flex-1">
<FieldLabel htmlFor="identity-sync-groups">同步组织映射</FieldLabel>
<FieldDescription>只处理本提供方已登记的外部组织映射。</FieldDescription>
</div>
<Switch id="identity-sync-groups" checked={form.sync_groups} onCheckedChange={(checked) => setForm({ ...form, sync_groups: checked })} />
</Field>
<Field orientation="horizontal">
<div className="min-w-0 flex-1">
<FieldLabel htmlFor="identity-enabled">启用提供方</FieldLabel>
<FieldDescription>{oidc ? '启用后会出现在登录页。' : '启用后允许目录适配器使用该配置。'}</FieldDescription>
</div>
<Switch id="identity-enabled" checked={form.enabled} onCheckedChange={(checked) => setForm({ ...form, enabled: checked })} />
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive" className="mt-5">
<CircleAlert />
<AlertTitle>保存未完成</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
</div>
<DialogFooter className="m-0 rounded-none px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>取消</Button>
<Button type="submit" disabled={pending || !form.name.trim() || !form.slug.trim()}>
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" /> : <Save data-icon="inline-start" />}
{pending ? '保存中' : '保存提供方'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,107 @@
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 { SystemIdentityPage } from './SystemIdentityPage'
const mocks = vi.hoisted(() => ({
listIdentityProviders: vi.fn(),
listUserGroups: vi.fn(),
listEnterpriseGroupMappings: vi.fn(),
createEnterpriseGroupMapping: vi.fn(),
deleteEnterpriseGroupMapping: vi.fn(),
syncEnterpriseDirectory: vi.fn(),
updateIdentityProvider: vi.fn(),
createIdentityProvider: vi.fn()
}))
vi.mock('./api', () => ({
...mocks
}))
const provider = {
id: 1,
slug: 'corp',
name: '企业统一身份',
provider_type: 'oidc' as const,
enabled: true,
button_label: '使用企业账号登录',
issuer: 'https://idp.example.com',
metadata_url: '',
client_id: 'ticket-platform',
credential_set: true,
credential_masked: '****cret',
scopes: ['openid', 'profile', 'email'],
claim_mappings: {},
configuration: {},
auto_create_users: true,
sync_profile: true,
sync_groups: true,
identity_count: 12,
group_mapping_count: 1,
created_at: '2026-07-27T00:00:00Z',
updated_at: '2026-07-27T00:00:00Z'
}
describe('SystemIdentityPage', () => {
afterEach(cleanup)
beforeEach(() => {
vi.clearAllMocks()
mocks.listIdentityProviders.mockResolvedValue([provider])
mocks.listUserGroups.mockResolvedValue([
{
id: 10,
name: '一线支持',
code: 'support_l1',
description: '',
is_active: true
}
])
mocks.listEnterpriseGroupMappings.mockResolvedValue([
{
id: 20,
provider_id: 1,
external_group: 'IT-Support',
user_group_id: 10,
user_group_name: '一线支持',
user_group_code: 'support_l1',
created_at: '2026-07-27T00:00:00Z'
}
])
mocks.syncEnterpriseDirectory.mockResolvedValue({
id: 30,
provider_id: 1,
status: 'completed',
total_count: 1,
created_count: 1,
updated_count: 0,
failed_count: 0,
errors: [],
started_at: '2026-07-27T00:00:00Z',
finished_at: '2026-07-27T00:00:01Z'
})
})
it('renders provider status and controlled group mapping', async () => {
render(<SystemIdentityPage />)
expect((await screen.findAllByText('企业统一身份')).length).toBeGreaterThan(0)
expect(await screen.findByText('IT-Support')).toBeInTheDocument()
expect(screen.getByText('12 个身份 · 1 个映射')).toBeInTheDocument()
})
it('submits normalized directory records and reports the run', async () => {
const user = userEvent.setup()
render(<SystemIdentityPage />)
await screen.findByText('IT-Support')
await user.click(screen.getByRole('tab', { name: /目录同步/ }))
await user.click(await screen.findByRole('button', { name: '开始目录同步' }))
await waitFor(() => expect(mocks.syncEnterpriseDirectory).toHaveBeenCalledTimes(1))
expect(mocks.syncEnterpriseDirectory.mock.calls[0][0]).toBe(1)
expect(mocks.syncEnterpriseDirectory.mock.calls[0][1][0].subject).toBe('employee-1001')
expect(await screen.findByText(/同步运行 #30/)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,432 @@
import * as React from 'react'
import { Building2, Link2, Pencil, Plus, RefreshCw, ShieldCheck, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Field, 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'
import { formatDateTime } from '@/lib/format'
import {
createEnterpriseGroupMapping,
deleteEnterpriseGroupMapping,
listEnterpriseGroupMappings,
listIdentityProviders,
listUserGroups,
syncEnterpriseDirectory,
updateIdentityProvider
} from './api'
import { IdentityProviderDialog } from './IdentityProviderDialog'
import type {
DirectorySyncRun,
EnterpriseGroupMapping,
IdentityProvider,
UserGroup
} from './types'
const directorySample = JSON.stringify([
{
subject: 'employee-1001',
username: 'zhangsan',
email: 'zhangsan@example.com',
display_name: '张三',
active: true,
groups: ['IT-Support']
}
], null, 2)
export function SystemIdentityPage() {
const [providers, setProviders] = React.useState<IdentityProvider[]>([])
const [userGroups, setUserGroups] = React.useState<UserGroup[]>([])
const [selectedId, setSelectedId] = React.useState<number | null>(null)
const [mappings, setMappings] = React.useState<EnterpriseGroupMapping[]>([])
const [dialogOpen, setDialogOpen] = React.useState(false)
const [editingProvider, setEditingProvider] = React.useState<IdentityProvider | null>(null)
const [externalGroup, setExternalGroup] = React.useState('')
const [userGroupId, setUserGroupId] = React.useState('')
const [directoryJson, setDirectoryJson] = React.useState(directorySample)
const [syncRun, setSyncRun] = React.useState<DirectorySyncRun | null>(null)
const [mappingToDelete, setMappingToDelete] = React.useState<EnterpriseGroupMapping | null>(null)
const [pending, setPending] = React.useState<string | null>(null)
const [error, setError] = React.useState('')
const selected = providers.find((provider) => provider.id === selectedId) ?? null
const loadProviders = React.useCallback(async () => {
const next = await listIdentityProviders()
setProviders(next)
setSelectedId((current) => current && next.some((item) => item.id === current) ? current : next[0]?.id ?? null)
}, [])
React.useEffect(() => {
Promise.all([loadProviders(), listUserGroups().then(setUserGroups)])
.then(() => setError(''))
.catch((reason) => setError(reason instanceof Error ? reason.message : '身份治理配置加载失败'))
}, [loadProviders])
React.useEffect(() => {
if (!selectedId) {
setMappings([])
return
}
void listEnterpriseGroupMappings(selectedId)
.then(setMappings)
.catch((reason) => setError(reason instanceof Error ? reason.message : '组织映射加载失败'))
}, [selectedId])
async function toggleProvider(provider: IdentityProvider, enabled: boolean) {
setPending(`provider-${provider.id}`)
try {
await updateIdentityProvider(provider.id, { enabled })
await loadProviders()
toast.success(enabled ? '身份提供方已启用' : '身份提供方已停用')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '身份提供方状态更新失败')
} finally {
setPending(null)
}
}
async function addMapping(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!selected || !userGroupId) return
setPending('mapping')
try {
await createEnterpriseGroupMapping(selected.id, externalGroup.trim(), Number(userGroupId))
setExternalGroup('')
setUserGroupId('')
setMappings(await listEnterpriseGroupMappings(selected.id))
await loadProviders()
toast.success('组织映射已创建')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '组织映射创建失败')
} finally {
setPending(null)
}
}
async function removeMapping(mapping: EnterpriseGroupMapping) {
setPending(`mapping-${mapping.id}`)
try {
await deleteEnterpriseGroupMapping(mapping.id)
setMappings(await listEnterpriseGroupMappings(mapping.provider_id))
await loadProviders()
setMappingToDelete(null)
toast.success('组织映射已删除')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '组织映射删除失败')
} finally {
setPending(null)
}
}
async function runSync(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!selected) return
setPending('sync')
setSyncRun(null)
try {
const parsed: unknown = JSON.parse(directoryJson)
if (!Array.isArray(parsed)) throw new Error('目录数据必须是 JSON 数组')
const run = await syncEnterpriseDirectory(selected.id, parsed)
setSyncRun(run)
await loadProviders()
toast.success(run.failed_count ? '目录同步已完成,部分记录需处理' : '目录同步已完成')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '目录同步失败')
} finally {
setPending(null)
}
}
const providerColumns: AppDataTableColumn<IdentityProvider>[] = [
{
key: 'provider',
header: '身份提供方',
cell: (provider) => (
<div>
<p className="font-medium">{provider.name}</p>
<p className="mt-1 text-sm text-muted-foreground">{provider.slug}</p>
</div>
)
},
{
key: 'type',
header: '协议',
cell: (provider) => <Badge variant="outline">{provider.provider_type === 'oidc' ? 'OIDC' : 'LDAP'}</Badge>
},
{
key: 'counts',
header: '同步范围',
cell: (provider) => (
<span className="text-sm text-muted-foreground">
{provider.identity_count} 个身份 · {provider.group_mapping_count} 个映射
</span>
)
},
{
key: 'status',
header: '状态',
cell: (provider) => (
<div className="flex items-center gap-2">
<Switch
checked={provider.enabled}
disabled={pending === `provider-${provider.id}`}
onCheckedChange={(checked) => void toggleProvider(provider, checked)}
aria-label={`${provider.name}启用状态`}
/>
<Badge variant={provider.enabled ? 'success' : 'secondary'}>{provider.enabled ? '已启用' : '未启用'}</Badge>
</div>
)
},
{
key: 'actions',
header: '',
className: 'text-right',
cell: (provider) => (
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingProvider(provider)
setDialogOpen(true)
}}
>
<Pencil data-icon="inline-start" />
编辑
</Button>
)
}
]
const mappingColumns: AppDataTableColumn<EnterpriseGroupMapping>[] = [
{
key: 'external',
header: '企业组织标识',
cell: (mapping) => <span className="font-mono text-sm">{mapping.external_group}</span>
},
{
key: 'local',
header: '平台用户组',
cell: (mapping) => (
<div>
<p className="font-medium">{mapping.user_group_name}</p>
<p className="mt-1 text-sm text-muted-foreground">{mapping.user_group_code}</p>
</div>
)
},
{
key: 'created',
header: '创建时间',
cell: (mapping) => <span className="text-muted-foreground">{formatDateTime(mapping.created_at)}</span>
},
{
key: 'actions',
header: '',
className: 'text-right',
cell: (mapping) => (
<Button
variant="ghost"
size="icon-sm"
aria-label={`删除 ${mapping.external_group} 映射`}
disabled={pending === `mapping-${mapping.id}`}
onClick={() => setMappingToDelete(mapping)}
>
<Trash2 />
</Button>
)
}
]
return (
<AppPage
header={(
<AppPageHeader
title="企业身份"
description="统一管理单点登录、外部身份绑定与企业组织映射。"
actions={(
<Button onClick={() => { setEditingProvider(null); setDialogOpen(true) }}>
<Plus data-icon="inline-start" />
新增提供方
</Button>
)}
/>
)}
>
{error ? (
<Alert variant="destructive">
<AlertTitle>操作未完成</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<AppDataTable
columns={providerColumns}
rows={providers}
getRowKey={(provider) => provider.id}
emptyTitle="尚未配置企业身份"
emptyDescription="新增 OIDC 提供方后,启用的企业登录入口会出现在登录页。"
footer={<p className="text-sm text-muted-foreground">共 {providers.length} 个身份提供方</p>}
/>
{selected ? (
<Card>
<CardHeader className="gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2"><Building2 />组织同步工作区</CardTitle>
<CardDescription className="mt-1">选择提供方,维护受控映射或通过目录适配器导入成员。</CardDescription>
</div>
<Select value={String(selected.id)} onValueChange={(value) => setSelectedId(Number(value))}>
<SelectTrigger className="w-full sm:w-64"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
{providers.map((provider) => <SelectItem key={provider.id} value={String(provider.id)}>{provider.name}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
<Tabs defaultValue="mappings">
<TabsList>
<TabsTrigger value="mappings"><Link2 />组织映射</TabsTrigger>
<TabsTrigger value="sync"><RefreshCw />目录同步</TabsTrigger>
</TabsList>
<TabsContent value="mappings" className="mt-4 flex flex-col gap-4">
<form onSubmit={addMapping}>
<FieldGroup className="grid items-end gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<Field>
<FieldLabel htmlFor="external-group">企业组织标识</FieldLabel>
<Input id="external-group" value={externalGroup} onChange={(event) => setExternalGroup(event.target.value)} placeholder="例如:IT-Support" required />
</Field>
<Field>
<FieldLabel>平台用户组</FieldLabel>
<Select value={userGroupId} onValueChange={setUserGroupId}>
<SelectTrigger className="w-full"><SelectValue placeholder="选择用户组" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{userGroups.filter((group) => group.is_active).map((group) => <SelectItem key={group.id} value={String(group.id)}>{group.name}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Button type="submit" disabled={pending === 'mapping' || !externalGroup.trim() || !userGroupId}>
<Plus data-icon="inline-start" />
创建映射
</Button>
</FieldGroup>
</form>
<AppDataTable
columns={mappingColumns}
rows={mappings}
getRowKey={(mapping) => mapping.id}
emptyTitle="暂无组织映射"
emptyDescription="没有映射的企业组织不会改变平台中的用户组归属。"
/>
</TabsContent>
<TabsContent value="sync" className="mt-4">
<form className="flex flex-col gap-4" onSubmit={runSync}>
<Alert>
<ShieldCheck />
<AlertTitle>目录适配器输入</AlertTitle>
<AlertDescription>
该入口接收已由可信连接器读取并标准化的成员记录;不会在浏览器中保存 LDAP 密码。
</AlertDescription>
</Alert>
<Field>
<FieldLabel htmlFor="directory-records">标准化成员 JSON</FieldLabel>
<Textarea
id="directory-records"
className="min-h-64 font-mono text-xs"
value={directoryJson}
onChange={(event) => setDirectoryJson(event.target.value)}
/>
<FieldDescription>单次最多 1000 条;同一 subject 重复同步只会更新原身份。</FieldDescription>
</Field>
<Button type="submit" className="self-end" disabled={pending === 'sync'}>
<RefreshCw data-icon="inline-start" />
{pending === 'sync' ? '同步中' : '开始目录同步'}
</Button>
{syncRun ? (
<Alert variant={syncRun.failed_count ? 'destructive' : 'default'}>
<AlertTitle>同步运行 #{syncRun.id} · {syncRun.status}</AlertTitle>
<AlertDescription>
共 {syncRun.total_count} 条,新建 {syncRun.created_count},更新 {syncRun.updated_count},失败 {syncRun.failed_count}。
</AlertDescription>
</Alert>
) : null}
</form>
</TabsContent>
</Tabs>
</CardContent>
</Card>
) : providers.length === 0 ? (
<AppEmptyState title="等待身份提供方" description="配置提供方后即可维护组织映射与目录同步。" />
) : null}
<IdentityProviderDialog
open={dialogOpen}
provider={editingProvider}
onOpenChange={setDialogOpen}
onSaved={loadProviders}
/>
<AlertDialog
open={Boolean(mappingToDelete)}
onOpenChange={(open) => {
if (!open && !pending?.startsWith('mapping-')) setMappingToDelete(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除组织映射?</AlertDialogTitle>
<AlertDialogDescription>
删除后,企业组织“{mappingToDelete?.external_group}”将不再同步到平台用户组。已有用户不会在本次操作中被修改。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={Boolean(pending?.startsWith('mapping-'))}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={!mappingToDelete || Boolean(pending?.startsWith('mapping-'))}
onClick={(event) => {
event.preventDefault()
if (mappingToDelete) void removeMapping(mappingToDelete)
}}
>
{pending?.startsWith('mapping-') ? '删除中' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AppPage>
)
}

View File

@@ -1,6 +1,17 @@
import { apiRequest } from '@/lib/api'
import type { AuditLog, Permission, RoleDetail, SystemMenu, SystemUser, UserGroup } from './types'
import type {
AuditLog,
DirectorySyncRun,
EnterpriseGroupMapping,
IdentityProvider,
IdentityProviderInput,
Permission,
RoleDetail,
SystemMenu,
SystemUser,
UserGroup
} from './types'
export function listUsers() {
return apiRequest<SystemUser[]>('/accounts/users')
@@ -138,3 +149,55 @@ export function updateMenu(
export function listAuditLogs() {
return apiRequest<AuditLog[]>('/audit/operation-logs')
}
export function listIdentityProviders() {
return apiRequest<IdentityProvider[]>('/identity/providers')
}
export function createIdentityProvider(payload: IdentityProviderInput & { slug: string; provider_type: 'oidc' | 'ldap' }) {
return apiRequest<IdentityProvider>('/identity/providers', {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function updateIdentityProvider(providerId: number, payload: Partial<IdentityProviderInput>) {
return apiRequest<IdentityProvider>(`/identity/providers/${providerId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
}
export function listEnterpriseGroupMappings(providerId: number) {
return apiRequest<EnterpriseGroupMapping[]>(`/identity/providers/${providerId}/group-mappings`)
}
export function createEnterpriseGroupMapping(providerId: number, externalGroup: string, userGroupId: number) {
return apiRequest<EnterpriseGroupMapping>(`/identity/providers/${providerId}/group-mappings`, {
method: 'POST',
body: JSON.stringify({ external_group: externalGroup, user_group_id: userGroupId })
})
}
export function deleteEnterpriseGroupMapping(mappingId: number) {
return apiRequest<{ status: string }>(`/identity/group-mappings/${mappingId}`, {
method: 'DELETE'
})
}
export function syncEnterpriseDirectory(
providerId: number,
records: Array<{
subject: string
username?: string
email?: string
display_name?: string
active?: boolean
groups?: string[]
}>
) {
return apiRequest<DirectorySyncRun>(`/identity/providers/${providerId}/directory-sync`, {
method: 'POST',
body: JSON.stringify({ records })
})
}

View File

@@ -61,3 +61,68 @@ export type AuditLog = {
user_agent: string
created_at: string
}
export type IdentityProvider = {
id: number
slug: string
name: string
provider_type: 'oidc' | 'ldap'
enabled: boolean
button_label: string
issuer: string
metadata_url: string
client_id: string
credential_set: boolean
credential_masked: string
scopes: string[]
claim_mappings: Record<string, string>
configuration: Record<string, unknown>
auto_create_users: boolean
sync_profile: boolean
sync_groups: boolean
identity_count: number
group_mapping_count: number
created_at: string
updated_at: string
}
export type IdentityProviderInput = {
slug?: string
name: string
provider_type?: 'oidc' | 'ldap'
enabled: boolean
button_label: string
issuer: string
metadata_url: string
client_id: string
credential?: string
scopes: string[]
claim_mappings: Record<string, string>
configuration: Record<string, unknown>
auto_create_users: boolean
sync_profile: boolean
sync_groups: boolean
}
export type EnterpriseGroupMapping = {
id: number
provider_id: number
external_group: string
user_group_id: number
user_group_name: string
user_group_code: string
created_at: string
}
export type DirectorySyncRun = {
id: number
provider_id: number
status: 'completed' | 'partial' | 'failed'
total_count: number
created_count: number
updated_count: number
failed_count: number
errors: Array<{ index: string; subject: string; error: string }>
started_at: string
finished_at: string | null
}

View File

@@ -1,5 +1,9 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api'
export function apiUrl(path: string) {
return `${API_BASE_URL}${path}`
}
let csrfToken: string | null = null
export class ApiError extends Error {
@@ -29,7 +33,7 @@ async function readError(response: Response) {
async function getCsrfToken() {
if (!csrfToken) {
const response = await fetch(`${API_BASE_URL}/accounts/auth/csrf`, {
const response = await fetch(apiUrl('/accounts/auth/csrf'), {
credentials: 'include'
})
if (!response.ok) {
@@ -51,7 +55,7 @@ export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T
headers.set('x-csrftoken', await getCsrfToken())
}
const response = await fetch(`${API_BASE_URL}${path}`, {
const response = await fetch(apiUrl(path), {
...init,
credentials: 'include',
headers