feat: add ShareServer console prototype

ShareServer previously existed only as design documents. The repository now includes a standalone Hono service and a responsive React management-console prototype covering administrator and member workflows, with local build, test, and container entry points.

The sharing design now includes organization-scoped digital agents and explicit local partitioned-memory selection for @ calls. Memory documentation consistently uses partitioned memory and treats time or internal events only as optional recall triggers.

This remains an interactive prototype: fixture actions do not persist, and production authentication, PostgreSQL isolation, device connections, routing, relay, and federation still require implementation.

Release note: 新增可独立运行的 ShareServer 管理控制台交互原型,并完善组织数字人与分区记忆共享设计;当前原型数据和操作不会写入生产系统。
This commit is contained in:
mesalogo
2026-09-05 14:01:20 +08:00
parent 482cd0c467
commit 4041edefa8
49 changed files with 8614 additions and 67 deletions
+5
View File
@@ -0,0 +1,5 @@
build-server
dist
node_modules
*.log
*.png
+4
View File
@@ -0,0 +1,4 @@
build-server/
dist/
node_modules/
*.png
+24
View File
@@ -0,0 +1,24 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
ENV SHARESERVER_PORT=8787
ENV SHARESERVER_HOST=0.0.0.0
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY --from=build /app/build-server ./build-server
USER node
EXPOSE 8787
CMD ["node", "build-server/server/index.js"]
+85
View File
@@ -0,0 +1,85 @@
# ShareServer interactive prototype
This directory contains the first runnable ShareServer project. It is an
interactive control-console prototype based on the
[ShareServer PRD](../docs/features/share-server/prd.md), not a production
ShareServer implementation.
## Included
- Hono HTTP service with health, readiness, prototype snapshot, and prototype
action endpoints.
- Same-origin React, Vite, TypeScript, Tailwind CSS, and shadcn/ui-style Web
console.
- Administrator views for overview, members, devices, capabilities, policies,
approvals, releases, federation, tasks, audit, and settings.
- Member self-service views for personal devices, grants, and requests.
- Responsive navigation, dark and light themes, search, filters, details,
forms, confirmations, and keyboard-accessible dialogs.
- Production build and a non-root container image.
The UI uses official shadcn/ui conventions and Radix primitives. Its information
architecture was informed by the MIT-licensed
[`satnaing/shadcn-admin`](https://github.com/satnaing/shadcn-admin), but this
project does not copy that template or include its Clerk integration and sample
business pages.
## Not included yet
Prototype records are deliberately read-only fixtures. The action endpoint
returns an acknowledgement but does not persist changes. PostgreSQL,
initialization and login, organization isolation, real authorization, device
WebSocket connections, task routing, relay, package storage, federation, and
auditable mutations remain production implementation work.
The readiness endpoint reports this explicitly:
```json
{
"status": "ready",
"mode": "interactive-prototype",
"productionReady": false
}
```
## Run locally
```text
cd shareserver
npm install
npm run dev
```
Open `http://127.0.0.1:5174`. Vite proxies `/api` to the Hono service on port
`8787`.
## Run the production build
```text
cd shareserver
npm run build
npm start
```
Open `http://127.0.0.1:8787`.
Set `SHARESERVER_PORT` to use another listen port. Local runs bind to
`127.0.0.1` by default; set `SHARESERVER_HOST` explicitly when a trusted reverse
proxy or container needs another interface. Do not expose the prototype as a
production service.
## Container
```text
docker build -t goodbuddy-shareserver-prototype ./shareserver
docker run --rm -p 8787:8787 goodbuddy-shareserver-prototype
```
## Validation
```text
npm test
npm run typecheck
npm run lint
npm run build
```
+33
View File
@@ -0,0 +1,33 @@
import js from '@eslint/js'
import reactHooks from 'eslint-plugin-react-hooks'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{
ignores: ['build-server/**', 'dist/**', 'node_modules/**']
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
globals: {
...globals.browser,
...globals.vitest
}
},
plugins: {
'react-hooks': reactHooks
},
rules: {
...reactHooks.configs.recommended.rules
}
},
{
files: ['server/**/*.ts'],
languageOptions: {
globals: globals.node
}
}
)
+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>
+5770
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@goodbuddy/shareserver",
"version": "0.1.0-prototype.1",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently -k -n api,web -c cyan,magenta \"npm:dev:api\" \"npm:dev:web\"",
"dev:api": "tsx watch server/index.ts",
"dev:web": "vite",
"build": "npm run typecheck && vite build && npm run build:server",
"build:server": "node -e \"require('node:fs').rmSync('build-server',{recursive:true,force:true})\" && tsc -p tsconfig.server.json",
"start": "node build-server/server/index.js",
"test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.server.json",
"lint": "eslint ."
},
"dependencies": {
"@hono/node-server": "2.1.1",
"@radix-ui/react-dialog": "1.1.23",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"hono": "4.13.7",
"lucide-react": "1.27.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-router-dom": "7.18.3",
"tailwind-merge": "3.6.0"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@tailwindcss/vite": "4.3.3",
"@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2",
"@types/node": "26.1.2",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.2.0",
"concurrently": "10.0.5",
"eslint": "10.8.0",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.8.0",
"jsdom": "30.0.1",
"tailwindcss": "4.3.3",
"tsx": "4.21.0",
"typescript": "6.0.3",
"typescript-eslint": "8.65.0",
"vite": "7.3.6",
"vitest": "4.1.10"
}
}
+40
View File
@@ -0,0 +1,40 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { app } from './app.js'
describe('ShareServer prototype API', () => {
it('reports prototype readiness without claiming production readiness', async () => {
const response = await app.request('/api/v1/public/ready')
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
status: 'ready',
mode: 'interactive-prototype',
productionReady: false
})
})
it('returns the organization-scoped console snapshot', async () => {
const response = await app.request('/api/v1/web/prototype/snapshot')
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
instance: { organization: 'MesaLab' },
pages: {
devices: expect.any(Array),
approvals: expect.any(Array),
myDevices: expect.any(Array)
}
})
})
it('rejects malformed prototype actions', async () => {
const response = await app.request('/api/v1/web/prototype/actions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: '批准申请' })
})
expect(response.status).toBe(400)
await expect(response.json()).resolves.toMatchObject({
code: 'INVALID_PROTOTYPE_ACTION'
})
})
})
+75
View File
@@ -0,0 +1,75 @@
import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
import { prototypeSnapshot } from '../shared/prototype-data.js'
const app = new Hono()
app.use('*', logger())
app.use(
'*',
secureHeaders({
contentSecurityPolicy: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"]
},
crossOriginEmbedderPolicy: false
})
)
app.get('/api/v1/public/health', (context) =>
context.json({
status: 'ok',
service: 'shareserver',
version: '0.1.0-prototype.1'
})
)
app.get('/api/v1/public/ready', (context) =>
context.json({
status: 'ready',
mode: 'interactive-prototype',
productionReady: false
})
)
app.get('/api/v1/web/prototype/snapshot', (context) =>
context.json({
...prototypeSnapshot,
instance: {
...prototypeSnapshot.instance,
generatedAt: new Date().toISOString()
}
})
)
app.post('/api/v1/web/prototype/actions', async (context) => {
const body = await context.req.json<{ action?: string; targetId?: string }>().catch(() => null)
if (!body?.action || !body.targetId) {
return context.json(
{
code: 'INVALID_PROTOTYPE_ACTION',
message: '缺少原型操作或目标对象。',
requestId: crypto.randomUUID()
},
400
)
}
return context.json({
accepted: true,
action: body.action,
targetId: body.targetId,
prototypeOnly: true,
requestId: crypto.randomUUID()
})
})
app.use('*', serveStatic({ root: './dist' }))
app.get('*', serveStatic({ path: './dist/index.html' }))
export { app }
+9
View File
@@ -0,0 +1,9 @@
import { serve } from '@hono/node-server'
import { app } from './app.js'
const port = Number(process.env.SHARESERVER_PORT ?? 8787)
const hostname = process.env.SHARESERVER_HOST ?? '127.0.0.1'
serve({ fetch: app.fetch, port, hostname }, (info) => {
console.log(`ShareServer interactive prototype listening on http://${hostname}:${info.port}`)
})
+324
View File
@@ -0,0 +1,324 @@
export type PrototypeRole = 'admin' | 'member'
export type Metric = {
label: string
value: string
detail: string
tone: 'cyan' | 'violet' | 'amber' | 'rose'
target: string
}
export type PrototypeRecord = {
id: string
primary: string
secondary: string
status: string
statusTone: 'online' | 'warning' | 'offline' | 'danger' | 'neutral'
meta: string[]
detail: Record<string, string>
}
export type PrototypeSnapshot = {
instance: {
name: string
organization: string
protocol: string
region: string
generatedAt: string
}
metrics: Metric[]
activity: Array<{ time: string; label: string; value: number }>
pages: Record<string, PrototypeRecord[]>
}
const record = (
id: string,
primary: string,
secondary: string,
status: string,
statusTone: PrototypeRecord['statusTone'],
meta: string[],
detail: Record<string, string>
): PrototypeRecord => ({ id, primary, secondary, status, statusTone, meta, detail })
export const prototypeSnapshot: PrototypeSnapshot = {
instance: {
name: 'Nebula ShareServer',
organization: 'MesaLab',
protocol: 'Share Protocol 1.0',
region: '上海 · 私有云',
generatedAt: '2026-09-05T09:42:00+08:00'
},
metrics: [
{ label: '待处理审批', value: '6', detail: '最旧等待 42 分钟', tone: 'amber', target: '/approvals' },
{ label: '在线设备', value: '18 / 24', detail: '2 台需要关注', tone: 'cyan', target: '/devices' },
{ label: '可用能力', value: '37', detail: '来自 21 个 Provider', tone: 'violet', target: '/capabilities' },
{ label: '活动任务', value: '12', detail: '过去 24 小时成功率 98.2%', tone: 'rose', target: '/tasks' }
],
activity: [
{ time: '08:00', label: '任务', value: 12 },
{ time: '09:00', label: '任务', value: 19 },
{ time: '10:00', label: '任务', value: 15 },
{ time: '11:00', label: '任务', value: 26 },
{ time: '12:00', label: '任务', value: 21 },
{ time: '13:00', label: '任务', value: 33 },
{ time: '14:00', label: '任务', value: 28 },
{ time: '15:00', label: '任务', value: 42 },
{ time: '16:00', label: '任务', value: 36 },
{ time: '17:00', label: '任务', value: 48 },
{ time: '18:00', label: '任务', value: 39 },
{ time: '19:00', label: '任务', value: 51 }
],
pages: {
members: [
record('m-1', '林江', 'jiang@mesalab.internal', '活跃', 'online', ['系统管理员', '3 台设备', '刚刚'], {
身份源: '本地账号',
用户组: '平台工程、审批人',
有效授权: '12 项 Grant',
最近活动: '批准图像生成能力发布'
}),
record('m-2', '陈汐', 'xi.chen@mesalab.internal', '活跃', 'online', ['组织管理员', '2 台设备', '8 分钟前'], {
身份源: '企业 OIDC',
用户组: '设计中心',
有效授权: '8 项 Grant',
最近活动: '调用文档转换能力'
}),
record('m-3', '王简', 'jian.wang@partner.example', '待接受邀请', 'warning', ['成员', '0 台设备', '2 小时前'], {
身份源: '邀请待确认',
用户组: '法务协作',
有效授权: '邀请生效后 2 项',
最近活动: '尚未登录'
})
],
groups: [
record('g-1', '平台工程', '维护内部 Agent 与能力节点', '12 名成员', 'online', ['8 个 Grant', '4 个设备组', '今天更新'], {
成员: '12',
设备组: 'GPU 节点、开发工作站',
角色: '发布者、审批人',
范围: 'MesaLab'
}),
record('g-2', '设计中心', '使用视觉与内容能力', '28 名成员', 'online', ['5 个 Grant', '2 个设备组', '昨天更新'], {
成员: '28',
设备组: '设计工作站',
角色: '成员',
范围: 'MesaLab'
})
],
roles: [
record('r-1', '组织管理员', '管理成员、设备、策略与联邦', '6 人', 'online', ['系统内置', '高权限', '全组织'], {
权限: '组织范围管理',
绑定人数: '6',
可委托: '否',
最近变更: '系统内置'
}),
record('r-2', '能力发布者', '发布获准能力和签名包', '9 人', 'neutral', ['自定义', '有界范围', '4 个组'], {
权限: 'Publication、Package',
绑定人数: '9',
可委托: '按策略',
最近变更: '3 天前'
})
],
devices: [
record('d-1', 'Jiang-PC', '林江 · Windows 11 x64', '在线', 'online', ['2 项能力', '客户端 0.12.4', '刚刚'], {
注册状态: '已验证',
连接代次: 'generation 84',
最近地址: '内网出口 · 已脱敏',
合规状态: '符合组织基线'
}),
record('d-2', 'GPU-Worker-02', 'AI Team · Linux x64', '在线', 'online', ['8 项能力', '客户端 0.12.3', '12 秒前'], {
注册状态: '已验证',
连接代次: 'generation 221',
最近地址: '计算节点区 · 已脱敏',
合规状态: '符合组织基线'
}),
record('d-3', 'Design-Mac-07', '陈汐 · macOS arm64', '离线', 'offline', ['3 项能力', '客户端 0.12.2', '2 小时前'], {
注册状态: '已验证',
连接代次: 'generation 17',
最近地址: '办公网络 · 已脱敏',
合规状态: '客户端建议更新'
}),
record('d-4', 'Legacy-Lab', '测试组 · Linux x64', '已撤销', 'danger', ['0 项可用能力', '客户端 0.10.1', '12 天前'], {
注册状态: '2026-08-24 已撤销',
连接代次: '无活动连接',
最近地址: '不再保留',
合规状态: '不具备新任务资格'
})
],
capabilities: [
record('c-1', '图像生成', 'image.generate · 2.1.0', '2 / 3 在线', 'warning', ['3 个 Provider', '设计组', 'remote'], {
输入: '文本、参考图片',
数据策略: '任务期保留,不允许委托',
版本: '2.0.x – 2.1.x',
最近错误: 'Design-Mac-07 离线'
}),
record('c-2', '文档转 Word', 'document.convert.docx · 1.4.0', '可用', 'online', ['2 个 Provider', '全组织', 'remote + package'], {
输入: 'PDF、图片、文本',
数据策略: '处理完成立即清理',
版本: '1.4.0',
最近错误: '无'
}),
record('c-3', '代码安全扫描', 'code.security.scan · 3.0.2', '已暂停', 'offline', ['4 个 Provider', '平台工程', 'remote'], {
输入: '代码目录',
数据策略: '仅组织内,不允许联网',
版本: '3.0.2',
最近错误: 'Publication 被管理员暂停'
})
],
policies: [
record('p-1', '生产数据禁止跨组织', '组织强制策略 · deny', '已生效', 'online', ['优先级最高', '数据分类:生产', '刚刚评估'], {
作用主体: '全体成员与设备',
能力范围: '全部能力',
决策: '跨组织调用时拒绝',
修订版本: 'revision 18'
}),
record('p-2', '设计组图像能力', 'Grant · allow', '已生效', 'online', ['设计中心', '每日 20 次/人', '有效至 12-31'], {
作用主体: '用户组:设计中心',
能力范围: '图像生成 2.x',
决策: '允许文本与图片输入',
修订版本: 'revision 6'
}),
record('p-3', '外部法务文档转换', 'Grant · require_approval', '已生效', 'warning', ['联邦组织', '每次审批', '有效至 10-01'], {
作用主体: '法务服务商',
能力范围: '文档转 Word 1.4.0',
决策: '每次调用需要审批',
修订版本: 'revision 3'
})
],
approvals: [
record('a-1', '权限扩大更新', '代码安全扫描 3.1.0', '等待审批', 'warning', ['申请人:周启', '新增网络访问', '等待 42 分钟'], {
申请类型: 'Package 权限变化',
影响范围: '平台工程组 12 台设备',
策略依据: '权限扩大必须重新批准',
有效期限: '批准后 7 天内发布'
}),
record('a-2', '本次能力调用', '法务服务商 → 文档转 Word', '等待审批', 'warning', ['申请人:王简', '机密文档', '等待 18 分钟'], {
申请类型: '联邦能力调用',
影响范围: '1 个 PDF,2.8 MB',
策略依据: '外部法务文档转换',
有效期限: '本次调用,15 分钟'
}),
record('a-3', '发布图像生成能力', 'GPU-Worker-05 · 2.1.0', '等待审批', 'warning', ['申请人:苏禾', '组织内发布', '等待 6 分钟'], {
申请类型: 'Publication',
影响范围: '设计中心可发现并调用',
策略依据: '新增 Provider 需要审批',
有效期限: '批准后持续有效'
})
],
releases: [
record('pkg-1', 'document-convert', '1.4.0 · Linux/Windows/macOS', '可用', 'online', ['签名已验证', '48.2 MB', '稳定通道'], {
摘要: 'sha256: 9b3a…42e1',
发布者: 'MesaLab Platform',
权限变化: '无',
下载事实: '24 台成功,0 台失败'
}),
record('pkg-2', 'security-scanner', '3.1.0 · Linux x64', '等待审批', 'warning', ['签名已验证', '128.6 MB', '候选通道'], {
摘要: 'sha256: a18f…91d0',
发布者: 'Security Team',
权限变化: '新增受限网络访问',
下载事实: '尚未进入发布目录'
}),
record('pkg-3', 'image-runtime', '2.0.4 · Linux x64', '紧急撤销', 'danger', ['签名有效', '3.2 GB', '已停止下载'], {
摘要: 'sha256: f221…d872',
发布者: 'AI Team',
权限变化: '无',
下载事实: '因运行崩溃于 09-02 撤销'
})
],
federation: [
record('f-1', '法务服务商', 'share.legal.example', '已连接', 'online', ['2 项开放能力', '双边确认', '有效至 12-31'], {
我方开放: '文档转 Word',
对方开放: '合同审阅、法规检索',
最终可用: '2 项',
网关指纹: 'SHA256:9F:2A:…:71'
}),
record('f-2', '华东研究院', 'gateway.east-research.internal', '已暂停', 'offline', ['5 项开放能力', '我方暂停', '无新任务'], {
我方开放: '代码扫描、文档解析',
对方开放: '仿真计算、数据清洗、模型评测',
最终可用: '暂停期间 0 项',
网关指纹: 'SHA256:42:BC:…:E9'
})
],
tasks: [
record('t-1', 'task_8B2K…19QF', '陈汐 → 图像生成', '运行中', 'online', ['GPU-Worker-02', '网关直连', '已运行 38 秒'], {
数据类别: '普通 · 文本与图片',
Provider: 'GPU-Worker-02 / image.generate 2.1.0',
路由: '经 ShareServer 协商后端点直连',
最后确认点: 'Provider 已报告 62%'
}),
record('t-2', 'task_4D8M…71XA', '王简 → 文档转 Word', '等待审批', 'warning', ['法务服务商', '联邦中继', '18 分钟前'], {
数据类别: '机密 · PDF',
Provider: 'MesaLab / document.convert.docx',
路由: '联邦 + 端到端加密中继',
最后确认点: '尚未发送正文'
}),
record('t-3', 'task_2A9P…82CD', '平台工程 → 代码安全扫描', '结果未知', 'danger', ['GPU-Worker-09', '网关直连', '1 小时前'], {
数据类别: '内部 · 代码',
Provider: 'GPU-Worker-09 / code.security.scan 3.0.2',
路由: '端点直连',
最后确认点: 'Provider 执行后连接中断'
})
],
audit: [
record('e-1', '批准 Publication', '林江 · GPU-Worker-05', '成功', 'online', ['MesaLab', 'audit_7K2…1PA', '09:38:12'], {
操作者: '林江 (user_81A…)',
对象: 'publication_71B…',
结果: 'active',
时间: '2026-09-05T09:38:12+08:00'
}),
record('e-2', '撤销设备', '陈汐 · Legacy-Lab', '成功', 'online', ['MesaLab', 'audit_1M4…9FD', '09:14:07'], {
操作者: '陈汐 (user_22C…)',
对象: 'device_B82…',
结果: 'revoked',
时间: '2026-09-05T09:14:07+08:00'
}),
record('e-3', '创建联邦任务', '王简 · 法务服务商', '需要审批', 'warning', ['MesaLab', 'audit_8X7…0QA', '09:02:44'], {
操作者: '王简 (external_subject_18D…)',
对象: 'task_4D8…',
结果: 'require_approval',
时间: '2026-09-05T09:02:44+08:00'
})
],
myDevices: [
record('md-1', 'Jiang-PC', '当前设备 · Windows 11 x64', '在线', 'online', ['2 项已发布能力', '5 项可用授权', '刚刚'], {
注册时间: '2026-08-11',
当前组织: 'MesaLab',
发布能力: '屏幕理解、文档解析',
登录状态: '当前 Web 会话不等于设备凭据'
}),
record('md-2', 'Home-Mac', 'macOS arm64', '离线', 'offline', ['未发布能力', '3 项可用授权', '4 天前'], {
注册时间: '2026-07-28',
当前组织: 'MesaLab',
发布能力: '无',
登录状态: '需要重新连接 ShareServer'
})
],
myGrants: [
record('mg-1', '图像生成 2.x', '设计中心 Grant', '可调用', 'online', ['文本与图片', '每日 20 次', '组织内'], {
Provider: '3 个已发布节点',
数据范围: '普通、内部',
有效期: '2026-12-31',
委托: '不允许'
}),
record('mg-2', '文档转 Word 1.4.0', '全组织 Grant', '可调用与下载', 'online', ['PDF、图片、文本', '每次最大 10 MB', '组织内'], {
Provider: '2 个在线节点',
数据范围: '普通、内部、机密',
有效期: '持续有效',
委托: '不允许'
})
],
myRequests: [
record('mr-1', '本次能力调用', '法务服务商 → 文档转 Word', '等待审批', 'warning', ['机密文档', '18 分钟前', '15 分钟有效'], {
申请对象: 'task_4D8M…71XA',
审批范围: '仅本次调用',
当前阶段: '尚未发送正文',
可执行操作: '取消申请'
}),
record('mr-2', '设备注册', 'Home-Mac', '已批准', 'online', ['MesaLab', '2026-07-28', '已完成'], {
申请对象: 'device_91M…A21',
审批范围: '设备注册',
当前阶段: '设备当前离线',
可执行操作: '查看设备'
})
]
}
}
+32
View File
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { prototypeSnapshot } from '../shared/prototype-data'
import App from './App'
describe('ShareServer console', () => {
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()
})
})
+124
View File
@@ -0,0 +1,124 @@
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 { OverviewPage } from './components/overview-page'
import { SettingsPage } from './components/settings-page'
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 === '/settings' ? (
<SettingsPage snapshot={snapshot} />
) : (
<DataPage 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>
<Console snapshot={snapshot} role={role} onRoleChange={setRole} />
</BrowserRouter>
)
}
+157
View File
@@ -0,0 +1,157 @@
import {
Activity,
Boxes,
Building2,
CircleUserRound,
Cpu,
FileKey2,
Gauge,
GitFork,
PackageCheck,
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
tabs?: Array<{ label: string; dataKey: string }>
}
export const pages: PageConfig[] = [
{
path: '/',
title: '运行概览',
description: '需要行动的事项与当前 ShareServer 运行事实。',
icon: Gauge,
role: 'admin'
},
{
path: '/members',
title: '组织与成员',
description: '管理当前组织的成员、用户组和有界角色。',
dataKey: 'members',
icon: Building2,
role: 'admin',
primaryAction: '邀请成员',
tabs: [
{ label: '成员', dataKey: 'members' },
{ label: '用户组', dataKey: 'groups' },
{ label: '角色', dataKey: 'roles' }
]
},
{
path: '/devices',
title: '设备',
description: '查看已注册设备、连接状态与合规元数据。',
dataKey: 'devices',
icon: Cpu,
role: 'admin',
primaryAction: '生成注册链接'
},
{
path: '/capabilities',
title: '能力目录',
description: '查看显式发布的能力、版本和 Provider 可用性。',
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)
}
+250
View File
@@ -0,0 +1,250 @@
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)
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">
Control plane
</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) => {
const Icon = item.icon
return (
<NavLink
key={item.path}
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>
</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 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>
)
}
+417
View File
@@ -0,0 +1,417 @@
import {
ArrowRight,
Check,
ChevronLeft,
ChevronRight,
Filter,
MoreHorizontal,
Search,
SlidersHorizontal,
X
} from 'lucide-react'
import { useMemo, useState } from 'react'
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 === '/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>
</>
)
}
@@ -0,0 +1,214 @@
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={`${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="关键指标" 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>
</>
)
}
@@ -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>
)
}
@@ -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>
</>
)
}
+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>
)
}
@@ -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>
)
}
+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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.server.json" }
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "build-server",
"rootDir": ".",
"types": ["node"]
},
"include": ["server", "shared"],
"exclude": ["server/**/*.test.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5174,
proxy: {
'/api': 'http://127.0.0.1:8787'
}
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test-setup.ts'],
exclude: ['build-server/**', 'dist/**', 'node_modules/**']
}
})