feat: add ShareServer console prototype
ShareServer previously existed only as design documents. The repository now includes a standalone Hono service and a responsive React management-console prototype covering administrator and member workflows, with local build, test, and container entry points. The sharing design now includes organization-scoped digital agents and explicit local partitioned-memory selection for @ calls. Memory documentation consistently uses partitioned memory and treats time or internal events only as optional recall triggers. This remains an interactive prototype: fixture actions do not persist, and production authentication, PostgreSQL isolation, device connections, routing, relay, and federation still require implementation. Release note: 新增可独立运行的 ShareServer 管理控制台交互原型,并完善组织数字人与分区记忆共享设计;当前原型数据和操作不会写入生产系统。
This commit is contained in:
+4
-4
@@ -60,8 +60,8 @@ otherwise.
|
||||
supervision, unified Runtime monitoring, managed processes, safe static HTML
|
||||
preview, target-pinnable workspace/browser/artifact instances, bottom
|
||||
docking, and separate windows. Task Center remains the singleton Task index;
|
||||
attachments and knowledge remain in the conversation composer, while future
|
||||
memory and historical execution context belong to the associated Task. See
|
||||
attachments and knowledge remain in the conversation composer, while memory,
|
||||
when implemented, and historical execution context belong to the associated Task. See
|
||||
the [Feature PRD](./docs/features/assistant-workbar/prd.md).
|
||||
|
||||
### Agent Runtimes and model connections
|
||||
@@ -432,8 +432,8 @@ otherwise.
|
||||
created and edited for Global or one or more selected Projects. Legacy
|
||||
single-project settings migrate without loss, and project-level memory and
|
||||
action output must explicitly target a Project in scope. Task Center and
|
||||
Settings no longer duplicate the form. Future partitioned memory remains an
|
||||
independently designed long-term direction. See the
|
||||
Settings no longer duplicate the form. Partition-aware review, candidate
|
||||
generation, and recall triggers remain to be designed. See the
|
||||
[Smart Heartbeat PRD](./docs/features/smart-heartbeat/prd.md).
|
||||
- [ ] **General supervision** (planned): Uses a fixed supervision section to
|
||||
observe user-selected conversations, Tasks, automations, or experiments and
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@
|
||||
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;迁移旧缓存时若历史可能已被截断,会持续明确提示,不能把既有缺口伪装成完整。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
|
||||
- [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。普通消息与到期 Scheduled Task 共用 Conversation 级持久发送队列,同一会话一次只执行一项;当前回复期间仍可继续发送,队列按顺序续跑,并允许删除或“立即中断并插入”。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/features/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md)。
|
||||
- [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
|
||||
- [x] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单。“未来分区记忆”仍只是尚待独立设计的长期方向。详见 [智能心跳 PRD](./docs/features/smart-heartbeat/prd.md)。
|
||||
- [x] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单;按分区回顾、候选生成和唤起条件仍待设计。详见 [智能心跳 PRD](./docs/features/smart-heartbeat/prd.md)。
|
||||
- [ ] **通用监督**(规划中):通过固定监督栏目观察用户选择的会话、任务、自动化或实验对象,提供带证据的评论与人工介入请求,但不自动发言、批准工具或切换 Execute。详见 [会话监督 PRD](./docs/features/conversation-supervision/prd.md)。
|
||||
- [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。
|
||||
- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。
|
||||
|
||||
+1
-1
@@ -640,7 +640,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 状态卡片使用统一状态令牌,不只依赖颜色。
|
||||
- 保留“运行概览 / 待处理建议 / 报告与记录 / 心跳计划”四个同级页面。
|
||||
- 智能心跳菜单入口是完整配置的权威位置;任务中心和设置中心不得复制同一 CRUD 表单。
|
||||
- “未来分区记忆”仅为长期方向,数据、状态和页面尚未设计,不得显示占位入口。
|
||||
- 分区记忆协同与唤起条件尚未实现,不得显示占位入口或平行的记忆类型。
|
||||
|
||||
### 13.5 Task Center
|
||||
|
||||
|
||||
@@ -37,11 +37,11 @@ GoodBuddy 当前已经具备若干长期助手能力,但它们仍是彼此分
|
||||
|
||||
## 2. 核心产品判断
|
||||
|
||||
### 2.1 智能心跳保持独立,未来分区记忆另行设计
|
||||
### 2.1 智能心跳保持独立,分区记忆协同另行设计
|
||||
|
||||
当前智能心跳继续承担周期回顾、报告和建议,并支持 Global 或指定 Project 范围。它不是
|
||||
Task、通用调度器或后台 Agent,也不进入统一 `AutomationPlan.kind`。未来分区记忆的
|
||||
数据、状态、唤起和页面需要独立设计,不能从当前方向直接推导。
|
||||
Task、通用调度器或后台 Agent,也不进入统一 `AutomationPlan.kind`。按分区回顾、候选生成
|
||||
以及时间/内部事件唤起需要独立设计,不能从当前心跳行为直接推导,也不创建平行记忆类型。
|
||||
|
||||
- Scheduled Task 解决“何时在一个 Task 中执行新的 Job”。
|
||||
- Goal Task 解决“围绕结果在同一 Task 中持续规划和推进”。
|
||||
@@ -507,7 +507,7 @@ experiment_runs
|
||||
现有 `schedules`、`schedule_runs`、`heartbeat_configs`、`heartbeat_runs`、
|
||||
`heartbeat_entries`、`tasks` 和 `runs` 不应一次性重写。Schedule 可渐进建立稳定 Task 与
|
||||
Conversation 关联,旧 child-task 字段可兼容映射到 Job/Subjob;心跳数据保持独立,不得
|
||||
静默转成 `AutomationPlan` 或顶层 Task。未来分区记忆完成设计前,不新增迁移目标。
|
||||
静默转成 `AutomationPlan` 或顶层 Task。分区记忆协同和唤起条件完成设计前,不新增迁移目标。
|
||||
|
||||
## 16. 分阶段实施
|
||||
|
||||
@@ -518,7 +518,7 @@ Conversation 关联,旧 child-task 字段可兼容映射到 Job/Subjob;心
|
||||
- 明确 Task N:1 Conversation 关系、左侧行首展开按钮与 Task 子项图标,以及 Task Center
|
||||
索引边界,不复制内容。
|
||||
- 为现有 Scheduled Task 和专家执行建立按 Task 聚合的活动视图。
|
||||
- 明确当前心跳保持独立,未来分区记忆尚待设计。
|
||||
- 明确当前心跳保持独立,分区记忆协同与唤起条件尚待设计。
|
||||
- 补充触发来源、运行版本、预算和读写范围展示。
|
||||
|
||||
### 阶段 1:调度与运行基础
|
||||
@@ -569,7 +569,7 @@ Conversation 关联,旧 child-task 字段可兼容映射到 Job/Subjob;心
|
||||
|
||||
## 18. 总体验收标准
|
||||
|
||||
- [ ] 智能心跳保持独立,不作为 Task 类型;未来分区记忆尚未设计。
|
||||
- [ ] 智能心跳保持独立,不作为 Task 类型;分区记忆协同与唤起条件尚未设计。
|
||||
- [ ] Task Center 只索引 Task;每个 Task 只关联一条 Conversation,一条 Conversation 可以
|
||||
关联多个 Task。
|
||||
- [ ] 当前 UI 只展示到 Task,不提供 Job/Subjob/Run 树或独立导航。
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
## 1. 背景
|
||||
|
||||
智能心跳可以生成摘要、后续任务和记忆候选,但这还不是完整学习。其长期“未来分区记忆”
|
||||
方向尚未设计,也不承担持续学习、模式挖掘或自动改进:
|
||||
智能心跳可以生成摘要、后续任务和记忆候选,但这还不是完整学习。它与分区记忆的协同尚未
|
||||
实施,也不承担持续学习、模式挖掘或自动改进:
|
||||
|
||||
- 候选是否改善未来行为没有评估。
|
||||
- 一条反思是否会被检索和使用并不确定。
|
||||
|
||||
@@ -162,7 +162,7 @@ GoodBuddy 当前已经具备:
|
||||
引用和脱敏诊断。
|
||||
- Agent Runtime 的选择或切换不改变当前向量连接;向量连接的选择或切换也不改变当前
|
||||
Agent Runtime。
|
||||
- 未来记忆、笔记或其他本地语义检索若要复用向量连接,必须经过独立产品设计、权限与
|
||||
- 分区记忆、笔记或其他本地语义检索若要复用向量连接,必须经过独立产品设计、权限与
|
||||
数据路径评审后显式接入,不能因存在全局向量连接而自动获得使用权。
|
||||
|
||||
---
|
||||
|
||||
+12
-11
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.3 |
|
||||
| 日期 | 2026-08-19 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-05 |
|
||||
| 依赖 | [自动化平台总体设计](../../architecture/automation-platform-architecture.md)、[智能心跳 PRD](../smart-heartbeat/prd.md)、[Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) |
|
||||
|
||||
## 1. 背景
|
||||
@@ -16,13 +16,13 @@ GoodBuddy 当前记忆已经支持:
|
||||
- `global`、`project`、`conversation` 三种作用域。
|
||||
- `preference`、`fact`、`summary`、`procedure` 四种类型。
|
||||
- `proposed`、`confirmed`、`rejected` 三种状态。
|
||||
- 智能心跳提出 Global 或 Project 记忆候选,由用户确认;其长期“未来分区记忆”方向尚未设计。
|
||||
- 智能心跳提出 Global 或 Project 记忆候选,由用户确认;后续按本 PRD 的分区记忆模型增强。
|
||||
|
||||
但当前能力仍不足以支撑自动化和并行实验:
|
||||
|
||||
1. 交互请求会把已加载列表中的最多 20 条已确认记忆直接拼入提示,缺少查询相关度和明确的
|
||||
会话级过滤契约。
|
||||
2. 数据库有会话作用域,但旧版心跳候选与未来唤起需求混在同一产品概念中。
|
||||
2. 数据库有会话作用域,但旧版心跳候选与分区记忆的唤起需求混在同一产品概念中。
|
||||
3. 缺少 Automation、Experiment 和 Run 分区。
|
||||
4. 来源字段存在于表结构,但普通创建和心跳候选尚未完整保存来源关系。
|
||||
5. 缺少事实的有效时间、冲突、替代、访问记录和衰减。
|
||||
@@ -64,10 +64,11 @@ SQLite 显式字段、FTS、来源关系和可选本地 Embedding 足以支持
|
||||
- 同一实体跨大量会话的别名消歧。
|
||||
- 可解释的关系证据链。
|
||||
|
||||
### 2.5 未来分区记忆尚待设计
|
||||
### 2.5 分区记忆与唤起
|
||||
|
||||
已确认智能心跳的长期方向是“未来分区记忆”,但当前尚未定义其数据结构、唤起条件、状态、
|
||||
生命周期、与长期记忆的关系或迁移方式。本 PRD 不新增 `FutureMemory` 类型、表或检索规则。
|
||||
正式产品概念统一为“分区记忆”。时间和内部事件可以作为分区记忆条目的可选唤起条件,但
|
||||
不能改变条目所属分区、绕过分区读取边界,也不建立平行的记忆类型或表。唤起字段、状态、
|
||||
生命周期、页面和旧数据迁移必须在实施前补充设计。
|
||||
|
||||
## 3. 目标
|
||||
|
||||
@@ -430,8 +431,8 @@ Project 记忆与 Global 偏好冲突时:
|
||||
|
||||
每条记忆展示内容、类型、范围、来源、状态、时间、置信度、重要性和冲突。
|
||||
|
||||
智能心跳的完整配置仍在“智能心跳”菜单中管理;未来分区记忆完成独立设计前,不加入记忆
|
||||
中心信息架构。
|
||||
智能心跳的完整配置仍在“智能心跳”菜单中管理。分区记忆的唤起条件完成字段、状态和生命周期
|
||||
设计后,可以在记忆中心增加“待唤起”视图;不得创建平行的记忆分区或内容页。
|
||||
|
||||
## 16. 数据模型建议
|
||||
|
||||
@@ -474,13 +475,13 @@ Project 记忆与 Global 偏好冲突时:
|
||||
6. 增加冲突、时态、替代和归档。
|
||||
7. 增加实验冻结快照与 Run 隔离。
|
||||
8. 增加可选本地 Embedding 和混合排序。
|
||||
9. 未来分区记忆和时间知识图谱都必须在明确需求与独立设计后再实施。
|
||||
9. 增加分区记忆的时间/内部事件唤起;时间知识图谱仍须在明确需求与独立设计后再实施。
|
||||
|
||||
## 19. 验收标准
|
||||
|
||||
- [ ] 普通会话只读取 Global、当前 Project 和当前 Conversation 的允许记忆。
|
||||
- [ ] 智能心跳配置只能属于 Global 或 Main 已验证的一个、多个 Project。
|
||||
- [ ] 未来分区记忆完成独立设计前,不新增相关表、状态或检索行为。
|
||||
- [ ] 唤起条件始终依附于一个已验证的记忆分区,不建立平行的记忆表或范围。
|
||||
- [ ] Task 执行只读取内部 Run 快照绑定的分区。
|
||||
- [ ] 实验 Run 不能读取其他 Run 的消息或记忆。
|
||||
- [ ] 每条非手动记忆都有可追溯来源。
|
||||
|
||||
@@ -14,10 +14,13 @@ ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不
|
||||
| [功能逻辑](./logic-design.md) | 组织隔离、设备、发布、策略、任务和联邦状态规则 |
|
||||
| [管理界面设计](./ui-design.md) | ShareServer Web 控制台的信息架构与交互 |
|
||||
| [技术设计](./technical-design.md) | 服务模块、API、数据、部署、运维和验证 |
|
||||
| [实施进度](./progress.md) | 已验证实现、剩余生产工作与验证证据 |
|
||||
| [共享网络总体设计](../../architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威 |
|
||||
|
||||
当前没有单独的 User Stories:管理员、能力发布者、审计员和普通成员场景已进入 PRD 与逻辑
|
||||
设计。服务尚未实施,因此不创建 `progress.md`。
|
||||
设计。仓库已建立可运行的全控制台交互原型;它用于确认信息架构与交互,不代表生产服务已经
|
||||
实现。当前边界和运行方法见[实施进度](./progress.md)与
|
||||
[`shareserver/README.md`](../../../shareserver/README.md)。
|
||||
|
||||
## 角色
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-05 |
|
||||
| 关联 PRD | [ShareServer PRD](./prd.md) |
|
||||
| 共享规则 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
|
||||
|
||||
@@ -53,6 +53,14 @@ ShareServer 只路由调用方冻结的 provider,不在离线、繁忙或失
|
||||
初始化只在实例尚无系统管理员时可用。首个系统管理员创建成功后,后续请求即使持有旧的
|
||||
一次性引导材料也不能再次进入初始化流程。
|
||||
|
||||
### INV-10 数字人发布不等于记忆公开
|
||||
|
||||
发布数字人只建立一个有界 Agent Publication。它默认不取得所有者任何本地记忆;所有者可以
|
||||
在发布时勾选 Agent、Global、指定 Project 和指定 Automation 分区,不能开放 Conversation
|
||||
和 Run 分区。ShareServer 和调用者只能看到允许分区种类与“本次调用会使用本地记忆”的政策
|
||||
事实,不能读取原始条目、分区内容、来源正文或检索结果。新增可读分区按权限扩大重新审批,
|
||||
移除分区可以立即收紧。
|
||||
|
||||
## 2. 主要状态
|
||||
|
||||
### 2.1 成员
|
||||
@@ -120,7 +128,7 @@ active <-> paused
|
||||
|
||||
1. 验证请求身份、会话 audience 和 organization。
|
||||
2. 验证用户、设备、provider、Publication 和 FederationTrust 当前状态。
|
||||
3. 校验能力版本、动作、输入 schema、数据类别、大小和委托链。
|
||||
3. 校验能力或数字人版本、动作、输入 schema、数据类别、大小和委托链。
|
||||
4. 收集调用方组织、提供方组织、Publication 和 Grant 规则。
|
||||
5. 按拒绝优先合并,计算 `deny/require_approval/allow`。
|
||||
6. 校验次数、并发、预算和有效期。
|
||||
@@ -137,6 +145,7 @@ active <-> paused
|
||||
| 成员与组 | 紧急管理 | 是 | 否 | 否 | 只读 | 自己 |
|
||||
| 注册设备 | 只读/撤销 | 是 | 自己 | 否 | 只读 | 自己 |
|
||||
| 发布能力/包 | 否 | 是 | 授权范围 | 否 | 只读 | 否 |
|
||||
| 发布自己的数字人 | 否 | 是 | 授权范围 | 否 | 只读 | 自己 |
|
||||
| 编辑策略 | 否 | 是 | 否 | 否 | 只读 | 否 |
|
||||
| 处理审批 | 否 | 可配置 | 否 | 授权范围 | 只读 | 自己的申请 |
|
||||
| 联邦关系 | 实例级许可 | 是 | 否 | 可共同审批 | 只读 | 否 |
|
||||
@@ -201,9 +210,10 @@ active <-> paused
|
||||
| `FR-10` | 组织隔离、删除撤销与保留规则 |
|
||||
| `FR-11` | 组织角色边界及各对象状态;展示行为由 UI 设计定义 |
|
||||
| `FR-12` | 短期权威恢复规则和逻辑完整性;部署结构由技术设计定义 |
|
||||
| `FR-13` | 注册、Publication、目录与授权不变量,provider 冻结及 `INV-10` |
|
||||
|
||||
## 10. 逻辑完整性
|
||||
|
||||
本设计覆盖实例、组织、成员、设备、发布、授权、审批、任务、更新、联邦、撤销和恢复。实现
|
||||
前仍需确定首个 OIDC 提供者矩阵、默认审计保留期、部署规模目标和中继带宽上限;这些部署
|
||||
本设计覆盖实例、组织、成员、设备、能力与数字人发布、授权、审批、任务、更新、联邦、撤销
|
||||
和恢复。实现前仍需确定首个 OIDC 提供者矩阵、默认审计保留期、部署规模目标和中继带宽上限;这些部署
|
||||
参数应可配置,不改变拒绝优先、双边联邦和 provider 冻结规则。
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-05 |
|
||||
| 适用产品 | 独立部署的 ShareServer 与 Web 管理控制台 |
|
||||
| 关联架构 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
|
||||
| 关联文档 | [功能逻辑](./logic-design.md)、[管理界面](./ui-design.md)、[技术设计](./technical-design.md) |
|
||||
@@ -25,6 +25,7 @@
|
||||
4. 设备离线、用户撤销或策略变化能准确影响新任务,不破坏历史证据。
|
||||
5. 两个组织可经双边确认建立有限范围的联邦协作。
|
||||
6. ShareServer 可以只运行控制面,也可按部署策略提供数据中继和包存储。
|
||||
7. 成员可以显式开放自己的数字人,获准的组织成员可通过 `@数字人` 发起有界协作任务。
|
||||
|
||||
## 3. 产品范围
|
||||
|
||||
@@ -37,6 +38,7 @@
|
||||
- 设备出站控制通道、任务路由、取消和可选中继。
|
||||
- 签名能力包仓库、发布通道、更新策略和撤销。
|
||||
- ShareServer 间双边联邦。
|
||||
- 组织数字人目录、数字人 Publication、授权发现和 `@数字人` 调用。
|
||||
- 审计、用量、保留和导出。
|
||||
- Web 管理控制台和成员自助页面。
|
||||
|
||||
@@ -46,6 +48,8 @@
|
||||
- 发现未注册的局域网设备。
|
||||
- 保存或分发用户 API Key、Cookie 和桌面登录状态。
|
||||
- 默认查看任务正文、附件和模型输出。
|
||||
- 上传、同步或浏览数字人所有者的本地个人、项目、会话和原始 Agent 记忆。
|
||||
- 因为成员加入组织而自动开放其数字人、记忆、Runtime、工作区或工具。
|
||||
- 未经提供者发布自动盘点设备能力。
|
||||
- 自动信任同域邮箱、同一网络或其他 ShareServer。
|
||||
- 公共开放市场、计费结算或匿名能力调用。
|
||||
@@ -79,6 +83,8 @@
|
||||
- 目录按调用者身份、设备状态、组织和策略过滤后返回。
|
||||
- 支持同一能力多个版本和多个 provider,不掩盖来源。
|
||||
- provider 离线时保留目录事实但显示不可用,不派发新任务。
|
||||
- 数字人作为由具体设备或服务节点提供的复合 Agent 能力进入目录,持续显示所有者、提供者、
|
||||
运行位置和可调用状态,不伪装成 ShareServer 自身能力。
|
||||
|
||||
### FR-5 授权策略
|
||||
|
||||
@@ -89,7 +95,7 @@
|
||||
|
||||
### FR-6 审批
|
||||
|
||||
- 调用、能力发布、权限扩大更新和联邦关系可以要求审批。
|
||||
- 调用、能力发布、数字人上线或范围扩大、权限扩大更新和联邦关系可以要求审批。
|
||||
- 审批项显示申请人、对象、影响范围、数据类别、期限和策略依据。
|
||||
- 批准结果有明确范围和有效期,不提供无边界“永久允许”。
|
||||
- 申请内容变化后旧审批失效。
|
||||
@@ -125,9 +131,10 @@
|
||||
|
||||
### FR-11 管理控制台
|
||||
|
||||
- 提供概览、组织与成员、设备、能力目录、权限策略、审批、发布与更新、网关联邦、任务与
|
||||
中继、审计、系统设置。
|
||||
- 提供概览、组织与成员、设备、能力目录、数字人、权限策略、审批、发布与更新、网关联邦、
|
||||
任务与中继、审计、系统设置。
|
||||
- 普通成员只看到自己的设备、授权和待办;管理员栏目按权限隐藏并在 API 再次校验。
|
||||
- 普通成员可以管理自己发布的数字人,并发现和 `@` 当前组织内获准调用的数字人。
|
||||
- 所有高影响操作说明对象、范围和后果。
|
||||
|
||||
### FR-12 可部署性
|
||||
@@ -137,6 +144,38 @@
|
||||
- 提供健康、就绪、指标、结构化日志、备份和恢复说明。
|
||||
- 服务升级不能要求同时升级所有在线桌面端;按协议版本协商兼容集合。
|
||||
|
||||
### 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. 产品验收
|
||||
|
||||
- 新实例可完成初始化、登录、创建组织、邀请成员和注册真实桌面设备。
|
||||
@@ -146,4 +185,11 @@
|
||||
- 任务中继关闭时控制面仍可运行;中继开启时可取消并形成准确终态。
|
||||
- 权限扩大的包更新不能自动部署。
|
||||
- 两个独立 ShareServer 能建立、使用、暂停和撤销双边联邦。
|
||||
- 成员可以显式发布一个数字人,获准的同组织成员能从选择器插入 `@handle` 并完成一次有界
|
||||
调用;未获准成员不能通过目录、搜索、补全或直接 API 枚举和调用该数字人。
|
||||
- 暂停或撤销数字人后不能创建新调用,已完成任务与有界审计仍可追溯到原 Publication 和
|
||||
provider。
|
||||
- 数字人调用可以使用所有者在发布时勾选的 Agent、Global、指定 Project 或指定 Automation
|
||||
分区,但不能开放 Conversation 和 Run;ShareServer 和调用者不能读取原始记忆、未选择的
|
||||
本地分区或记忆来源正文。
|
||||
- 备份恢复后组织、策略、发布和审计身份保持一致,短期连接和令牌不恢复。
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# ShareServer 实施进度
|
||||
|
||||
## 当前状态
|
||||
|
||||
ShareServer 已进入可运行交互原型阶段。当前实现用于验证完整控制台信息架构、视觉方向与操作
|
||||
流程,不提供生产身份、组织隔离、设备连接、授权、任务路由或数据持久化。
|
||||
|
||||
## 已验证实现
|
||||
|
||||
### 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 记忆边界展示,因此这些能力仍属于剩余生产与原型工作,
|
||||
不能由现有通用能力目录或原型操作确认替代。
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-05 |
|
||||
| 关联 PRD | [ShareServer PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| 共享协议 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
|
||||
@@ -38,8 +38,11 @@ shareserver/
|
||||
tests/
|
||||
```
|
||||
|
||||
当前只提交设计文档,不创建无法运行的目录占位。服务端和 Desktop 共同依赖未来的
|
||||
`packages/share-protocol`,ShareServer 不导入 Electron 的 `src/shared`。
|
||||
仓库已创建可独立运行的 `shareserver/` 全控制台交互原型。原型使用 Hono 提供健康、就绪、
|
||||
界面快照和无持久化操作确认 API,并同源托管 React/Vite Web 控制台;它不依赖 Electron 的
|
||||
`src/shared`。当前尚未建立生产数据库、认证、设备连接和共享协议,因此也不提前创建空的
|
||||
`packages/share-protocol`。进入真实 Desktop 协议实现时,服务端和 Desktop 共同依赖
|
||||
`packages/share-protocol`,ShareServer 仍不得导入 Electron 的 `src/shared`。
|
||||
|
||||
## 2. 技术选型约束
|
||||
|
||||
@@ -57,7 +60,7 @@ shareserver/
|
||||
| `auth` | 本地身份、OIDC、Web session、服务和设备凭据 |
|
||||
| `organizations` | 组织、成员、组、角色和范围解析 |
|
||||
| `devices` | 注册挑战、设备记录、出站连接和在线状态 |
|
||||
| `capabilities` | manifest、provider、Publication 和目录投影 |
|
||||
| `capabilities` | manifest、数字人 profile、provider、Publication 和目录投影 |
|
||||
| `policies` | Grant、强制策略和确定性授权计算 |
|
||||
| `approvals` | 内容绑定申请、决定、过期和通知 |
|
||||
| `tasks` | task/attempt、provider 路由、状态和取消 |
|
||||
@@ -104,6 +107,7 @@ device_credentials
|
||||
device_connections
|
||||
capabilities
|
||||
capability_versions
|
||||
agent_profiles
|
||||
providers
|
||||
publications
|
||||
grants
|
||||
@@ -130,6 +134,13 @@ revocations
|
||||
- 密钥保存为单向摘要或经外部主密钥加密的密文;数据库不保存明文 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. 事务与一致性
|
||||
|
||||
@@ -240,3 +251,19 @@ PostgreSQL
|
||||
- 生产 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 上叠加生产状态。
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-05 |
|
||||
| 关联 PRD | [ShareServer PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
|
||||
@@ -33,6 +33,7 @@ ShareServer 是独立 Web 产品,不直接复用 Electron 页面壳层实现
|
||||
组织与成员
|
||||
设备
|
||||
能力目录
|
||||
数字人
|
||||
权限策略
|
||||
审批
|
||||
发布与更新
|
||||
@@ -90,6 +91,34 @@ GPU-Worker-02 AI Team Linux 离线 8 2 小时前
|
||||
详情包含 manifest、provider、Publication、允许输入、数据政策、调用趋势和最近错误。管理员
|
||||
看到的是组织元数据,不通过目录读取能力内部数据。
|
||||
|
||||
### 6.1 数字人
|
||||
|
||||
数字人使用独立栏目呈现,不混入普通工具能力表。目录按 `@handle`、显示名、所有者、专业
|
||||
说明、提供者、Runtime、版本、在线状态和可用范围筛选;只有当前成员可发现的数字人进入
|
||||
搜索和提及补全。
|
||||
|
||||
数字人详情显示:
|
||||
|
||||
- 所有者、来源组织、执行设备或服务节点。
|
||||
- Publication 版本、支持的输入/输出、可用时间、并发和有效期。
|
||||
- 数据去向、路由、中继可读性、是否允许委托。
|
||||
- 是否使用本地记忆、允许的分区种类,以及允许处理的数据敏感度;不显示记忆条目、具体分区
|
||||
名称、来源正文和检索结果。
|
||||
- 当前成员的 `discover/inspect/invoke` 权限、命中策略和审批要求。
|
||||
|
||||
成员可以在“我的数字人”中创建多个用途不同的发布草稿,选择一个本地 Agent、设置唯一
|
||||
`@handle` 和开放范围、提交审批、暂停、恢复、更新或撤销。加入组织不会生成默认数字人,
|
||||
创建本地 Agent 也不会自动发布。
|
||||
|
||||
发布表单的“可用记忆”默认全部关闭,并允许勾选数字人的 Agent、Global、指定 Project 和
|
||||
指定 Automation 分区。Conversation 和 Run 不作为可选项。表单持续说明记忆只在提供者本地
|
||||
检索,调用者和 ShareServer 看不到内容;新增分区在保存前明确标为权限扩大,移除分区明确
|
||||
标为立即收紧。
|
||||
|
||||
组织会话和任务输入区使用带搜索的显式选择器插入 `@数字人`,不能只靠自由文本猜测 handle。
|
||||
选择后持续显示目标数字人;发送前确认实际接收者、执行位置、发送数据和授权。离线、无权或
|
||||
需要审批的项保持可读并说明原因,但不能形成已发送调用。
|
||||
|
||||
## 7. 权限策略
|
||||
|
||||
策略页分“强制策略”和“Grant”。创建和编辑使用结构化表单:主体、能力、动作、输入、提供者、
|
||||
@@ -157,3 +186,15 @@ GPU-Worker-02 AI Team Linux 离线 8 2 小时前
|
||||
- 窄屏将表格转为字段列表,保持组织、来源、状态和风险信息,不把管理动作挤成不可辨图标。
|
||||
- 所有时间显示实例时区并可查看 ISO 时间;联邦事件同时显示对端时间时标明时区。
|
||||
- 状态不只依赖颜色。危险操作在确认后保持原页面上下文和可恢复错误。
|
||||
|
||||
## 15. 交互原型视觉基线
|
||||
|
||||
2026-09-05 的首个可运行原型采用深海科技控制台方向,并同时提供浅色主题。视觉实现遵循
|
||||
shadcn/ui 的本地组件模式和 Radix 可访问 primitives,布局参考成熟后台的侧栏、顶栏、数据
|
||||
表格和详情对话框,但不复制第三方示例业务页面。
|
||||
|
||||
原型已覆盖数字人需求加入前的一级栏目、管理员与成员自助视图切换、组织范围、搜索筛选、
|
||||
页签、详情、创建表单、危险确认和 390px 窄屏导航。动态发光、网格和网络脉冲只用于运行状态
|
||||
与品牌氛围;业务表格仍保持克制,状态继续同时使用图标与文字。原型操作会调用无持久化确认
|
||||
API,并持续标明不会写入生产数据,不能据此宣称真实审批、撤销、策略或任务操作已实现。
|
||||
`FR-13` 新增的数字人目录、“我的数字人”和 `@` 选择器尚未进入当前原型。
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 入口与范围已实现;未来分区记忆待独立设计 |
|
||||
| 版本 | 0.5 |
|
||||
| 日期 | 2026-08-19 |
|
||||
| 状态 | 入口与范围已实现;分区记忆协同待设计 |
|
||||
| 版本 | 0.6 |
|
||||
| 日期 | 2026-09-05 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 文档角色 | 智能心跳当前能力、权威入口、范围和长期边界 |
|
||||
| 相关设计 | [统一界面设计系统](../../../UI-DESIGN.md) |
|
||||
@@ -14,8 +14,8 @@
|
||||
| 相关界面 | [通用助手工作栏与执行空间 PRD](../assistant-workbar/prd.md) |
|
||||
| Task 模型 | [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) |
|
||||
|
||||
> 本文区分近期实施与长期方向。未完成设计的“未来分区记忆”不得转写为数据结构、状态机、
|
||||
> 页面、迁移或验收条件。
|
||||
> 本文区分近期实施与长期方向。分区记忆由[分区记忆 PRD](../memory/prd.md)定义;智能心跳
|
||||
> 不能另行创建平行的记忆类型、表或页面。
|
||||
|
||||
## 1. 已确认的产品边界
|
||||
|
||||
@@ -26,23 +26,23 @@
|
||||
- 智能心跳不并入通用自动化中心,也不作为任务中心条目。
|
||||
- 现有心跳报告、记忆建议、行动建议和审计历史在本轮继续保留。
|
||||
|
||||
### 1.2 长期方向:未来分区记忆
|
||||
### 1.2 长期方向:分区记忆协同
|
||||
|
||||
已确认的方向只有:
|
||||
|
||||
> 智能心跳未来应成为按范围隔离的未来记忆。
|
||||
> 智能心跳后续应按分区回顾本地信息,提出对应分区的记忆候选和可选唤起建议。
|
||||
|
||||
以下内容尚未设计:
|
||||
|
||||
- “未来记忆”的数据结构与存储方式。
|
||||
- 与当前长期记忆、心跳报告和建议的关系。
|
||||
- 唤起条件、状态和生命周期。
|
||||
- 新增 Automation、Run 和 Agent 等分区后的逐分区回顾规则。
|
||||
- 与当前记忆候选、心跳报告和建议的关系。
|
||||
- 时间与内部事件唤起条件的字段、状态和生命周期。
|
||||
- 到期后的页面和交互。
|
||||
- 是否以及如何创建 Task。
|
||||
- 旧版心跳数据如何迁移。
|
||||
|
||||
本轮不得实现 `FutureMemory`、新增未来记忆数据库表,或制作“未来记忆 / 即将唤起 /
|
||||
已唤起”等页面。后续需要独立 PRD 和用户确认。
|
||||
本轮不得扩展分区记忆类型或表。后续唤起能力必须作为分区记忆条目的可选条件,并继续遵守
|
||||
该条目的分区读取边界。
|
||||
|
||||
## 2. 本轮实施范围
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 不设计或实现未来分区记忆。
|
||||
- 不扩展分区记忆模型或实现唤起条件。
|
||||
- 不修改 Task、Job 或 Task Center 模型。
|
||||
- 不新增独立 Automation Center。
|
||||
- 不移除定时任务现有入口。
|
||||
@@ -181,7 +181,7 @@
|
||||
|
||||
## 6. 数据与安全要求
|
||||
|
||||
目标合同只扩展现有心跳范围,不引入 Future Memory:
|
||||
目标合同只扩展现有心跳范围,不扩展分区记忆合同:
|
||||
|
||||
```ts
|
||||
type HeartbeatScope =
|
||||
@@ -217,11 +217,11 @@ type HeartbeatScope =
|
||||
- 如需要,设置中心仅保留“打开智能心跳”导航。
|
||||
- 验证应用内仍只有一个完整配置入口。
|
||||
|
||||
### 后续:独立设计
|
||||
### 后续:分区记忆协同设计
|
||||
|
||||
- 未来分区记忆。
|
||||
- 与长期记忆的关系。
|
||||
- 唤起模型与生命周期。
|
||||
- 按 Global、Project、Automation、Run 和 Agent 分区独立回顾并提出候选。
|
||||
- 与已确认记忆、候选、心跳报告和建议的关系。
|
||||
- 时间与内部事件唤起条件及生命周期。
|
||||
- 与 Task 的关系。
|
||||
|
||||
这些内容需要新的 PRD 和明确确认,不属于本轮实现。
|
||||
@@ -242,5 +242,5 @@ type HeartbeatScope =
|
||||
- [x] 设置中心不再包含单条心跳 CRUD。
|
||||
- [x] 任务中心本身以及定时任务现有行为没有被删除。
|
||||
- [x] 智能心跳仍保持只读、禁用工具和有界输入输出。
|
||||
- [x] 没有新增 Future Memory 表、状态机或未确认页面。
|
||||
- [x] 没有新增平行的记忆类型、表或页面。
|
||||
- [x] 加载失败、空状态、字段错误和危险操作满足统一设计与无障碍要求。
|
||||
|
||||
@@ -169,8 +169,8 @@ GoodBuddy 应能够:
|
||||
- 当前智能心跳继续提供周期回顾、报告、记忆建议、行动建议和运行历史。
|
||||
- “智能心跳”菜单入口负责完整配置,并支持 Global 或指定一个、多个 Project。
|
||||
- 任务中心和设置中心不复制智能心跳 CRUD。
|
||||
- 智能心跳长期方向是“未来分区记忆”,但数据结构、唤起模型、生命周期、页面以及与任务和
|
||||
长期记忆的关系尚未设计,不能提前实现。
|
||||
- 智能心跳长期按分区回顾信息并提出记忆候选或唤起建议,但协同规则、唤起条件、生命周期、
|
||||
页面以及与任务的关系尚未设计,不能提前实现平行的记忆类型或页面。
|
||||
|
||||
### 4.6 成果和预览
|
||||
|
||||
@@ -355,7 +355,7 @@ GoodBuddy 应能够:
|
||||
- 用户删除后不再检索到。
|
||||
- 每条记忆显示来源与作用域。
|
||||
- 智能心跳配置明确属于 Global 或指定 Project,当前报告和建议继续沿用已有生命周期。
|
||||
- 未来分区记忆完成独立设计前,不新增相关数据、页面或任务转换。
|
||||
- 分区记忆协同和唤起条件完成设计前,不新增相关数据、页面或任务转换。
|
||||
|
||||
### 8.5 安全
|
||||
|
||||
|
||||
+28
-1
@@ -12,7 +12,10 @@ export default tseslint.config(
|
||||
'coverage/**',
|
||||
'dist/**',
|
||||
'node_modules/**',
|
||||
'out/**'
|
||||
'out/**',
|
||||
'shareserver/build-server/**',
|
||||
'shareserver/dist/**',
|
||||
'shareserver/node_modules/**'
|
||||
]
|
||||
},
|
||||
js.configs.recommended,
|
||||
@@ -50,6 +53,30 @@ export default tseslint.config(
|
||||
...reactHooks.configs.recommended.rules
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['shareserver/src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.vitest
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['shareserver/server/**/*.ts'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.vitest
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['sites/**/*.js'],
|
||||
languageOptions: {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
build-server
|
||||
dist
|
||||
node_modules
|
||||
*.log
|
||||
*.png
|
||||
@@ -0,0 +1,4 @@
|
||||
build-server/
|
||||
dist/
|
||||
node_modules/
|
||||
*.png
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV SHARESERVER_PORT=8787
|
||||
ENV SHARESERVER_HOST=0.0.0.0
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/build-server ./build-server
|
||||
|
||||
USER node
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["node", "build-server/server/index.js"]
|
||||
@@ -0,0 +1,85 @@
|
||||
# ShareServer interactive prototype
|
||||
|
||||
This directory contains the first runnable ShareServer project. It is an
|
||||
interactive control-console prototype based on the
|
||||
[ShareServer PRD](../docs/features/share-server/prd.md), not a production
|
||||
ShareServer implementation.
|
||||
|
||||
## Included
|
||||
|
||||
- Hono HTTP service with health, readiness, prototype snapshot, and prototype
|
||||
action endpoints.
|
||||
- Same-origin React, Vite, TypeScript, Tailwind CSS, and shadcn/ui-style Web
|
||||
console.
|
||||
- Administrator views for overview, members, devices, capabilities, policies,
|
||||
approvals, releases, federation, tasks, audit, and settings.
|
||||
- Member self-service views for personal devices, grants, and requests.
|
||||
- Responsive navigation, dark and light themes, search, filters, details,
|
||||
forms, confirmations, and keyboard-accessible dialogs.
|
||||
- Production build and a non-root container image.
|
||||
|
||||
The UI uses official shadcn/ui conventions and Radix primitives. Its information
|
||||
architecture was informed by the MIT-licensed
|
||||
[`satnaing/shadcn-admin`](https://github.com/satnaing/shadcn-admin), but this
|
||||
project does not copy that template or include its Clerk integration and sample
|
||||
business pages.
|
||||
|
||||
## Not included yet
|
||||
|
||||
Prototype records are deliberately read-only fixtures. The action endpoint
|
||||
returns an acknowledgement but does not persist changes. PostgreSQL,
|
||||
initialization and login, organization isolation, real authorization, device
|
||||
WebSocket connections, task routing, relay, package storage, federation, and
|
||||
auditable mutations remain production implementation work.
|
||||
|
||||
The readiness endpoint reports this explicitly:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ready",
|
||||
"mode": "interactive-prototype",
|
||||
"productionReady": false
|
||||
}
|
||||
```
|
||||
|
||||
## Run locally
|
||||
|
||||
```text
|
||||
cd shareserver
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:5174`. Vite proxies `/api` to the Hono service on port
|
||||
`8787`.
|
||||
|
||||
## Run the production build
|
||||
|
||||
```text
|
||||
cd shareserver
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8787`.
|
||||
|
||||
Set `SHARESERVER_PORT` to use another listen port. Local runs bind to
|
||||
`127.0.0.1` by default; set `SHARESERVER_HOST` explicitly when a trusted reverse
|
||||
proxy or container needs another interface. Do not expose the prototype as a
|
||||
production service.
|
||||
|
||||
## Container
|
||||
|
||||
```text
|
||||
docker build -t goodbuddy-shareserver-prototype ./shareserver
|
||||
docker run --rm -p 8787:8787 goodbuddy-shareserver-prototype
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
```text
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
import js from '@eslint/js'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['build-server/**', 'dist/**', 'node_modules/**']
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.vitest
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['server/**/*.ts'],
|
||||
languageOptions: {
|
||||
globals: globals.node
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#07111f" />
|
||||
<title>ShareServer Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5770
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@goodbuddy/shareserver",
|
||||
"version": "0.1.0-prototype.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -n api,web -c cyan,magenta \"npm:dev:api\" \"npm:dev:web\"",
|
||||
"dev:api": "tsx watch server/index.ts",
|
||||
"dev:web": "vite",
|
||||
"build": "npm run typecheck && vite build && npm run build:server",
|
||||
"build:server": "node -e \"require('node:fs').rmSync('build-server',{recursive:true,force:true})\" && tsc -p tsconfig.server.json",
|
||||
"start": "node build-server/server/index.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.server.json",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "2.1.1",
|
||||
"@radix-ui/react-dialog": "1.1.23",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"hono": "4.13.7",
|
||||
"lucide-react": "1.27.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-router-dom": "7.18.3",
|
||||
"tailwind-merge": "3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@testing-library/jest-dom": "7.0.0",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@types/node": "26.1.2",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "5.2.0",
|
||||
"concurrently": "10.0.5",
|
||||
"eslint": "10.8.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"globals": "17.8.0",
|
||||
"jsdom": "30.0.1",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.65.0",
|
||||
"vite": "7.3.6",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { app } from './app.js'
|
||||
|
||||
describe('ShareServer prototype API', () => {
|
||||
it('reports prototype readiness without claiming production readiness', async () => {
|
||||
const response = await app.request('/api/v1/public/ready')
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
mode: 'interactive-prototype',
|
||||
productionReady: false
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the organization-scoped console snapshot', async () => {
|
||||
const response = await app.request('/api/v1/web/prototype/snapshot')
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
instance: { organization: 'MesaLab' },
|
||||
pages: {
|
||||
devices: expect.any(Array),
|
||||
approvals: expect.any(Array),
|
||||
myDevices: expect.any(Array)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed prototype actions', async () => {
|
||||
const response = await app.request('/api/v1/web/prototype/actions', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action: '批准申请' })
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
code: 'INVALID_PROTOTYPE_ACTION'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { serveStatic } from '@hono/node-server/serve-static'
|
||||
import { Hono } from 'hono'
|
||||
import { logger } from 'hono/logger'
|
||||
import { secureHeaders } from 'hono/secure-headers'
|
||||
import { prototypeSnapshot } from '../shared/prototype-data.js'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.use('*', logger())
|
||||
app.use(
|
||||
'*',
|
||||
secureHeaders({
|
||||
contentSecurityPolicy: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"]
|
||||
},
|
||||
crossOriginEmbedderPolicy: false
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/api/v1/public/health', (context) =>
|
||||
context.json({
|
||||
status: 'ok',
|
||||
service: 'shareserver',
|
||||
version: '0.1.0-prototype.1'
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/api/v1/public/ready', (context) =>
|
||||
context.json({
|
||||
status: 'ready',
|
||||
mode: 'interactive-prototype',
|
||||
productionReady: false
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/api/v1/web/prototype/snapshot', (context) =>
|
||||
context.json({
|
||||
...prototypeSnapshot,
|
||||
instance: {
|
||||
...prototypeSnapshot.instance,
|
||||
generatedAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
app.post('/api/v1/web/prototype/actions', async (context) => {
|
||||
const body = await context.req.json<{ action?: string; targetId?: string }>().catch(() => null)
|
||||
if (!body?.action || !body.targetId) {
|
||||
return context.json(
|
||||
{
|
||||
code: 'INVALID_PROTOTYPE_ACTION',
|
||||
message: '缺少原型操作或目标对象。',
|
||||
requestId: crypto.randomUUID()
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
return context.json({
|
||||
accepted: true,
|
||||
action: body.action,
|
||||
targetId: body.targetId,
|
||||
prototypeOnly: true,
|
||||
requestId: crypto.randomUUID()
|
||||
})
|
||||
})
|
||||
|
||||
app.use('*', serveStatic({ root: './dist' }))
|
||||
app.get('*', serveStatic({ path: './dist/index.html' }))
|
||||
|
||||
export { app }
|
||||
@@ -0,0 +1,9 @@
|
||||
import { serve } from '@hono/node-server'
|
||||
import { app } from './app.js'
|
||||
|
||||
const port = Number(process.env.SHARESERVER_PORT ?? 8787)
|
||||
const hostname = process.env.SHARESERVER_HOST ?? '127.0.0.1'
|
||||
|
||||
serve({ fetch: app.fetch, port, hostname }, (info) => {
|
||||
console.log(`ShareServer interactive prototype listening on http://${hostname}:${info.port}`)
|
||||
})
|
||||
@@ -0,0 +1,324 @@
|
||||
export type PrototypeRole = 'admin' | 'member'
|
||||
|
||||
export type Metric = {
|
||||
label: string
|
||||
value: string
|
||||
detail: string
|
||||
tone: 'cyan' | 'violet' | 'amber' | 'rose'
|
||||
target: string
|
||||
}
|
||||
|
||||
export type PrototypeRecord = {
|
||||
id: string
|
||||
primary: string
|
||||
secondary: string
|
||||
status: string
|
||||
statusTone: 'online' | 'warning' | 'offline' | 'danger' | 'neutral'
|
||||
meta: string[]
|
||||
detail: Record<string, string>
|
||||
}
|
||||
|
||||
export type PrototypeSnapshot = {
|
||||
instance: {
|
||||
name: string
|
||||
organization: string
|
||||
protocol: string
|
||||
region: string
|
||||
generatedAt: string
|
||||
}
|
||||
metrics: Metric[]
|
||||
activity: Array<{ time: string; label: string; value: number }>
|
||||
pages: Record<string, PrototypeRecord[]>
|
||||
}
|
||||
|
||||
const record = (
|
||||
id: string,
|
||||
primary: string,
|
||||
secondary: string,
|
||||
status: string,
|
||||
statusTone: PrototypeRecord['statusTone'],
|
||||
meta: string[],
|
||||
detail: Record<string, string>
|
||||
): PrototypeRecord => ({ id, primary, secondary, status, statusTone, meta, detail })
|
||||
|
||||
export const prototypeSnapshot: PrototypeSnapshot = {
|
||||
instance: {
|
||||
name: 'Nebula ShareServer',
|
||||
organization: 'MesaLab',
|
||||
protocol: 'Share Protocol 1.0',
|
||||
region: '上海 · 私有云',
|
||||
generatedAt: '2026-09-05T09:42:00+08:00'
|
||||
},
|
||||
metrics: [
|
||||
{ label: '待处理审批', value: '6', detail: '最旧等待 42 分钟', tone: 'amber', target: '/approvals' },
|
||||
{ label: '在线设备', value: '18 / 24', detail: '2 台需要关注', tone: 'cyan', target: '/devices' },
|
||||
{ label: '可用能力', value: '37', detail: '来自 21 个 Provider', tone: 'violet', target: '/capabilities' },
|
||||
{ label: '活动任务', value: '12', detail: '过去 24 小时成功率 98.2%', tone: 'rose', target: '/tasks' }
|
||||
],
|
||||
activity: [
|
||||
{ time: '08:00', label: '任务', value: 12 },
|
||||
{ time: '09:00', label: '任务', value: 19 },
|
||||
{ time: '10:00', label: '任务', value: 15 },
|
||||
{ time: '11:00', label: '任务', value: 26 },
|
||||
{ time: '12:00', label: '任务', value: 21 },
|
||||
{ time: '13:00', label: '任务', value: 33 },
|
||||
{ time: '14:00', label: '任务', value: 28 },
|
||||
{ time: '15:00', label: '任务', value: 42 },
|
||||
{ time: '16:00', label: '任务', value: 36 },
|
||||
{ time: '17:00', label: '任务', value: 48 },
|
||||
{ time: '18:00', label: '任务', value: 39 },
|
||||
{ time: '19:00', label: '任务', value: 51 }
|
||||
],
|
||||
pages: {
|
||||
members: [
|
||||
record('m-1', '林江', 'jiang@mesalab.internal', '活跃', 'online', ['系统管理员', '3 台设备', '刚刚'], {
|
||||
身份源: '本地账号',
|
||||
用户组: '平台工程、审批人',
|
||||
有效授权: '12 项 Grant',
|
||||
最近活动: '批准图像生成能力发布'
|
||||
}),
|
||||
record('m-2', '陈汐', 'xi.chen@mesalab.internal', '活跃', 'online', ['组织管理员', '2 台设备', '8 分钟前'], {
|
||||
身份源: '企业 OIDC',
|
||||
用户组: '设计中心',
|
||||
有效授权: '8 项 Grant',
|
||||
最近活动: '调用文档转换能力'
|
||||
}),
|
||||
record('m-3', '王简', 'jian.wang@partner.example', '待接受邀请', 'warning', ['成员', '0 台设备', '2 小时前'], {
|
||||
身份源: '邀请待确认',
|
||||
用户组: '法务协作',
|
||||
有效授权: '邀请生效后 2 项',
|
||||
最近活动: '尚未登录'
|
||||
})
|
||||
],
|
||||
groups: [
|
||||
record('g-1', '平台工程', '维护内部 Agent 与能力节点', '12 名成员', 'online', ['8 个 Grant', '4 个设备组', '今天更新'], {
|
||||
成员: '12',
|
||||
设备组: 'GPU 节点、开发工作站',
|
||||
角色: '发布者、审批人',
|
||||
范围: 'MesaLab'
|
||||
}),
|
||||
record('g-2', '设计中心', '使用视觉与内容能力', '28 名成员', 'online', ['5 个 Grant', '2 个设备组', '昨天更新'], {
|
||||
成员: '28',
|
||||
设备组: '设计工作站',
|
||||
角色: '成员',
|
||||
范围: 'MesaLab'
|
||||
})
|
||||
],
|
||||
roles: [
|
||||
record('r-1', '组织管理员', '管理成员、设备、策略与联邦', '6 人', 'online', ['系统内置', '高权限', '全组织'], {
|
||||
权限: '组织范围管理',
|
||||
绑定人数: '6',
|
||||
可委托: '否',
|
||||
最近变更: '系统内置'
|
||||
}),
|
||||
record('r-2', '能力发布者', '发布获准能力和签名包', '9 人', 'neutral', ['自定义', '有界范围', '4 个组'], {
|
||||
权限: 'Publication、Package',
|
||||
绑定人数: '9',
|
||||
可委托: '按策略',
|
||||
最近变更: '3 天前'
|
||||
})
|
||||
],
|
||||
devices: [
|
||||
record('d-1', 'Jiang-PC', '林江 · Windows 11 x64', '在线', 'online', ['2 项能力', '客户端 0.12.4', '刚刚'], {
|
||||
注册状态: '已验证',
|
||||
连接代次: 'generation 84',
|
||||
最近地址: '内网出口 · 已脱敏',
|
||||
合规状态: '符合组织基线'
|
||||
}),
|
||||
record('d-2', 'GPU-Worker-02', 'AI Team · Linux x64', '在线', 'online', ['8 项能力', '客户端 0.12.3', '12 秒前'], {
|
||||
注册状态: '已验证',
|
||||
连接代次: 'generation 221',
|
||||
最近地址: '计算节点区 · 已脱敏',
|
||||
合规状态: '符合组织基线'
|
||||
}),
|
||||
record('d-3', 'Design-Mac-07', '陈汐 · macOS arm64', '离线', 'offline', ['3 项能力', '客户端 0.12.2', '2 小时前'], {
|
||||
注册状态: '已验证',
|
||||
连接代次: 'generation 17',
|
||||
最近地址: '办公网络 · 已脱敏',
|
||||
合规状态: '客户端建议更新'
|
||||
}),
|
||||
record('d-4', 'Legacy-Lab', '测试组 · Linux x64', '已撤销', 'danger', ['0 项可用能力', '客户端 0.10.1', '12 天前'], {
|
||||
注册状态: '2026-08-24 已撤销',
|
||||
连接代次: '无活动连接',
|
||||
最近地址: '不再保留',
|
||||
合规状态: '不具备新任务资格'
|
||||
})
|
||||
],
|
||||
capabilities: [
|
||||
record('c-1', '图像生成', 'image.generate · 2.1.0', '2 / 3 在线', 'warning', ['3 个 Provider', '设计组', 'remote'], {
|
||||
输入: '文本、参考图片',
|
||||
数据策略: '任务期保留,不允许委托',
|
||||
版本: '2.0.x – 2.1.x',
|
||||
最近错误: 'Design-Mac-07 离线'
|
||||
}),
|
||||
record('c-2', '文档转 Word', 'document.convert.docx · 1.4.0', '可用', 'online', ['2 个 Provider', '全组织', 'remote + package'], {
|
||||
输入: 'PDF、图片、文本',
|
||||
数据策略: '处理完成立即清理',
|
||||
版本: '1.4.0',
|
||||
最近错误: '无'
|
||||
}),
|
||||
record('c-3', '代码安全扫描', 'code.security.scan · 3.0.2', '已暂停', 'offline', ['4 个 Provider', '平台工程', 'remote'], {
|
||||
输入: '代码目录',
|
||||
数据策略: '仅组织内,不允许联网',
|
||||
版本: '3.0.2',
|
||||
最近错误: 'Publication 被管理员暂停'
|
||||
})
|
||||
],
|
||||
policies: [
|
||||
record('p-1', '生产数据禁止跨组织', '组织强制策略 · deny', '已生效', 'online', ['优先级最高', '数据分类:生产', '刚刚评估'], {
|
||||
作用主体: '全体成员与设备',
|
||||
能力范围: '全部能力',
|
||||
决策: '跨组织调用时拒绝',
|
||||
修订版本: 'revision 18'
|
||||
}),
|
||||
record('p-2', '设计组图像能力', 'Grant · allow', '已生效', 'online', ['设计中心', '每日 20 次/人', '有效至 12-31'], {
|
||||
作用主体: '用户组:设计中心',
|
||||
能力范围: '图像生成 2.x',
|
||||
决策: '允许文本与图片输入',
|
||||
修订版本: 'revision 6'
|
||||
}),
|
||||
record('p-3', '外部法务文档转换', 'Grant · require_approval', '已生效', 'warning', ['联邦组织', '每次审批', '有效至 10-01'], {
|
||||
作用主体: '法务服务商',
|
||||
能力范围: '文档转 Word 1.4.0',
|
||||
决策: '每次调用需要审批',
|
||||
修订版本: 'revision 3'
|
||||
})
|
||||
],
|
||||
approvals: [
|
||||
record('a-1', '权限扩大更新', '代码安全扫描 3.1.0', '等待审批', 'warning', ['申请人:周启', '新增网络访问', '等待 42 分钟'], {
|
||||
申请类型: 'Package 权限变化',
|
||||
影响范围: '平台工程组 12 台设备',
|
||||
策略依据: '权限扩大必须重新批准',
|
||||
有效期限: '批准后 7 天内发布'
|
||||
}),
|
||||
record('a-2', '本次能力调用', '法务服务商 → 文档转 Word', '等待审批', 'warning', ['申请人:王简', '机密文档', '等待 18 分钟'], {
|
||||
申请类型: '联邦能力调用',
|
||||
影响范围: '1 个 PDF,2.8 MB',
|
||||
策略依据: '外部法务文档转换',
|
||||
有效期限: '本次调用,15 分钟'
|
||||
}),
|
||||
record('a-3', '发布图像生成能力', 'GPU-Worker-05 · 2.1.0', '等待审批', 'warning', ['申请人:苏禾', '组织内发布', '等待 6 分钟'], {
|
||||
申请类型: 'Publication',
|
||||
影响范围: '设计中心可发现并调用',
|
||||
策略依据: '新增 Provider 需要审批',
|
||||
有效期限: '批准后持续有效'
|
||||
})
|
||||
],
|
||||
releases: [
|
||||
record('pkg-1', 'document-convert', '1.4.0 · Linux/Windows/macOS', '可用', 'online', ['签名已验证', '48.2 MB', '稳定通道'], {
|
||||
摘要: 'sha256: 9b3a…42e1',
|
||||
发布者: 'MesaLab Platform',
|
||||
权限变化: '无',
|
||||
下载事实: '24 台成功,0 台失败'
|
||||
}),
|
||||
record('pkg-2', 'security-scanner', '3.1.0 · Linux x64', '等待审批', 'warning', ['签名已验证', '128.6 MB', '候选通道'], {
|
||||
摘要: 'sha256: a18f…91d0',
|
||||
发布者: 'Security Team',
|
||||
权限变化: '新增受限网络访问',
|
||||
下载事实: '尚未进入发布目录'
|
||||
}),
|
||||
record('pkg-3', 'image-runtime', '2.0.4 · Linux x64', '紧急撤销', 'danger', ['签名有效', '3.2 GB', '已停止下载'], {
|
||||
摘要: 'sha256: f221…d872',
|
||||
发布者: 'AI Team',
|
||||
权限变化: '无',
|
||||
下载事实: '因运行崩溃于 09-02 撤销'
|
||||
})
|
||||
],
|
||||
federation: [
|
||||
record('f-1', '法务服务商', 'share.legal.example', '已连接', 'online', ['2 项开放能力', '双边确认', '有效至 12-31'], {
|
||||
我方开放: '文档转 Word',
|
||||
对方开放: '合同审阅、法规检索',
|
||||
最终可用: '2 项',
|
||||
网关指纹: 'SHA256:9F:2A:…:71'
|
||||
}),
|
||||
record('f-2', '华东研究院', 'gateway.east-research.internal', '已暂停', 'offline', ['5 项开放能力', '我方暂停', '无新任务'], {
|
||||
我方开放: '代码扫描、文档解析',
|
||||
对方开放: '仿真计算、数据清洗、模型评测',
|
||||
最终可用: '暂停期间 0 项',
|
||||
网关指纹: 'SHA256:42:BC:…:E9'
|
||||
})
|
||||
],
|
||||
tasks: [
|
||||
record('t-1', 'task_8B2K…19QF', '陈汐 → 图像生成', '运行中', 'online', ['GPU-Worker-02', '网关直连', '已运行 38 秒'], {
|
||||
数据类别: '普通 · 文本与图片',
|
||||
Provider: 'GPU-Worker-02 / image.generate 2.1.0',
|
||||
路由: '经 ShareServer 协商后端点直连',
|
||||
最后确认点: 'Provider 已报告 62%'
|
||||
}),
|
||||
record('t-2', 'task_4D8M…71XA', '王简 → 文档转 Word', '等待审批', 'warning', ['法务服务商', '联邦中继', '18 分钟前'], {
|
||||
数据类别: '机密 · PDF',
|
||||
Provider: 'MesaLab / document.convert.docx',
|
||||
路由: '联邦 + 端到端加密中继',
|
||||
最后确认点: '尚未发送正文'
|
||||
}),
|
||||
record('t-3', 'task_2A9P…82CD', '平台工程 → 代码安全扫描', '结果未知', 'danger', ['GPU-Worker-09', '网关直连', '1 小时前'], {
|
||||
数据类别: '内部 · 代码',
|
||||
Provider: 'GPU-Worker-09 / code.security.scan 3.0.2',
|
||||
路由: '端点直连',
|
||||
最后确认点: 'Provider 执行后连接中断'
|
||||
})
|
||||
],
|
||||
audit: [
|
||||
record('e-1', '批准 Publication', '林江 · GPU-Worker-05', '成功', 'online', ['MesaLab', 'audit_7K2…1PA', '09:38:12'], {
|
||||
操作者: '林江 (user_81A…)',
|
||||
对象: 'publication_71B…',
|
||||
结果: 'active',
|
||||
时间: '2026-09-05T09:38:12+08:00'
|
||||
}),
|
||||
record('e-2', '撤销设备', '陈汐 · Legacy-Lab', '成功', 'online', ['MesaLab', 'audit_1M4…9FD', '09:14:07'], {
|
||||
操作者: '陈汐 (user_22C…)',
|
||||
对象: 'device_B82…',
|
||||
结果: 'revoked',
|
||||
时间: '2026-09-05T09:14:07+08:00'
|
||||
}),
|
||||
record('e-3', '创建联邦任务', '王简 · 法务服务商', '需要审批', 'warning', ['MesaLab', 'audit_8X7…0QA', '09:02:44'], {
|
||||
操作者: '王简 (external_subject_18D…)',
|
||||
对象: 'task_4D8…',
|
||||
结果: 'require_approval',
|
||||
时间: '2026-09-05T09:02:44+08:00'
|
||||
})
|
||||
],
|
||||
myDevices: [
|
||||
record('md-1', 'Jiang-PC', '当前设备 · Windows 11 x64', '在线', 'online', ['2 项已发布能力', '5 项可用授权', '刚刚'], {
|
||||
注册时间: '2026-08-11',
|
||||
当前组织: 'MesaLab',
|
||||
发布能力: '屏幕理解、文档解析',
|
||||
登录状态: '当前 Web 会话不等于设备凭据'
|
||||
}),
|
||||
record('md-2', 'Home-Mac', 'macOS arm64', '离线', 'offline', ['未发布能力', '3 项可用授权', '4 天前'], {
|
||||
注册时间: '2026-07-28',
|
||||
当前组织: 'MesaLab',
|
||||
发布能力: '无',
|
||||
登录状态: '需要重新连接 ShareServer'
|
||||
})
|
||||
],
|
||||
myGrants: [
|
||||
record('mg-1', '图像生成 2.x', '设计中心 Grant', '可调用', 'online', ['文本与图片', '每日 20 次', '组织内'], {
|
||||
Provider: '3 个已发布节点',
|
||||
数据范围: '普通、内部',
|
||||
有效期: '2026-12-31',
|
||||
委托: '不允许'
|
||||
}),
|
||||
record('mg-2', '文档转 Word 1.4.0', '全组织 Grant', '可调用与下载', 'online', ['PDF、图片、文本', '每次最大 10 MB', '组织内'], {
|
||||
Provider: '2 个在线节点',
|
||||
数据范围: '普通、内部、机密',
|
||||
有效期: '持续有效',
|
||||
委托: '不允许'
|
||||
})
|
||||
],
|
||||
myRequests: [
|
||||
record('mr-1', '本次能力调用', '法务服务商 → 文档转 Word', '等待审批', 'warning', ['机密文档', '18 分钟前', '15 分钟有效'], {
|
||||
申请对象: 'task_4D8M…71XA',
|
||||
审批范围: '仅本次调用',
|
||||
当前阶段: '尚未发送正文',
|
||||
可执行操作: '取消申请'
|
||||
}),
|
||||
record('mr-2', '设备注册', 'Home-Mac', '已批准', 'online', ['MesaLab', '2026-07-28', '已完成'], {
|
||||
申请对象: 'device_91M…A21',
|
||||
审批范围: '设备注册',
|
||||
当前阶段: '设备当前离线',
|
||||
可执行操作: '查看设备'
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { prototypeSnapshot } from '../shared/prototype-data'
|
||||
import App from './App'
|
||||
|
||||
describe('ShareServer console', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => prototypeSnapshot
|
||||
})
|
||||
)
|
||||
window.history.replaceState({}, '', '/')
|
||||
})
|
||||
|
||||
it('renders the organization overview from the API snapshot', async () => {
|
||||
render(<App />)
|
||||
expect(await screen.findByRole('heading', { name: '运行概览' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Nebula ShareServer')).toBeInTheDocument()
|
||||
expect(screen.getByText('在线设备')).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: '管理控制台' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens global search with the keyboard shortcut', async () => {
|
||||
render(<App />)
|
||||
await screen.findByRole('heading', { name: '运行概览' })
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }))
|
||||
expect(await screen.findByRole('dialog', { name: '全局搜索' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
BrowserRouter,
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation
|
||||
} from 'react-router-dom'
|
||||
import type { PrototypeRole, PrototypeSnapshot } from '../shared/prototype-data'
|
||||
import { pages, visiblePages } from './app-config'
|
||||
import { AppShell } from './components/app-shell'
|
||||
import { DataPage } from './components/data-page'
|
||||
import { OverviewPage } from './components/overview-page'
|
||||
import { SettingsPage } from './components/settings-page'
|
||||
import { Button } from './components/ui/button'
|
||||
|
||||
async function loadSnapshot(): Promise<PrototypeSnapshot> {
|
||||
const response = await fetch('/api/v1/web/prototype/snapshot')
|
||||
if (!response.ok) throw new Error('控制台快照加载失败')
|
||||
return response.json() as Promise<PrototypeSnapshot>
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-[#07111f]" role="status" aria-busy="true">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto size-10 animate-spin rounded-full border-2 border-cyan-300/15 border-t-cyan-300" />
|
||||
<p className="mt-4 text-sm text-slate-400">正在连接 ShareServer…</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Console({
|
||||
snapshot,
|
||||
role,
|
||||
onRoleChange
|
||||
}: {
|
||||
snapshot: PrototypeSnapshot
|
||||
role: PrototypeRole
|
||||
onRoleChange: (role: PrototypeRole) => void
|
||||
}) {
|
||||
const location = useLocation()
|
||||
const allowedPaths = visiblePages(role).map((page) => page.path)
|
||||
const fallback = role === 'admin' ? '/' : '/me/devices'
|
||||
|
||||
if (!allowedPaths.includes(location.pathname)) {
|
||||
return <Navigate replace to={fallback} />
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell snapshot={snapshot} role={role} onRoleChange={onRoleChange}>
|
||||
<Routes>
|
||||
{pages.map((page) => (
|
||||
<Route
|
||||
key={page.path}
|
||||
path={page.path}
|
||||
element={
|
||||
page.path === '/' ? (
|
||||
<OverviewPage snapshot={snapshot} />
|
||||
) : page.path === '/settings' ? (
|
||||
<SettingsPage snapshot={snapshot} />
|
||||
) : (
|
||||
<DataPage page={page} snapshot={snapshot} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Route path="*" element={<Navigate replace to={fallback} />} />
|
||||
</Routes>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [snapshot, setSnapshot] = useState<PrototypeSnapshot | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [role, setRole] = useState<PrototypeRole>('admin')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
loadSnapshot()
|
||||
.then((data) => {
|
||||
if (!cancelled) setSnapshot(data)
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(reason instanceof Error ? reason.message : '控制台加载失败')
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [reloadKey])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-[#07111f] p-6 text-slate-100">
|
||||
<div role="alert" className="max-w-md rounded-2xl border border-rose-400/20 bg-rose-400/[0.06] p-6 text-center">
|
||||
<p className="font-medium">{error}</p>
|
||||
<p className="mt-2 text-sm text-slate-400">请确认 ShareServer API 已启动,然后重试。</p>
|
||||
<Button
|
||||
className="mt-5"
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setReloadKey((key) => key + 1)
|
||||
}}
|
||||
>
|
||||
重新连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!snapshot) return <LoadingState />
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Console snapshot={snapshot} role={role} onRoleChange={setRole} />
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
Activity,
|
||||
Boxes,
|
||||
Building2,
|
||||
CircleUserRound,
|
||||
Cpu,
|
||||
FileKey2,
|
||||
Gauge,
|
||||
GitFork,
|
||||
PackageCheck,
|
||||
ScrollText,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
UserRoundCheck
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { PrototypeRole } from '../shared/prototype-data'
|
||||
|
||||
export type PageConfig = {
|
||||
path: string
|
||||
title: string
|
||||
shortTitle?: string
|
||||
description: string
|
||||
dataKey?: string
|
||||
icon: LucideIcon
|
||||
role: PrototypeRole | 'all'
|
||||
primaryAction?: string
|
||||
tabs?: Array<{ label: string; dataKey: string }>
|
||||
}
|
||||
|
||||
export const pages: PageConfig[] = [
|
||||
{
|
||||
path: '/',
|
||||
title: '运行概览',
|
||||
description: '需要行动的事项与当前 ShareServer 运行事实。',
|
||||
icon: Gauge,
|
||||
role: 'admin'
|
||||
},
|
||||
{
|
||||
path: '/members',
|
||||
title: '组织与成员',
|
||||
description: '管理当前组织的成员、用户组和有界角色。',
|
||||
dataKey: 'members',
|
||||
icon: Building2,
|
||||
role: 'admin',
|
||||
primaryAction: '邀请成员',
|
||||
tabs: [
|
||||
{ label: '成员', dataKey: 'members' },
|
||||
{ label: '用户组', dataKey: 'groups' },
|
||||
{ label: '角色', dataKey: 'roles' }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/devices',
|
||||
title: '设备',
|
||||
description: '查看已注册设备、连接状态与合规元数据。',
|
||||
dataKey: 'devices',
|
||||
icon: Cpu,
|
||||
role: 'admin',
|
||||
primaryAction: '生成注册链接'
|
||||
},
|
||||
{
|
||||
path: '/capabilities',
|
||||
title: '能力目录',
|
||||
description: '查看显式发布的能力、版本和 Provider 可用性。',
|
||||
dataKey: 'capabilities',
|
||||
icon: Boxes,
|
||||
role: 'admin'
|
||||
},
|
||||
{
|
||||
path: '/policies',
|
||||
title: '权限策略',
|
||||
description: '配置拒绝优先的组织强制策略与 Grant。',
|
||||
dataKey: 'policies',
|
||||
icon: ShieldCheck,
|
||||
role: 'admin',
|
||||
primaryAction: '新建策略'
|
||||
},
|
||||
{
|
||||
path: '/approvals',
|
||||
title: '审批',
|
||||
description: '处理调用、发布、更新和联邦申请。',
|
||||
dataKey: 'approvals',
|
||||
icon: UserRoundCheck,
|
||||
role: 'admin'
|
||||
},
|
||||
{
|
||||
path: '/releases',
|
||||
title: '发布与更新',
|
||||
description: '管理签名能力包、发布通道、更新策略和撤销。',
|
||||
dataKey: 'releases',
|
||||
icon: PackageCheck,
|
||||
role: 'admin',
|
||||
primaryAction: '上传能力包'
|
||||
},
|
||||
{
|
||||
path: '/federation',
|
||||
title: '网关联邦',
|
||||
description: '管理经双边确认的组织协作边界。',
|
||||
dataKey: 'federation',
|
||||
icon: GitFork,
|
||||
role: 'admin',
|
||||
primaryAction: '建立联邦关系'
|
||||
},
|
||||
{
|
||||
path: '/tasks',
|
||||
title: '任务与中继',
|
||||
description: '查看任务路由、终态和可选中继容量。',
|
||||
dataKey: 'tasks',
|
||||
icon: Activity,
|
||||
role: 'admin'
|
||||
},
|
||||
{
|
||||
path: '/audit',
|
||||
title: '审计',
|
||||
description: '按组织查询有界操作元数据,不包含任务正文。',
|
||||
dataKey: 'audit',
|
||||
icon: ScrollText,
|
||||
role: 'admin',
|
||||
primaryAction: '导出当前结果'
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
title: '系统设置',
|
||||
description: '查看实例、身份源、基础设施状态与保留策略。',
|
||||
icon: Settings2,
|
||||
role: 'admin'
|
||||
},
|
||||
{
|
||||
path: '/me/devices',
|
||||
title: '我的设备',
|
||||
description: '查看你在当前组织注册的设备与连接状态。',
|
||||
dataKey: 'myDevices',
|
||||
icon: CircleUserRound,
|
||||
role: 'member'
|
||||
},
|
||||
{
|
||||
path: '/me/grants',
|
||||
title: '我的授权',
|
||||
description: '查看你获准发现、调用或下载的能力。',
|
||||
dataKey: 'myGrants',
|
||||
icon: FileKey2,
|
||||
role: 'member'
|
||||
},
|
||||
{
|
||||
path: '/me/requests',
|
||||
title: '我的申请',
|
||||
description: '跟踪你的设备、能力与任务审批。',
|
||||
dataKey: 'myRequests',
|
||||
icon: UserRoundCheck,
|
||||
role: 'member'
|
||||
}
|
||||
]
|
||||
|
||||
export function visiblePages(role: PrototypeRole): PageConfig[] {
|
||||
return pages.filter((page) => page.role === 'all' || page.role === role)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
Bell,
|
||||
ChevronsUpDown,
|
||||
Command,
|
||||
Menu,
|
||||
MoonStar,
|
||||
Search,
|
||||
Shield,
|
||||
Sun,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import type { PrototypeRole, PrototypeSnapshot } from '../../shared/prototype-data'
|
||||
import { visiblePages } from '../app-config'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Button } from './ui/button'
|
||||
import { Dialog } from './ui/dialog'
|
||||
|
||||
function BrandMark() {
|
||||
return (
|
||||
<div className="relative grid size-10 shrink-0 place-items-center rounded-xl border border-cyan-300/25 bg-cyan-300/10 text-cyan-300 shadow-[0_0_34px_-10px_rgba(34,211,238,.9)]">
|
||||
<span className="absolute inset-1 rounded-lg border border-violet-400/20" />
|
||||
<Shield aria-hidden="true" className="size-5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
snapshot,
|
||||
role,
|
||||
onRoleChange,
|
||||
children
|
||||
}: {
|
||||
snapshot: PrototypeSnapshot
|
||||
role: PrototypeRole
|
||||
onRoleChange: (role: PrototypeRole) => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [dark, setDark] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
const navigation = visiblePages(role)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||
}, [dark])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
setSearchOpen(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
const nav = (
|
||||
<>
|
||||
<div className="flex h-19 items-center gap-3 border-b border-white/[0.07] px-5">
|
||||
<BrandMark />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold tracking-wide text-white">
|
||||
{snapshot.instance.name}
|
||||
</p>
|
||||
<p className="truncate text-[11px] uppercase tracking-[0.16em] text-cyan-300/70">
|
||||
Control plane
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-4">
|
||||
<button className="flex w-full items-center gap-3 rounded-xl border border-white/[0.07] bg-white/[0.035] px-3 py-2.5 text-left outline-none transition hover:bg-white/[0.06] focus-visible:ring-2 focus-visible:ring-cyan-400/70">
|
||||
<span className="grid size-8 place-items-center rounded-lg bg-violet-400/12 text-sm font-semibold text-violet-200">
|
||||
M
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-slate-100">
|
||||
{snapshot.instance.organization}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-slate-500">当前组织</span>
|
||||
</span>
|
||||
<ChevronsUpDown aria-hidden="true" className="size-4 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
<nav aria-label={role === 'admin' ? '管理控制台' : '成员自助'} className="flex-1 overflow-y-auto px-3 pb-4">
|
||||
<p className="mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-600">
|
||||
{role === 'admin' ? '组织控制台' : '成员自助'}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === '/'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'group relative flex min-h-10 items-center gap-3 rounded-xl px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-cyan-400/70',
|
||||
isActive
|
||||
? 'bg-cyan-300/[0.09] font-medium text-cyan-100'
|
||||
: 'text-slate-400 hover:bg-white/[0.045] hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive ? (
|
||||
<span className="absolute inset-y-2 left-0 w-0.5 rounded-full bg-cyan-300 shadow-[0_0_12px_rgba(34,211,238,.9)]" />
|
||||
) : null}
|
||||
<Icon aria-hidden="true" className="size-[18px]" />
|
||||
<span>{item.title}</span>
|
||||
{item.path === '/approvals' ? (
|
||||
<span className="ml-auto rounded-full bg-amber-300/15 px-1.5 py-0.5 text-[10px] font-semibold text-amber-200">
|
||||
6
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="border-t border-white/[0.07] p-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextRole = role === 'admin' ? 'member' : 'admin'
|
||||
onRoleChange(nextRole)
|
||||
navigate(nextRole === 'admin' ? '/' : '/me/devices')
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left outline-none transition hover:bg-white/[0.05] focus-visible:ring-2 focus-visible:ring-cyan-400/70"
|
||||
>
|
||||
<span className="grid size-9 place-items-center rounded-full border border-violet-300/20 bg-gradient-to-br from-cyan-300/25 to-violet-400/25 text-sm font-semibold text-white">
|
||||
林
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-slate-200">林江</span>
|
||||
<span className="block truncate text-[11px] text-slate-500">
|
||||
{role === 'admin' ? '切换到成员自助' : '切换到管理控制台'}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronsUpDown aria-hidden="true" className="size-4 text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--canvas)] text-[var(--text-primary)]">
|
||||
<div className="pointer-events-none fixed inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div className="absolute -left-60 -top-72 size-[620px] rounded-full bg-cyan-500/[0.055] blur-3xl" />
|
||||
<div className="absolute -right-48 top-24 size-[520px] rounded-full bg-violet-500/[0.06] blur-3xl" />
|
||||
<div className="grid-overlay absolute inset-0 opacity-40" />
|
||||
</div>
|
||||
<aside className="app-sidebar fixed inset-y-0 left-0 z-30 hidden w-[258px] flex-col border-r border-white/[0.075] bg-[#07111f]/95 backdrop-blur-xl lg:flex">
|
||||
{nav}
|
||||
</aside>
|
||||
{mobileOpen ? (
|
||||
<div className="fixed inset-0 z-40 lg:hidden">
|
||||
<button
|
||||
aria-label="关闭导航"
|
||||
className="absolute inset-0 bg-slate-950/75 backdrop-blur-sm"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<aside className="app-sidebar relative flex h-full w-[min(300px,86vw)] flex-col border-r border-white/10 bg-[#07111f] shadow-2xl">
|
||||
<Button
|
||||
aria-label="关闭导航"
|
||||
className="absolute right-3 top-3 z-10"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<X className="size-5" />
|
||||
</Button>
|
||||
{nav}
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative lg:pl-[258px]">
|
||||
<header className="sticky top-0 z-20 flex h-16 items-center gap-3 border-b border-white/[0.07] bg-[var(--header)] px-4 backdrop-blur-xl sm:px-6">
|
||||
<Button
|
||||
aria-label="打开导航"
|
||||
className="lg:hidden"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</Button>
|
||||
<button
|
||||
className="flex h-9 max-w-md flex-1 items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.035] px-3 text-left text-sm text-slate-500 outline-none transition hover:border-white/[0.15] hover:bg-white/[0.055] focus-visible:ring-2 focus-visible:ring-cyan-400/70 sm:flex-initial sm:basis-[360px]"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
>
|
||||
<Search aria-hidden="true" className="size-4" />
|
||||
<span className="min-w-0 flex-1 truncate">搜索设备、能力、任务或成员</span>
|
||||
<span className="hidden items-center gap-1 rounded border border-white/10 px-1.5 py-0.5 text-[10px] text-slate-500 sm:flex">
|
||||
<Command className="size-3" />K
|
||||
</span>
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<span className="mr-2 hidden items-center gap-2 text-xs text-slate-500 xl:flex">
|
||||
<span className="size-1.5 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,.8)]" />
|
||||
控制面正常
|
||||
</span>
|
||||
<Button
|
||||
aria-label={dark ? '切换到浅色主题' : '切换到深色主题'}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setDark((value) => !value)}
|
||||
>
|
||||
{dark ? <Sun className="size-4" /> : <MoonStar className="size-4" />}
|
||||
</Button>
|
||||
<Button aria-label="查看通知,6 条待处理" size="icon" variant="ghost">
|
||||
<Bell className="size-4" />
|
||||
<span className="absolute ml-3 mt-[-13px] size-1.5 rounded-full bg-amber-300" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="min-h-[calc(100vh-4rem)] px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
|
||||
<div className="mx-auto max-w-[1440px]">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
<Dialog
|
||||
open={searchOpen}
|
||||
onOpenChange={setSearchOpen}
|
||||
title="全局搜索"
|
||||
description={`搜索范围:组织 ${snapshot.instance.organization}`}
|
||||
width="max-w-2xl"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 size-4 text-slate-500" />
|
||||
<input
|
||||
autoFocus
|
||||
aria-label="搜索关键词"
|
||||
className="h-11 w-full rounded-xl border border-white/10 bg-white/[0.04] pl-10 pr-3 text-sm outline-none placeholder:text-slate-600 focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/15"
|
||||
placeholder="输入设备、能力、任务 ID 或成员名称…"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-5 text-center text-sm text-slate-500">
|
||||
输入关键词后,将按当前身份和组织权限显示结果。
|
||||
</p>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { PrototypeRecord, PrototypeSnapshot } from '../../shared/prototype-data'
|
||||
import type { PageConfig } from '../app-config'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Button } from './ui/button'
|
||||
import { Dialog } from './ui/dialog'
|
||||
import { StatusBadge } from './ui/status-badge'
|
||||
import { PageHeader } from './page-header'
|
||||
|
||||
type Feedback = {
|
||||
tone: 'success' | 'danger'
|
||||
message: string
|
||||
}
|
||||
|
||||
async function sendPrototypeAction(action: string, targetId: string): Promise<void> {
|
||||
const response = await fetch('/api/v1/web/prototype/actions', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action, targetId })
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('原型操作未能提交,请重试。')
|
||||
}
|
||||
}
|
||||
|
||||
function DetailPanel({
|
||||
record,
|
||||
page,
|
||||
onAction
|
||||
}: {
|
||||
record: PrototypeRecord
|
||||
page: PageConfig
|
||||
onAction: (action: string, record: PrototypeRecord) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/[0.07] pb-5">
|
||||
<div>
|
||||
<p className="text-base font-semibold text-slate-100">{record.primary}</p>
|
||||
<p className="mt-1 text-sm text-slate-400">{record.secondary}</p>
|
||||
</div>
|
||||
<StatusBadge status={record.status} tone={record.statusTone} />
|
||||
</div>
|
||||
<dl className="divide-y divide-white/[0.06]">
|
||||
{Object.entries(record.detail).map(([label, value]) => (
|
||||
<div key={label} className="grid gap-1 py-4 sm:grid-cols-[140px_1fr] sm:gap-4">
|
||||
<dt className="text-xs font-medium text-slate-500">{label}</dt>
|
||||
<dd className="text-sm leading-6 text-slate-200">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2 border-t border-white/[0.07] pt-5">
|
||||
{page.path === '/approvals' ? (
|
||||
<>
|
||||
<Button variant="danger" onClick={() => onAction('拒绝申请', record)}>
|
||||
<X className="size-4" />
|
||||
拒绝
|
||||
</Button>
|
||||
<Button onClick={() => onAction('批准申请', record)}>
|
||||
<Check className="size-4" />
|
||||
批准此申请
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{page.path === '/devices' && record.status !== '已撤销' ? (
|
||||
<Button variant="danger" onClick={() => onAction('撤销设备', record)}>
|
||||
撤销设备
|
||||
</Button>
|
||||
) : null}
|
||||
{page.path === '/tasks' && record.status === '运行中' ? (
|
||||
<Button variant="danger" onClick={() => onAction('取消任务', record)}>
|
||||
取消任务
|
||||
</Button>
|
||||
) : null}
|
||||
{page.path === '/federation' ? (
|
||||
<Button variant="secondary" onClick={() => onAction('切换联邦状态', record)}>
|
||||
{record.status === '已连接' ? '暂停关系' : '恢复关系'}
|
||||
</Button>
|
||||
) : null}
|
||||
{!['/approvals', '/devices', '/tasks', '/federation'].includes(page.path) ? (
|
||||
<Button variant="secondary" onClick={() => onAction('保存变更', record)}>
|
||||
编辑配置
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataPage({
|
||||
page,
|
||||
snapshot
|
||||
}: {
|
||||
page: PageConfig
|
||||
snapshot: PrototypeSnapshot
|
||||
}) {
|
||||
const [activeKey, setActiveKey] = useState(page.dataKey ?? '')
|
||||
const [query, setQuery] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('全部状态')
|
||||
const [selected, setSelected] = useState<PrototypeRecord | null>(null)
|
||||
const [actionTarget, setActionTarget] = useState<{
|
||||
action: string
|
||||
record: PrototypeRecord
|
||||
} | null>(null)
|
||||
const [primaryOpen, setPrimaryOpen] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [feedback, setFeedback] = useState<Feedback | null>(null)
|
||||
|
||||
const records = useMemo(
|
||||
() => snapshot.pages[activeKey] ?? [],
|
||||
[activeKey, snapshot.pages]
|
||||
)
|
||||
const statuses = ['全部状态', ...new Set(records.map((item) => item.status))]
|
||||
const visibleRecords = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
return records.filter((item) => {
|
||||
const matchesQuery =
|
||||
!normalized ||
|
||||
[item.primary, item.secondary, ...item.meta]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(normalized)
|
||||
const matchesStatus = statusFilter === '全部状态' || item.status === statusFilter
|
||||
return matchesQuery && matchesStatus
|
||||
})
|
||||
}, [query, records, statusFilter])
|
||||
|
||||
const handleAction = (action: string, record: PrototypeRecord) => {
|
||||
setSelected(null)
|
||||
setActionTarget({ action, record })
|
||||
}
|
||||
|
||||
const confirmAction = async () => {
|
||||
if (!actionTarget) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await sendPrototypeAction(actionTarget.action, actionTarget.record.id)
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `${actionTarget.action}已在交互原型中完成,不会写入生产数据。`
|
||||
})
|
||||
setActionTarget(null)
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
tone: 'danger',
|
||||
message: error instanceof Error ? error.message : '操作失败,请重试。'
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={`组织 · ${snapshot.instance.organization}`}
|
||||
title={page.title}
|
||||
description={page.description}
|
||||
action={
|
||||
page.primaryAction ? (
|
||||
<Button onClick={() => setPrimaryOpen(true)}>
|
||||
{page.primaryAction}
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<div
|
||||
role={feedback.tone === 'danger' ? 'alert' : 'status'}
|
||||
className={cn(
|
||||
'mb-5 flex items-center justify-between gap-4 rounded-xl border px-4 py-3 text-sm',
|
||||
feedback.tone === 'success'
|
||||
? 'border-emerald-400/20 bg-emerald-400/[0.08] text-emerald-200'
|
||||
: 'border-rose-400/20 bg-rose-400/[0.08] text-rose-200'
|
||||
)}
|
||||
>
|
||||
<span>{feedback.message}</span>
|
||||
<button
|
||||
aria-label="关闭消息"
|
||||
className="rounded p-1 outline-none hover:bg-white/10 focus-visible:ring-2"
|
||||
onClick={() => setFeedback(null)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{page.tabs ? (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={`${page.title}分类`}
|
||||
className="mb-5 flex w-fit max-w-full gap-1 overflow-x-auto rounded-xl border border-white/[0.07] bg-white/[0.025] p-1"
|
||||
>
|
||||
{page.tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.dataKey}
|
||||
role="tab"
|
||||
aria-selected={activeKey === tab.dataKey}
|
||||
className={cn(
|
||||
'min-h-8 shrink-0 rounded-lg px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-cyan-400/70',
|
||||
activeKey === tab.dataKey
|
||||
? 'bg-white/[0.09] font-medium text-slate-100 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-200'
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveKey(tab.dataKey)
|
||||
setStatusFilter('全部状态')
|
||||
setQuery('')
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="overflow-hidden rounded-2xl border border-white/[0.075] bg-[var(--panel)] shadow-[0_18px_50px_-36px_rgba(0,0,0,.7)]">
|
||||
<div className="flex flex-col gap-3 border-b border-white/[0.07] p-4 sm:flex-row sm:items-center">
|
||||
<label className="relative min-w-0 flex-1 sm:max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-2.5 size-4 text-slate-500" />
|
||||
<span className="sr-only">搜索{page.title}</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="h-9 w-full rounded-lg border border-white/[0.08] bg-black/10 pl-9 pr-3 text-sm outline-none placeholder:text-slate-600 focus:border-cyan-300/35 focus:ring-2 focus:ring-cyan-400/10"
|
||||
placeholder={`搜索${page.title}…`}
|
||||
/>
|
||||
</label>
|
||||
<label className="relative">
|
||||
<span className="sr-only">状态筛选</span>
|
||||
<Filter className="pointer-events-none absolute left-3 top-2.5 size-4 text-slate-500" />
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
className="h-9 min-w-36 appearance-none rounded-lg border border-white/[0.08] bg-[var(--control)] pl-9 pr-8 text-sm text-slate-300 outline-none focus:border-cyan-300/35 focus:ring-2 focus:ring-cyan-400/10"
|
||||
>
|
||||
{statuses.map((status) => (
|
||||
<option key={status}>{status}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button variant="secondary">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
更多筛选
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden grid-cols-[minmax(220px,1.45fr)_minmax(160px,.85fr)_minmax(300px,1.7fr)_44px] border-b border-white/[0.06] px-5 py-3 text-[11px] font-medium uppercase tracking-[0.12em] text-slate-400 md:grid">
|
||||
<span>对象</span>
|
||||
<span>状态</span>
|
||||
<span>范围与事实</span>
|
||||
<span className="sr-only">操作</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-white/[0.055]">
|
||||
{visibleRecords.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className="group grid w-full gap-4 px-5 py-4 text-left outline-none transition hover:bg-cyan-300/[0.025] focus-visible:bg-cyan-300/[0.04] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cyan-400/50 md:grid-cols-[minmax(220px,1.45fr)_minmax(160px,.85fr)_minmax(300px,1.7fr)_44px] md:items-center"
|
||||
onClick={() => setSelected(item)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-[var(--text-strong)]">
|
||||
{item.primary}
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-xs text-[var(--text-muted)]">
|
||||
{item.secondary}
|
||||
</span>
|
||||
</span>
|
||||
<StatusBadge status={item.status} tone={item.statusTone} />
|
||||
<span className="flex min-w-0 flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--text-muted)]">
|
||||
{item.meta.map((meta) => (
|
||||
<span key={meta} className="truncate">
|
||||
{meta}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="hidden size-8 place-items-center rounded-lg text-slate-500 transition group-hover:bg-white/[0.06] group-hover:text-slate-200 md:grid">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{visibleRecords.length === 0 ? (
|
||||
<div className="px-5 py-16 text-center">
|
||||
<p className="text-sm font-medium text-slate-300">没有符合条件的结果</p>
|
||||
<p className="mt-1 text-xs text-slate-500">请修改关键词或清除状态筛选。</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setStatusFilter('全部状态')
|
||||
}}
|
||||
>
|
||||
清除筛选
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-between border-t border-white/[0.07] px-5 py-3 text-xs text-[var(--text-muted)]">
|
||||
<span>显示 {visibleRecords.length} / {records.length} 项</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button aria-label="上一页" disabled size="icon" variant="ghost">
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="px-2">第 1 页</span>
|
||||
<Button aria-label="下一页" disabled size="icon" variant="ghost">
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(selected)}
|
||||
onOpenChange={(open) => !open && setSelected(null)}
|
||||
title={`${page.title}详情`}
|
||||
description="当前组织范围内可见的有界元数据。"
|
||||
>
|
||||
{selected ? (
|
||||
<DetailPanel record={selected} page={page} onAction={handleAction} />
|
||||
) : null}
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={primaryOpen}
|
||||
onOpenChange={setPrimaryOpen}
|
||||
title={page.primaryAction ?? '创建对象'}
|
||||
description={`该表单展示 ${page.title} 的完整交互结构;本阶段不会写入生产数据库。`}
|
||||
>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setPrimaryOpen(false)
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `${page.primaryAction ?? '操作'}已在交互原型中提交。`
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium text-slate-400">名称或标识</span>
|
||||
<input
|
||||
required
|
||||
className="h-10 w-full rounded-lg border border-white/10 bg-white/[0.035] px-3 text-sm outline-none focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/10"
|
||||
placeholder={`输入${page.title}名称`}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium text-slate-400">范围</span>
|
||||
<select className="h-10 w-full rounded-lg border border-white/10 bg-[var(--control)] px-3 text-sm outline-none focus:border-cyan-300/40">
|
||||
<option>组织:{snapshot.instance.organization}</option>
|
||||
<option>指定用户组</option>
|
||||
<option>仅当前用户</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium text-slate-400">说明</span>
|
||||
<textarea
|
||||
className="min-h-24 w-full resize-y rounded-lg border border-white/10 bg-white/[0.035] p-3 text-sm outline-none focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/10"
|
||||
placeholder="补充用途、影响范围或有效期"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onClick={() => setPrimaryOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit">{page.primaryAction ?? '提交'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(actionTarget)}
|
||||
onOpenChange={(open) => !open && setActionTarget(null)}
|
||||
title={actionTarget?.action ?? '确认操作'}
|
||||
description={
|
||||
actionTarget
|
||||
? `对象:${actionTarget.record.primary}。请确认操作范围和直接影响。`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="rounded-xl border border-amber-400/15 bg-amber-300/[0.055] p-4 text-sm leading-6 text-amber-100/80">
|
||||
这是交互原型。确认后会调用 ShareServer 原型 API 并返回请求编号,但不会更改生产数据。
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setActionTarget(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant={actionTarget?.action.includes('撤销') || actionTarget?.action.includes('拒绝') ? 'danger' : 'default'}
|
||||
disabled={submitting}
|
||||
onClick={confirmAction}
|
||||
>
|
||||
{submitting ? '正在提交…' : actionTarget?.action}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
GitFork,
|
||||
RadioTower,
|
||||
ServerCog,
|
||||
ShieldCheck,
|
||||
Sparkles
|
||||
} from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { Metric, PrototypeSnapshot } from '../../shared/prototype-data'
|
||||
import { PageHeader } from './page-header'
|
||||
import { StatusBadge } from './ui/status-badge'
|
||||
|
||||
const metricStyles: Record<Metric['tone'], string> = {
|
||||
cyan: 'from-cyan-400/16 to-cyan-400/[0.025] border-cyan-300/15 text-cyan-300',
|
||||
violet: 'from-violet-400/16 to-violet-400/[0.025] border-violet-300/15 text-violet-300',
|
||||
amber: 'from-amber-400/16 to-amber-400/[0.025] border-amber-300/15 text-amber-200',
|
||||
rose: 'from-rose-400/16 to-rose-400/[0.025] border-rose-300/15 text-rose-300'
|
||||
}
|
||||
|
||||
export function OverviewPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
|
||||
const navigate = useNavigate()
|
||||
const maxActivity = Math.max(...snapshot.activity.map((item) => item.value))
|
||||
const recent = [
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: '策略决定',
|
||||
copy: '生产数据跨组织请求被强制策略拒绝',
|
||||
time: '2 分钟前',
|
||||
tone: 'warning' as const
|
||||
},
|
||||
{
|
||||
icon: CheckCircle2,
|
||||
title: '能力发布',
|
||||
copy: 'GPU-Worker-05 的图像生成能力已批准',
|
||||
time: '4 分钟前',
|
||||
tone: 'online' as const
|
||||
},
|
||||
{
|
||||
icon: GitFork,
|
||||
title: '联邦心跳',
|
||||
copy: '法务服务商网关完成双边状态同步',
|
||||
time: '8 分钟前',
|
||||
tone: 'neutral' as const
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={`实例 · ${snapshot.instance.region}`}
|
||||
title="运行概览"
|
||||
description={`${snapshot.instance.name} 正在为组织 ${snapshot.instance.organization} 提供控制面服务。`}
|
||||
action={
|
||||
<div className="flex items-center gap-2 rounded-xl border border-emerald-400/15 bg-emerald-400/[0.06] px-3 py-2 text-xs text-emerald-200">
|
||||
<span className="size-1.5 rounded-full bg-emerald-300 shadow-[0_0_10px_rgba(52,211,153,.9)]" />
|
||||
所有核心服务正常
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<section aria-label="关键指标" className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{snapshot.metrics.map((metric) => (
|
||||
<button
|
||||
key={metric.label}
|
||||
className={`group relative overflow-hidden rounded-2xl border bg-gradient-to-br p-5 text-left outline-none transition duration-200 hover:-translate-y-0.5 hover:border-white/20 focus-visible:ring-2 focus-visible:ring-cyan-400/70 ${metricStyles[metric.tone]}`}
|
||||
onClick={() => navigate(metric.target)}
|
||||
>
|
||||
<div className="absolute -right-8 -top-8 size-24 rounded-full bg-current opacity-[0.055] blur-2xl" />
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="text-xs font-medium text-[var(--text-muted)]">{metric.label}</span>
|
||||
<ArrowUpRight className="size-4 opacity-50 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5 group-hover:opacity-100" />
|
||||
</div>
|
||||
<p className="mt-5 text-3xl font-semibold tracking-[-0.04em] text-[var(--text-strong)]">
|
||||
{metric.value}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-[var(--text-muted)]">{metric.detail}</p>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<div className="mt-5 grid gap-5 xl:grid-cols-[1.55fr_.85fr]">
|
||||
<section className="rounded-2xl border border-white/[0.075] bg-[var(--panel)] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-[var(--text-strong)]">任务活动</h2>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">过去 12 小时创建的任务</p>
|
||||
</div>
|
||||
<span className="rounded-lg border border-white/[0.07] bg-white/[0.035] px-2.5 py-1.5 text-xs text-[var(--text-muted)]">
|
||||
24 小时
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-8 flex h-52 items-end gap-2 sm:gap-3" role="img" aria-label="过去十二小时任务量柱状图">
|
||||
{snapshot.activity.map((item, index) => (
|
||||
<div key={item.time} className="group flex h-full min-w-0 flex-1 flex-col justify-end">
|
||||
<div className="relative flex-1">
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 min-h-1 rounded-t-md bg-gradient-to-t from-cyan-400/35 to-cyan-300/90 shadow-[0_0_18px_-8px_rgba(34,211,238,.8)] transition group-hover:from-violet-400/50 group-hover:to-violet-300"
|
||||
style={{ height: `${Math.max(6, (item.value / maxActivity) * 100)}%` }}
|
||||
>
|
||||
<span className="absolute -top-6 left-1/2 hidden -translate-x-1/2 text-[10px] text-cyan-200 group-hover:block">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`mt-2 text-center text-[9px] text-slate-600 ${index % 2 ? 'hidden sm:block' : ''}`}>
|
||||
{item.time}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-white/[0.075] bg-[var(--panel)] p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-[var(--text-strong)]">网络脉冲</h2>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">连接与路由实时摘要</p>
|
||||
</div>
|
||||
<RadioTower className="size-5 text-cyan-300" />
|
||||
</div>
|
||||
<div className="relative mt-7 grid place-items-center py-4">
|
||||
<div className="pulse-ring absolute size-36 rounded-full border border-cyan-300/10" />
|
||||
<div className="pulse-ring pulse-delay absolute size-24 rounded-full border border-cyan-300/15" />
|
||||
<div className="relative grid size-16 place-items-center rounded-full border border-cyan-300/25 bg-cyan-300/10 shadow-[0_0_45px_-10px_rgba(34,211,238,.7)]">
|
||||
<ServerCog className="size-7 text-cyan-200" />
|
||||
</div>
|
||||
</div>
|
||||
<dl className="mt-4 grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-xl bg-white/[0.025] p-3">
|
||||
<dt className="text-[10px] text-slate-500">设备连接</dt>
|
||||
<dd className="mt-1 text-sm font-semibold text-slate-200">18</dd>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/[0.025] p-3">
|
||||
<dt className="text-[10px] text-slate-500">联邦网关</dt>
|
||||
<dd className="mt-1 text-sm font-semibold text-slate-200">1 / 2</dd>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/[0.025] p-3">
|
||||
<dt className="text-[10px] text-slate-500">中继占用</dt>
|
||||
<dd className="mt-1 text-sm font-semibold text-slate-200">34%</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 lg:grid-cols-2">
|
||||
<section className="overflow-hidden rounded-2xl border border-white/[0.075] bg-[var(--panel)]">
|
||||
<header className="flex items-center justify-between border-b border-white/[0.065] px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-[var(--text-strong)]">需要关注</h2>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">按影响与等待时间排序</p>
|
||||
</div>
|
||||
<CircleAlert className="size-5 text-amber-300" />
|
||||
</header>
|
||||
<div className="divide-y divide-white/[0.055]">
|
||||
{snapshot.pages.approvals.slice(0, 3).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className="flex w-full items-center gap-4 px-5 py-4 text-left outline-none transition hover:bg-white/[0.025] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cyan-400/60"
|
||||
onClick={() => navigate('/approvals')}
|
||||
>
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-xl border border-amber-300/15 bg-amber-300/[0.07] text-amber-200">
|
||||
<Sparkles className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-[var(--text-strong)]">{item.primary}</span>
|
||||
<span className="mt-1 block truncate text-xs text-[var(--text-muted)]">{item.secondary}</span>
|
||||
</span>
|
||||
<span className="hidden text-xs text-slate-600 sm:block">{item.meta[2]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="overflow-hidden rounded-2xl border border-white/[0.075] bg-[var(--panel)]">
|
||||
<header className="flex items-center justify-between border-b border-white/[0.065] px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-[var(--text-strong)]">最近活动</h2>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">仅显示有界审计元数据</p>
|
||||
</div>
|
||||
<Activity className="size-5 text-violet-300" />
|
||||
</header>
|
||||
<div className="divide-y divide-white/[0.055]">
|
||||
{recent.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div key={item.copy} className="flex items-center gap-4 px-5 py-4">
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-white/[0.04] text-slate-300">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-medium text-slate-500">{item.title}</span>
|
||||
<span className="mt-1 block truncate text-sm text-[var(--text-primary)]">{item.copy}</span>
|
||||
</span>
|
||||
<span className="text-xs text-slate-600">{item.time}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="mt-6 flex flex-wrap items-center gap-3 text-[11px] text-slate-600">
|
||||
<StatusBadge status="原型数据" tone="neutral" />
|
||||
<span>{snapshot.instance.protocol}</span>
|
||||
<span>·</span>
|
||||
<span>快照 {new Date(snapshot.instance.generatedAt).toLocaleString('zh-CN')}</span>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
eyebrow,
|
||||
action
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
eyebrow?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<header className="mb-7 flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
|
||||
<div>
|
||||
{eyebrow ? (
|
||||
<p className="mb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-cyan-300">
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.025em] text-[var(--text-strong)] sm:text-[28px]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-muted)]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
Braces,
|
||||
CheckCircle2,
|
||||
CloudCog,
|
||||
Database,
|
||||
KeyRound,
|
||||
Mail,
|
||||
RadioTower,
|
||||
Save,
|
||||
ShieldCheck
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { PrototypeSnapshot } from '../../shared/prototype-data'
|
||||
import { PageHeader } from './page-header'
|
||||
import { Button } from './ui/button'
|
||||
import { StatusBadge } from './ui/status-badge'
|
||||
|
||||
function SettingCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
children
|
||||
}: {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/[0.075] bg-[var(--panel)] p-5">
|
||||
<header className="flex items-start gap-3">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl border border-cyan-300/15 bg-cyan-300/[0.065] text-cyan-300">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-[var(--text-strong)]">{title}</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--text-muted)]">{description}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="mt-5 border-t border-white/[0.06] pt-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start justify-between gap-4 rounded-xl p-2 outline-none hover:bg-white/[0.025]">
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-[var(--text-primary)]">{label}</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-[var(--text-muted)]">{description}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span className="relative mt-0.5 h-6 w-11 shrink-0 rounded-full border border-white/10 bg-slate-700 transition peer-checked:border-cyan-300/30 peer-checked:bg-cyan-400/80 peer-focus-visible:ring-2 peer-focus-visible:ring-cyan-400/70 after:absolute after:left-0.5 after:top-0.5 after:size-5 after:rounded-full after:bg-white after:shadow after:transition-transform peer-checked:after:translate-x-5" />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
|
||||
const [relay, setRelay] = useState(true)
|
||||
const [federation, setFederation] = useState(true)
|
||||
const [oidc, setOidc] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="实例范围"
|
||||
title="系统设置"
|
||||
description="管理实例公开信息与功能开关。数据库连接、主密钥和容器挂载路径只从部署环境读取。"
|
||||
action={
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSaved(true)
|
||||
window.setTimeout(() => setSaved(false), 4500)
|
||||
}}
|
||||
>
|
||||
<Save className="size-4" />
|
||||
保存设置
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{saved ? (
|
||||
<div role="status" className="mb-5 rounded-xl border border-emerald-400/20 bg-emerald-400/[0.07] px-4 py-3 text-sm text-emerald-200">
|
||||
原型设置已保存到当前浏览器会话,不会写入生产配置。
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<SettingCard
|
||||
icon={CloudCog}
|
||||
title="实例信息"
|
||||
description="公开名称、地址与协议协商信息。"
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<label>
|
||||
<span className="mb-1.5 block text-xs font-medium text-[var(--text-muted)]">实例名称</span>
|
||||
<input
|
||||
defaultValue={snapshot.instance.name}
|
||||
className="h-10 w-full rounded-lg border border-white/10 bg-white/[0.025] px-3 text-sm outline-none focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/10"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="mb-1.5 block text-xs font-medium text-[var(--text-muted)]">公开地址</span>
|
||||
<input
|
||||
defaultValue="https://share.mesalab.internal"
|
||||
className="h-10 w-full rounded-lg border border-white/10 bg-white/[0.025] px-3 font-mono text-xs outline-none focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/10"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center justify-between rounded-xl bg-white/[0.025] px-3 py-2.5">
|
||||
<span className="text-xs text-[var(--text-muted)]">{snapshot.instance.protocol}</span>
|
||||
<StatusBadge status="兼容" tone="online" />
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard
|
||||
icon={Database}
|
||||
title="基础设施"
|
||||
description="状态可见,敏感连接信息不在 Web 控制台回显。"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
['PostgreSQL', '正常', '主数据与审计权威'],
|
||||
['对象存储', '正常', 'Package 与有界中继暂存'],
|
||||
['网关身份', '正常', '证书将在 82 天后轮换']
|
||||
].map(([name, status, copy]) => (
|
||||
<div key={name} className="flex items-center gap-3 rounded-xl bg-white/[0.025] px-3 py-3">
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-300" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-[var(--text-primary)]">{name}</span>
|
||||
<span className="block truncate text-xs text-[var(--text-muted)]">{copy}</span>
|
||||
</span>
|
||||
<span className="text-xs text-emerald-300">{status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard
|
||||
icon={KeyRound}
|
||||
title="身份与登录"
|
||||
description="本地账号可用;OIDC 需要部署管理员配置固定回调地址。"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Toggle
|
||||
checked={oidc}
|
||||
onChange={setOidc}
|
||||
label="允许企业 OIDC"
|
||||
description="使用 state、nonce 和 PKCE 完成登录。"
|
||||
/>
|
||||
<div className="flex items-center justify-between rounded-xl p-2">
|
||||
<span>
|
||||
<span className="block text-sm text-[var(--text-primary)]">本地账号</span>
|
||||
<span className="mt-1 block text-xs text-[var(--text-muted)]">首个系统管理员已完成初始化</span>
|
||||
</span>
|
||||
<StatusBadge status="已启用" tone="online" />
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard
|
||||
icon={RadioTower}
|
||||
title="中继与联邦"
|
||||
description="控制面独立运行,关闭中继不会影响设备注册和目录。"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Toggle
|
||||
checked={relay}
|
||||
onChange={setRelay}
|
||||
label="启用数据中继"
|
||||
description="网络无法直连且双方策略允许时提供有界中继。"
|
||||
/>
|
||||
<Toggle
|
||||
checked={federation}
|
||||
onChange={setFederation}
|
||||
label="允许网关联邦"
|
||||
description="仍需双方管理员核对指纹并分别确认关系。"
|
||||
/>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard
|
||||
icon={Mail}
|
||||
title="通知"
|
||||
description="用于邀请、审批和高影响状态通知。"
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-xl bg-white/[0.025] px-3 py-3">
|
||||
<span>
|
||||
<span className="block text-sm text-[var(--text-primary)]">SMTP 邮件</span>
|
||||
<span className="mt-1 block text-xs text-[var(--text-muted)]">凭据由部署环境提供,永不回显</span>
|
||||
</span>
|
||||
<Button size="sm" variant="secondary">发送测试</Button>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard
|
||||
icon={Braces}
|
||||
title="保留与审计"
|
||||
description="默认只保留有界元数据,不保存提示正文、文件与模型输出。"
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className="mb-1.5 block text-xs font-medium text-[var(--text-muted)]">审计保留期</span>
|
||||
<select className="h-10 w-full rounded-lg border border-white/10 bg-[var(--control)] px-3 text-sm outline-none">
|
||||
<option>365 天</option>
|
||||
<option>180 天</option>
|
||||
<option>90 天</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="mb-1.5 block text-xs font-medium text-[var(--text-muted)]">实例时区</span>
|
||||
<select className="h-10 w-full rounded-lg border border-white/10 bg-[var(--control)] px-3 text-sm outline-none">
|
||||
<option>Asia/Shanghai (UTC+8)</option>
|
||||
<option>UTC</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</SettingCard>
|
||||
</div>
|
||||
<section className="mt-5 rounded-2xl border border-rose-400/15 bg-rose-400/[0.035] p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldCheck className="mt-0.5 size-5 shrink-0 text-rose-300" />
|
||||
<div className="flex-1">
|
||||
<h2 className="text-sm font-semibold text-rose-200">危险操作</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-rose-200/60">
|
||||
删除组织前必须停止新任务、撤销联邦和设备,并按保留策略归档审计。
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="danger">删除组织</Button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex min-h-9 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition-[color,background-color,border-color,box-shadow,transform] duration-150 outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70 disabled:pointer-events-none disabled:opacity-45 active:translate-y-px',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border border-cyan-300/25 bg-cyan-400 text-slate-950 shadow-[0_0_28px_-8px_rgba(34,211,238,.75)] hover:bg-cyan-300',
|
||||
secondary:
|
||||
'border border-white/10 bg-white/[0.055] text-slate-100 hover:border-white/20 hover:bg-white/[0.09]',
|
||||
ghost: 'text-slate-300 hover:bg-white/[0.07] hover:text-white',
|
||||
danger:
|
||||
'border border-rose-400/25 bg-rose-500/12 text-rose-200 hover:bg-rose-500/20'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9',
|
||||
sm: 'h-8 min-h-8 px-2.5 text-xs',
|
||||
icon: 'size-9 min-h-9 px-0'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants>
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function Dialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
width = 'max-w-xl'
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
width?: string
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-slate-950/75 backdrop-blur-sm data-[state=open]:animate-in" />
|
||||
<DialogPrimitive.Content
|
||||
className={`fixed left-1/2 top-1/2 z-50 max-h-[86vh] w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-2xl border border-white/10 bg-[var(--dialog)] p-6 text-[var(--text-primary)] shadow-2xl outline-none ${width}`}
|
||||
>
|
||||
<div className="pr-10">
|
||||
<DialogPrimitive.Title className="text-lg font-semibold tracking-tight">
|
||||
{title}
|
||||
</DialogPrimitive.Title>
|
||||
{description ? (
|
||||
<DialogPrimitive.Description className="mt-1 text-sm leading-6 text-slate-400">
|
||||
{description}
|
||||
</DialogPrimitive.Description>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 grid size-9 place-items-center rounded-lg text-slate-400 outline-none transition hover:bg-white/[0.07] hover:text-white focus-visible:ring-2 focus-visible:ring-cyan-400/70">
|
||||
<X aria-hidden="true" className="size-4" />
|
||||
<span className="sr-only">关闭</span>
|
||||
</DialogPrimitive.Close>
|
||||
<div className="mt-6">{children}</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CircleCheck, CircleMinus, CircleX, Clock3, Radio } from 'lucide-react'
|
||||
import type { PrototypeRecord } from '../../../shared/prototype-data'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
const styles: Record<PrototypeRecord['statusTone'], string> = {
|
||||
online: 'border-emerald-400/20 bg-emerald-400/10 text-emerald-300',
|
||||
warning: 'border-amber-400/20 bg-amber-400/10 text-amber-200',
|
||||
offline: 'border-slate-400/15 bg-slate-400/10 text-slate-400',
|
||||
danger: 'border-rose-400/20 bg-rose-400/10 text-rose-300',
|
||||
neutral: 'border-violet-400/20 bg-violet-400/10 text-violet-300'
|
||||
}
|
||||
|
||||
const icons = {
|
||||
online: CircleCheck,
|
||||
warning: Clock3,
|
||||
offline: CircleMinus,
|
||||
danger: CircleX,
|
||||
neutral: Radio
|
||||
}
|
||||
|
||||
export function StatusBadge({
|
||||
status,
|
||||
tone,
|
||||
className
|
||||
}: {
|
||||
status: string
|
||||
tone: PrototypeRecord['statusTone']
|
||||
className?: string
|
||||
}) {
|
||||
const Icon = icons[tone]
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex w-fit items-center gap-1.5 rounded-full border px-2 py-1 text-xs font-medium',
|
||||
styles[tone],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-3.5" />
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,204 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root,
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--canvas: #07111f;
|
||||
--header: rgba(7, 17, 31, 0.78);
|
||||
--sidebar: #07111f;
|
||||
--panel: rgba(12, 25, 43, 0.78);
|
||||
--dialog: #0a1525;
|
||||
--control: #0d1b2e;
|
||||
--text-strong: #f1f6fd;
|
||||
--text-primary: #cdd9e8;
|
||||
--text-muted: #718198;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--canvas: #edf3fa;
|
||||
--header: rgba(247, 250, 253, 0.82);
|
||||
--sidebar: #0a1627;
|
||||
--panel: rgba(255, 255, 255, 0.86);
|
||||
--dialog: #ffffff;
|
||||
--control: #f7fafc;
|
||||
--text-strong: #0e1a2b;
|
||||
--text-primary: #2b3b50;
|
||||
--text-muted: #52657d;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
background: var(--canvas);
|
||||
font-family:
|
||||
Inter, "Microsoft YaHei UI", "Noto Sans SC", system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(34, 211, 238, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: rgba(113, 129, 152, 0.35);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.grid-overlay {
|
||||
background-image:
|
||||
linear-gradient(rgba(93, 125, 158, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(93, 125, 158, 0.035) 1px, transparent 1px);
|
||||
background-size: 38px 38px;
|
||||
mask-image: linear-gradient(to bottom, black, transparent 92%);
|
||||
}
|
||||
|
||||
.pulse-ring {
|
||||
animation: pulse-ring 2.8s ease-out infinite;
|
||||
}
|
||||
|
||||
.pulse-delay {
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
opacity: 0.25;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.04;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.grid-overlay {
|
||||
background-size: 28px 28px;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .border-white\/\[0\.075\],
|
||||
:root[data-theme="light"] .border-white\/\[0\.07\],
|
||||
:root[data-theme="light"] .border-white\/\[0\.065\],
|
||||
:root[data-theme="light"] .border-white\/\[0\.06\],
|
||||
:root[data-theme="light"] .border-white\/\[0\.055\],
|
||||
:root[data-theme="light"] .border-white\/\[0\.08\],
|
||||
:root[data-theme="light"] .border-white\/10 {
|
||||
border-color: rgba(45, 67, 91, 0.12);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .text-slate-100,
|
||||
:root[data-theme="light"] .text-slate-200,
|
||||
:root[data-theme="light"] .text-slate-300 {
|
||||
color: #25364c;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .text-slate-400,
|
||||
:root[data-theme="light"] .text-slate-500,
|
||||
:root[data-theme="light"] .text-slate-600 {
|
||||
color: #52657d;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .text-cyan-100,
|
||||
:root[data-theme="light"] .text-cyan-200,
|
||||
:root[data-theme="light"] .text-cyan-300 {
|
||||
color: #006579;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .text-amber-100,
|
||||
:root[data-theme="light"] .text-amber-200,
|
||||
:root[data-theme="light"] .text-amber-300 {
|
||||
color: #8c5300;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .text-emerald-200,
|
||||
:root[data-theme="light"] .text-emerald-300 {
|
||||
color: #087453;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .text-violet-200,
|
||||
:root[data-theme="light"] .text-violet-300 {
|
||||
color: #6842b5;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .text-rose-200,
|
||||
:root[data-theme="light"] .text-rose-300 {
|
||||
color: #b81743;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .app-sidebar .text-white,
|
||||
:root[data-theme="light"] .app-sidebar .text-slate-100,
|
||||
:root[data-theme="light"] .app-sidebar .text-slate-200,
|
||||
:root[data-theme="light"] .app-sidebar .text-slate-300 {
|
||||
color: #e8f1fb;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .app-sidebar .text-slate-400 {
|
||||
color: #9eb0c5;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .app-sidebar .text-slate-500,
|
||||
:root[data-theme="light"] .app-sidebar .text-slate-600 {
|
||||
color: #72879f;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .app-sidebar .text-cyan-100,
|
||||
:root[data-theme="light"] .app-sidebar .text-cyan-200,
|
||||
:root[data-theme="light"] .app-sidebar .text-cyan-300 {
|
||||
color: #67e8f9;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] main .bg-white\/\[0\.025\],
|
||||
:root[data-theme="light"] main .bg-white\/\[0\.035\],
|
||||
:root[data-theme="light"] main .bg-white\/\[0\.04\] {
|
||||
background-color: rgba(29, 56, 84, 0.035);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src", "shared"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.server.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "build-server",
|
||||
"rootDir": ".",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["server", "shared"],
|
||||
"exclude": ["server/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8787'
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
exclude: ['build-server/**', 'dist/**', 'node_modules/**']
|
||||
}
|
||||
})
|
||||
@@ -6,6 +6,12 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/renderer/src/test-setup.ts'],
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/build-server/**',
|
||||
'**/.git/**'
|
||||
],
|
||||
maxWorkers: 1,
|
||||
coverage: {
|
||||
reporter: ['text', 'html']
|
||||
|
||||
Reference in New Issue
Block a user