feat: add local sharing and containerized web/server layout
This commit is contained in:
@@ -2,3 +2,4 @@ build-server/
|
|||||||
dist/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
*.png
|
*.png
|
||||||
|
data/
|
||||||
|
|||||||
-24
@@ -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"]
|
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
This repository contains the first runnable ShareServer project. It is an
|
This repository contains the first runnable ShareServer project. It is an
|
||||||
interactive control-console prototype based on the
|
interactive control-console prototype based on the
|
||||||
[ShareServer PRD](docs/features/share-server/prd.md), not a production
|
[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
|
## 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
|
`goodbuddy-shareserver` next to each other. Building and running ShareServer
|
||||||
does not require the Desktop checkout.
|
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
|
Source and feature documentation were extracted from GoodBuddy commit
|
||||||
`ee804edcae828066614e03a3b37e6ba389b09323`, from `shareserver/` and
|
`ee804edcae828066614e03a3b37e6ba389b09323`, from `shareserver/` and
|
||||||
`docs/features/share-server/`. Earlier history remains in that repository.
|
`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,
|
- Administrator views for overview, members, devices, capabilities, policies,
|
||||||
approvals, releases, federation, tasks, audit, and settings.
|
approvals, releases, federation, tasks, audit, and settings.
|
||||||
- Member self-service views for personal devices, grants, and requests.
|
- 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,
|
- Responsive navigation, dark and light themes, search, filters, details,
|
||||||
forms, confirmations, and keyboard-accessible dialogs.
|
forms, confirmations, and keyboard-accessible dialogs.
|
||||||
- Production build and a non-root container image.
|
- Production build and a non-root container image.
|
||||||
@@ -44,16 +61,28 @@ business pages.
|
|||||||
|
|
||||||
## Not included yet
|
## Not included yet
|
||||||
|
|
||||||
Prototype records are deliberately read-only fixtures. The action endpoint
|
The existing snapshot records are read-only fixtures; the service demos mutate
|
||||||
returns an acknowledgement but does not persist changes. PostgreSQL,
|
only in-memory examples. The action endpoint returns an acknowledgement but does
|
||||||
|
not persist changes. PostgreSQL,
|
||||||
initialization and login, organization isolation, real authorization, device
|
initialization and login, organization isolation, real authorization, device
|
||||||
WebSocket connections, task routing, relay, package storage, federation, and
|
WebSocket connections, task routing, relay, package storage, federation, and
|
||||||
auditable mutations remain production implementation work. The planned Office
|
auditable mutations remain production implementation work. The planned Office
|
||||||
editing integration also has no current implementation: ONLYOFFICE Docs,
|
editing integration also has no current implementation: ONLYOFFICE Docs,
|
||||||
object storage, document sessions, save callbacks, checkpoints, and Desktop
|
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).
|
[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:
|
The readiness endpoint reports this explicitly:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -75,6 +104,23 @@ npm run dev
|
|||||||
Open `http://127.0.0.1:5174`. Vite proxies `/api` to the Hono service on port
|
Open `http://127.0.0.1:5174`. Vite proxies `/api` to the Hono service on port
|
||||||
`8787`.
|
`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
|
## Run the production build
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -85,6 +131,11 @@ npm start
|
|||||||
|
|
||||||
Open `http://127.0.0.1:8787`.
|
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
|
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
|
`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
|
proxy or container needs another interface. Do not expose the prototype as a
|
||||||
@@ -92,11 +143,43 @@ production service.
|
|||||||
|
|
||||||
## Container
|
## Container
|
||||||
|
|
||||||
|
Container configuration lives in `docker/`. Run these commands from the repository
|
||||||
|
root to build and start the Web console and API together:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
docker build -t goodbuddy-shareserver-prototype .
|
mkdir -p data
|
||||||
docker run --rm -p 8787:8787 goodbuddy-shareserver-prototype
|
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
|
## Validation
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -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
|
node_modules
|
||||||
*.log
|
*.log
|
||||||
*.png
|
*.png
|
||||||
|
.git
|
||||||
|
data
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
@@ -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
|
||||||
@@ -4,6 +4,13 @@ ShareServer 是 GoodBuddy 设备共享网络的独立 Web 服务。它为一个
|
|||||||
目录、能力发布、授权策略、任务路由、可选数据中继、能力包更新、网关联邦、Office 编辑和
|
目录、能力发布、授权策略、任务路由、可选数据中继、能力包更新、网关联邦、Office 编辑和
|
||||||
审计管理。目标范围还包括共享工作空间的实时消息、成员处理请求和资料同步。
|
审计管理。目标范围还包括共享工作空间的实时消息、成员处理请求和资料同步。
|
||||||
|
|
||||||
|
成员的日常操作在 GoodBuddy 中完成,ShareServer 提供组织后台。模型网关向其他模型服务转发
|
||||||
|
请求,不托管模型或运行推理。文档主要存储在 ShareServer;个人创建者可删除自己的文档或将
|
||||||
|
所有权转移给组织,转移后删除由组织权限决定,原创建者不再保留个人删除权。
|
||||||
|
|
||||||
|
Office 区分服务端主存文档与显式的本地来源导入/回写模式。既有 Desktop 源文件、检查点和
|
||||||
|
冲突契约用于后者;编辑会话结束不删除服务端主文档,检查点可用也不等于本地或 SSH 源文件已回写。
|
||||||
|
|
||||||
ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不自动取得设备上的能力、
|
ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不自动取得设备上的能力、
|
||||||
文件、凭据或 SSH Host。
|
文件、凭据或 SSH Host。
|
||||||
|
|
||||||
@@ -21,11 +28,31 @@ ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不
|
|||||||
| [Desktop Office 协同编辑](../../../../goodbuddy/docs/features/office-document-editing/README.md) | 文档 Tab、源文件回写、冲突和 AI 选区编辑(GoodBuddy 仓库) |
|
| [Desktop Office 协同编辑](../../../../goodbuddy/docs/features/office-document-editing/README.md) | 文档 Tab、源文件回写、冲突和 AI 选区编辑(GoodBuddy 仓库) |
|
||||||
|
|
||||||
工作空间协作场景由用户故事追踪;管理员、能力发布者、审计员等既有场景继续由 PRD 与逻辑
|
工作空间协作场景由用户故事追踪;管理员、能力发布者、审计员等既有场景继续由 PRD 与逻辑
|
||||||
设计覆盖。仓库已建立可运行的全控制台交互原型;它用于确认信息架构与交互,不代表生产服务已经
|
设计覆盖。仓库已有全控制台交互原型及本地共享目录服务,后者持久化 GoodBuddy 注册和发布的元数据。
|
||||||
实现。当前边界和运行方法见[实施进度](./progress.md)与
|
组织生产服务尚未实现。当前边界和运行方法见[实施进度](./progress.md)与
|
||||||
[`README.md`](../../../README.md)。跨仓库文档链接要求将 `goodbuddy` 与
|
[`README.md`](../../../README.md)。跨仓库文档链接要求将 `goodbuddy` 与
|
||||||
`goodbuddy-shareserver` 克隆到同一级目录;服务构建和运行不依赖桌面端仓库。
|
`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)。
|
||||||
|
|
||||||
## 角色
|
## 角色
|
||||||
|
|
||||||
- **系统管理员**:维护实例、身份源、域名、证书、存储和全局限制。
|
- **系统管理员**:维护实例、身份源、域名、证书、存储和全局限制。
|
||||||
|
|||||||
@@ -10,6 +10,21 @@
|
|||||||
| 关联 PRD | [ShareServer PRD](./prd.md) |
|
| 关联 PRD | [ShareServer PRD](./prd.md) |
|
||||||
| 共享规则 | [共享网络总体设计](../../../../goodbuddy/docs/architecture/share-network-architecture.md) |
|
| 共享规则 | [共享网络总体设计](../../../../goodbuddy/docs/architecture/share-network-architecture.md) |
|
||||||
|
|
||||||
|
## 本地目录首期状态规则
|
||||||
|
|
||||||
|
当前本地联调仅实现设备与发布元数据,使用[技术设计第 4.1 节](./technical-design.md#41-本地共享目录契约已实现)
|
||||||
|
的独立接口。下文组织身份、Grant、任务和文档归属规则属于目标设计。
|
||||||
|
|
||||||
|
- 注册相同设备 ID 更新名称、平台及版本,保留首次注册时间;相同内容重试不改变时间。
|
||||||
|
- 注册不自动发布内容,也不表示设备在线。设备列表目前没有心跳或离线判断。
|
||||||
|
- 发布必须关联已有设备。新 ID 创建 `published` 条目;相同 ID、相同内容返回已有记录,
|
||||||
|
相同 ID、不同内容报冲突,不覆盖原记录。
|
||||||
|
- 撤销将状态变为 `revoked` 并保留目录记录。重复撤销不改变结果;重试旧发布请求不恢复已撤销记录。
|
||||||
|
- 能力与知识按 `kind` 区分。知识的搜索、原文、下载标志分别存储,不相互推导;
|
||||||
|
`server` 来源也只是发布者声明,不表示文件已经上传或同步。
|
||||||
|
- 当前没有用户主体或组织所有权,因此真实目录按设备查看。按用户查看仍使用组织示例,
|
||||||
|
不把设备名称推定为用户身份。
|
||||||
|
|
||||||
## 1. 核心不变量
|
## 1. 核心不变量
|
||||||
|
|
||||||
### INV-1 组织隔离先于角色
|
### INV-1 组织隔离先于角色
|
||||||
@@ -66,10 +81,16 @@ ShareServer 只路由调用方冻结的 provider,不在离线、繁忙或失
|
|||||||
|
|
||||||
### INV-11 服务端检查点不等于源文件已保存
|
### INV-11 服务端检查点不等于源文件已保存
|
||||||
|
|
||||||
ONLYOFFICE 保存只产生 ShareServer 权威的 `DocumentCheckpoint`。只有 Desktop 或远程
|
在显式的本地来源导入/回写模式中,ONLYOFFICE 保存只产生 ShareServer 权威的 `DocumentCheckpoint`。只有 Desktop 或远程
|
||||||
GoodBuddy Agent 校验源文件基准并完成原子替换后,源文件才是已保存状态。ShareServer 不根据
|
GoodBuddy Agent 校验源文件基准并完成原子替换后,源文件才是已保存状态。ShareServer 不根据
|
||||||
callback 成功推断本机或 SSH 文件已经更新。
|
callback 成功推断本机或 SSH 文件已经更新。
|
||||||
|
|
||||||
|
文档默认以 ShareServer 为主存储,日常操作在 GoodBuddy 中完成。服务端主存模式不要求本地
|
||||||
|
回写确认;主文档生命周期独立于编辑会话,不能因会话关闭或检查点清理而删除主文档。
|
||||||
|
|
||||||
|
个人创建者可删除自己的文档或确认转移所有权给组织。转移后原个人删除权立即失效,删除和
|
||||||
|
恢复按当前组织权限判断;恢复不改变归属。管理身份和归属元数据可见性不授予个人正文访问权。
|
||||||
|
|
||||||
### INV-12 Office 凭据单用途
|
### INV-12 Office 凭据单用途
|
||||||
|
|
||||||
设备 API 凭据、编辑器一次性启动授权、ONLYOFFICE 文件令牌和 callback token 分别绑定不同
|
设备 API 凭据、编辑器一次性启动授权、ONLYOFFICE 文件令牌和 callback token 分别绑定不同
|
||||||
@@ -140,6 +161,8 @@ active <-> paused
|
|||||||
|
|
||||||
### 2.7 DocumentEditSession
|
### 2.7 DocumentEditSession
|
||||||
|
|
||||||
|
以下包含上传阶段的流程描述本地来源模式;不将其作为服务端主文档的生命周期。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
creating -> uploading -> ready -> editing
|
creating -> uploading -> ready -> editing
|
||||||
creating/uploading -> failed | cancelled | expired
|
creating/uploading -> failed | cancelled | expired
|
||||||
@@ -287,6 +310,11 @@ Publication/Grant,本人草稿确认也不能绕过当前访问权限。
|
|||||||
|
|
||||||
### 5.2 来源、受众与动作
|
### 5.2 来源、受众与动作
|
||||||
|
|
||||||
|
共享知识区分服务器同步副本和远程设备取用。已就绪的服务器副本不因来源设备离线而失效,
|
||||||
|
但更新需等待设备连接;远程取用在设备离线时不可用。发布暂停或设备撤销仍阻止使用,不能
|
||||||
|
以已有副本绕过撤销。搜索、读取原文、下载分别判断权限;可用状态、共享对象和管理员元数据
|
||||||
|
可见性均不替代动作授权。当前共享中心只演示这些状态,无真实内容请求。
|
||||||
|
|
||||||
可用内容为来源授权、当前入口受众、动作、空间权限、组织策略及 Provider 本地权限的交集。
|
可用内容为来源授权、当前入口受众、动作、空间权限、组织策略及 Provider 本地权限的交集。
|
||||||
个人外部入口与组织空间独立授权;同一分身身份复用不复制其所有来源权限。
|
个人外部入口与组织空间独立授权;同一分身身份复用不复制其所有来源权限。
|
||||||
|
|
||||||
@@ -313,6 +341,20 @@ Publication/Grant,本人草稿确认也不能绕过当前访问权限。
|
|||||||
任何权限扩大都从自动路径转为 `require_approval`。降级只有在目标版本仍被签名目录允许且
|
任何权限扩大都从自动路径转为 `require_approval`。降级只有在目标版本仍被签名目录允许且
|
||||||
策略明确指定时才提供,不自动视为故障回滚。
|
策略明确指定时才提供,不自动视为故障回滚。
|
||||||
|
|
||||||
|
### 6.1 网关配置演示
|
||||||
|
|
||||||
|
模型网关负责向其他服务转发,不托管模型。原型仅在上游与映射均启用、目标组被允许时判定
|
||||||
|
模型可用;模拟调用记录允许/拒绝及路由元数据,不发送请求或记录正文。
|
||||||
|
|
||||||
|
配置演示与上述签名能力包更新分开。草稿选择目标组可用模型,发布时重新检查策略;每组
|
||||||
|
独立记录发布历史。回退仅选择该组上一条记录,并重新检查其模型可用性,恢复版本及默认
|
||||||
|
模型后移除当前历史末项。发布和回退仅更新目标组在线示例设备;离线设备保留已应用版本,
|
||||||
|
目标版本随发布变化并显示待下发,未模拟重连交付。
|
||||||
|
|
||||||
|
这些状态仅保存在三页共享的会话内存中,刷新重置;角色开关不构成实际授权,发布状态不构成
|
||||||
|
设备确认。文档演示中仅本人可删除/转移个人文档,组织删除/恢复要求显式模拟组织文档管理员
|
||||||
|
角色,其他成员个人文档仅显示元数据,回收站不提供永久删除。
|
||||||
|
|
||||||
## 7. 联邦可见性
|
## 7. 联邦可见性
|
||||||
|
|
||||||
联邦目录只导出对方明确允许的能力投影:组织显示名、能力、版本范围、数据政策和可用性摘要。
|
联邦目录只导出对方明确允许的能力投影:组织显示名、能力、版本范围、数据政策和可用性摘要。
|
||||||
@@ -326,11 +368,13 @@ Publication/Grant,本人草稿确认也不能绕过当前访问权限。
|
|||||||
- 撤销设备、Publication、Grant、Package 或联邦后立即阻止新令牌。
|
- 撤销设备、Publication、Grant、Package 或联邦后立即阻止新令牌。
|
||||||
- 对象存储中的未完成任务正文按任务期限清理;审计元数据按组织保留策略清理。
|
- 对象存储中的未完成任务正文按任务期限清理;审计元数据按组织保留策略清理。
|
||||||
- Office 会话关闭、撤销或到期后停止签发新启动、文件和 callback 令牌。工作副本与 checkpoint
|
- Office 会话关闭、撤销或到期后停止签发新启动、文件和 callback 令牌。工作副本与 checkpoint
|
||||||
按 Office 保留策略清理;法定保留只能保留匹配对象和审计,不能恢复会话权限。
|
按 Office 保留策略清理,不连带删除服务端主文档;法定保留只能保留匹配对象和审计,不能恢复会话权限。
|
||||||
- 法定保留只锁定匹配审计事件,不使被撤销身份重新可用。
|
- 法定保留只锁定匹配审计事件,不使被撤销身份重新可用。
|
||||||
|
|
||||||
### 8.1 Office 保存与回写结果
|
### 8.1 Office 保存与回写结果
|
||||||
|
|
||||||
|
下表的源文件确认与冲突项仅适用于本地来源导入/回写模式。
|
||||||
|
|
||||||
| 条件 | ShareServer 结果 |
|
| 条件 | ShareServer 结果 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| callback 通过验证且对象完整 | 原子发布新 checkpoint,通知 Desktop |
|
| callback 通过验证且对象完整 | 原子发布新 checkpoint,通知 Desktop |
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
| 项目 | 内容 |
|
| 项目 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 状态 | 设计中 |
|
| 状态 | 设计中 |
|
||||||
| 版本 | 0.6 |
|
| 版本 | 0.7 |
|
||||||
| 日期 | 2026-09-28 |
|
| 日期 | 2026-09-30 |
|
||||||
| 适用产品 | 独立部署的 ShareServer 与 Web 管理控制台 |
|
| 适用产品 | 独立部署的 ShareServer 与 Web 管理控制台 |
|
||||||
| 关联架构 | [共享网络总体设计](../../../../goodbuddy/docs/architecture/share-network-architecture.md) |
|
| 关联架构 | [共享网络总体设计](../../../../goodbuddy/docs/architecture/share-network-architecture.md) |
|
||||||
| 关联文档 | [功能逻辑](./logic-design.md)、[管理界面](./ui-design.md)、[技术设计](./technical-design.md) |
|
| 关联文档 | [功能逻辑](./logic-design.md)、[管理界面](./ui-design.md)、[技术设计](./technical-design.md) |
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
1. 组织可在自有网络、私有云或公网独立部署 ShareServer。
|
1. 组织可在自有网络、私有云或公网独立部署 ShareServer。
|
||||||
2. 管理员可统一管理用户、组、设备、能力、权限、更新和审计。
|
2. 管理员可统一管理用户、组、设备、能力、权限、更新和审计。
|
||||||
3. 成员通过桌面端使用获准能力,无需日常进入管理控制台。
|
3. 成员在 GoodBuddy 中完成日常操作;ShareServer 提供组织后台,无需日常进入管理控制台。
|
||||||
4. 设备离线、用户撤销或策略变化能准确影响新任务,不破坏历史证据。
|
4. 设备离线、用户撤销或策略变化能准确影响新任务,不破坏历史证据。
|
||||||
5. 两个组织可经双边确认建立有限范围的联邦协作。
|
5. 两个组织可经双边确认建立有限范围的联邦协作。
|
||||||
6. ShareServer 可以只运行控制面,也可按部署策略提供数据中继和包存储。
|
6. ShareServer 可以只运行控制面,也可按部署策略提供数据中继和包存储。
|
||||||
@@ -31,6 +31,16 @@
|
|||||||
|
|
||||||
## 3. 产品范围
|
## 3. 产品范围
|
||||||
|
|
||||||
|
### 本地首期交付
|
||||||
|
|
||||||
|
先验证 GoodBuddy 应用中心中的设备共享与 ShareServer 目录互通:用户保存本地服务地址、
|
||||||
|
注册本机,显式发布能力或知识的名称、描述、来源及权限声明,再撤销发布。Web 共享中心能查询
|
||||||
|
相同记录,服务重启后记录仍保留。此阶段不自动枚举本地能力或上传知识文件。
|
||||||
|
|
||||||
|
验收要求:注册不自动发布,撤销后刷新仍为已撤销,网络失败有可重试反馈,知识权限独立保存;
|
||||||
|
真实目录与组织样例分别展示。API、状态和验证证据分别由技术设计、逻辑设计和实施进度维护。
|
||||||
|
身份认证、组织授权、内容同步与远程调用仍按以下完整产品范围继续实现。
|
||||||
|
|
||||||
### 3.1 包含
|
### 3.1 包含
|
||||||
|
|
||||||
- 本地账号及可配置 OIDC 身份源。
|
- 本地账号及可配置 OIDC 身份源。
|
||||||
@@ -46,12 +56,14 @@
|
|||||||
- Web 管理控制台和成员自助页面。
|
- Web 管理控制台和成员自助页面。
|
||||||
- 工作空间的组织共享关联、成员访问、实时普通消息与线程、提及通知、处理请求和资料同步。
|
- 工作空间的组织共享关联、成员访问、实时普通消息与线程、提及通知、处理请求和资料同步。
|
||||||
- 选定知识与监督回顾的共享授权,及数字分身对这些来源的复用。
|
- 选定知识与监督回顾的共享授权,及数字分身对这些来源的复用。
|
||||||
|
- 模型网关接入其他模型服务,管理模型映射、访问策略和调用审计,以及按用户组分配网关模型配置。
|
||||||
|
- 以 ShareServer 为主存储的文档、个人与组织归属管理;本地来源导入/回写为显式选择的独立模式。
|
||||||
|
|
||||||
### 3.2 不包含
|
### 3.2 不包含
|
||||||
|
|
||||||
- 远程管理设备桌面、任意 Shell 或任意文件系统。
|
- 远程管理设备桌面、任意 Shell 或任意文件系统。
|
||||||
- 发现未注册的局域网设备。
|
- 发现未注册的局域网设备。
|
||||||
- 保存或分发用户 API Key、Cookie 和桌面登录状态。
|
- 收集或分发用户个人 API Key、Cookie 和桌面登录状态。模型网关所需的组织上游凭据由管理员单独配置、服务端保管,不下发给客户端;当前原型不接收凭据。
|
||||||
- 默认查看任务正文、附件和模型输出。
|
- 默认查看任务正文、附件和模型输出。
|
||||||
- 自动上传、同步或浏览数字人所有者的本地个人、项目、会话和原始 Agent 记忆;用户明确发布的知识与选定回顾按 FR-18 单独管理。
|
- 自动上传、同步或浏览数字人所有者的本地个人、项目、会话和原始 Agent 记忆;用户明确发布的知识与选定回顾按 FR-18 单独管理。
|
||||||
- 因为成员加入组织而自动开放其数字人、记忆、Runtime、工作区或工具。
|
- 因为成员加入组织而自动开放其数字人、记忆、Runtime、工作区或工具。
|
||||||
@@ -60,7 +72,8 @@
|
|||||||
- 公共开放市场、计费结算或匿名能力调用。
|
- 公共开放市场、计费结算或匿名能力调用。
|
||||||
- 替代 GoodBuddy 官方客户端、模型和 Agent 发布源。
|
- 替代 GoodBuddy 官方客户端、模型和 Agent 发布源。
|
||||||
- 自行实现 Office 渲染器,或把 ONLYOFFICE Document Server 打进 Desktop 安装包。
|
- 自行实现 Office 渲染器,或把 ONLYOFFICE Document Server 打进 Desktop 安装包。
|
||||||
- 默认永久保存用户文档、选区、提示词或模型输出。
|
- 模型托管与推理运行。
|
||||||
|
- 在普通日志或审计中保存文档正文、选区、提示词或模型输出;服务端主文档按归属、删除和保留规则管理。
|
||||||
- 首期不含私聊、语音视频、已读回执、正在输入及分身输出逐字广播;空间协作按轻量 IM 范围实现。
|
- 首期不含私聊、语音视频、已读回执、正在输入及分身输出逐字广播;空间协作按轻量 IM 范围实现。
|
||||||
|
|
||||||
## 4. 功能需求
|
## 4. 功能需求
|
||||||
@@ -144,6 +157,15 @@
|
|||||||
- 普通成员只看到自己的设备、授权和待办;管理员栏目按权限隐藏并在 API 再次校验。
|
- 普通成员只看到自己的设备、授权和待办;管理员栏目按权限隐藏并在 API 再次校验。
|
||||||
- 普通成员可以管理自己发布的数字人,并发现和 `@` 当前组织内获准调用的数字人。
|
- 普通成员可以管理自己发布的数字人,并发现和 `@` 当前组织内获准调用的数字人。
|
||||||
- 所有高影响操作说明对象、范围和后果。
|
- 所有高影响操作说明对象、范围和后果。
|
||||||
|
- 共享中心扩展既有共享能力入口,保留 `/capabilities`;提供能力目录、共享知识库、按用户查看、
|
||||||
|
按设备查看四个页签。内容展示发布者、提供设备、版本、在线与发布状态、共享对象;用户和
|
||||||
|
设备视图列出关联内容并可进入详情,既有设备详情提供对应共享视图入口。
|
||||||
|
|
||||||
|
当前“GoodBuddy 服务”原型提供 `/model-gateway`、`/documents`、`/distribution` 三页,覆盖
|
||||||
|
网关策略判断、文档归属与回收站、OnlyOffice 实例检查、配置发布和回退。操作仅修改会话内存,
|
||||||
|
跨页保留、刷新重置,不发送服务请求或写入本地存储;模拟角色不构成授权,模拟成功不构成
|
||||||
|
推理、转发、Office 保存或设备交付的验收。离线设备保持待下发与原已应用版本;发布和回退
|
||||||
|
都检查目标组当前可用模型,回退同时恢复版本及默认模型。细节见 UI 设计第 16.1 节。
|
||||||
|
|
||||||
### FR-12 可部署性
|
### FR-12 可部署性
|
||||||
|
|
||||||
@@ -190,6 +212,13 @@
|
|||||||
|
|
||||||
### FR-14 Office 编辑服务
|
### FR-14 Office 编辑服务
|
||||||
|
|
||||||
|
- 日常文档使用在 GoodBuddy 中完成,ShareServer 主存文档与临时编辑会话分别管理。个人创建者
|
||||||
|
可删除自己的文档或确认转移给组织;转移后按组织权限删除,原创建者的个人删除权失效。
|
||||||
|
管理后台展示归属元数据不授予个人正文访问权。原型以显式“组织文档管理员”角色模拟组织
|
||||||
|
文档删除/恢复,恢复保持归属,不提供永久删除。
|
||||||
|
- 本节既有工作副本上传、检查点下载和源文件回写流程适用于显式选择的本地来源导入/回写
|
||||||
|
模式,保留 Desktop/SSH 源文件基准校验与冲突契约。服务端主存模式不以本地回写作为保存
|
||||||
|
完成条件;关闭编辑会话不删除主文档,也不改变归属。
|
||||||
- Office 编辑是可选服务能力。未配置或健康检查失败时,ShareServer 仍可运行其他控制面功能,
|
- Office 编辑是可选服务能力。未配置或健康检查失败时,ShareServer 仍可运行其他控制面功能,
|
||||||
并向 Desktop 返回明确不可用原因。
|
并向 Desktop 返回明确不可用原因。
|
||||||
- 首选 ONLYOFFICE Docs Developer Edition,目标支持 DOCX、XLSX 和 PPTX。正式采用前必须完成
|
- 首选 ONLYOFFICE Docs Developer Edition,目标支持 DOCX、XLSX 和 PPTX。正式采用前必须完成
|
||||||
@@ -202,11 +231,11 @@
|
|||||||
修改定位与高亮、撤销重做;源文件回写仍以检查点为依据。客户端需求与选型验证以
|
修改定位与高亮、撤销重做;源文件回写仍以检查点为依据。客户端需求与选型验证以
|
||||||
[Office PRD](../../../../goodbuddy/docs/features/office-document-editing/prd.md) 的 `FR-6`、
|
[Office PRD](../../../../goodbuddy/docs/features/office-document-editing/prd.md) 的 `FR-6`、
|
||||||
`FR-13`、`FR-14` 为准。
|
`FR-13`、`FR-14` 为准。
|
||||||
- Desktop 使用设备身份创建 `DocumentEditSession`,上传经过大小、格式和 SHA-256 声明的工作
|
- 本地来源模式下,Desktop 使用设备身份创建 `DocumentEditSession`,上传经过大小、格式和 SHA-256 声明的工作
|
||||||
副本。ShareServer 不接收本机或 SSH 绝对路径,也不允许文件名决定对象存储路径。
|
副本。ShareServer 不接收本机或 SSH 绝对路径,也不允许文件名决定对象存储路径。
|
||||||
- ShareServer 为编辑器签发一次性启动授权,为 ONLYOFFICE 签发独立的短期文件和 callback
|
- ShareServer 为编辑器签发一次性启动授权,为 ONLYOFFICE 签发独立的短期文件和 callback
|
||||||
凭据;设备、编辑器和 callback audience 不能互换。
|
凭据;设备、编辑器和 callback audience 不能互换。
|
||||||
- ONLYOFFICE 保存先形成不可变 `DocumentCheckpoint`。ShareServer 只宣称检查点可用,不宣称
|
- 本地来源模式下,ONLYOFFICE 保存先形成不可变 `DocumentCheckpoint`。ShareServer 只宣称检查点可用,不宣称
|
||||||
Desktop 本地或 SSH 源文件已经回写成功。
|
Desktop 本地或 SSH 源文件已经回写成功。
|
||||||
- 服务端保存组织、主体、会话、文档类型、大小、策略、编辑器版本、checkpoint 和清理审计;
|
- 服务端保存组织、主体、会话、文档类型、大小、策略、编辑器版本、checkpoint 和清理审计;
|
||||||
默认不保存文档正文、选区正文、提示词或模型输出到普通日志和审计表。
|
默认不保存文档正文、选区正文、提示词或模型输出到普通日志和审计表。
|
||||||
@@ -217,7 +246,7 @@
|
|||||||
- Ask 只能取得 AI 修改建议;只有 Desktop 确认的 Execute 请求可以取得绑定单会话、revision
|
- Ask 只能取得 AI 修改建议;只有 Desktop 确认的 Execute 请求可以取得绑定单会话、revision
|
||||||
和选区的短期编辑授权。
|
和选区的短期编辑授权。
|
||||||
- 会话关闭、撤销或到期后停止新读取、保存和 AI 操作,并按组织保留策略清理工作副本、
|
- 会话关闭、撤销或到期后停止新读取、保存和 AI 操作,并按组织保留策略清理工作副本、
|
||||||
checkpoint、Cookie 和对象令牌。
|
checkpoint、Cookie 和对象令牌;服务端主文档不随临时会话清理。
|
||||||
|
|
||||||
### FR-15 工作空间与共享属性
|
### FR-15 工作空间与共享属性
|
||||||
|
|
||||||
@@ -263,6 +292,8 @@
|
|||||||
|
|
||||||
### FR-18 共享知识与个人分身来源
|
### FR-18 共享知识与个人分身来源
|
||||||
|
|
||||||
|
- 知识库明确区分服务器同步副本与远程设备取用,分别展示来源可用性。搜索、读取原文、下载
|
||||||
|
独立授权;管理员的元数据可见性不产生内容权限。当前 Web 原型仅展示示例规则,不接入内容操作。
|
||||||
- 知识库可独立共享,也可被空间和分身引用。复用来源身份与版本,各入口按来源授权、受众、
|
- 知识库可独立共享,也可被空间和分身引用。复用来源身份与版本,各入口按来源授权、受众、
|
||||||
使用动作及组织策略求交集;关联资料不自动扩大权限。
|
使用动作及组织策略求交集;关联资料不自动扩大权限。
|
||||||
- 用户可设置分身身份、表达提示词和选定知识来源。提示词不授予权限,开启分身不共享全部
|
- 用户可设置分身身份、表达提示词和选定知识来源。提示词不授予权限,开启分身不共享全部
|
||||||
|
|||||||
@@ -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 编辑目标设计
|
### 2026-09-09:Office 编辑目标设计
|
||||||
|
|
||||||
@@ -58,7 +133,7 @@ ShareServer 的实现与验证在后续功能开发中单独推进;真实 Desk
|
|||||||
- 成员自助覆盖自己的设备、授权和申请;角色切换后导航和 URL 范围同步变化。
|
- 成员自助覆盖自己的设备、授权和申请;角色切换后导航和 URL 范围同步变化。
|
||||||
- 控制台支持深浅主题、组织范围、全局搜索、列表搜索、状态筛选、详情、表单、确认、成功/
|
- 控制台支持深浅主题、组织范围、全局搜索、列表搜索、状态筛选、详情、表单、确认、成功/
|
||||||
失败反馈、响应式侧栏和键盘对话框。
|
失败反馈、响应式侧栏和键盘对话框。
|
||||||
- 构建后的 Web 由 Hono 同源托管;`Dockerfile` 使用多阶段构建和非 root 运行用户。
|
- 构建后的 Web 由 Hono 同源托管;`docker/Dockerfile` 使用多阶段构建和非 root 运行用户。
|
||||||
- readiness 明确返回 `interactive-prototype` 与 `productionReady=false`,避免把 fixture
|
- readiness 明确返回 `interactive-prototype` 与 `productionReady=false`,避免把 fixture
|
||||||
误认为生产服务。
|
误认为生产服务。
|
||||||
|
|
||||||
@@ -96,7 +171,7 @@ npm run build # typecheck + Vite Web + server TypeScript passed
|
|||||||
8. `FR-15` 至 `FR-18` 本地空间共享关联、资料定期/按需同步、实时消息、显式处理请求和共享来源授权;
|
8. `FR-15` 至 `FR-18` 本地空间共享关联、资料定期/按需同步、实时消息、显式处理请求和共享来源授权;
|
||||||
对照用户故事完成服务端契约与 Desktop 集成,不能把静态 demo 当作真实消息服务。
|
对照用户故事完成服务端契约与 Desktop 集成,不能把静态 demo 当作真实消息服务。
|
||||||
|
|
||||||
当前尚未满足 PRD 的产品验收,也未进行真实 Desktop/ShareServer 集成测试。
|
当前尚未满足 PRD 的组织产品验收;本地元数据范围的真实 Desktop/ShareServer 联调记录见上文。
|
||||||
|
|
||||||
2026-09-05 PRD `FR-13` 新增组织数字人发布和 `@数字人` 调用。当前交互原型尚无“数字人”
|
2026-09-05 PRD `FR-13` 新增组织数字人发布和 `@数字人` 调用。当前交互原型尚无“数字人”
|
||||||
目录、发布管理、提及选择器或 Agent 记忆边界展示,因此这些能力仍属于剩余生产与原型工作,
|
目录、发布管理、提及选择器或 Agent 记忆边界展示,因此这些能力仍属于剩余生产与原型工作,
|
||||||
|
|||||||
@@ -19,11 +19,22 @@ ShareServer 在独立的 `goodbuddy-shareserver` 仓库根目录维护。当前
|
|||||||
goodbuddy-shareserver/
|
goodbuddy-shareserver/
|
||||||
README.md
|
README.md
|
||||||
package.json
|
package.json
|
||||||
server/ # Hono API 与同源静态资源服务
|
server/
|
||||||
|
src/ # Hono API、测试与同源静态资源服务
|
||||||
|
tsconfig.json
|
||||||
shared/ # 原型 fixture 与类型
|
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/
|
docs/features/share-server/
|
||||||
Dockerfile
|
docker/ # Dockerfile、Compose 与构建忽略规则
|
||||||
```
|
```
|
||||||
|
|
||||||
以下是后续生产实现的建议模块结构,尚未创建,不代表已实现:
|
以下是后续生产实现的建议模块结构,尚未创建,不代表已实现:
|
||||||
@@ -32,7 +43,7 @@ goodbuddy-shareserver/
|
|||||||
goodbuddy-shareserver/
|
goodbuddy-shareserver/
|
||||||
README.md
|
README.md
|
||||||
package.json
|
package.json
|
||||||
src/
|
server/src/
|
||||||
api/
|
api/
|
||||||
auth/
|
auth/
|
||||||
organizations/
|
organizations/
|
||||||
@@ -52,9 +63,15 @@ goodbuddy-shareserver/
|
|||||||
tests/
|
tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
仓库根目录是可独立运行的全控制台交互原型。原型使用 Hono 提供健康、就绪、
|
依赖与命令统一由根 `package.json` 管理,不拆分 workspace。Vite 输出到 `dist/web/`;
|
||||||
界面快照和无持久化操作确认 API,并同源托管 React/Vite Web 控制台;它不依赖 Electron 的
|
服务端 TypeScript 以仓库根为 `rootDir`,输出到 `dist/server/`,因此启动入口为
|
||||||
`src/shared`。当前尚未建立生产数据库、认证、设备连接和共享协议,因此也不提前创建空的
|
`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 共同依赖
|
`packages/share-protocol`。进入真实 Desktop 协议实现时,服务端和 Desktop 共同依赖
|
||||||
独立发布的 `share-protocol` 协议包,具体归属在真实协议实现时确定;
|
独立发布的 `share-protocol` 协议包,具体归属在真实协议实现时确定;
|
||||||
ShareServer 仍不得导入 Electron 的 `src/shared`,也不得依赖相邻桌面仓库构建。
|
ShareServer 仍不得导入 Electron 的 `src/shared`,也不得依赖相邻桌面仓库构建。
|
||||||
@@ -112,8 +129,49 @@ audience,并绑定单个 `DocumentEditSession`。
|
|||||||
所有请求经 schema、认证、organization scope、授权、速率和大小边界。列表使用游标分页和固定
|
所有请求经 schema、认证、organization scope、授权、速率和大小边界。列表使用游标分页和固定
|
||||||
最大页;错误返回 request ID、稳定 code 和有界参数。
|
最大页;错误返回 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. 数据模型
|
## 5. 数据模型
|
||||||
|
|
||||||
|
下列数据库模型属于组织服务目标设计。本地目录首期使用第 4.1 节的文件存储,不将本地设备 ID 当作组织身份。
|
||||||
|
|
||||||
核心表建议为:
|
核心表建议为:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -225,7 +283,15 @@ Release catalog 使用独立签名域。服务器不能重打包已签名 payloa
|
|||||||
|
|
||||||
### 10.1 部署边界
|
### 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
|
```text
|
||||||
@@ -281,6 +347,7 @@ ShareServer 负责中继有界事件,不持久化选区正文、Prompt 或模
|
|||||||
### 10.5 生命周期和清理
|
### 10.5 生命周期和清理
|
||||||
|
|
||||||
- 会话、工作副本、checkpoint、启动 Cookie 和对象 token 分别设置明确期限。
|
- 会话、工作副本、checkpoint、启动 Cookie 和对象 token 分别设置明确期限。
|
||||||
|
- 服务端主文档独立保留,清理临时会话对象不得连带删除主文档。
|
||||||
- 关闭、撤销或到期立即拒绝新读取、保存和 AI 操作。
|
- 关闭、撤销或到期立即拒绝新读取、保存和 AI 操作。
|
||||||
- 清理使用对象标签和数据库状态,重复执行保持幂等,不根据用户文件名构造删除前缀。
|
- 清理使用对象标签和数据库状态,重复执行保持幂等,不根据用户文件名构造删除前缀。
|
||||||
- 法定保留只能延长明确匹配的工作副本、checkpoint 或审计元数据,不能恢复访问授权。
|
- 法定保留只能延长明确匹配的工作副本、checkpoint 或审计元数据,不能恢复访问授权。
|
||||||
@@ -334,7 +401,7 @@ Office 配置包括启用状态、Document Server 内部地址、公开编辑 or
|
|||||||
- 指标覆盖请求、WebSocket、授权结果、任务状态、中继、Package、Office 会话/callback 和联邦,
|
- 指标覆盖请求、WebSocket、授权结果、任务状态、中继、Package、Office 会话/callback 和联邦,
|
||||||
不包含能力正文、文档正文或选区。
|
不包含能力正文、文档正文或选区。
|
||||||
- tracing 跨内部服务传播关联 ID,跨联邦只传播双方同意的关联标识。
|
- tracing 跨内部服务传播关联 ID,跨联邦只传播双方同意的关联标识。
|
||||||
- 备份 PostgreSQL、Package 对象、策略要求保留的 Office checkpoint 和网关身份;不备份缓存、
|
- 备份 PostgreSQL、服务端主文档、Package 对象、策略要求保留的 Office checkpoint 和网关身份;不备份缓存、
|
||||||
Web session、nonce、Office 短期 token 或中继暂存。
|
Web session、nonce、Office 短期 token 或中继暂存。
|
||||||
- 恢复后撤销列表、组织 ID、签名目录和审计序列保持有效,所有设备重新连接。
|
- 恢复后撤销列表、组织 ID、签名目录和审计序列保持有效,所有设备重新连接。
|
||||||
|
|
||||||
@@ -382,8 +449,30 @@ Office 配置包括启用状态、Document Server 内部地址、公开编辑 or
|
|||||||
`productionReady=false`。原型 fixture、角色切换和操作确认只用于评审信息架构,不得被
|
`productionReady=false`。原型 fixture、角色切换和操作确认只用于评审信息架构,不得被
|
||||||
Desktop 或部署脚本当作真实身份、策略、任务或审计 API。下一实施阶段必须从 FR-1
|
Desktop 或部署脚本当作真实身份、策略、任务或审计 API。下一实施阶段必须从 FR-1
|
||||||
初始化、本地身份、PostgreSQL 组织作用域和事务审计开始,不在 fixture 上叠加生产状态。
|
初始化、本地身份、PostgreSQL 组织作用域和事务审计开始,不在 fixture 上叠加生产状态。
|
||||||
当前原型没有 Office 导航、配置、会话 API、对象存储、ONLYOFFICE、callback 或 Desktop 文档
|
当前 `/documents` 已有 OnlyOffice 实例配置与预设检查模拟;Office 会话 API、对象存储、
|
||||||
Tab 集成;上述章节仅定义目标设计。
|
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. 工作空间协作的待实现契约
|
## 17. 工作空间协作的待实现契约
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ ShareServer 是独立 Web 产品,不直接复用 Electron 页面壳层实现
|
|||||||
|
|
||||||
## 1. 使用对象
|
## 1. 使用对象
|
||||||
|
|
||||||
|
当前本地首期在 `/capabilities?view=local` 提供“本地服务目录”页签,读取真实设备和发布数据。
|
||||||
|
其余四个共享中心页签保留组织演示。真实页不使用示例组织、用户归属或在线状态解释本地记录。
|
||||||
|
|
||||||
|
GoodBuddy 从应用菜单或应用中心进入“设备共享”,完成地址与名称保存、注册、发布、撤销和刷新。
|
||||||
|
Web 提供设备、类型、状态、关键词筛选,显示注册时间、来源声明及独立知识权限;提供加载、空目录、
|
||||||
|
无匹配结果、失败重试和刷新状态。Web 不重复提供本机发布表单。权限与来源提示跟随真实目录展示,
|
||||||
|
不把元数据发布成功写成能力可执行或知识可读取。桌面交互详见
|
||||||
|
[设备共享 UI 设计](../../../../goodbuddy/docs/features/device-sharing/ui-design.md)。
|
||||||
|
|
||||||
- 普通成员主要在 GoodBuddy Desktop 协作和使用能力;Web 端管理自己的设备、申请、授权和已发布数字人。
|
- 普通成员主要在 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 状态分别显示。
|
设备详情包含概览、连接、已发布能力、Grant 和活动。设备状态与 Publication 状态分别显示。
|
||||||
撤销设备使用危险确认,说明新任务、活动任务和重新注册影响。
|
撤销设备使用危险确认,说明新任务、活动任务和重新注册影响。
|
||||||
|
|
||||||
## 6. 能力目录
|
## 6. 共享中心
|
||||||
|
|
||||||
目录以能力为第一列,提供者为可展开信息:
|
沿用 `/capabilities`,页签依次为“能力目录”“共享知识库”“按用户查看”“按设备查看”。
|
||||||
|
各内容展示发布者、提供设备、版本、设备在线状态、发布状态及共享对象,名称进入元数据详情。
|
||||||
|
发布者和设备链接切换到对应分组并清除旧筛选;既有 `/devices` 详情链接定位同一设备的共享视图。
|
||||||
|
|
||||||
```text
|
四个页签支持元数据搜索、可用状态、来源和共享对象筛选,用户/设备视图增加主体选择。
|
||||||
能力 版本 提供者 可见范围 可用性
|
显示当前结果数量、无结果说明及“清除筛选”,重置保留当前页签。页签支持方向键、Home、End,
|
||||||
图像生成 2.1.0 3 个节点 设计组 2/3 在线
|
窄屏横向滚动;筛选和列表随宽度换行,复用本地主题、按钮、状态徽标和详情对话框。
|
||||||
```
|
|
||||||
|
|
||||||
详情包含 manifest、provider、Publication、允许输入、数据政策、调用趋势和最近错误。管理员
|
知识库同时显示来源模式与搜索、读取原文、下载三项显式权限。详情解释服务器副本和远程取用
|
||||||
看到的是组织元数据,不通过目录读取能力内部数据。
|
的离线差异,管理员查看元数据不会获得正文权限。当前仅搜索示例元数据;内容按钮禁用并标注
|
||||||
|
“未接入”,不返回虚构正文或下载成功。能力就绪也不代表获得调用权限。
|
||||||
|
|
||||||
### 6.1 数字人
|
### 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 窄屏导航。动态发光、网格和网络脉冲只用于运行状态
|
页签、详情、创建表单、危险确认和 390px 窄屏导航。动态发光、网格和网络脉冲只用于运行状态
|
||||||
与品牌氛围;业务表格仍保持克制,状态继续同时使用图标与文字。原型操作会调用无持久化确认
|
与品牌氛围;业务表格仍保持克制,状态继续同时使用图标与文字。既有 fixture 页面操作会调用无持久化确认
|
||||||
API,并持续标明不会写入生产数据,不能据此宣称真实审批、撤销、策略或任务操作已实现。
|
API,并持续标明不会写入生产数据,不能据此宣称真实审批、撤销、策略或任务操作已实现。
|
||||||
`FR-13` 新增的数字人目录、“我的数字人”和 `@` 选择器尚未进入当前原型。
|
`FR-13` 新增的数字人目录、“我的数字人”和 `@` 选择器尚未进入当前原型。
|
||||||
`FR-14` 新增的 Office 编辑导航、配置、健康和会话管理也尚未进入当前原型。
|
“文档与协作”已提供 OnlyOffice 实例配置和预设健康检查模拟;真实 Office 编辑与会话管理尚未实现。
|
||||||
|
|
||||||
|
### 16.1 GoodBuddy 服务演示
|
||||||
|
|
||||||
|
侧栏“GoodBuddy 服务”分组提供三个管理员路由。页首持续标注模拟边界:三页共享当前会话
|
||||||
|
内存,导航保留状态、刷新重置;操作不调用网络或写本地存储。角色选择仅用于评审,不构成
|
||||||
|
真实授权;未接入模型推理、网关转发、OnlyOffice 编辑保存或设备交付。
|
||||||
|
|
||||||
|
| 路由 | 交互与结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `/model-gateway` 模型网关 | “上游服务”“模型映射 / 访问策略”“调用审计”页签;可添加和启停上游,切换映射上游、允许组及启停状态,选择调用组模拟允许/拒绝。最近 50 条审计仅含路由与策略结果,可查看详情,不含请求/响应正文 |
|
||||||
|
| `/documents` 文档与协作 | “组织文档”“归属元数据”“OnlyOffice 服务器”“回收站”页签;展示名称与归属,无个人正文预览。OnlyOffice 可添加实例、预设健康/失败并累计模拟检查次数,不访问实例或建立编辑会话 |
|
||||||
|
| `/distribution` 配置下发 | 创建一个配置草稿,选择目标组、当前可用默认模型和三段数字版本;发布前再次检查模型与组策略。按组展示当前/上一版本、设备已应用/目标版本,并可回退到上一条发布记录 |
|
||||||
|
|
||||||
|
“打开模拟 GoodBuddy 客户端”仅打开对话框,个人创建者可删除自己的示例文档或确认转移给
|
||||||
|
当前组织,不启动真实客户端或上传文件。转移后从个人列表移除,原个人删除权失效;显式
|
||||||
|
勾选模拟“组织文档管理员”才可删除/恢复组织文档。其他成员个人文档不可操作,恢复保留
|
||||||
|
原归属,演示不提供永久删除。
|
||||||
|
|
||||||
|
配置发布与回退仅更新目标组在线示例设备;离线设备保持原已应用版本并标记待下发,无重连
|
||||||
|
交付模拟。回退同时恢复版本和默认模型;历史模型停用或当前组策略不允许时禁止回退,不能
|
||||||
|
仅改版本标签。上游及 OnlyOffice 地址表单只接受不含账号、密码、查询参数或片段的 HTTPS
|
||||||
|
地址,不提供密钥字段,也不验证连接。
|
||||||
|
|
||||||
## 17. Desktop 工作空间协作目标交互
|
## 17. Desktop 工作空间协作目标交互
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -10,7 +10,7 @@ export default tseslint.config(
|
|||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
{
|
{
|
||||||
files: ['src/**/*.{ts,tsx}'],
|
files: ['web/src/**/*.{ts,tsx}'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
@@ -25,7 +25,7 @@ export default tseslint.config(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['server/**/*.ts'],
|
files: ['server/src/**/*.ts'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: globals.node
|
globals: globals.node
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -5,13 +5,13 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/dev.mjs",
|
"dev": "node scripts/dev.mjs",
|
||||||
"dev:api": "node --watch --import tsx server/index.ts",
|
"dev:api": "node --watch --import tsx server/src/index.ts",
|
||||||
"dev:web": "vite",
|
"dev:web": "vite --config web/vite.config.ts",
|
||||||
"build": "npm run typecheck && vite build && npm run build:server",
|
"build": "npm run typecheck && vite build --config web/vite.config.ts && npm run build:server",
|
||||||
"build:server": "node -e \"require('node:fs').rmSync('build-server',{recursive:true,force:true})\" && tsc -p tsconfig.server.json",
|
"build:server": "node -e \"require('node:fs').rmSync('dist/server',{recursive:true,force:true})\" && tsc -p server/tsconfig.json",
|
||||||
"start": "node build-server/server/index.js",
|
"start": "node dist/server/server/src/index.js",
|
||||||
"test": "vitest run",
|
"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 ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"outDir": "build-server",
|
"outDir": "../dist/server",
|
||||||
"rootDir": ".",
|
"rootDir": "..",
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["server", "shared"],
|
"include": ["src", "../shared"],
|
||||||
"exclude": ["server/**/*.test.ts"]
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./web/tsconfig.json" },
|
||||||
{ "path": "./tsconfig.server.json" }
|
{ "path": "./server/tsconfig.json" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,12 +6,14 @@ import {
|
|||||||
Routes,
|
Routes,
|
||||||
useLocation
|
useLocation
|
||||||
} from 'react-router-dom'
|
} 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 { pages, visiblePages } from './app-config'
|
||||||
import { AppShell } from './components/app-shell'
|
import { AppShell } from './components/app-shell'
|
||||||
import { DataPage } from './components/data-page'
|
import { DataPage } from './components/data-page'
|
||||||
|
import { SharingCenter } from './components/sharing-center'
|
||||||
import { OverviewPage } from './components/overview-page'
|
import { OverviewPage } from './components/overview-page'
|
||||||
import { SettingsPage } from './components/settings-page'
|
import { SettingsPage } from './components/settings-page'
|
||||||
|
import { ServiceDemoPage, ServicesDemo } from './components/services-demo'
|
||||||
import { Button } from './components/ui/button'
|
import { Button } from './components/ui/button'
|
||||||
|
|
||||||
async function loadSnapshot(): Promise<PrototypeSnapshot> {
|
async function loadSnapshot(): Promise<PrototypeSnapshot> {
|
||||||
@@ -58,10 +60,14 @@ function Console({
|
|||||||
element={
|
element={
|
||||||
page.path === '/' ? (
|
page.path === '/' ? (
|
||||||
<OverviewPage snapshot={snapshot} />
|
<OverviewPage snapshot={snapshot} />
|
||||||
) : page.path === '/settings' ? (
|
) : page.path === '/capabilities' ? (
|
||||||
|
<SharingCenter snapshot={snapshot} />
|
||||||
|
) : page.path === '/settings' ? (
|
||||||
<SettingsPage snapshot={snapshot} />
|
<SettingsPage snapshot={snapshot} />
|
||||||
) : (
|
) : ['/model-gateway', '/documents', '/distribution'].includes(page.path) ? (
|
||||||
<DataPage page={page} snapshot={snapshot} />
|
<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 (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Console snapshot={snapshot} role={role} onRoleChange={setRole} />
|
<ServicesDemo>
|
||||||
|
<Console snapshot={snapshot} role={role} onRoleChange={setRole} />
|
||||||
|
</ServicesDemo>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4,17 +4,20 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
CircleUserRound,
|
CircleUserRound,
|
||||||
Cpu,
|
Cpu,
|
||||||
|
FileText,
|
||||||
FileKey2,
|
FileKey2,
|
||||||
Gauge,
|
Gauge,
|
||||||
GitFork,
|
GitFork,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
Network,
|
||||||
|
Send,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Settings2,
|
Settings2,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
UserRoundCheck
|
UserRoundCheck
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } 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 = {
|
export type PageConfig = {
|
||||||
path: string
|
path: string
|
||||||
@@ -25,6 +28,7 @@ export type PageConfig = {
|
|||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
role: PrototypeRole | 'all'
|
role: PrototypeRole | 'all'
|
||||||
primaryAction?: string
|
primaryAction?: string
|
||||||
|
group?: string
|
||||||
tabs?: Array<{ label: string; dataKey: string }>
|
tabs?: Array<{ label: string; dataKey: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,10 +36,34 @@ export const pages: PageConfig[] = [
|
|||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
title: '运行概览',
|
title: '运行概览',
|
||||||
description: '需要行动的事项与当前 ShareServer 运行事实。',
|
description: 'GoodBuddy 组织服务、接入状态与待处理事项。',
|
||||||
icon: Gauge,
|
icon: Gauge,
|
||||||
role: 'admin'
|
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',
|
path: '/members',
|
||||||
title: '组织与成员',
|
title: '组织与成员',
|
||||||
@@ -52,8 +80,8 @@ export const pages: PageConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/devices',
|
path: '/devices',
|
||||||
title: '设备',
|
title: 'GoodBuddy 接入',
|
||||||
description: '查看已注册设备、连接状态与合规元数据。',
|
description: '管理 GoodBuddy 客户端与设备的注册、连接状态和接入授权。',
|
||||||
dataKey: 'devices',
|
dataKey: 'devices',
|
||||||
icon: Cpu,
|
icon: Cpu,
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
@@ -61,8 +89,8 @@ export const pages: PageConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/capabilities',
|
path: '/capabilities',
|
||||||
title: '能力目录',
|
title: '共享中心',
|
||||||
description: '查看显式发布的能力、版本和 Provider 可用性。',
|
description: '按内容、发布用户或提供设备查看共享能力与知识库。',
|
||||||
dataKey: 'capabilities',
|
dataKey: 'capabilities',
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
role: 'admin'
|
role: 'admin'
|
||||||
@@ -86,7 +114,7 @@ export const pages: PageConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/releases',
|
path: '/releases',
|
||||||
title: '发布与更新',
|
title: '资源与更新',
|
||||||
description: '管理签名能力包、发布通道、更新策略和撤销。',
|
description: '管理签名能力包、发布通道、更新策略和撤销。',
|
||||||
dataKey: 'releases',
|
dataKey: 'releases',
|
||||||
icon: PackageCheck,
|
icon: PackageCheck,
|
||||||
@@ -95,7 +123,7 @@ export const pages: PageConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/federation',
|
path: '/federation',
|
||||||
title: '网关联邦',
|
title: '网关互联',
|
||||||
description: '管理经双边确认的组织协作边界。',
|
description: '管理经双边确认的组织协作边界。',
|
||||||
dataKey: 'federation',
|
dataKey: 'federation',
|
||||||
icon: GitFork,
|
icon: GitFork,
|
||||||
@@ -104,7 +132,7 @@ export const pages: PageConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/tasks',
|
path: '/tasks',
|
||||||
title: '任务与中继',
|
title: '任务中心',
|
||||||
description: '查看任务路由、终态和可选中继容量。',
|
description: '查看任务路由、终态和可选中继容量。',
|
||||||
dataKey: 'tasks',
|
dataKey: 'tasks',
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
@@ -112,7 +140,7 @@ export const pages: PageConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/audit',
|
path: '/audit',
|
||||||
title: '审计',
|
title: '操作审计',
|
||||||
description: '按组织查询有界操作元数据,不包含任务正文。',
|
description: '按组织查询有界操作元数据,不包含任务正文。',
|
||||||
dataKey: 'audit',
|
dataKey: 'audit',
|
||||||
icon: ScrollText,
|
icon: ScrollText,
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useEffect, useState, type ReactNode } from 'react'
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
import { NavLink, useNavigate } from 'react-router-dom'
|
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 { visiblePages } from '../app-config'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
@@ -41,7 +41,10 @@ export function AppShell({
|
|||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [dark, setDark] = useState(true)
|
const [dark, setDark] = useState(true)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const navigation = visiblePages(role)
|
const navigation = visiblePages(role).map((page) => ({
|
||||||
|
...page,
|
||||||
|
group: page.group ?? (role === 'member' ? '成员自助' : page.path === '/' ? '概览' : '组织与运维')
|
||||||
|
}))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||||
@@ -67,7 +70,7 @@ export function AppShell({
|
|||||||
{snapshot.instance.name}
|
{snapshot.instance.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-[11px] uppercase tracking-[0.16em] text-cyan-300/70">
|
<p className="truncate text-[11px] uppercase tracking-[0.16em] text-cyan-300/70">
|
||||||
Control plane
|
GoodBuddy 组织服务
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,11 +93,12 @@ export function AppShell({
|
|||||||
{role === 'admin' ? '组织控制台' : '成员自助'}
|
{role === 'admin' ? '组织控制台' : '成员自助'}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{navigation.map((item) => {
|
{navigation.map((item, index) => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<div key={item.path}>
|
||||||
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}
|
to={item.path}
|
||||||
end={item.path === '/'}
|
end={item.path === '/'}
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
@@ -121,7 +125,8 @@ export function AppShell({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +198,7 @@ export function AppShell({
|
|||||||
<Menu className="size-5" />
|
<Menu className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<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)}
|
onClick={() => setSearchOpen(true)}
|
||||||
>
|
>
|
||||||
<Search aria-hidden="true" className="size-4" />
|
<Search aria-hidden="true" className="size-4" />
|
||||||
@@ -205,7 +210,7 @@ export function AppShell({
|
|||||||
<div className="ml-auto flex items-center gap-1">
|
<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="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 className="size-1.5 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,.8)]" />
|
||||||
控制面正常
|
交互演示 · 非实时数据
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
aria-label={dark ? '切换到浅色主题' : '切换到深色主题'}
|
aria-label={dark ? '切换到浅色主题' : '切换到深色主题'}
|
||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
X
|
X
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useMemo, useState } from '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 type { PageConfig } from '../app-config'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
@@ -61,6 +62,11 @@ function DetailPanel({
|
|||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
<div className="mt-5 flex flex-wrap justify-end gap-2 border-t border-white/[0.07] pt-5">
|
<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' ? (
|
{page.path === '/approvals' ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="danger" onClick={() => onAction('拒绝申请', record)}>
|
<Button variant="danger" onClick={() => onAction('拒绝申请', record)}>
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
Sparkles
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 { PageHeader } from './page-header'
|
||||||
import { StatusBadge } from './ui/status-badge'
|
import { StatusBadge } from './ui/status-badge'
|
||||||
|
|
||||||
@@ -53,15 +53,27 @@ export function OverviewPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow={`实例 · ${snapshot.instance.region}`}
|
eyebrow={`实例 · ${snapshot.instance.region}`}
|
||||||
title="运行概览"
|
title="运行概览"
|
||||||
description={`${snapshot.instance.name} 正在为组织 ${snapshot.instance.organization} 提供控制面服务。`}
|
description={`用户在 GoodBuddy 中工作,${snapshot.instance.name} 为 ${snapshot.instance.organization} 管理服务与组织资源。`}
|
||||||
action={
|
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">
|
<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)]" />
|
<span className="size-1.5 rounded-full bg-emerald-300 shadow-[0_0_10px_rgba(52,211,153,.9)]" />
|
||||||
所有核心服务正常
|
交互原型 · 示例数据
|
||||||
</div>
|
</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">
|
<section aria-label="关键指标" className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
{snapshot.metrics.map((metric) => (
|
{snapshot.metrics.map((metric) => (
|
||||||
<button
|
<button
|
||||||
@@ -118,7 +130,7 @@ export function OverviewPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-[var(--text-strong)]">网络脉冲</h2>
|
<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>
|
</div>
|
||||||
<RadioTower className="size-5 text-cyan-300" />
|
<RadioTower className="size-5 text-cyan-300" />
|
||||||
</div>
|
</div>
|
||||||
@@ -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'
|
} from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { LucideIcon } from 'lucide-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 { PageHeader } from './page-header'
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
import { StatusBadge } from './ui/status-badge'
|
import { StatusBadge } from './ui/status-badge'
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 { 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'
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
const styles: Record<PrototypeRecord['statusTone'], string> = {
|
const styles: Record<PrototypeRecord['statusTone'], string> = {
|
||||||
@@ -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: '设备离线,远程取用不可用' }
|
||||||
|
}
|
||||||
@@ -17,5 +17,5 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||||
},
|
},
|
||||||
"include": ["src", "shared"]
|
"include": ["src", "../shared"]
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { defineConfig } from 'vitest/config'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
root: fileURLToPath(new URL('.', import.meta.url)),
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
build: {
|
||||||
|
outDir: '../dist/web',
|
||||||
|
emptyOutDir: true
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5174,
|
port: 5174,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://127.0.0.1:8787'
|
'/api': 'http://127.0.0.1:8787'
|
||||||
}
|
}
|
||||||
},
|
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
setupFiles: ['./src/test-setup.ts'],
|
|
||||||
exclude: ['build-server/**', 'dist/**', 'node_modules/**']
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user