feat: add local model gateway and console tabs

This commit is contained in:
lofyer
2026-10-12 14:21:13 +08:00
parent 9fa8f3e762
commit ec2c90d208
46 changed files with 3491 additions and 1120 deletions
+77 -81
View File
@@ -1,6 +1,6 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { prototypeSnapshot } from '../../shared/prototype-data'
import { pages } from './app-config'
import App from './App'
describe('ShareServer console', () => {
@@ -10,102 +10,98 @@ describe('ShareServer console', () => {
})
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => prototypeSnapshot
})
)
vi.stubGlobal('fetch', vi.fn().mockImplementation(async (url: string) => {
if (url === '/api/v1/gateway/config') {
return { ok: true, json: async () => ({ upstreams: [], models: [], audits: [], gatewayBaseUrl: null }) }
}
if (url === '/api/v1/gateway/access-keys') return { ok: true, json: async () => ({ accessKeys: [] }) }
if (url === '/api/v1/sharing/devices') return { ok: true, json: async () => ({ devices: [] }) }
if (url === '/api/v1/sharing/publications') return { ok: true, json: async () => ({ publications: [] }) }
throw new Error(`Unexpected request: ${url}`)
}))
window.history.replaceState({}, '', '/')
})
it('renders the organization overview from the API snapshot', async () => {
it('renders service links without a snapshot request, fabricated metrics or identity', () => {
render(<App />)
expect(await screen.findByRole('heading', { name: '运行概览' })).toBeInTheDocument()
expect(screen.getByText('Nebula ShareServer')).toBeInTheDocument()
expect(screen.getByText('在线设备')).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: '管理控制台' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: '运行概览' })).toBeInTheDocument()
const services = screen.getByRole('region', { name: 'GoodBuddy 联动服务' })
expect(within(services).getByRole('link', { name: /模型网关/ })).toHaveAttribute('href', '/model-gateway')
expect(within(services).getByRole('link', { name: /共享中心/ })).toHaveAttribute('href', '/capabilities')
const nav = screen.getByRole('navigation', { name: '管理控制台' })
for (const page of pages) expect(within(nav).getByRole('link', { name: page.title })).toHaveAttribute('href', page.path)
expect(within(nav).getByRole('link', { name: '审批' })).toHaveTextContent(/^审批$/)
expect(screen.queryByText(/Nebula|MesaLab|林江|在线设备|任务活动|网络脉冲|示例数据/)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /通知|搜索|切换到成员|切换到管理/ })).not.toBeInTheDocument()
fireEvent.keyDown(window, { key: 'k', ctrlKey: true })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(fetch).toHaveBeenCalledWith('/api/v1/sharing/devices', expect.anything())
expect(fetch).toHaveBeenCalledWith('/api/v1/sharing/publications', expect.anything())
expect(fetch).toHaveBeenCalledWith('/api/v1/gateway/config', expect.anything())
})
it('opens global search with the keyboard shortcut', async () => {
it.each(pages.filter((page) => !['/', '/capabilities', '/model-gateway', '/distribution', '/devices'].includes(page.path)))(
'shows a boundary state without pretend actions at $path', (page) => {
window.history.replaceState({}, '', page.path)
render(<App />)
const main = screen.getByRole('main')
expect(within(main).getByRole('heading', { name: page.title })).toBeInTheDocument()
expect(within(main).getByRole('heading', { name: '当前没有可用的业务数据' })).toBeInTheDocument()
expect(within(main).queryByRole('button')).not.toBeInTheDocument()
expect(within(main).getAllByRole('tab').length).toBeGreaterThan(0)
expect(fetch).not.toHaveBeenCalled()
}
)
it('preserves theme switching and closes mobile navigation after navigation', () => {
render(<App />)
await screen.findByRole('heading', { name: '运行概览' })
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }))
expect(await screen.findByRole('dialog', { name: '全局搜索' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '切换到浅色主题' }))
expect(document.documentElement).toHaveAttribute('data-theme', 'light')
fireEvent.click(screen.getByRole('button', { name: '切换到深色主题' }))
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
fireEvent.click(screen.getByRole('button', { name: '打开导航' }))
const navigation = screen.getAllByRole('navigation', { name: '管理控制台' })
expect(navigation).toHaveLength(2)
fireEvent.click(within(navigation[1]).getByRole('link', { name: '组织与成员' }))
expect(screen.getAllByRole('navigation')).toHaveLength(1)
expect(screen.getByRole('heading', { name: '组织与成员' })).toBeInTheDocument()
})
it('changes list data when navigating between existing console routes', async () => {
render(<App />)
await screen.findByRole('heading', { name: '运行概览' })
fireEvent.click(screen.getByRole('link', { name: 'GoodBuddy 接入' }))
expect(screen.getByText('Jiang-PC')).toBeInTheDocument()
fireEvent.click(screen.getByRole('link', { name: '组织与成员' }))
expect(screen.getByText('jiang@mesalab.internal')).toBeInTheDocument()
expect(screen.queryByText('Jiang-PC')).not.toBeInTheDocument()
})
it('retains gateway state across navigation and blocks publishing against a disabled upstream', async () => {
it('preserves distribution and its link to the real gateway', async () => {
window.history.replaceState({}, '', '/distribution')
render(<App />)
await screen.findByRole('heading', { name: '配置下发' })
fireEvent.click(screen.getByRole('button', { name: '创建配置草稿' }))
fireEvent.change(screen.getByLabelText('版本'), { target: { value: '1.0.0' } })
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }))
fireEvent.click(screen.getByRole('link', { name: '模型网关' }))
fireEvent.click(screen.getByRole('button', { name: '停用 示例上游' }))
fireEvent.click(screen.getByRole('link', { name: '配置下发' }))
expect(screen.getByRole('button', { name: '模拟发布' })).toBeDisabled()
fireEvent.click(screen.getByRole('link', { name: '模型网关' }))
fireEvent.click(screen.getByRole('button', { name: '启用 示例上游' }))
fireEvent.click(screen.getByRole('link', { name: '配置下发' }))
fireEvent.click(screen.getByRole('button', { name: '模拟发布' }))
expect(screen.getByText('模拟已应用')).toBeInTheDocument()
expect(screen.getByText('离线 · 待下发(模拟)')).toBeInTheDocument()
expect(screen.getByText('已应用:1.0.0 / org-chat · 目标:1.0.0')).toBeInTheDocument()
expect(screen.getByText('已应用:无 · 目标:1.0.0')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '创建配置草稿' }))
fireEvent.change(screen.getByLabelText('版本'), { target: { value: '1.1.0' } })
fireEvent.change(screen.getByLabelText('默认模型'), { target: { value: 'model-code' } })
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }))
fireEvent.click(screen.getByRole('button', { name: '模拟发布' }))
expect(screen.getByText('已应用:1.1.0 / org-code · 目标:1.1.0')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '模拟回退到 1.0.0' }))
expect(screen.getByText('已应用:1.0.0 / org-chat · 目标:1.0.0')).toBeInTheDocument()
expect(screen.getByText('已应用:无 · 目标:1.0.0')).toBeInTheDocument()
expect(screen.getByText('尚无配置下发服务')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '创建配置草稿' })).not.toBeInTheDocument()
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
expect(screen.queryByText(/org-chat|org-code|工程工作站/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('link', { name: '前往模型网关' }))
expect(await screen.findByText(/尚无上游服务/)).toBeInTheDocument()
await screen.findByText(/暂无访问密钥/)
expect(fetch).toHaveBeenCalledTimes(2)
expect(fetch).toHaveBeenCalledWith('/api/v1/gateway/config', expect.anything())
})
it('removes personal delete authority after document transfer and preserves ownership through restore', async () => {
window.history.replaceState({}, '', '/documents')
it('renders the real device directory', async () => {
vi.mocked(fetch).mockImplementation(async (input: string | URL | Request) => {
const url = input.toString()
if (url === '/api/v1/sharing/devices') return { ok: true, json: async () => ({ devices: [{ id: 'device-1', name: '本机工作站', platform: 'win32', appVersion: '1.0.0', registeredAt: '2026-10-01', updatedAt: '2026-10-02' }] }) } as Response
if (url === '/api/v1/sharing/publications') return { ok: true, json: async () => ({ publications: [] }) } as Response
throw new Error(`Unexpected request: ${url}`)
})
window.history.replaceState({}, '', '/devices')
render(<App />)
await screen.findByRole('heading', { name: '文档与协作' })
fireEvent.click(screen.getByRole('button', { name: '打开模拟 GoodBuddy 客户端' }))
fireEvent.click(screen.getByRole('button', { name: '确认转移给组织' }))
const document = screen.getByRole('heading', { name: '我的项目提案.docx' }).closest('article')!
expect(within(document).getByText('归属:组织:MesaLab')).toBeInTheDocument()
expect(within(document).getByRole('button', { name: '移入回收站' })).toBeDisabled()
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.click(within(document).getByRole('button', { name: '移入回收站' }))
fireEvent.click(screen.getByRole('tab', { name: '回收站' }))
expect(screen.getByText('归属:组织:MesaLab')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '恢复文档' }))
fireEvent.click(screen.getByRole('tab', { name: '组织文档' }))
expect(screen.getByRole('heading', { name: '我的项目提案.docx' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '打开模拟 GoodBuddy 客户端' }))
expect(screen.queryByRole('button', { name: '删除我的文档' })).not.toBeInTheDocument()
expect(await screen.findByRole('heading', { name: '本机工作站' })).toBeInTheDocument()
expect(screen.getByText('已登记')).toBeInTheDocument()
expect(screen.getByRole('link', { name: '查看共享内容' })).toHaveAttribute('href', '/capabilities?view=local&device=device-1')
})
it('records denied gateway calls and hides administration from member navigation', async () => {
window.history.replaceState({}, '', '/model-gateway')
it('redirects unknown routes to the overview without requesting a snapshot', () => {
window.history.replaceState({}, '', '/missing')
render(<App />)
await screen.findByRole('heading', { name: '模型网关' })
fireEvent.click(screen.getByRole('tab', { name: '模型映射 / 访问策略' }))
fireEvent.change(screen.getByLabelText('模拟调用者用户组'), { target: { value: '设计中心' } })
fireEvent.click(screen.getByRole('button', { name: '模拟调用 org-code' }))
fireEvent.click(screen.getByRole('tab', { name: '调用审计' }))
expect(screen.getByText('模拟拒绝')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /切换到成员自助/ }))
expect(screen.getByRole('heading', { name: '我的设备' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: '模型网关' })).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: '运行概览' })).toBeInTheDocument()
expect(window.location.pathname).toBe('/')
expect(fetch).toHaveBeenCalledWith('/api/v1/sharing/devices', expect.anything())
expect(fetch).toHaveBeenCalledWith('/api/v1/sharing/publications', expect.anything())
expect(fetch).toHaveBeenCalledWith('/api/v1/gateway/config', expect.anything())
})
})
+25 -122
View File
@@ -1,132 +1,35 @@
import { useEffect, useState } from 'react'
import {
BrowserRouter,
Navigate,
Route,
Routes,
useLocation
} from 'react-router-dom'
import type { PrototypeRole, PrototypeSnapshot } from '../../shared/prototype-data'
import { pages, visiblePages } from './app-config'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { pages } from './app-config'
import { AppShell } from './components/app-shell'
import { DataPage } from './components/data-page'
import { SharingCenter } from './components/sharing-center'
import { OverviewPage } from './components/overview-page'
import { SettingsPage } from './components/settings-page'
import { ServiceDemoPage, ServicesDemo } from './components/services-demo'
import { Button } from './components/ui/button'
async function loadSnapshot(): Promise<PrototypeSnapshot> {
const response = await fetch('/api/v1/web/prototype/snapshot')
if (!response.ok) throw new Error('控制台快照加载失败')
return response.json() as Promise<PrototypeSnapshot>
}
function LoadingState() {
return (
<div className="grid min-h-screen place-items-center bg-[#07111f]" role="status" aria-busy="true">
<div className="text-center">
<div className="mx-auto size-10 animate-spin rounded-full border-2 border-cyan-300/15 border-t-cyan-300" />
<p className="mt-4 text-sm text-slate-400">正在连接 ShareServer…</p>
</div>
</div>
)
}
function Console({
snapshot,
role,
onRoleChange
}: {
snapshot: PrototypeSnapshot
role: PrototypeRole
onRoleChange: (role: PrototypeRole) => void
}) {
const location = useLocation()
const allowedPaths = visiblePages(role).map((page) => page.path)
const fallback = role === 'admin' ? '/' : '/me/devices'
if (!allowedPaths.includes(location.pathname)) {
return <Navigate replace to={fallback} />
}
return (
<AppShell snapshot={snapshot} role={role} onRoleChange={onRoleChange}>
<Routes>
{pages.map((page) => (
<Route
key={page.path}
path={page.path}
element={
page.path === '/' ? (
<OverviewPage snapshot={snapshot} />
) : page.path === '/capabilities' ? (
<SharingCenter snapshot={snapshot} />
) : page.path === '/settings' ? (
<SettingsPage snapshot={snapshot} />
) : ['/model-gateway', '/documents', '/distribution'].includes(page.path) ? (
<ServiceDemoPage key={page.path} page={page} snapshot={snapshot} />
) : (
<DataPage key={page.path} page={page} snapshot={snapshot} />
)
}
/>
))}
<Route path="*" element={<Navigate replace to={fallback} />} />
</Routes>
</AppShell>
)
}
import { GatewayPage } from './components/gateway-page'
import { DistributionPage } from './components/distribution-page'
import { DevicesPage } from './components/devices-page'
import { UnavailablePage } from './components/unavailable-page'
export default function App() {
const [snapshot, setSnapshot] = useState<PrototypeSnapshot | null>(null)
const [error, setError] = useState<string | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [role, setRole] = useState<PrototypeRole>('admin')
useEffect(() => {
let cancelled = false
loadSnapshot()
.then((data) => {
if (!cancelled) setSnapshot(data)
})
.catch((reason: unknown) => {
if (!cancelled) {
setError(reason instanceof Error ? reason.message : '控制台加载失败')
}
})
return () => {
cancelled = true
}
}, [reloadKey])
if (error) {
return (
<div className="grid min-h-screen place-items-center bg-[#07111f] p-6 text-slate-100">
<div role="alert" className="max-w-md rounded-2xl border border-rose-400/20 bg-rose-400/[0.06] p-6 text-center">
<p className="font-medium">{error}</p>
<p className="mt-2 text-sm text-slate-400">请确认 ShareServer API 已启动,然后重试。</p>
<Button
className="mt-5"
onClick={() => {
setError(null)
setReloadKey((key) => key + 1)
}}
>
重新连接
</Button>
</div>
</div>
)
}
if (!snapshot) return <LoadingState />
return (
<BrowserRouter>
<ServicesDemo>
<Console snapshot={snapshot} role={role} onRoleChange={setRole} />
</ServicesDemo>
<AppShell>
<Routes>
{pages.map((page) => (
<Route
key={page.path}
path={page.path}
element={
page.path === '/' ? <OverviewPage />
: page.path === '/capabilities' ? <SharingCenter />
: page.path === '/model-gateway' ? <GatewayPage />
: page.path === '/distribution' ? <DistributionPage />
: page.path === '/devices' ? <DevicesPage />
: <UnavailablePage title={page.title} description={page.description} boundary={page.boundary ?? '该模块的身份、权限和持久化接口尚未接入,因此当前不显示示例数据,也不提供不会生效的操作。'} next={page.next} tabs={page.tabs?.map(({ label, dataKey }) => ({ label, key: dataKey }))} />
}
/>
))}
<Route path="*" element={<Navigate replace to="/" />} />
</Routes>
</AppShell>
</BrowserRouter>
)
}
+49 -19
View File
@@ -30,6 +30,8 @@ export type PageConfig = {
primaryAction?: string
group?: string
tabs?: Array<{ label: string; dataKey: string }>
boundary?: string
next?: { label: string; to: string }
}
export const pages: PageConfig[] = [
@@ -38,15 +40,17 @@ export const pages: PageConfig[] = [
title: '运行概览',
description: 'GoodBuddy 组织服务、接入状态与待处理事项。',
icon: Gauge,
role: 'admin'
role: 'admin',
tabs: [{ label: '服务状态', dataKey: 'status' }, { label: '待处理事项', dataKey: 'pending' }, { label: '最近活动', dataKey: 'activity' }]
},
{
path: '/model-gateway',
title: '模型网关',
description: '接入上游模型服务,管理转发、访问策略和调用审计;ShareServer 不运行模型。',
description: '本地技术预览:配置真实上游与模型别名、测试转发并查看审计;尚无组织认证与配额。',
icon: Network,
role: 'admin',
group: 'GoodBuddy 服务'
group: 'GoodBuddy 服务',
tabs: [{ label: '概览', dataKey: 'overview' }, { label: '上游服务', dataKey: 'upstreams' }, { label: '模型目录', dataKey: 'models' }, { label: '访问密钥', dataKey: 'keys' }, { label: '请求测试', dataKey: 'test' }, { label: '调用审计', dataKey: 'audits' }, { label: '客户端接入', dataKey: 'client' }]
},
{
path: '/documents',
@@ -54,15 +58,17 @@ export const pages: PageConfig[] = [
description: '文档存储在 ShareServer,在 GoodBuddy 中使用;管理归属、回收站和 OnlyOffice 服务。',
icon: FileText,
role: 'admin',
group: 'GoodBuddy 服务'
group: 'GoodBuddy 服务',
tabs: [{ label: '文档库', dataKey: 'documents' }, { label: '编辑会话', dataKey: 'sessions' }, { label: 'Office 服务', dataKey: 'office' }, { label: '保存记录', dataKey: 'saves' }]
},
{
path: '/distribution',
title: '配置下发',
description: '向 GoodBuddy 分配网关模型配置,查看版本、生效情况与回滚结果。',
description: '配置分发尚未接入;可先在模型网关配置与测试真实上游。',
icon: Send,
role: 'admin',
group: 'GoodBuddy 服务'
group: 'GoodBuddy 服务',
tabs: [{ label: '配置草稿', dataKey: 'drafts' }, { label: '设备生效', dataKey: 'devices' }, { label: '发布记录', dataKey: 'releases' }, { label: '回滚记录', dataKey: 'rollbacks' }]
},
{
path: '/members',
@@ -71,7 +77,8 @@ export const pages: PageConfig[] = [
dataKey: 'members',
icon: Building2,
role: 'admin',
primaryAction: '邀请成员',
group: '组织管理',
boundary: '成员、用户组、角色、登录和组织授权 API 尚未接入。当前不显示成员示例,也不能从此页面发送不会生效的邀请。',
tabs: [
{ label: '成员', dataKey: 'members' },
{ label: '用户组', dataKey: 'groups' },
@@ -85,7 +92,10 @@ export const pages: PageConfig[] = [
dataKey: 'devices',
icon: Cpu,
role: 'admin',
primaryAction: '生成注册链接'
group: '共享资源',
boundary: '当前可以查看真实登记设备和共享目录元数据。设备在线状态、身份认证、设备撤销和注册链接 API 尚未接入。',
next: { label: '查看真实设备目录', to: '/devices' },
tabs: [{ label: '设备列表', dataKey: 'list' }, { label: '设备详情', dataKey: 'detail' }, { label: '连接记录', dataKey: 'connections' }, { label: '撤销记录', dataKey: 'revocations' }]
},
{
path: '/capabilities',
@@ -93,7 +103,9 @@ export const pages: PageConfig[] = [
description: '按内容、发布用户或提供设备查看共享能力与知识库。',
dataKey: 'capabilities',
icon: Boxes,
role: 'admin'
role: 'admin',
group: '共享资源',
tabs: [{ label: '能力目录', dataKey: 'capabilities' }, { label: '共享知识库', dataKey: 'knowledge' }, { label: '按成员查看', dataKey: 'members' }, { label: '按设备查看', dataKey: 'devices' }]
},
{
path: '/policies',
@@ -102,7 +114,9 @@ export const pages: PageConfig[] = [
dataKey: 'policies',
icon: ShieldCheck,
role: 'admin',
primaryAction: '新建策略'
group: '组织管理',
boundary: '权限策略、Grant、配额和组织授权 API 尚未接入。共享目录中的权限字段目前只是发布者声明,不能在此执行授权决策。',
tabs: [{ label: '强制策略', dataKey: 'mandatory' }, { label: 'Grant', dataKey: 'grants' }, { label: '策略模拟', dataKey: 'simulation' }, { label: '变更记录', dataKey: 'changes' }]
},
{
path: '/approvals',
@@ -110,7 +124,9 @@ export const pages: PageConfig[] = [
description: '处理调用、发布、更新和联邦申请。',
dataKey: 'approvals',
icon: UserRoundCheck,
role: 'admin'
role: 'admin',
group: '组织管理',
tabs: [{ label: '待处理', dataKey: 'pending' }, { label: '已处理', dataKey: 'processed' }, { label: '我的申请', dataKey: 'mine' }, { label: '审批规则', dataKey: 'rules' }]
},
{
path: '/releases',
@@ -119,7 +135,9 @@ export const pages: PageConfig[] = [
dataKey: 'releases',
icon: PackageCheck,
role: 'admin',
primaryAction: '上传能力包'
group: '运营与审计',
boundary: '能力包存储、签名校验、版本发布和客户端更新 API 尚未接入。当前不提供上传按钮,避免产生未保存的发布记录。',
tabs: [{ label: '能力包', dataKey: 'packages' }, { label: '发布通道', dataKey: 'channels' }, { label: '更新策略', dataKey: 'policies' }, { label: '撤销记录', dataKey: 'revocations' }]
},
{
path: '/federation',
@@ -128,7 +146,9 @@ export const pages: PageConfig[] = [
dataKey: 'federation',
icon: GitFork,
role: 'admin',
primaryAction: '建立联邦关系'
group: '运营与审计',
boundary: '联邦握手、双边确认、共享范围和跨网关连接 API 尚未接入。当前不显示虚构的网关关系或连接状态。',
tabs: [{ label: '合作组织', dataKey: 'partners' }, { label: '待确认', dataKey: 'pending' }, { label: '开放范围', dataKey: 'scope' }, { label: '事件记录', dataKey: 'events' }]
},
{
path: '/tasks',
@@ -136,7 +156,9 @@ export const pages: PageConfig[] = [
description: '查看任务路由、终态和可选中继容量。',
dataKey: 'tasks',
icon: Activity,
role: 'admin'
role: 'admin',
group: '运营与审计',
tabs: [{ label: '任务列表', dataKey: 'tasks' }, { label: '活动任务', dataKey: 'active' }, { label: '中继连接', dataKey: 'relay' }, { label: '运行统计', dataKey: 'stats' }]
},
{
path: '/audit',
@@ -145,14 +167,19 @@ export const pages: PageConfig[] = [
dataKey: 'audit',
icon: ScrollText,
role: 'admin',
primaryAction: '导出当前结果'
group: '运营与审计',
boundary: '组织操作审计、筛选和导出 API 尚未接入。模型网关的调用元数据仍只能在模型网关页面查看。',
next: { label: '查看模型调用记录', to: '/model-gateway' },
tabs: [{ label: '事件查询', dataKey: 'events' }, { label: '调用链', dataKey: 'traces' }, { label: '导出任务', dataKey: 'exports' }, { label: '保留策略', dataKey: 'retention' }]
},
{
path: '/settings',
title: '系统设置',
description: '查看实例、身份源、基础设施状态与保留策略。',
icon: Settings2,
role: 'admin'
role: 'admin',
group: '系统',
tabs: [{ label: '实例信息', dataKey: 'instance' }, { label: '身份与登录', dataKey: 'identity' }, { label: '基础设施', dataKey: 'infrastructure' }, { label: '中继与联邦', dataKey: 'relay' }, { label: '通知', dataKey: 'notifications' }, { label: '保留与审计', dataKey: 'retention' }]
},
{
path: '/me/devices',
@@ -160,7 +187,8 @@ export const pages: PageConfig[] = [
description: '查看你在当前组织注册的设备与连接状态。',
dataKey: 'myDevices',
icon: CircleUserRound,
role: 'member'
role: 'member',
tabs: [{ label: '我的设备', dataKey: 'devices' }, { label: '我的授权', dataKey: 'grants' }, { label: '我的申请', dataKey: 'requests' }, { label: '我的数字人', dataKey: 'digital-humans' }]
},
{
path: '/me/grants',
@@ -168,7 +196,8 @@ export const pages: PageConfig[] = [
description: '查看你获准发现、调用或下载的能力。',
dataKey: 'myGrants',
icon: FileKey2,
role: 'member'
role: 'member',
tabs: [{ label: '授权详情', dataKey: 'grants' }, { label: '使用限制', dataKey: 'limits' }, { label: '变更记录', dataKey: 'changes' }]
},
{
path: '/me/requests',
@@ -176,7 +205,8 @@ export const pages: PageConfig[] = [
description: '跟踪你的设备、能力与任务审批。',
dataKey: 'myRequests',
icon: UserRoundCheck,
role: 'member'
role: 'member',
tabs: [{ label: '全部申请', dataKey: 'all' }, { label: '待处理', dataKey: 'pending' }, { label: '已完成', dataKey: 'completed' }]
}
]
+10 -112
View File
@@ -1,21 +1,15 @@
import {
Bell,
ChevronsUpDown,
Command,
Menu,
MoonStar,
Search,
Shield,
Sun,
X
} from 'lucide-react'
import { useEffect, useState, type ReactNode } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
import type { PrototypeRole, PrototypeSnapshot } from '../../../shared/prototype-data'
import { visiblePages } from '../app-config'
import { NavLink } from 'react-router-dom'
import { pages } from '../app-config'
import { cn } from '../lib/utils'
import { Button } from './ui/button'
import { Dialog } from './ui/dialog'
function BrandMark() {
return (
@@ -27,70 +21,38 @@ function BrandMark() {
}
export function AppShell({
snapshot,
role,
onRoleChange,
children
}: {
snapshot: PrototypeSnapshot
role: PrototypeRole
onRoleChange: (role: PrototypeRole) => void
children: ReactNode
}) {
const [mobileOpen, setMobileOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [dark, setDark] = useState(true)
const navigate = useNavigate()
const navigation = visiblePages(role).map((page) => ({
const groupOrder = ['工作台', 'GoodBuddy 服务', '共享资源', '组织管理', '运营与审计', '系统', '成员自助']
const navigation = pages.map((page) => ({
...page,
group: page.group ?? (role === 'member' ? '成员自助' : page.path === '/' ? '概览' : '组织与运维')
}))
group: page.group ?? (page.role === 'member' ? '成员自助' : page.path === '/' ? '工作台' : '组织管理')
})).sort((left, right) => groupOrder.indexOf(left.group) - groupOrder.indexOf(right.group))
useEffect(() => {
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
}, [dark])
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
setSearchOpen(true)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
const nav = (
<>
<div className="flex h-19 items-center gap-3 border-b border-white/[0.07] px-5">
<BrandMark />
<div className="min-w-0">
<p className="truncate text-sm font-semibold tracking-wide text-white">
{snapshot.instance.name}
ShareServer
</p>
<p className="truncate text-[11px] uppercase tracking-[0.16em] text-cyan-300/70">
GoodBuddy 组织服务
</p>
</div>
</div>
<div className="px-3 py-4">
<button className="flex w-full items-center gap-3 rounded-xl border border-white/[0.07] bg-white/[0.035] px-3 py-2.5 text-left outline-none transition hover:bg-white/[0.06] focus-visible:ring-2 focus-visible:ring-cyan-400/70">
<span className="grid size-8 place-items-center rounded-lg bg-violet-400/12 text-sm font-semibold text-violet-200">
M
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-slate-100">
{snapshot.instance.organization}
</span>
<span className="block truncate text-[11px] text-slate-500">当前组织</span>
</span>
<ChevronsUpDown aria-hidden="true" className="size-4 text-slate-500" />
</button>
</div>
<nav aria-label={role === 'admin' ? '管理控制台' : '成员自助'} className="flex-1 overflow-y-auto px-3 pb-4">
<nav aria-label="管理控制台" className="flex-1 overflow-y-auto px-3 py-4">
<p className="mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-600">
{role === 'admin' ? '组织控制台' : '成员自助'}
控制台
</p>
<div className="space-y-1">
{navigation.map((item, index) => {
@@ -118,11 +80,6 @@ export function AppShell({
) : null}
<Icon aria-hidden="true" className="size-[18px]" />
<span>{item.title}</span>
{item.path === '/approvals' ? (
<span className="ml-auto rounded-full bg-amber-300/15 px-1.5 py-0.5 text-[10px] font-semibold text-amber-200">
6
</span>
) : null}
</>
)}
</NavLink>
@@ -131,27 +88,6 @@ export function AppShell({
})}
</div>
</nav>
<div className="border-t border-white/[0.07] p-3">
<button
onClick={() => {
const nextRole = role === 'admin' ? 'member' : 'admin'
onRoleChange(nextRole)
navigate(nextRole === 'admin' ? '/' : '/me/devices')
}}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left outline-none transition hover:bg-white/[0.05] focus-visible:ring-2 focus-visible:ring-cyan-400/70"
>
<span className="grid size-9 place-items-center rounded-full border border-violet-300/20 bg-gradient-to-br from-cyan-300/25 to-violet-400/25 text-sm font-semibold text-white">
林
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-slate-200">林江</span>
<span className="block truncate text-[11px] text-slate-500">
{role === 'admin' ? '切换到成员自助' : '切换到管理控制台'}
</span>
</span>
<ChevronsUpDown aria-hidden="true" className="size-4 text-slate-600" />
</button>
</div>
</>
)
@@ -197,22 +133,8 @@ export function AppShell({
>
<Menu className="size-5" />
</Button>
<button
className="flex h-9 min-w-0 max-w-md flex-1 items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.035] px-3 text-left text-sm text-slate-500 outline-none transition hover:border-white/[0.15] hover:bg-white/[0.055] focus-visible:ring-2 focus-visible:ring-cyan-400/70 sm:flex-initial sm:basis-[360px]"
onClick={() => setSearchOpen(true)}
>
<Search aria-hidden="true" className="size-4" />
<span className="min-w-0 flex-1 truncate">搜索设备、能力、任务或成员</span>
<span className="hidden items-center gap-1 rounded border border-white/10 px-1.5 py-0.5 text-[10px] text-slate-500 sm:flex">
<Command className="size-3" />K
</span>
</button>
<div className="ml-auto flex items-center gap-1">
<span className="mr-2 hidden items-center gap-2 text-xs text-slate-500 xl:flex">
<span className="size-1.5 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,.8)]" />
交互演示 · 非实时数据
</span>
<Button
<Button
aria-label={dark ? '切换到浅色主题' : '切换到深色主题'}
size="icon"
variant="ghost"
@@ -220,36 +142,12 @@ export function AppShell({
>
{dark ? <Sun className="size-4" /> : <MoonStar className="size-4" />}
</Button>
<Button aria-label="查看通知,6 条待处理" size="icon" variant="ghost">
<Bell className="size-4" />
<span className="absolute ml-3 mt-[-13px] size-1.5 rounded-full bg-amber-300" />
</Button>
</div>
</header>
<main className="min-h-[calc(100vh-4rem)] px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
<div className="mx-auto max-w-[1440px]">{children}</div>
</main>
</div>
<Dialog
open={searchOpen}
onOpenChange={setSearchOpen}
title="全局搜索"
description={`搜索范围:组织 ${snapshot.instance.organization}`}
width="max-w-2xl"
>
<div className="relative">
<Search className="absolute left-3 top-3 size-4 text-slate-500" />
<input
autoFocus
aria-label="搜索关键词"
className="h-11 w-full rounded-xl border border-white/10 bg-white/[0.04] pl-10 pr-3 text-sm outline-none placeholder:text-slate-600 focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/15"
placeholder="输入设备、能力、任务 ID 或成员名称…"
/>
</div>
<p className="mt-5 text-center text-sm text-slate-500">
输入关键词后,将按当前身份和组织权限显示结果。
</p>
</Dialog>
</div>
)
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import type { Device, Publication } from '../../../shared/sharing'
import { sharingRequest } from '../lib/sharing-api'
import { PageHeader } from './page-header'
import { Button } from './ui/button'
import { Dialog } from './ui/dialog'
import { StatusBadge } from './ui/status-badge'
import { PageTabs } from './page-tabs'
const panel = 'space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5'
const muted = 'text-sm leading-6 text-[var(--text-muted)]'
const control = 'mt-1 w-full min-w-0 rounded-lg border border-white/10 bg-[var(--control)] p-2 text-sm text-[var(--text-primary)] focus-visible:outline-2'
type State = { status: 'loading' } | { status: 'error'; message: string } | { status: 'loaded'; devices: Device[]; publications: Publication[] }
export function DevicesPage() {
const [params, setParams] = useSearchParams()
const [state, setState] = useState<State>({ status: 'loading' })
const [attempt, setAttempt] = useState(0)
const [activeTab, setActiveTab] = useState('list')
const selectedId = params.get('device') ?? ''
const query = params.get('q') ?? ''
const selected = state.status === 'loaded' ? state.devices.find((device) => device.id === selectedId) : undefined
useEffect(() => {
const controller = new AbortController()
Promise.all([
sharingRequest<{ devices: Device[] }>('/devices', controller.signal),
sharingRequest<{ publications: Publication[] }>('/publications', controller.signal)
]).then(([devices, publications]) => {
if (!controller.signal.aborted) setState({ status: 'loaded', devices: devices.devices, publications: publications.publications })
}).catch((error: unknown) => {
if (!controller.signal.aborted) setState({ status: 'error', message: error instanceof Error ? error.message : '网络请求失败' })
})
return () => controller.abort()
}, [attempt])
function selectDevice(id: string) {
const next = new URLSearchParams(params)
if (id) next.set('device', id)
else next.delete('device')
setParams(next)
}
return <>
<PageHeader eyebrow="本地服务目录 · 只读" title="GoodBuddy 接入" description="查看已登记的 GoodBuddy 设备及其发布的共享目录元数据。当前没有在线状态、身份认证或设备管理 API。" action={<Button variant="secondary" onClick={() => { setState({ status: 'loading' }); setAttempt((value) => value + 1) }} disabled={state.status === 'loading'}>刷新目录</Button>} />
<PageTabs title="GoodBuddy 接入" active={activeTab} onChange={setActiveTab} tabs={[{ key: 'list', label: '设备列表' }, { key: 'detail', label: '设备详情' }, { key: 'connections', label: '连接记录' }, { key: 'revocations', label: '撤销记录' }]} />
{activeTab !== 'list' && <section className={panel}><h2 className="font-semibold">{activeTab === 'detail' ? '设备详情' : activeTab === 'connections' ? '连接记录' : '撤销记录'}</h2><p className={muted}>该 Tab 的生产接口尚未接入。当前服务只提供设备登记元数据,暂不显示在线状态、连接心跳或撤销结果。</p></section>}
{state.status === 'loading' && <p role="status" className={muted}>正在加载登记设备…</p>}
{state.status === 'error' && <section className={panel}><h2 className="font-semibold">设备目录加载失败</h2><p className={`${muted} break-words`}>{state.message}</p><Button variant="secondary" onClick={() => { setState({ status: 'loading' }); setAttempt((value) => value + 1) }}>重试</Button></section>}
{state.status === 'loaded' && activeTab === 'list' && <>
<section className={panel}>
<label className="block text-sm">搜索设备<input className={control} value={query} placeholder="名称、平台、版本或设备标识" onChange={(event) => { const next = new URLSearchParams(params); if (event.target.value) next.set('q', event.target.value); else next.delete('q'); setParams(next, { replace: true }) }} /></label>
<p role="status" className={muted}>当前登记设备:{state.devices.length} 台。登记记录不代表设备在线。</p>
</section>
{!state.devices.length ? <section className={panel}><h2 className="font-semibold">尚无登记设备</h2><p className={muted}>请在 GoodBuddy 的“设备共享(技术预览)”中开启功能并连接此服务。</p></section> : <section className="grid gap-4 lg:grid-cols-2" aria-label="登记设备列表">
{state.devices.filter((device) => [device.name, device.id, device.platform, device.appVersion].join(' ').toLowerCase().includes(query.trim().toLowerCase())).map((device) => {
const count = state.publications.filter((item) => item.deviceId === device.id).length
return <article key={device.id} className={panel}>
<div className="flex flex-wrap items-start justify-between gap-3"><div><h2 className="font-semibold text-[var(--text-strong)]">{device.name}</h2><p className={muted}>{device.platform} · {device.appVersion}</p></div><StatusBadge status="已登记" tone="neutral" /></div>
<dl className="grid gap-2 text-sm sm:grid-cols-2"><div><dt className={muted}>设备标识</dt><dd className="break-all">{device.id}</dd></div><div><dt className={muted}>共享记录</dt><dd>{count} 项</dd></div><div><dt className={muted}>登记时间</dt><dd><time dateTime={device.registeredAt}>{device.registeredAt}</time></dd></div><div><dt className={muted}>最近登记</dt><dd><time dateTime={device.updatedAt}>{device.updatedAt}</time></dd></div></dl>
<div className="flex flex-wrap gap-2"><Button variant="secondary" onClick={() => selectDevice(device.id)}>查看详情</Button><Link className="inline-flex min-h-9 items-center rounded-lg px-3 text-sm underline underline-offset-4" to={`/capabilities?view=local&device=${encodeURIComponent(device.id)}`}>查看共享内容</Link></div>
</article>
})}
</section>}
{query && !state.devices.some((device) => [device.name, device.id, device.platform, device.appVersion].join(' ').toLowerCase().includes(query.trim().toLowerCase())) && <section className={panel}><h2 className="font-semibold">没有符合条件的设备</h2><p className={muted}>请调整搜索条件。</p></section>}
</>}
<Dialog open={Boolean(selected)} onOpenChange={(open) => { if (!open) selectDevice('') }} title={selected?.name ?? '设备详情'} description="以下信息来自本地登记记录,不代表在线连接或设备所有权。">
{selected && <div className="space-y-4"><dl className="grid gap-3 text-sm sm:grid-cols-2"><div><dt className={muted}>设备标识</dt><dd className="break-all">{selected.id}</dd></div><div><dt className={muted}>平台</dt><dd>{selected.platform}</dd></div><div><dt className={muted}>应用版本</dt><dd>{selected.appVersion}</dd></div><div><dt className={muted}>登记时间</dt><dd>{selected.registeredAt}</dd></div></dl><Link className="inline-flex min-h-9 items-center rounded-lg bg-cyan-400 px-3 text-sm font-medium text-slate-950" to={`/capabilities?view=local&device=${encodeURIComponent(selected.id)}`} onClick={() => selectDevice('')}>查看此设备的共享内容</Link></div>}
</Dialog>
</>
}
+13
View File
@@ -0,0 +1,13 @@
import { Link } from 'react-router-dom'
import { PageHeader } from './page-header'
export function DistributionPage() {
return <div className="min-w-0 text-[var(--text-primary)]">
<PageHeader eyebrow="当前服务器 · 待接入" title="配置下发" description="GoodBuddy 配置分发尚未接入。" />
<section className="space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5">
<h2 className="font-semibold">尚无配置下发服务</h2>
<p className="text-sm leading-6 text-[var(--text-muted)]">目前没有可用的配置草稿、设备生效回执或发布回滚记录。模型网关可独立配置与测试;保存网关模型不会自动下发到 GoodBuddy。</p>
<Link className="inline-flex min-h-10 items-center rounded-lg underline outline-offset-4" to="/model-gateway">前往模型网关</Link>
</section>
</div>
}
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useRef, useState } from 'react'
import type { GatewayAccessKey, GatewayAccessKeyCreated, GatewayAccessKeyInput, GatewayModel } from '../../../shared/gateway'
import { gatewayRequest } from '../lib/gateway-api'
import { Button } from './ui/button'
import { Dialog } from './ui/dialog'
export function GatewayAccessKeys({ models }: { models: GatewayModel[] }) {
const [keys, setKeys] = useState<GatewayAccessKey[] | null>(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(true)
const [creating, setCreating] = useState(false)
const [selected, setSelected] = useState<string[]>([])
const [created, setCreated] = useState<GatewayAccessKeyCreated | null>(null)
const [revoking, setRevoking] = useState<GatewayAccessKey | null>(null)
const active = useRef<AbortController | null>(null)
function load() {
if (active.current) return
const controller = new AbortController()
active.current = controller
return gatewayRequest<{ accessKeys: GatewayAccessKey[] }>('/access-keys', controller.signal).then(data => {
if (!controller.signal.aborted) setKeys(data.accessKeys)
}).catch((cause: unknown) => {
if (!controller.signal.aborted) setError(`访问密钥加载失败:${cause instanceof Error ? cause.message : '请稍后重试。'}`)
}).finally(() => {
if (active.current === controller) active.current = null
if (!controller.signal.aborted) setBusy(false)
})
}
async function run(operation: 'create' | 'revoke', input?: GatewayAccessKeyInput, id?: string) {
if (active.current) return
const controller = new AbortController()
active.current = controller
try {
if (operation === 'create') {
const data = await gatewayRequest<GatewayAccessKeyCreated>('/access-keys', controller.signal, 'POST', input)
if (!controller.signal.aborted) {
setKeys(previous => [...(previous ?? []), data.accessKey]); setCreated(data); setCreating(false)
}
} else {
const data = await gatewayRequest<GatewayAccessKey>(`/access-keys/${encodeURIComponent(id!)}/revoke`, controller.signal, 'POST')
if (!controller.signal.aborted) {
setKeys(previous => previous?.map(key => key.id === data.id ? data : key) ?? []); setRevoking(null)
setCreated(previous => previous?.accessKey.id === data.id ? null : previous)
}
}
} catch (cause) {
if (!controller.signal.aborted) setError(`${{ load: '访问密钥加载', create: '创建访问密钥', revoke: '撤销访问密钥' }[operation]}失败:${cause instanceof Error ? cause.message : '请稍后重试。'}`)
} finally {
if (active.current === controller) active.current = null
if (!controller.signal.aborted) setBusy(false)
}
}
function start(operation: 'load' | 'create' | 'revoke', input?: GatewayAccessKeyInput, id?: string) {
if (active.current) return
setBusy(true); setError('')
if (operation === 'load') void load()
else void run(operation, input, id)
}
useEffect(() => {
void load()
return () => { active.current?.abort(); active.current = null }
}, [])
return <section className="space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5" aria-label="访问密钥">
<div className="flex flex-wrap items-center justify-between gap-3"><h2 className="font-semibold">访问密钥</h2><div className="flex flex-wrap gap-2">
<Button variant="secondary" disabled={busy} onClick={() => start('load')}>刷新访问密钥</Button>
<Button variant="secondary" disabled={busy || keys === null || !models.length || Boolean(created)} onClick={() => { setSelected([]); setError(''); setCreating(true) }}>创建访问密钥</Button>
</div></div>
<p className="text-sm leading-6 text-[var(--text-muted)]">每个密钥只允许调用所选模型。原文仅在创建成功时显示一次,关闭后无法再次查看。撤销后使用该密钥的客户端将无法继续调用。</p>
{busy && <p role="status">正在处理访问密钥…</p>}
{error && !creating && !revoking && <p role="alert" className="break-words">{error}</p>}
{keys?.length === 0 && <p className="text-sm text-[var(--text-muted)]">暂无访问密钥。配置模型后创建。</p>}
{keys?.map(key => <article key={key.id} className="space-y-2 border-t border-white/10 pt-4 text-sm">
<h3 className="break-all font-semibold">{key.name}</h3>
<p>{key.revokedAt ? '已撤销' : '有效'} · 创建于 <time dateTime={key.createdAt}>{key.createdAt}</time></p>
<p className="break-all">授权模型:{key.modelIds.map(id => models.find(model => model.id === id)?.alias ?? '模型已不可用').join('、')}</p>
{key.revokedAt ? <p>撤销于 <time dateTime={key.revokedAt}>{key.revokedAt}</time></p> : <Button variant="secondary" disabled={busy} onClick={() => { setError(''); setRevoking(key) }}>撤销密钥 {key.name}</Button>}
</article>)}
{created && <Dialog open title="访问密钥已创建" description="请立即将密钥填入客户端 API Key。关闭后无法再次查看;遗失时请撤销并重新创建。" onOpenChange={open => { if (!open) setCreated(null) }}>
<label className="block text-sm">新访问密钥<input readOnly autoComplete="off" value={created.key} onFocus={event => event.target.select()} className="mt-2 w-full min-w-0 rounded-lg bg-[var(--control)] p-2 font-mono text-sm" /></label>
<Button variant="secondary" onClick={() => setCreated(null)}>已保存,关闭密钥</Button>
</Dialog>}
{creating && <Dialog open title="创建访问密钥" description="为客户端命名,并选择允许调用的模型。授权绑定模型记录,修改别名不会丢失授权。" onOpenChange={open => { if (!open && !busy) { setCreating(false); setError('') } }}>
<form className="space-y-4" onSubmit={event => {
event.preventDefault()
const form = new FormData(event.currentTarget)
start('create', { name: String(form.get('name')).trim(), modelIds: selected })
}}>
<fieldset disabled={busy} className="space-y-4">
<label className="block text-sm">密钥名称<input name="name" required maxLength={200} className="mt-1 w-full rounded-lg border border-white/10 bg-[var(--control)] p-2" /></label>
<fieldset className="space-y-2"><legend className="mb-2 text-sm">授权模型(至少选择一个)</legend>{models.map(model => <label key={model.id} className="flex items-start gap-2 break-all text-sm"><input type="checkbox" checked={selected.includes(model.id)} onChange={event => setSelected(previous => event.target.checked ? [...previous, model.id] : previous.filter(id => id !== model.id))} />{model.alias}{model.displayName ? ` · ${model.displayName}` : ''}{!model.enabled ? '(已停用)' : ''}</label>)}</fieldset>
</fieldset>
{error && <p role="alert" className="break-words">{error}</p>}
<div className="flex flex-wrap justify-end gap-2"><Button variant="ghost" disabled={busy} onClick={() => { setCreating(false); setError('') }}>取消</Button><Button type="submit" disabled={busy || !selected.length}>{busy ? '正在创建…' : '确认创建'}</Button></div>
</form>
</Dialog>}
{revoking && <Dialog open title="撤销访问密钥" description={`撤销「${revoking.name}」后,使用它的客户端将无法调用任何模型。此操作无法恢复。`} onOpenChange={open => { if (!open && !busy) { setRevoking(null); setError('') } }}>
{error && <p role="alert" className="break-words">{error}</p>}
<div className="flex flex-wrap justify-end gap-2"><Button variant="ghost" disabled={busy} onClick={() => { setRevoking(null); setError('') }}>取消</Button><Button disabled={busy} onClick={() => start('revoke', undefined, revoking.id)}>{busy ? '正在撤销…' : '确认撤销'}</Button></div>
</Dialog>}
</section>
}
+294
View File
@@ -0,0 +1,294 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GatewayAccessKey, GatewaySnapshot, GatewayUpstream } from '../../../shared/gateway'
import { GatewayPage } from './gateway-page'
const upstream: GatewayUpstream = { id: 'up-1', name: 'Local provider', baseUrl: 'https://provider.example/v1', protocol: 'openai-chat-completions', authentication: 'api-key', enabled: true, credentialConfigured: true }
const model = { id: 'model-stable-id', alias: 'local-chat', remoteModel: 'remote-chat', upstreamId: upstream.id, enabled: true }
const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
const failure = () => response({ error: { code: 'CREDENTIAL_UNAVAILABLE', message: 'secret-should-not-echo' }, requestId: 'secret-should-not-echo' }, 503)
describe('real gateway UI', () => {
let snapshot: GatewaySnapshot
let keys: GatewayAccessKey[]
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
snapshot = { upstreams: [], models: [], audits: [], gatewayBaseUrl: null }
keys = []
fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url === '/api/v1/gateway/config') return response(snapshot)
const body = init?.body ? JSON.parse(String(init.body)) : null
if (url === '/api/v1/gateway/access-keys') {
if (init?.method === 'POST') {
const accessKey = { ...body, id: 'key-1', createdAt: '2026-10-01T00:00:00.000Z', revokedAt: null }
keys.push(accessKey)
return response({ accessKey, key: 'ss_only-once-secret' })
}
return response({ accessKeys: keys })
}
if (url === '/api/v1/gateway/access-keys/key-1/revoke') {
keys = keys.map(key => ({ ...key, revokedAt: '2026-10-02T00:00:00.000Z' }))
return response(keys[0])
}
if (url === '/api/v1/gateway/upstreams/up-1/discover') return response({ models: ['discovered-chat'] })
if (url === '/api/v1/gateway/upstreams' || url === '/api/v1/gateway/upstreams/up-1') {
const { apiKey, credentialAction, ...fields } = body
const saved = { ...upstream, ...fields, credentialConfigured: credentialAction === 'clear' ? false : Boolean(apiKey) || (snapshot.upstreams[0]?.credentialConfigured ?? false) }
snapshot = { ...snapshot, upstreams: [saved] }
return response(saved)
}
if (url === '/api/v1/gateway/models' || url === `/api/v1/gateway/models/${model.id}`) {
const saved = { ...model, ...body }
snapshot = { ...snapshot, models: [saved] }
return response(saved)
}
throw new Error(`Unexpected request: ${init?.method} ${url}`)
})
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks() })
it('loads empty real data and asks for a configured client URL without inventing an origin', async () => {
render(<GatewayPage />)
await screen.findByText(/尚无上游服务/)
await screen.findByText(/暂无访问密钥/)
expect(screen.getByText(/尚无模型映射/)).toBeInTheDocument()
expect(screen.getByText(/SHARESERVER_GATEWAY_BASE_URL/)).toBeInTheDocument()
expect(screen.queryByText(/127\.0\.0\.1:8787|localhost:5174/)).not.toBeInTheDocument()
expect(screen.queryByLabelText(/环境变量/)).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '发送真实请求' })).toBeDisabled()
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual(['/api/v1/gateway/config', '/api/v1/gateway/access-keys'])
})
it('shows the configured baseURL and client protocol, alias and key instructions', async () => {
snapshot = { ...snapshot, upstreams: [upstream], models: [{ ...model, displayName: '聊天模型' }], gatewayBaseUrl: 'https://gateway.example/custom/v1' }
render(<GatewayPage />)
const settings = await screen.findByRole('region', { name: '客户端设置' })
expect(within(settings).getByText(snapshot.gatewayBaseUrl!)).toBeInTheDocument()
expect(settings).toHaveTextContent('openai-chat-completions')
expect(settings).toHaveTextContent(`模型 ID:${model.alias}`)
expect(settings).toHaveTextContent('API Key 使用新建的访问密钥')
})
it('retries load failures with useful Chinese errors without reflecting arbitrary server content', async () => {
fetchMock.mockResolvedValueOnce(failure())
render(<GatewayPage />)
expect(await screen.findByRole('alert')).toHaveTextContent('HTTP 503 · 服务器无法加密或读取凭据')
expect(document.body).not.toHaveTextContent('secret-should-not-echo')
fireEvent.click(screen.getByRole('button', { name: '重试加载' }))
await screen.findByText(/尚无上游服务/)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('preserves the last snapshot after refresh failure', async () => {
snapshot.upstreams = [upstream]
render(<GatewayPage />)
await screen.findByText(/暂无访问密钥/)
fetchMock.mockResolvedValueOnce(failure())
fireEvent.click(screen.getByRole('button', { name: '刷新配置与审计' }))
expect(await screen.findByRole('alert')).toHaveTextContent('当前显示上次读取的数据')
expect(screen.getByRole('heading', { name: upstream.name })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '重试加载' }))
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
})
it('creates with a secret, keeps blank edits, and toggles without secrets', async () => {
const storage = vi.spyOn(Storage.prototype, 'setItem')
render(<GatewayPage />)
await screen.findByText(/暂无访问密钥/)
fireEvent.click(screen.getByRole('button', { name: '添加上游' }))
fireEvent.change(screen.getByLabelText('上游名称'), { target: { value: upstream.name } })
fireEvent.change(screen.getByLabelText('上游 Base URL'), { target: { value: upstream.baseUrl } })
expect(screen.getByLabelText('上游 API Key')).toHaveAttribute('type', 'password')
fireEvent.change(screen.getByLabelText('上游 API Key'), { target: { value: 'private-upstream-key' } })
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await screen.findByRole('heading', { name: upstream.name })
const input = { name: upstream.name, baseUrl: upstream.baseUrl, protocol: upstream.protocol, authentication: 'api-key', credentialAction: 'replace', apiKey: 'private-upstream-key', enabled: true }
expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/upstreams', expect.objectContaining({ method: 'POST', body: JSON.stringify(input) }))
expect(screen.queryByDisplayValue('private-upstream-key')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: `编辑上游 ${upstream.name}` }))
expect(screen.getByLabelText('上游 API Key')).toHaveValue('')
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
const keep = { name: upstream.name, baseUrl: upstream.baseUrl, protocol: upstream.protocol, authentication: 'api-key', credentialAction: 'keep', enabled: true }
expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/upstreams/up-1', expect.objectContaining({ body: JSON.stringify(keep) }))
fireEvent.click(screen.getByRole('button', { name: `停用上游 ${upstream.name}` }))
await screen.findByRole('button', { name: `启用上游 ${upstream.name}` })
expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/upstreams/up-1', expect.objectContaining({ body: JSON.stringify({ ...keep, enabled: false }) }))
expect(storage).not.toHaveBeenCalled()
})
it('clears submitted secrets on errors and permits replacement retry', async () => {
snapshot.upstreams = [upstream]
render(<GatewayPage />)
await screen.findByText(/暂无访问密钥/)
fireEvent.click(screen.getByRole('button', { name: `编辑上游 ${upstream.name}` }))
fireEvent.change(screen.getByLabelText('上游 API Key'), { target: { value: 'secret-should-not-echo' } })
fetchMock.mockResolvedValueOnce(failure())
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
expect(await screen.findByRole('alert')).toHaveTextContent('保存配置失败')
expect(screen.getByLabelText('上游 API Key')).toHaveValue('')
expect(screen.getByLabelText('上游名称')).toHaveValue(upstream.name)
expect(document.body).not.toHaveTextContent('secret-should-not-echo')
fireEvent.change(screen.getByLabelText('上游 API Key'), { target: { value: 'replacement-secret' } })
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: `编辑上游 ${upstream.name}` }))
expect(screen.getByLabelText('上游 API Key')).toHaveValue('')
})
it('allows unauthenticated upstream discovery and testing without configured credentials', async () => {
snapshot.upstreams = [{ ...upstream, authentication: 'none', credentialConfigured: false }]
snapshot.models = [model]
render(<GatewayPage />)
await screen.findByText(/暂无访问密钥/)
expect(screen.getByRole('button', { name: '发送真实请求' })).toBeEnabled()
expect(screen.getByLabelText('测试模型')).toHaveValue(model.alias)
fireEvent.click(screen.getByRole('button', { name: `编辑上游 ${upstream.name}` }))
expect(screen.getByLabelText('认证方式')).toHaveValue('none')
expect(screen.queryByRole('option', { name: '设置或替换凭据' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '取消' }))
fireEvent.click(screen.getByRole('button', { name: '发现模型 Local provider' }))
expect(await screen.findByRole('button', { name: '添加映射 discovered-chat' })).toBeEnabled()
})
it('discovers then selects a model and preserves alias and display name through edits and toggle', async () => {
snapshot.upstreams = [upstream]
render(<GatewayPage />)
fireEvent.click(await screen.findByRole('button', { name: '发现模型 Local provider' }))
fireEvent.click(await screen.findByRole('button', { name: '添加映射 discovered-chat' }))
expect(screen.getByLabelText('远端模型名称')).toHaveValue('discovered-chat')
fireEvent.change(screen.getByLabelText('模型别名'), { target: { value: 'discovered-alias' } })
fireEvent.change(screen.getByLabelText('显示名称(可选)'), { target: { value: '发现的模型' } })
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await screen.findByRole('heading', { name: 'discovered-alias → discovered-chat' })
const input = { alias: 'discovered-alias', upstreamId: upstream.id, remoteModel: 'discovered-chat', displayName: '发现的模型', enabled: true }
expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/models', expect.objectContaining({ method: 'POST', body: JSON.stringify(input) }))
fireEvent.click(screen.getByRole('button', { name: '编辑映射 discovered-alias' }))
expect(screen.getByLabelText('显示名称(可选)')).toHaveValue('发现的模型')
fireEvent.change(screen.getByLabelText('模型别名'), { target: { value: 'edited-alias' } })
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await screen.findByRole('heading', { name: 'edited-alias → discovered-chat' })
fireEvent.click(screen.getByRole('button', { name: '停用映射 edited-alias' }))
await screen.findByRole('button', { name: '启用映射 edited-alias' })
expect(fetchMock).toHaveBeenCalledWith(`/api/v1/gateway/models/${model.id}`, expect.objectContaining({ method: 'PUT', body: JSON.stringify({ ...input, alias: 'edited-alias', enabled: false }) }))
})
it('allows manual mappings when discovery is unavailable', async () => {
snapshot.upstreams = [{ ...upstream, credentialConfigured: false }]
render(<GatewayPage />)
await screen.findByText(/暂无访问密钥/)
expect(screen.getByRole('button', { name: '发现模型 Local provider' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '手动添加映射' }))
const alias = screen.getByLabelText('模型别名')
for (const value of ['spaces not allowed', '_invalid', '中文别名']) {
fireEvent.change(alias, { target: { value } })
expect(alias).toBeInvalid()
}
fireEvent.change(alias, { target: { value: 'provider/model:1.0_test-chat' } })
expect(alias).toBeValid()
fireEvent.change(screen.getByLabelText('模型别名'), { target: { value: 'manual-alias' } })
fireEvent.change(screen.getByLabelText('远端模型名称'), { target: { value: 'manual-remote' } })
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await screen.findByRole('heading', { name: 'manual-alias → manual-remote' })
expect(screen.getByRole('button', { name: '发送真实请求' })).toBeEnabled()
})
it('creates scoped keys by stable model IDs, dismisses raw secrets permanently, and revokes with retry', async () => {
const storage = vi.spyOn(Storage.prototype, 'setItem')
snapshot = { ...snapshot, upstreams: [upstream], models: [model] }
const view = render(<GatewayPage />)
await screen.findByText(/暂无访问密钥/)
fireEvent.click(screen.getByRole('button', { name: '创建访问密钥' }))
expect(screen.getByRole('button', { name: '确认创建' })).toBeDisabled()
fireEvent.change(screen.getByLabelText('密钥名称'), { target: { value: 'Desktop' } })
fireEvent.click(screen.getByRole('checkbox', { name: model.alias }))
fetchMock.mockResolvedValueOnce(failure())
fireEvent.click(screen.getByRole('button', { name: '确认创建' }))
expect(await screen.findByRole('alert')).toHaveTextContent('创建访问密钥失败')
expect(screen.getByLabelText('密钥名称')).toHaveValue('Desktop')
expect(screen.getByRole('checkbox', { name: model.alias })).toBeChecked()
fireEvent.click(screen.getByRole('button', { name: '确认创建' }))
expect(await screen.findByLabelText('新访问密钥')).toHaveValue('ss_only-once-secret')
expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/access-keys', expect.objectContaining({ method: 'POST', body: JSON.stringify({ name: 'Desktop', modelIds: [model.id] }) }))
fireEvent.click(screen.getByRole('button', { name: '已保存,关闭密钥' }))
expect(screen.queryByDisplayValue('ss_only-once-secret')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '刷新访问密钥' }))
await waitFor(() => expect(screen.getByRole('button', { name: '刷新访问密钥' })).toBeEnabled())
expect(screen.queryByLabelText('新访问密钥')).not.toBeInTheDocument()
view.unmount()
snapshot.models = [{ ...model, alias: 'renamed-alias' }]
render(<GatewayPage />)
await screen.findByRole('heading', { name: 'Desktop' })
expect(screen.getByText('授权模型:renamed-alias')).toBeInTheDocument()
expect(screen.queryByDisplayValue('ss_only-once-secret')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '撤销密钥 Desktop' }))
expect(screen.getByRole('dialog')).toHaveTextContent('此操作无法恢复')
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(fetchMock.mock.calls.some(([url]) => url.endsWith('/revoke'))).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '撤销密钥 Desktop' }))
fetchMock.mockResolvedValueOnce(failure())
fireEvent.click(screen.getByRole('button', { name: '确认撤销' }))
expect(await screen.findByRole('alert')).toHaveTextContent('撤销访问密钥失败')
fireEvent.click(screen.getByRole('button', { name: '确认撤销' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(screen.queryByRole('button', { name: '撤销密钥 Desktop' })).not.toBeInTheDocument()
expect(screen.getByText(/已撤销/)).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/access-keys/key-1/revoke', expect.objectContaining({ method: 'POST' }))
expect(storage).not.toHaveBeenCalled()
})
it('keeps key list failures distinct from an empty list and supports retry', async () => {
fetchMock.mockImplementationOnce(async () => response(snapshot)).mockResolvedValueOnce(failure())
render(<GatewayPage />)
expect(await screen.findByRole('alert')).toHaveTextContent('访问密钥加载失败')
expect(screen.queryByText(/暂无访问密钥/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '刷新访问密钥' }))
await screen.findByText(/暂无访问密钥/)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('tests via POST /test nonstreaming and forgets prompt and response after unmount', async () => {
snapshot = { ...snapshot, upstreams: [upstream], models: [model] }
const view = render(<GatewayPage />)
await screen.findByText(/暂无访问密钥/)
fireEvent.change(screen.getByLabelText('测试模型'), { target: { value: model.alias } })
fireEvent.change(screen.getByLabelText('测试提示词'), { target: { value: 'A private prompt' } })
fetchMock.mockImplementationOnce(async () => {
snapshot.audits = [{ id: 'audit-1', time: '2026-10-01T00:00:00Z', model: model.alias, upstreamId: upstream.id, accessKeyId: null, stream: false, status: 'success', httpStatus: 200, durationMs: 128, inputTokens: null, outputTokens: 0, errorCode: null }]
return response({ choices: [{ message: { content: 'Actual response text' } }] })
})
fireEvent.click(screen.getByRole('button', { name: '发送真实请求' }))
await screen.findByText('Actual response text')
expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/test', expect.objectContaining({ method: 'POST', body: JSON.stringify({ model: model.alias, messages: [{ role: 'user', content: 'A private prompt' }], stream: false }) }))
const table = await screen.findByRole('table')
expect(within(table).getByText('未知')).toBeInTheDocument()
expect(within(table).getByText('0')).toBeInTheDocument()
expect(table).not.toHaveTextContent('A private prompt')
view.unmount()
render(<GatewayPage />)
await screen.findByText(/暂无访问密钥/)
expect(screen.getByLabelText('测试提示词')).toHaveValue('')
expect(screen.queryByText('Actual response text')).not.toBeInTheDocument()
})
it('cancels requests, ignores late responses, and aborts on unmount', async () => {
snapshot = { ...snapshot, upstreams: [upstream], models: [model] }
const view = render(<GatewayPage />)
await screen.findByText(/暂无访问密钥/)
fireEvent.change(screen.getByLabelText('测试模型'), { target: { value: model.alias } })
fireEvent.change(screen.getByLabelText('测试提示词'), { target: { value: 'Cancel me' } })
let signal: AbortSignal | undefined
let resolve!: (value: Response) => void
fetchMock.mockImplementationOnce((_url: string, init: RequestInit) => { signal = init.signal!; return new Promise<Response>((done) => { resolve = done }) })
fireEvent.click(screen.getByRole('button', { name: '发送真实请求' }))
fireEvent.click(screen.getByRole('button', { name: '取消测试' }))
expect(signal?.aborted).toBe(true)
await act(async () => resolve(response({ choices: [{ message: { content: 'Late response' } }] })))
expect(screen.queryByText('Late response')).not.toBeInTheDocument()
fetchMock.mockImplementationOnce((_url: string, init: RequestInit) => { signal = init.signal!; return new Promise(() => {}) })
fireEvent.click(screen.getByRole('button', { name: '发送真实请求' }))
view.unmount()
expect(signal?.aborted).toBe(true)
})
})
+191
View File
@@ -0,0 +1,191 @@
import { useEffect, useRef, useState } from 'react'
import type { GatewayModel, GatewayModelInput, GatewaySnapshot, GatewayUpstream, GatewayUpstreamInput } from '../../../shared/gateway'
import { PageHeader } from './page-header'
import { Button } from './ui/button'
import { Dialog } from './ui/dialog'
import { StatusBadge } from './ui/status-badge'
import { gatewayRequest as request } from '../lib/gateway-api'
import { GatewayAccessKeys } from './gateway-access-keys'
import { PageTabs } from './page-tabs'
const panel = 'space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5'
const muted = 'text-sm leading-6 text-[var(--text-muted)]'
const control = 'mt-1 w-full min-w-0 rounded-lg border border-white/10 bg-[var(--control)] p-2 text-sm text-[var(--text-primary)] focus-visible:outline-2 focus-visible:outline-current'
type Editor = { kind: 'upstream'; value?: GatewayUpstream } | { kind: 'model'; value?: GatewayModel; upstreamId?: string; remoteModel?: string }
export function GatewayPage() {
const [snapshot, setSnapshot] = useState<GatewaySnapshot | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState('')
const [actionError, setActionError] = useState('')
const [pending, setPending] = useState('')
const [message, setMessage] = useState('')
const [editor, setEditor] = useState<Editor | null>(null)
const [authentication, setAuthentication] = useState<GatewayUpstreamInput['authentication']>('api-key')
const [discovery, setDiscovery] = useState<{ upstreamId: string; models: string[] } | null>(null)
const [testing, setTesting] = useState(false)
const [testMessage, setTestMessage] = useState('')
const [testError, setTestError] = useState('')
const [result, setResult] = useState('')
const [testAlias, setTestAlias] = useState('')
const [activeTab, setActiveTab] = useState('overview')
const requests = useRef(new Set<AbortController>())
const testRequest = useRef<AbortController | null>(null)
const loadRequest = useRef<AbortController | null>(null)
const actionRequest = useRef<AbortController | null>(null)
function loadConfig() {
loadRequest.current?.abort()
const controller = new AbortController()
loadRequest.current = controller
requests.current.add(controller)
return request<GatewaySnapshot>('/config', controller.signal).then((data) => {
if (!controller.signal.aborted) { setSnapshot(data); setLoadError('') }
}).catch((error: unknown) => {
if (!controller.signal.aborted) setLoadError(`网关配置加载失败:${error instanceof Error ? error.message : '网络请求失败'}`)
}).finally(() => {
requests.current.delete(controller)
if (!controller.signal.aborted) setLoading(false)
})
}
function refresh() {
setLoading(true)
return loadConfig()
}
useEffect(() => {
void loadConfig()
const active = requests.current
return () => { active.forEach((controller) => controller.abort()) }
}, [])
async function act(label: string, path: string, method: string, body?: unknown, discoverId?: string) {
if (actionRequest.current) return
const controller = new AbortController()
actionRequest.current = controller
requests.current.add(controller)
setPending(label); setActionError(''); setMessage('')
try {
const data = await request<GatewaySnapshot | { models: string[] }>(path, controller.signal, method, body)
if (controller.signal.aborted) return
if (discoverId) setDiscovery({ upstreamId: discoverId, models: (data as { models: string[] }).models })
else {
setEditor(null)
await refresh()
}
setMessage(`${label}完成。${discoverId ? '请选择模型添加映射,也可手动填写。' : '配置已保存到服务器。'}`)
} catch (error) {
if (!controller.signal.aborted) setActionError(`${label}失败:${error instanceof Error ? error.message : '网络请求失败'}`)
} finally {
requests.current.delete(controller)
actionRequest.current = null
if (!controller.signal.aborted) setPending('')
}
}
async function test(alias: string, prompt: string) {
if (testRequest.current) return
const controller = new AbortController()
testRequest.current = controller
requests.current.add(controller)
setTesting(true); setResult(''); setTestError(''); setTestMessage('正在请求上游…')
try {
const data = await request<{ choices?: { message?: { content?: string | null } }[] }>('/test', controller.signal, 'POST', { model: alias, messages: [{ role: 'user', content: prompt }], stream: false })
if (!controller.signal.aborted) {
setResult(data.choices?.[0]?.message?.content ?? '上游未返回文本内容。')
setTestMessage('请求完成。')
}
} catch (error) {
if (!controller.signal.aborted) setTestError(`测试失败:${error instanceof Error ? error.message : '网络请求失败'}`)
if (!controller.signal.aborted) setTestMessage('')
} finally {
requests.current.delete(controller)
if (testRequest.current === controller) testRequest.current = null
if (!controller.signal.aborted) { setTesting(false); void refresh() }
}
}
const busy = loading || Boolean(pending)
const available = snapshot?.models.filter((model) => model.enabled && snapshot.upstreams.some((upstream) => upstream.id === model.upstreamId && upstream.enabled)) ?? []
return <div className="min-w-0 space-y-5 text-[var(--text-primary)]">
<PageHeader eyebrow="当前服务器 · 本地技术预览" title="模型网关" description="真实保存上游与模型别名,向上游转发请求并查看调用元数据。ShareServer 不运行模型。" action={<Button disabled={busy} onClick={() => { setActionError(''); setAuthentication('api-key'); setEditor({ kind: 'upstream' }) }}>添加上游</Button>} />
<aside className={panel} aria-label="网关使用说明">
<p className={muted}>管理操作仅限服务器本机。客户端通过访问密钥调用获授权的模型;尚无组织认证或配额控制。</p>
<p className={muted}>先添加上游,再发现或手动添加模型,完成测试后创建客户端访问密钥。上游 API Key 加密保存在服务器,编辑时不会返回原文。</p>
<Button variant="secondary" disabled={busy} onClick={() => void refresh()}>{loading ? '正在刷新…' : '刷新配置与审计'}</Button>
</aside>
{loading && <p role="status">正在加载网关配置…</p>}
{loadError && <div className={panel}><p role="alert" className="break-words">{loadError}{snapshot ? '。当前显示上次读取的数据。' : ''}</p><Button variant="secondary" disabled={busy} onClick={() => void refresh()}>重试加载</Button></div>}
{actionError && !editor && <p role="alert" className="break-words">{actionError}</p>}
{pending && <p role="status">{pending}中…</p>}
{message && <p role="status">{message}</p>}
{snapshot && <>
<PageTabs title="模型网关" active={activeTab} onChange={setActiveTab} tabs={[{ key: 'overview', label: '概览' }, { key: 'upstreams', label: '上游服务' }, { key: 'models', label: '模型目录' }, { key: 'keys', label: '访问密钥' }, { key: 'test', label: '请求测试' }, { key: 'audits', label: '调用审计' }, { key: 'client', label: '客户端接入' }]} />
<section className={`${panel} ${activeTab === 'overview' ? '' : 'hidden'}`} aria-label="网关概览"><h2 className="font-semibold">网关概览</h2><div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"><div className="rounded-xl border border-white/10 bg-white/[0.025] p-4"><p className={muted}>上游服务</p><p className="mt-1 text-2xl font-semibold">{snapshot.upstreams.length}</p></div><div className="rounded-xl border border-white/10 bg-white/[0.025] p-4"><p className={muted}>模型映射</p><p className="mt-1 text-2xl font-semibold">{snapshot.models.length}</p></div><div className="rounded-xl border border-white/10 bg-white/[0.025] p-4"><p className={muted}>调用记录</p><p className="mt-1 text-2xl font-semibold">{snapshot.audits.length}</p></div><div className="rounded-xl border border-white/10 bg-white/[0.025] p-4"><p className={muted}>客户端地址</p><p className="mt-1 break-all text-sm">{snapshot.gatewayBaseUrl ?? '未配置'}</p></div></div><p className={muted}>客户端通过访问密钥调用已授权模型;上游凭据只保存在服务器,网关不执行模型返回的工具调用。</p></section>
<section className={`${panel} ${activeTab === 'upstreams' ? '' : 'hidden'}`} aria-label="上游服务"><h2 className="font-semibold">上游服务</h2>
{!snapshot.upstreams.length && <p className={muted}>尚无上游服务。添加上游后,再配置模型映射。</p>}
{snapshot.upstreams.map((upstream) => <article key={upstream.id} className="space-y-3 border-t border-white/10 pt-4">
<div className="flex flex-wrap items-center gap-3"><h3 className="break-all font-semibold">{upstream.name}</h3><StatusBadge status={upstream.enabled ? '已启用' : '已停用'} tone={upstream.enabled ? 'online' : 'offline'} /></div>
<p className={`${muted} break-all`}>{upstream.baseUrl}</p><p className={`${muted} break-all`}>OpenAI Chat Completions · {upstream.authentication === 'none' ? '无需认证' : upstream.credentialConfigured ? '凭据已配置' : '凭据未配置'}(不代表连接已验证)</p>
<div className="flex flex-wrap gap-2"><Button variant="secondary" disabled={busy} onClick={() => { setActionError(''); setAuthentication(upstream.authentication); setEditor({ kind: 'upstream', value: upstream }) }}>编辑上游 {upstream.name}</Button><Button variant="secondary" disabled={busy} onClick={() => void act('更新上游', `/upstreams/${encodeURIComponent(upstream.id)}`, 'PUT', { name: upstream.name, baseUrl: upstream.baseUrl, protocol: upstream.protocol, authentication: upstream.authentication, credentialAction: 'keep', enabled: !upstream.enabled } satisfies GatewayUpstreamInput)}>{upstream.enabled ? '停用' : '启用'}上游 {upstream.name}</Button><Button variant="secondary" disabled={busy || !upstream.enabled || (upstream.authentication === 'api-key' && !upstream.credentialConfigured)} onClick={() => { setDiscovery(null); void act('发现模型', `/upstreams/${encodeURIComponent(upstream.id)}/discover`, 'POST', undefined, upstream.id) }}>发现模型 {upstream.name}</Button></div>
</article>)}
{discovery && <div className="min-w-0 space-y-3"><h3 className="break-all font-semibold">发现结果 · {snapshot.upstreams.find((item) => item.id === discovery.upstreamId)?.name}</h3>{!discovery.models.length && <p className={muted}>上游未返回模型,可手动添加映射。</p>}<div className="flex flex-wrap gap-2">{discovery.models.map((remoteModel) => <Button key={remoteModel} className="h-auto min-w-0 max-w-full whitespace-normal break-all py-2 text-left" variant="secondary" disabled={busy} onClick={() => { setActionError(''); setEditor({ kind: 'model', upstreamId: discovery.upstreamId, remoteModel }) }}>添加映射 {remoteModel}</Button>)}</div></div>}
</section>
<section className={`${panel} ${activeTab === 'models' ? '' : 'hidden'}`} aria-label="模型映射"><div className="flex flex-wrap items-center justify-between gap-3"><h2 className="font-semibold">模型映射</h2><Button variant="secondary" disabled={busy || !snapshot.upstreams.length} onClick={() => { setActionError(''); setEditor({ kind: 'model' }) }}>手动添加映射</Button></div>
{!snapshot.models.length && <p className={muted}>尚无模型映射。先添加上游,再发现模型或手动填写远端模型名称与别名。</p>}
{snapshot.models.map((model) => <article key={model.id} className="space-y-3 border-t border-white/10 pt-4"><h3 className="break-all font-semibold">{model.alias} → {model.remoteModel}</h3>{model.displayName && <p className="break-all">{model.displayName}</p>}<p className={muted}>上游:{snapshot.upstreams.find((item) => item.id === model.upstreamId)?.name ?? model.upstreamId} · {model.enabled ? '映射已启用' : '映射已停用'}</p><div className="flex flex-wrap gap-2"><Button variant="secondary" disabled={busy} onClick={() => { setActionError(''); setEditor({ kind: 'model', value: model }) }}>编辑映射 {model.alias}</Button><Button variant="secondary" disabled={busy} onClick={() => void act('更新映射', `/models/${encodeURIComponent(model.id)}`, 'PUT', { alias: model.alias, upstreamId: model.upstreamId, remoteModel: model.remoteModel, ...(model.displayName ? { displayName: model.displayName } : {}), enabled: !model.enabled } satisfies GatewayModelInput)}>{model.enabled ? '停用' : '启用'}映射 {model.alias}</Button></div></article>)}
</section>
<div className={activeTab === 'keys' ? '' : 'hidden'}><GatewayAccessKeys models={snapshot.models} /></div>
<section className={`${panel} ${activeTab === 'client' ? '' : 'hidden'}`} aria-label="客户端设置"><h2 className="font-semibold">客户端设置</h2>
<p className={muted}>在 GoodBuddy 或兼容客户端中添加服务:协议选择 OpenAI Chat Completions(openai-chat-completions)。</p>
{snapshot.gatewayBaseUrl ? <p className="break-all text-sm">baseURL:<code>{snapshot.gatewayBaseUrl}</code></p> : <p className={muted}>尚未配置客户端 baseURL。请管理员设置 SHARESERVER_GATEWAY_BASE_URL 为客户端可达的完整网关 API 基础地址并重启服务。</p>}
<p className={muted}>模型 ID 填写下方模型别名,不填远端模型名称或显示名称。API Key 使用新建的访问密钥,并确保它已授权该模型。</p>
<ul className="space-y-2 text-sm">{snapshot.models.map(model => <li key={model.id} className="break-all">模型 ID:<code>{model.alias}</code>{model.displayName ? ` · ${model.displayName}` : ''}</li>)}</ul>
</section>
<section className={`${panel} ${activeTab === 'test' ? '' : 'hidden'}`} aria-label="真实请求测试"><h2 className="font-semibold">真实请求测试</h2><p id="gateway-test-help" className={muted}>选择模型并发送一条真实请求。提示词和结果仅保留在当前页面,失败后可以直接修改并重试。</p>
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); const form = new FormData(event.currentTarget); void test(String(form.get('alias')), String(form.get('prompt'))) }}>
<fieldset disabled={testing || busy || !available.length} className="space-y-4"><label className="block text-sm">测试模型<select name="alias" required value={testAlias || (available.length === 1 ? available[0].alias : '')} onChange={event => setTestAlias(event.target.value)} className={control}><option value="">请选择模型别名</option>{available.map((model) => <option key={model.id} value={model.alias}>{model.alias}</option>)}</select></label><label className="block text-sm">测试提示词<textarea name="prompt" required rows={3} aria-describedby="gateway-test-help" className={control} /></label><Button type="submit" variant="secondary">{testing ? '请求中…' : '发送真实请求'}</Button></fieldset>
{!available.length && <p className={muted}>请先启用模型与上游。</p>}
{testing && <Button variant="secondary" onClick={() => { testRequest.current?.abort(); testRequest.current = null; setTesting(false); setTestMessage('已取消测试;服务器审计可能稍后更新,请刷新查看。') }}>取消测试</Button>}
</form>
{testMessage && <p role="status">{testMessage}</p>}{testError && <p role="alert" className="break-words">{testError}</p>}{result && <div><h3 className="font-semibold">测试结果</h3><pre className="mt-2 whitespace-pre-wrap break-words text-sm">{result}</pre><Button variant="ghost" onClick={() => setResult('')}>清除结果</Button></div>}
</section>
<section className={`${panel} ${activeTab === 'audits' ? '' : 'hidden'}`} aria-label="调用审计"><h2 className="font-semibold">调用审计</h2><p className={muted}>服务器返回的调用元数据;不含提示词或响应正文。用量未返回时显示「未知」,不按零计算。</p>
{!snapshot.audits.length ? <p className={muted}>暂无调用审计。完成真实请求后刷新查看。</p> : <div className="max-w-full overflow-x-auto" tabIndex={0} role="region" aria-label="调用审计表格"><table className="w-full min-w-[900px] text-left text-sm"><caption className="sr-only">真实网关调用元数据</caption><thead><tr>{['时间 / 编号', '模型 / 上游', '方式', '结果 / HTTP', '耗时 ms', '输入 Token', '输出 Token', '错误码', '访问密钥 ID'].map((heading, index) => <th key={heading} scope="col" className={`p-2 ${index >= 4 && index <= 6 ? 'text-right' : ''}`}>{heading}</th>)}</tr></thead><tbody>{snapshot.audits.map((audit) => <tr key={audit.id} className="border-t border-white/10"><td className="p-2"><time dateTime={audit.time}>{audit.time}</time><div>{audit.id}</div></td><td className="break-all p-2">{audit.model}<div>{audit.upstreamId ?? '未知'}</div></td><td className="p-2">{audit.stream ? '流式' : '非流式'}</td><td className="p-2">{{ success: '成功', failed: '失败', cancelled: '已取消' }[audit.status]} / {audit.httpStatus}</td><td className="p-2 text-right">{audit.durationMs}</td><td className="p-2 text-right">{audit.inputTokens ?? '未知'}</td><td className="p-2 text-right">{audit.outputTokens ?? '未知'}</td><td className="p-2">{audit.errorCode ?? '无'}</td><td className="break-all p-2">{audit.accessKeyId ?? '未关联密钥'}</td></tr>)}</tbody></table></div>}
</section>
</>}
{editor && <Dialog open title={`${editor.value ? '编辑' : '添加'}${editor.kind === 'upstream' ? '上游服务' : '模型映射'}`} description="保存到当前服务器。失败后保留普通字段;密钥提交后清空,重试时需重新输入。" onOpenChange={(open) => { if (!open && !pending) { setEditor(null); setActionError('') } }}>
<form className="space-y-4" aria-describedby={actionError ? 'gateway-editor-error' : undefined} onSubmit={(event) => {
event.preventDefault()
const form = new FormData(event.currentTarget)
const text = (key: string) => String(form.get(key) ?? '').trim()
const apiKey = text('apiKey')
const body = editor.kind === 'upstream' ? { name: text('name'), baseUrl: text('baseUrl'), protocol: 'openai-chat-completions', authentication, credentialAction: apiKey ? 'replace' : 'keep', ...(apiKey ? { apiKey } : {}), enabled: form.has('enabled') } satisfies GatewayUpstreamInput : { alias: text('alias'), upstreamId: text('upstreamId'), remoteModel: text('remoteModel'), ...(text('displayName') ? { displayName: text('displayName') } : {}), enabled: form.has('enabled') } satisfies GatewayModelInput
const secret = event.currentTarget.elements.namedItem('apiKey')
if (secret instanceof HTMLInputElement) secret.value = ''
void act('保存配置', `/${editor.kind === 'upstream' ? 'upstreams' : 'models'}${editor.value ? `/${encodeURIComponent(editor.value.id)}` : ''}`, editor.value ? 'PUT' : 'POST', body)
}}>
<fieldset disabled={Boolean(pending)} className="space-y-4">
{editor.kind === 'upstream' ? <>
<label className="block text-sm">上游名称<input name="name" required maxLength={200} defaultValue={editor.value?.name} className={control} /></label>
<label className="block text-sm">上游 Base URL<input name="baseUrl" type="url" required maxLength={2048} defaultValue={editor.value?.baseUrl} placeholder="https://api.deepseek.com/v1" className={control} /></label>
<label className="block text-sm">上游协议<select className={control} defaultValue="openai-chat-completions"><option value="openai-chat-completions">OpenAI Chat Completions</option></select></label>
<label className="block text-sm">认证方式<select value={authentication} className={control} onChange={event => setAuthentication(event.target.value as typeof authentication)}><option value="api-key">API Key</option><option value="none">无需认证</option></select></label>
{authentication === 'api-key' && <label className="block text-sm">上游 API Key(留空则保留现有值)<input aria-label="上游 API Key" name="apiKey" type="password" autoComplete="new-password" maxLength={16384} className={control} /></label>}
<p className={muted}>保存时不填写 API Key 会保留当前凭据;填写新值会覆盖当前凭据。凭据提交后不会再次显示。</p>
</> : <>
<label className="block text-sm">对外暴露名称(模型 ID)<input aria-label="模型别名" name="alias" required maxLength={editor.value?.alias.length && editor.value.alias.length > 128 ? 200 : 128} pattern="[A-Za-z0-9][A-Za-z0-9._:\/\-]*" defaultValue={editor.value?.alias} className={control} aria-describedby="gateway-alias-help" /></label>
<p id="gateway-alias-help" className={muted}>客户端使用这个名称调用模型,以字母或数字开头;可包含字母、数字、点、下划线、冒号、斜杠和连字符。</p>
<label className="block text-sm">显示名称(可选)<input name="displayName" maxLength={200} defaultValue={editor.value?.displayName} className={control} /></label>
{(editor.value?.upstreamId || editor.upstreamId) ? <><input type="hidden" name="upstreamId" value={editor.value?.upstreamId ?? editor.upstreamId} /><p className={muted}>所属上游:{snapshot?.upstreams.find((upstream) => upstream.id === (editor.value?.upstreamId ?? editor.upstreamId))?.name ?? '未知上游'}</p></> : <label className="block text-sm">映射上游<select name="upstreamId" required defaultValue={snapshot?.upstreams[0]?.id} className={control}>{snapshot?.upstreams.map((upstream) => <option key={upstream.id} value={upstream.id}>{upstream.name}</option>)}</select></label>}
<label className="block text-sm">远端模型名称<input name="remoteModel" required maxLength={200} defaultValue={editor.value?.remoteModel ?? editor.remoteModel} className={control} /></label>
</>}
<label className="flex items-center gap-2 text-sm"><input name="enabled" type="checkbox" defaultChecked={editor.value?.enabled ?? true} />保存后启用</label>
</fieldset>
{actionError && <p id="gateway-editor-error" role="alert" className="break-words">{actionError}</p>}
<div className="flex flex-wrap justify-end gap-2"><Button variant="ghost" disabled={Boolean(pending)} onClick={() => { setEditor(null); setActionError('') }}>取消</Button><Button type="submit" disabled={Boolean(pending)}>{pending ? '正在保存…' : '保存配置'}</Button></div>
</form>
</Dialog>}
</div>
}
+8 -14
View File
@@ -1,7 +1,6 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { prototypeSnapshot } from '../../../shared/prototype-data'
import type { Device, Publication } from '../../../shared/sharing'
import { SharingCenter } from './sharing-center'
@@ -22,7 +21,7 @@ function mockDirectory(deviceRows = devices, publicationRows = publications) {
throw new Error(`Unexpected request: ${url}`)
})
}
function mount() { return render(<BrowserRouter><SharingCenter snapshot={prototypeSnapshot} /></BrowserRouter>) }
function mount() { return render(<BrowserRouter><SharingCenter /></BrowserRouter>) }
describe('local sharing directory', () => {
beforeEach(() => {
@@ -45,7 +44,8 @@ describe('local sharing directory', () => {
for (const text of ['已撤销', '预算说明', '搜索:声明允许', '读取原文:未声明允许', '下载:声明允许']) expect(within(revoked).getByText(text)).toBeInTheDocument()
expect(within(revoked).getByText(/来源声明:服务器/)).toBeInTheDocument()
expect(screen.queryByText(/当前示例访问者/)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /发布|登记|下载/ })).not.toBeInTheDocument()
expect(screen.getAllByRole('button', { name: '撤销发布' })).toHaveLength(2)
expect(screen.queryByRole('button', { name: /^(发布|登记|下载)/ })).not.toBeInTheDocument()
})
it('combines device, kind, search and status filters, restores URL state and clears no results', async () => {
@@ -102,22 +102,16 @@ describe('local sharing directory', () => {
expect(await screen.findByRole('heading', { name: '本机工作站' })).toBeInTheDocument()
})
it('keeps fixture tabs separate and aborts pending local loads when leaving', async () => {
it('ignores obsolete fixture links and aborts pending loads when leaving', () => {
const signals: AbortSignal[] = []
vi.stubGlobal('fetch', vi.fn().mockImplementation((_url, { signal }) => { signals.push(signal); return new Promise(() => {}) }))
window.history.replaceState({}, '', '/capabilities?view=local&item=k-1')
mount()
const app = mount()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '共享知识库' }))
expect(screen.getAllByRole('tab')).toHaveLength(4)
expect(screen.queryByRole('link', { name: '设计规范归档' })).not.toBeInTheDocument()
app.unmount()
expect(signals).toHaveLength(2)
expect(signals.every((signal) => signal.aborted)).toBe(true)
expect(screen.getByRole('link', { name: '设计规范归档' })).toBeInTheDocument()
expect(screen.queryByText(/此页读取当前服务/)).not.toBeInTheDocument()
vi.stubGlobal('fetch', mockDirectory())
fireEvent.keyDown(screen.getByRole('tab', { name: '共享知识库' }), { key: 'Home' })
expect(screen.getByRole('tab', { name: '本地服务目录' })).toHaveFocus()
expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', 'sharing-tab-local')
await screen.findByRole('heading', { name: '本地分析能力' })
expect(screen.queryByRole('link', { name: '设计规范归档' })).not.toBeInTheDocument()
})
})
+24 -13
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import type { Device, Publication } from '../../../shared/sharing'
import { sharingRequest } from '../lib/sharing-api'
import { Button } from './ui/button'
import { StatusBadge } from './ui/status-badge'
@@ -14,6 +15,8 @@ export function LocalSharing() {
const [params, setParams] = useSearchParams()
const [attempt, setAttempt] = useState(0)
const [state, setState] = useState<LoadState>({ status: 'loading' })
const [pending, setPending] = useState('')
const [actionError, setActionError] = useState('')
const deviceId = params.get('device') ?? ''
const kind = params.get('kind') ?? ''
const status = params.get('status') ?? ''
@@ -21,17 +24,9 @@ export function LocalSharing() {
useEffect(() => {
const controller = new AbortController()
async function load<T>(resource: string, label: string): Promise<T> {
const response = await fetch(`/api/v1/sharing/${resource}`, { signal: controller.signal })
if (!response.ok) {
const error = await response.json().catch(() => null)
throw new Error(`${label}加载失败(HTTP ${response.status})${typeof error?.code === 'string' ? ` · ${error.code}` : ''}${typeof error?.requestId === 'string' ? ` · 请求 ID:${error.requestId}` : ''}`)
}
return response.json() as Promise<T>
}
Promise.all([
load<Pick<Directory, 'devices'>>('devices', '设备'),
load<Pick<Directory, 'publications'>>('publications', '发布记录')
sharingRequest<Pick<Directory, 'devices'>>('/devices', controller.signal),
sharingRequest<Pick<Directory, 'publications'>>('/publications', controller.signal)
]).then(([devices, publications]) => {
if (!controller.signal.aborted) setState({ status: 'loaded', data: { ...devices, ...publications } })
}).catch((error: unknown) => {
@@ -48,11 +43,26 @@ export function LocalSharing() {
setParams(next, { replace: true })
}
async function revoke(publication: Publication) {
if (publication.status !== 'published' || pending || !window.confirm(`撤销“${publication.name}”?撤销后会保留记录,但不能继续作为已发布内容使用。`)) return
setPending(publication.id)
setActionError('')
try {
await sharingRequest(`/publications/${encodeURIComponent(publication.id)}/revoke`, undefined, 'POST')
reload()
} catch (error) {
setActionError(`撤销失败:${error instanceof Error ? error.message : '网络请求失败'}`)
} finally {
setPending('')
}
}
return <>
<div className={panel}>
<p className={muted}>此页读取当前服务的真实设备和发布元数据,与其他页签的模拟用户、组织数据独立。请在 GoodBuddy 应用中心登记设备、发布或撤销共享内容,再刷新此目录。</p>
<p className={muted}>此页读取当前服务的设备和发布元数据。请在 GoodBuddy 应用中心登记设备、发布或撤销共享内容,再刷新此目录。</p>
<p className={muted}>登记不代表设备在线;已发布不代表可以调用。来源模式仅为声明,服务器未同步正文。知识权限分别记录声明,尚未执行授权校验;此页不搜索正文、读取原文、下载文件或调用能力。</p>
<Button variant="secondary" disabled={state.status === 'loading'} onClick={reload}>刷新目录</Button>
{actionError && <p role="alert" className="text-sm text-rose-300">{actionError}</p>}
</div>
{state.status === 'loading' && <p role="status" className={muted}>正在加载本地服务目录…</p>}
{state.status === 'error' && <div className={panel}>
@@ -90,8 +100,9 @@ export function LocalSharing() {
<p className={muted}>发布标识:{item.id} · 设备标识:{item.deviceId}</p>
<p className={muted}>来源声明:{item.sourceMode === 'server' ? '服务器' : '设备'}(仅描述来源,不代表内容已同步或可取用)</p>
<p className={muted}>发布时间:<time dateTime={item.createdAt}>{item.createdAt}</time> · 更新时间:<time dateTime={item.updatedAt}>{item.updatedAt}</time></p>
{item.kind === 'knowledge' && <div className="space-y-2"><h3 className="text-sm font-semibold">知识权限声明(各自独立,未执行授权校验)</h3><div className="flex flex-wrap gap-3 text-sm"><span>搜索:{item.permissions.search ? '声明允许' : '未声明允许'}</span><span>读取原文:{item.permissions.read ? '声明允许' : '未声明允许'}</span><span>下载:{item.permissions.download ? '声明允许' : '未声明允许'}</span></div></div>}
</article>)}
{item.kind === 'knowledge' && <div className="space-y-2"><h3 className="text-sm font-semibold">知识权限声明(各自独立,未执行授权校验)</h3><div className="flex flex-wrap gap-3 text-sm"><span>搜索:{item.permissions.search ? '声明允许' : '未声明允许'}</span><span>读取原文:{item.permissions.read ? '声明允许' : '未声明允许'}</span><span>下载:{item.permissions.download ? '声明允许' : '未声明允许'}</span></div></div>}
{item.status === 'published' && <Button variant="danger" size="sm" disabled={Boolean(pending)} onClick={() => void revoke(item)}>{pending === item.id ? '撤销中…' : '撤销发布'}</Button>}
</article>)}
</>
})()}
</>
+62 -210
View File
@@ -1,226 +1,78 @@
import {
Activity,
ArrowUpRight,
CheckCircle2,
CircleAlert,
GitFork,
RadioTower,
ServerCog,
ShieldCheck,
Sparkles
} from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import type { Metric, PrototypeSnapshot } from '../../../shared/prototype-data'
import { ArrowUpRight, CircleAlert, Router, Server, Share2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import type { Device, Publication } from '../../../shared/sharing'
import type { GatewaySnapshot } from '../../../shared/gateway'
import { sharingRequest } from '../lib/sharing-api'
import { gatewayRequest } from '../lib/gateway-api'
import { PageHeader } from './page-header'
import { StatusBadge } from './ui/status-badge'
import { PageTabs } from './page-tabs'
const metricStyles: Record<Metric['tone'], string> = {
cyan: 'from-cyan-400/16 to-cyan-400/[0.025] border-cyan-300/15 text-cyan-300',
violet: 'from-violet-400/16 to-violet-400/[0.025] border-violet-300/15 text-violet-300',
amber: 'from-amber-400/16 to-amber-400/[0.025] border-amber-300/15 text-amber-200',
rose: 'from-rose-400/16 to-rose-400/[0.025] border-rose-300/15 text-rose-300'
const panel = 'rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5'
const muted = 'text-sm leading-6 text-[var(--text-muted)]'
type Topology = { devices: Device[]; publications: Publication[]; gateway: GatewaySnapshot }
function TopologyNode({ icon: Icon, title, detail, tone = 'cyan' }: { icon: LucideIcon; title: string; detail: string; tone?: 'cyan' | 'violet' | 'slate' }) {
const colors = { cyan: 'border-cyan-300/25 bg-cyan-300/[0.08] text-cyan-100', violet: 'border-violet-300/25 bg-violet-300/[0.08] text-violet-100', slate: 'border-white/10 bg-white/[0.04] text-slate-200' }
return <div className={`min-w-0 rounded-xl border p-4 ${colors[tone]}`}><Icon aria-hidden="true" className="mb-3 size-5" /><p className="font-medium">{title}</p><p className="mt-1 break-words text-xs leading-5 text-[var(--text-muted)]">{detail}</p></div>
}
export function OverviewPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
const navigate = useNavigate()
const maxActivity = Math.max(...snapshot.activity.map((item) => item.value))
const recent = [
{
icon: ShieldCheck,
title: '策略决定',
copy: '生产数据跨组织请求被强制策略拒绝',
time: '2 分钟前',
tone: 'warning' as const
},
{
icon: CheckCircle2,
title: '能力发布',
copy: 'GPU-Worker-05 的图像生成能力已批准',
time: '4 分钟前',
tone: 'online' as const
},
{
icon: GitFork,
title: '联邦心跳',
copy: '法务服务商网关完成双边状态同步',
time: '8 分钟前',
tone: 'neutral' as const
}
]
export function OverviewPage() {
const [topology, setTopology] = useState<Topology | null>(null)
const [topologyError, setTopologyError] = useState('')
const [activeTab, setActiveTab] = useState('status')
useEffect(() => {
const controller = new AbortController()
Promise.all([
sharingRequest<{ devices: Device[] }>('/devices', controller.signal),
sharingRequest<{ publications: Publication[] }>('/publications', controller.signal),
gatewayRequest<GatewaySnapshot>('/config', controller.signal)
]).then(([devices, publications, gateway]) => {
if (!controller.signal.aborted) setTopology({ devices: devices.devices, publications: publications.publications, gateway })
}).catch((error: unknown) => {
if (!controller.signal.aborted) setTopologyError(error instanceof Error ? error.message : '拓扑数据加载失败')
})
return () => controller.abort()
}, [])
return (
<>
<PageHeader
eyebrow={`实例 · ${snapshot.instance.region}`}
eyebrow="ShareServer"
title="运行概览"
description={`用户在 GoodBuddy 中工作,${snapshot.instance.name} 为 ${snapshot.instance.organization} 管理服务与组织资源。`}
action={
<div className="flex items-center gap-2 rounded-xl border border-emerald-400/15 bg-emerald-400/[0.06] px-3 py-2 text-xs text-emerald-200">
<span className="size-1.5 rounded-full bg-emerald-300 shadow-[0_0_10px_rgba(52,211,153,.9)]" />
交互原型 · 示例数据
</div>
}
description="管理当前服务的模型网关与本地共享目录。"
/>
<section aria-label="GoodBuddy 联动服务" className="mb-6 grid gap-4 md:grid-cols-3">
{[
{ path: '/model-gateway', title: '模型网关', flow: 'GoodBuddy → ShareServer → 上游模型', copy: '管理模型转发和授权,演示允许或拒绝调用及审计记录。' },
{ path: '/documents', title: '文档与协作', flow: 'GoodBuddy → 服务端文档 → OnlyOffice', copy: '管理文档归属与编辑服务,演示个人文档转交组织及回收恢复。' },
{ path: '/distribution', title: '配置下发', flow: '组织配置 → GoodBuddy → 生效回执', copy: '选择网关模型、发布配置,演示在线生效、离线等待和版本回滚。' }
].map((service) => <button key={service.path} onClick={() => navigate(service.path)} className="rounded-2xl border border-white/10 bg-[var(--panel)] p-5 text-left outline-none transition hover:bg-[var(--control)] focus-visible:ring-2 focus-visible:ring-cyan-400/70">
<span className="flex items-center justify-between gap-2 font-semibold text-[var(--text-strong)]">{service.title}<ArrowUpRight className="size-4" /></span>
<span className="mt-3 block text-xs leading-5 text-[var(--text-primary)]">{service.flow}</span>
<span className="mt-2 block text-sm leading-6 text-[var(--text-muted)]">{service.copy}</span>
</button>)}
<PageTabs title="运行概览" active={activeTab} onChange={setActiveTab} tabs={[{ key: 'status', label: '服务状态' }, { key: 'pending', label: '待处理事项' }, { key: 'activity', label: '最近活动' }]} />
{activeTab !== 'status' && <section className={`${panel} mb-6`}><h2 className="font-semibold">{activeTab === 'pending' ? '待处理事项' : '最近活动'}</h2><p className={`mt-2 ${muted}`}>该 Tab 的组织事件接口尚未接入,当前不生成示例审批、任务或活动记录。</p></section>}
{activeTab === 'status' && <>
<section aria-label="服务拓扑" className={`${panel} mb-6`}>
<div className="flex flex-wrap items-start justify-between gap-3"><div><h2 className="font-semibold text-[var(--text-strong)]">服务拓扑</h2><p className={`mt-1 ${muted}`}>根据当前登记设备、共享目录和模型网关配置展示实际连接关系。</p></div><Link className="text-sm underline underline-offset-4" to="/devices">查看接入设备</Link></div>
{topologyError && <p role="alert" className="mt-4 flex items-center gap-2 text-sm text-rose-300"><CircleAlert aria-hidden="true" className="size-4" />拓扑数据加载失败:{topologyError}</p>}
{!topology && !topologyError && <p role="status" className={`mt-5 ${muted}`}>正在读取服务拓扑…</p>}
{topology && <div className="mt-5 grid items-center gap-3 lg:grid-cols-[1fr_auto_1fr_auto_1fr]">
<TopologyNode icon={Share2} title="GoodBuddy 设备" detail={`${topology.devices.length} 台已登记设备;登记记录不代表在线`} />
<div aria-hidden="true" className="hidden h-px bg-cyan-300/30 lg:block" />
<TopologyNode icon={Server} title="ShareServer" detail={`${topology.publications.filter((item) => item.status === 'published').length} 项已发布目录;负责网关转发与目录管理`} tone="violet" />
<div aria-hidden="true" className="hidden h-px bg-violet-300/30 lg:block" />
<TopologyNode icon={Router} title="外部模型上游" detail={`${topology.gateway.upstreams.filter((item) => item.enabled).length} 个启用上游,${topology.gateway.models.filter((item) => item.enabled).length} 个启用模型`} tone="slate" />
</div>}
{topology && <p className={`mt-4 ${muted}`}>共享目录当前只保存发布元数据;模型请求通过 ShareServer 转发到已配置上游,GoodBuddy 不直接读取上游凭据。</p>}
</section>
<section aria-label="关键指标" className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{snapshot.metrics.map((metric) => (
<button
key={metric.label}
className={`group relative overflow-hidden rounded-2xl border bg-gradient-to-br p-5 text-left outline-none transition duration-200 hover:-translate-y-0.5 hover:border-white/20 focus-visible:ring-2 focus-visible:ring-cyan-400/70 ${metricStyles[metric.tone]}`}
onClick={() => navigate(metric.target)}
>
<div className="absolute -right-8 -top-8 size-24 rounded-full bg-current opacity-[0.055] blur-2xl" />
<div className="flex items-start justify-between">
<span className="text-xs font-medium text-[var(--text-muted)]">{metric.label}</span>
<ArrowUpRight className="size-4 opacity-50 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5 group-hover:opacity-100" />
</div>
<p className="mt-5 text-3xl font-semibold tracking-[-0.04em] text-[var(--text-strong)]">
{metric.value}
</p>
<p className="mt-2 text-xs text-[var(--text-muted)]">{metric.detail}</p>
</button>
<section aria-label="GoodBuddy 联动服务" className="mb-6 grid gap-4 md:grid-cols-2">
{[
{ path: '/model-gateway', title: '模型网关', copy: '配置上游与模型别名、测试请求转发并查看调用审计。' },
{ path: '/capabilities', title: '共享中心', copy: '查看当前服务登记的设备与发布记录,按设备、类型和发布状态筛选。' }
].map((service) => (
<Link key={service.path} to={service.path} className="rounded-2xl border border-white/10 bg-[var(--panel)] p-5 text-left outline-none transition hover:bg-[var(--control)] focus-visible:ring-2 focus-visible:ring-cyan-400/70">
<span className="flex items-center justify-between gap-2 font-semibold text-[var(--text-strong)]">{service.title}<ArrowUpRight aria-hidden="true" className="size-4" /></span>
<span className="mt-2 block text-sm leading-6 text-[var(--text-muted)]">{service.copy}</span>
</Link>
))}
</section>
<div className="mt-5 grid gap-5 xl:grid-cols-[1.55fr_.85fr]">
<section className="rounded-2xl border border-white/[0.075] bg-[var(--panel)] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-sm font-semibold text-[var(--text-strong)]">任务活动</h2>
<p className="mt-1 text-xs text-[var(--text-muted)]">过去 12 小时创建的任务</p>
</div>
<span className="rounded-lg border border-white/[0.07] bg-white/[0.035] px-2.5 py-1.5 text-xs text-[var(--text-muted)]">
24 小时
</span>
</div>
<div className="mt-8 flex h-52 items-end gap-2 sm:gap-3" role="img" aria-label="过去十二小时任务量柱状图">
{snapshot.activity.map((item, index) => (
<div key={item.time} className="group flex h-full min-w-0 flex-1 flex-col justify-end">
<div className="relative flex-1">
<div
className="absolute inset-x-0 bottom-0 min-h-1 rounded-t-md bg-gradient-to-t from-cyan-400/35 to-cyan-300/90 shadow-[0_0_18px_-8px_rgba(34,211,238,.8)] transition group-hover:from-violet-400/50 group-hover:to-violet-300"
style={{ height: `${Math.max(6, (item.value / maxActivity) * 100)}%` }}
>
<span className="absolute -top-6 left-1/2 hidden -translate-x-1/2 text-[10px] text-cyan-200 group-hover:block">
{item.value}
</span>
</div>
</div>
<span className={`mt-2 text-center text-[9px] text-slate-600 ${index % 2 ? 'hidden sm:block' : ''}`}>
{item.time}
</span>
</div>
))}
</div>
</section>
<section className="rounded-2xl border border-white/[0.075] bg-[var(--panel)] p-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-[var(--text-strong)]">网络脉冲</h2>
<p className="mt-1 text-xs text-[var(--text-muted)]">连接与路由示例摘要</p>
</div>
<RadioTower className="size-5 text-cyan-300" />
</div>
<div className="relative mt-7 grid place-items-center py-4">
<div className="pulse-ring absolute size-36 rounded-full border border-cyan-300/10" />
<div className="pulse-ring pulse-delay absolute size-24 rounded-full border border-cyan-300/15" />
<div className="relative grid size-16 place-items-center rounded-full border border-cyan-300/25 bg-cyan-300/10 shadow-[0_0_45px_-10px_rgba(34,211,238,.7)]">
<ServerCog className="size-7 text-cyan-200" />
</div>
</div>
<dl className="mt-4 grid grid-cols-3 gap-2 text-center">
<div className="rounded-xl bg-white/[0.025] p-3">
<dt className="text-[10px] text-slate-500">设备连接</dt>
<dd className="mt-1 text-sm font-semibold text-slate-200">18</dd>
</div>
<div className="rounded-xl bg-white/[0.025] p-3">
<dt className="text-[10px] text-slate-500">联邦网关</dt>
<dd className="mt-1 text-sm font-semibold text-slate-200">1 / 2</dd>
</div>
<div className="rounded-xl bg-white/[0.025] p-3">
<dt className="text-[10px] text-slate-500">中继占用</dt>
<dd className="mt-1 text-sm font-semibold text-slate-200">34%</dd>
</div>
</dl>
</section>
</div>
<div className="mt-5 grid gap-5 lg:grid-cols-2">
<section className="overflow-hidden rounded-2xl border border-white/[0.075] bg-[var(--panel)]">
<header className="flex items-center justify-between border-b border-white/[0.065] px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-[var(--text-strong)]">需要关注</h2>
<p className="mt-1 text-xs text-[var(--text-muted)]">按影响与等待时间排序</p>
</div>
<CircleAlert className="size-5 text-amber-300" />
</header>
<div className="divide-y divide-white/[0.055]">
{snapshot.pages.approvals.slice(0, 3).map((item) => (
<button
key={item.id}
className="flex w-full items-center gap-4 px-5 py-4 text-left outline-none transition hover:bg-white/[0.025] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cyan-400/60"
onClick={() => navigate('/approvals')}
>
<span className="grid size-9 shrink-0 place-items-center rounded-xl border border-amber-300/15 bg-amber-300/[0.07] text-amber-200">
<Sparkles className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--text-strong)]">{item.primary}</span>
<span className="mt-1 block truncate text-xs text-[var(--text-muted)]">{item.secondary}</span>
</span>
<span className="hidden text-xs text-slate-600 sm:block">{item.meta[2]}</span>
</button>
))}
</div>
</section>
<section className="overflow-hidden rounded-2xl border border-white/[0.075] bg-[var(--panel)]">
<header className="flex items-center justify-between border-b border-white/[0.065] px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-[var(--text-strong)]">最近活动</h2>
<p className="mt-1 text-xs text-[var(--text-muted)]">仅显示有界审计元数据</p>
</div>
<Activity className="size-5 text-violet-300" />
</header>
<div className="divide-y divide-white/[0.055]">
{recent.map((item) => {
const Icon = item.icon
return (
<div key={item.copy} className="flex items-center gap-4 px-5 py-4">
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-white/[0.04] text-slate-300">
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium text-slate-500">{item.title}</span>
<span className="mt-1 block truncate text-sm text-[var(--text-primary)]">{item.copy}</span>
</span>
<span className="text-xs text-slate-600">{item.time}</span>
</div>
)
})}
</div>
</section>
</div>
<footer className="mt-6 flex flex-wrap items-center gap-3 text-[11px] text-slate-600">
<StatusBadge status="原型数据" tone="neutral" />
<span>{snapshot.instance.protocol}</span>
<span>·</span>
<span>快照 {new Date(snapshot.instance.generatedAt).toLocaleString('zh-CN')}</span>
</footer>
</>}
</>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { useId } from 'react'
import { cn } from '../lib/utils'
export type PageTab = { label: string; key: string }
export function PageTabs({
title,
tabs,
active,
onChange
}: {
title: string
tabs: PageTab[]
active: string
onChange: (key: string) => void
}) {
const id = useId()
const activeIndex = Math.max(0, tabs.findIndex((tab) => tab.key === active))
return <div role="tablist" aria-label={`${title}分类`} className="mb-5 flex max-w-full gap-1 overflow-x-auto rounded-xl border border-white/[0.08] bg-white/[0.025] p-1">
{tabs.map((tab, index) => <button
key={tab.key}
id={`${id}-tab-${tab.key}`}
role="tab"
aria-selected={active === tab.key}
aria-controls={`${id}-panel`}
tabIndex={active === tab.key ? 0 : -1}
className={cn(
'min-h-10 shrink-0 rounded-lg px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-cyan-400/70',
active === tab.key ? 'bg-white/[0.09] font-medium text-[var(--text-strong)] shadow-sm' : 'text-[var(--text-muted)] hover:text-[var(--text-primary)]'
)}
onClick={() => onChange(tab.key)}
onKeyDown={(event) => {
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : event.key === 'ArrowRight' ? (index + 1) % tabs.length : event.key === 'ArrowLeft' ? (index + tabs.length - 1) % tabs.length : -1
if (next < 0) return
event.preventDefault()
onChange(tabs[next].key)
document.getElementById(`${id}-tab-${tabs[next].key}`)?.focus()
}}
>{tab.label}</button>)}
</div>
}
+3 -168
View File
@@ -6,26 +6,14 @@ import { Button } from './ui/button'
import { Dialog } from './ui/dialog'
import { StatusBadge } from './ui/status-badge'
type Upstream = { id: string; name: string; url: string; enabled: boolean }
type Model = { id: string; upstreamId: string; alias: string; remote: string; enabled: boolean; group: string }
type Audit = { id: string; time: string; model: string; upstream: string; group: string; result: string; allowed: boolean }
type Document = { id: string; name: string; owner: 'self' | 'other' | 'organization'; deleted: boolean }
type Office = { id: string; name: string; url: string; checks: number; healthy: boolean }
type Release = { version: string; modelId: string; modelName: string; group: string }
type Device = { id: string; name: string; group: string; online: boolean; applied: Release | null }
type DemoState = {
upstreams: Upstream[]
models: Model[]
audits: Audit[]
documents: Document[]
offices: Office[]
organizationRole: boolean
draft: Release | null
releases: Record<string, Release[]>
devices: Device[]
}
const groups = ['平台工程', '设计中心']
const control = 'mt-1 w-full min-w-0 rounded-lg border border-white/10 bg-[var(--control)] p-2 text-sm text-[var(--text-primary)] focus-visible:outline-2 focus-visible:outline-current'
const panel = 'space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5'
const muted = 'text-sm leading-6 text-[var(--text-muted)]'
@@ -33,27 +21,13 @@ const DemoContext = createContext<{ state: DemoState; setState: Dispatch<SetStat
function initialState(): DemoState {
return {
upstreams: [{ id: 'upstream-demo', name: '示例上游', url: 'https://gateway.example/v1', enabled: true }],
models: [
{ id: 'model-chat', upstreamId: 'upstream-demo', alias: 'org-chat', remote: 'demo-chat', enabled: true, group: '全部用户组' },
{ id: 'model-code', upstreamId: 'upstream-demo', alias: 'org-code', remote: 'demo-code', enabled: true, group: '平台工程' }
],
audits: [],
documents: [
{ id: 'doc-org', name: '组织入门指南.docx', owner: 'organization', deleted: false },
{ id: 'doc-self', name: '我的项目提案.docx', owner: 'self', deleted: false },
{ id: 'doc-other', name: '其他成员文档(仅归属元数据)', owner: 'other', deleted: false }
],
offices: [{ id: 'office-demo', name: '示例 OnlyOffice', url: 'https://office.example', checks: 0, healthy: true }],
organizationRole: false,
draft: null,
releases: {},
devices: [
{ id: 'device-1', name: '工程工作站(示例)', group: groups[0], online: true, applied: null },
{ id: 'device-2', name: '工程笔记本(示例)', group: groups[0], online: false, applied: null },
{ id: 'device-3', name: '设计工作站(示例)', group: groups[1], online: true, applied: null },
{ id: 'device-4', name: '设计笔记本(示例)', group: groups[1], online: false, applied: null }
]
organizationRole: false
}
}
@@ -69,12 +43,6 @@ function useDemo() {
return value
}
function availableModels(state: DemoState, group?: string) {
return state.models.filter((model) => model.enabled &&
state.upstreams.some((upstream) => upstream.id === model.upstreamId && upstream.enabled) &&
(!group || model.group === '全部用户组' || model.group === group))
}
function Tabs({ labels, children }: { labels: string[]; children: (index: number) => ReactNode }) {
const [active, setActive] = useState(0)
const id = useId()
@@ -128,63 +96,6 @@ function EndpointDialog({ title, onClose, onSave }: { title: string; onClose: ()
</Dialog>
}
function GatewayDemo() {
const { state, setState } = useDemo()
const [adding, setAdding] = useState(false)
const [selectedAudit, setSelectedAudit] = useState<Audit | null>(null)
const [group, setGroup] = useState(groups[0])
const [message, setMessage] = useState('')
return <>
<p className={`${muted} mb-4`}>ShareServer 不托管模型。此页仅模拟访问策略判断与向上游转发,不运行推理,不发送提示词或真实请求。</p>
{message && <p role="status" className="mb-4 text-sm">{message}</p>}
<Tabs labels={['上游服务', '模型映射 / 访问策略', '调用审计']}>{(tab) => <>
{tab === 0 && <>
<Button onClick={() => setAdding(true)}>添加上游</Button>
{state.upstreams.map((upstream) => <article className={panel} key={upstream.id}>
<div className="flex flex-wrap items-center justify-between gap-3"><h2 className="font-semibold">{upstream.name}</h2><StatusBadge status={upstream.enabled ? '已启用(模拟)' : '已停用'} tone={upstream.enabled ? 'online' : 'offline'} /></div>
<p className={`${muted} break-all`}>{upstream.url}</p>
<Button variant="secondary" onClick={() => setState((current) => ({ ...current, upstreams: current.upstreams.map((item) => item.id === upstream.id ? { ...item, enabled: !item.enabled } : item) }))}>{upstream.enabled ? '停用' : '启用'} {upstream.name}</Button>
</article>)}
</>}
{tab === 1 && <>
<label className="block max-w-sm text-sm">模拟调用者用户组<select className={control} value={group} onChange={(event) => setGroup(event.target.value)}>{groups.map((item) => <option key={item}>{item}</option>)}</select></label>
{state.models.map((model) => <article className={panel} key={model.id}>
<div className="flex flex-wrap items-center justify-between gap-3"><h2 className="break-all font-semibold">{model.alias} → {model.remote}</h2><StatusBadge status={availableModels(state).some((item) => item.id === model.id) ? '映射可用' : '映射不可用'} tone={availableModels(state).some((item) => item.id === model.id) ? 'online' : 'offline'} /></div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="text-sm">上游服务<select className={control} value={model.upstreamId} onChange={(event) => setState((current) => ({ ...current, models: current.models.map((item) => item.id === model.id ? { ...item, upstreamId: event.target.value } : item) }))}>{state.upstreams.map((item) => <option key={item.id} value={item.id}>{item.name}{item.enabled ? '' : '(已停用)'}</option>)}</select></label>
<label className="text-sm">允许访问的用户组<select className={control} value={model.group} onChange={(event) => setState((current) => ({ ...current, models: current.models.map((item) => item.id === model.id ? { ...item, group: event.target.value } : item) }))}>{['全部用户组', ...groups].map((item) => <option key={item}>{item}</option>)}</select></label>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => setState((current) => ({ ...current, models: current.models.map((item) => item.id === model.id ? { ...item, enabled: !item.enabled } : item) }))}>{model.enabled ? '停用映射' : '启用映射'}</Button>
<Button variant="secondary" onClick={() => {
const allowed = availableModels(state, group).some((item) => item.id === model.id)
const audit: Audit = { id: crypto.randomUUID(), time: new Date().toLocaleString('zh-CN'), model: `${model.alias} → ${model.remote}`, upstream: state.upstreams.find((item) => item.id === model.upstreamId)?.name ?? '无上游', group, allowed, result: allowed ? '模拟允许转发,未发送请求' : '模拟拒绝:上游 / 映射停用或用户组不在策略范围' }
setState((current) => ({ ...current, audits: [audit, ...current.audits].slice(0, 50) }))
setMessage(`${model.alias}:${audit.result}。已记录调用审计。`)
}}>模拟调用 {model.alias}</Button>
</div>
</article>)}
</>}
{tab === 2 && <>
<p className={muted}>最近 50 条模拟调用;仅记录路由与策略结果,不记录请求或响应正文。</p>
{!state.audits.length && <p className={panel}>暂无调用。请在模型映射页执行一次模拟调用。</p>}
{state.audits.map((audit) => <article key={audit.id} className={panel}>
<h2 className="font-semibold">{audit.model}</h2><p className={muted}>{audit.time} · {audit.group}</p>
<div className="flex flex-wrap items-center gap-3"><StatusBadge status={audit.allowed ? '模拟允许' : '模拟拒绝'} tone={audit.allowed ? 'online' : 'danger'} /><Button variant="secondary" onClick={() => setSelectedAudit(audit)}>查看审计详情</Button></div>
</article>)}
</>}
</>}</Tabs>
{adding && <EndpointDialog title="添加上游服务" onClose={() => setAdding(false)} onSave={(name, url) => {
if (state.upstreams.some((item) => item.name === name || item.url === url)) return '该名称或地址已存在。'
setState((current) => ({ ...current, upstreams: [...current.upstreams, { id: crypto.randomUUID(), name, url, enabled: true }] }))
setMessage('已添加示例上游。可在模型映射页将模型关联到此服务。')
}} />}
<Dialog open={Boolean(selectedAudit)} onOpenChange={(open) => { if (!open) setSelectedAudit(null) }} title="调用审计详情" description="模拟事实,不代表真实转发或上游可用性。">
{selectedAudit && <dl className="space-y-3 text-sm">{Object.entries({ 编号: selectedAudit.id, 时间: selectedAudit.time, 模型映射: selectedAudit.model, 上游: selectedAudit.upstream, 调用组: selectedAudit.group, 策略结果: selectedAudit.result }).map(([label, value]) => <div key={label}><dt className={muted}>{label}</dt><dd className="break-words">{value}</dd></div>)}</dl>}
</Dialog>
</>
}
function DocumentsDemo({ organization }: { organization: string }) {
const { state, setState } = useDemo()
const [adding, setAdding] = useState(false)
@@ -248,89 +159,13 @@ function DocumentsDemo({ organization }: { organization: string }) {
</>
}
function DistributionDemo() {
const { state, setState } = useDemo()
const [creating, setCreating] = useState(false)
const [group, setGroup] = useState(groups[0])
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const models = availableModels(state, group)
const draftAllowed = state.draft && availableModels(state, state.draft.group).some((model) => model.id === state.draft?.modelId)
function releaseToDevices(current: DemoState, release: Release): Device[] {
return current.devices.map((device) => device.group === release.group && device.online ? { ...device, applied: release } : device)
}
return <div className="space-y-5">
<p className={muted}>仅模拟配置发布,不执行真实设备下发。每个用户组独立保留发布历史;离线设备保持待下发,已应用版本不会被假装更新。</p>
{message && <p role="status" className="text-sm">{message}</p>}
<Button disabled={Boolean(state.draft)} onClick={() => { setError(''); setGroup(groups[0]); setCreating(true) }}>创建配置草稿</Button>
{state.draft && <section className={panel}>
<h2 className="font-semibold">草稿 {state.draft.version}</h2><p className={muted}>{state.draft.group} · 默认模型:{state.draft.modelName}</p>
{!draftAllowed && <p role="alert" className="text-sm text-rose-300">默认模型已停用或不允许目标组访问。请调整网关策略,或丢弃草稿后重新选择。</p>}
<div className="flex flex-wrap gap-2"><Button disabled={!draftAllowed} onClick={() => {
setState((current) => {
const draft = current.draft
if (!draft || !availableModels(current, draft.group).some((item) => item.id === draft.modelId)) return current
return { ...current, draft: null, releases: { ...current.releases, [draft.group]: [...(current.releases[draft.group] ?? []), draft] }, devices: releaseToDevices(current, draft) }
})
setMessage('已模拟发布:目标组在线设备更新,离线设备保持待下发。')
}}>模拟发布</Button><Button variant="ghost" onClick={() => setState((current) => ({ ...current, draft: null }))}>丢弃草稿</Button></div>
</section>}
{groups.map((target) => {
const history = state.releases[target] ?? []
const active = history.at(-1)
const previous = history.at(-2)
const rollbackAllowed = previous && availableModels(state, target).some((item) => item.id === previous.modelId)
return <section key={target} className={panel}>
<h2 className="font-semibold">{target}</h2>
<p className={muted}>当前发布:{active ? `${active.version} · 默认模型 ${active.modelName}` : '尚未发布'}</p>
<p className={muted}>可回退版本:{previous ? `${previous.version} · 默认模型 ${previous.modelName}` : '无历史版本'}</p>
{previous && !rollbackAllowed && <p className="text-sm text-rose-300">历史版本模型不可用或策略不允许,暂不能回退。</p>}
<Button variant="secondary" disabled={!rollbackAllowed} onClick={() => {
setState((current) => {
const versions = current.releases[target] ?? []
const prior = versions.at(-2)
if (!prior || !availableModels(current, target).some((item) => item.id === prior.modelId)) return current
return { ...current, releases: { ...current.releases, [target]: versions.slice(0, -1) }, devices: releaseToDevices(current, prior) }
})
setMessage(`${target} 已模拟回退到 ${previous?.version},同时恢复该版本默认模型;离线设备等待该版本。`)
}}>模拟回退{previous ? `到 ${previous.version}` : ''}</Button>
<ul className="space-y-3">{state.devices.filter((device) => device.group === target).map((device) => <li key={device.id} className="flex flex-col justify-between gap-2 border-t border-white/10 pt-3 sm:flex-row sm:items-center">
<div className="min-w-0"><p className="break-words text-sm font-medium">{device.name} · {device.online ? '在线' : '离线'}</p><p className={muted}>已应用:{device.applied ? `${device.applied.version} / ${device.applied.modelName}` : '无'} · 目标:{active?.version ?? '无'}</p></div>
<StatusBadge status={!active ? '尚未下发' : device.online ? '模拟已应用' : '离线 · 待下发(模拟)'} tone={!active ? 'neutral' : device.online ? 'online' : 'warning'} />
</li>)}</ul>
</section>
})}
<Dialog open={creating} onOpenChange={setCreating} title="创建配置草稿" description="默认模型来自当前启用的网关模型,并受目标组访问策略约束。">
<form className="space-y-4" onSubmit={(event) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
const version = String(data.get('version') ?? '').trim()
const model = models.find((item) => item.id === data.get('model'))
if (!/^\d+\.\d+\.\d+$/.test(version)) { setError('版本格式应为三个数字段,例如 1.0.0。'); return }
if (!model) { setError('请选择目标组可访问的启用模型。'); return }
if (state.releases[group]?.some((item) => item.version === version)) { setError('该用户组的发布历史中已存在此版本。'); return }
setState((current) => ({ ...current, draft: { version, group, modelId: model.id, modelName: model.alias } }))
setCreating(false)
setMessage('草稿已保存到内存,尚未模拟发布。')
}}>
<label className="block text-sm">目标用户组<select value={group} className={control} onChange={(event) => setGroup(event.target.value)}>{groups.map((item) => <option key={item}>{item}</option>)}</select></label>
<label className="block text-sm">默认模型<select key={group} name="model" required className={control} defaultValue={models[0]?.id ?? ''} disabled={!models.length}>{!models.length && <option value="">无可用模型</option>}{models.map((model) => <option key={model.id} value={model.id}>{model.alias}</option>)}</select></label>
{!models.length && <p className={muted}>请先在模型网关启用上游与模型,并允许目标组访问。</p>}
<label className="block text-sm">版本<input name="version" required maxLength={30} placeholder="1.0.0" className={control} /></label>
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
<div className="flex flex-wrap justify-end gap-2"><Button variant="ghost" onClick={() => setCreating(false)}>取消</Button><Button type="submit" disabled={!models.length}>保存草稿</Button></div>
</form>
</Dialog>
</div>
}
export function ServiceDemoPage({ page, snapshot }: { page: PageConfig; snapshot: PrototypeSnapshot }) {
useDemo()
return <div className="min-w-0 text-[var(--text-primary)]">
<PageHeader eyebrow={`组织 · ${snapshot.instance.organization}`} title={page.title} description={page.description} />
<aside className={`${panel} mb-5 text-sm leading-6`} aria-label="原型说明">
交互原型:所有操作均为模拟,仅保存在当前页面会话内存中。三个服务页面间导航保留状态,刷新页面后重置。不调用网络,不写入本地存储,不代表真实授权、健康检查或设备下发。
文档交互原型:此页所有操作均为模拟,仅保存在当前页面会话内存中。导航后保留文档状态,刷新页面后重置。不调用网络,不写入本地存储,不代表真实授权或健康检查。与真实模型网关独立。
</aside>
{page.path === '/model-gateway' ? <GatewayDemo /> : page.path === '/documents' ? <DocumentsDemo organization={snapshot.instance.organization} /> : page.path === '/distribution' ? <DistributionDemo /> : <p role="alert">此页面不属于服务演示。</p>}
{page.path === '/documents' ? <DocumentsDemo organization={snapshot.instance.organization} /> : <p role="alert">此页面不属于文档演示。</p>}
</div>
}
+17 -14
View File
@@ -15,6 +15,7 @@ import type { PrototypeSnapshot } from '../../../shared/prototype-data'
import { PageHeader } from './page-header'
import { Button } from './ui/button'
import { StatusBadge } from './ui/status-badge'
import { PageTabs } from './page-tabs'
function SettingCard({
icon: Icon,
@@ -77,6 +78,7 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
const [federation, setFederation] = useState(true)
const [oidc, setOidc] = useState(false)
const [saved, setSaved] = useState(false)
const [activeTab, setActiveTab] = useState('instance')
return (
<>
@@ -101,8 +103,9 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
原型设置已保存到当前浏览器会话,不会写入生产配置。
</div>
) : null}
<PageTabs title="系统设置" active={activeTab} onChange={setActiveTab} tabs={[{ key: 'instance', label: '实例信息' }, { key: 'identity', label: '身份与登录' }, { key: 'infrastructure', label: '基础设施' }, { key: 'relay', label: '中继与联邦' }, { key: 'notifications', label: '通知' }, { key: 'retention', label: '保留与审计' }]} />
<div className="grid gap-5 xl:grid-cols-2">
<SettingCard
{activeTab === 'instance' && <SettingCard
icon={CloudCog}
title="实例信息"
description="公开名称、地址与协议协商信息。"
@@ -127,9 +130,9 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
<StatusBadge status="兼容" tone="online" />
</div>
</div>
</SettingCard>
</SettingCard>}
<SettingCard
{activeTab === 'infrastructure' && <SettingCard
icon={Database}
title="基础设施"
description="状态可见,敏感连接信息不在 Web 控制台回显。"
@@ -150,9 +153,9 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
</div>
))}
</div>
</SettingCard>
</SettingCard>}
<SettingCard
{activeTab === 'identity' && <SettingCard
icon={KeyRound}
title="身份与登录"
description="本地账号可用;OIDC 需要部署管理员配置固定回调地址。"
@@ -172,9 +175,9 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
<StatusBadge status="已启用" tone="online" />
</div>
</div>
</SettingCard>
</SettingCard>}
<SettingCard
{activeTab === 'relay' && <SettingCard
icon={RadioTower}
title="中继与联邦"
description="控制面独立运行,关闭中继不会影响设备注册和目录。"
@@ -193,9 +196,9 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
description="仍需双方管理员核对指纹并分别确认关系。"
/>
</div>
</SettingCard>
</SettingCard>}
<SettingCard
{activeTab === 'notifications' && <SettingCard
icon={Mail}
title="通知"
description="用于邀请、审批和高影响状态通知。"
@@ -207,9 +210,9 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
</span>
<Button size="sm" variant="secondary">发送测试</Button>
</div>
</SettingCard>
</SettingCard>}
<SettingCard
{activeTab === 'retention' && <SettingCard
icon={Braces}
title="保留与审计"
description="默认只保留有界元数据,不保存提示正文、文件与模型输出。"
@@ -231,9 +234,9 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
</select>
</label>
</div>
</SettingCard>
</SettingCard>}
</div>
<section className="mt-5 rounded-2xl border border-rose-400/15 bg-rose-400/[0.035] p-5">
{activeTab === 'instance' && <section className="mt-5 rounded-2xl border border-rose-400/15 bg-rose-400/[0.035] p-5">
<div className="flex items-start gap-3">
<ShieldCheck className="mt-0.5 size-5 shrink-0 text-rose-300" />
<div className="flex-1">
@@ -244,7 +247,7 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
</div>
<Button variant="danger">删除组织</Button>
</div>
</section>
</section>}
</>
)
}
+61 -99
View File
@@ -1,122 +1,84 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { prototypeSnapshot } from '../../../shared/prototype-data'
import App from '../App'
import { sharedItems, sharingAvailability } from '../lib/sharing-fixtures'
function emptyDirectory() {
return vi.fn().mockImplementation(async (url: string) => {
if (url === '/api/v1/sharing/devices') return { ok: true, json: async () => ({ devices: [] }) }
if (url === '/api/v1/sharing/publications') return { ok: true, json: async () => ({ publications: [] }) }
throw new Error(`Unexpected request: ${url}`)
})
}
describe('sharing center', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/capabilities')
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => prototypeSnapshot }))
vi.stubGlobal('fetch', emptyDirectory())
})
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
it('keeps synced copies available offline while remote retrieval waits for the device', () => {
const synced = sharedItems.find((item) => item.id === 'k-1')!
const remote = sharedItems.find((item) => item.id === 'k-2')!
expect(sharingAvailability(synced, '离线').available).toBe(true)
expect(sharingAvailability(remote, '离线').available).toBe(false)
expect(sharingAvailability(remote, '在线').available).toBe(true)
expect(sharingAvailability(remote, '未知').available).toBe(false)
expect(sharingAvailability({ ...synced, paused: true }, '离线').available).toBe(false)
expect(sharingAvailability(synced, '已撤销').available).toBe(false)
})
it('displays independent permissions and source availability without granting content access to admins', async () => {
window.history.replaceState({}, '', '/capabilities?view=knowledge')
it.each(['', '?view=local', '?view=knowledge&item=k-1'])('loads the real directory without fixture tabs at /capabilities%s', async (search) => {
window.history.replaceState({}, '', `/capabilities${search}`)
render(<App />)
await screen.findByRole('heading', { name: '共享中心' })
const synced = screen.getByRole('link', { name: '设计规范归档' }).closest('article')!
expect(within(synced).getByText(/设备状态:离线/)).toBeInTheDocument()
expect(within(synced).getByText('服务器副本就绪(示例)')).toBeInTheDocument()
expect(within(synced).getByText('读取原文:允许')).toBeInTheDocument()
expect(within(synced).getByText('下载:未授权')).toBeInTheDocument()
fireEvent.click(screen.getByRole('link', { name: '设计项目素材' }))
const dialog = screen.getByRole('dialog', { name: '设计项目素材详情' })
expect(within(dialog).getByText('搜索:允许')).toBeInTheDocument()
expect(within(dialog).getByText('读取原文:未授权')).toBeInTheDocument()
expect(within(dialog).getByText('下载:允许')).toBeInTheDocument()
expect(within(dialog).getByText('设备离线,远程取用不可用')).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: '下载(未接入)' })).toBeDisabled()
expect(within(dialog).getByText(/管理员身份不会增加这些权限/)).toBeInTheDocument()
expect(fetch).toHaveBeenCalledTimes(1)
expect(screen.getByRole('heading', { name: '共享中心' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { name: '尚无发布记录' })).toBeInTheDocument()
expect(screen.getByText(/尚无登记设备/)).toBeInTheDocument()
expect(screen.getAllByRole('tab')).toHaveLength(4)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.queryByText(/设计规范归档|设计项目素材|GPU-Worker|MesaLab|当前示例访问者/)).not.toBeInTheDocument()
expect(fetch).toHaveBeenCalledTimes(2)
for (const resource of ['devices', 'publications']) {
expect(fetch).toHaveBeenCalledWith(`/api/v1/sharing/${resource}`, { signal: expect.any(AbortSignal) })
}
})
it('shows metadata-only knowledge even when all content permissions are denied', async () => {
window.history.replaceState({}, '', '/capabilities?view=knowledge&item=k-4')
render(<App />)
const dialog = await screen.findByRole('dialog', { name: '受限研究资料详情' })
for (const action of ['搜索', '读取原文', '下载']) expect(within(dialog).getByText(`${action}:未授权`)).toBeInTheDocument()
for (const button of within(dialog).getAllByRole('button', { name: /未接入/ })) expect(button).toBeDisabled()
})
it('combines filters, recovers from no results, and restores URL state on remount', async () => {
window.history.replaceState({}, '', '/capabilities?view=knowledge')
it('preserves local URL filters across mounting and editing', async () => {
window.history.replaceState({}, '', '/capabilities?view=local&device=registered-device&kind=knowledge&status=revoked&q=manual')
const app = render(<App />)
await screen.findByRole('heading', { name: '共享中心' })
fireEvent.change(screen.getByLabelText('内容来源'), { target: { value: 'synced' } })
fireEvent.change(screen.getByLabelText('可用状态'), { target: { value: 'ready' } })
fireEvent.change(screen.getByLabelText('共享对象'), { target: { value: '全组织' } })
fireEvent.change(screen.getByLabelText('搜索共享元数据'), { target: { value: 'Design-Mac' } })
expect(screen.getByText('当前结果:1 项共享内容')).toBeInTheDocument()
expect(screen.getByRole('link', { name: '设计规范归档' })).toBeInTheDocument()
await screen.findByRole('heading', { name: '尚无发布记录' })
expect(screen.getByLabelText('登记设备')).toHaveValue('registered-device')
expect(screen.getByLabelText('共享类型')).toHaveValue('knowledge')
expect(screen.getByLabelText('发布状态')).toHaveValue('revoked')
expect(screen.getByLabelText('搜索本地元数据')).toHaveValue('manual')
fireEvent.change(screen.getByLabelText('搜索本地元数据'), { target: { value: 'updated' } })
const params = new URLSearchParams(window.location.search)
expect(params.get('device')).toBe('registered-device')
expect(params.get('kind')).toBe('knowledge')
expect(params.get('status')).toBe('revoked')
expect(params.get('q')).toBe('updated')
app.unmount()
render(<App />)
await screen.findByRole('heading', { name: '共享中心' })
expect(screen.getByLabelText('搜索共享元数据')).toHaveValue('Design-Mac')
expect(screen.getByText('当前结果:1 项共享内容')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('可用状态'), { target: { value: 'unavailable' } })
expect(screen.getByRole('heading', { name: '没有符合条件的结果' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
expect(window.location.search).toBe('?view=knowledge')
expect(screen.getByText('当前结果:4 项共享内容')).toBeInTheDocument()
expect(await screen.findByLabelText('搜索本地元数据')).toHaveValue('updated')
})
it('navigates from content to publisher and provider with matching details and clears stale filters', async () => {
window.history.replaceState({}, '', '/capabilities?view=knowledge&q=归档&item=k-1')
it('shows API errors without substituting examples and recovers on retry', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false, status: 503,
json: async () => ({ code: 'STORAGE_UNAVAILABLE', requestId: 'sharing-request' })
}))
render(<App />)
const detail = await screen.findByRole('dialog', { name: '设计规范归档详情' })
fireEvent.click(within(detail).getByRole('link', { name: '发布者:陈汐' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByRole('tab', { name: '按用户查看' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByLabelText('发布用户')).toHaveValue('m-2')
expect(screen.getByText('当前结果:3 项共享内容')).toBeInTheDocument()
fireEvent.click(screen.getByRole('link', { name: '图像生成' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('link', { name: '提供设备:GPU-Worker-02' }))
expect(screen.getByLabelText('提供设备')).toHaveValue('d-2')
expect(screen.getByRole('link', { name: '代码安全扫描' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('link', { name: '代码安全扫描' }))
expect(within(screen.getByRole('dialog')).getByText('发布已暂停')).toBeInTheDocument()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('HTTP 503')
expect(alert).toHaveTextContent('STORAGE_UNAVAILABLE')
expect(alert).toHaveTextContent('sharing-request')
expect(screen.queryByRole('article')).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: '尚无发布记录' })).not.toBeInTheDocument()
vi.stubGlobal('fetch', emptyDirectory())
fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByRole('heading', { name: '尚无发布记录' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('links the existing device detail to its exact sharing view including empty devices', async () => {
window.history.replaceState({}, '', '/devices')
it('aborts directory requests when navigating away', () => {
const signals: AbortSignal[] = []
vi.stubGlobal('fetch', vi.fn().mockImplementation((_url, { signal }) => {
signals.push(signal)
return new Promise(() => {})
}))
render(<App />)
await screen.findByRole('heading', { name: 'GoodBuddy 接入' })
fireEvent.click(screen.getByRole('button', { name: /Design-Mac-07/ }))
const link = within(screen.getByRole('dialog')).getByRole('link', { name: '查看此设备的共享内容' })
expect(link).toHaveAttribute('href', '/capabilities?view=devices&device=d-3')
fireEvent.click(link)
expect(screen.getByLabelText('提供设备')).toHaveValue('d-3')
expect(screen.getByText('当前结果:2 项共享内容')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('提供设备'), { target: { value: 'd-4' } })
expect(screen.getByRole('heading', { name: 'Legacy-Lab · 已撤销 · 0 项' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: '没有符合条件的结果' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
expect(screen.getByText('当前结果:7 项共享内容')).toBeInTheDocument()
})
it('supports keyboard tabs and keeps invalid detail links recoverable', async () => {
render(<App />)
await screen.findByRole('heading', { name: '共享中心' })
fireEvent.keyDown(screen.getByRole('tab', { name: '能力目录' }), { key: 'ArrowRight' })
expect(screen.getByRole('tab', { name: '共享知识库' })).toHaveFocus()
expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', 'sharing-tab-knowledge')
fireEvent.keyDown(screen.getByRole('tab', { name: '共享知识库' }), { key: 'End' })
expect(screen.getByRole('tab', { name: '按设备查看' })).toHaveFocus()
cleanup()
window.history.replaceState({}, '', '/capabilities?view=knowledge&item=missing')
render(<App />)
expect(await screen.findByRole('dialog', { name: '共享内容不存在' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('link', { name: '运行概览' }))
expect(signals).toHaveLength(5)
expect(signals.slice(0, 2).every((signal) => signal.aborted)).toBe(true)
expect(screen.getByRole('heading', { name: '运行概览' })).toBeInTheDocument()
})
})
+8 -121
View File
@@ -1,128 +1,15 @@
import { Link, useSearchParams } from 'react-router-dom'
import type { PrototypeSnapshot } from '../../../shared/prototype-data'
import { sharedItems, sharingAvailability, type SharedItem } from '../lib/sharing-fixtures'
import { PageHeader } from './page-header'
import { Button } from './ui/button'
import { Dialog } from './ui/dialog'
import { StatusBadge } from './ui/status-badge'
import { LocalSharing } from './local-sharing'
import { useState } from 'react'
import { PageTabs } from './page-tabs'
const tabs = [ ['local', '本地服务目录'], ['catalog', '能力目录'], ['knowledge', '共享知识库'], ['users', '按用户查看'], ['devices', '按设备查看'] ] as const
const panel = 'space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5'
const muted = 'text-sm leading-6 text-[var(--text-muted)]'
const control = 'mt-1 w-full min-w-0 rounded-lg border border-white/10 bg-[var(--control)] p-2 text-sm focus-visible:outline-2'
const link = 'rounded text-sm underline underline-offset-4 focus-visible:outline-2'
const sourceLabels = { synced: '服务器同步副本', remote: '远程设备取用' }
export function SharingCenter({ snapshot }: { snapshot: PrototypeSnapshot }) {
const [params, setParams] = useSearchParams()
const view = tabs.some(([key]) => key === params.get('view')) ? params.get('view')! : 'catalog'
const query = params.get('q') ?? ''
const status = params.get('status') ?? ''
const source = params.get('source') ?? ''
const audience = params.get('audience') ?? ''
const scopeKey = view === 'users' ? 'user' : 'device'
const scope = params.get(scopeKey) ?? ''
const entities = snapshot.pages[view === 'users' ? 'members' : 'devices'] ?? []
const device = (item: SharedItem) => snapshot.pages.devices.find((entry) => entry.id === item.device)
const publisher = (item: SharedItem) => snapshot.pages.members.find((entry) => entry.id === item.publisher)
const availability = (item: SharedItem) => sharingAvailability(item, device(item)?.status ?? '未知')
const update = (key: string, value: string) => {
const next = new URLSearchParams(params)
if (value) next.set(key, value)
else next.delete(key)
next.delete('item')
setParams(next, { replace: true })
}
const itemHref = (item: SharedItem) => {
const next = new URLSearchParams(params)
next.set('item', item.id)
return `/capabilities?${next}`
}
const visible = sharedItems.filter((item) => {
if (view === 'catalog' && item.kind !== 'capability') return false
if (view === 'knowledge' && item.kind !== 'knowledge') return false
if (view === 'users' && scope && item.publisher !== scope) return false
if (view === 'devices' && scope && item.device !== scope) return false
return (!query.trim() || [item.name, item.version, publisher(item)?.primary, device(item)?.primary, item.audience].join(' ').toLowerCase().includes(query.trim().toLowerCase())) &&
(!status || (status === 'ready' ? availability(item).available : !availability(item).available)) &&
(!source || item.source === source) && (!audience || item.audience === audience)
})
const selected = sharedItems.find((item) => item.id === params.get('item'))
const grouped = view === 'users' || view === 'devices'
const permissionSummary = (item: SharedItem) => <div className="space-y-2">
<p className={muted}>当前示例访问者的显式权限(独立判断)</p>
<div className="flex flex-wrap gap-3 text-sm">
<span>搜索:{item.permissions.search ? '允许' : '未授权'}</span>
<span>读取原文:{item.permissions.read ? '允许' : '未授权'}</span>
<span>下载:{item.permissions.download ? '允许' : '未授权'}</span>
</div>
</div>
const metadata = (item: SharedItem) => <>
<div className="flex flex-wrap gap-4">
<Link className={link} to={`/capabilities?view=users&user=${item.publisher}`}>发布者:{publisher(item)?.primary ?? item.publisher}</Link>
<Link className={link} to={`/capabilities?view=devices&device=${item.device}`}>提供设备:{device(item)?.primary ?? item.device}</Link>
</div>
<p className={muted}>版本:{item.version} · 设备状态:{device(item)?.status ?? '未知'} · 共享对象:{item.audience}</p>
<p className={muted}>来源:{sourceLabels[item.source]} · 发布状态:{item.paused ? '已暂停' : '已发布'}</p>
{item.kind === 'knowledge' && permissionSummary(item)}
</>
const cards = (items: SharedItem[]) => items.map((item) => <article key={item.id} className={panel}>
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="font-semibold text-[var(--text-strong)]"><Link className={link} to={itemHref(item)}>{item.name}</Link></h3>
<StatusBadge status={availability(item).label} tone={availability(item).available ? 'online' : 'warning'} />
</div>
{metadata(item)}
</article>)
export function SharingCenter() {
const [activeTab, setActiveTab] = useState('capabilities')
return <>
<PageHeader eyebrow={view === 'local' ? '当前本地服务 · 真实元数据' : `示例组织 · ${snapshot.instance.organization}`} title="共享中心" description={view === 'local' ? '查看当前服务登记的设备与发布记录。其他页签为示例数据。' : '查看示例共享内容、发布用户和提供设备的关联。'} />
<div role="tablist" aria-label="共享中心分类" className="mb-5 flex max-w-full shrink-0 gap-1 overflow-x-auto rounded-xl border border-white/10 p-1">
{tabs.map(([key, label], index) => <button key={key} id={`sharing-tab-${key}`} role="tab" aria-selected={view === key} aria-controls="sharing-panel" tabIndex={view === key ? 0 : -1}
className={`min-h-10 shrink-0 rounded-lg px-3 text-sm focus-visible:outline-2 ${view === key ? 'bg-[var(--control)] font-semibold text-[var(--text-strong)]' : 'text-[var(--text-muted)]'}`}
onClick={() => setParams({ view: key })} onKeyDown={(event) => {
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : event.key === 'ArrowRight' ? (index + 1) % tabs.length : event.key === 'ArrowLeft' ? (index + tabs.length - 1) % tabs.length : null
if (next === null) return
event.preventDefault()
setParams({ view: tabs[next][0] })
document.getElementById(`sharing-tab-${tabs[next][0]}`)?.focus()
}}>{label}</button>)}
</div>
<section id="sharing-panel" role="tabpanel" aria-labelledby={`sharing-tab-${view}`} className="space-y-4">
{view === 'local' ? <LocalSharing /> : <>
<p className={muted}>交互原型:仅展示示例元数据。管理员可见元数据不代表获得内容权限;本页不搜索正文、读取原文、下载文件或调用能力。</p>
<div className={panel}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<label className="text-sm">搜索共享元数据<input className={control} value={query} placeholder="名称、发布者、设备或版本" onChange={(event) => update('q', event.target.value)} /></label>
<label className="text-sm">可用状态<select className={control} value={status} onChange={(event) => update('status', event.target.value)}><option value="">全部状态</option><option value="ready">就绪</option><option value="unavailable">不可用</option></select></label>
<label className="text-sm">内容来源<select className={control} value={source} onChange={(event) => update('source', event.target.value)}><option value="">全部来源</option><option value="synced">服务器同步副本</option><option value="remote">远程设备取用</option></select></label>
<label className="text-sm">共享对象<select className={control} value={audience} onChange={(event) => update('audience', event.target.value)}><option value="">全部对象</option>{[...new Set(sharedItems.map((item) => item.audience))].map((value) => <option key={value}>{value}</option>)}</select></label>
{grouped && <label className="text-sm">{view === 'users' ? '发布用户' : '提供设备'}<select className={control} value={scope} onChange={(event) => update(scopeKey, event.target.value)}><option value="">全部</option>{entities.map((entity) => <option key={entity.id} value={entity.id}>{entity.primary}</option>)}</select></label>}
</div>
<div className="flex flex-wrap items-center justify-between gap-3"><p className={muted} role="status">当前结果:{visible.length} 项共享内容</p><Button variant="secondary" onClick={() => setParams({ view })}>清除筛选</Button></div>
</div>
{grouped ? entities.filter((entity) => !scope || entity.id === scope).map((entity) => {
const items = visible.filter((item) => (view === 'users' ? item.publisher : item.device) === entity.id)
return <section key={entity.id} className="space-y-4" aria-label={`${entity.primary}的共享内容`}>
<h2 className="font-semibold">{entity.primary} · {entity.status} · {items.length} 项</h2>
{cards(items)}
{!items.length && <p className={muted}>当前条件下没有共享内容。</p>}
</section>
}) : cards(visible)}
{!visible.length && <div className={panel}><h2 className="font-semibold">没有符合条件的结果</h2><p className={muted}>请修改关键词或使用“清除筛选”恢复当前页签的全部示例。</p></div>}
</>}
<PageHeader eyebrow="当前本地服务" title="共享中心" description="查看当前服务登记的设备与发布记录。" />
<PageTabs title="共享中心" active={activeTab} onChange={setActiveTab} tabs={[{ key: 'capabilities', label: '能力目录' }, { key: 'knowledge', label: '共享知识库' }, { key: 'members', label: '按成员查看' }, { key: 'devices', label: '按设备查看' }]} />
<section aria-label="本地服务目录" className="space-y-4">
{activeTab === 'capabilities' ? <LocalSharing /> : <div className="space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5"><h2 className="font-semibold">{activeTab === 'knowledge' ? '共享知识库' : activeTab === 'members' ? '按成员查看' : '按设备查看'}</h2><p className="text-sm leading-6 text-[var(--text-muted)]">该 Tab 的生产接口尚未接入。当前目录只保存发布元数据,不提供正文读取、成员授权或设备在线状态。</p></div>}
</section>
<Dialog open={view !== 'local' && params.has('item')} onOpenChange={(open) => { if (!open) { const next = new URLSearchParams(params); next.delete('item'); setParams(next) } }} title={selected ? `${selected.name}详情` : '共享内容不存在'} description="仅查看示例元数据,不访问真实内容。">
{selected ? <div className="space-y-4">
{metadata(selected)}
<StatusBadge status={availability(selected).label} tone={availability(selected).available ? 'online' : 'warning'} />
{selected.kind === 'knowledge' && <>
<p className={muted}>{selected.source === 'synced' ? '示例副本已同步到服务器;来源设备离线不影响该版本副本。更新仍需设备重新连接。' : '内容保留在提供设备,取用依赖该设备在线;服务器未保存内容副本。'}</p>
<p className={muted}>授权与来源可用性分别判断,允许搜索不代表允许读取原文或下载。管理员身份不会增加这些权限。</p>
<div className="flex flex-wrap gap-2"><Button disabled variant="secondary">搜索正文(未接入)</Button><Button disabled variant="secondary">读取原文(未接入)</Button><Button disabled variant="secondary">下载(未接入)</Button></div>
</>}
{selected.kind === 'capability' && <p className={muted}>就绪仅表示示例设备在线且发布未暂停,不代表当前访问者获准调用。能力执行未接入。</p>}
</div> : <p className={muted}>该链接没有对应的示例条目,请关闭详情并重新选择。</p>}
</Dialog>
</>
}
+21
View File
@@ -0,0 +1,21 @@
import { Link } from 'react-router-dom'
import { PageHeader } from './page-header'
import { StatusBadge } from './ui/status-badge'
import { PageTabs, type PageTab } from './page-tabs'
import { useState } from 'react'
type UnavailablePageProps = { title: string; description: string; boundary: string; next?: { label: string; to: string }; tabs?: PageTab[] }
export function UnavailablePage({ title, description, boundary, next, tabs }: UnavailablePageProps) {
const [active, setActive] = useState(tabs?.[0]?.key ?? '')
return <>
<PageHeader eyebrow="服务端接口 · 待接入" title={title} description={description} />
{tabs?.length ? <PageTabs title={title} tabs={tabs} active={active} onChange={setActive} /> : null}
<section className="space-y-5 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-3"><h2 className="font-semibold">当前没有可用的业务数据</h2><StatusBadge status="待接入" tone="warning" /></div>
{tabs?.length ? <p className="text-sm font-medium text-[var(--text-primary)]">当前 Tab:{tabs.find((tab) => tab.key === active)?.label}</p> : null}
<p className="max-w-3xl text-sm leading-6 text-[var(--text-muted)]">{boundary}</p>
{next && <Link className="inline-flex min-h-9 items-center rounded-lg bg-cyan-400 px-3 text-sm font-medium text-slate-950" to={next.to}>{next.label}</Link>}
</section>
</>
}
+38
View File
@@ -0,0 +1,38 @@
const errors: Record<string, string> = {
CREDENTIAL_UNAVAILABLE: '服务器无法加密或读取凭据,请管理员检查网关主密钥配置。',
CREDENTIAL_NOT_CONFIGURED: '请编辑上游并设置 API Key,或选择无需认证。',
INVALID_BASE_URL: '请输入 HTTP 或 HTTPS 地址,不含用户名、密码、查询参数或片段。',
INVALID_GATEWAY_BASE_URL: '请管理员检查 SHARESERVER_GATEWAY_BASE_URL 配置。',
INVALID_CREDENTIAL_ACTION: '请检查凭据操作;只有替换凭据时才可填写 API Key。',
INVALID_ALIAS: '模型别名须以字母或数字开头,仅支持字母、数字、点、下划线、冒号、斜杠和连字符。',
ALIAS_EXISTS: '该模型别名已存在,请更换别名。',
INVALID_MODEL_SCOPE: '请至少选择一个仍存在的模型,刷新后重试。',
UPSTREAM_HTTP_ERROR: '上游拒绝请求,请检查上游地址、凭据和模型权限。',
UPSTREAM_FAILURE: '无法连接上游,请检查地址和服务器网络。',
REQUEST_TIMEOUT: '请求超时,请检查上游服务后重试。',
INVALID_UPSTREAM_RESPONSE: '上游响应不符合 OpenAI Chat Completions 协议。',
GATEWAY_STORAGE_UNAVAILABLE: '网关数据无法读取或保存,请管理员检查存储目录。',
MODEL_DISABLED: '模型或上游已停用,请启用后重试。',
UPSTREAM_DISABLED: '上游已停用,请启用后重试。',
ADMIN_ACCESS_DENIED: '管理操作仅允许从服务器本机直接访问,请检查访问地址与代理配置。',
INVALID_TEXT: '请检查必填项、长度及不可见字符。',
INVALID_FIELDS: '请求字段不符合服务器接口,请刷新页面后重试。',
MODEL_NOT_FOUND: '模型已不存在,请刷新配置后重试。',
UPSTREAM_NOT_FOUND: '上游已不存在,请刷新配置后重试。',
ACCESS_KEY_NOT_FOUND: '访问密钥已不存在,请刷新列表后重试。',
}
export async function gatewayRequest<T>(path: string, signal: AbortSignal, method = 'GET', body?: unknown): Promise<T> {
let response: Response
try {
response = await fetch(`/api/v1/gateway${path}`, { signal, method, ...(body === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) })
} catch { throw new Error('网络请求失败,请检查连接后重试。') }
if (!response.ok) {
const envelope = await response.json().catch(() => null)
const code = envelope?.error?.code ?? envelope?.code
// Never render arbitrary server messages: they may echo submitted credentials.
const message = typeof code === 'string' && Object.hasOwn(errors, code) ? errors[code] : '请求失败,请检查输入与服务器配置后重试。'
throw new Error(`HTTP ${response.status} · ${message}`)
}
return response.json() as Promise<T>
}
+15
View File
@@ -0,0 +1,15 @@
export async function sharingRequest<T>(
path: string,
signal?: AbortSignal,
method = 'GET'
): Promise<T> {
const init: RequestInit = { signal }
if (method !== 'GET') init.method = method
const response = await fetch(`/api/v1/sharing${path}`, init)
const payload = await response.json().catch(() => null) as { code?: string; message?: string; requestId?: string } | null
if (!response.ok) {
const details = [payload?.code, payload?.requestId ? `请求 ID:${payload.requestId}` : ''].filter(Boolean).join(' · ')
throw new Error(`${payload?.message ?? `HTTP ${response.status}`}${details ? `(${details})` : ''}`)
}
return payload as T
}
+93
View File
@@ -0,0 +1,93 @@
// @vitest-environment node
import { once } from 'node:events'
import { createServer as createHttpServer, request, type OutgoingHttpHeaders } from 'node:http'
import type { AddressInfo } from 'node:net'
import { networkInterfaces } from 'node:os'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createServer, type ViteDevServer } from 'vite'
import config from '../vite.config'
describe('Vite API socket boundary over real HTTP', () => {
const forwarded: string[] = []
const peers: string[] = []
const backend = createHttpServer((req, res) => {
forwarded.push(req.url!)
res.end('backend reached')
})
const lanAddress = Object.values(networkInterfaces()).flat().find(address => address?.family === 'IPv4' && !address.internal)?.address
let vite: ViteDevServer | undefined
let port: number
beforeAll(async () => {
backend.listen(0, '127.0.0.1')
await once(backend, 'listening')
vite = await createServer({
...config,
configFile: false,
plugins: [{
name: 'observe-real-peer',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
peers.push(req.socket.remoteAddress!)
next()
})
}
}, ...config.plugins!],
server: {
...config.server,
host: '::',
port: 0,
hmr: false,
watch: null,
proxy: { '/api': `http://127.0.0.1:${(backend.address() as AddressInfo).port}` }
}
})
await vite.listen()
port = (vite.httpServer!.address() as AddressInfo).port
})
afterAll(async () => {
await vite?.close()
if (backend.listening) await new Promise<void>((resolve, reject) => backend.close(error => error ? reject(error) : resolve()))
})
function get(host: string, path: string, headers: OutgoingHttpHeaders = {}, method = 'GET') {
return new Promise<{ status: number; body: string }>((resolve, reject) => {
const req = request({ host, port, path, headers, method, agent: false }, res => {
let body = ''
res.setEncoding('utf8')
res.on('data', chunk => { body += chunk })
res.on('end', () => resolve({ status: res.statusCode!, body }))
})
req.on('error', reject)
req.setTimeout(5000, () => req.destroy(new Error('HTTP request timed out')))
req.end()
})
}
it('defaults to an explicit IPv4 loopback listener', () => {
expect(config.server?.host).toBe('127.0.0.1')
})
it.each(['127.0.0.1', '::1'])('proxies loopback %s regardless of forwarded headers', async host => {
const path = '/api/v1/gateway/config'
const count = forwarded.length
expect(await get(host, path, { 'X-Forwarded-For': '203.0.113.10' })).toEqual({ status: 200, body: 'backend reached' })
expect(forwarded.slice(count)).toEqual([path])
expect(peers.at(-1)).toBe(host === '127.0.0.1' ? '::ffff:127.0.0.1' : '::1')
})
it.skipIf(!lanAddress)('rejects LAN reads and mutations before proxying, even with spoofed loopback headers', async () => {
const count = forwarded.length
for (const [method, path] of [['GET', '/api/v1/gateway/config'], ['POST', '/api/v1/gateway/access-keys'], ['PUT', '/api/v1/gateway/upstreams/test'], ['GET', '/api'], ['GET', '/api/v1/models']]) {
for (const headers of [{}, { Forwarded: 'for=127.0.0.1', 'X-Forwarded-For': '::1', 'X-Real-IP': '127.0.0.1', Host: 'localhost' }]) {
const response = await get(lanAddress!, path, headers, method)
expect(response.status).toBe(403)
expect(JSON.parse(response.body).error.code).toBe('ADMIN_ACCESS_DENIED')
}
}
expect(peers.at(-1)).toBe(`::ffff:${lanAddress}`)
expect(forwarded).toHaveLength(count)
expect((await get(lanAddress!, '/@vite/client')).status).toBe(200)
})
})
+18 -2
View File
@@ -1,19 +1,35 @@
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { isIP } from 'node:net'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
export default defineConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
plugins: [react(), tailwindcss()],
plugins: [react(), tailwindcss(), {
name: 'loopback-api-only',
configureServer(server) {
// Check the original socket before the proxy replaces it with a local connection.
server.middlewares.use((req, res, next) => {
if (!req.url?.startsWith('/api')) return next()
const address = req.socket.remoteAddress?.toLowerCase()
const ipv4 = address?.startsWith('::ffff:') ? address.slice(7) : address
if (address === '::1' || (ipv4 !== undefined && isIP(ipv4) === 4 && ipv4.startsWith('127.'))) return next()
res.writeHead(403, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: { code: 'ADMIN_ACCESS_DENIED', message: 'Direct loopback administration required.' } }))
})
}
}],
build: {
outDir: '../dist/web',
emptyOutDir: true
},
server: {
host: '127.0.0.1',
port: 5174,
strictPort: true,
proxy: {
'/api': 'http://127.0.0.1:8787'
'/api': `http://127.0.0.1:${process.env.SHARESERVER_API_PORT ?? process.env.SHARESERVER_PORT ?? '8787'}`
}
}
})