chore: move ShareServer development to a standalone repository
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s

Remove the extracted ShareServer prototype and its server-specific documents
from Desktop. Link the shared network architecture to goodbuddy-shareserver,
while retaining Desktop ownership of the cross-product architecture.

Preserve local generated artifacts and ignore the old server build output.
Desktop runtime behavior and released user data are unchanged. Desktop tests,
typecheck, and lint passed; the standalone service was validated separately.
This commit is contained in:
mesalogo
2026-09-06 19:02:41 +08:00
parent ee804edcae
commit e692362414
40 changed files with 26 additions and 9331 deletions
+2
View File
@@ -1,6 +1,8 @@
node_modules/
out/
dist/
# Local build output left by the extracted ShareServer project.
shareserver/build-server/
coverage/
.vite/
.runtime-resources/
+24 -17
View File
@@ -9,7 +9,7 @@
| 日期 | 2026-09-03 |
| 适用范围 | GoodBuddy 桌面端、ShareServer、网关间联邦协议 |
| 桌面端设计 | [设备发现与共享](../features/device-sharing/README.md) |
| 服务端设计 | [ShareServer](../features/share-server/README.md) |
| 服务端设计 | [ShareServer](../../../goodbuddy-shareserver/docs/features/share-server/README.md) |
## 1. 文档职责
@@ -72,7 +72,8 @@ ShareServer 负责身份、目录、策略、任务路由、更新和审计。
| `Package` | 可下载到本机安装的签名能力工件 |
| `FederationTrust` | 两个组织网关之间经管理员确认的双边信任关系 |
“网关”是 ShareServer 的网络角色;产品名称和代码目录统一使用 `ShareServer` / `shareserver`。
“网关”是 ShareServer 的网络角色;产品名称为 `ShareServer`,独立仓库为
`goodbuddy-shareserver`。
## 4. 系统拓扑
@@ -406,21 +407,27 @@ Revocation
## 15. 仓库边界
目标目录结构:
当前仓库边界(跨仓库文档链接约定两个仓库克隆到同一级目录):
```text
docs/
architecture/share-network-architecture.md
features/device-sharing/
features/share-server/
packages/
share-protocol/ # 与 Electron、数据库和 Web 框架无关的 schema/类型
shareserver/ # 独立 Web 服务及管理前端
src/ # GoodBuddy Desktop
goodbuddy/
docs/architecture/share-network-architecture.md
docs/features/device-sharing/
src/ # GoodBuddy Desktop
goodbuddy-shareserver/
docs/features/share-server/
server/ # 独立 Hono Web 服务
shared/ # 当前原型 fixture,不是跨端协议
src/ # React 管理前端
```
当前文档先固定边界,不创建空服务脚手架。实现时 `shareserver` 不反向依赖桌面端 `src/shared`;
双方依赖独立 `share-protocol`。协议包不得包含数据库模型、Electron API、服务实现或凭据。
ShareServer 源码和服务端文档已迁入独立仓库:
`ssh://git@git.digiman.live:11022/root/goodbuddy-shareserver.git`。
当前服务是交互原型,尚未提供真实 Desktop 集成。共享网络总体设计仍由本文统一维护。
ShareServer 不反向依赖桌面端 `src/shared`,构建和运行不要求桌面仓库存在。
真实跨端协议实现时双方依赖独立 `share-protocol` 包,具体仓库归属和发布方式届时确定,
不提前创建空包。协议包不得包含数据库模型、Electron API、服务实现或凭据。
## 16. 共同验收标准
@@ -442,9 +449,9 @@ src/ # GoodBuddy Desktop
- [设备发现与共享功能逻辑](../features/device-sharing/logic-design.md)
- [设备发现与共享 UI](../features/device-sharing/ui-design.md)
- [设备发现与共享技术设计](../features/device-sharing/technical-design.md)
- [ShareServer PRD](../features/share-server/prd.md)
- [ShareServer 功能逻辑](../features/share-server/logic-design.md)
- [ShareServer 管理界面](../features/share-server/ui-design.md)
- [ShareServer 技术设计](../features/share-server/technical-design.md)
- [ShareServer PRD](../../../goodbuddy-shareserver/docs/features/share-server/prd.md)
- [ShareServer 功能逻辑](../../../goodbuddy-shareserver/docs/features/share-server/logic-design.md)
- [ShareServer 管理界面](../../../goodbuddy-shareserver/docs/features/share-server/ui-design.md)
- [ShareServer 技术设计](../../../goodbuddy-shareserver/docs/features/share-server/technical-design.md)
- [SSH 远程主机与 Agent](../features/remote-host/README.md)
- [GoodBuddy 统一界面设计系统](../../UI-DESIGN.md)
-32
View File
@@ -1,32 +0,0 @@
# ShareServer
ShareServer 是 GoodBuddy 设备共享网络的独立 Web 服务。它为一个或多个组织提供身份与设备
目录、能力发布、授权策略、任务路由、可选数据中继、能力包更新、网关联邦和审计管理。
ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不自动取得设备上的能力、
文件、凭据或 SSH Host。
## 文档导航
| 文档 | 权威职责 |
| --- | --- |
| [PRD](./prd.md) | 管理角色、产品范围、功能需求和验收 |
| [功能逻辑](./logic-design.md) | 组织隔离、设备、发布、策略、任务和联邦状态规则 |
| [管理界面设计](./ui-design.md) | ShareServer Web 控制台的信息架构与交互 |
| [技术设计](./technical-design.md) | 服务模块、API、数据、部署、运维和验证 |
| [实施进度](./progress.md) | 已验证实现、剩余生产工作与验证证据 |
| [共享网络总体设计](../../architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威 |
当前没有单独的 User Stories:管理员、能力发布者、审计员和普通成员场景已进入 PRD 与逻辑
设计。仓库已建立可运行的全控制台交互原型;它用于确认信息架构与交互,不代表生产服务已经
实现。当前边界和运行方法见[实施进度](./progress.md)与
[`shareserver/README.md`](../../../shareserver/README.md)。
## 角色
- **系统管理员**:维护实例、身份源、域名、证书、存储和全局限制。
- **组织管理员**:管理本组织成员、设备、组、策略、能力和联邦关系。
- **能力发布者**:在获准范围内发布设备能力或签名能力包。
- **审批人**:处理调用、发布、更新和联邦申请。
- **审计员**:只读查看和导出获准范围内的审计事件。
- **成员**:主要通过 GoodBuddy Desktop 使用能力,只在 Web 端处理个人设备和审批。
-219
View File
@@ -1,219 +0,0 @@
# ShareServer 功能逻辑设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.2 |
| 日期 | 2026-09-05 |
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 共享规则 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
## 1. 核心不变量
### INV-1 组织隔离先于角色
任何查询和变更先绑定 organization,再计算角色。系统管理员的跨组织操作使用独立系统管理
入口并形成审计,不能依靠省略 organization 条件获得全局数据。
### INV-2 注册不等于发布
设备注册只建立身份和连接资格。没有有效 Publication 时,目录中没有该设备能力。
### INV-3 目录不等于授权
看到能力不能推导可调用或可下载。创建任务和下载包时重新计算当前授权。
### INV-4 服务端授权不能扩大端点授权
ShareServer 的 `allow` 只是签发任务资格。Provider 和调用方仍执行各自本地策略;任一端拒绝
即终止动作。
### INV-5 策略拒绝优先
同一动作命中多个规则时使用 `deny > require_approval > allow`。没有匹配 allow 时默认 deny。
### INV-6 联邦为双边交集
单方创建或接受的关系不生效。双方 active 且两端发布、Grant 和数据策略均允许时才能跨组织
创建任务。
### INV-7 任务提供者不可改写
ShareServer 只路由调用方冻结的 provider,不在离线、繁忙或失败时替换节点。
### INV-8 不恢复短期权威
备份恢复不恢复 Web 会话、设备 socket、一次性挑战、审批 nonce 或任务数据通道。终端必须
重新连接并证明身份。
### INV-9 初始化入口一次性关闭
初始化只在实例尚无系统管理员时可用。首个系统管理员创建成功后,后续请求即使持有旧的
一次性引导材料也不能再次进入初始化流程。
### INV-10 数字人发布不等于记忆公开
发布数字人只建立一个有界 Agent Publication。它默认不取得所有者任何本地记忆;所有者可以
在发布时勾选 Agent、Global、指定 Project 和指定 Automation 分区,不能开放 Conversation
和 Run 分区。ShareServer 和调用者只能看到允许分区种类与“本次调用会使用本地记忆”的政策
事实,不能读取原始条目、分区内容、来源正文或检索结果。新增可读分区按权限扩大重新审批,
移除分区可以立即收紧。
## 2. 主要状态
### 2.1 成员
```text
invited -> active -> suspended -> active
invited -> expired | revoked
active/suspended -> removed
```
`suspended` 不能登录或创建新任务;历史归属保留。`removed` 不级联删除审计和已完成任务。
### 2.2 注册设备
```text
pending -> active <-> offline
pending -> expired | rejected
active/offline -> revoked
```
客户端心跳只决定 `active/offline`,不能清除 `revoked`。重新注册生成新设备 registration。
### 2.3 Publication
```text
draft -> pending_approval -> active
draft/pending_approval -> rejected
active <-> paused
active/paused -> revoked
```
provider 离线不会把 Publication 变为 paused;目录同时展示“已发布”和“当前离线”两个事实。
### 2.4 审批
```text
pending -> approved | rejected | cancelled | expired
```
审批对象内容摘要、申请主体或策略版本变化后,原审批不能复用。approved 只产生其声明范围内
的 Grant 或一次性决定。
### 2.5 Package Release
```text
draft -> verifying -> available -> paused -> available
verifying -> invalid
available/paused -> revoked
```
发布后的版本和字节不可变。修复内容必须使用新版本;revoked 不删除审计证据和对象摘要。
### 2.6 FederationTrust
```text
draft -> offered -> mutually_confirmed -> active
offered -> rejected | expired
active <-> paused
任意非终态 -> revoked
```
`mutually_confirmed` 只表示身份确认完成;双方策略同步成功后才进入 active。
## 3. 授权计算顺序
1. 验证请求身份、会话 audience 和 organization。
2. 验证用户、设备、provider、Publication 和 FederationTrust 当前状态。
3. 校验能力或数字人版本、动作、输入 schema、数据类别、大小和委托链。
4. 收集调用方组织、提供方组织、Publication 和 Grant 规则。
5. 按拒绝优先合并,计算 `deny/require_approval/allow`。
6. 校验次数、并发、预算和有效期。
7. 返回决定、稳定原因和策略引用;需要批准时建立内容绑定申请。
8. 创建任务时再次计算,并把决定快照写入任务。
目录查询可以预计算“可能可用”,但不能替代第 1 至 8 步。
## 4. 组织角色边界
| 动作 | 系统管理员 | 组织管理员 | 发布者 | 审批人 | 审计员 | 成员 |
| --- | --- | --- | --- | --- | --- | --- |
| 实例设置 | 是 | 否 | 否 | 否 | 只读有限项 | 否 |
| 成员与组 | 紧急管理 | 是 | 否 | 否 | 只读 | 自己 |
| 注册设备 | 只读/撤销 | 是 | 自己 | 否 | 只读 | 自己 |
| 发布能力/包 | 否 | 是 | 授权范围 | 否 | 只读 | 否 |
| 发布自己的数字人 | 否 | 是 | 授权范围 | 否 | 只读 | 自己 |
| 编辑策略 | 否 | 是 | 否 | 否 | 只读 | 否 |
| 处理审批 | 否 | 可配置 | 否 | 授权范围 | 只读 | 自己的申请 |
| 联邦关系 | 实例级许可 | 是 | 否 | 可共同审批 | 只读 | 否 |
| 审计导出 | 实例级 | 组织级 | 自己相关 | 自己相关 | 授权范围 | 自己相关 |
实际权限仍由策略计算,表格定义产品默认职责而非硬编码角色名称。
## 5. 任务路由决策
| 条件 | 结果 |
| --- | --- |
| provider 在线且双方可直连 | 签发短期直连协商信息 |
| 无法直连且双方策略允许中继 | 建立有界中继通道 |
| 中继关闭或任一方禁止 | 创建失败,说明无可用路由 |
| provider 离线 | 不排队到无限期,按能力配置有界等待或拒绝 |
| provider 接受后连接中断 | 保留 task,等待同一 provider 恢复/查询 |
| provider 终态未知且恢复期结束 | `outcome_unknown` |
| 调用方取消 | 转发取消;provider 确认后终态 cancelled |
中继容量不足只产生排队或资源错误,不切换 provider,不改变是否允许中继。
## 6. 更新策略优先级
```text
紧急撤销
> 组织强制固定版本/禁止版本
> 设备组发布通道
> 用户允许的版本范围
> 当前已安装版本
```
任何权限扩大都从自动路径转为 `require_approval`。降级只有在目标版本仍被签名目录允许且
策略明确指定时才提供,不自动视为故障回滚。
## 7. 联邦可见性
联邦目录只导出对方明确允许的能力投影:组织显示名、能力、版本范围、数据政策和可用性摘要。
不导出内部设备名、成员清单、组结构、节点地址、内部策略全文或审计记录。跨组织请求使用
双方关联 ID,双方各自解析本地主体。
## 8. 删除、撤销与保留
- 删除组织前必须停止新任务、撤销联邦和设备,并按保留策略归档审计;不提供无提示级联。
- 删除用户不删除其历史动作,审计显示不可变主体摘要。
- 撤销设备、Publication、Grant、Package 或联邦后立即阻止新令牌。
- 对象存储中的未完成任务正文按任务期限清理;审计元数据按组织保留策略清理。
- 法定保留只锁定匹配审计事件,不使被撤销身份重新可用。
## 9. 需求追踪
| 需求 | 主要逻辑 |
| --- | --- |
| `FR-1` | `INV-1`、`INV-9` 和系统管理员角色边界 |
| `FR-2` | 组织隔离不变量、成员状态和角色边界 |
| `FR-3` | 注册不等于发布、设备状态和短期权威恢复规则 |
| `FR-4` | 注册、Publication、目录与授权不变量 |
| `FR-5` | 拒绝优先不变量和授权计算顺序 |
| `FR-6` | 审批状态、内容绑定和授权重算 |
| `FR-7` | provider 冻结不变量和任务路由决策 |
| `FR-8` | Package Release 状态和更新策略优先级 |
| `FR-9` | 联邦双边交集、FederationTrust 状态和联邦可见性 |
| `FR-10` | 组织隔离、删除撤销与保留规则 |
| `FR-11` | 组织角色边界及各对象状态;展示行为由 UI 设计定义 |
| `FR-12` | 短期权威恢复规则和逻辑完整性;部署结构由技术设计定义 |
| `FR-13` | 注册、Publication、目录与授权不变量,provider 冻结及 `INV-10` |
## 10. 逻辑完整性
本设计覆盖实例、组织、成员、设备、能力与数字人发布、授权、审批、任务、更新、联邦、撤销
和恢复。实现前仍需确定首个 OIDC 提供者矩阵、默认审计保留期、部署规模目标和中继带宽上限;这些部署
参数应可配置,不改变拒绝优先、双边联邦和 provider 冻结规则。
-195
View File
@@ -1,195 +0,0 @@
# ShareServer PRD
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.2 |
| 日期 | 2026-09-05 |
| 适用产品 | 独立部署的 ShareServer 与 Web 管理控制台 |
| 关联架构 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
| 关联文档 | [功能逻辑](./logic-design.md)、[管理界面](./ui-design.md)、[技术设计](./technical-design.md) |
## 1. 背景
局域网配对适合附近设备,但不能统一解决跨网络访问、成员离职、组织策略、集中更新、审计和
跨组织协作。ShareServer 提供一个可自建的组织控制面,让设备通过主动出站连接注册能力并
接收任务,不要求为每台桌面设备开放公网端口。
## 2. 产品目标
1. 组织可在自有网络、私有云或公网独立部署 ShareServer。
2. 管理员可统一管理用户、组、设备、能力、权限、更新和审计。
3. 成员通过桌面端使用获准能力,无需日常进入管理控制台。
4. 设备离线、用户撤销或策略变化能准确影响新任务,不破坏历史证据。
5. 两个组织可经双边确认建立有限范围的联邦协作。
6. ShareServer 可以只运行控制面,也可按部署策略提供数据中继和包存储。
7. 成员可以显式开放自己的数字人,获准的组织成员可通过 `@数字人` 发起有界协作任务。
## 3. 产品范围
### 3.1 包含
- 本地账号及可配置 OIDC 身份源。
- 组织、成员、角色、用户组、设备组和服务账号。
- 设备注册、状态、撤销和合规元数据。
- 能力目录、Publication、Grant、强制策略和审批。
- 设备出站控制通道、任务路由、取消和可选中继。
- 签名能力包仓库、发布通道、更新策略和撤销。
- ShareServer 间双边联邦。
- 组织数字人目录、数字人 Publication、授权发现和 `@数字人` 调用。
- 审计、用量、保留和导出。
- Web 管理控制台和成员自助页面。
### 3.2 不包含
- 远程管理设备桌面、任意 Shell 或任意文件系统。
- 发现未注册的局域网设备。
- 保存或分发用户 API Key、Cookie 和桌面登录状态。
- 默认查看任务正文、附件和模型输出。
- 上传、同步或浏览数字人所有者的本地个人、项目、会话和原始 Agent 记忆。
- 因为成员加入组织而自动开放其数字人、记忆、Runtime、工作区或工具。
- 未经提供者发布自动盘点设备能力。
- 自动信任同域邮箱、同一网络或其他 ShareServer。
- 公共开放市场、计费结算或匿名能力调用。
- 替代 GoodBuddy 官方客户端、模型和 Agent 发布源。
## 4. 功能需求
### FR-1 实例初始化
- 首次启动通过一次性引导创建实例标识和首个系统管理员。
- 引导完成后永久关闭初始化入口,后续管理员通过正常身份流程创建。
- 实例公开描述包含名称、协议版本、登录方式和联邦端点,不包含内部拓扑。
### FR-2 身份与组织
- 支持一个实例承载多个隔离组织,也支持部署为单组织模式。
- 支持本地账号和 OIDC;组织可限制允许的身份源。
- 成员可以属于多个组并拥有有界角色,角色不隐式跨组织。
- 停用用户立即停止签发新设备和任务凭据。
### FR-3 设备管理
- 桌面端经用户登录、一次性挑战和设备签名完成注册。
- 设备条目显示所有者、平台、客户端版本、最近在线、连接和撤销状态。
- 设备主动建立出站控制连接;服务端不能借此调用未发布能力。
- 管理员可撤销设备并终止其新任务资格。
### FR-4 能力目录
- 只接收已注册设备或服务节点提交的显式 Publication。
- 目录按调用者身份、设备状态、组织和策略过滤后返回。
- 支持同一能力多个版本和多个 provider,不掩盖来源。
- provider 离线时保留目录事实但显示不可用,不派发新任务。
- 数字人作为由具体设备或服务节点提供的复合 Agent 能力进入目录,持续显示所有者、提供者、
运行位置和可调用状态,不伪装成 ShareServer 自身能力。
### FR-5 授权策略
- Grant 可绑定用户、组、设备、设备组和联邦组织。
- 可约束能力、版本、动作、输入类型、数据分类、提供者、时间、次数、并发和委托。
- 支持组织强制策略和用户可收紧偏好;拒绝优先。
- 每个决定返回稳定结果、命中策略和面向用户的原因。
### FR-6 审批
- 调用、能力发布、数字人上线或范围扩大、权限扩大更新和联邦关系可以要求审批。
- 审批项显示申请人、对象、影响范围、数据类别、期限和策略依据。
- 批准结果有明确范围和有效期,不提供无边界“永久允许”。
- 申请内容变化后旧审批失效。
### FR-7 任务路由
- 根据冻结 provider、在线连接和路由策略把任务声明发送给目标设备。
- 支持直连协商、网关中继和取消传播。
- 任务正文与控制元数据分离;默认审计不保存正文。
- 已接受任务断线后允许查询同一 task,不自动创建重复执行。
### FR-8 能力包与更新
- 保存不可变签名 Package 和发布目录。
- 支持组织、组、平台、架构和维护窗口的更新策略。
- 支持固定版本、允许版本范围、暂停发布和紧急撤销。
- 权限扩大必须触发新批准,不能被普通自动更新策略覆盖。
- 包来源、大小、摘要、签名和下载结果可审计。
### FR-9 联邦
- 两个 ShareServer 通过指纹交换和双方管理员确认建立关系。
- 双方分别配置公开能力、允许主体、数据范围、用量和期限。
- 最终权限取双方策略交集;任一端撤销后停止新任务。
- 不默认链式转发到第三个组织。
### FR-10 审计与用量
- 记录身份、设备、发布、授权、审批、任务、包和联邦管理事件。
- 默认仅记录共享架构定义的有界元数据。
- 支持按组织隔离查询、条件筛选、分页和流式导出。
- 审计员只读,不能因查看日志取得任务正文或管理权限。
### FR-11 管理控制台
- 提供概览、组织与成员、设备、能力目录、数字人、权限策略、审批、发布与更新、网关联邦、
任务与中继、审计、系统设置。
- 普通成员只看到自己的设备、授权和待办;管理员栏目按权限隐藏并在 API 再次校验。
- 普通成员可以管理自己发布的数字人,并发现和 `@` 当前组织内获准调用的数字人。
- 所有高影响操作说明对象、范围和后果。
### FR-12 可部署性
- 支持容器部署,配置与持久数据分离。
- 支持外部 PostgreSQL;对象存储和中继按功能启用。
- 提供健康、就绪、指标、结构化日志、备份和恢复说明。
- 服务升级不能要求同时升级所有在线桌面端;按协议版本协商兼容集合。
### FR-13 数字人发布与组织内 `@` 调用
- “数字人”是运行在成员设备或组织服务节点上的可调用 Agent Publication。一个成员可以按
不同用途发布零个或多个数字人,每个数字人独立配置 handle、能力、提供者和记忆来源;
加入组织、注册设备或创建本地 Agent 都不会自动发布。
- 发布时必须明确数字人的组织内唯一 `@handle`、显示名、说明、版本、提供者、支持的输入/
输出类型、可用时间、并发、有效期、目标用户或用户组,以及
`discover/inspect/invoke` 动作。`@数字人` 是 `invoke` 的界面入口,不绕过 Grant 或审批。
- 数字人目录持续显示所有者、来源组织、执行设备或服务节点、Runtime 类型、版本、在线状态、
数据策略和最近可用性;重名显示名不能掩盖唯一 handle 和实际提供者。
- 获准成员在组织会话或任务输入中通过显式选择器插入 `@数字人`。发送前必须显示实际接收者、
执行位置、将发送的数据类别、附件、路由、中继可读性、有效授权和可能的下游委托。
- 一次 `@数字人` 创建绑定所选数字人 Publication、版本和 provider 的远程 Task。数字人离线
或拒绝时不得改发给同名数字人或其他 provider;网络中断后的终态遵守 `FR-7`。
- 数字人默认无权读取所有者的其他本地记忆。发布时,所有者可以勾选该数字人的 Agent 分区、
Global 分区、指定 Project 分区和指定 Automation 分区;首期不能开放短期的 Conversation
和 Run 分区。一次调用只在 Publication 冻结的允许分区中读取当前有效、已确认且敏感度
允许的记忆。
- 记忆检索和上下文组装始终在 Provider 本地完成。ShareServer 只保存 Publication 的分区
种类、经过验证的本地关联 ID 摘要和是否使用专属记忆等策略元数据;原始记忆、分区内容、
来源正文和检索结果不上传 ShareServer,也不向调用者展示。
- 数字人输出可能受到其 Agent 记忆影响,因此发布页和调用确认必须显示“使用数字人专属记忆”
这一事实及允许的数据敏感度。Restricted 记忆不得用于远端调用;暂停或撤销数字人后停止
新调用,但保留历史任务和有界审计。
- Provider 在执行边界重新验证 Publication、Grant、输入 schema、数据类别、大小、期限和
本地 Agent 记忆权限。ShareServer 的允许决定不能扩大数字人所在设备的本地权限。
- 所有者可以暂停、恢复、更新或撤销自己的数字人。handle 或能力、数据、工具、委托发生
变化,或新增可读记忆分区时,旧批准失效并重新进入审批;移除可读分区可以立即收紧而不
等待新批准,普通文案修改不得改变既有权限。
- 审计默认只记录调用方、数字人、Publication、provider、数据类别、路由、授权、状态、耗时
和大小,不保存消息正文、附件内容、模型输出或记忆内容。
## 5. 产品验收
- 新实例可完成初始化、登录、创建组织、邀请成员和注册真实桌面设备。
- 两个不同组织不能通过 API、搜索、计数、错误或审计结果读取对方数据。
- 设备只有发布能力后才进入获准目录,撤销后不能接收新任务。
- 调用授权能同时处理用户、设备、组织、提供者、数据和联邦限制。
- 任务中继关闭时控制面仍可运行;中继开启时可取消并形成准确终态。
- 权限扩大的包更新不能自动部署。
- 两个独立 ShareServer 能建立、使用、暂停和撤销双边联邦。
- 成员可以显式发布一个数字人,获准的同组织成员能从选择器插入 `@handle` 并完成一次有界
调用;未获准成员不能通过目录、搜索、补全或直接 API 枚举和调用该数字人。
- 暂停或撤销数字人后不能创建新调用,已完成任务与有界审计仍可追溯到原 Publication 和
provider。
- 数字人调用可以使用所有者在发布时勾选的 Agent、Global、指定 Project 或指定 Automation
分区,但不能开放 Conversation 和 Run;ShareServer 和调用者不能读取原始记忆、未选择的
本地分区或记忆来源正文。
- 备份恢复后组织、策略、发布和审计身份保持一致,短期连接和令牌不恢复。
-71
View File
@@ -1,71 +0,0 @@
# ShareServer 实施进度
## 当前状态
ShareServer 已进入可运行交互原型阶段。当前实现用于验证完整控制台信息架构、视觉方向与操作
流程,不提供生产身份、组织隔离、设备连接、授权、任务路由或数据持久化。
### 2026-09-06:桌面发布与测试边界
ShareServer 功能尚未完成,不属于本次 Desktop `0.12.5` / Agent `0.11.19`
的产品交付或发布验收范围。用户确认本次发布不需要单独测试 ShareServer,
其原型测试通过也不代表生产功能已完成。
桌面端根测试和 lint 配置排除 `shareserver/**`,桌面发布 CI 不为其单独安装测试依赖。
ShareServer 独立项目自身的测试配置保留,供后续功能开发使用;本次不运行其独立测试。
此前候选 `89a81dd` 临时加入的 `npm ci --prefix shareserver` 步骤已撤除。
ShareServer 的实现与验证在后续功能开发中单独推进;真实 Desktop 集成验收仍属于
下文的剩余生产工作。
## 已验证实现
### 2026-09-05:全控制台交互原型
- `shareserver/` 是独立 Node.js/TypeScript 项目,不依赖 Electron Main 或
`src/shared`。
- Hono 同源提供健康、就绪、原型快照和无持久化操作确认 API。
- 管理控制台覆盖概览、组织与成员、设备、能力目录、权限策略、审批、发布与更新、网关联邦、
任务与中继、审计和系统设置。
- 成员自助覆盖自己的设备、授权和申请;角色切换后导航和 URL 范围同步变化。
- 控制台支持深浅主题、组织范围、全局搜索、列表搜索、状态筛选、详情、表单、确认、成功/
失败反馈、响应式侧栏和键盘对话框。
- 构建后的 Web 由 Hono 同源托管;`Dockerfile` 使用多阶段构建和非 root 运行用户。
- readiness 明确返回 `interactive-prototype` 与 `productionReady=false`,避免把 fixture
误认为生产服务。
验证证据:
```text
cd shareserver
npm test # 2 files, 5 tests passed
npm run lint # passed
npm run build # typecheck + Vite Web + server TypeScript passed
```
浏览器实际验收:
- 生产构建在 `http://127.0.0.1:8787` 启动并成功加载 API 快照。
- 宽屏管理员概览、审批详情、批准确认和原型 API 提交流程通过。
- `390 × 844` 成员自助页面、筛选区和列表布局通过。
- 深色与浅色审批页的 WCAG 2 A/AA 自动检查均为 0 个确定违规;半透明侧栏被检查器列为
需人工判断项,已通过实际截图复核文字可读性。
- 仓库级 `npm test` 通过 325 个测试文件、3501 个测试,另有 8 个文件和 55 个测试按既有
条件跳过;仓库级 `npm run typecheck` 与 `npm run lint` 通过。
## 剩余生产工作
按技术设计从以下纵向切片开始,不能把原型 fixture 逐步演变为权威状态:
1. FR-1 一次性初始化、本地管理员身份、Web session 和 CSRF。
2. PostgreSQL migration、实例/组织/成员表、强制 organization repository 作用域和事务审计。
3. 真实组织与成员管理 API,再将对应原型页面切换到权威数据。
4. 设备挑战注册、凭据和出站 WebSocket 连接。
5. Publication、目录、拒绝优先策略、审批和任务路由。
6. Package、可选中继、联邦、保留、导出、备份恢复和真实 Desktop 端到端验证。
当前尚未满足 PRD 的产品验收,也未进行真实 Desktop/ShareServer 集成测试。
2026-09-05 PRD `FR-13` 新增组织数字人发布和 `@数字人` 调用。当前交互原型尚无“数字人”
目录、发布管理、提及选择器或 Agent 记忆边界展示,因此这些能力仍属于剩余生产与原型工作,
不能由现有通用能力目录或原型操作确认替代。
@@ -1,269 +0,0 @@
# ShareServer 技术设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.2 |
| 日期 | 2026-09-05 |
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
| 共享协议 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
## 1. 仓库与运行边界
实现时在仓库顶层创建独立项目:
```text
shareserver/
README.md
package.json
src/
api/
auth/
organizations/
devices/
capabilities/
policies/
approvals/
tasks/
relay/
packages/
federation/
audit/
web/
migrations/
deploy/
tests/
```
仓库已创建可独立运行的 `shareserver/` 全控制台交互原型。原型使用 Hono 提供健康、就绪、
界面快照和无持久化操作确认 API,并同源托管 React/Vite Web 控制台;它不依赖 Electron 的
`src/shared`。当前尚未建立生产数据库、认证、设备连接和共享协议,因此也不提前创建空的
`packages/share-protocol`。进入真实 Desktop 协议实现时,服务端和 Desktop 共同依赖
`packages/share-protocol`,ShareServer 仍不得导入 Electron 的 `src/shared`。
## 2. 技术选型约束
- 使用与仓库一致的 TypeScript/Node.js 工具链,具体 HTTP 框架在实现前根据维护性选择。
- PostgreSQL 是组织、策略、任务元数据和审计的权威数据库。
- Package 与可选中继暂存使用 S3 兼容对象存储;不开启对应功能时不要求对象存储。
- Web 控制台与 API 同源部署,采用严格 CSP,不在浏览器保存设备凭据。
- 单实例部署可完整工作;水平扩展时再引入共享事件/连接路由组件,不为未验证规模预设复杂
分布式协议。
## 3. 服务模块
| 模块 | 职责 |
| --- | --- |
| `auth` | 本地身份、OIDC、Web session、服务和设备凭据 |
| `organizations` | 组织、成员、组、角色和范围解析 |
| `devices` | 注册挑战、设备记录、出站连接和在线状态 |
| `capabilities` | manifest、数字人 profile、provider、Publication 和目录投影 |
| `policies` | Grant、强制策略和确定性授权计算 |
| `approvals` | 内容绑定申请、决定、过期和通知 |
| `tasks` | task/attempt、provider 路由、状态和取消 |
| `relay` | 可选有界数据中继,不解释能力正文 |
| `packages` | 不可变对象、签名目录、通道和撤销 |
| `federation` | 网关身份、offer、双边策略和跨组织映射 |
| `audit` | 事务内审计写入、查询、保留和导出 |
模块通过服务接口调用,API handler 不直接拼接跨组织 SQL 或自行计算权限。
## 4. API 边界
建议分为四类 audience:
```text
/api/v1/web/* 管理控制台与成员自助 API
/api/v1/device/* 设备注册、目录和任务控制 API
/api/v1/federation/* ShareServer 间 API
/api/v1/public/* 服务描述、健康与登录入口
```
设备使用 WebSocket `/api/v1/device/connect` 建立出站控制通道。包下载使用短期、绑定主体和对象
摘要的 URL。联邦 API 使用网关身份,不接受普通用户 session。每类 token 使用不同 issuer/
audience/scope,不能互换。
所有请求经 schema、认证、organization scope、授权、速率和大小边界。列表使用游标分页和固定
最大页;错误返回 request ID、稳定 code 和有界参数。
## 5. 数据模型
核心表建议为:
```text
instances
identity_providers
users
organizations
memberships
groups
group_members
role_bindings
registered_devices
device_credentials
device_connections
capabilities
capability_versions
agent_profiles
providers
publications
grants
policies
approval_requests
approval_decisions
tasks
task_attempts
task_events
package_objects
package_releases
release_channels
update_policies
federation_trusts
federation_capability_exports
audit_events
revocations
```
要求:
- 所有组织数据表包含不可为空的 `organization_id`,并由 repository 层强制作用域。
- 唯一约束同时包含 organization,避免跨组织名称冲突泄漏。
- 密钥保存为单向摘要或经外部主密钥加密的密文;数据库不保存明文 token。
- 在线连接和短期 nonce 主要在内存;表中只保存恢复所需元数据,不把 socket 当持久对象。
- task event 只存控制状态和有界统计;正文流不进入普通关系表。
- `agent_profiles` 只保存组织内唯一 handle、显示元数据和其能力版本引用。数字人复用
Publication、Grant、Approval 和 Task,不创建第二套授权或路由模型;本地 Agent 记忆条目、
分区目录和来源正文不进入 ShareServer 数据库。
- 数字人 Publication revision 保存有界 `memory_source_policy`,只允许 `agent`、`global`、
`project` 和 `automation` 种类及 Provider 验证后的本地关联 ID 摘要。Provider 执行时按该
revision 在本地重新解析分区并检索;服务端不接收记忆文本、来源正文、Embedding 或检索
结果。新增来源是权限扩大,移除来源是单向收紧。
## 6. 事务与一致性
- 业务变更与对应审计事件在同一 PostgreSQL 事务提交。
- Publication/Grant/Policy 使用 revision 做乐观并发,Web 草稿不能覆盖他人更新。
- 授权决定创建 task 时再次读取当前 revision,并将命中策略摘要冻结到 task。
- 设备在线状态允许短暂最终一致;身份、撤销、授权和任务接受必须走权威存储。
- 重复注册挑战、联邦 offer 和 task create 使用唯一幂等键。
- 无法确认 provider 副作用时只更新 `outcome_unknown`,不靠消息队列自动重放。
## 7. 设备连接与任务
连接建立后完成设备 credential、organization、客户端协议和当前 registration 校验。服务端为
每个连接设置心跳、空闲、帧、发送队列和并发上限。
任务流程:
```text
authorize -> create task -> resolve exact provider connection
-> send declaration -> provider accepts/rejects
-> establish direct or relay route -> stream progress
-> provider terminal result -> caller delivery -> audit terminal state
```
同一设备重复连接时使用 connection generation;新连接接管未来任务,旧连接仍只允许完成或
查询已归属 task,随后有界关闭。不能把新任务随机发到身份相同但 generation 过期的 socket。
## 8. 中继
中继是可选模块:
- 控制面与中继可部署在不同进程,但共享 task 授权验证。
- 每个 relay ticket 绑定 task、调用方、provider、方向、期限和最大字节。
- 实现背压和全局/组织/设备并发及带宽上限。
- 不解析能力正文,不写普通日志;需要临时落盘时使用加密 GoodBuddy-owned 暂存并按 task 清理。
- 中继关闭、过载或策略禁止时返回准确路由错误,不静默转到其他节点。
## 9. Package 存储
上传先进入不可公开暂存:流式计算大小和 SHA-256,验证签名、manifest、版本、平台和权限后,
以 digest 发布为不可变对象。相同 digest 可去重,但组织授权和 Release 分别保存。
Release catalog 使用独立签名域。服务器不能重打包已签名 payload;镜像目标必须保持相同字节、
大小和 SHA-256。撤销更新目录和授权,不覆盖或原地删除仍受审计保留的对象。
## 10. 联邦协议
- 每个实例生成网关身份和可轮换证书,私钥不进入数据库备份明文。
- offer 包含双方可人工核对的组织名、端点、指纹、协议和 nonce。
- 双方确认后交换有界 capability projection 和本地映射 ID。
- 跨组织 task 带双方关联 ID 和短期签名声明;每端独立授权并各写审计。
- 不接受调用方提供的任意回调 URL、第三方 hop 或内部主体 ID。
- 证书轮换需要旧身份签名或双方重新确认;身份突变直接暂停关系。
## 11. Web 安全
- 使用 HttpOnly、Secure、SameSite cookie 和 CSRF 防护管理 Web session。
- OIDC 使用 state、nonce、PKCE 和固定回调地址。
- 富文本字段按纯文本显示;manifest/schema 使用安全代码查看器,不执行 HTML。
- 权限隐藏仅改善界面,所有 API 在服务端重新校验。
- 登录、设备注册、目录、审批、导出和联邦接口分别限速。
- 密钥字段只能替换、轮换和测试,永不回显。
## 12. 部署与配置
最小生产部署:
```text
TLS reverse proxy/load balancer
ShareServer API + Web
PostgreSQL
可选:S3 compatible object storage
可选:Relay worker
```
部署配置通过环境变量或挂载文件提供数据库、主密钥、公开 URL、OIDC、邮件、对象存储和限制。
普通 Web 管理员不能修改基础设施秘密。提供数据库迁移命令、不可变镜像、非 root 容器、健康
与就绪端点、优雅停止和部署示例。
## 13. 可观测性与备份
- 结构化日志只包含 request/task/organization 关联 ID、稳定事件和错误码。
- 指标覆盖请求、WebSocket、授权结果、任务状态、中继、Package 和联邦,不包含能力正文。
- tracing 跨内部服务传播关联 ID,跨联邦只传播双方同意的关联标识。
- 备份 PostgreSQL、Package 对象和网关身份;不备份缓存、Web session、nonce 或中继暂存。
- 恢复后撤销列表、组织 ID、签名目录和审计序列保持有效,所有设备重新连接。
## 14. 测试策略
### 14.1 自动化
- schema、认证 audience、CSRF、OIDC 和设备挑战。
- 每个 repository/API 的跨组织隔离与枚举防护。
- 拒绝优先授权、审批内容绑定、预算和并发。
- device generation、离线、撤销、重连和 provider 冻结。
- task 幂等、取消、结果未知和不自动重放。
- Package 流式校验、不可变性、权限 diff 和撤销。
- 联邦双方确认、策略交集、暂停、证书变化和禁止第三 hop。
- 审计事务、过滤、分页、导出和正文缺失。
### 14.2 集成与部署
- Desktop 与真实 ShareServer 完成注册、目录、调用、文件和取消。
- 两个隔离组织验证搜索、计数、错误、对象和审计不泄漏。
- 两个 ShareServer 完成联邦建立、跨组织调用和单方撤销。
- 中继关闭与开启、容量耗尽、连接中断和大文件背压。
- 数据库备份恢复、版本升级、滚动停止和设备重连。
- 生产 TLS、OIDC 和对象存储组合的端到端验证。
设计完成不代表服务已实现;只有独立部署和真实 Desktop 端到端路径通过后才能更新实施状态。
## 15. 当前原型实现
2026-09-05 建立的交互原型固定以下技术基线:
- API:Hono + `@hono/node-server`,保留 `/api/v1/public/*` 与
`/api/v1/web/*` audience 路径。
- Web:React、Vite、TypeScript、Tailwind CSS 和 Radix primitives,按 shadcn/ui 组件模式
维护本地可修改组件。
- 路由与范围:管理员和成员自助使用不同可见导航;当前组织持续显示。
- 部署:生产构建由同源 Hono 服务托管,并提供非 root 多阶段 Docker 镜像。
`/api/v1/public/ready` 返回 `mode=interactive-prototype` 和
`productionReady=false`。原型 fixture、角色切换和操作确认只用于评审信息架构,不得被
Desktop 或部署脚本当作真实身份、策略、任务或审计 API。下一实施阶段必须从 FR-1
初始化、本地身份、PostgreSQL 组织作用域和事务审计开始,不在 fixture 上叠加生产状态。
-200
View File
@@ -1,200 +0,0 @@
# ShareServer Web 管理界面设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.2 |
| 日期 | 2026-09-05 |
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
ShareServer 是独立 Web 产品,不直接复用 Electron 页面壳层实现,但沿用 GoodBuddy 的语义
词汇、状态含义和克制的运营工具风格。界面以列表、表格和主从详情为主,不使用营销首页或
装饰性卡片墙。
## 1. 使用对象
- 普通成员主要在 GoodBuddy Desktop 使用能力;Web 端只处理自己的设备、申请和授权。
- 组织管理员使用完整组织控制台。
- 系统管理员使用与组织控制台分开的实例管理范围。
- 审计员进入只读视图,不显示可变更控件。
登录后直接进入当前身份可用的实际控制台,不显示产品宣传页。
## 2. 全局框架
```text
顶部栏:ShareServer 实例名 | 当前组织 | 全局搜索 | 审批 | 账号
侧栏:
概览
组织与成员
设备
能力目录
数字人
权限策略
审批
发布与更新
网关联邦
任务与中继
审计
系统设置(仅系统管理员)
```
当前组织始终显示。切换组织会重新加载整个数据范围,不保留上一组织的选择、筛选或详情。
系统管理使用“实例范围”标识,不能伪装成某个组织。
## 3. 概览
概览只显示需要行动和运行事实:
- 待审批数量及最旧等待时间。
- 在线/离线/已撤销设备。
- 可用/离线/暂停能力。
- 当前任务、失败和结果未知。
- 包更新异常和紧急撤销。
- 联邦连接异常。
- 中继流量和容量,仅在中继启用时显示。
指标可点击进入已带筛选的对应页面。没有事件时不显示虚构趋势。
## 4. 组织与成员
使用“成员”“用户组”“角色”三个同级页签。成员表包含姓名、身份源、组、状态、设备数和
最近活动。邀请是唯一主操作;暂停和移除进入行末菜单。
成员详情采用抽屉,显示身份、组、角色、设备、有效 Grant、申请和审计。管理员不能查看成员
任务正文或桌面文件。
## 5. 设备
```text
设备 所有者 平台 状态 能力 最近在线
Jiang-PC Jiang Windows 在线 2 刚刚
GPU-Worker-02 AI Team Linux 离线 8 2 小时前
```
设备详情包含概览、连接、已发布能力、Grant 和活动。设备状态与 Publication 状态分别显示。
撤销设备使用危险确认,说明新任务、活动任务和重新注册影响。
## 6. 能力目录
目录以能力为第一列,提供者为可展开信息:
```text
能力 版本 提供者 可见范围 可用性
图像生成 2.1.0 3 个节点 设计组 2/3 在线
```
详情包含 manifest、provider、Publication、允许输入、数据政策、调用趋势和最近错误。管理员
看到的是组织元数据,不通过目录读取能力内部数据。
### 6.1 数字人
数字人使用独立栏目呈现,不混入普通工具能力表。目录按 `@handle`、显示名、所有者、专业
说明、提供者、Runtime、版本、在线状态和可用范围筛选;只有当前成员可发现的数字人进入
搜索和提及补全。
数字人详情显示:
- 所有者、来源组织、执行设备或服务节点。
- Publication 版本、支持的输入/输出、可用时间、并发和有效期。
- 数据去向、路由、中继可读性、是否允许委托。
- 是否使用本地记忆、允许的分区种类,以及允许处理的数据敏感度;不显示记忆条目、具体分区
名称、来源正文和检索结果。
- 当前成员的 `discover/inspect/invoke` 权限、命中策略和审批要求。
成员可以在“我的数字人”中创建多个用途不同的发布草稿,选择一个本地 Agent、设置唯一
`@handle` 和开放范围、提交审批、暂停、恢复、更新或撤销。加入组织不会生成默认数字人,
创建本地 Agent 也不会自动发布。
发布表单的“可用记忆”默认全部关闭,并允许勾选数字人的 Agent、Global、指定 Project 和
指定 Automation 分区。Conversation 和 Run 不作为可选项。表单持续说明记忆只在提供者本地
检索,调用者和 ShareServer 看不到内容;新增分区在保存前明确标为权限扩大,移除分区明确
标为立即收紧。
组织会话和任务输入区使用带搜索的显式选择器插入 `@数字人`,不能只靠自由文本猜测 handle。
选择后持续显示目标数字人;发送前确认实际接收者、执行位置、发送数据和授权。离线、无权或
需要审批的项保持可读并说明原因,但不能形成已发送调用。
## 7. 权限策略
策略页分“强制策略”和“Grant”。创建和编辑使用结构化表单:主体、能力、动作、输入、提供者、
时间、预算、批准和委托。保存前提供自然语言摘要:
> 允许“设计组”从合规设备调用组织内“图像生成”2.x;只发送文本和图片;每人每日 20 次;
> 不允许下游委托。
策略冲突预览显示最终结果和命中规则,但不提供允许规则覆盖拒绝规则的顺序拖拽。
## 8. 审批
审批收件箱按调用、发布、更新和联邦分类。详情必须显示申请人、能力或对象、目标范围、数据
类别、权限变化、期限和策略依据。批准按钮使用具体文案,例如“允许本次调用”“批准此版本
发布”,不能只有“同意”。
批量批准仅用于同类型、同策略且不包含权限扩大的低影响申请;联邦和权限扩大更新不支持
批量批准。
## 9. 发布与更新
页面分“能力包”“发布通道”“更新策略”“撤销记录”。Package 表显示版本、发布者、签名、
大小、目标平台、权限变化和状态。上传后先进入验证,不直接发布。
版本详情提供 manifest、摘要、签名、兼容范围、权限 diff、依赖和下载事实。紧急撤销位于
独立 danger zone,需要输入版本标识确认,并说明受影响设备和新任务。
## 10. 网关联邦
```text
合作组织 对端地址 状态 开放能力 有效期
法务服务商 share.legal.example 已连接 2 项 12-31
```
建立关系采用向导:填写端点、核对组织和指纹、选择公开范围、设置调用与数据限制、生成 offer、
等待对方确认、复核双方交集。只有双方完成后显示“已连接”。
详情明确展示“我方开放”“对方开放”“最终可用”三个集合。暂停和撤销说明对活动任务的处理。
## 11. 任务与中继
任务表显示调用方、能力、provider、路由、数据类别、状态、耗时和时间。默认不显示正文。
活动任务提供取消;结果未知使用独立状态并显示 provider 最后确认点。
中继未启用时页面显示控制面正常和启用位置,不展示零值图表。启用后显示活动连接、吞吐、
排队、容量和拒绝原因,不提供查看正文的调试入口。
## 12. 审计
提供操作者、动作、对象、组织、结果、时间和关联 ID 的可筛选表格。导出前显示条件、估算行数、
时区和字段范围;大导出作为后台任务生成有时效下载。审计详情保持只读,不把原始内部错误、
认证信息或任务正文显示给管理员。
## 13. 系统设置
只包含实例名称、公开地址、身份源、邮件、数据库/对象存储状态、中继开关与上限、保留策略、
联邦总开关、证书和版本。秘密字段只支持替换或测试,不回显原值。
运行时基础设施配置优先来自部署环境;Web 界面不能修改数据库连接串、主密钥或容器挂载路径。
## 14. 状态、响应式与无障碍
- 加载、无数据、无匹配、无权限、服务错误和部分不可用分别展示。
- 管理表格支持键盘访问、可见焦点、表头关联和分页;行菜单有可访问名称。
- 窄屏将表格转为字段列表,保持组织、来源、状态和风险信息,不把管理动作挤成不可辨图标。
- 所有时间显示实例时区并可查看 ISO 时间;联邦事件同时显示对端时间时标明时区。
- 状态不只依赖颜色。危险操作在确认后保持原页面上下文和可恢复错误。
## 15. 交互原型视觉基线
2026-09-05 的首个可运行原型采用深海科技控制台方向,并同时提供浅色主题。视觉实现遵循
shadcn/ui 的本地组件模式和 Radix 可访问 primitives,布局参考成熟后台的侧栏、顶栏、数据
表格和详情对话框,但不复制第三方示例业务页面。
原型已覆盖数字人需求加入前的一级栏目、管理员与成员自助视图切换、组织范围、搜索筛选、
页签、详情、创建表单、危险确认和 390px 窄屏导航。动态发光、网格和网络脉冲只用于运行状态
与品牌氛围;业务表格仍保持克制,状态继续同时使用图标与文字。原型操作会调用无持久化确认
API,并持续标明不会写入生产数据,不能据此宣称真实审批、撤销、策略或任务操作已实现。
`FR-13` 新增的数字人目录、“我的数字人”和 `@` 选择器尚未进入当前原型。
-5
View File
@@ -1,5 +0,0 @@
build-server
dist
node_modules
*.log
*.png
-4
View File
@@ -1,4 +0,0 @@
build-server/
dist/
node_modules/
*.png
-24
View File
@@ -1,24 +0,0 @@
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
@@ -1,85 +0,0 @@
# 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
@@ -1,33 +0,0 @@
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
@@ -1,13 +0,0 @@
<!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
@@ -1,50 +0,0 @@
{
"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
@@ -1,40 +0,0 @@
// @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
@@ -1,75 +0,0 @@
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
@@ -1,9 +0,0 @@
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
@@ -1,324 +0,0 @@
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
@@ -1,32 +0,0 @@
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
@@ -1,124 +0,0 @@
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
@@ -1,157 +0,0 @@
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
@@ -1,250 +0,0 @@
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
@@ -1,417 +0,0 @@
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>
</>
)
}
@@ -1,214 +0,0 @@
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>
</>
)
}
@@ -1,32 +0,0 @@
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>
)
}
@@ -1,250 +0,0 @@
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
@@ -1,48 +0,0 @@
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
@@ -1,46 +0,0 @@
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>
)
}
@@ -1,43 +0,0 @@
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
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
-10
View File
@@ -1,10 +0,0 @@
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
@@ -1,204 +0,0 @@
@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
@@ -1 +0,0 @@
import '@testing-library/jest-dom/vitest'
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-21
View File
@@ -1,21 +0,0 @@
{
"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
@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.server.json" }
]
}
-15
View File
@@ -1,15 +0,0 @@
{
"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
@@ -1,18 +0,0 @@
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/**']
}
})