feat: add local sharing and containerized web/server layout

This commit is contained in:
lofyer
2026-10-01 12:01:44 +08:00
parent 599a0a6645
commit c9d5e63276
51 changed files with 2212 additions and 233 deletions
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#07111f" />
<title>ShareServer Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+111
View File
@@ -0,0 +1,111 @@
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 App from './App'
describe('ShareServer console', () => {
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => prototypeSnapshot
})
)
window.history.replaceState({}, '', '/')
})
it('renders the organization overview from the API snapshot', async () => {
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()
})
it('opens global search with the keyboard shortcut', async () => {
render(<App />)
await screen.findByRole('heading', { name: '运行概览' })
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }))
expect(await screen.findByRole('dialog', { 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 () => {
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()
})
it('removes personal delete authority after document transfer and preserves ownership through restore', async () => {
window.history.replaceState({}, '', '/documents')
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()
})
it('records denied gateway calls and hides administration from member navigation', async () => {
window.history.replaceState({}, '', '/model-gateway')
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()
})
})
+132
View File
@@ -0,0 +1,132 @@
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 { 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>
)
}
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>
</BrowserRouter>
)
}
+185
View File
@@ -0,0 +1,185 @@
import {
Activity,
Boxes,
Building2,
CircleUserRound,
Cpu,
FileText,
FileKey2,
Gauge,
GitFork,
PackageCheck,
Network,
Send,
ScrollText,
Settings2,
ShieldCheck,
UserRoundCheck
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { PrototypeRole } from '../../shared/prototype-data'
export type PageConfig = {
path: string
title: string
shortTitle?: string
description: string
dataKey?: string
icon: LucideIcon
role: PrototypeRole | 'all'
primaryAction?: string
group?: string
tabs?: Array<{ label: string; dataKey: string }>
}
export const pages: PageConfig[] = [
{
path: '/',
title: '运行概览',
description: 'GoodBuddy 组织服务、接入状态与待处理事项。',
icon: Gauge,
role: 'admin'
},
{
path: '/model-gateway',
title: '模型网关',
description: '接入上游模型服务,管理转发、访问策略和调用审计;ShareServer 不运行模型。',
icon: Network,
role: 'admin',
group: 'GoodBuddy 服务'
},
{
path: '/documents',
title: '文档与协作',
description: '文档存储在 ShareServer,在 GoodBuddy 中使用;管理归属、回收站和 OnlyOffice 服务。',
icon: FileText,
role: 'admin',
group: 'GoodBuddy 服务'
},
{
path: '/distribution',
title: '配置下发',
description: '向 GoodBuddy 分配网关模型配置,查看版本、生效情况与回滚结果。',
icon: Send,
role: 'admin',
group: 'GoodBuddy 服务'
},
{
path: '/members',
title: '组织与成员',
description: '管理当前组织的成员、用户组和有界角色。',
dataKey: 'members',
icon: Building2,
role: 'admin',
primaryAction: '邀请成员',
tabs: [
{ label: '成员', dataKey: 'members' },
{ label: '用户组', dataKey: 'groups' },
{ label: '角色', dataKey: 'roles' }
]
},
{
path: '/devices',
title: 'GoodBuddy 接入',
description: '管理 GoodBuddy 客户端与设备的注册、连接状态和接入授权。',
dataKey: 'devices',
icon: Cpu,
role: 'admin',
primaryAction: '生成注册链接'
},
{
path: '/capabilities',
title: '共享中心',
description: '按内容、发布用户或提供设备查看共享能力与知识库。',
dataKey: 'capabilities',
icon: Boxes,
role: 'admin'
},
{
path: '/policies',
title: '权限策略',
description: '配置拒绝优先的组织强制策略与 Grant。',
dataKey: 'policies',
icon: ShieldCheck,
role: 'admin',
primaryAction: '新建策略'
},
{
path: '/approvals',
title: '审批',
description: '处理调用、发布、更新和联邦申请。',
dataKey: 'approvals',
icon: UserRoundCheck,
role: 'admin'
},
{
path: '/releases',
title: '资源与更新',
description: '管理签名能力包、发布通道、更新策略和撤销。',
dataKey: 'releases',
icon: PackageCheck,
role: 'admin',
primaryAction: '上传能力包'
},
{
path: '/federation',
title: '网关互联',
description: '管理经双边确认的组织协作边界。',
dataKey: 'federation',
icon: GitFork,
role: 'admin',
primaryAction: '建立联邦关系'
},
{
path: '/tasks',
title: '任务中心',
description: '查看任务路由、终态和可选中继容量。',
dataKey: 'tasks',
icon: Activity,
role: 'admin'
},
{
path: '/audit',
title: '操作审计',
description: '按组织查询有界操作元数据,不包含任务正文。',
dataKey: 'audit',
icon: ScrollText,
role: 'admin',
primaryAction: '导出当前结果'
},
{
path: '/settings',
title: '系统设置',
description: '查看实例、身份源、基础设施状态与保留策略。',
icon: Settings2,
role: 'admin'
},
{
path: '/me/devices',
title: '我的设备',
description: '查看你在当前组织注册的设备与连接状态。',
dataKey: 'myDevices',
icon: CircleUserRound,
role: 'member'
},
{
path: '/me/grants',
title: '我的授权',
description: '查看你获准发现、调用或下载的能力。',
dataKey: 'myGrants',
icon: FileKey2,
role: 'member'
},
{
path: '/me/requests',
title: '我的申请',
description: '跟踪你的设备、能力与任务审批。',
dataKey: 'myRequests',
icon: UserRoundCheck,
role: 'member'
}
]
export function visiblePages(role: PrototypeRole): PageConfig[] {
return pages.filter((page) => page.role === 'all' || page.role === role)
}
+255
View File
@@ -0,0 +1,255 @@
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 { cn } from '../lib/utils'
import { Button } from './ui/button'
import { Dialog } from './ui/dialog'
function BrandMark() {
return (
<div className="relative grid size-10 shrink-0 place-items-center rounded-xl border border-cyan-300/25 bg-cyan-300/10 text-cyan-300 shadow-[0_0_34px_-10px_rgba(34,211,238,.9)]">
<span className="absolute inset-1 rounded-lg border border-violet-400/20" />
<Shield aria-hidden="true" className="size-5" />
</div>
)
}
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) => ({
...page,
group: page.group ?? (role === 'member' ? '成员自助' : page.path === '/' ? '概览' : '组织与运维')
}))
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}
</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">
<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) => {
const Icon = item.icon
return (
<div key={item.path}>
{index > 0 && item.group !== navigation[index - 1].group ? <p className="px-3 pb-2 pt-4 text-[11px] font-medium text-slate-500">{item.group}</p> : null}
<NavLink
to={item.path}
end={item.path === '/'}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
'group relative flex min-h-10 items-center gap-3 rounded-xl px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-cyan-400/70',
isActive
? 'bg-cyan-300/[0.09] font-medium text-cyan-100'
: 'text-slate-400 hover:bg-white/[0.045] hover:text-slate-100'
)
}
>
{({ isActive }) => (
<>
{isActive ? (
<span className="absolute inset-y-2 left-0 w-0.5 rounded-full bg-cyan-300 shadow-[0_0_12px_rgba(34,211,238,.9)]" />
) : 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>
</div>
)
})}
</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>
</>
)
return (
<div className="min-h-screen bg-[var(--canvas)] text-[var(--text-primary)]">
<div className="pointer-events-none fixed inset-0 overflow-hidden" aria-hidden="true">
<div className="absolute -left-60 -top-72 size-[620px] rounded-full bg-cyan-500/[0.055] blur-3xl" />
<div className="absolute -right-48 top-24 size-[520px] rounded-full bg-violet-500/[0.06] blur-3xl" />
<div className="grid-overlay absolute inset-0 opacity-40" />
</div>
<aside className="app-sidebar fixed inset-y-0 left-0 z-30 hidden w-[258px] flex-col border-r border-white/[0.075] bg-[#07111f]/95 backdrop-blur-xl lg:flex">
{nav}
</aside>
{mobileOpen ? (
<div className="fixed inset-0 z-40 lg:hidden">
<button
aria-label="关闭导航"
className="absolute inset-0 bg-slate-950/75 backdrop-blur-sm"
onClick={() => setMobileOpen(false)}
/>
<aside className="app-sidebar relative flex h-full w-[min(300px,86vw)] flex-col border-r border-white/10 bg-[#07111f] shadow-2xl">
<Button
aria-label="关闭导航"
className="absolute right-3 top-3 z-10"
size="icon"
variant="ghost"
onClick={() => setMobileOpen(false)}
>
<X className="size-5" />
</Button>
{nav}
</aside>
</div>
) : null}
<div className="relative lg:pl-[258px]">
<header className="sticky top-0 z-20 flex h-16 items-center gap-3 border-b border-white/[0.07] bg-[var(--header)] px-4 backdrop-blur-xl sm:px-6">
<Button
aria-label="打开导航"
className="lg:hidden"
size="icon"
variant="ghost"
onClick={() => setMobileOpen(true)}
>
<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
aria-label={dark ? '切换到浅色主题' : '切换到深色主题'}
size="icon"
variant="ghost"
onClick={() => setDark((value) => !value)}
>
{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>
)
}
+423
View File
@@ -0,0 +1,423 @@
import {
ArrowRight,
Check,
ChevronLeft,
ChevronRight,
Filter,
MoreHorizontal,
Search,
SlidersHorizontal,
X
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import type { PrototypeRecord, PrototypeSnapshot } from '../../../shared/prototype-data'
import type { PageConfig } from '../app-config'
import { cn } from '../lib/utils'
import { Button } from './ui/button'
import { Dialog } from './ui/dialog'
import { StatusBadge } from './ui/status-badge'
import { PageHeader } from './page-header'
type Feedback = {
tone: 'success' | 'danger'
message: string
}
async function sendPrototypeAction(action: string, targetId: string): Promise<void> {
const response = await fetch('/api/v1/web/prototype/actions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action, targetId })
})
if (!response.ok) {
throw new Error('原型操作未能提交,请重试。')
}
}
function DetailPanel({
record,
page,
onAction
}: {
record: PrototypeRecord
page: PageConfig
onAction: (action: string, record: PrototypeRecord) => void
}) {
return (
<div>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/[0.07] pb-5">
<div>
<p className="text-base font-semibold text-slate-100">{record.primary}</p>
<p className="mt-1 text-sm text-slate-400">{record.secondary}</p>
</div>
<StatusBadge status={record.status} tone={record.statusTone} />
</div>
<dl className="divide-y divide-white/[0.06]">
{Object.entries(record.detail).map(([label, value]) => (
<div key={label} className="grid gap-1 py-4 sm:grid-cols-[140px_1fr] sm:gap-4">
<dt className="text-xs font-medium text-slate-500">{label}</dt>
<dd className="text-sm leading-6 text-slate-200">{value}</dd>
</div>
))}
</dl>
<div className="mt-5 flex flex-wrap justify-end gap-2 border-t border-white/[0.07] pt-5">
{page.path === '/devices' && (
<Link className="rounded-lg p-2 text-sm underline focus-visible:outline-2" to={`/capabilities?view=devices&device=${encodeURIComponent(record.id)}`}>
查看此设备的共享内容
</Link>
)}
{page.path === '/approvals' ? (
<>
<Button variant="danger" onClick={() => onAction('拒绝申请', record)}>
<X className="size-4" />
拒绝
</Button>
<Button onClick={() => onAction('批准申请', record)}>
<Check className="size-4" />
批准此申请
</Button>
</>
) : null}
{page.path === '/devices' && record.status !== '已撤销' ? (
<Button variant="danger" onClick={() => onAction('撤销设备', record)}>
撤销设备
</Button>
) : null}
{page.path === '/tasks' && record.status === '运行中' ? (
<Button variant="danger" onClick={() => onAction('取消任务', record)}>
取消任务
</Button>
) : null}
{page.path === '/federation' ? (
<Button variant="secondary" onClick={() => onAction('切换联邦状态', record)}>
{record.status === '已连接' ? '暂停关系' : '恢复关系'}
</Button>
) : null}
{!['/approvals', '/devices', '/tasks', '/federation'].includes(page.path) ? (
<Button variant="secondary" onClick={() => onAction('保存变更', record)}>
编辑配置
</Button>
) : null}
</div>
</div>
)
}
export function DataPage({
page,
snapshot
}: {
page: PageConfig
snapshot: PrototypeSnapshot
}) {
const [activeKey, setActiveKey] = useState(page.dataKey ?? '')
const [query, setQuery] = useState('')
const [statusFilter, setStatusFilter] = useState('全部状态')
const [selected, setSelected] = useState<PrototypeRecord | null>(null)
const [actionTarget, setActionTarget] = useState<{
action: string
record: PrototypeRecord
} | null>(null)
const [primaryOpen, setPrimaryOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [feedback, setFeedback] = useState<Feedback | null>(null)
const records = useMemo(
() => snapshot.pages[activeKey] ?? [],
[activeKey, snapshot.pages]
)
const statuses = ['全部状态', ...new Set(records.map((item) => item.status))]
const visibleRecords = useMemo(() => {
const normalized = query.trim().toLowerCase()
return records.filter((item) => {
const matchesQuery =
!normalized ||
[item.primary, item.secondary, ...item.meta]
.join(' ')
.toLowerCase()
.includes(normalized)
const matchesStatus = statusFilter === '全部状态' || item.status === statusFilter
return matchesQuery && matchesStatus
})
}, [query, records, statusFilter])
const handleAction = (action: string, record: PrototypeRecord) => {
setSelected(null)
setActionTarget({ action, record })
}
const confirmAction = async () => {
if (!actionTarget) return
setSubmitting(true)
try {
await sendPrototypeAction(actionTarget.action, actionTarget.record.id)
setFeedback({
tone: 'success',
message: `${actionTarget.action}已在交互原型中完成,不会写入生产数据。`
})
setActionTarget(null)
} catch (error) {
setFeedback({
tone: 'danger',
message: error instanceof Error ? error.message : '操作失败,请重试。'
})
} finally {
setSubmitting(false)
}
}
return (
<>
<PageHeader
eyebrow={`组织 · ${snapshot.instance.organization}`}
title={page.title}
description={page.description}
action={
page.primaryAction ? (
<Button onClick={() => setPrimaryOpen(true)}>
{page.primaryAction}
<ArrowRight className="size-4" />
</Button>
) : undefined
}
/>
{feedback ? (
<div
role={feedback.tone === 'danger' ? 'alert' : 'status'}
className={cn(
'mb-5 flex items-center justify-between gap-4 rounded-xl border px-4 py-3 text-sm',
feedback.tone === 'success'
? 'border-emerald-400/20 bg-emerald-400/[0.08] text-emerald-200'
: 'border-rose-400/20 bg-rose-400/[0.08] text-rose-200'
)}
>
<span>{feedback.message}</span>
<button
aria-label="关闭消息"
className="rounded p-1 outline-none hover:bg-white/10 focus-visible:ring-2"
onClick={() => setFeedback(null)}
>
<X className="size-4" />
</button>
</div>
) : null}
{page.tabs ? (
<div
role="tablist"
aria-label={`${page.title}分类`}
className="mb-5 flex w-fit max-w-full gap-1 overflow-x-auto rounded-xl border border-white/[0.07] bg-white/[0.025] p-1"
>
{page.tabs.map((tab) => (
<button
key={tab.dataKey}
role="tab"
aria-selected={activeKey === tab.dataKey}
className={cn(
'min-h-8 shrink-0 rounded-lg px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-cyan-400/70',
activeKey === tab.dataKey
? 'bg-white/[0.09] font-medium text-slate-100 shadow-sm'
: 'text-slate-500 hover:text-slate-200'
)}
onClick={() => {
setActiveKey(tab.dataKey)
setStatusFilter('全部状态')
setQuery('')
}}
>
{tab.label}
</button>
))}
</div>
) : null}
<section className="overflow-hidden rounded-2xl border border-white/[0.075] bg-[var(--panel)] shadow-[0_18px_50px_-36px_rgba(0,0,0,.7)]">
<div className="flex flex-col gap-3 border-b border-white/[0.07] p-4 sm:flex-row sm:items-center">
<label className="relative min-w-0 flex-1 sm:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-2.5 size-4 text-slate-500" />
<span className="sr-only">搜索{page.title}</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
className="h-9 w-full rounded-lg border border-white/[0.08] bg-black/10 pl-9 pr-3 text-sm outline-none placeholder:text-slate-600 focus:border-cyan-300/35 focus:ring-2 focus:ring-cyan-400/10"
placeholder={`搜索${page.title}…`}
/>
</label>
<label className="relative">
<span className="sr-only">状态筛选</span>
<Filter className="pointer-events-none absolute left-3 top-2.5 size-4 text-slate-500" />
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
className="h-9 min-w-36 appearance-none rounded-lg border border-white/[0.08] bg-[var(--control)] pl-9 pr-8 text-sm text-slate-300 outline-none focus:border-cyan-300/35 focus:ring-2 focus:ring-cyan-400/10"
>
{statuses.map((status) => (
<option key={status}>{status}</option>
))}
</select>
</label>
<Button variant="secondary">
<SlidersHorizontal className="size-4" />
更多筛选
</Button>
</div>
<div className="hidden grid-cols-[minmax(220px,1.45fr)_minmax(160px,.85fr)_minmax(300px,1.7fr)_44px] border-b border-white/[0.06] px-5 py-3 text-[11px] font-medium uppercase tracking-[0.12em] text-slate-400 md:grid">
<span>对象</span>
<span>状态</span>
<span>范围与事实</span>
<span className="sr-only">操作</span>
</div>
<div className="divide-y divide-white/[0.055]">
{visibleRecords.map((item) => (
<button
key={item.id}
className="group grid w-full gap-4 px-5 py-4 text-left outline-none transition hover:bg-cyan-300/[0.025] focus-visible:bg-cyan-300/[0.04] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cyan-400/50 md:grid-cols-[minmax(220px,1.45fr)_minmax(160px,.85fr)_minmax(300px,1.7fr)_44px] md:items-center"
onClick={() => setSelected(item)}
>
<span className="min-w-0">
<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>
<StatusBadge status={item.status} tone={item.statusTone} />
<span className="flex min-w-0 flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--text-muted)]">
{item.meta.map((meta) => (
<span key={meta} className="truncate">
{meta}
</span>
))}
</span>
<span className="hidden size-8 place-items-center rounded-lg text-slate-500 transition group-hover:bg-white/[0.06] group-hover:text-slate-200 md:grid">
<MoreHorizontal className="size-4" />
</span>
</button>
))}
{visibleRecords.length === 0 ? (
<div className="px-5 py-16 text-center">
<p className="text-sm font-medium text-slate-300">没有符合条件的结果</p>
<p className="mt-1 text-xs text-slate-500">请修改关键词或清除状态筛选。</p>
<Button
className="mt-4"
size="sm"
variant="secondary"
onClick={() => {
setQuery('')
setStatusFilter('全部状态')
}}
>
清除筛选
</Button>
</div>
) : null}
</div>
<footer className="flex items-center justify-between border-t border-white/[0.07] px-5 py-3 text-xs text-[var(--text-muted)]">
<span>显示 {visibleRecords.length} / {records.length} 项</span>
<div className="flex items-center gap-1">
<Button aria-label="上一页" disabled size="icon" variant="ghost">
<ChevronLeft className="size-4" />
</Button>
<span className="px-2">第 1 页</span>
<Button aria-label="下一页" disabled size="icon" variant="ghost">
<ChevronRight className="size-4" />
</Button>
</div>
</footer>
</section>
<Dialog
open={Boolean(selected)}
onOpenChange={(open) => !open && setSelected(null)}
title={`${page.title}详情`}
description="当前组织范围内可见的有界元数据。"
>
{selected ? (
<DetailPanel record={selected} page={page} onAction={handleAction} />
) : null}
</Dialog>
<Dialog
open={primaryOpen}
onOpenChange={setPrimaryOpen}
title={page.primaryAction ?? '创建对象'}
description={`该表单展示 ${page.title} 的完整交互结构;本阶段不会写入生产数据库。`}
>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
setPrimaryOpen(false)
setFeedback({
tone: 'success',
message: `${page.primaryAction ?? '操作'}已在交互原型中提交。`
})
}}
>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-slate-400">名称或标识</span>
<input
required
className="h-10 w-full rounded-lg border border-white/10 bg-white/[0.035] px-3 text-sm outline-none focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/10"
placeholder={`输入${page.title}名称`}
/>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-slate-400">范围</span>
<select className="h-10 w-full rounded-lg border border-white/10 bg-[var(--control)] px-3 text-sm outline-none focus:border-cyan-300/40">
<option>组织:{snapshot.instance.organization}</option>
<option>指定用户组</option>
<option>仅当前用户</option>
</select>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-slate-400">说明</span>
<textarea
className="min-h-24 w-full resize-y rounded-lg border border-white/10 bg-white/[0.035] p-3 text-sm outline-none focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/10"
placeholder="补充用途、影响范围或有效期"
/>
</label>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setPrimaryOpen(false)}>
取消
</Button>
<Button type="submit">{page.primaryAction ?? '提交'}</Button>
</div>
</form>
</Dialog>
<Dialog
open={Boolean(actionTarget)}
onOpenChange={(open) => !open && setActionTarget(null)}
title={actionTarget?.action ?? '确认操作'}
description={
actionTarget
? `对象:${actionTarget.record.primary}。请确认操作范围和直接影响。`
: undefined
}
>
<div className="rounded-xl border border-amber-400/15 bg-amber-300/[0.055] p-4 text-sm leading-6 text-amber-100/80">
这是交互原型。确认后会调用 ShareServer 原型 API 并返回请求编号,但不会更改生产数据。
</div>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setActionTarget(null)}>
取消
</Button>
<Button
variant={actionTarget?.action.includes('撤销') || actionTarget?.action.includes('拒绝') ? 'danger' : 'default'}
disabled={submitting}
onClick={confirmAction}
>
{submitting ? '正在提交…' : actionTarget?.action}
</Button>
</div>
</Dialog>
</>
)
}
+123
View File
@@ -0,0 +1,123 @@
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'
const devices: Device[] = [
{ id: 'real-a', name: '本机工作站', platform: 'win32', appVersion: '1.2.3', registeredAt: '2026-10-01T08:00:00.000Z', updatedAt: '2026-10-01T09:00:00.000Z' },
{ id: 'real-b', name: '资料设备', platform: 'linux', appVersion: '1.2.4', registeredAt: '2026-10-01T08:30:00.000Z', updatedAt: '2026-10-01T08:30:00.000Z' }
]
const publications: Publication[] = [
{ id: 'pub-a', deviceId: 'real-a', name: '本地分析能力', kind: 'capability', description: '处理 CSV 元数据', sourceMode: 'device', status: 'published', permissions: { search: false, read: false, download: false }, createdAt: devices[0].registeredAt, updatedAt: devices[0].updatedAt },
{ id: 'pub-b', deviceId: 'real-b', name: '已撤销资料', kind: 'knowledge', description: '预算说明', sourceMode: 'server', status: 'revoked', permissions: { search: true, read: false, download: true }, createdAt: devices[1].registeredAt, updatedAt: devices[1].updatedAt },
{ id: 'pub-c', deviceId: 'real-a', name: '本地手册', kind: 'knowledge', description: '操作参考', sourceMode: 'device', status: 'published', permissions: { search: false, read: true, download: false }, createdAt: devices[0].registeredAt, updatedAt: devices[0].updatedAt }
]
const response = (data: unknown) => ({ ok: true, json: async () => data })
function mockDirectory(deviceRows = devices, publicationRows = publications) {
return vi.fn().mockImplementation(async (url: string) => {
if (url === '/api/v1/sharing/devices') return response({ devices: deviceRows })
if (url === '/api/v1/sharing/publications') return response({ publications: publicationRows })
throw new Error(`Unexpected request: ${url}`)
})
}
function mount() { return render(<BrowserRouter><SharingCenter snapshot={prototypeSnapshot} /></BrowserRouter>) }
describe('local sharing directory', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/capabilities?view=local')
vi.stubGlobal('fetch', mockDirectory())
})
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
it('loads real metadata, registration times, revoked records and independent permission declarations', async () => {
mount()
expect(screen.getByRole('status')).toHaveTextContent('正在加载')
expect(screen.getByRole('button', { name: '刷新目录' })).toBeDisabled()
await screen.findByRole('heading', { name: '本机工作站' })
expect(fetch).toHaveBeenCalledTimes(2)
for (const resource of ['devices', 'publications']) expect(fetch).toHaveBeenCalledWith(`/api/v1/sharing/${resource}`, { signal: expect.any(AbortSignal) })
const deviceList = screen.getByRole('region', { name: '真实登记设备' })
expect(within(deviceList).getByText('2026-10-01T08:00:00.000Z')).toHaveAttribute('datetime', devices[0].registeredAt)
expect(within(deviceList).queryByText(/在线|离线/)).not.toBeInTheDocument()
const revoked = screen.getByRole('heading', { name: '已撤销资料' }).closest('article')!
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()
})
it('combines device, kind, search and status filters, restores URL state and clears no results', async () => {
const app = mount()
await screen.findByRole('heading', { name: '本机工作站' })
fireEvent.change(screen.getByLabelText('登记设备'), { target: { value: 'real-a' } })
fireEvent.change(screen.getByLabelText('共享类型'), { target: { value: 'knowledge' } })
fireEvent.change(screen.getByLabelText('搜索本地元数据'), { target: { value: ' 操作参考 ' } })
expect(screen.getByRole('status')).toHaveTextContent('1 项本地发布记录')
expect(screen.getByRole('heading', { name: '本地手册' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: '本地分析能力' })).not.toBeInTheDocument()
app.unmount()
mount()
await screen.findByRole('heading', { name: '本地手册' })
expect(screen.getByLabelText('登记设备')).toHaveValue('real-a')
expect(screen.getByLabelText('共享类型')).toHaveValue('knowledge')
expect(screen.getByLabelText('搜索本地元数据')).toHaveValue(' 操作参考 ')
fireEvent.change(screen.getByLabelText('发布状态'), { target: { value: 'revoked' } })
expect(screen.getByRole('heading', { name: '没有符合条件的本地记录' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
expect(window.location.search).toBe('?view=local')
expect(screen.getByRole('status')).toHaveTextContent('3 项本地发布记录')
fireEvent.change(screen.getByLabelText('发布状态'), { target: { value: 'revoked' } })
expect(screen.getByRole('status')).toHaveTextContent('1 项本地发布记录')
expect(screen.getByRole('heading', { name: '已撤销资料' })).toBeInTheDocument()
})
it.each(['devices', 'publications'])('shows %s API error context and recovers on retry', async (resource) => {
const fetchMock = mockDirectory()
fetchMock.mockImplementationOnce(async (url: string) => url.endsWith(resource)
? { ok: false, status: 503, json: async () => ({ code: 'STORAGE_UNAVAILABLE', requestId: 'req-local' }) }
: response({ devices }))
if (resource === 'publications') fetchMock.mockImplementationOnce(async () => ({ ok: false, status: 503, json: async () => ({ code: 'STORAGE_UNAVAILABLE', requestId: 'req-local' }) }))
vi.stubGlobal('fetch', fetchMock)
mount()
expect(await screen.findByRole('alert')).toHaveTextContent('HTTP 503')
expect(screen.getByRole('alert')).toHaveTextContent('req-local')
expect(screen.queryByRole('heading', { name: '本地分析能力' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByRole('heading', { name: '本地分析能力' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('handles network failure and an empty directory, then refreshes newly registered data', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
mount()
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to fetch')
vi.stubGlobal('fetch', mockDirectory([], []))
fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText(/尚无登记设备/)).toBeInTheDocument()
expect(screen.getByRole('heading', { name: '尚无发布记录' })).toBeInTheDocument()
vi.stubGlobal('fetch', mockDirectory())
fireEvent.click(screen.getByRole('button', { name: '刷新目录' }))
expect(await screen.findByRole('heading', { name: '本机工作站' })).toBeInTheDocument()
})
it('keeps fixture tabs separate and aborts pending local loads when leaving', async () => {
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()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '共享知识库' }))
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()
})
})
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import type { Device, Publication } from '../../../shared/sharing'
import { Button } from './ui/button'
import { StatusBadge } from './ui/status-badge'
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'
type Directory = { devices: Device[]; publications: Publication[] }
type LoadState = { status: 'loading' } | { status: 'error'; message: string } | { status: 'loaded'; data: Directory }
export function LocalSharing() {
const [params, setParams] = useSearchParams()
const [attempt, setAttempt] = useState(0)
const [state, setState] = useState<LoadState>({ status: 'loading' })
const deviceId = params.get('device') ?? ''
const kind = params.get('kind') ?? ''
const status = params.get('status') ?? ''
const query = params.get('q') ?? ''
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', '发布记录')
]).then(([devices, publications]) => {
if (!controller.signal.aborted) setState({ status: 'loaded', data: { ...devices, ...publications } })
}).catch((error: unknown) => {
if (!controller.signal.aborted) setState({ status: 'error', message: error instanceof Error ? error.message : '请求失败' })
})
return () => controller.abort()
}, [attempt])
const reload = () => { setState({ status: 'loading' }); setAttempt((value) => value + 1) }
const update = (key: string, value: string) => {
const next = new URLSearchParams(params)
if (value) next.set(key, value)
else next.delete(key)
setParams(next, { replace: true })
}
return <>
<div className={panel}>
<p className={muted}>此页读取当前服务的真实设备和发布元数据,与其他页签的模拟用户、组织数据独立。请在 GoodBuddy 应用中心登记设备、发布或撤销共享内容,再刷新此目录。</p>
<p className={muted}>登记不代表设备在线;已发布不代表可以调用。来源模式仅为声明,服务器未同步正文。知识权限分别记录声明,尚未执行授权校验;此页不搜索正文、读取原文、下载文件或调用能力。</p>
<Button variant="secondary" disabled={state.status === 'loading'} onClick={reload}>刷新目录</Button>
</div>
{state.status === 'loading' && <p role="status" className={muted}>正在加载本地服务目录…</p>}
{state.status === 'error' && <div className={panel}>
<div role="alert"><h2 className="font-semibold">本地服务目录加载失败</h2><p className={`${muted} break-words`}>{state.message}</p><p className={muted}>请确认本地服务可访问后重试。</p></div>
<Button variant="secondary" onClick={reload}>重试</Button>
</div>}
{state.status === 'loaded' && (() => {
const { devices, publications } = state.data
const visible = publications.filter((item) => (!deviceId || item.deviceId === deviceId) && (!kind || item.kind === kind) && (!status || item.status === status) &&
[item.name, item.description, item.id, item.deviceId, devices.find((device) => device.id === item.deviceId)?.name].join(' ').toLowerCase().includes(query.trim().toLowerCase()))
return <>
<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={deviceId} onChange={(event) => update('device', event.target.value)}><option value="">全部设备</option>{devices.map((device) => <option key={device.id} value={device.id}>{device.name}({device.id})</option>)}{deviceId && !devices.some((device) => device.id === deviceId) && <option value={deviceId}>未找到设备({deviceId})</option>}</select></label>
<label className="text-sm">共享类型<select className={control} value={kind} onChange={(event) => update('kind', event.target.value)}><option value="">全部类型</option><option value="capability">能力</option><option value="knowledge">知识库</option></select></label>
<label className="text-sm">发布状态<select className={control} value={status} onChange={(event) => update('status', event.target.value)}><option value="">全部状态</option><option value="published">已发布</option><option value="revoked">已撤销</option></select></label>
</div>
<div className="flex flex-wrap items-center justify-between gap-3"><p role="status" className={muted}>当前结果:{visible.length} 项本地发布记录</p><Button variant="secondary" onClick={() => setParams({ view: 'local' })}>清除筛选</Button></div>
</div>
<section className={panel} aria-label="真实登记设备">
<h2 className="font-semibold">登记设备 · {devices.length}</h2>
{!devices.length && <p className={muted}>尚无登记设备。请在 GoodBuddy 应用中心连接此服务并登记设备。</p>}
{devices.filter((device) => !deviceId || device.id === deviceId).map((device) => <div key={device.id} className="space-y-1 break-words">
<h3 className="font-semibold">{device.name}</h3>
<p className={muted}>设备标识:{device.id} · 平台:{device.platform} · 应用版本:{device.appVersion}</p>
<p className={muted}>登记时间:<time dateTime={device.registeredAt}>{device.registeredAt}</time></p>
</div>)}
</section>
{!publications.length ? <div className={panel}><h2 className="font-semibold">尚无发布记录</h2><p className={muted}>请在 GoodBuddy 应用中心发布能力或知识库,再刷新目录。</p></div> : !visible.length && <div className={panel}><h2 className="font-semibold">没有符合条件的本地记录</h2><p className={muted}>请调整条件或清除筛选。</p></div>}
{visible.map((item) => <article key={item.id} className={`${panel} break-words`}>
<div className="flex flex-wrap items-center justify-between gap-3"><h2 className="min-w-0 font-semibold text-[var(--text-strong)]">{item.name}</h2><StatusBadge status={item.status === 'revoked' ? '已撤销' : '已发布'} tone={item.status === 'revoked' ? 'warning' : 'neutral'} /></div>
<p className={`${muted} whitespace-pre-wrap`}>{item.description || '未提供描述。'}</p>
<p className={muted}>类型:{item.kind === 'knowledge' ? '知识库' : '能力'} · 提供设备:{devices.find((device) => device.id === item.deviceId)?.name ?? item.deviceId}</p>
<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>)}
</>
})()}
</>
}
+226
View File
@@ -0,0 +1,226 @@
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 { PageHeader } from './page-header'
import { StatusBadge } from './ui/status-badge'
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'
}
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
}
]
return (
<>
<PageHeader
eyebrow={`实例 · ${snapshot.instance.region}`}
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>
}
/>
<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>)}
</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>
<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>
</>
)
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react'
export function PageHeader({
title,
description,
eyebrow,
action
}: {
title: string
description: string
eyebrow?: string
action?: ReactNode
}) {
return (
<header className="mb-7 flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
<div>
{eyebrow ? (
<p className="mb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-cyan-300">
{eyebrow}
</p>
) : null}
<h1 className="text-2xl font-semibold tracking-[-0.025em] text-[var(--text-strong)] sm:text-[28px]">
{title}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-muted)]">
{description}
</p>
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</header>
)
}
+336
View File
@@ -0,0 +1,336 @@
import { createContext, useContext, useId, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react'
import type { PrototypeSnapshot } from '../../../shared/prototype-data'
import type { PageConfig } from '../app-config'
import { PageHeader } from './page-header'
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)]'
const DemoContext = createContext<{ state: DemoState; setState: Dispatch<SetStateAction<DemoState>> } | null>(null)
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 }
]
}
}
// Mount once above the route switch, never inside a keyed route element.
export function ServicesDemo({ children }: { children: ReactNode }) {
const [state, setState] = useState(initialState)
return <DemoContext.Provider value={{ state, setState }}>{children}</DemoContext.Provider>
}
function useDemo() {
const value = useContext(DemoContext)
if (!value) throw new Error('ServiceDemoPage must be rendered inside ServicesDemo')
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()
return <>
<div role="tablist" aria-label="服务分类" className="mb-5 flex max-w-full gap-1 overflow-x-auto rounded-xl border border-white/10 p-1">
{labels.map((label, index) => <button
key={label} id={`${id}-tab-${index}`} role="tab" aria-selected={active === index}
aria-controls={`${id}-panel`} tabIndex={active === index ? 0 : -1}
className={`min-h-10 shrink-0 rounded-lg px-3 text-sm focus-visible:outline-2 focus-visible:outline-current ${active === index ? 'bg-[var(--control)] font-semibold text-[var(--text-strong)]' : 'text-[var(--text-muted)]'}`}
onClick={() => setActive(index)}
onKeyDown={(event) => {
const next = event.key === 'Home' ? 0 : event.key === 'End' ? labels.length - 1 :
event.key === 'ArrowRight' ? (index + 1) % labels.length :
event.key === 'ArrowLeft' ? (index + labels.length - 1) % labels.length : null
if (next === null) return
event.preventDefault()
setActive(next)
event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('[role="tab"]')[next]?.focus()
}}
>{label}</button>)}
</div>
<section id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-tab-${active}`} tabIndex={0} className="space-y-4 outline-offset-4">
{children(active)}
</section>
</>
}
function EndpointDialog({ title, onClose, onSave }: { title: string; onClose: () => void; onSave: (name: string, url: string) => string | void }) {
const [error, setError] = useState('')
return <Dialog open onOpenChange={(open) => { if (!open) onClose() }} title={title} description="仅保存内存中的示例配置,不发送网络请求。请勿输入真实凭据。">
<form className="space-y-4" onSubmit={(event) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
const name = String(data.get('name') ?? '').trim()
let url: URL
try { url = new URL(String(data.get('url') ?? '').trim()) } catch { setError('请输入有效的 HTTPS 地址。'); return }
if (!name || url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
setError('名称不能为空;地址必须使用 HTTPS,且不能包含账号、密码、查询参数或片段。')
return
}
const problem = onSave(name, url.href)
if (problem) setError(problem)
else onClose()
}}>
<label className="block text-sm">名称<input name="name" required maxLength={60} className={control} /></label>
<label className="block text-sm">HTTPS 服务地址<input name="url" type="url" required maxLength={300} placeholder="https://service.example" className={control} /></label>
<p className={muted}>不提供密钥字段,也不会验证连接或保存凭据。</p>
{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={onClose}>取消</Button><Button type="submit">保存示例配置</Button></div>
</form>
</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)
const [transfer, setTransfer] = useState(false)
const [message, setMessage] = useState('')
const personal = state.documents.filter((item) => item.owner === 'self' && !item.deleted)
const canDelete = (document: Document) => document.owner === 'organization' ? state.organizationRole : document.owner === 'self'
const owner = (document: Document) => document.owner === 'organization' ? `组织:${organization}` : document.owner === 'self' ? '当前演示用户:林江' : '其他成员'
function setDeleted(document: Document, deleted: boolean) {
setState((current) => ({ ...current, documents: current.documents.map((item) => item.id === document.id &&
(item.owner === 'self' || (item.owner === 'organization' && current.organizationRole)) ? { ...item, deleted } : item) }))
setMessage(`${document.name} 已${deleted ? '移入回收站' : '恢复'}(模拟)。归属保持不变。`)
}
return <>
<div className={`${panel} mb-5`}>
<p className={muted}>当前演示用户:林江。控制台仅展示归属元数据,不读取个人文档正文。下方角色切换仅用于演示,不是实际授权。</p>
<label className="flex items-start gap-2 text-sm"><input type="checkbox" className="mt-1" checked={state.organizationRole} onChange={(event) => setState((current) => ({ ...current, organizationRole: event.target.checked }))} />模拟授予「组织文档管理员」角色(允许删除 / 恢复组织文档)</label>
<Button variant="secondary" onClick={() => setTransfer(true)}>打开模拟 GoodBuddy 客户端</Button>
</div>
{message && <p role="status" className="mb-4 text-sm">{message}</p>}
<Tabs labels={['组织文档', '归属元数据', 'OnlyOffice 服务器', '回收站']}>{(tab) => {
const documents = state.documents.filter((item) => tab === 3 ? item.deleted : !item.deleted && (tab === 1 || item.owner === 'organization'))
return tab === 2 ? <>
<Button onClick={() => setAdding(true)}>添加 OnlyOffice 实例</Button>
<p className={muted}>模拟健康检查只使用下方预设结果,不访问服务器,也不建立编辑会话。</p>
{state.offices.map((office) => <article className={panel} key={office.id}>
<h2 className="font-semibold">{office.name}</h2><p className={`${muted} break-all`}>{office.url}</p>
<StatusBadge status={office.checks ? `模拟${office.healthy ? '健康' : '异常'} · 已检查 ${office.checks} 次` : '尚未模拟检查'} tone={!office.checks ? 'neutral' : office.healthy ? 'online' : 'danger'} />
<label className="block text-sm">预设检查结果<select className={control} value={office.healthy ? 'healthy' : 'failed'} onChange={(event) => setState((current) => ({ ...current, offices: current.offices.map((item) => item.id === office.id ? { ...item, healthy: event.target.value === 'healthy', checks: 0 } : item) }))}><option value="healthy">模拟健康</option><option value="failed">模拟连接失败</option></select></label>
<Button variant="secondary" onClick={() => setState((current) => ({ ...current, offices: current.offices.map((item) => item.id === office.id ? { ...item, checks: item.checks + 1 } : item) }))}>执行模拟健康检查</Button>
</article>)}
</> : <>
{tab === 1 && <p className={muted}>个人文档只展示名称与归属,不提供正文预览。个人删除操作仅在模拟客户端提供。</p>}
{tab === 3 && <p className={muted}>恢复保留原归属与权限;此演示不提供永久删除。</p>}
{!documents.length && <p className={panel}>此分类暂无文档。</p>}
{documents.map((document) => <article key={document.id} className={panel}>
<h2 className="break-words font-semibold">{document.name}</h2><p className={muted}>归属:{owner(document)}</p>
<StatusBadge status={document.deleted ? '回收站' : document.owner === 'organization' ? '组织资产' : '个人资产 · 仅元数据'} tone={document.deleted ? 'warning' : 'neutral'} />
<p className={muted}>{document.owner === 'organization' ? '原个人所有者不再具有个人删除权;仅显式组织角色可删除或恢复。' : '仅个人所有者可在客户端转移或删除。'}</p>
{(document.owner === 'organization' || document.deleted) && <Button variant={document.deleted ? 'secondary' : 'danger'} disabled={!canDelete(document)} onClick={() => setDeleted(document, !document.deleted)}>{document.deleted ? '恢复文档' : '移入回收站'}</Button>}
</article>)}
</>
}}</Tabs>
{adding && <EndpointDialog title="添加 OnlyOffice 实例" onClose={() => setAdding(false)} onSave={(name, url) => {
if (state.offices.some((item) => item.name === name || item.url === url)) return '该名称或地址已存在。'
setState((current) => ({ ...current, offices: [...current.offices, { id: crypto.randomUUID(), name, url, checks: 0, healthy: true }] }))
}} />}
<Dialog open={transfer} onOpenChange={setTransfer} title="GoodBuddy 客户端(模拟)" description={`以林江身份操作自己的示例文档;转移目标:${organization}。不会打开真实客户端或上传文件。`}>
<p className={`${muted} mb-4`}>确认转移后,文档所有者变为组织,并从个人列表移除。原个人删除权立即失效;删除或恢复需要显式组织文档管理员角色。</p>
{!personal.length && <p className={muted}>没有可转移的个人文档。已转移的文档请到组织文档查看。</p>}
{personal.map((document) => <article key={document.id} className={panel}>
<h2 className="font-semibold">{document.name}</h2><p className={muted}>所有者:当前演示用户林江;仅展示元数据。</p>
<div className="flex flex-wrap gap-2"><Button onClick={() => {
setState((current) => ({ ...current, documents: current.documents.map((item) => item.id === document.id && item.owner === 'self' && !item.deleted ? { ...item, owner: 'organization' } : item) }))
setTransfer(false)
setMessage('已模拟转移到组织。原个人删除权已移除;组织文档操作取决于显式组织角色。')
}}>确认转移给组织</Button><Button variant="danger" onClick={() => { setDeleted(document, true); setTransfer(false) }}>删除我的文档</Button></div>
</article>)}
<div className="mt-4 flex justify-end"><Button variant="ghost" onClick={() => setTransfer(false)}>关闭客户端模拟</Button></div>
</Dialog>
</>
}
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>}
</div>
}
+250
View File
@@ -0,0 +1,250 @@
import {
Braces,
CheckCircle2,
CloudCog,
Database,
KeyRound,
Mail,
RadioTower,
Save,
ShieldCheck
} from 'lucide-react'
import { useState } from 'react'
import type { LucideIcon } from 'lucide-react'
import type { PrototypeSnapshot } from '../../../shared/prototype-data'
import { PageHeader } from './page-header'
import { Button } from './ui/button'
import { StatusBadge } from './ui/status-badge'
function SettingCard({
icon: Icon,
title,
description,
children
}: {
icon: LucideIcon
title: string
description: string
children: React.ReactNode
}) {
return (
<section className="rounded-2xl border border-white/[0.075] bg-[var(--panel)] p-5">
<header className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl border border-cyan-300/15 bg-cyan-300/[0.065] text-cyan-300">
<Icon className="size-5" />
</span>
<div>
<h2 className="text-sm font-semibold text-[var(--text-strong)]">{title}</h2>
<p className="mt-1 text-xs leading-5 text-[var(--text-muted)]">{description}</p>
</div>
</header>
<div className="mt-5 border-t border-white/[0.06] pt-5">{children}</div>
</section>
)
}
function Toggle({
label,
description,
checked,
onChange
}: {
label: string
description: string
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<label className="flex cursor-pointer items-start justify-between gap-4 rounded-xl p-2 outline-none hover:bg-white/[0.025]">
<span>
<span className="block text-sm font-medium text-[var(--text-primary)]">{label}</span>
<span className="mt-1 block text-xs leading-5 text-[var(--text-muted)]">{description}</span>
</span>
<input
type="checkbox"
role="switch"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="peer sr-only"
/>
<span className="relative mt-0.5 h-6 w-11 shrink-0 rounded-full border border-white/10 bg-slate-700 transition peer-checked:border-cyan-300/30 peer-checked:bg-cyan-400/80 peer-focus-visible:ring-2 peer-focus-visible:ring-cyan-400/70 after:absolute after:left-0.5 after:top-0.5 after:size-5 after:rounded-full after:bg-white after:shadow after:transition-transform peer-checked:after:translate-x-5" />
</label>
)
}
export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
const [relay, setRelay] = useState(true)
const [federation, setFederation] = useState(true)
const [oidc, setOidc] = useState(false)
const [saved, setSaved] = useState(false)
return (
<>
<PageHeader
eyebrow="实例范围"
title="系统设置"
description="管理实例公开信息与功能开关。数据库连接、主密钥和容器挂载路径只从部署环境读取。"
action={
<Button
onClick={() => {
setSaved(true)
window.setTimeout(() => setSaved(false), 4500)
}}
>
<Save className="size-4" />
保存设置
</Button>
}
/>
{saved ? (
<div role="status" className="mb-5 rounded-xl border border-emerald-400/20 bg-emerald-400/[0.07] px-4 py-3 text-sm text-emerald-200">
原型设置已保存到当前浏览器会话,不会写入生产配置。
</div>
) : null}
<div className="grid gap-5 xl:grid-cols-2">
<SettingCard
icon={CloudCog}
title="实例信息"
description="公开名称、地址与协议协商信息。"
>
<div className="grid gap-4">
<label>
<span className="mb-1.5 block text-xs font-medium text-[var(--text-muted)]">实例名称</span>
<input
defaultValue={snapshot.instance.name}
className="h-10 w-full rounded-lg border border-white/10 bg-white/[0.025] px-3 text-sm outline-none focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/10"
/>
</label>
<label>
<span className="mb-1.5 block text-xs font-medium text-[var(--text-muted)]">公开地址</span>
<input
defaultValue="https://share.mesalab.internal"
className="h-10 w-full rounded-lg border border-white/10 bg-white/[0.025] px-3 font-mono text-xs outline-none focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/10"
/>
</label>
<div className="flex items-center justify-between rounded-xl bg-white/[0.025] px-3 py-2.5">
<span className="text-xs text-[var(--text-muted)]">{snapshot.instance.protocol}</span>
<StatusBadge status="兼容" tone="online" />
</div>
</div>
</SettingCard>
<SettingCard
icon={Database}
title="基础设施"
description="状态可见,敏感连接信息不在 Web 控制台回显。"
>
<div className="space-y-2">
{[
['PostgreSQL', '正常', '主数据与审计权威'],
['对象存储', '正常', 'Package 与有界中继暂存'],
['网关身份', '正常', '证书将在 82 天后轮换']
].map(([name, status, copy]) => (
<div key={name} className="flex items-center gap-3 rounded-xl bg-white/[0.025] px-3 py-3">
<CheckCircle2 className="size-4 shrink-0 text-emerald-300" />
<span className="min-w-0 flex-1">
<span className="block text-sm text-[var(--text-primary)]">{name}</span>
<span className="block truncate text-xs text-[var(--text-muted)]">{copy}</span>
</span>
<span className="text-xs text-emerald-300">{status}</span>
</div>
))}
</div>
</SettingCard>
<SettingCard
icon={KeyRound}
title="身份与登录"
description="本地账号可用;OIDC 需要部署管理员配置固定回调地址。"
>
<div className="space-y-2">
<Toggle
checked={oidc}
onChange={setOidc}
label="允许企业 OIDC"
description="使用 state、nonce 和 PKCE 完成登录。"
/>
<div className="flex items-center justify-between rounded-xl p-2">
<span>
<span className="block text-sm text-[var(--text-primary)]">本地账号</span>
<span className="mt-1 block text-xs text-[var(--text-muted)]">首个系统管理员已完成初始化</span>
</span>
<StatusBadge status="已启用" tone="online" />
</div>
</div>
</SettingCard>
<SettingCard
icon={RadioTower}
title="中继与联邦"
description="控制面独立运行,关闭中继不会影响设备注册和目录。"
>
<div className="space-y-2">
<Toggle
checked={relay}
onChange={setRelay}
label="启用数据中继"
description="网络无法直连且双方策略允许时提供有界中继。"
/>
<Toggle
checked={federation}
onChange={setFederation}
label="允许网关联邦"
description="仍需双方管理员核对指纹并分别确认关系。"
/>
</div>
</SettingCard>
<SettingCard
icon={Mail}
title="通知"
description="用于邀请、审批和高影响状态通知。"
>
<div className="flex items-center justify-between rounded-xl bg-white/[0.025] px-3 py-3">
<span>
<span className="block text-sm text-[var(--text-primary)]">SMTP 邮件</span>
<span className="mt-1 block text-xs text-[var(--text-muted)]">凭据由部署环境提供,永不回显</span>
</span>
<Button size="sm" variant="secondary">发送测试</Button>
</div>
</SettingCard>
<SettingCard
icon={Braces}
title="保留与审计"
description="默认只保留有界元数据,不保存提示正文、文件与模型输出。"
>
<div className="grid gap-4 sm:grid-cols-2">
<label>
<span className="mb-1.5 block text-xs font-medium text-[var(--text-muted)]">审计保留期</span>
<select className="h-10 w-full rounded-lg border border-white/10 bg-[var(--control)] px-3 text-sm outline-none">
<option>365 天</option>
<option>180 天</option>
<option>90 天</option>
</select>
</label>
<label>
<span className="mb-1.5 block text-xs font-medium text-[var(--text-muted)]">实例时区</span>
<select className="h-10 w-full rounded-lg border border-white/10 bg-[var(--control)] px-3 text-sm outline-none">
<option>Asia/Shanghai (UTC+8)</option>
<option>UTC</option>
</select>
</label>
</div>
</SettingCard>
</div>
<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">
<h2 className="text-sm font-semibold text-rose-200">危险操作</h2>
<p className="mt-1 text-xs leading-5 text-rose-200/60">
删除组织前必须停止新任务、撤销联邦和设备,并按保留策略归档审计。
</p>
</div>
<Button variant="danger">删除组织</Button>
</div>
</section>
</>
)
}
+122
View File
@@ -0,0 +1,122 @@
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 App from '../App'
import { sharedItems, sharingAvailability } from '../lib/sharing-fixtures'
describe('sharing center', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/capabilities')
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => prototypeSnapshot }))
})
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')
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)
})
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')
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()
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()
})
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')
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()
})
it('links the existing device detail to its exact sharing view including empty devices', async () => {
window.history.replaceState({}, '', '/devices')
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()
})
})
+128
View File
@@ -0,0 +1,128 @@
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'
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>)
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>}
</>}
</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>
</>
}
+48
View File
@@ -0,0 +1,48 @@
import { cva, type VariantProps } from 'class-variance-authority'
import type { ButtonHTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
const buttonVariants = cva(
'inline-flex min-h-9 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition-[color,background-color,border-color,box-shadow,transform] duration-150 outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70 disabled:pointer-events-none disabled:opacity-45 active:translate-y-px',
{
variants: {
variant: {
default:
'border border-cyan-300/25 bg-cyan-400 text-slate-950 shadow-[0_0_28px_-8px_rgba(34,211,238,.75)] hover:bg-cyan-300',
secondary:
'border border-white/10 bg-white/[0.055] text-slate-100 hover:border-white/20 hover:bg-white/[0.09]',
ghost: 'text-slate-300 hover:bg-white/[0.07] hover:text-white',
danger:
'border border-rose-400/25 bg-rose-500/12 text-rose-200 hover:bg-rose-500/20'
},
size: {
default: 'h-9',
sm: 'h-8 min-h-8 px-2.5 text-xs',
icon: 'size-9 min-h-9 px-0'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>
export function Button({
className,
variant,
size,
type = 'button',
...props
}: ButtonProps) {
return (
<button
type={type}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
)
}
+46
View File
@@ -0,0 +1,46 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import type { ReactNode } from 'react'
export function Dialog({
open,
onOpenChange,
title,
description,
children,
width = 'max-w-xl'
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
children: ReactNode
width?: string
}) {
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-slate-950/75 backdrop-blur-sm data-[state=open]:animate-in" />
<DialogPrimitive.Content
className={`fixed left-1/2 top-1/2 z-50 max-h-[86vh] w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-2xl border border-white/10 bg-[var(--dialog)] p-6 text-[var(--text-primary)] shadow-2xl outline-none ${width}`}
>
<div className="pr-10">
<DialogPrimitive.Title className="text-lg font-semibold tracking-tight">
{title}
</DialogPrimitive.Title>
{description ? (
<DialogPrimitive.Description className="mt-1 text-sm leading-6 text-slate-400">
{description}
</DialogPrimitive.Description>
) : null}
</div>
<DialogPrimitive.Close className="absolute right-4 top-4 grid size-9 place-items-center rounded-lg text-slate-400 outline-none transition hover:bg-white/[0.07] hover:text-white focus-visible:ring-2 focus-visible:ring-cyan-400/70">
<X aria-hidden="true" className="size-4" />
<span className="sr-only">关闭</span>
</DialogPrimitive.Close>
<div className="mt-6">{children}</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { CircleCheck, CircleMinus, CircleX, Clock3, Radio } from 'lucide-react'
import type { PrototypeRecord } from '../../../../shared/prototype-data'
import { cn } from '../../lib/utils'
const styles: Record<PrototypeRecord['statusTone'], string> = {
online: 'border-emerald-400/20 bg-emerald-400/10 text-emerald-300',
warning: 'border-amber-400/20 bg-amber-400/10 text-amber-200',
offline: 'border-slate-400/15 bg-slate-400/10 text-slate-400',
danger: 'border-rose-400/20 bg-rose-400/10 text-rose-300',
neutral: 'border-violet-400/20 bg-violet-400/10 text-violet-300'
}
const icons = {
online: CircleCheck,
warning: Clock3,
offline: CircleMinus,
danger: CircleX,
neutral: Radio
}
export function StatusBadge({
status,
tone,
className
}: {
status: string
tone: PrototypeRecord['statusTone']
className?: string
}) {
const Icon = icons[tone]
return (
<span
className={cn(
'inline-flex w-fit items-center gap-1.5 rounded-full border px-2 py-1 text-xs font-medium',
styles[tone],
className
)}
>
<Icon aria-hidden="true" className="size-3.5" />
{status}
</span>
)
}
+34
View File
@@ -0,0 +1,34 @@
// Local metadata fixtures only. No document bodies, credentials or content API calls.
export type SharedItem = {
id: string
name: string
kind: 'capability' | 'knowledge'
publisher: string
device: string
version: string
audience: string
paused?: boolean
source: 'remote' | 'synced'
permissions: { search: boolean; read: boolean; download: boolean }
}
export const sharedItems: SharedItem[] = [
{ id: 'c-1', name: '图像生成', kind: 'capability', publisher: 'm-2', device: 'd-2', version: '2.1.0', audience: '设计中心', source: 'remote', permissions: { search: false, read: false, download: false } },
{ id: 'c-2', name: '文档转 Word', kind: 'capability', publisher: 'm-1', device: 'd-1', version: '1.4.0', audience: '全组织', source: 'remote', permissions: { search: false, read: false, download: false } },
{ id: 'c-3', name: '代码安全扫描', kind: 'capability', publisher: 'm-1', device: 'd-2', version: '3.0.2', audience: '平台工程', source: 'remote', paused: true, permissions: { search: false, read: false, download: false } },
{ id: 'k-1', name: '设计规范归档', kind: 'knowledge', publisher: 'm-2', device: 'd-3', version: '2026.09', audience: '全组织', source: 'synced', permissions: { search: true, read: true, download: false } },
{ id: 'k-2', name: '设计项目素材', kind: 'knowledge', publisher: 'm-2', device: 'd-3', version: '1.2.0', audience: '设计中心', source: 'remote', permissions: { search: true, read: false, download: true } },
{ id: 'k-3', name: '工程操作手册', kind: 'knowledge', publisher: 'm-1', device: 'd-1', version: '3.0.0', audience: '平台工程', source: 'remote', permissions: { search: true, read: false, download: false } },
{ id: 'k-4', name: '受限研究资料', kind: 'knowledge', publisher: 'm-1', device: 'd-2', version: '0.8.0', audience: '指定用户:林江', source: 'synced', permissions: { search: false, read: false, download: false } }
]
export function sharingAvailability(item: SharedItem, deviceStatus: string) {
if (item.paused) return { available: false, label: '发布已暂停' }
if (deviceStatus === '已撤销') return { available: false, label: '设备已撤销,发布不可用' }
if (item.kind === 'knowledge' && item.source === 'synced') {
return { available: true, label: '服务器副本就绪(示例)' }
}
return deviceStatus === '在线'
? { available: true, label: '远程设备就绪(示例)' }
: { available: false, label: '设备离线,远程取用不可用' }
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)
+204
View File
@@ -0,0 +1,204 @@
@import "tailwindcss";
:root,
:root[data-theme="dark"] {
color-scheme: dark;
--canvas: #07111f;
--header: rgba(7, 17, 31, 0.78);
--sidebar: #07111f;
--panel: rgba(12, 25, 43, 0.78);
--dialog: #0a1525;
--control: #0d1b2e;
--text-strong: #f1f6fd;
--text-primary: #cdd9e8;
--text-muted: #718198;
}
:root[data-theme="light"] {
color-scheme: light;
--canvas: #edf3fa;
--header: rgba(247, 250, 253, 0.82);
--sidebar: #0a1627;
--panel: rgba(255, 255, 255, 0.86);
--dialog: #ffffff;
--control: #f7fafc;
--text-strong: #0e1a2b;
--text-primary: #2b3b50;
--text-muted: #52657d;
}
* {
box-sizing: border-box;
}
html {
min-width: 320px;
background: var(--canvas);
font-family:
Inter, "Microsoft YaHei UI", "Noto Sans SC", system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", sans-serif;
text-rendering: optimizeLegibility;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
::selection {
background: rgba(34, 211, 238, 0.25);
color: #fff;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
border: 3px solid transparent;
border-radius: 999px;
background: rgba(113, 129, 152, 0.35);
background-clip: padding-box;
}
.grid-overlay {
background-image:
linear-gradient(rgba(93, 125, 158, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(93, 125, 158, 0.035) 1px, transparent 1px);
background-size: 38px 38px;
mask-image: linear-gradient(to bottom, black, transparent 92%);
}
.pulse-ring {
animation: pulse-ring 2.8s ease-out infinite;
}
.pulse-delay {
animation-delay: 0.8s;
}
@keyframes pulse-ring {
0% {
opacity: 0.25;
transform: scale(0.82);
}
70% {
opacity: 0.04;
transform: scale(1.15);
}
100% {
opacity: 0;
transform: scale(1.2);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@media (max-width: 640px) {
.grid-overlay {
background-size: 28px 28px;
}
}
:root[data-theme="light"] .border-white\/\[0\.075\],
:root[data-theme="light"] .border-white\/\[0\.07\],
:root[data-theme="light"] .border-white\/\[0\.065\],
:root[data-theme="light"] .border-white\/\[0\.06\],
:root[data-theme="light"] .border-white\/\[0\.055\],
:root[data-theme="light"] .border-white\/\[0\.08\],
:root[data-theme="light"] .border-white\/10 {
border-color: rgba(45, 67, 91, 0.12);
}
:root[data-theme="light"] .text-slate-100,
:root[data-theme="light"] .text-slate-200,
:root[data-theme="light"] .text-slate-300 {
color: #25364c;
}
:root[data-theme="light"] .text-slate-400,
:root[data-theme="light"] .text-slate-500,
:root[data-theme="light"] .text-slate-600 {
color: #52657d;
}
:root[data-theme="light"] .text-cyan-100,
:root[data-theme="light"] .text-cyan-200,
:root[data-theme="light"] .text-cyan-300 {
color: #006579;
}
:root[data-theme="light"] .text-amber-100,
:root[data-theme="light"] .text-amber-200,
:root[data-theme="light"] .text-amber-300 {
color: #8c5300;
}
:root[data-theme="light"] .text-emerald-200,
:root[data-theme="light"] .text-emerald-300 {
color: #087453;
}
:root[data-theme="light"] .text-violet-200,
:root[data-theme="light"] .text-violet-300 {
color: #6842b5;
}
:root[data-theme="light"] .text-rose-200,
:root[data-theme="light"] .text-rose-300 {
color: #b81743;
}
:root[data-theme="light"] .app-sidebar .text-white,
:root[data-theme="light"] .app-sidebar .text-slate-100,
:root[data-theme="light"] .app-sidebar .text-slate-200,
:root[data-theme="light"] .app-sidebar .text-slate-300 {
color: #e8f1fb;
}
:root[data-theme="light"] .app-sidebar .text-slate-400 {
color: #9eb0c5;
}
:root[data-theme="light"] .app-sidebar .text-slate-500,
:root[data-theme="light"] .app-sidebar .text-slate-600 {
color: #72879f;
}
:root[data-theme="light"] .app-sidebar .text-cyan-100,
:root[data-theme="light"] .app-sidebar .text-cyan-200,
:root[data-theme="light"] .app-sidebar .text-cyan-300 {
color: #67e8f9;
}
:root[data-theme="light"] main .bg-white\/\[0\.025\],
:root[data-theme="light"] main .bg-white\/\[0\.035\],
:root[data-theme="light"] main .bg-white\/\[0\.04\] {
background-color: rgba(29, 56, 84, 0.035);
}
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src", "../shared"]
}
+19
View File
@@ -0,0 +1,19 @@
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
export default defineConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
plugins: [react(), tailwindcss()],
build: {
outDir: '../dist/web',
emptyOutDir: true
},
server: {
port: 5174,
proxy: {
'/api': 'http://127.0.0.1:8787'
}
}
})