feat: add local sharing and containerized web/server layout

This commit is contained in:
lofyer
2026-10-01 12:01:44 +08:00
parent 599a0a6645
commit c9d5e63276
51 changed files with 2212 additions and 233 deletions
+1
View File
@@ -2,3 +2,4 @@ build-server/
dist/
node_modules/
*.png
data/
-24
View File
@@ -1,24 +0,0 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
ENV SHARESERVER_PORT=8787
ENV SHARESERVER_HOST=0.0.0.0
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY --from=build /app/build-server ./build-server
USER node
EXPOSE 8787
CMD ["node", "build-server/server/index.js"]
+89 -6
View File
@@ -3,7 +3,8 @@
This repository 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.
ShareServer implementation. It also includes a persistent local sharing metadata
service connected to GoodBuddy's Device Sharing application.
## Repository and documentation
@@ -19,6 +20,12 @@ For cross-repository documentation links, clone `goodbuddy` and
`goodbuddy-shareserver` next to each other. Building and running ShareServer
does not require the Desktop checkout.
Web sources, HTML entry, and Vite/TypeScript configuration live in `web/`;
Hono sources and tests live in `server/src/`, with `server/tsconfig.json`.
Shared types and fixtures remain in root `shared/`. The root package scripts,
`vitest.config.ts`, and ESLint configuration cover both applications; there are
no separate packages or workspaces.
Source and feature documentation were extracted from GoodBuddy commit
`ee804edcae828066614e03a3b37e6ba389b09323`, from `shareserver/` and
`docs/features/share-server/`. Earlier history remains in that repository.
@@ -32,6 +39,16 @@ Source and feature documentation were extracted from GoodBuddy commit
- 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.
- Sharing center at the preserved `/capabilities` route: capability and knowledge
catalogs, publisher/device views, URL filters and linked metadata details.
Knowledge fixtures distinguish synced copies from remote retrieval and display
separate search/read/download permissions; no content operations are connected.
- GoodBuddy service demos: `/model-gateway`, `/documents`, and `/distribution`.
They simulate upstream routing policies and call audit, document ownership and
recycle-bin permissions, OnlyOffice instance checks, and per-group configuration
releases and rollback. State survives navigation between these routes but resets
on refresh; these actions use browser memory only, with no network requests or
local storage. Offline devices retain their applied version and remain pending.
- Responsive navigation, dark and light themes, search, filters, details,
forms, confirmations, and keyboard-accessible dialogs.
- Production build and a non-root container image.
@@ -44,16 +61,28 @@ business pages.
## Not included yet
Prototype records are deliberately read-only fixtures. The action endpoint
returns an acknowledgement but does not persist changes. PostgreSQL,
The existing snapshot records are read-only fixtures; the service demos mutate
only in-memory examples. 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 planned Office
editing integration also has no current implementation: ONLYOFFICE Docs,
object storage, document sessions, save callbacks, checkpoints, and Desktop
document tabs remain design work described in the
document tabs remain design work. Model inference, actual gateway forwarding,
document saving, and device delivery are not implemented. The OnlyOffice checks
use preset results and do not establish an editing session. See the
[ShareServer feature documentation](docs/features/share-server/README.md).
Daily work belongs in GoodBuddy; ShareServer provides the organization backend.
Its model gateway forwards to other model services and does not host models.
The agreed document model uses ShareServer as primary storage. Personal creators
may delete their documents or transfer ownership to the organization; after
transfer, organization permissions govern deletion, with no retained personal
delete right. Explicit local-source import/writeback remains a separate Office
mode: a server checkpoint does not confirm a Desktop or SSH source-file save.
Closing an editing session does not delete a server-stored primary document.
The readiness endpoint reports this explicitly:
```json
@@ -75,6 +104,23 @@ npm run dev
Open `http://127.0.0.1:5174`. Vite proxies `/api` to the Hono service on port
`8787`.
## Local GoodBuddy integration
With `npm run dev` running here, run `npm run dev` in the adjacent GoodBuddy
checkout. Open Device Sharing from its application center, save the server URL
(`http://127.0.0.1:8787`), register the device, and publish a metadata entry.
Open `http://127.0.0.1:5174/capabilities?view=local` to view actual records separately
from the organization fixtures. Revoke in GoodBuddy and refresh the Web directory.
Records persist in `data/sharing.json`; override the directory with
`SHARESERVER_DATA_DIR`. Use one process per data directory. This API is a local,
unauthenticated development service: device IDs identify records, not owners.
Source modes and permission flags are declarations only; no knowledge content,
file synchronization, remote execution, or organization authorization is provided.
The [technical design](docs/features/share-server/technical-design.md#41-本地共享目录契约已实现)
defines the actual HTTP contract. The readiness response additionally reports this
local-service boundary and keeps `productionReady: false`.
## Run the production build
```text
@@ -85,6 +131,11 @@ npm start
Open `http://127.0.0.1:8787`.
Build output is `dist/web/` for the console and `dist/server/` for the backend.
`npm start` runs `dist/server/server/src/index.js` from the repository root and
serves `dist/web/`. The backend preserves the source directory structure under
its output directory so compiled imports can resolve root `shared/` modules.
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
@@ -92,11 +143,43 @@ production service.
## Container
Container configuration lives in `docker/`. Run these commands from the repository
root to build and start the Web console and API together:
```text
docker build -t goodbuddy-shareserver-prototype .
docker run --rm -p 8787:8787 goodbuddy-shareserver-prototype
mkdir -p data
sudo chown 1000:1000 data
docker compose -f docker/compose.yaml up -d --build
docker compose -f docker/compose.yaml ps
docker compose -f docker/compose.yaml logs -f shareserver
```
Open `http://127.0.0.1:8787`. The container runs as the non-root `node` user
and stores sharing metadata in `/app/data`, bind-mounted from the project's
`./data` directory. No named volume or additional database container is required.
Existing local `data/sharing.json` is used directly and must be readable by UID 1000.
Only sharing metadata is
persisted; the console's other demos retain their prototype behavior.
Stop with `docker compose -f docker/compose.yaml down`; the host directory survives
container recreation and removal with `down -v`. Run only one process per data directory, including
local development servers. The setup commands above make the directory writable
by the container's `node` user (UID/GID 1000). Compose requires the directory to
exist before startup.
To run without Compose:
```text
docker build -f docker/Dockerfile -t goodbuddy-shareserver-prototype .
docker run --rm -p 127.0.0.1:8787:8787 --mount type=bind,source="$(pwd)/data",target=/app/data goodbuddy-shareserver-prototype
```
Compose publishes port 8787 on all host interfaces; the standalone example
publishes only on the host loopback interface. Containerization
does not add authentication or authorization; this remains a local prototype.
The image health check verifies HTTP availability, not storage writability or
production readiness.
## Validation
```text
+27
View File
@@ -0,0 +1,27 @@
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
ENV SHARESERVER_DATA_DIR=/app/data
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && mkdir -p /app/data && chown node:node /app/data
COPY --from=build /app/dist/web ./dist/web
COPY --from=build /app/dist/server ./dist/server
USER node
EXPOSE 8787
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:' + process.env.SHARESERVER_PORT + '/api/v1/public/health', { signal: AbortSignal.timeout(4000) }).then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "dist/server/server/src/index.js"]
@@ -3,3 +3,7 @@ dist
node_modules
*.log
*.png
.git
data
.env
.env.*
+16
View File
@@ -0,0 +1,16 @@
name: goodbuddy-shareserver
services:
shareserver:
build:
context: ..
dockerfile: docker/Dockerfile
ports:
- "8787:8787"
volumes:
- type: bind
source: ../data
target: /app/data
bind:
create_host_path: false
restart: unless-stopped
+29 -2
View File
@@ -4,6 +4,13 @@ ShareServer 是 GoodBuddy 设备共享网络的独立 Web 服务。它为一个
目录、能力发布、授权策略、任务路由、可选数据中继、能力包更新、网关联邦、Office 编辑和
审计管理。目标范围还包括共享工作空间的实时消息、成员处理请求和资料同步。
成员的日常操作在 GoodBuddy 中完成,ShareServer 提供组织后台。模型网关向其他模型服务转发
请求,不托管模型或运行推理。文档主要存储在 ShareServer;个人创建者可删除自己的文档或将
所有权转移给组织,转移后删除由组织权限决定,原创建者不再保留个人删除权。
Office 区分服务端主存文档与显式的本地来源导入/回写模式。既有 Desktop 源文件、检查点和
冲突契约用于后者;编辑会话结束不删除服务端主文档,检查点可用也不等于本地或 SSH 源文件已回写。
ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不自动取得设备上的能力、
文件、凭据或 SSH Host。
@@ -21,11 +28,31 @@ ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不
| [Desktop Office 协同编辑](../../../../goodbuddy/docs/features/office-document-editing/README.md) | 文档 Tab、源文件回写、冲突和 AI 选区编辑(GoodBuddy 仓库) |
工作空间协作场景由用户故事追踪;管理员、能力发布者、审计员等既有场景继续由 PRD 与逻辑
设计覆盖。仓库已建立可运行的全控制台交互原型;它用于确认信息架构与交互,不代表生产服务已经
实现。当前边界和运行方法见[实施进度](./progress.md)与
设计覆盖。仓库已有全控制台交互原型及本地共享目录服务,后者持久化 GoodBuddy 注册和发布的元数据。
组织生产服务尚未实现。当前边界和运行方法见[实施进度](./progress.md)与
[`README.md`](../../../README.md)。跨仓库文档链接要求将 `goodbuddy` 与
`goodbuddy-shareserver` 克隆到同一级目录;服务构建和运行不依赖桌面端仓库。
当前源码按 `web/src/`、`server/src/` 和根 `shared/` 组织,构建、测试与依赖仍由根目录统一管理。
目录和产物路径见[技术设计第 1 节](./technical-design.md#1-仓库与运行边界)。
## GoodBuddy 服务演示
当前新增 `/model-gateway`(模型网关)、`/documents`(文档与协作)、`/distribution`(配置下发)
三个路由,演示策略判断、文档归属与回收站、OnlyOffice 实例配置和检查、按组发布与回退。
三页导航共享会话内存,刷新重置;服务演示操作不发网络请求、不写本地存储。角色开关、检查
结果和在线设备应用状态均为模拟,未实现推理、真实转发、Office 编辑保存或设备交付。
具体交互见 [UI 设计第 16.1 节](./ui-design.md#161-goodbuddy-服务演示)。
共享中心沿用 `/capabilities`。能力目录、共享知识库、按用户查看和按设备查看使用示例数据;
“本地服务目录”页签读取真实本地记录。来源模式、权限展示与跳转约定见
[UI 设计](./ui-design.md),本地 API 见[技术设计第 4.1 节](./technical-design.md#41-本地共享目录契约已实现)。
本地体验:在本仓库运行 `npm run dev`,在 GoodBuddy 仓库运行 `npm run dev`,从桌面应用中心
进入设备共享,连接 `http://127.0.0.1:8787`,注册、发布,再打开
`http://127.0.0.1:5174/capabilities?view=local` 查看。服务数据默认保存到 `data/sharing.json`。
完整桌面范围与验证见[设备共享文档](../../../../goodbuddy/docs/features/device-sharing/README.md)。
## 角色
- **系统管理员**:维护实例、身份源、域名、证书、存储和全局限制。
+46 -2
View File
@@ -10,6 +10,21 @@
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 共享规则 | [共享网络总体设计](../../../../goodbuddy/docs/architecture/share-network-architecture.md) |
## 本地目录首期状态规则
当前本地联调仅实现设备与发布元数据,使用[技术设计第 4.1 节](./technical-design.md#41-本地共享目录契约已实现)
的独立接口。下文组织身份、Grant、任务和文档归属规则属于目标设计。
- 注册相同设备 ID 更新名称、平台及版本,保留首次注册时间;相同内容重试不改变时间。
- 注册不自动发布内容,也不表示设备在线。设备列表目前没有心跳或离线判断。
- 发布必须关联已有设备。新 ID 创建 `published` 条目;相同 ID、相同内容返回已有记录,
相同 ID、不同内容报冲突,不覆盖原记录。
- 撤销将状态变为 `revoked` 并保留目录记录。重复撤销不改变结果;重试旧发布请求不恢复已撤销记录。
- 能力与知识按 `kind` 区分。知识的搜索、原文、下载标志分别存储,不相互推导;
`server` 来源也只是发布者声明,不表示文件已经上传或同步。
- 当前没有用户主体或组织所有权,因此真实目录按设备查看。按用户查看仍使用组织示例,
不把设备名称推定为用户身份。
## 1. 核心不变量
### INV-1 组织隔离先于角色
@@ -66,10 +81,16 @@ ShareServer 只路由调用方冻结的 provider,不在离线、繁忙或失
### INV-11 服务端检查点不等于源文件已保存
ONLYOFFICE 保存只产生 ShareServer 权威的 `DocumentCheckpoint`。只有 Desktop 或远程
在显式的本地来源导入/回写模式中,ONLYOFFICE 保存只产生 ShareServer 权威的 `DocumentCheckpoint`。只有 Desktop 或远程
GoodBuddy Agent 校验源文件基准并完成原子替换后,源文件才是已保存状态。ShareServer 不根据
callback 成功推断本机或 SSH 文件已经更新。
文档默认以 ShareServer 为主存储,日常操作在 GoodBuddy 中完成。服务端主存模式不要求本地
回写确认;主文档生命周期独立于编辑会话,不能因会话关闭或检查点清理而删除主文档。
个人创建者可删除自己的文档或确认转移所有权给组织。转移后原个人删除权立即失效,删除和
恢复按当前组织权限判断;恢复不改变归属。管理身份和归属元数据可见性不授予个人正文访问权。
### INV-12 Office 凭据单用途
设备 API 凭据、编辑器一次性启动授权、ONLYOFFICE 文件令牌和 callback token 分别绑定不同
@@ -140,6 +161,8 @@ active <-> paused
### 2.7 DocumentEditSession
以下包含上传阶段的流程描述本地来源模式;不将其作为服务端主文档的生命周期。
```text
creating -> uploading -> ready -> editing
creating/uploading -> failed | cancelled | expired
@@ -287,6 +310,11 @@ Publication/Grant,本人草稿确认也不能绕过当前访问权限。
### 5.2 来源、受众与动作
共享知识区分服务器同步副本和远程设备取用。已就绪的服务器副本不因来源设备离线而失效,
但更新需等待设备连接;远程取用在设备离线时不可用。发布暂停或设备撤销仍阻止使用,不能
以已有副本绕过撤销。搜索、读取原文、下载分别判断权限;可用状态、共享对象和管理员元数据
可见性均不替代动作授权。当前共享中心只演示这些状态,无真实内容请求。
可用内容为来源授权、当前入口受众、动作、空间权限、组织策略及 Provider 本地权限的交集。
个人外部入口与组织空间独立授权;同一分身身份复用不复制其所有来源权限。
@@ -313,6 +341,20 @@ Publication/Grant,本人草稿确认也不能绕过当前访问权限。
任何权限扩大都从自动路径转为 `require_approval`。降级只有在目标版本仍被签名目录允许且
策略明确指定时才提供,不自动视为故障回滚。
### 6.1 网关配置演示
模型网关负责向其他服务转发,不托管模型。原型仅在上游与映射均启用、目标组被允许时判定
模型可用;模拟调用记录允许/拒绝及路由元数据,不发送请求或记录正文。
配置演示与上述签名能力包更新分开。草稿选择目标组可用模型,发布时重新检查策略;每组
独立记录发布历史。回退仅选择该组上一条记录,并重新检查其模型可用性,恢复版本及默认
模型后移除当前历史末项。发布和回退仅更新目标组在线示例设备;离线设备保留已应用版本,
目标版本随发布变化并显示待下发,未模拟重连交付。
这些状态仅保存在三页共享的会话内存中,刷新重置;角色开关不构成实际授权,发布状态不构成
设备确认。文档演示中仅本人可删除/转移个人文档,组织删除/恢复要求显式模拟组织文档管理员
角色,其他成员个人文档仅显示元数据,回收站不提供永久删除。
## 7. 联邦可见性
联邦目录只导出对方明确允许的能力投影:组织显示名、能力、版本范围、数据政策和可用性摘要。
@@ -326,11 +368,13 @@ Publication/Grant,本人草稿确认也不能绕过当前访问权限。
- 撤销设备、Publication、Grant、Package 或联邦后立即阻止新令牌。
- 对象存储中的未完成任务正文按任务期限清理;审计元数据按组织保留策略清理。
- Office 会话关闭、撤销或到期后停止签发新启动、文件和 callback 令牌。工作副本与 checkpoint
按 Office 保留策略清理;法定保留只能保留匹配对象和审计,不能恢复会话权限。
按 Office 保留策略清理,不连带删除服务端主文档;法定保留只能保留匹配对象和审计,不能恢复会话权限。
- 法定保留只锁定匹配审计事件,不使被撤销身份重新可用。
### 8.1 Office 保存与回写结果
下表的源文件确认与冲突项仅适用于本地来源导入/回写模式。
| 条件 | ShareServer 结果 |
| --- | --- |
| callback 通过验证且对象完整 | 原子发布新 checkpoint,通知 Desktop |
+39 -8
View File
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.6 |
| 日期 | 2026-09-28 |
| 版本 | 0.7 |
| 日期 | 2026-09-30 |
| 适用产品 | 独立部署的 ShareServer 与 Web 管理控制台 |
| 关联架构 | [共享网络总体设计](../../../../goodbuddy/docs/architecture/share-network-architecture.md) |
| 关联文档 | [功能逻辑](./logic-design.md)、[管理界面](./ui-design.md)、[技术设计](./technical-design.md) |
@@ -21,7 +21,7 @@
1. 组织可在自有网络、私有云或公网独立部署 ShareServer。
2. 管理员可统一管理用户、组、设备、能力、权限、更新和审计。
3. 成员通过桌面端使用获准能力,无需日常进入管理控制台。
3. 成员在 GoodBuddy 中完成日常操作;ShareServer 提供组织后台,无需日常进入管理控制台。
4. 设备离线、用户撤销或策略变化能准确影响新任务,不破坏历史证据。
5. 两个组织可经双边确认建立有限范围的联邦协作。
6. ShareServer 可以只运行控制面,也可按部署策略提供数据中继和包存储。
@@ -31,6 +31,16 @@
## 3. 产品范围
### 本地首期交付
先验证 GoodBuddy 应用中心中的设备共享与 ShareServer 目录互通:用户保存本地服务地址、
注册本机,显式发布能力或知识的名称、描述、来源及权限声明,再撤销发布。Web 共享中心能查询
相同记录,服务重启后记录仍保留。此阶段不自动枚举本地能力或上传知识文件。
验收要求:注册不自动发布,撤销后刷新仍为已撤销,网络失败有可重试反馈,知识权限独立保存;
真实目录与组织样例分别展示。API、状态和验证证据分别由技术设计、逻辑设计和实施进度维护。
身份认证、组织授权、内容同步与远程调用仍按以下完整产品范围继续实现。
### 3.1 包含
- 本地账号及可配置 OIDC 身份源。
@@ -46,12 +56,14 @@
- Web 管理控制台和成员自助页面。
- 工作空间的组织共享关联、成员访问、实时普通消息与线程、提及通知、处理请求和资料同步。
- 选定知识与监督回顾的共享授权,及数字分身对这些来源的复用。
- 模型网关接入其他模型服务,管理模型映射、访问策略和调用审计,以及按用户组分配网关模型配置。
- 以 ShareServer 为主存储的文档、个人与组织归属管理;本地来源导入/回写为显式选择的独立模式。
### 3.2 不包含
- 远程管理设备桌面、任意 Shell 或任意文件系统。
- 发现未注册的局域网设备。
- 保存或分发用户 API Key、Cookie 和桌面登录状态。
- 收集或分发用户个人 API Key、Cookie 和桌面登录状态。模型网关所需的组织上游凭据由管理员单独配置、服务端保管,不下发给客户端;当前原型不接收凭据。
- 默认查看任务正文、附件和模型输出。
- 自动上传、同步或浏览数字人所有者的本地个人、项目、会话和原始 Agent 记忆;用户明确发布的知识与选定回顾按 FR-18 单独管理。
- 因为成员加入组织而自动开放其数字人、记忆、Runtime、工作区或工具。
@@ -60,7 +72,8 @@
- 公共开放市场、计费结算或匿名能力调用。
- 替代 GoodBuddy 官方客户端、模型和 Agent 发布源。
- 自行实现 Office 渲染器,或把 ONLYOFFICE Document Server 打进 Desktop 安装包。
- 默认永久保存用户文档、选区、提示词或模型输出。
- 模型托管与推理运行。
- 在普通日志或审计中保存文档正文、选区、提示词或模型输出;服务端主文档按归属、删除和保留规则管理。
- 首期不含私聊、语音视频、已读回执、正在输入及分身输出逐字广播;空间协作按轻量 IM 范围实现。
## 4. 功能需求
@@ -144,6 +157,15 @@
- 普通成员只看到自己的设备、授权和待办;管理员栏目按权限隐藏并在 API 再次校验。
- 普通成员可以管理自己发布的数字人,并发现和 `@` 当前组织内获准调用的数字人。
- 所有高影响操作说明对象、范围和后果。
- 共享中心扩展既有共享能力入口,保留 `/capabilities`;提供能力目录、共享知识库、按用户查看、
按设备查看四个页签。内容展示发布者、提供设备、版本、在线与发布状态、共享对象;用户和
设备视图列出关联内容并可进入详情,既有设备详情提供对应共享视图入口。
当前“GoodBuddy 服务”原型提供 `/model-gateway`、`/documents`、`/distribution` 三页,覆盖
网关策略判断、文档归属与回收站、OnlyOffice 实例检查、配置发布和回退。操作仅修改会话内存,
跨页保留、刷新重置,不发送服务请求或写入本地存储;模拟角色不构成授权,模拟成功不构成
推理、转发、Office 保存或设备交付的验收。离线设备保持待下发与原已应用版本;发布和回退
都检查目标组当前可用模型,回退同时恢复版本及默认模型。细节见 UI 设计第 16.1 节。
### FR-12 可部署性
@@ -190,6 +212,13 @@
### FR-14 Office 编辑服务
- 日常文档使用在 GoodBuddy 中完成,ShareServer 主存文档与临时编辑会话分别管理。个人创建者
可删除自己的文档或确认转移给组织;转移后按组织权限删除,原创建者的个人删除权失效。
管理后台展示归属元数据不授予个人正文访问权。原型以显式“组织文档管理员”角色模拟组织
文档删除/恢复,恢复保持归属,不提供永久删除。
- 本节既有工作副本上传、检查点下载和源文件回写流程适用于显式选择的本地来源导入/回写
模式,保留 Desktop/SSH 源文件基准校验与冲突契约。服务端主存模式不以本地回写作为保存
完成条件;关闭编辑会话不删除主文档,也不改变归属。
- Office 编辑是可选服务能力。未配置或健康检查失败时,ShareServer 仍可运行其他控制面功能,
并向 Desktop 返回明确不可用原因。
- 首选 ONLYOFFICE Docs Developer Edition,目标支持 DOCX、XLSX 和 PPTX。正式采用前必须完成
@@ -202,11 +231,11 @@
修改定位与高亮、撤销重做;源文件回写仍以检查点为依据。客户端需求与选型验证以
[Office PRD](../../../../goodbuddy/docs/features/office-document-editing/prd.md) 的 `FR-6`、
`FR-13`、`FR-14` 为准。
- Desktop 使用设备身份创建 `DocumentEditSession`,上传经过大小、格式和 SHA-256 声明的工作
- 本地来源模式下,Desktop 使用设备身份创建 `DocumentEditSession`,上传经过大小、格式和 SHA-256 声明的工作
副本。ShareServer 不接收本机或 SSH 绝对路径,也不允许文件名决定对象存储路径。
- ShareServer 为编辑器签发一次性启动授权,为 ONLYOFFICE 签发独立的短期文件和 callback
凭据;设备、编辑器和 callback audience 不能互换。
- ONLYOFFICE 保存先形成不可变 `DocumentCheckpoint`。ShareServer 只宣称检查点可用,不宣称
- 本地来源模式下,ONLYOFFICE 保存先形成不可变 `DocumentCheckpoint`。ShareServer 只宣称检查点可用,不宣称
Desktop 本地或 SSH 源文件已经回写成功。
- 服务端保存组织、主体、会话、文档类型、大小、策略、编辑器版本、checkpoint 和清理审计;
默认不保存文档正文、选区正文、提示词或模型输出到普通日志和审计表。
@@ -217,7 +246,7 @@
- Ask 只能取得 AI 修改建议;只有 Desktop 确认的 Execute 请求可以取得绑定单会话、revision
和选区的短期编辑授权。
- 会话关闭、撤销或到期后停止新读取、保存和 AI 操作,并按组织保留策略清理工作副本、
checkpoint、Cookie 和对象令牌。
checkpoint、Cookie 和对象令牌;服务端主文档不随临时会话清理。
### FR-15 工作空间与共享属性
@@ -263,6 +292,8 @@
### FR-18 共享知识与个人分身来源
- 知识库明确区分服务器同步副本与远程设备取用,分别展示来源可用性。搜索、读取原文、下载
独立授权;管理员的元数据可见性不产生内容权限。当前 Web 原型仅展示示例规则,不接入内容操作。
- 知识库可独立共享,也可被空间和分身引用。复用来源身份与版本,各入口按来源授权、受众、
使用动作及组织策略求交集;关联资料不自动扩大权限。
- 用户可设置分身身份、表达提示词和选定知识来源。提示词不授予权限,开启分身不共享全部
+79 -4
View File
@@ -2,8 +2,83 @@
## 当前状态
ShareServer 已进入可运行交互原型阶段。当前实现用于验证完整控制台信息架构、视觉方向与操作
流程,不提供生产身份、组织隔离、设备连接、授权、任务路由或数据持久化。
ShareServer 包含控制台交互原型和可持久化的本地共享目录服务。GoodBuddy 已能注册设备、发布与
撤销元数据,Web 能查看真实目录;生产身份、组织隔离、设备控制连接、授权及任务路由仍未实现。
### 2026-10-01:Web 与服务端目录整理
- Web 源码与测试迁至 `web/src/`,HTML、Vite 和 Web TypeScript 配置置于 `web/`;
Hono 源码与测试迁至 `server/src/`,服务端配置置于 `server/tsconfig.json`。根 `shared/` 保留。
- 根目录统一管理依赖、脚本、Vitest、ESLint 与 TypeScript 项目引用,未新增依赖或 workspace。
构建产物为 `dist/web/` 和 `dist/server/`,启动入口为 `dist/server/server/src/index.js`。
- 根 Node v24.16.0 环境下,`npm test` 的 5 个文件、51 个测试通过;`npm run typecheck`、
`npm run lint`、`npm run build` 和 `git diff --check` 通过。
- `docker compose -f docker/compose.yaml build` 与
`docker compose -f docker/compose.yaml up -d --wait --wait-timeout 60` 通过,现有服务重建后健康。
HTTP 验证覆盖首页、JS/CSS 资源、共享中心路由回退、health、ready 和本地元数据读取。
- Compose 的 `../data` 保持不变,实际挂载为仓库根 `data/` 到 `/app/data`;重启前后
`sharing.json` 的 SHA-256 一致。Vite 开发服务验证前端模块、根共享模块及 API 代理可访问。
- 本轮未做浏览器视觉验收。Docker 安装时完整依赖集报告 4 项 audit 问题,运行时依赖报告 0 项;
本轮未调整依赖版本。原型与本地元数据服务边界不变,readiness 仍报告 `productionReady: false`。
### 2026-10-01:本地目录与 GoodBuddy 联调
- 新增五个本地共享 HTTP 接口,覆盖设备注册和查询、发布查询和创建、撤销。JSON 文件保存真实
记录,重启后仍可查询;API 的输入限制、错误与部署范围见技术设计第 4.1 节。
- GoodBuddy 应用中心新增设备共享入口,通过生产页面、preload、可信 IPC 与 Main HTTP 服务
操作本地目录,持久化服务配置和本机 ID。两仓分别构建,没有源码跨仓依赖。
- Web 共享中心新增“本地服务目录”,提供真实设备与发布记录、组合筛选、错误重试和刷新;
既有组织示例保留。注册时间不解释为在线状态,来源及知识权限按声明显示。
- ShareServer `npm test`:5 个文件、51 个测试通过;`npm run typecheck`、`npm run lint`、
`npm run build` 和 `git diff --check` 通过。服务测试包含真实 HTTP 和独立进程重启持久化。
- Web 使用隔离临时数据及真实 API 完成发布、撤销、筛选、重载、刷新和空目录验证;注入 503 后
重试通过。Edge/Playwright 检查 1440px 桌面和 390px 手机深浅主题,无横向溢出或脚本错误。
- GoodBuddy 真实 Electron 组件组合联调通过,覆盖应用中心入口、注册、知识独立权限、发布、
撤销、刷新、设备 ID 重读及 480px 深浅主题。未验收完整生产 App、安装包或跨平台原生窗口。
定向检查与全仓测试差异见[桌面验证记录](../../../../goodbuddy/docs/features/device-sharing/progress.md#2026-10-01桌面实现轮次的独立验证):
全仓测试曾有两项失败,修正配置样例及单独复跑后通过,未将全仓结果记为一次全绿。
本轮完成本地元数据流程;设备认证、组织归属、能力执行、知识正文检索和同步、模型转发、
OnlyOffice 编辑与真实配置下发仍待实现。测试使用独立端口和临时目录,不修改既有服务数据。
本次中文文档经过 deai-writing 扫描,无阻断项;枚举、权限边界和历史验证限制按原义保留。
最终本地预览在 `http://127.0.0.1:8787` 启动,readiness 返回本地 JSON 元数据模式。
浏览器打开 `/capabilities?view=local` 正常读取空目录,显示 0 台设备和 0 项发布,未填入测试样例。
### 2026-09-30:共享中心交互原型
- 既有 `/capabilities` 扩展为共享中心,提供能力目录、共享知识库、按用户查看、按设备查看。
本地示例包含 3 项能力和 4 项知识,关联现有成员与设备;搜索、筛选、详情及主体定位由 URL 保存。
- 知识详情分别展示来源模式、可用性及搜索/读取原文/下载权限,覆盖离线服务器副本、离线
远程取用和仅元数据可见场景。内容操作均未接入,管理员身份不增加内容权限。
- `/devices` 详情可跳转对应设备共享视图,内容详情可跳转发布用户与提供设备。修复顶栏搜索
按钮在手机宽度下无法收缩造成的横向溢出,保留本轮之前的服务演示改动。
- `npm test` 通过 3 个文件、16 个用例,其中新增 7 个共享中心用例;`npm run lint`、
`npm run build` 通过,构建包含前后端类型检查。测试覆盖离线语义、独立权限、组合筛选与重置、
URL 恢复、用户/设备跳转、既有设备入口、键盘页签及无效详情链接。
- 构建后使用现有 8791 服务完成 HTTP 冒烟和 Edge 浏览器检查。1440×1000、390×844 的深浅
主题均无页面横向溢出,详情在视口内;设备详情到共享设备、知识详情及发布用户的跳转通过,
无页面脚本错误。截图复核了桌面深色列表、手机浅色列表和手机深色详情;现有服务保持运行。
- 中文文档经 deai-writing 扫描与本次修改范围复核;无阻断项,字段枚举和权限限制按原义保留。
当前没有新增内容 API、真实同步、内容检索、读取、下载或生产授权,也未进行真实设备集成验证。
### 2026-09-30:GoodBuddy 服务联动演示
- 新增模型网关、文档与协作、配置下发三个管理员页面,并在导航和概览中集中展示入口。
模型网关只转发其他模型服务,不提供模型托管或推理。
- 网关演示支持添加上游、启停、模型映射、用户组策略和模拟调用审计。停用上游或限制用户组
后,相应模型无法用于配置发布。
- 文档演示支持本人将个人文档转交组织、删除至回收站、恢复,以及 OnlyOffice 实例配置与
预设健康检查。转交后个人删除权失效,组织文档删除与恢复依赖显式模拟角色;不展示个人正文。
- 配置演示支持草稿、按组发布、在线设备模拟生效、离线待下发和回退版本及默认模型。
三页共享内存状态,跨页导航保留,刷新重置;没有真实转发、文件存储、Office 编辑或客户端下发。
- 修复既有列表页切换路由时沿用上一页数据分类的问题。前端测试显式清理组件,避免测试间
留存的对话框和路由影响后续用例。
- `npm test` 通过 2 个文件、9 个用例;类型检查、lint 和前后端生产构建通过。
新增用例覆盖跨页数据、网关停用阻止发布、离线等待、版本与模型回退、归属与删除权限、
回收恢复、拒绝审计和成员菜单范围。
- 构建服务在回环地址通过浏览器加载验证;实际操作验证了文档转交和 OnlyOffice 模拟检查,
截图复核了折叠导航布局及深浅主题。尚未完成指定桌面/手机尺寸的完整视觉回归,
也未进行真实 GoodBuddy、上游模型或 OnlyOffice 集成测试。
### 2026-09-09:Office 编辑目标设计
@@ -58,7 +133,7 @@ ShareServer 的实现与验证在后续功能开发中单独推进;真实 Desk
- 成员自助覆盖自己的设备、授权和申请;角色切换后导航和 URL 范围同步变化。
- 控制台支持深浅主题、组织范围、全局搜索、列表搜索、状态筛选、详情、表单、确认、成功/
失败反馈、响应式侧栏和键盘对话框。
- 构建后的 Web 由 Hono 同源托管;`Dockerfile` 使用多阶段构建和非 root 运行用户。
- 构建后的 Web 由 Hono 同源托管;`docker/Dockerfile` 使用多阶段构建和非 root 运行用户。
- readiness 明确返回 `interactive-prototype` 与 `productionReady=false`,避免把 fixture
误认为生产服务。
@@ -96,7 +171,7 @@ npm run build # typecheck + Vite Web + server TypeScript passed
8. `FR-15` 至 `FR-18` 本地空间共享关联、资料定期/按需同步、实时消息、显式处理请求和共享来源授权;
对照用户故事完成服务端契约与 Desktop 集成,不能把静态 demo 当作真实消息服务。
当前尚未满足 PRD 的产品验收,也未进行真实 Desktop/ShareServer 集成测试。
当前尚未满足 PRD 的组织产品验收;本地元数据范围的真实 Desktop/ShareServer 联调记录见上文。
2026-09-05 PRD `FR-13` 新增组织数字人发布和 `@数字人` 调用。当前交互原型尚无“数字人”
目录、发布管理、提及选择器或 Agent 记忆边界展示,因此这些能力仍属于剩余生产与原型工作,
+100 -11
View File
@@ -19,11 +19,22 @@ ShareServer 在独立的 `goodbuddy-shareserver` 仓库根目录维护。当前
goodbuddy-shareserver/
README.md
package.json
server/ # Hono API 与同源静态资源服务
server/
src/ # Hono API、测试与同源静态资源服务
tsconfig.json
shared/ # 原型 fixture 与类型
src/ # React Web 控制台
web/
src/ # React Web 控制台与测试
index.html
vite.config.ts
tsconfig.json
vitest.config.ts # Web 与 API 测试统一入口
tsconfig.json # 引用 Web 与服务端配置
dist/ # 生成产物,不提交
web/
server/
docs/features/share-server/
Dockerfile
docker/ # Dockerfile、Compose 与构建忽略规则
```
以下是后续生产实现的建议模块结构,尚未创建,不代表已实现:
@@ -32,7 +43,7 @@ goodbuddy-shareserver/
goodbuddy-shareserver/
README.md
package.json
src/
server/src/
api/
auth/
organizations/
@@ -52,9 +63,15 @@ goodbuddy-shareserver/
tests/
```
仓库根目录是可独立运行的全控制台交互原型。原型使用 Hono 提供健康、就绪、
界面快照和无持久化操作确认 API,并同源托管 React/Vite Web 控制台;它不依赖 Electron 的
`src/shared`。当前尚未建立生产数据库、认证、设备连接和共享协议,因此也不提前创建空的
依赖与命令统一由根 `package.json` 管理,不拆分 workspace。Vite 输出到 `dist/web/`;
服务端 TypeScript 以仓库根为 `rootDir`,输出到 `dist/server/`,因此启动入口为
`dist/server/server/src/index.js`,共享模块位于 `dist/server/shared/`。根目录运行
`npm start` 或容器启动后,Hono 同源托管 `dist/web/`。Docker 配置仍在 `docker/`,
Compose 的 `../data` 仍映射仓库根 `data/` 到容器 `/app/data`。
仓库根目录包含全控制台交互原型和本地共享目录服务。Hono 提供健康、就绪、
界面快照、原型操作确认与真实元数据 API,并同源托管 React/Vite Web 控制台;它不依赖 Electron 的
`src/shared`。本地目录使用 JSON 文件持久化,契约见第 4.1 节。当前尚未建立生产数据库、认证、设备控制连接,因此也不提前创建空的
`packages/share-protocol`。进入真实 Desktop 协议实现时,服务端和 Desktop 共同依赖
独立发布的 `share-protocol` 协议包,具体归属在真实协议实现时确定;
ShareServer 仍不得导入 Electron 的 `src/shared`,也不得依赖相邻桌面仓库构建。
@@ -112,8 +129,49 @@ audience,并绑定单个 `DocumentEditSession`。
所有请求经 schema、认证、organization scope、授权、速率和大小边界。列表使用游标分页和固定
最大页;错误返回 request ID、稳定 code 和有界参数。
### 4.1 本地共享目录契约(已实现)
此接口供同机 GoodBuddy 与 Web Demo 联调,默认监听 `127.0.0.1:8787`。没有登录、组织隔离
或设备认证;可访问接口的客户端均可修改目录。设备 ID 仅标识记录,客户端只显示本机撤销按钮
不构成服务端所有权校验。`productionReady` 保持 `false`。上文 audience、组织授权及 WebSocket
规则是后续组织服务目标,不由这些本地接口实现。
| 方法与路径 | 输入 | 成功响应 |
| --- | --- | --- |
| `POST /api/v1/sharing/devices/register` | `id,name,platform,appVersion` | Device |
| `GET /api/v1/sharing/devices` | 无 | `{devices: Device[]}` |
| `GET /api/v1/sharing/publications` | 可选查询参数 `deviceId` | `{publications: Publication[]}` |
| `POST /api/v1/sharing/publications` | PublicationInput | Publication |
| `POST /api/v1/sharing/publications/:id/revoke` | 路径 ID | Publication |
成功状态码为 200。Device 增加 `registeredAt,updatedAt`,均为服务端 ISO UTC 时间。
PublicationInput 包含 `id,deviceId,name,kind,description,sourceMode,permissions`;
`kind` 为 `capability|knowledge`,`sourceMode` 为 `device|server`,`permissions` 包含
独立布尔字段 `search,read,download`。Publication 增加 `status: published|revoked` 及
`createdAt,updatedAt`。来源和权限目前只保存声明,不传输正文、不执行授权或能力调用。
源码类型见 `shared/sharing.ts`,状态规则见[本地目录逻辑](./logic-design.md#本地目录首期状态规则)。
创建与注册要求 `application/json`,不接收额外字段。ID 长度 1–128,首位为 ASCII 字母或数字,
其余允许字母、数字、点、下划线、连字符。名称最多 200 字符,平台与版本最多 64 字符,描述
最多 4000 字符且可为空。请求体上限 32768 字节。
错误响应为 `{code,message,requestId}`:输入或 JSON 无效返回 400,设备或条目不存在返回 404,
发布 ID 内容冲突返回 409,超限返回 413,媒体类型错误返回 415,存储故障返回 503。
未知 API 路径返回 JSON 404,不进入 SPA 回退。列表暂不分页,适用于本地小规模验证。
`SHARESERVER_DATA_DIR` 指定数据目录,默认 `./data`;其中 `sharing.json` 保存设备和发布记录。
每个目录只供一个进程中的一个 Store 使用。读写排队,写入临时文件并同步后 rename,写失败不更新
内存。坏文件返回存储错误,不自动清空。服务重启重新加载文件;就绪接口不验证存储是否可写。
生产组织服务仍计划使用 PostgreSQL,当前没有文件到 PostgreSQL 的迁移实现。
GoodBuddy 通过 `页面 → preload → 可信 IPC → Main HTTP 服务` 调用,Main 保存稳定设备 ID、
服务地址与设备名称,并设置请求超时。Web 同源读取目录,不保存桌面设备凭据。
两仓独立构建;桌面实现与验证见[设备共享文档](../../../../goodbuddy/docs/features/device-sharing/README.md)。
## 5. 数据模型
下列数据库模型属于组织服务目标设计。本地目录首期使用第 4.1 节的文件存储,不将本地设备 ID 当作组织身份。
核心表建议为:
```text
@@ -225,7 +283,15 @@ Release catalog 使用独立签名域。服务器不能重打包已签名 payloa
### 10.1 部署边界
ShareServer 集成 ONLYOFFICE Docs Developer Edition,但不把 Document Server 代码并入 Hono
文档以 ShareServer 为主存储,GoodBuddy 承担日常操作。个人创建者可删除或转移给组织,转移
后删除按组织权限判断;管理元数据可见性不授予个人正文访问权。服务端主文档与编辑会话分开
管理,主文档纳入备份及自身删除/保留规则,不随会话过期清理。
第 10.2、10.3 节既有上传、检查点下载和 `source-commit` 契约描述显式的本地来源导入/回写
模式,继续保留 Desktop/SSH 源文件基准校验和冲突处理。服务端主存模式不要求本地回写确认;
其具体存取接口未在本次演示中实现,不由会话 API 推定已经具备文档持久化。
目标设计集成 ONLYOFFICE Docs Developer Edition,但不把 Document Server 代码并入 Hono
进程。生产部署使用独立容器和内部网络:
```text
@@ -281,6 +347,7 @@ ShareServer 负责中继有界事件,不持久化选区正文、Prompt 或模
### 10.5 生命周期和清理
- 会话、工作副本、checkpoint、启动 Cookie 和对象 token 分别设置明确期限。
- 服务端主文档独立保留,清理临时会话对象不得连带删除主文档。
- 关闭、撤销或到期立即拒绝新读取、保存和 AI 操作。
- 清理使用对象标签和数据库状态,重复执行保持幂等,不根据用户文件名构造删除前缀。
- 法定保留只能延长明确匹配的工作副本、checkpoint 或审计元数据,不能恢复访问授权。
@@ -334,7 +401,7 @@ Office 配置包括启用状态、Document Server 内部地址、公开编辑 or
- 指标覆盖请求、WebSocket、授权结果、任务状态、中继、Package、Office 会话/callback 和联邦,
不包含能力正文、文档正文或选区。
- tracing 跨内部服务传播关联 ID,跨联邦只传播双方同意的关联标识。
- 备份 PostgreSQL、Package 对象、策略要求保留的 Office checkpoint 和网关身份;不备份缓存、
- 备份 PostgreSQL、服务端主文档、Package 对象、策略要求保留的 Office checkpoint 和网关身份;不备份缓存、
Web session、nonce、Office 短期 token 或中继暂存。
- 恢复后撤销列表、组织 ID、签名目录和审计序列保持有效,所有设备重新连接。
@@ -382,8 +449,30 @@ Office 配置包括启用状态、Document Server 内部地址、公开编辑 or
`productionReady=false`。原型 fixture、角色切换和操作确认只用于评审信息架构,不得被
Desktop 或部署脚本当作真实身份、策略、任务或审计 API。下一实施阶段必须从 FR-1
初始化、本地身份、PostgreSQL 组织作用域和事务审计开始,不在 fixture 上叠加生产状态。
当前原型没有 Office 导航、配置、会话 API、对象存储、ONLYOFFICE、callback 或 Desktop 文档
Tab 集成;上述章节仅定义目标设计。
当前 `/documents` 已有 OnlyOffice 实例配置与预设检查模拟;Office 会话 API、对象存储、
ONLYOFFICE 编辑、callback、文档保存和 Desktop 文档 Tab 集成仍未实现。
`web/src/app-config.ts` 将 `/model-gateway`、`/documents`、`/distribution` 放入管理员的
“GoodBuddy 服务”分组。`web/src/components/services-demo.tsx` 使用 `ServicesDemo` 的 React
Context 与 `useState` 共享演示状态,跨路由导航保留、刷新重置。服务演示操作不使用 Hono
操作确认 API、不发网络请求、不写本地存储,也不保存凭据;页面角色与权限判断均为内存模拟。
控制台启动仍通过既有快照 API 加载示例数据,此边界仅指三个服务页的模拟操作。
模型网关只模拟上游/映射启用与用户组策略判断及最近 50 条有界调用审计;产品职责是转发
其他模型服务,不托管模型或执行推理。文档仅保存示例名称、归属及回收站标记;转移清除个人
删除权,组织删除/恢复依赖显式模拟角色,恢复保留归属。OnlyOffice 检查使用预设结果。
配置演示保存单个草稿和按组发布历史,发布与回退均重新检查模型可用性;回退取上一条记录并
移除当前末项,恢复版本及默认模型。仅目标组在线示例设备更新 `applied`,离线设备保持原值与
待下发状态。这里没有设备连接、重连投递或持久化版本记录,不能作为真实配置交付证据。
共享中心原型由 `web/src/components/sharing-center.tsx` 和 `web/src/lib/sharing-fixtures.ts` 提供。
条目使用现有快照中的成员、设备 ID 建立关联,本地 fixture 仅含元数据和显式权限标记。
既有 `/capabilities` 路由不变;查询参数 `view=catalog|knowledge|users|devices` 选择页签,
`user`/`device` 定位主体,`item` 打开详情,`q`、`status=ready|unavailable`、
`source=synced|remote`、`audience` 保存筛选。刷新恢复 URL 状态;内容链接保留筛选,
跨主体链接清除旧条件,无效条目链接显示可关闭的错误详情。除启动快照外不新增 API 请求,
不保存正文、不模拟下载;真实授权和同步仍属后续实现。
## 17. 工作空间协作的待实现契约
+52 -15
View File
@@ -16,6 +16,15 @@ ShareServer 是独立 Web 产品,不直接复用 Electron 页面壳层实现
## 1. 使用对象
当前本地首期在 `/capabilities?view=local` 提供“本地服务目录”页签,读取真实设备和发布数据。
其余四个共享中心页签保留组织演示。真实页不使用示例组织、用户归属或在线状态解释本地记录。
GoodBuddy 从应用菜单或应用中心进入“设备共享”,完成地址与名称保存、注册、发布、撤销和刷新。
Web 提供设备、类型、状态、关键词筛选,显示注册时间、来源声明及独立知识权限;提供加载、空目录、
无匹配结果、失败重试和刷新状态。Web 不重复提供本机发布表单。权限与来源提示跟随真实目录展示,
不把元数据发布成功写成能力可执行或知识可读取。桌面交互详见
[设备共享 UI 设计](../../../../goodbuddy/docs/features/device-sharing/ui-design.md)。
- 普通成员主要在 GoodBuddy Desktop 协作和使用能力;Web 端管理自己的设备、申请、授权和已发布数字人。
- 组织管理员使用完整组织控制台。
- 系统管理员使用与组织控制台分开的实例管理范围。
@@ -30,16 +39,16 @@ ShareServer 是独立 Web 产品,不直接复用 Electron 页面壳层实现
侧栏:
概览
GoodBuddy 服务:模型网关 | 文档与协作 | 配置下发
组织与成员
设备
能力目录
GoodBuddy 接入
共享中心
数字人
权限策略
审批
发布与更新
网关联邦
任务与中继
Office 编辑
审计
系统设置(仅系统管理员)
```
@@ -81,17 +90,19 @@ GPU-Worker-02 AI Team Linux 离线 8 2 小时前
设备详情包含概览、连接、已发布能力、Grant 和活动。设备状态与 Publication 状态分别显示。
撤销设备使用危险确认,说明新任务、活动任务和重新注册影响。
## 6. 能力目录
## 6. 共享中心
目录以能力为第一列,提供者为可展开信息:
沿用 `/capabilities`,页签依次为“能力目录”“共享知识库”“按用户查看”“按设备查看”。
各内容展示发布者、提供设备、版本、设备在线状态、发布状态及共享对象,名称进入元数据详情。
发布者和设备链接切换到对应分组并清除旧筛选;既有 `/devices` 详情链接定位同一设备的共享视图。
```text
能力 版本 提供者 可见范围 可用性
图像生成 2.1.0 3 个节点 设计组 2/3 在线
```
四个页签支持元数据搜索、可用状态、来源和共享对象筛选,用户/设备视图增加主体选择。
显示当前结果数量、无结果说明及“清除筛选”,重置保留当前页签。页签支持方向键、Home、End,
窄屏横向滚动;筛选和列表随宽度换行,复用本地主题、按钮、状态徽标和详情对话框。
详情包含 manifest、provider、Publication、允许输入、数据政策、调用趋势和最近错误。管理员
看到的是组织元数据,不通过目录读取能力内部数据。
知识库同时显示来源模式与搜索、读取原文、下载三项显式权限。详情解释服务器副本和远程取用
的离线差异,管理员查看元数据不会获得正文权限。当前仅搜索示例元数据;内容按钮禁用并标注
“未接入”,不返回虚构正文或下载成功。能力就绪也不代表获得调用权限。
### 6.1 数字人
@@ -178,9 +189,13 @@ GPU-Worker-02 AI Team Linux 离线 8 2 小时前
时区和字段范围;大导出作为后台任务生成有时效下载。审计详情保持只读,不把原始内部错误、
认证信息或任务正文显示给管理员。
## 13. Office 编辑
## 13. Office 编辑目标设计
Office 编辑页面属于系统管理员的服务配置和组织管理员的策略管理,不提供文档正文浏览器。
Office 编辑管理归入“文档与协作”,属于系统管理员的服务配置和组织管理员的策略管理,不提供
文档正文浏览器。以下是生产目标,当前实例配置与预设检查演示见第 16.1 节。
日常文档操作在 GoodBuddy 中完成。文档以 ShareServer 为主存储;显式选择本地来源导入/回写
时,另行显示检查点与源文件回写状态。关闭会话不表示删除主文档,检查点可用不表示本地源文件已保存。
系统管理员视图显示:
@@ -223,10 +238,32 @@ shadcn/ui 的本地组件模式和 Radix 可访问 primitives,布局参考成
原型已覆盖数字人需求加入前的一级栏目、管理员与成员自助视图切换、组织范围、搜索筛选、
页签、详情、创建表单、危险确认和 390px 窄屏导航。动态发光、网格和网络脉冲只用于运行状态
与品牌氛围;业务表格仍保持克制,状态继续同时使用图标与文字。原型操作会调用无持久化确认
与品牌氛围;业务表格仍保持克制,状态继续同时使用图标与文字。既有 fixture 页面操作会调用无持久化确认
API,并持续标明不会写入生产数据,不能据此宣称真实审批、撤销、策略或任务操作已实现。
`FR-13` 新增的数字人目录、“我的数字人”和 `@` 选择器尚未进入当前原型。
`FR-14` 新增的 Office 编辑导航、配置、健康和会话管理也尚未进入当前原型。
“文档与协作”已提供 OnlyOffice 实例配置和预设健康检查模拟;真实 Office 编辑与会话管理尚未实现。
### 16.1 GoodBuddy 服务演示
侧栏“GoodBuddy 服务”分组提供三个管理员路由。页首持续标注模拟边界:三页共享当前会话
内存,导航保留状态、刷新重置;操作不调用网络或写本地存储。角色选择仅用于评审,不构成
真实授权;未接入模型推理、网关转发、OnlyOffice 编辑保存或设备交付。
| 路由 | 交互与结果 |
| --- | --- |
| `/model-gateway` 模型网关 | “上游服务”“模型映射 / 访问策略”“调用审计”页签;可添加和启停上游,切换映射上游、允许组及启停状态,选择调用组模拟允许/拒绝。最近 50 条审计仅含路由与策略结果,可查看详情,不含请求/响应正文 |
| `/documents` 文档与协作 | “组织文档”“归属元数据”“OnlyOffice 服务器”“回收站”页签;展示名称与归属,无个人正文预览。OnlyOffice 可添加实例、预设健康/失败并累计模拟检查次数,不访问实例或建立编辑会话 |
| `/distribution` 配置下发 | 创建一个配置草稿,选择目标组、当前可用默认模型和三段数字版本;发布前再次检查模型与组策略。按组展示当前/上一版本、设备已应用/目标版本,并可回退到上一条发布记录 |
“打开模拟 GoodBuddy 客户端”仅打开对话框,个人创建者可删除自己的示例文档或确认转移给
当前组织,不启动真实客户端或上传文件。转移后从个人列表移除,原个人删除权失效;显式
勾选模拟“组织文档管理员”才可删除/恢复组织文档。其他成员个人文档不可操作,恢复保留
原归属,演示不提供永久删除。
配置发布与回退仅更新目标组在线示例设备;离线设备保持原已应用版本并标记待下发,无重连
交付模拟。回退同时恢复版本和默认模型;历史模型停用或当前组策略不允许时禁止回退,不能
仅改版本标签。上游及 OnlyOffice 地址表单只接受不含账号、密码、查询参数或片段的 HTTPS
地址,不提供密钥字段,也不验证连接。
## 17. Desktop 工作空间协作目标交互
+2 -2
View File
@@ -10,7 +10,7 @@ export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.{ts,tsx}'],
files: ['web/src/**/*.{ts,tsx}'],
languageOptions: {
globals: {
...globals.browser,
@@ -25,7 +25,7 @@ export default tseslint.config(
}
},
{
files: ['server/**/*.ts'],
files: ['server/src/**/*.ts'],
languageOptions: {
globals: globals.node
}
+6 -6
View File
@@ -5,13 +5,13 @@
"type": "module",
"scripts": {
"dev": "node scripts/dev.mjs",
"dev:api": "node --watch --import tsx 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",
"dev:api": "node --watch --import tsx server/src/index.ts",
"dev:web": "vite --config web/vite.config.ts",
"build": "npm run typecheck && vite build --config web/vite.config.ts && npm run build:server",
"build:server": "node -e \"require('node:fs').rmSync('dist/server',{recursive:true,force:true})\" && tsc -p server/tsconfig.json",
"start": "node dist/server/server/src/index.js",
"test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.server.json",
"typecheck": "tsc --noEmit -p web/tsconfig.json && tsc --noEmit -p server/tsconfig.json",
"lint": "eslint ."
},
"dependencies": {
-75
View File
@@ -1,75 +0,0 @@
import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
import { prototypeSnapshot } from '../shared/prototype-data.js'
const app = new Hono()
app.use('*', logger())
app.use(
'*',
secureHeaders({
contentSecurityPolicy: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"]
},
crossOriginEmbedderPolicy: false
})
)
app.get('/api/v1/public/health', (context) =>
context.json({
status: 'ok',
service: 'shareserver',
version: '0.1.0-prototype.1'
})
)
app.get('/api/v1/public/ready', (context) =>
context.json({
status: 'ready',
mode: 'interactive-prototype',
productionReady: false
})
)
app.get('/api/v1/web/prototype/snapshot', (context) =>
context.json({
...prototypeSnapshot,
instance: {
...prototypeSnapshot.instance,
generatedAt: new Date().toISOString()
}
})
)
app.post('/api/v1/web/prototype/actions', async (context) => {
const body = await context.req.json<{ action?: string; targetId?: string }>().catch(() => null)
if (!body?.action || !body.targetId) {
return context.json(
{
code: 'INVALID_PROTOTYPE_ACTION',
message: '缺少原型操作或目标对象。',
requestId: crypto.randomUUID()
},
400
)
}
return context.json({
accepted: true,
action: body.action,
targetId: body.targetId,
prototypeOnly: true,
requestId: crypto.randomUUID()
})
})
app.use('*', serveStatic({ root: './dist' }))
app.get('*', serveStatic({ path: './dist/index.html' }))
export { app }
+101
View File
@@ -0,0 +1,101 @@
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'
import { sharingRoutes } from './sharing-routes.js'
import { SharingStore } from './sharing-store.js'
export function createApp(store = new SharingStore()) {
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,
sharing: {
mode: 'local-metadata-only',
authentication: 'none',
persistence: 'local-json',
writerScope: 'single-process-single-store-per-data-directory',
sourceMode: 'descriptive-only-no-content-sync',
permissionsEnforced: false,
networkBoundary: 'trusted-local-test-service',
storageChecked: 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.route('/api/v1/sharing', sharingRoutes(store))
app.all('/api/*', (context) => context.json({
code: 'NOT_FOUND', message: 'API route not found.', requestId: crypto.randomUUID()
}, 404))
app.all('/api', (context) => context.json({
code: 'NOT_FOUND', message: 'API route not found.', requestId: crypto.randomUUID()
}, 404))
app.use('*', serveStatic({ root: './dist/web' }))
app.get('*', serveStatic({ path: './dist/web/index.html' }))
return app
}
const app = createApp()
export { app }
+42
View File
@@ -0,0 +1,42 @@
import { randomUUID } from 'node:crypto'
import { Hono, type Context } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { SharingError, SharingStore, validateDevice, validateId, validatePublication } from './sharing-store.js'
async function body(context: Context): Promise<unknown> {
if (context.req.header('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') {
throw new SharingError(415, 'UNSUPPORTED_MEDIA_TYPE', 'Use application/json.')
}
try {
return await context.req.json()
} catch {
throw new SharingError(400, 'INVALID_JSON', 'Request body must be valid JSON.')
}
}
export function sharingRoutes(store: SharingStore) {
const routes = new Hono()
routes.onError((error, context) => {
if (error instanceof SharingError) {
return context.json({ code: error.code, message: error.message, requestId: randomUUID() }, error.status)
}
return context.json({ code: 'STORAGE_UNAVAILABLE', message: 'Sharing metadata storage is unavailable.', requestId: randomUUID() }, 503)
})
routes.use('*', bodyLimit({
maxSize: 32 * 1024,
onError: (context) => context.json({ code: 'PAYLOAD_TOO_LARGE', message: 'Request body exceeds 32768 bytes.', requestId: randomUUID() }, 413)
}))
routes.post('/devices/register', async (context) => context.json(await store.register(validateDevice(await body(context)))))
routes.get('/devices', async (context) => context.json({ devices: await store.devices() }))
routes.get('/publications', async (context) => {
const queries = context.req.queries()
if (Object.keys(queries).some((key) => key !== 'deviceId') || (queries.deviceId && queries.deviceId.length !== 1)) {
throw new SharingError(400, 'INVALID_INPUT', 'Only one optional deviceId query parameter is supported.')
}
const deviceId = queries.deviceId ? validateId(queries.deviceId[0], 'deviceId') : undefined
return context.json({ publications: await store.publications(deviceId) })
})
routes.post('/publications', async (context) => context.json(await store.publish(validatePublication(await body(context)))))
routes.post('/publications/:id/revoke', async (context) => context.json(await store.revoke(validateId(context.req.param('id')))))
return routes
}
+219
View File
@@ -0,0 +1,219 @@
import { randomUUID } from 'node:crypto'
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'
import { resolve } from 'node:path'
import type { Device, DeviceInput, Publication, PublicationInput } from '../../shared/sharing.js'
export class SharingError extends Error {
constructor(public status: 400 | 404 | 409 | 413 | 415, public code: string, message: string) {
super(message)
}
}
function invalid(message: string): never {
throw new SharingError(400, 'INVALID_INPUT', message)
}
function object(value: unknown, keys: string[]): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid('Expected a JSON object.')
const record = value as Record<string, unknown>
if (Object.keys(record).length !== keys.length || keys.some((key) => !Object.hasOwn(record, key))) {
invalid(`Expected exactly these fields: ${keys.join(', ')}.`)
}
return record
}
function text(value: unknown, field: string, max: number, allowEmpty = false): string {
if (typeof value !== 'string' || value.length > max || (!allowEmpty && !value.trim()) ||
Array.from(value).some((character) => {
const code = character.charCodeAt(0)
return code === 127 || (code < 32 && ![9, 10, 13].includes(code))
})) {
invalid(`${field} must be ${allowEmpty ? '0' : '1'}-${max} characters without control characters.`)
}
return value
}
export function validateId(value: unknown, field = 'id'): string {
if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) {
invalid(`${field} must be 1-128 ASCII letters, digits, dots, underscores or hyphens, starting with a letter or digit.`)
}
return value
}
export function validateDevice(value: unknown): DeviceInput {
const input = object(value, ['id', 'name', 'platform', 'appVersion'])
return {
id: validateId(input.id),
name: text(input.name, 'name', 200),
platform: text(input.platform, 'platform', 64),
appVersion: text(input.appVersion, 'appVersion', 64)
}
}
export function validatePublication(value: unknown): PublicationInput {
const input = object(value, ['id', 'deviceId', 'name', 'kind', 'description', 'sourceMode', 'permissions'])
if (input.kind !== 'capability' && input.kind !== 'knowledge') invalid('Invalid kind.')
if (input.sourceMode !== 'device' && input.sourceMode !== 'server') invalid('Invalid sourceMode.')
const permissions = object(input.permissions, ['search', 'read', 'download'])
if (typeof permissions.search !== 'boolean' || typeof permissions.read !== 'boolean' ||
typeof permissions.download !== 'boolean') invalid('Permissions must be booleans.')
return {
id: validateId(input.id),
deviceId: validateId(input.deviceId, 'deviceId'),
name: text(input.name, 'name', 200),
kind: input.kind,
description: text(input.description, 'description', 4000, true),
sourceMode: input.sourceMode,
permissions: { search: permissions.search, read: permissions.read, download: permissions.download }
}
}
interface State {
version: 1
devices: Device[]
publications: Publication[]
}
function timestamp(value: unknown): string {
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString() !== value) throw new Error('Invalid stored timestamp.')
return value
}
function parseState(value: unknown): State {
const state = object(value, ['version', 'devices', 'publications'])
if (state.version !== 1 || !Array.isArray(state.devices) || !Array.isArray(state.publications)) {
throw new Error('Invalid metadata file.')
}
const devices = state.devices.map((value) => {
const { registeredAt, updatedAt, ...input } = object(value, ['id', 'name', 'platform', 'appVersion', 'registeredAt', 'updatedAt'])
return { ...validateDevice(input), registeredAt: timestamp(registeredAt), updatedAt: timestamp(updatedAt) }
})
const publications = state.publications.map((value): Publication => {
const { status, createdAt, updatedAt, ...input } = object(value,
['id', 'deviceId', 'name', 'kind', 'description', 'sourceMode', 'permissions', 'status', 'createdAt', 'updatedAt'])
if (status !== 'published' && status !== 'revoked') throw new Error('Invalid stored status.')
return { ...validatePublication(input), status, createdAt: timestamp(createdAt), updatedAt: timestamp(updatedAt) }
})
const ids = new Set(devices.map((device) => device.id))
if (ids.size !== devices.length || new Set(publications.map((item) => item.id)).size !== publications.length ||
publications.some((item) => !ids.has(item.deviceId))) throw new Error('Invalid metadata references.')
return { version: 1, devices, publications }
}
// One store per data directory in one process. The queue covers reads and mutations.
export class SharingStore {
private state?: State
private queue: Promise<unknown> = Promise.resolve()
private readonly directory: string
private readonly filename: string
constructor(directory = process.env.SHARESERVER_DATA_DIR ?? './data') {
this.directory = resolve(directory)
this.filename = resolve(this.directory, 'sharing.json')
}
private run<T>(operation: () => Promise<T>): Promise<T> {
const result = this.queue.then(operation)
this.queue = result.catch(() => undefined)
return result
}
private async load(): Promise<State> {
if (!this.state) {
try {
this.state = parseState(JSON.parse(await readFile(this.filename, 'utf8')))
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
// Stored corruption is a service failure, never a client validation error.
throw new Error('Unable to load sharing metadata.', { cause: error })
}
this.state = { version: 1, devices: [], publications: [] }
}
}
return this.state
}
private async save(state: State): Promise<void> {
await mkdir(this.directory, { recursive: true })
const temporary = `${this.filename}.${randomUUID()}.tmp`
try {
const file = await open(temporary, 'wx', 0o600)
try {
await file.writeFile(JSON.stringify(state, null, 2) + '\n', 'utf8')
await file.sync()
} finally {
await file.close()
}
await rename(temporary, this.filename)
this.state = state
} finally {
await rm(temporary, { force: true })
}
}
devices(): Promise<Device[]> {
return this.run(async () => structuredClone((await this.load()).devices))
}
publications(deviceId?: string): Promise<Publication[]> {
return this.run(async () => structuredClone((await this.load()).publications.filter(
(item) => deviceId === undefined || item.deviceId === deviceId
)))
}
register(input: DeviceInput): Promise<Device> {
return this.run(async () => {
const state = structuredClone(await this.load())
const index = state.devices.findIndex((item) => item.id === input.id)
const existing = state.devices[index]
if (existing && existing.name === input.name && existing.platform === input.platform &&
existing.appVersion === input.appVersion) return existing
const now = new Date().toISOString()
const device = { ...input, registeredAt: existing?.registeredAt ?? now, updatedAt: now }
if (index < 0) state.devices.push(device)
else state.devices[index] = device
await this.save(state)
return structuredClone(device)
})
}
publish(input: PublicationInput): Promise<Publication> {
return this.run(async () => {
const state = structuredClone(await this.load())
const existing = state.publications.find((item) => item.id === input.id)
if (existing) {
const original: PublicationInput = {
id: existing.id, deviceId: existing.deviceId, name: existing.name, kind: existing.kind,
description: existing.description, sourceMode: existing.sourceMode, permissions: existing.permissions
}
if (JSON.stringify(original) !== JSON.stringify(input)) {
throw new SharingError(409, 'PUBLICATION_ID_CONFLICT', 'Publication ID already has different metadata.')
}
// Retrying creation must never republish a revoked record.
return existing
}
if (!state.devices.some((item) => item.id === input.deviceId)) {
throw new SharingError(404, 'DEVICE_NOT_FOUND', 'Register the device before publishing.')
}
const now = new Date().toISOString()
const publication: Publication = { ...input, status: 'published', createdAt: now, updatedAt: now }
state.publications.push(publication)
await this.save(state)
return structuredClone(publication)
})
}
revoke(id: string): Promise<Publication> {
return this.run(async () => {
const state = structuredClone(await this.load())
const publication = state.publications.find((item) => item.id === id)
if (!publication) throw new SharingError(404, 'PUBLICATION_NOT_FOUND', 'Publication does not exist.')
if (publication.status === 'revoked') return publication
publication.status = 'revoked'
publication.updatedAt = new Date().toISOString()
await this.save(state)
return structuredClone(publication)
})
}
}
+264
View File
@@ -0,0 +1,264 @@
// @vitest-environment node
import { serve } from '@hono/node-server'
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import { mkdtemp, readFile, readdir, rm, writeFile, mkdir } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createApp } from './app.js'
import { SharingStore } from './sharing-store.js'
const device = { id: 'desktop-1', name: 'Local desktop', platform: 'win32', appVersion: '1.0.0' }
const publication = {
id: 'pub-1', deviceId: device.id, name: 'Local knowledge', kind: 'knowledge',
description: 'Metadata only', sourceMode: 'server', permissions: { search: true, read: true, download: false }
}
const prefix = '/api/v1/sharing'
const post = (value: unknown): RequestInit => ({
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value)
})
describe('persistent sharing API', () => {
let directory: string
let app: ReturnType<typeof createApp>
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'shareserver-test-'))
app = createApp(new SharingStore(directory))
})
afterEach(async () => {
await rm(directory, { recursive: true, force: true })
})
it('upserts devices and preserves registration time and exact retry timestamps', async () => {
const first = await app.request(`${prefix}/devices/register`, post(device))
expect(first.status).toBe(200)
const original = await first.json()
expect(original).toEqual({ ...device, registeredAt: expect.any(String), updatedAt: expect.any(String) })
expect(new Date(original.registeredAt).toISOString()).toBe(original.registeredAt)
const retry = await app.request(`${prefix}/devices/register`, post(device))
expect(await retry.json()).toEqual(original)
const update = await app.request(`${prefix}/devices/register`, post({ ...device, name: 'Renamed', appVersion: '2' }))
const updated = await update.json()
expect(updated).toMatchObject({ name: 'Renamed', appVersion: '2', registeredAt: original.registeredAt })
expect(updated.updatedAt >= original.updatedAt).toBe(true)
expect(await (await app.request(`${prefix}/devices`)).json()).toEqual({ devices: [updated] })
})
it('handles publication retries, conflicts, filtering and idempotent revocation across restarts', async () => {
await app.request(`${prefix}/devices/register`, post(device))
const created = await app.request(`${prefix}/publications`, post(publication))
expect(created.status).toBe(200)
const original = await created.json()
expect(original).toEqual({ ...publication, status: 'published', createdAt: expect.any(String), updatedAt: expect.any(String) })
// JSON key order is not part of the retry contract.
const retry = await app.request(`${prefix}/publications`, post({
...publication, permissions: { download: false, read: true, search: true }
}))
expect(await retry.json()).toEqual(original)
for (const change of [{ name: 'Changed' }, { deviceId: 'other' }, { permissions: { search: false, read: true, download: false } }]) {
const conflict = await app.request(`${prefix}/publications`, post({ ...publication, ...change }))
expect(conflict.status).toBe(409)
expect(await conflict.json()).toMatchObject({ code: 'PUBLICATION_ID_CONFLICT' })
}
app = createApp(new SharingStore(directory))
expect(await (await app.request(`${prefix}/publications?deviceId=${device.id}`)).json()).toEqual({ publications: [original] })
expect(await (await app.request(`${prefix}/publications?deviceId=other`)).json()).toEqual({ publications: [] })
const revoked = await (await app.request(`${prefix}/publications/pub-1/revoke`, { method: 'POST' })).json()
expect(revoked).toMatchObject({ status: 'revoked', createdAt: original.createdAt })
app = createApp(new SharingStore(directory))
expect(await (await app.request(`${prefix}/publications/pub-1/revoke`, { method: 'POST' })).json()).toEqual(revoked)
expect(await (await app.request(`${prefix}/publications`, post(publication))).json()).toEqual(revoked)
expect(await (await app.request(`${prefix}/publications`)).json()).toEqual({ publications: [revoked] })
expect(await readdir(directory)).toEqual(['sharing.json'])
})
it('serializes concurrent mutations without losing records, including conflicting ID retries', async () => {
const results = await Promise.all(Array.from({ length: 16 }, (_, index) =>
app.request(`${prefix}/devices/register`, post({ ...device, id: `device-${index}` }))))
expect(results.every((response) => response.status === 200)).toBe(true)
const publishes = await Promise.all(['A', 'B'].map((name) => app.request(`${prefix}/publications`,
post({ ...publication, deviceId: 'device-0', name }))))
expect(publishes.map((response) => response.status).sort()).toEqual([200, 409])
const restarted = createApp(new SharingStore(directory))
const devices = await (await restarted.request(`${prefix}/devices`)).json()
expect(devices.devices).toHaveLength(16)
const publications = await (await restarted.request(`${prefix}/publications`)).json()
expect(publications.publications).toHaveLength(1)
})
it('returns explicit missing-resource errors', async () => {
for (const [path, init, code] of [
[`${prefix}/publications`, post(publication), 'DEVICE_NOT_FOUND'],
[`${prefix}/publications/missing/revoke`, { method: 'POST' }, 'PUBLICATION_NOT_FOUND']
] as const) {
const response = await app.request(path, init)
expect(response.status).toBe(404)
expect(await response.json()).toMatchObject({ code, requestId: expect.any(String) })
}
})
it.each([
null, [], {}, { ...device, id: '../escape' }, { ...device, id: 'x'.repeat(129) },
{ ...device, name: ' ' }, { ...device, name: 'x'.repeat(201) }, { ...device, name: 'bad\u0000name' },
{ ...device, platform: 123 }, { ...device, appVersion: 'x'.repeat(65) }, { ...device, token: 'not-accepted' }
])('rejects invalid device input %j', async (input) => {
const response = await app.request(`${prefix}/devices/register`, post(input))
expect(response.status).toBe(400)
expect(await response.json()).toMatchObject({ code: 'INVALID_INPUT' })
expect(await readdir(directory)).toEqual([])
})
it.each([
{ ...publication, kind: 'file' }, { ...publication, sourceMode: 'cloud' },
{ ...publication, description: 'x'.repeat(4001) }, { ...publication, deviceId: '' },
{ ...publication, permissions: { search: 'true', read: true, download: false } },
{ ...publication, permissions: { search: true, read: true } },
{ ...publication, permissions: { ...publication.permissions, token: 'not-accepted' } },
{ ...publication, status: 'published' }
])('rejects invalid publication input %j', async (input) => {
const response = await app.request(`${prefix}/publications`, post(input))
expect(response.status).toBe(400)
expect(await response.json()).toMatchObject({ code: 'INVALID_INPUT' })
})
it('bounds requests and rejects malformed JSON, media types, and invalid filters', async () => {
for (const [init, status, code] of [
[{ method: 'POST', body: '{}' }, 415, 'UNSUPPORTED_MEDIA_TYPE'],
[{ ...post({}), body: '{' }, 400, 'INVALID_JSON'],
[post({ ...device, name: 'x'.repeat(32768) }), 413, 'PAYLOAD_TOO_LARGE']
] as const) {
const response = await app.request(`${prefix}/devices/register`, init)
expect(response.status).toBe(status)
expect(await response.json()).toMatchObject({ code })
}
for (const query of ['deviceId=', 'deviceId=a&deviceId=b', 'deviceId=..%2Fsecret', 'token=secret']) {
const response = await app.request(`${prefix}/publications?${query}`)
expect(response.status).toBe(400)
}
})
it('does not overwrite corrupt storage or expose filesystem details', async () => {
await writeFile(join(directory, 'sharing.json'), '{broken')
const response = await app.request(`${prefix}/devices/register`, post(device))
expect(response.status).toBe(503)
expect(await response.json()).toMatchObject({ code: 'STORAGE_UNAVAILABLE' })
expect(await readFile(join(directory, 'sharing.json'), 'utf8')).toBe('{broken')
await writeFile(join(directory, 'sharing.json'), JSON.stringify({ version: 1, devices: [], publications: [{ ...publication }] }))
expect((await app.request(`${prefix}/devices`)).status).toBe(503)
})
it('does not commit failed writes in memory and recovers its mutation queue', async () => {
// A directory at the final file path forces the atomic rename to fail.
expect((await app.request(`${prefix}/devices`)).status).toBe(200)
await mkdir(join(directory, 'sharing.json'))
expect((await app.request(`${prefix}/devices/register`, post(device))).status).toBe(503)
expect(await (await app.request(`${prefix}/devices`)).json()).toEqual({ devices: [] })
expect(await readdir(directory)).toEqual(['sharing.json'])
await rm(join(directory, 'sharing.json'), { recursive: true })
expect((await app.request(`${prefix}/devices/register`, post(device))).status).toBe(200)
})
it('uses JSON 404s for unknown API paths and unsupported methods', async () => {
for (const path of ['/api', '/api/missing', `${prefix}/missing`, `${prefix}/devices/register`]) {
const response = await app.request(path)
expect(response.status).toBe(404)
expect(response.headers.get('content-type')).toContain('application/json')
expect(await response.json()).toMatchObject({ code: 'NOT_FOUND' })
}
})
it('serves real HTTP and reloads persisted state after listener replacement', async () => {
async function listen(current: ReturnType<typeof createApp>) {
const server = serve({ fetch: current.fetch, hostname: '127.0.0.1', port: 0 })
await once(server, 'listening')
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing listener address')
return {
url: `http://127.0.0.1:${address.port}`,
close: () => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
}
}
let listener = await listen(app)
try {
const registered = await fetch(`${listener.url}${prefix}/devices/register`, post(device))
expect(registered.status).toBe(200)
await registered.json()
const published = await fetch(`${listener.url}${prefix}/publications`, post(publication))
expect(published.status).toBe(200)
const record = await published.json()
await listener.close()
listener = await listen(createApp(new SharingStore(directory)))
const listed = await fetch(`${listener.url}${prefix}/publications?deviceId=${device.id}`)
expect(await listed.json()).toEqual({ publications: [record] })
const revoked = await fetch(`${listener.url}${prefix}/publications/pub-1/revoke`, { method: 'POST' })
expect(await revoked.json()).toMatchObject({ status: 'revoked' })
const ready = await fetch(`${listener.url}/api/v1/public/ready`)
expect(await ready.json()).toMatchObject({ productionReady: false, sharing: {
authentication: 'none', sourceMode: 'descriptive-only-no-content-sync', permissionsEnforced: false
} })
const missing = await fetch(`${listener.url}/api/unknown`)
expect(missing.status).toBe(404)
expect(await missing.json()).toMatchObject({ code: 'NOT_FOUND' })
} finally {
await listener.close()
}
})
it('persists metadata across actual server process restarts using SHARESERVER_DATA_DIR', async () => {
async function start() {
const child = spawn(process.execPath, ['--import', 'tsx', fileURLToPath(new URL('./index.ts', import.meta.url))], {
env: { ...process.env, SHARESERVER_DATA_DIR: directory, SHARESERVER_HOST: '127.0.0.1', SHARESERVER_PORT: '0' },
stdio: ['ignore', 'pipe', 'pipe']
})
const exited = once(child, 'exit')
const stop = async () => {
if (child.exitCode === null && child.signalCode === null) child.kill()
await exited
}
try {
const url = await new Promise<string>((resolve, reject) => {
let output = ''
const timer = setTimeout(() => reject(new Error('Server startup timed out')), 10000)
child.once('error', (error) => { clearTimeout(timer); reject(error) })
child.once('exit', () => { clearTimeout(timer); reject(new Error(`Server exited during startup: ${output}`)) })
child.stderr.on('data', (chunk) => { output += chunk.toString() })
child.stdout.on('data', (chunk) => {
output += chunk.toString()
const address = output.match(/listening on (http:\/\/127\.0\.0\.1:\d+)/)
if (address) { clearTimeout(timer); resolve(address[1]) }
})
})
return { url, stop }
} catch (error) {
await stop()
throw error
}
}
const first = await start()
let original: unknown
try {
const registered = await fetch(`${first.url}${prefix}/devices/register`, post(device))
expect(registered.status).toBe(200)
await registered.json()
const response = await fetch(`${first.url}${prefix}/publications`, post(publication))
expect(response.status).toBe(200)
original = await response.json()
} finally {
await first.stop()
}
const second = await start()
try {
const response = await fetch(`${second.url}${prefix}/publications`)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ publications: [original] })
const retry = await fetch(`${second.url}${prefix}/publications`, post(publication))
expect(await retry.json()).toEqual(original)
} finally {
await second.stop()
}
}, 30000)
})
@@ -6,10 +6,10 @@
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "build-server",
"rootDir": ".",
"outDir": "../dist/server",
"rootDir": "..",
"types": ["node"]
},
"include": ["server", "shared"],
"exclude": ["server/**/*.test.ts"]
"include": ["src", "../shared"],
"exclude": ["src/**/*.test.ts"]
}
+27
View File
@@ -0,0 +1,27 @@
export interface DeviceInput {
id: string
name: string
platform: string
appVersion: string
}
export interface Device extends DeviceInput {
registeredAt: string
updatedAt: string
}
export interface PublicationInput {
id: string
deviceId: string
name: string
kind: 'capability' | 'knowledge'
description: string
sourceMode: 'device' | 'server'
permissions: { search: boolean; read: boolean; download: boolean }
}
export interface Publication extends PublicationInput {
status: 'published' | 'revoked'
createdAt: string
updatedAt: string
}
-32
View File
@@ -1,32 +0,0 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { prototypeSnapshot } from '../shared/prototype-data'
import App from './App'
describe('ShareServer console', () => {
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => prototypeSnapshot
})
)
window.history.replaceState({}, '', '/')
})
it('renders the organization overview from the API snapshot', async () => {
render(<App />)
expect(await screen.findByRole('heading', { name: '运行概览' })).toBeInTheDocument()
expect(screen.getByText('Nebula ShareServer')).toBeInTheDocument()
expect(screen.getByText('在线设备')).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: '管理控制台' })).toBeInTheDocument()
})
it('opens global search with the keyboard shortcut', async () => {
render(<App />)
await screen.findByRole('heading', { name: '运行概览' })
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }))
expect(await screen.findByRole('dialog', { name: '全局搜索' })).toBeInTheDocument()
})
})
+2 -2
View File
@@ -1,7 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.server.json" }
{ "path": "./web/tsconfig.json" },
{ "path": "./server/tsconfig.json" }
]
}
+11
View File
@@ -0,0 +1,11 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./web/src/test-setup.ts'],
include: ['web/src/**/*.test.{ts,tsx}', 'server/src/**/*.test.ts']
}
})
View File
+111
View File
@@ -0,0 +1,111 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { prototypeSnapshot } from '../../shared/prototype-data'
import App from './App'
describe('ShareServer console', () => {
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => prototypeSnapshot
})
)
window.history.replaceState({}, '', '/')
})
it('renders the organization overview from the API snapshot', async () => {
render(<App />)
expect(await screen.findByRole('heading', { name: '运行概览' })).toBeInTheDocument()
expect(screen.getByText('Nebula ShareServer')).toBeInTheDocument()
expect(screen.getByText('在线设备')).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: '管理控制台' })).toBeInTheDocument()
})
it('opens global search with the keyboard shortcut', async () => {
render(<App />)
await screen.findByRole('heading', { name: '运行概览' })
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }))
expect(await screen.findByRole('dialog', { name: '全局搜索' })).toBeInTheDocument()
})
it('changes list data when navigating between existing console routes', async () => {
render(<App />)
await screen.findByRole('heading', { name: '运行概览' })
fireEvent.click(screen.getByRole('link', { name: 'GoodBuddy 接入' }))
expect(screen.getByText('Jiang-PC')).toBeInTheDocument()
fireEvent.click(screen.getByRole('link', { name: '组织与成员' }))
expect(screen.getByText('jiang@mesalab.internal')).toBeInTheDocument()
expect(screen.queryByText('Jiang-PC')).not.toBeInTheDocument()
})
it('retains gateway state across navigation and blocks publishing against a disabled upstream', async () => {
window.history.replaceState({}, '', '/distribution')
render(<App />)
await screen.findByRole('heading', { name: '配置下发' })
fireEvent.click(screen.getByRole('button', { name: '创建配置草稿' }))
fireEvent.change(screen.getByLabelText('版本'), { target: { value: '1.0.0' } })
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }))
fireEvent.click(screen.getByRole('link', { name: '模型网关' }))
fireEvent.click(screen.getByRole('button', { name: '停用 示例上游' }))
fireEvent.click(screen.getByRole('link', { name: '配置下发' }))
expect(screen.getByRole('button', { name: '模拟发布' })).toBeDisabled()
fireEvent.click(screen.getByRole('link', { name: '模型网关' }))
fireEvent.click(screen.getByRole('button', { name: '启用 示例上游' }))
fireEvent.click(screen.getByRole('link', { name: '配置下发' }))
fireEvent.click(screen.getByRole('button', { name: '模拟发布' }))
expect(screen.getByText('模拟已应用')).toBeInTheDocument()
expect(screen.getByText('离线 · 待下发(模拟)')).toBeInTheDocument()
expect(screen.getByText('已应用:1.0.0 / org-chat · 目标:1.0.0')).toBeInTheDocument()
expect(screen.getByText('已应用:无 · 目标:1.0.0')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '创建配置草稿' }))
fireEvent.change(screen.getByLabelText('版本'), { target: { value: '1.1.0' } })
fireEvent.change(screen.getByLabelText('默认模型'), { target: { value: 'model-code' } })
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }))
fireEvent.click(screen.getByRole('button', { name: '模拟发布' }))
expect(screen.getByText('已应用:1.1.0 / org-code · 目标:1.1.0')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '模拟回退到 1.0.0' }))
expect(screen.getByText('已应用:1.0.0 / org-chat · 目标:1.0.0')).toBeInTheDocument()
expect(screen.getByText('已应用:无 · 目标:1.0.0')).toBeInTheDocument()
})
it('removes personal delete authority after document transfer and preserves ownership through restore', async () => {
window.history.replaceState({}, '', '/documents')
render(<App />)
await screen.findByRole('heading', { name: '文档与协作' })
fireEvent.click(screen.getByRole('button', { name: '打开模拟 GoodBuddy 客户端' }))
fireEvent.click(screen.getByRole('button', { name: '确认转移给组织' }))
const document = screen.getByRole('heading', { name: '我的项目提案.docx' }).closest('article')!
expect(within(document).getByText('归属:组织:MesaLab')).toBeInTheDocument()
expect(within(document).getByRole('button', { name: '移入回收站' })).toBeDisabled()
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.click(within(document).getByRole('button', { name: '移入回收站' }))
fireEvent.click(screen.getByRole('tab', { name: '回收站' }))
expect(screen.getByText('归属:组织:MesaLab')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '恢复文档' }))
fireEvent.click(screen.getByRole('tab', { name: '组织文档' }))
expect(screen.getByRole('heading', { name: '我的项目提案.docx' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '打开模拟 GoodBuddy 客户端' }))
expect(screen.queryByRole('button', { name: '删除我的文档' })).not.toBeInTheDocument()
})
it('records denied gateway calls and hides administration from member navigation', async () => {
window.history.replaceState({}, '', '/model-gateway')
render(<App />)
await screen.findByRole('heading', { name: '模型网关' })
fireEvent.click(screen.getByRole('tab', { name: '模型映射 / 访问策略' }))
fireEvent.change(screen.getByLabelText('模拟调用者用户组'), { target: { value: '设计中心' } })
fireEvent.click(screen.getByRole('button', { name: '模拟调用 org-code' }))
fireEvent.click(screen.getByRole('tab', { name: '调用审计' }))
expect(screen.getByText('模拟拒绝')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /切换到成员自助/ }))
expect(screen.getByRole('heading', { name: '我的设备' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: '模型网关' })).not.toBeInTheDocument()
})
})
+13 -5
View File
@@ -6,12 +6,14 @@ import {
Routes,
useLocation
} from 'react-router-dom'
import type { PrototypeRole, PrototypeSnapshot } from '../shared/prototype-data'
import type { PrototypeRole, PrototypeSnapshot } from '../../shared/prototype-data'
import { pages, visiblePages } from './app-config'
import { AppShell } from './components/app-shell'
import { DataPage } from './components/data-page'
import { SharingCenter } from './components/sharing-center'
import { OverviewPage } from './components/overview-page'
import { SettingsPage } from './components/settings-page'
import { ServiceDemoPage, ServicesDemo } from './components/services-demo'
import { Button } from './components/ui/button'
async function loadSnapshot(): Promise<PrototypeSnapshot> {
@@ -58,10 +60,14 @@ function Console({
element={
page.path === '/' ? (
<OverviewPage snapshot={snapshot} />
) : page.path === '/settings' ? (
) : page.path === '/capabilities' ? (
<SharingCenter snapshot={snapshot} />
) : page.path === '/settings' ? (
<SettingsPage snapshot={snapshot} />
) : (
<DataPage page={page} snapshot={snapshot} />
) : ['/model-gateway', '/documents', '/distribution'].includes(page.path) ? (
<ServiceDemoPage key={page.path} page={page} snapshot={snapshot} />
) : (
<DataPage key={page.path} page={page} snapshot={snapshot} />
)
}
/>
@@ -118,7 +124,9 @@ export default function App() {
return (
<BrowserRouter>
<Console snapshot={snapshot} role={role} onRoleChange={setRole} />
<ServicesDemo>
<Console snapshot={snapshot} role={role} onRoleChange={setRole} />
</ServicesDemo>
</BrowserRouter>
)
}
+38 -10
View File
@@ -4,17 +4,20 @@ import {
Building2,
CircleUserRound,
Cpu,
FileText,
FileKey2,
Gauge,
GitFork,
PackageCheck,
Network,
Send,
ScrollText,
Settings2,
ShieldCheck,
UserRoundCheck
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { PrototypeRole } from '../shared/prototype-data'
import type { PrototypeRole } from '../../shared/prototype-data'
export type PageConfig = {
path: string
@@ -25,6 +28,7 @@ export type PageConfig = {
icon: LucideIcon
role: PrototypeRole | 'all'
primaryAction?: string
group?: string
tabs?: Array<{ label: string; dataKey: string }>
}
@@ -32,10 +36,34 @@ export const pages: PageConfig[] = [
{
path: '/',
title: '运行概览',
description: '需要行动的事项与当前 ShareServer 运行事实。',
description: 'GoodBuddy 组织服务、接入状态与待处理事项。',
icon: Gauge,
role: 'admin'
},
{
path: '/model-gateway',
title: '模型网关',
description: '接入上游模型服务,管理转发、访问策略和调用审计;ShareServer 不运行模型。',
icon: Network,
role: 'admin',
group: 'GoodBuddy 服务'
},
{
path: '/documents',
title: '文档与协作',
description: '文档存储在 ShareServer,在 GoodBuddy 中使用;管理归属、回收站和 OnlyOffice 服务。',
icon: FileText,
role: 'admin',
group: 'GoodBuddy 服务'
},
{
path: '/distribution',
title: '配置下发',
description: '向 GoodBuddy 分配网关模型配置,查看版本、生效情况与回滚结果。',
icon: Send,
role: 'admin',
group: 'GoodBuddy 服务'
},
{
path: '/members',
title: '组织与成员',
@@ -52,8 +80,8 @@ export const pages: PageConfig[] = [
},
{
path: '/devices',
title: '设备',
description: '查看已注册设备、连接状态与合规元数据。',
title: 'GoodBuddy 接入',
description: '管理 GoodBuddy 客户端与设备的注册、连接状态和接入授权。',
dataKey: 'devices',
icon: Cpu,
role: 'admin',
@@ -61,8 +89,8 @@ export const pages: PageConfig[] = [
},
{
path: '/capabilities',
title: '能力目录',
description: '查看显式发布的能力、版本和 Provider 可用性。',
title: '共享中心',
description: '按内容、发布用户或提供设备查看共享能力与知识库。',
dataKey: 'capabilities',
icon: Boxes,
role: 'admin'
@@ -86,7 +114,7 @@ export const pages: PageConfig[] = [
},
{
path: '/releases',
title: '发布与更新',
title: '资源与更新',
description: '管理签名能力包、发布通道、更新策略和撤销。',
dataKey: 'releases',
icon: PackageCheck,
@@ -95,7 +123,7 @@ export const pages: PageConfig[] = [
},
{
path: '/federation',
title: '网关联邦',
title: '网关互联',
description: '管理经双边确认的组织协作边界。',
dataKey: 'federation',
icon: GitFork,
@@ -104,7 +132,7 @@ export const pages: PageConfig[] = [
},
{
path: '/tasks',
title: '任务与中继',
title: '任务中心',
description: '查看任务路由、终态和可选中继容量。',
dataKey: 'tasks',
icon: Activity,
@@ -112,7 +140,7 @@ export const pages: PageConfig[] = [
},
{
path: '/audit',
title: '审计',
title: '操作审计',
description: '按组织查询有界操作元数据,不包含任务正文。',
dataKey: 'audit',
icon: ScrollText,
@@ -11,7 +11,7 @@ import {
} 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 type { PrototypeRole, PrototypeSnapshot } from '../../../shared/prototype-data'
import { visiblePages } from '../app-config'
import { cn } from '../lib/utils'
import { Button } from './ui/button'
@@ -41,7 +41,10 @@ export function AppShell({
const [searchOpen, setSearchOpen] = useState(false)
const [dark, setDark] = useState(true)
const navigate = useNavigate()
const navigation = visiblePages(role)
const navigation = visiblePages(role).map((page) => ({
...page,
group: page.group ?? (role === 'member' ? '成员自助' : page.path === '/' ? '概览' : '组织与运维')
}))
useEffect(() => {
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
@@ -67,7 +70,7 @@ export function AppShell({
{snapshot.instance.name}
</p>
<p className="truncate text-[11px] uppercase tracking-[0.16em] text-cyan-300/70">
Control plane
GoodBuddy 组织服务
</p>
</div>
</div>
@@ -90,11 +93,12 @@ export function AppShell({
{role === 'admin' ? '组织控制台' : '成员自助'}
</p>
<div className="space-y-1">
{navigation.map((item) => {
const Icon = item.icon
return (
<NavLink
key={item.path}
{navigation.map((item, index) => {
const Icon = item.icon
return (
<div key={item.path}>
{index > 0 && item.group !== navigation[index - 1].group ? <p className="px-3 pb-2 pt-4 text-[11px] font-medium text-slate-500">{item.group}</p> : null}
<NavLink
to={item.path}
end={item.path === '/'}
onClick={() => setMobileOpen(false)}
@@ -121,7 +125,8 @@ export function AppShell({
) : null}
</>
)}
</NavLink>
</NavLink>
</div>
)
})}
</div>
@@ -193,7 +198,7 @@ export function AppShell({
<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]"
className="flex h-9 min-w-0 max-w-md flex-1 items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.035] px-3 text-left text-sm text-slate-500 outline-none transition hover:border-white/[0.15] hover:bg-white/[0.055] focus-visible:ring-2 focus-visible:ring-cyan-400/70 sm:flex-initial sm:basis-[360px]"
onClick={() => setSearchOpen(true)}
>
<Search aria-hidden="true" className="size-4" />
@@ -205,7 +210,7 @@ export function AppShell({
<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 ? '切换到浅色主题' : '切换到深色主题'}
@@ -10,7 +10,8 @@ import {
X
} from 'lucide-react'
import { useMemo, useState } from 'react'
import type { PrototypeRecord, PrototypeSnapshot } from '../../shared/prototype-data'
import { Link } from 'react-router-dom'
import type { PrototypeRecord, PrototypeSnapshot } from '../../../shared/prototype-data'
import type { PageConfig } from '../app-config'
import { cn } from '../lib/utils'
import { Button } from './ui/button'
@@ -61,6 +62,11 @@ function DetailPanel({
))}
</dl>
<div className="mt-5 flex flex-wrap justify-end gap-2 border-t border-white/[0.07] pt-5">
{page.path === '/devices' && (
<Link className="rounded-lg p-2 text-sm underline focus-visible:outline-2" to={`/capabilities?view=devices&device=${encodeURIComponent(record.id)}`}>
查看此设备的共享内容
</Link>
)}
{page.path === '/approvals' ? (
<>
<Button variant="danger" onClick={() => onAction('拒绝申请', record)}>
+123
View File
@@ -0,0 +1,123 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { prototypeSnapshot } from '../../../shared/prototype-data'
import type { Device, Publication } from '../../../shared/sharing'
import { SharingCenter } from './sharing-center'
const devices: Device[] = [
{ id: 'real-a', name: '本机工作站', platform: 'win32', appVersion: '1.2.3', registeredAt: '2026-10-01T08:00:00.000Z', updatedAt: '2026-10-01T09:00:00.000Z' },
{ id: 'real-b', name: '资料设备', platform: 'linux', appVersion: '1.2.4', registeredAt: '2026-10-01T08:30:00.000Z', updatedAt: '2026-10-01T08:30:00.000Z' }
]
const publications: Publication[] = [
{ id: 'pub-a', deviceId: 'real-a', name: '本地分析能力', kind: 'capability', description: '处理 CSV 元数据', sourceMode: 'device', status: 'published', permissions: { search: false, read: false, download: false }, createdAt: devices[0].registeredAt, updatedAt: devices[0].updatedAt },
{ id: 'pub-b', deviceId: 'real-b', name: '已撤销资料', kind: 'knowledge', description: '预算说明', sourceMode: 'server', status: 'revoked', permissions: { search: true, read: false, download: true }, createdAt: devices[1].registeredAt, updatedAt: devices[1].updatedAt },
{ id: 'pub-c', deviceId: 'real-a', name: '本地手册', kind: 'knowledge', description: '操作参考', sourceMode: 'device', status: 'published', permissions: { search: false, read: true, download: false }, createdAt: devices[0].registeredAt, updatedAt: devices[0].updatedAt }
]
const response = (data: unknown) => ({ ok: true, json: async () => data })
function mockDirectory(deviceRows = devices, publicationRows = publications) {
return vi.fn().mockImplementation(async (url: string) => {
if (url === '/api/v1/sharing/devices') return response({ devices: deviceRows })
if (url === '/api/v1/sharing/publications') return response({ publications: publicationRows })
throw new Error(`Unexpected request: ${url}`)
})
}
function mount() { return render(<BrowserRouter><SharingCenter snapshot={prototypeSnapshot} /></BrowserRouter>) }
describe('local sharing directory', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/capabilities?view=local')
vi.stubGlobal('fetch', mockDirectory())
})
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
it('loads real metadata, registration times, revoked records and independent permission declarations', async () => {
mount()
expect(screen.getByRole('status')).toHaveTextContent('正在加载')
expect(screen.getByRole('button', { name: '刷新目录' })).toBeDisabled()
await screen.findByRole('heading', { name: '本机工作站' })
expect(fetch).toHaveBeenCalledTimes(2)
for (const resource of ['devices', 'publications']) expect(fetch).toHaveBeenCalledWith(`/api/v1/sharing/${resource}`, { signal: expect.any(AbortSignal) })
const deviceList = screen.getByRole('region', { name: '真实登记设备' })
expect(within(deviceList).getByText('2026-10-01T08:00:00.000Z')).toHaveAttribute('datetime', devices[0].registeredAt)
expect(within(deviceList).queryByText(/在线|离线/)).not.toBeInTheDocument()
const revoked = screen.getByRole('heading', { name: '已撤销资料' }).closest('article')!
for (const text of ['已撤销', '预算说明', '搜索:声明允许', '读取原文:未声明允许', '下载:声明允许']) expect(within(revoked).getByText(text)).toBeInTheDocument()
expect(within(revoked).getByText(/来源声明:服务器/)).toBeInTheDocument()
expect(screen.queryByText(/当前示例访问者/)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /发布|登记|下载/ })).not.toBeInTheDocument()
})
it('combines device, kind, search and status filters, restores URL state and clears no results', async () => {
const app = mount()
await screen.findByRole('heading', { name: '本机工作站' })
fireEvent.change(screen.getByLabelText('登记设备'), { target: { value: 'real-a' } })
fireEvent.change(screen.getByLabelText('共享类型'), { target: { value: 'knowledge' } })
fireEvent.change(screen.getByLabelText('搜索本地元数据'), { target: { value: ' 操作参考 ' } })
expect(screen.getByRole('status')).toHaveTextContent('1 项本地发布记录')
expect(screen.getByRole('heading', { name: '本地手册' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: '本地分析能力' })).not.toBeInTheDocument()
app.unmount()
mount()
await screen.findByRole('heading', { name: '本地手册' })
expect(screen.getByLabelText('登记设备')).toHaveValue('real-a')
expect(screen.getByLabelText('共享类型')).toHaveValue('knowledge')
expect(screen.getByLabelText('搜索本地元数据')).toHaveValue(' 操作参考 ')
fireEvent.change(screen.getByLabelText('发布状态'), { target: { value: 'revoked' } })
expect(screen.getByRole('heading', { name: '没有符合条件的本地记录' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
expect(window.location.search).toBe('?view=local')
expect(screen.getByRole('status')).toHaveTextContent('3 项本地发布记录')
fireEvent.change(screen.getByLabelText('发布状态'), { target: { value: 'revoked' } })
expect(screen.getByRole('status')).toHaveTextContent('1 项本地发布记录')
expect(screen.getByRole('heading', { name: '已撤销资料' })).toBeInTheDocument()
})
it.each(['devices', 'publications'])('shows %s API error context and recovers on retry', async (resource) => {
const fetchMock = mockDirectory()
fetchMock.mockImplementationOnce(async (url: string) => url.endsWith(resource)
? { ok: false, status: 503, json: async () => ({ code: 'STORAGE_UNAVAILABLE', requestId: 'req-local' }) }
: response({ devices }))
if (resource === 'publications') fetchMock.mockImplementationOnce(async () => ({ ok: false, status: 503, json: async () => ({ code: 'STORAGE_UNAVAILABLE', requestId: 'req-local' }) }))
vi.stubGlobal('fetch', fetchMock)
mount()
expect(await screen.findByRole('alert')).toHaveTextContent('HTTP 503')
expect(screen.getByRole('alert')).toHaveTextContent('req-local')
expect(screen.queryByRole('heading', { name: '本地分析能力' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByRole('heading', { name: '本地分析能力' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('handles network failure and an empty directory, then refreshes newly registered data', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
mount()
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to fetch')
vi.stubGlobal('fetch', mockDirectory([], []))
fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText(/尚无登记设备/)).toBeInTheDocument()
expect(screen.getByRole('heading', { name: '尚无发布记录' })).toBeInTheDocument()
vi.stubGlobal('fetch', mockDirectory())
fireEvent.click(screen.getByRole('button', { name: '刷新目录' }))
expect(await screen.findByRole('heading', { name: '本机工作站' })).toBeInTheDocument()
})
it('keeps fixture tabs separate and aborts pending local loads when leaving', async () => {
const signals: AbortSignal[] = []
vi.stubGlobal('fetch', vi.fn().mockImplementation((_url, { signal }) => { signals.push(signal); return new Promise(() => {}) }))
window.history.replaceState({}, '', '/capabilities?view=local&item=k-1')
mount()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '共享知识库' }))
expect(signals).toHaveLength(2)
expect(signals.every((signal) => signal.aborted)).toBe(true)
expect(screen.getByRole('link', { name: '设计规范归档' })).toBeInTheDocument()
expect(screen.queryByText(/此页读取当前服务/)).not.toBeInTheDocument()
vi.stubGlobal('fetch', mockDirectory())
fireEvent.keyDown(screen.getByRole('tab', { name: '共享知识库' }), { key: 'Home' })
expect(screen.getByRole('tab', { name: '本地服务目录' })).toHaveFocus()
expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', 'sharing-tab-local')
await screen.findByRole('heading', { name: '本地分析能力' })
expect(screen.queryByRole('link', { name: '设计规范归档' })).not.toBeInTheDocument()
})
})
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import type { Device, Publication } from '../../../shared/sharing'
import { Button } from './ui/button'
import { StatusBadge } from './ui/status-badge'
const panel = 'space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5'
const muted = 'text-sm leading-6 text-[var(--text-muted)]'
const control = 'mt-1 w-full min-w-0 rounded-lg border border-white/10 bg-[var(--control)] p-2 text-sm focus-visible:outline-2'
type Directory = { devices: Device[]; publications: Publication[] }
type LoadState = { status: 'loading' } | { status: 'error'; message: string } | { status: 'loaded'; data: Directory }
export function LocalSharing() {
const [params, setParams] = useSearchParams()
const [attempt, setAttempt] = useState(0)
const [state, setState] = useState<LoadState>({ status: 'loading' })
const deviceId = params.get('device') ?? ''
const kind = params.get('kind') ?? ''
const status = params.get('status') ?? ''
const query = params.get('q') ?? ''
useEffect(() => {
const controller = new AbortController()
async function load<T>(resource: string, label: string): Promise<T> {
const response = await fetch(`/api/v1/sharing/${resource}`, { signal: controller.signal })
if (!response.ok) {
const error = await response.json().catch(() => null)
throw new Error(`${label}加载失败(HTTP ${response.status})${typeof error?.code === 'string' ? ` · ${error.code}` : ''}${typeof error?.requestId === 'string' ? ` · 请求 ID:${error.requestId}` : ''}`)
}
return response.json() as Promise<T>
}
Promise.all([
load<Pick<Directory, 'devices'>>('devices', '设备'),
load<Pick<Directory, 'publications'>>('publications', '发布记录')
]).then(([devices, publications]) => {
if (!controller.signal.aborted) setState({ status: 'loaded', data: { ...devices, ...publications } })
}).catch((error: unknown) => {
if (!controller.signal.aborted) setState({ status: 'error', message: error instanceof Error ? error.message : '请求失败' })
})
return () => controller.abort()
}, [attempt])
const reload = () => { setState({ status: 'loading' }); setAttempt((value) => value + 1) }
const update = (key: string, value: string) => {
const next = new URLSearchParams(params)
if (value) next.set(key, value)
else next.delete(key)
setParams(next, { replace: true })
}
return <>
<div className={panel}>
<p className={muted}>此页读取当前服务的真实设备和发布元数据,与其他页签的模拟用户、组织数据独立。请在 GoodBuddy 应用中心登记设备、发布或撤销共享内容,再刷新此目录。</p>
<p className={muted}>登记不代表设备在线;已发布不代表可以调用。来源模式仅为声明,服务器未同步正文。知识权限分别记录声明,尚未执行授权校验;此页不搜索正文、读取原文、下载文件或调用能力。</p>
<Button variant="secondary" disabled={state.status === 'loading'} onClick={reload}>刷新目录</Button>
</div>
{state.status === 'loading' && <p role="status" className={muted}>正在加载本地服务目录…</p>}
{state.status === 'error' && <div className={panel}>
<div role="alert"><h2 className="font-semibold">本地服务目录加载失败</h2><p className={`${muted} break-words`}>{state.message}</p><p className={muted}>请确认本地服务可访问后重试。</p></div>
<Button variant="secondary" onClick={reload}>重试</Button>
</div>}
{state.status === 'loaded' && (() => {
const { devices, publications } = state.data
const visible = publications.filter((item) => (!deviceId || item.deviceId === deviceId) && (!kind || item.kind === kind) && (!status || item.status === status) &&
[item.name, item.description, item.id, item.deviceId, devices.find((device) => device.id === item.deviceId)?.name].join(' ').toLowerCase().includes(query.trim().toLowerCase()))
return <>
<div className={panel}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<label className="text-sm">搜索本地元数据<input className={control} value={query} placeholder="名称、描述、设备或标识" onChange={(event) => update('q', event.target.value)} /></label>
<label className="text-sm">登记设备<select className={control} value={deviceId} onChange={(event) => update('device', event.target.value)}><option value="">全部设备</option>{devices.map((device) => <option key={device.id} value={device.id}>{device.name}({device.id})</option>)}{deviceId && !devices.some((device) => device.id === deviceId) && <option value={deviceId}>未找到设备({deviceId})</option>}</select></label>
<label className="text-sm">共享类型<select className={control} value={kind} onChange={(event) => update('kind', event.target.value)}><option value="">全部类型</option><option value="capability">能力</option><option value="knowledge">知识库</option></select></label>
<label className="text-sm">发布状态<select className={control} value={status} onChange={(event) => update('status', event.target.value)}><option value="">全部状态</option><option value="published">已发布</option><option value="revoked">已撤销</option></select></label>
</div>
<div className="flex flex-wrap items-center justify-between gap-3"><p role="status" className={muted}>当前结果:{visible.length} 项本地发布记录</p><Button variant="secondary" onClick={() => setParams({ view: 'local' })}>清除筛选</Button></div>
</div>
<section className={panel} aria-label="真实登记设备">
<h2 className="font-semibold">登记设备 · {devices.length}</h2>
{!devices.length && <p className={muted}>尚无登记设备。请在 GoodBuddy 应用中心连接此服务并登记设备。</p>}
{devices.filter((device) => !deviceId || device.id === deviceId).map((device) => <div key={device.id} className="space-y-1 break-words">
<h3 className="font-semibold">{device.name}</h3>
<p className={muted}>设备标识:{device.id} · 平台:{device.platform} · 应用版本:{device.appVersion}</p>
<p className={muted}>登记时间:<time dateTime={device.registeredAt}>{device.registeredAt}</time></p>
</div>)}
</section>
{!publications.length ? <div className={panel}><h2 className="font-semibold">尚无发布记录</h2><p className={muted}>请在 GoodBuddy 应用中心发布能力或知识库,再刷新目录。</p></div> : !visible.length && <div className={panel}><h2 className="font-semibold">没有符合条件的本地记录</h2><p className={muted}>请调整条件或清除筛选。</p></div>}
{visible.map((item) => <article key={item.id} className={`${panel} break-words`}>
<div className="flex flex-wrap items-center justify-between gap-3"><h2 className="min-w-0 font-semibold text-[var(--text-strong)]">{item.name}</h2><StatusBadge status={item.status === 'revoked' ? '已撤销' : '已发布'} tone={item.status === 'revoked' ? 'warning' : 'neutral'} /></div>
<p className={`${muted} whitespace-pre-wrap`}>{item.description || '未提供描述。'}</p>
<p className={muted}>类型:{item.kind === 'knowledge' ? '知识库' : '能力'} · 提供设备:{devices.find((device) => device.id === item.deviceId)?.name ?? item.deviceId}</p>
<p className={muted}>发布标识:{item.id} · 设备标识:{item.deviceId}</p>
<p className={muted}>来源声明:{item.sourceMode === 'server' ? '服务器' : '设备'}(仅描述来源,不代表内容已同步或可取用)</p>
<p className={muted}>发布时间:<time dateTime={item.createdAt}>{item.createdAt}</time> · 更新时间:<time dateTime={item.updatedAt}>{item.updatedAt}</time></p>
{item.kind === 'knowledge' && <div className="space-y-2"><h3 className="text-sm font-semibold">知识权限声明(各自独立,未执行授权校验)</h3><div className="flex flex-wrap gap-3 text-sm"><span>搜索:{item.permissions.search ? '声明允许' : '未声明允许'}</span><span>读取原文:{item.permissions.read ? '声明允许' : '未声明允许'}</span><span>下载:{item.permissions.download ? '声明允许' : '未声明允许'}</span></div></div>}
</article>)}
</>
})()}
</>
}
@@ -10,7 +10,7 @@ import {
Sparkles
} from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import type { Metric, PrototypeSnapshot } from '../../shared/prototype-data'
import type { Metric, PrototypeSnapshot } from '../../../shared/prototype-data'
import { PageHeader } from './page-header'
import { StatusBadge } from './ui/status-badge'
@@ -53,15 +53,27 @@ export function OverviewPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
<PageHeader
eyebrow={`实例 · ${snapshot.instance.region}`}
title="运行概览"
description={`${snapshot.instance.name} 正在为组织 ${snapshot.instance.organization} 提供控制面服务。`}
description={`用户在 GoodBuddy 中工作,${snapshot.instance.name} 为 ${snapshot.instance.organization} 管理服务与组织资源。`}
action={
<div className="flex items-center gap-2 rounded-xl border border-emerald-400/15 bg-emerald-400/[0.06] px-3 py-2 text-xs text-emerald-200">
<span className="size-1.5 rounded-full bg-emerald-300 shadow-[0_0_10px_rgba(52,211,153,.9)]" />
所有核心服务正常
交互原型 · 示例数据
</div>
}
/>
<section aria-label="GoodBuddy 联动服务" className="mb-6 grid gap-4 md:grid-cols-3">
{[
{ path: '/model-gateway', title: '模型网关', flow: 'GoodBuddy → ShareServer → 上游模型', copy: '管理模型转发和授权,演示允许或拒绝调用及审计记录。' },
{ path: '/documents', title: '文档与协作', flow: 'GoodBuddy → 服务端文档 → OnlyOffice', copy: '管理文档归属与编辑服务,演示个人文档转交组织及回收恢复。' },
{ path: '/distribution', title: '配置下发', flow: '组织配置 → GoodBuddy → 生效回执', copy: '选择网关模型、发布配置,演示在线生效、离线等待和版本回滚。' }
].map((service) => <button key={service.path} onClick={() => navigate(service.path)} className="rounded-2xl border border-white/10 bg-[var(--panel)] p-5 text-left outline-none transition hover:bg-[var(--control)] focus-visible:ring-2 focus-visible:ring-cyan-400/70">
<span className="flex items-center justify-between gap-2 font-semibold text-[var(--text-strong)]">{service.title}<ArrowUpRight className="size-4" /></span>
<span className="mt-3 block text-xs leading-5 text-[var(--text-primary)]">{service.flow}</span>
<span className="mt-2 block text-sm leading-6 text-[var(--text-muted)]">{service.copy}</span>
</button>)}
</section>
<section aria-label="关键指标" className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{snapshot.metrics.map((metric) => (
<button
@@ -118,7 +130,7 @@ export function OverviewPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
<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>
<p className="mt-1 text-xs text-[var(--text-muted)]">连接与路由示例摘要</p>
</div>
<RadioTower className="size-5 text-cyan-300" />
</div>
+336
View File
@@ -0,0 +1,336 @@
import { createContext, useContext, useId, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react'
import type { PrototypeSnapshot } from '../../../shared/prototype-data'
import type { PageConfig } from '../app-config'
import { PageHeader } from './page-header'
import { Button } from './ui/button'
import { Dialog } from './ui/dialog'
import { StatusBadge } from './ui/status-badge'
type Upstream = { id: string; name: string; url: string; enabled: boolean }
type Model = { id: string; upstreamId: string; alias: string; remote: string; enabled: boolean; group: string }
type Audit = { id: string; time: string; model: string; upstream: string; group: string; result: string; allowed: boolean }
type Document = { id: string; name: string; owner: 'self' | 'other' | 'organization'; deleted: boolean }
type Office = { id: string; name: string; url: string; checks: number; healthy: boolean }
type Release = { version: string; modelId: string; modelName: string; group: string }
type Device = { id: string; name: string; group: string; online: boolean; applied: Release | null }
type DemoState = {
upstreams: Upstream[]
models: Model[]
audits: Audit[]
documents: Document[]
offices: Office[]
organizationRole: boolean
draft: Release | null
releases: Record<string, Release[]>
devices: Device[]
}
const groups = ['平台工程', '设计中心']
const control = 'mt-1 w-full min-w-0 rounded-lg border border-white/10 bg-[var(--control)] p-2 text-sm text-[var(--text-primary)] focus-visible:outline-2 focus-visible:outline-current'
const panel = 'space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5'
const muted = 'text-sm leading-6 text-[var(--text-muted)]'
const DemoContext = createContext<{ state: DemoState; setState: Dispatch<SetStateAction<DemoState>> } | null>(null)
function initialState(): DemoState {
return {
upstreams: [{ id: 'upstream-demo', name: '示例上游', url: 'https://gateway.example/v1', enabled: true }],
models: [
{ id: 'model-chat', upstreamId: 'upstream-demo', alias: 'org-chat', remote: 'demo-chat', enabled: true, group: '全部用户组' },
{ id: 'model-code', upstreamId: 'upstream-demo', alias: 'org-code', remote: 'demo-code', enabled: true, group: '平台工程' }
],
audits: [],
documents: [
{ id: 'doc-org', name: '组织入门指南.docx', owner: 'organization', deleted: false },
{ id: 'doc-self', name: '我的项目提案.docx', owner: 'self', deleted: false },
{ id: 'doc-other', name: '其他成员文档(仅归属元数据)', owner: 'other', deleted: false }
],
offices: [{ id: 'office-demo', name: '示例 OnlyOffice', url: 'https://office.example', checks: 0, healthy: true }],
organizationRole: false,
draft: null,
releases: {},
devices: [
{ id: 'device-1', name: '工程工作站(示例)', group: groups[0], online: true, applied: null },
{ id: 'device-2', name: '工程笔记本(示例)', group: groups[0], online: false, applied: null },
{ id: 'device-3', name: '设计工作站(示例)', group: groups[1], online: true, applied: null },
{ id: 'device-4', name: '设计笔记本(示例)', group: groups[1], online: false, applied: null }
]
}
}
// Mount once above the route switch, never inside a keyed route element.
export function ServicesDemo({ children }: { children: ReactNode }) {
const [state, setState] = useState(initialState)
return <DemoContext.Provider value={{ state, setState }}>{children}</DemoContext.Provider>
}
function useDemo() {
const value = useContext(DemoContext)
if (!value) throw new Error('ServiceDemoPage must be rendered inside ServicesDemo')
return value
}
function availableModels(state: DemoState, group?: string) {
return state.models.filter((model) => model.enabled &&
state.upstreams.some((upstream) => upstream.id === model.upstreamId && upstream.enabled) &&
(!group || model.group === '全部用户组' || model.group === group))
}
function Tabs({ labels, children }: { labels: string[]; children: (index: number) => ReactNode }) {
const [active, setActive] = useState(0)
const id = useId()
return <>
<div role="tablist" aria-label="服务分类" className="mb-5 flex max-w-full gap-1 overflow-x-auto rounded-xl border border-white/10 p-1">
{labels.map((label, index) => <button
key={label} id={`${id}-tab-${index}`} role="tab" aria-selected={active === index}
aria-controls={`${id}-panel`} tabIndex={active === index ? 0 : -1}
className={`min-h-10 shrink-0 rounded-lg px-3 text-sm focus-visible:outline-2 focus-visible:outline-current ${active === index ? 'bg-[var(--control)] font-semibold text-[var(--text-strong)]' : 'text-[var(--text-muted)]'}`}
onClick={() => setActive(index)}
onKeyDown={(event) => {
const next = event.key === 'Home' ? 0 : event.key === 'End' ? labels.length - 1 :
event.key === 'ArrowRight' ? (index + 1) % labels.length :
event.key === 'ArrowLeft' ? (index + labels.length - 1) % labels.length : null
if (next === null) return
event.preventDefault()
setActive(next)
event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('[role="tab"]')[next]?.focus()
}}
>{label}</button>)}
</div>
<section id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-tab-${active}`} tabIndex={0} className="space-y-4 outline-offset-4">
{children(active)}
</section>
</>
}
function EndpointDialog({ title, onClose, onSave }: { title: string; onClose: () => void; onSave: (name: string, url: string) => string | void }) {
const [error, setError] = useState('')
return <Dialog open onOpenChange={(open) => { if (!open) onClose() }} title={title} description="仅保存内存中的示例配置,不发送网络请求。请勿输入真实凭据。">
<form className="space-y-4" onSubmit={(event) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
const name = String(data.get('name') ?? '').trim()
let url: URL
try { url = new URL(String(data.get('url') ?? '').trim()) } catch { setError('请输入有效的 HTTPS 地址。'); return }
if (!name || url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
setError('名称不能为空;地址必须使用 HTTPS,且不能包含账号、密码、查询参数或片段。')
return
}
const problem = onSave(name, url.href)
if (problem) setError(problem)
else onClose()
}}>
<label className="block text-sm">名称<input name="name" required maxLength={60} className={control} /></label>
<label className="block text-sm">HTTPS 服务地址<input name="url" type="url" required maxLength={300} placeholder="https://service.example" className={control} /></label>
<p className={muted}>不提供密钥字段,也不会验证连接或保存凭据。</p>
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
<div className="flex flex-wrap justify-end gap-2"><Button variant="ghost" onClick={onClose}>取消</Button><Button type="submit">保存示例配置</Button></div>
</form>
</Dialog>
}
function GatewayDemo() {
const { state, setState } = useDemo()
const [adding, setAdding] = useState(false)
const [selectedAudit, setSelectedAudit] = useState<Audit | null>(null)
const [group, setGroup] = useState(groups[0])
const [message, setMessage] = useState('')
return <>
<p className={`${muted} mb-4`}>ShareServer 不托管模型。此页仅模拟访问策略判断与向上游转发,不运行推理,不发送提示词或真实请求。</p>
{message && <p role="status" className="mb-4 text-sm">{message}</p>}
<Tabs labels={['上游服务', '模型映射 / 访问策略', '调用审计']}>{(tab) => <>
{tab === 0 && <>
<Button onClick={() => setAdding(true)}>添加上游</Button>
{state.upstreams.map((upstream) => <article className={panel} key={upstream.id}>
<div className="flex flex-wrap items-center justify-between gap-3"><h2 className="font-semibold">{upstream.name}</h2><StatusBadge status={upstream.enabled ? '已启用(模拟)' : '已停用'} tone={upstream.enabled ? 'online' : 'offline'} /></div>
<p className={`${muted} break-all`}>{upstream.url}</p>
<Button variant="secondary" onClick={() => setState((current) => ({ ...current, upstreams: current.upstreams.map((item) => item.id === upstream.id ? { ...item, enabled: !item.enabled } : item) }))}>{upstream.enabled ? '停用' : '启用'} {upstream.name}</Button>
</article>)}
</>}
{tab === 1 && <>
<label className="block max-w-sm text-sm">模拟调用者用户组<select className={control} value={group} onChange={(event) => setGroup(event.target.value)}>{groups.map((item) => <option key={item}>{item}</option>)}</select></label>
{state.models.map((model) => <article className={panel} key={model.id}>
<div className="flex flex-wrap items-center justify-between gap-3"><h2 className="break-all font-semibold">{model.alias} → {model.remote}</h2><StatusBadge status={availableModels(state).some((item) => item.id === model.id) ? '映射可用' : '映射不可用'} tone={availableModels(state).some((item) => item.id === model.id) ? 'online' : 'offline'} /></div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="text-sm">上游服务<select className={control} value={model.upstreamId} onChange={(event) => setState((current) => ({ ...current, models: current.models.map((item) => item.id === model.id ? { ...item, upstreamId: event.target.value } : item) }))}>{state.upstreams.map((item) => <option key={item.id} value={item.id}>{item.name}{item.enabled ? '' : '(已停用)'}</option>)}</select></label>
<label className="text-sm">允许访问的用户组<select className={control} value={model.group} onChange={(event) => setState((current) => ({ ...current, models: current.models.map((item) => item.id === model.id ? { ...item, group: event.target.value } : item) }))}>{['全部用户组', ...groups].map((item) => <option key={item}>{item}</option>)}</select></label>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => setState((current) => ({ ...current, models: current.models.map((item) => item.id === model.id ? { ...item, enabled: !item.enabled } : item) }))}>{model.enabled ? '停用映射' : '启用映射'}</Button>
<Button variant="secondary" onClick={() => {
const allowed = availableModels(state, group).some((item) => item.id === model.id)
const audit: Audit = { id: crypto.randomUUID(), time: new Date().toLocaleString('zh-CN'), model: `${model.alias} → ${model.remote}`, upstream: state.upstreams.find((item) => item.id === model.upstreamId)?.name ?? '无上游', group, allowed, result: allowed ? '模拟允许转发,未发送请求' : '模拟拒绝:上游 / 映射停用或用户组不在策略范围' }
setState((current) => ({ ...current, audits: [audit, ...current.audits].slice(0, 50) }))
setMessage(`${model.alias}:${audit.result}。已记录调用审计。`)
}}>模拟调用 {model.alias}</Button>
</div>
</article>)}
</>}
{tab === 2 && <>
<p className={muted}>最近 50 条模拟调用;仅记录路由与策略结果,不记录请求或响应正文。</p>
{!state.audits.length && <p className={panel}>暂无调用。请在模型映射页执行一次模拟调用。</p>}
{state.audits.map((audit) => <article key={audit.id} className={panel}>
<h2 className="font-semibold">{audit.model}</h2><p className={muted}>{audit.time} · {audit.group}</p>
<div className="flex flex-wrap items-center gap-3"><StatusBadge status={audit.allowed ? '模拟允许' : '模拟拒绝'} tone={audit.allowed ? 'online' : 'danger'} /><Button variant="secondary" onClick={() => setSelectedAudit(audit)}>查看审计详情</Button></div>
</article>)}
</>}
</>}</Tabs>
{adding && <EndpointDialog title="添加上游服务" onClose={() => setAdding(false)} onSave={(name, url) => {
if (state.upstreams.some((item) => item.name === name || item.url === url)) return '该名称或地址已存在。'
setState((current) => ({ ...current, upstreams: [...current.upstreams, { id: crypto.randomUUID(), name, url, enabled: true }] }))
setMessage('已添加示例上游。可在模型映射页将模型关联到此服务。')
}} />}
<Dialog open={Boolean(selectedAudit)} onOpenChange={(open) => { if (!open) setSelectedAudit(null) }} title="调用审计详情" description="模拟事实,不代表真实转发或上游可用性。">
{selectedAudit && <dl className="space-y-3 text-sm">{Object.entries({ 编号: selectedAudit.id, 时间: selectedAudit.time, 模型映射: selectedAudit.model, 上游: selectedAudit.upstream, 调用组: selectedAudit.group, 策略结果: selectedAudit.result }).map(([label, value]) => <div key={label}><dt className={muted}>{label}</dt><dd className="break-words">{value}</dd></div>)}</dl>}
</Dialog>
</>
}
function DocumentsDemo({ organization }: { organization: string }) {
const { state, setState } = useDemo()
const [adding, setAdding] = useState(false)
const [transfer, setTransfer] = useState(false)
const [message, setMessage] = useState('')
const personal = state.documents.filter((item) => item.owner === 'self' && !item.deleted)
const canDelete = (document: Document) => document.owner === 'organization' ? state.organizationRole : document.owner === 'self'
const owner = (document: Document) => document.owner === 'organization' ? `组织:${organization}` : document.owner === 'self' ? '当前演示用户:林江' : '其他成员'
function setDeleted(document: Document, deleted: boolean) {
setState((current) => ({ ...current, documents: current.documents.map((item) => item.id === document.id &&
(item.owner === 'self' || (item.owner === 'organization' && current.organizationRole)) ? { ...item, deleted } : item) }))
setMessage(`${document.name} 已${deleted ? '移入回收站' : '恢复'}(模拟)。归属保持不变。`)
}
return <>
<div className={`${panel} mb-5`}>
<p className={muted}>当前演示用户:林江。控制台仅展示归属元数据,不读取个人文档正文。下方角色切换仅用于演示,不是实际授权。</p>
<label className="flex items-start gap-2 text-sm"><input type="checkbox" className="mt-1" checked={state.organizationRole} onChange={(event) => setState((current) => ({ ...current, organizationRole: event.target.checked }))} />模拟授予「组织文档管理员」角色(允许删除 / 恢复组织文档)</label>
<Button variant="secondary" onClick={() => setTransfer(true)}>打开模拟 GoodBuddy 客户端</Button>
</div>
{message && <p role="status" className="mb-4 text-sm">{message}</p>}
<Tabs labels={['组织文档', '归属元数据', 'OnlyOffice 服务器', '回收站']}>{(tab) => {
const documents = state.documents.filter((item) => tab === 3 ? item.deleted : !item.deleted && (tab === 1 || item.owner === 'organization'))
return tab === 2 ? <>
<Button onClick={() => setAdding(true)}>添加 OnlyOffice 实例</Button>
<p className={muted}>模拟健康检查只使用下方预设结果,不访问服务器,也不建立编辑会话。</p>
{state.offices.map((office) => <article className={panel} key={office.id}>
<h2 className="font-semibold">{office.name}</h2><p className={`${muted} break-all`}>{office.url}</p>
<StatusBadge status={office.checks ? `模拟${office.healthy ? '健康' : '异常'} · 已检查 ${office.checks} 次` : '尚未模拟检查'} tone={!office.checks ? 'neutral' : office.healthy ? 'online' : 'danger'} />
<label className="block text-sm">预设检查结果<select className={control} value={office.healthy ? 'healthy' : 'failed'} onChange={(event) => setState((current) => ({ ...current, offices: current.offices.map((item) => item.id === office.id ? { ...item, healthy: event.target.value === 'healthy', checks: 0 } : item) }))}><option value="healthy">模拟健康</option><option value="failed">模拟连接失败</option></select></label>
<Button variant="secondary" onClick={() => setState((current) => ({ ...current, offices: current.offices.map((item) => item.id === office.id ? { ...item, checks: item.checks + 1 } : item) }))}>执行模拟健康检查</Button>
</article>)}
</> : <>
{tab === 1 && <p className={muted}>个人文档只展示名称与归属,不提供正文预览。个人删除操作仅在模拟客户端提供。</p>}
{tab === 3 && <p className={muted}>恢复保留原归属与权限;此演示不提供永久删除。</p>}
{!documents.length && <p className={panel}>此分类暂无文档。</p>}
{documents.map((document) => <article key={document.id} className={panel}>
<h2 className="break-words font-semibold">{document.name}</h2><p className={muted}>归属:{owner(document)}</p>
<StatusBadge status={document.deleted ? '回收站' : document.owner === 'organization' ? '组织资产' : '个人资产 · 仅元数据'} tone={document.deleted ? 'warning' : 'neutral'} />
<p className={muted}>{document.owner === 'organization' ? '原个人所有者不再具有个人删除权;仅显式组织角色可删除或恢复。' : '仅个人所有者可在客户端转移或删除。'}</p>
{(document.owner === 'organization' || document.deleted) && <Button variant={document.deleted ? 'secondary' : 'danger'} disabled={!canDelete(document)} onClick={() => setDeleted(document, !document.deleted)}>{document.deleted ? '恢复文档' : '移入回收站'}</Button>}
</article>)}
</>
}}</Tabs>
{adding && <EndpointDialog title="添加 OnlyOffice 实例" onClose={() => setAdding(false)} onSave={(name, url) => {
if (state.offices.some((item) => item.name === name || item.url === url)) return '该名称或地址已存在。'
setState((current) => ({ ...current, offices: [...current.offices, { id: crypto.randomUUID(), name, url, checks: 0, healthy: true }] }))
}} />}
<Dialog open={transfer} onOpenChange={setTransfer} title="GoodBuddy 客户端(模拟)" description={`以林江身份操作自己的示例文档;转移目标:${organization}。不会打开真实客户端或上传文件。`}>
<p className={`${muted} mb-4`}>确认转移后,文档所有者变为组织,并从个人列表移除。原个人删除权立即失效;删除或恢复需要显式组织文档管理员角色。</p>
{!personal.length && <p className={muted}>没有可转移的个人文档。已转移的文档请到组织文档查看。</p>}
{personal.map((document) => <article key={document.id} className={panel}>
<h2 className="font-semibold">{document.name}</h2><p className={muted}>所有者:当前演示用户林江;仅展示元数据。</p>
<div className="flex flex-wrap gap-2"><Button onClick={() => {
setState((current) => ({ ...current, documents: current.documents.map((item) => item.id === document.id && item.owner === 'self' && !item.deleted ? { ...item, owner: 'organization' } : item) }))
setTransfer(false)
setMessage('已模拟转移到组织。原个人删除权已移除;组织文档操作取决于显式组织角色。')
}}>确认转移给组织</Button><Button variant="danger" onClick={() => { setDeleted(document, true); setTransfer(false) }}>删除我的文档</Button></div>
</article>)}
<div className="mt-4 flex justify-end"><Button variant="ghost" onClick={() => setTransfer(false)}>关闭客户端模拟</Button></div>
</Dialog>
</>
}
function DistributionDemo() {
const { state, setState } = useDemo()
const [creating, setCreating] = useState(false)
const [group, setGroup] = useState(groups[0])
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const models = availableModels(state, group)
const draftAllowed = state.draft && availableModels(state, state.draft.group).some((model) => model.id === state.draft?.modelId)
function releaseToDevices(current: DemoState, release: Release): Device[] {
return current.devices.map((device) => device.group === release.group && device.online ? { ...device, applied: release } : device)
}
return <div className="space-y-5">
<p className={muted}>仅模拟配置发布,不执行真实设备下发。每个用户组独立保留发布历史;离线设备保持待下发,已应用版本不会被假装更新。</p>
{message && <p role="status" className="text-sm">{message}</p>}
<Button disabled={Boolean(state.draft)} onClick={() => { setError(''); setGroup(groups[0]); setCreating(true) }}>创建配置草稿</Button>
{state.draft && <section className={panel}>
<h2 className="font-semibold">草稿 {state.draft.version}</h2><p className={muted}>{state.draft.group} · 默认模型:{state.draft.modelName}</p>
{!draftAllowed && <p role="alert" className="text-sm text-rose-300">默认模型已停用或不允许目标组访问。请调整网关策略,或丢弃草稿后重新选择。</p>}
<div className="flex flex-wrap gap-2"><Button disabled={!draftAllowed} onClick={() => {
setState((current) => {
const draft = current.draft
if (!draft || !availableModels(current, draft.group).some((item) => item.id === draft.modelId)) return current
return { ...current, draft: null, releases: { ...current.releases, [draft.group]: [...(current.releases[draft.group] ?? []), draft] }, devices: releaseToDevices(current, draft) }
})
setMessage('已模拟发布:目标组在线设备更新,离线设备保持待下发。')
}}>模拟发布</Button><Button variant="ghost" onClick={() => setState((current) => ({ ...current, draft: null }))}>丢弃草稿</Button></div>
</section>}
{groups.map((target) => {
const history = state.releases[target] ?? []
const active = history.at(-1)
const previous = history.at(-2)
const rollbackAllowed = previous && availableModels(state, target).some((item) => item.id === previous.modelId)
return <section key={target} className={panel}>
<h2 className="font-semibold">{target}</h2>
<p className={muted}>当前发布:{active ? `${active.version} · 默认模型 ${active.modelName}` : '尚未发布'}</p>
<p className={muted}>可回退版本:{previous ? `${previous.version} · 默认模型 ${previous.modelName}` : '无历史版本'}</p>
{previous && !rollbackAllowed && <p className="text-sm text-rose-300">历史版本模型不可用或策略不允许,暂不能回退。</p>}
<Button variant="secondary" disabled={!rollbackAllowed} onClick={() => {
setState((current) => {
const versions = current.releases[target] ?? []
const prior = versions.at(-2)
if (!prior || !availableModels(current, target).some((item) => item.id === prior.modelId)) return current
return { ...current, releases: { ...current.releases, [target]: versions.slice(0, -1) }, devices: releaseToDevices(current, prior) }
})
setMessage(`${target} 已模拟回退到 ${previous?.version},同时恢复该版本默认模型;离线设备等待该版本。`)
}}>模拟回退{previous ? `到 ${previous.version}` : ''}</Button>
<ul className="space-y-3">{state.devices.filter((device) => device.group === target).map((device) => <li key={device.id} className="flex flex-col justify-between gap-2 border-t border-white/10 pt-3 sm:flex-row sm:items-center">
<div className="min-w-0"><p className="break-words text-sm font-medium">{device.name} · {device.online ? '在线' : '离线'}</p><p className={muted}>已应用:{device.applied ? `${device.applied.version} / ${device.applied.modelName}` : '无'} · 目标:{active?.version ?? '无'}</p></div>
<StatusBadge status={!active ? '尚未下发' : device.online ? '模拟已应用' : '离线 · 待下发(模拟)'} tone={!active ? 'neutral' : device.online ? 'online' : 'warning'} />
</li>)}</ul>
</section>
})}
<Dialog open={creating} onOpenChange={setCreating} title="创建配置草稿" description="默认模型来自当前启用的网关模型,并受目标组访问策略约束。">
<form className="space-y-4" onSubmit={(event) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
const version = String(data.get('version') ?? '').trim()
const model = models.find((item) => item.id === data.get('model'))
if (!/^\d+\.\d+\.\d+$/.test(version)) { setError('版本格式应为三个数字段,例如 1.0.0。'); return }
if (!model) { setError('请选择目标组可访问的启用模型。'); return }
if (state.releases[group]?.some((item) => item.version === version)) { setError('该用户组的发布历史中已存在此版本。'); return }
setState((current) => ({ ...current, draft: { version, group, modelId: model.id, modelName: model.alias } }))
setCreating(false)
setMessage('草稿已保存到内存,尚未模拟发布。')
}}>
<label className="block text-sm">目标用户组<select value={group} className={control} onChange={(event) => setGroup(event.target.value)}>{groups.map((item) => <option key={item}>{item}</option>)}</select></label>
<label className="block text-sm">默认模型<select key={group} name="model" required className={control} defaultValue={models[0]?.id ?? ''} disabled={!models.length}>{!models.length && <option value="">无可用模型</option>}{models.map((model) => <option key={model.id} value={model.id}>{model.alias}</option>)}</select></label>
{!models.length && <p className={muted}>请先在模型网关启用上游与模型,并允许目标组访问。</p>}
<label className="block text-sm">版本<input name="version" required maxLength={30} placeholder="1.0.0" className={control} /></label>
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
<div className="flex flex-wrap justify-end gap-2"><Button variant="ghost" onClick={() => setCreating(false)}>取消</Button><Button type="submit" disabled={!models.length}>保存草稿</Button></div>
</form>
</Dialog>
</div>
}
export function ServiceDemoPage({ page, snapshot }: { page: PageConfig; snapshot: PrototypeSnapshot }) {
useDemo()
return <div className="min-w-0 text-[var(--text-primary)]">
<PageHeader eyebrow={`组织 · ${snapshot.instance.organization}`} title={page.title} description={page.description} />
<aside className={`${panel} mb-5 text-sm leading-6`} aria-label="原型说明">
交互原型:所有操作均为模拟,仅保存在当前页面会话内存中。三个服务页面间导航保留状态,刷新页面后重置。不调用网络,不写入本地存储,不代表真实授权、健康检查或设备下发。
</aside>
{page.path === '/model-gateway' ? <GatewayDemo /> : page.path === '/documents' ? <DocumentsDemo organization={snapshot.instance.organization} /> : page.path === '/distribution' ? <DistributionDemo /> : <p role="alert">此页面不属于服务演示。</p>}
</div>
}
@@ -11,7 +11,7 @@ import {
} from 'lucide-react'
import { useState } from 'react'
import type { LucideIcon } from 'lucide-react'
import type { PrototypeSnapshot } from '../../shared/prototype-data'
import type { PrototypeSnapshot } from '../../../shared/prototype-data'
import { PageHeader } from './page-header'
import { Button } from './ui/button'
import { StatusBadge } from './ui/status-badge'
+122
View File
@@ -0,0 +1,122 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { prototypeSnapshot } from '../../../shared/prototype-data'
import App from '../App'
import { sharedItems, sharingAvailability } from '../lib/sharing-fixtures'
describe('sharing center', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/capabilities')
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => prototypeSnapshot }))
})
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
it('keeps synced copies available offline while remote retrieval waits for the device', () => {
const synced = sharedItems.find((item) => item.id === 'k-1')!
const remote = sharedItems.find((item) => item.id === 'k-2')!
expect(sharingAvailability(synced, '离线').available).toBe(true)
expect(sharingAvailability(remote, '离线').available).toBe(false)
expect(sharingAvailability(remote, '在线').available).toBe(true)
expect(sharingAvailability(remote, '未知').available).toBe(false)
expect(sharingAvailability({ ...synced, paused: true }, '离线').available).toBe(false)
expect(sharingAvailability(synced, '已撤销').available).toBe(false)
})
it('displays independent permissions and source availability without granting content access to admins', async () => {
window.history.replaceState({}, '', '/capabilities?view=knowledge')
render(<App />)
await screen.findByRole('heading', { name: '共享中心' })
const synced = screen.getByRole('link', { name: '设计规范归档' }).closest('article')!
expect(within(synced).getByText(/设备状态:离线/)).toBeInTheDocument()
expect(within(synced).getByText('服务器副本就绪(示例)')).toBeInTheDocument()
expect(within(synced).getByText('读取原文:允许')).toBeInTheDocument()
expect(within(synced).getByText('下载:未授权')).toBeInTheDocument()
fireEvent.click(screen.getByRole('link', { name: '设计项目素材' }))
const dialog = screen.getByRole('dialog', { name: '设计项目素材详情' })
expect(within(dialog).getByText('搜索:允许')).toBeInTheDocument()
expect(within(dialog).getByText('读取原文:未授权')).toBeInTheDocument()
expect(within(dialog).getByText('下载:允许')).toBeInTheDocument()
expect(within(dialog).getByText('设备离线,远程取用不可用')).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: '下载(未接入)' })).toBeDisabled()
expect(within(dialog).getByText(/管理员身份不会增加这些权限/)).toBeInTheDocument()
expect(fetch).toHaveBeenCalledTimes(1)
})
it('shows metadata-only knowledge even when all content permissions are denied', async () => {
window.history.replaceState({}, '', '/capabilities?view=knowledge&item=k-4')
render(<App />)
const dialog = await screen.findByRole('dialog', { name: '受限研究资料详情' })
for (const action of ['搜索', '读取原文', '下载']) expect(within(dialog).getByText(`${action}:未授权`)).toBeInTheDocument()
for (const button of within(dialog).getAllByRole('button', { name: /未接入/ })) expect(button).toBeDisabled()
})
it('combines filters, recovers from no results, and restores URL state on remount', async () => {
window.history.replaceState({}, '', '/capabilities?view=knowledge')
const app = render(<App />)
await screen.findByRole('heading', { name: '共享中心' })
fireEvent.change(screen.getByLabelText('内容来源'), { target: { value: 'synced' } })
fireEvent.change(screen.getByLabelText('可用状态'), { target: { value: 'ready' } })
fireEvent.change(screen.getByLabelText('共享对象'), { target: { value: '全组织' } })
fireEvent.change(screen.getByLabelText('搜索共享元数据'), { target: { value: 'Design-Mac' } })
expect(screen.getByText('当前结果:1 项共享内容')).toBeInTheDocument()
expect(screen.getByRole('link', { name: '设计规范归档' })).toBeInTheDocument()
app.unmount()
render(<App />)
await screen.findByRole('heading', { name: '共享中心' })
expect(screen.getByLabelText('搜索共享元数据')).toHaveValue('Design-Mac')
expect(screen.getByText('当前结果:1 项共享内容')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('可用状态'), { target: { value: 'unavailable' } })
expect(screen.getByRole('heading', { name: '没有符合条件的结果' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
expect(window.location.search).toBe('?view=knowledge')
expect(screen.getByText('当前结果:4 项共享内容')).toBeInTheDocument()
})
it('navigates from content to publisher and provider with matching details and clears stale filters', async () => {
window.history.replaceState({}, '', '/capabilities?view=knowledge&q=归档&item=k-1')
render(<App />)
const detail = await screen.findByRole('dialog', { name: '设计规范归档详情' })
fireEvent.click(within(detail).getByRole('link', { name: '发布者:陈汐' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByRole('tab', { name: '按用户查看' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByLabelText('发布用户')).toHaveValue('m-2')
expect(screen.getByText('当前结果:3 项共享内容')).toBeInTheDocument()
fireEvent.click(screen.getByRole('link', { name: '图像生成' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('link', { name: '提供设备:GPU-Worker-02' }))
expect(screen.getByLabelText('提供设备')).toHaveValue('d-2')
expect(screen.getByRole('link', { name: '代码安全扫描' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('link', { name: '代码安全扫描' }))
expect(within(screen.getByRole('dialog')).getByText('发布已暂停')).toBeInTheDocument()
})
it('links the existing device detail to its exact sharing view including empty devices', async () => {
window.history.replaceState({}, '', '/devices')
render(<App />)
await screen.findByRole('heading', { name: 'GoodBuddy 接入' })
fireEvent.click(screen.getByRole('button', { name: /Design-Mac-07/ }))
const link = within(screen.getByRole('dialog')).getByRole('link', { name: '查看此设备的共享内容' })
expect(link).toHaveAttribute('href', '/capabilities?view=devices&device=d-3')
fireEvent.click(link)
expect(screen.getByLabelText('提供设备')).toHaveValue('d-3')
expect(screen.getByText('当前结果:2 项共享内容')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('提供设备'), { target: { value: 'd-4' } })
expect(screen.getByRole('heading', { name: 'Legacy-Lab · 已撤销 · 0 项' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: '没有符合条件的结果' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
expect(screen.getByText('当前结果:7 项共享内容')).toBeInTheDocument()
})
it('supports keyboard tabs and keeps invalid detail links recoverable', async () => {
render(<App />)
await screen.findByRole('heading', { name: '共享中心' })
fireEvent.keyDown(screen.getByRole('tab', { name: '能力目录' }), { key: 'ArrowRight' })
expect(screen.getByRole('tab', { name: '共享知识库' })).toHaveFocus()
expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', 'sharing-tab-knowledge')
fireEvent.keyDown(screen.getByRole('tab', { name: '共享知识库' }), { key: 'End' })
expect(screen.getByRole('tab', { name: '按设备查看' })).toHaveFocus()
cleanup()
window.history.replaceState({}, '', '/capabilities?view=knowledge&item=missing')
render(<App />)
expect(await screen.findByRole('dialog', { name: '共享内容不存在' })).toBeInTheDocument()
})
})
+128
View File
@@ -0,0 +1,128 @@
import { Link, useSearchParams } from 'react-router-dom'
import type { PrototypeSnapshot } from '../../../shared/prototype-data'
import { sharedItems, sharingAvailability, type SharedItem } from '../lib/sharing-fixtures'
import { PageHeader } from './page-header'
import { Button } from './ui/button'
import { Dialog } from './ui/dialog'
import { StatusBadge } from './ui/status-badge'
import { LocalSharing } from './local-sharing'
const tabs = [ ['local', '本地服务目录'], ['catalog', '能力目录'], ['knowledge', '共享知识库'], ['users', '按用户查看'], ['devices', '按设备查看'] ] as const
const panel = 'space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5'
const muted = 'text-sm leading-6 text-[var(--text-muted)]'
const control = 'mt-1 w-full min-w-0 rounded-lg border border-white/10 bg-[var(--control)] p-2 text-sm focus-visible:outline-2'
const link = 'rounded text-sm underline underline-offset-4 focus-visible:outline-2'
const sourceLabels = { synced: '服务器同步副本', remote: '远程设备取用' }
export function SharingCenter({ snapshot }: { snapshot: PrototypeSnapshot }) {
const [params, setParams] = useSearchParams()
const view = tabs.some(([key]) => key === params.get('view')) ? params.get('view')! : 'catalog'
const query = params.get('q') ?? ''
const status = params.get('status') ?? ''
const source = params.get('source') ?? ''
const audience = params.get('audience') ?? ''
const scopeKey = view === 'users' ? 'user' : 'device'
const scope = params.get(scopeKey) ?? ''
const entities = snapshot.pages[view === 'users' ? 'members' : 'devices'] ?? []
const device = (item: SharedItem) => snapshot.pages.devices.find((entry) => entry.id === item.device)
const publisher = (item: SharedItem) => snapshot.pages.members.find((entry) => entry.id === item.publisher)
const availability = (item: SharedItem) => sharingAvailability(item, device(item)?.status ?? '未知')
const update = (key: string, value: string) => {
const next = new URLSearchParams(params)
if (value) next.set(key, value)
else next.delete(key)
next.delete('item')
setParams(next, { replace: true })
}
const itemHref = (item: SharedItem) => {
const next = new URLSearchParams(params)
next.set('item', item.id)
return `/capabilities?${next}`
}
const visible = sharedItems.filter((item) => {
if (view === 'catalog' && item.kind !== 'capability') return false
if (view === 'knowledge' && item.kind !== 'knowledge') return false
if (view === 'users' && scope && item.publisher !== scope) return false
if (view === 'devices' && scope && item.device !== scope) return false
return (!query.trim() || [item.name, item.version, publisher(item)?.primary, device(item)?.primary, item.audience].join(' ').toLowerCase().includes(query.trim().toLowerCase())) &&
(!status || (status === 'ready' ? availability(item).available : !availability(item).available)) &&
(!source || item.source === source) && (!audience || item.audience === audience)
})
const selected = sharedItems.find((item) => item.id === params.get('item'))
const grouped = view === 'users' || view === 'devices'
const permissionSummary = (item: SharedItem) => <div className="space-y-2">
<p className={muted}>当前示例访问者的显式权限(独立判断)</p>
<div className="flex flex-wrap gap-3 text-sm">
<span>搜索:{item.permissions.search ? '允许' : '未授权'}</span>
<span>读取原文:{item.permissions.read ? '允许' : '未授权'}</span>
<span>下载:{item.permissions.download ? '允许' : '未授权'}</span>
</div>
</div>
const metadata = (item: SharedItem) => <>
<div className="flex flex-wrap gap-4">
<Link className={link} to={`/capabilities?view=users&user=${item.publisher}`}>发布者:{publisher(item)?.primary ?? item.publisher}</Link>
<Link className={link} to={`/capabilities?view=devices&device=${item.device}`}>提供设备:{device(item)?.primary ?? item.device}</Link>
</div>
<p className={muted}>版本:{item.version} · 设备状态:{device(item)?.status ?? '未知'} · 共享对象:{item.audience}</p>
<p className={muted}>来源:{sourceLabels[item.source]} · 发布状态:{item.paused ? '已暂停' : '已发布'}</p>
{item.kind === 'knowledge' && permissionSummary(item)}
</>
const cards = (items: SharedItem[]) => items.map((item) => <article key={item.id} className={panel}>
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="font-semibold text-[var(--text-strong)]"><Link className={link} to={itemHref(item)}>{item.name}</Link></h3>
<StatusBadge status={availability(item).label} tone={availability(item).available ? 'online' : 'warning'} />
</div>
{metadata(item)}
</article>)
return <>
<PageHeader eyebrow={view === 'local' ? '当前本地服务 · 真实元数据' : `示例组织 · ${snapshot.instance.organization}`} title="共享中心" description={view === 'local' ? '查看当前服务登记的设备与发布记录。其他页签为示例数据。' : '查看示例共享内容、发布用户和提供设备的关联。'} />
<div role="tablist" aria-label="共享中心分类" className="mb-5 flex max-w-full shrink-0 gap-1 overflow-x-auto rounded-xl border border-white/10 p-1">
{tabs.map(([key, label], index) => <button key={key} id={`sharing-tab-${key}`} role="tab" aria-selected={view === key} aria-controls="sharing-panel" tabIndex={view === key ? 0 : -1}
className={`min-h-10 shrink-0 rounded-lg px-3 text-sm focus-visible:outline-2 ${view === key ? 'bg-[var(--control)] font-semibold text-[var(--text-strong)]' : 'text-[var(--text-muted)]'}`}
onClick={() => setParams({ view: key })} onKeyDown={(event) => {
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : event.key === 'ArrowRight' ? (index + 1) % tabs.length : event.key === 'ArrowLeft' ? (index + tabs.length - 1) % tabs.length : null
if (next === null) return
event.preventDefault()
setParams({ view: tabs[next][0] })
document.getElementById(`sharing-tab-${tabs[next][0]}`)?.focus()
}}>{label}</button>)}
</div>
<section id="sharing-panel" role="tabpanel" aria-labelledby={`sharing-tab-${view}`} className="space-y-4">
{view === 'local' ? <LocalSharing /> : <>
<p className={muted}>交互原型:仅展示示例元数据。管理员可见元数据不代表获得内容权限;本页不搜索正文、读取原文、下载文件或调用能力。</p>
<div className={panel}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<label className="text-sm">搜索共享元数据<input className={control} value={query} placeholder="名称、发布者、设备或版本" onChange={(event) => update('q', event.target.value)} /></label>
<label className="text-sm">可用状态<select className={control} value={status} onChange={(event) => update('status', event.target.value)}><option value="">全部状态</option><option value="ready">就绪</option><option value="unavailable">不可用</option></select></label>
<label className="text-sm">内容来源<select className={control} value={source} onChange={(event) => update('source', event.target.value)}><option value="">全部来源</option><option value="synced">服务器同步副本</option><option value="remote">远程设备取用</option></select></label>
<label className="text-sm">共享对象<select className={control} value={audience} onChange={(event) => update('audience', event.target.value)}><option value="">全部对象</option>{[...new Set(sharedItems.map((item) => item.audience))].map((value) => <option key={value}>{value}</option>)}</select></label>
{grouped && <label className="text-sm">{view === 'users' ? '发布用户' : '提供设备'}<select className={control} value={scope} onChange={(event) => update(scopeKey, event.target.value)}><option value="">全部</option>{entities.map((entity) => <option key={entity.id} value={entity.id}>{entity.primary}</option>)}</select></label>}
</div>
<div className="flex flex-wrap items-center justify-between gap-3"><p className={muted} role="status">当前结果:{visible.length} 项共享内容</p><Button variant="secondary" onClick={() => setParams({ view })}>清除筛选</Button></div>
</div>
{grouped ? entities.filter((entity) => !scope || entity.id === scope).map((entity) => {
const items = visible.filter((item) => (view === 'users' ? item.publisher : item.device) === entity.id)
return <section key={entity.id} className="space-y-4" aria-label={`${entity.primary}的共享内容`}>
<h2 className="font-semibold">{entity.primary} · {entity.status} · {items.length} 项</h2>
{cards(items)}
{!items.length && <p className={muted}>当前条件下没有共享内容。</p>}
</section>
}) : cards(visible)}
{!visible.length && <div className={panel}><h2 className="font-semibold">没有符合条件的结果</h2><p className={muted}>请修改关键词或使用“清除筛选”恢复当前页签的全部示例。</p></div>}
</>}
</section>
<Dialog open={view !== 'local' && params.has('item')} onOpenChange={(open) => { if (!open) { const next = new URLSearchParams(params); next.delete('item'); setParams(next) } }} title={selected ? `${selected.name}详情` : '共享内容不存在'} description="仅查看示例元数据,不访问真实内容。">
{selected ? <div className="space-y-4">
{metadata(selected)}
<StatusBadge status={availability(selected).label} tone={availability(selected).available ? 'online' : 'warning'} />
{selected.kind === 'knowledge' && <>
<p className={muted}>{selected.source === 'synced' ? '示例副本已同步到服务器;来源设备离线不影响该版本副本。更新仍需设备重新连接。' : '内容保留在提供设备,取用依赖该设备在线;服务器未保存内容副本。'}</p>
<p className={muted}>授权与来源可用性分别判断,允许搜索不代表允许读取原文或下载。管理员身份不会增加这些权限。</p>
<div className="flex flex-wrap gap-2"><Button disabled variant="secondary">搜索正文(未接入)</Button><Button disabled variant="secondary">读取原文(未接入)</Button><Button disabled variant="secondary">下载(未接入)</Button></div>
</>}
{selected.kind === 'capability' && <p className={muted}>就绪仅表示示例设备在线且发布未暂停,不代表当前访问者获准调用。能力执行未接入。</p>}
</div> : <p className={muted}>该链接没有对应的示例条目,请关闭详情并重新选择。</p>}
</Dialog>
</>
}
@@ -1,5 +1,5 @@
import { CircleCheck, CircleMinus, CircleX, Clock3, Radio } from 'lucide-react'
import type { PrototypeRecord } from '../../../shared/prototype-data'
import type { PrototypeRecord } from '../../../../shared/prototype-data'
import { cn } from '../../lib/utils'
const styles: Record<PrototypeRecord['statusTone'], string> = {
+34
View File
@@ -0,0 +1,34 @@
// Local metadata fixtures only. No document bodies, credentials or content API calls.
export type SharedItem = {
id: string
name: string
kind: 'capability' | 'knowledge'
publisher: string
device: string
version: string
audience: string
paused?: boolean
source: 'remote' | 'synced'
permissions: { search: boolean; read: boolean; download: boolean }
}
export const sharedItems: SharedItem[] = [
{ id: 'c-1', name: '图像生成', kind: 'capability', publisher: 'm-2', device: 'd-2', version: '2.1.0', audience: '设计中心', source: 'remote', permissions: { search: false, read: false, download: false } },
{ id: 'c-2', name: '文档转 Word', kind: 'capability', publisher: 'm-1', device: 'd-1', version: '1.4.0', audience: '全组织', source: 'remote', permissions: { search: false, read: false, download: false } },
{ id: 'c-3', name: '代码安全扫描', kind: 'capability', publisher: 'm-1', device: 'd-2', version: '3.0.2', audience: '平台工程', source: 'remote', paused: true, permissions: { search: false, read: false, download: false } },
{ id: 'k-1', name: '设计规范归档', kind: 'knowledge', publisher: 'm-2', device: 'd-3', version: '2026.09', audience: '全组织', source: 'synced', permissions: { search: true, read: true, download: false } },
{ id: 'k-2', name: '设计项目素材', kind: 'knowledge', publisher: 'm-2', device: 'd-3', version: '1.2.0', audience: '设计中心', source: 'remote', permissions: { search: true, read: false, download: true } },
{ id: 'k-3', name: '工程操作手册', kind: 'knowledge', publisher: 'm-1', device: 'd-1', version: '3.0.0', audience: '平台工程', source: 'remote', permissions: { search: true, read: false, download: false } },
{ id: 'k-4', name: '受限研究资料', kind: 'knowledge', publisher: 'm-1', device: 'd-2', version: '0.8.0', audience: '指定用户:林江', source: 'synced', permissions: { search: false, read: false, download: false } }
]
export function sharingAvailability(item: SharedItem, deviceStatus: string) {
if (item.paused) return { available: false, label: '发布已暂停' }
if (deviceStatus === '已撤销') return { available: false, label: '设备已撤销,发布不可用' }
if (item.kind === 'knowledge' && item.source === 'synced') {
return { available: true, label: '服务器副本就绪(示例)' }
}
return deviceStatus === '在线'
? { available: true, label: '远程设备就绪(示例)' }
: { available: false, label: '设备离线,远程取用不可用' }
}
View File
View File
+1 -1
View File
@@ -17,5 +17,5 @@
"jsx": "react-jsx",
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src", "shared"]
"include": ["src", "../shared"]
}
+7 -6
View File
@@ -1,18 +1,19 @@
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vitest/config'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
export default defineConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
plugins: [react(), tailwindcss()],
build: {
outDir: '../dist/web',
emptyOutDir: true
},
server: {
port: 5174,
proxy: {
'/api': 'http://127.0.0.1:8787'
}
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test-setup.ts'],
exclude: ['build-server/**', 'dist/**', 'node_modules/**']
}
})