Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2100353ef5 | ||
|
|
edeeec3c41 | ||
|
|
ec23d44b02 | ||
|
|
2c5bf15cce | ||
|
|
e0eb6a721e | ||
|
|
ba78a24384 | ||
|
|
85a9a3ab45 | ||
|
|
617ba4d87c | ||
|
|
d2d705ed4c | ||
|
|
7c6d06cb43 | ||
|
|
64ef058e0d | ||
|
|
f2438e4660 | ||
|
|
66dcb97e1c | ||
|
|
87cc7785da | ||
|
|
b64d2b78bb | ||
|
|
a5e5acb03b | ||
|
|
7e969e7e8c | ||
|
|
db778e0bac | ||
|
|
2191efab2f | ||
|
|
0a53e665eb | ||
|
|
ddf953b641 | ||
|
|
436d5500f1 | ||
|
|
1752b9ef84 | ||
|
|
bebcf2a06c | ||
|
|
bf44cc0f25 | ||
|
|
31acd33a2e | ||
|
|
cf699d6207 | ||
|
|
f9c71cc076 | ||
|
|
28287a1447 | ||
|
|
abef9ffabf |
@@ -2,9 +2,17 @@ TJWATER_AUTH_MODE=required
|
|||||||
TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater
|
TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater
|
||||||
TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater
|
TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN=
|
TJWATER_MAPBOX_ACCESS_TOKEN=
|
||||||
|
TJWATER_TIANDITU_TOKEN=
|
||||||
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
||||||
TJWATER_GEOSERVER_WORKSPACE=tjwater
|
TJWATER_GEOSERVER_WORKSPACE=tjwater_next
|
||||||
TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787
|
TJWATER_SERVER_API_BASE_URL=http://127.0.0.1:8000
|
||||||
|
TJWATER_AGENT_URL=http://127.0.0.1:8787
|
||||||
|
TJWATER_WORKBENCH_RUNTIME_MODE=auto
|
||||||
|
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL=
|
||||||
|
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE=demo
|
||||||
|
TJWATER_PERIPHERAL_SAMPLE_DEMO_START=2026-09-17T00:00:00Z
|
||||||
|
TJWATER_PERIPHERAL_SAMPLE_DEMO_END=2026-09-17T12:00:00Z
|
||||||
|
TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS=8
|
||||||
TJWATER_ENABLE_DEV_PANEL=false
|
TJWATER_ENABLE_DEV_PANEL=false
|
||||||
TJWATER_ENABLE_MSW=false
|
TJWATER_ENABLE_MSW=false
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ dist-server
|
|||||||
coverage
|
coverage
|
||||||
playwright-report
|
playwright-report
|
||||||
test-results
|
test-results
|
||||||
|
/public/city-model
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS build
|
ARG NODE_IMAGE=node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
|
||||||
|
|
||||||
|
FROM ${NODE_IMAGE} AS build
|
||||||
|
|
||||||
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
||||||
ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY}
|
ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY}
|
||||||
@@ -13,17 +15,26 @@ RUN pnpm install --frozen-lockfile --registry="${NPM_REGISTRY}"
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
FROM caddy:2.10.2-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d
|
FROM caddy:2.10.2-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d AS caddy-bin
|
||||||
|
|
||||||
|
FROM ${NODE_IMAGE} AS runner
|
||||||
|
|
||||||
ARG ALPINE_MIRROR=mirrors.aliyun.com
|
ARG ALPINE_MIRROR=mirrors.aliyun.com
|
||||||
RUN sed -i "s|dl-cdn.alpinelinux.org|${ALPINE_MIRROR}|g" /etc/apk/repositories && \
|
RUN sed -i "s|dl-cdn.alpinelinux.org|${ALPINE_MIRROR}|g" /etc/apk/repositories && \
|
||||||
apk add --no-cache jq nodejs
|
apk add --no-cache ca-certificates jq mailcap && \
|
||||||
|
mkdir -p /config/caddy /data/caddy /etc/caddy /srv && \
|
||||||
|
chmod 1777 /config/caddy /data/caddy
|
||||||
|
ENV XDG_CONFIG_HOME=/config
|
||||||
|
ENV XDG_DATA_HOME=/data
|
||||||
|
COPY --from=caddy-bin /usr/bin/caddy /usr/bin/caddy
|
||||||
COPY Caddyfile /etc/caddy/Caddyfile
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/tjwater-frontend-entrypoint
|
COPY docker-entrypoint.sh /usr/local/bin/tjwater-frontend-entrypoint
|
||||||
COPY --from=build /app/dist /srv
|
COPY --from=build /app/dist /srv
|
||||||
COPY --from=build /app/dist-server/edge-tts-server.cjs /usr/local/lib/tjwater-frontend/edge-tts-server.cjs
|
COPY --from=build /app/dist-server/edge-tts-server.cjs /usr/local/lib/tjwater-frontend/edge-tts-server.cjs
|
||||||
RUN chmod 755 /usr/local/bin/tjwater-frontend-entrypoint
|
RUN chmod 755 /usr/local/bin/tjwater-frontend-entrypoint
|
||||||
|
|
||||||
|
WORKDIR /srv
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD wget --quiet --spider http://127.0.0.1/ || exit 1
|
CMD wget --quiet --spider http://127.0.0.1/ || exit 1
|
||||||
|
|||||||
@@ -32,25 +32,42 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
|
|||||||
|
|
||||||
浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 或 `NEXT_PUBLIC_` 前缀。可在 `.env.local` 中设置以下变量:
|
浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 或 `NEXT_PUBLIC_` 前缀。可在 `.env.local` 中设置以下变量:
|
||||||
|
|
||||||
|
开发环境使用内网认证服务时,仅在被 Git 忽略的 `.env.development.local` 中覆盖
|
||||||
|
`TJWATER_KEYCLOAK_ISSUER`,保留 `.env.local` 的 HTTPS issuer。Vite 默认开发模式读取
|
||||||
|
此覆盖,正常 production 构建不读取。登录 issuer 必须与 Agent、Server MCP 及 worker
|
||||||
|
一致;更改后重新加载开发服务器并重新登录,不复用旧 issuer 的令牌。
|
||||||
|
|
||||||
| 变量 | 默认值 | 说明 |
|
| 变量 | 默认值 | 说明 |
|
||||||
| --- | --- | --- |
|
| ---------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` |
|
| `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` |
|
||||||
| `TJWATER_KEYCLOAK_ISSUER` | 无 | Keycloak Realm issuer,例如 `https://keycloak.example.com/realms/tjwater` |
|
| `TJWATER_KEYCLOAK_ISSUER` | 无 | Keycloak Realm issuer,例如 `https://keycloak.example.com/realms/tjwater` |
|
||||||
| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID |
|
| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID |
|
||||||
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
|
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
|
||||||
|
| `TJWATER_TIANDITU_TOKEN` | 空 | 天地图矢量、影像及中文注记访问令牌 |
|
||||||
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
|
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
|
||||||
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater` | GeoServer 工作区 |
|
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 |
|
||||||
| `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 |
|
| `TJWATER_SERVER_API_BASE_URL` | `http://127.0.0.1:8000` | 浏览器访问的 TJWater 后端 API 地址 |
|
||||||
|
| `TJWATER_AGENT_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 |
|
||||||
|
| `TJWATER_WORKBENCH_RUNTIME_MODE` | `auto` | 工作台时间模式:工况始终使用 mock,预约工单只读取后端投影;`auto` 和 `backend` 使用当天日期;`demo` 与 `history` 定位到最近一个有后端预约工单的日期,并在同一天生成 mock 工况,其中 `demo` 标记为演示时间。分析成果和后端任务状态不作为工况或日期依据 |
|
||||||
|
| `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` | 空 | 可选的外围系统模拟服务地址,留空时不发起连接 |
|
||||||
|
| `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE` | `demo` | 历史回放默认时间模式:`demo` 使用固定有数据窗口,`recent` 使用最近时段 |
|
||||||
|
| `TJWATER_PERIPHERAL_SAMPLE_DEMO_START` | `2026-09-17T00:00:00Z` | 演示历史窗口开始时间(UTC、带时区) |
|
||||||
|
| `TJWATER_PERIPHERAL_SAMPLE_DEMO_END` | `2026-09-17T12:00:00Z` | 演示历史窗口结束时间(UTC、最长 7 天) |
|
||||||
|
| `TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS` | `8` | 实时时间模式回看小时数,范围 1—168 |
|
||||||
| `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
|
| `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
|
||||||
| `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
|
| `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
|
||||||
|
|
||||||
`.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。
|
`.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。Mapbox 和天地图令牌会随底图请求发送到浏览器,应在服务商控制台限制允许的来源域名。
|
||||||
|
|
||||||
浏览器使用 Keycloak Standard Flow 和 PKCE S256,不需要也不能配置 client secret。生产环境必须提供 Realm issuer;未认证用户会跳转到 Keycloak,同一 Realm 中已有登录会话时会直接完成单点登录。
|
浏览器使用 Keycloak Standard Flow 和 PKCE S256,不需要也不能配置 client secret。生产环境必须提供 Realm issuer;未认证用户会跳转到 Keycloak,同一 Realm 中已有登录会话时会直接完成单点登录。
|
||||||
|
|
||||||
Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。
|
Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。
|
||||||
|
|
||||||
Agent 请求由浏览器直接访问 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
|
业务数据与 Agent 请求分别由浏览器直接访问 `TJWATER_SERVER_API_BASE_URL` 和 `TJWATER_AGENT_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
|
||||||
|
|
||||||
|
Agent 模型目录由 Agent 服务提供,页面重新获得焦点或网络重连时刷新;发送指令前再次读取当前目录。仍有效的用户选择会保留,失效时改用目录中的默认模型或首个可用模型,并同步更新菜单。目录为空时提示错误并阻止生成请求。前端始终提交目录返回的完整模型 ID。
|
||||||
|
|
||||||
|
Agent 运行时仍可发送消息,默认“引导当前任务”(steer),也可选择“排队处理”(queue);停止按钮独立保留。尚未处理的消息显示在输入框上方,可以逐条切换发送方式。处理状态由 Agent 的原生收件箱决定,已处理后不再允许修改;追加消息和模式切换复用当前会话归属及授权,不会先中断执行。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
@@ -105,4 +122,8 @@ src/
|
|||||||
|
|
||||||
## 构建说明
|
## 构建说明
|
||||||
|
|
||||||
|
调查任务支持样板反馈的人工复核草稿。在“外围样板”显式导入反馈证据后,可读取同一来源的完整反馈集合、确认并保存新修订;已保存草稿也可从“调查任务”的历史草稿中再次打开,不依赖外围服务在线。页面区分待重分析、冲突证据及前后修订,不代表已经重新计算。接口与存储边界见 [复核修订说明](../next-tjwater-server/docs/feedback-review-revisions.md)。
|
||||||
|
|
||||||
|
“分析成果”面板只读取当前项目 BizDB 中已持久化的分析历史,不启动分析任务。外围样板面板可对 synthetic 测试工单和测试数据执行 CRUD,历史监测回放仍为只读。部署所需表和边界见 Server 的[外围样板说明](../next-tjwater-server/docs/peripheral-sample.md)。
|
||||||
|
|
||||||
生产构建可能提示 Shiki、Mermaid 语言包超过 Rollup 默认的 500 kB 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
|
生产构建可能提示 Shiki、Mermaid 语言包超过 Rollup 默认的 500 kB 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
|
||||||
|
|||||||
@@ -6,9 +6,17 @@ runtime_config=$(jq -cn \
|
|||||||
--arg keycloakIssuer "${TJWATER_KEYCLOAK_ISSUER:-}" \
|
--arg keycloakIssuer "${TJWATER_KEYCLOAK_ISSUER:-}" \
|
||||||
--arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \
|
--arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \
|
||||||
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
|
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
|
||||||
|
--arg tiandituToken "${TJWATER_TIANDITU_TOKEN:-}" \
|
||||||
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
|
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
|
||||||
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater}" \
|
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater_next}" \
|
||||||
--arg agentApiBaseUrl "${TJWATER_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \
|
--arg serverApiBaseUrl "${TJWATER_SERVER_API_BASE_URL:-http://127.0.0.1:8000}" \
|
||||||
|
--arg agentApiBaseUrl "${TJWATER_AGENT_URL:-http://127.0.0.1:8787}" \
|
||||||
|
--arg workbenchRuntimeMode "${TJWATER_WORKBENCH_RUNTIME_MODE:-auto}" \
|
||||||
|
--arg peripheralSampleApiBaseUrl "${TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL:-}" \
|
||||||
|
--arg peripheralSampleTimeMode "${TJWATER_PERIPHERAL_SAMPLE_TIME_MODE:-demo}" \
|
||||||
|
--arg peripheralSampleDemoStart "${TJWATER_PERIPHERAL_SAMPLE_DEMO_START:-2026-09-17T00:00:00Z}" \
|
||||||
|
--arg peripheralSampleDemoEnd "${TJWATER_PERIPHERAL_SAMPLE_DEMO_END:-2026-09-17T12:00:00Z}" \
|
||||||
|
--arg peripheralSampleRecentHours "${TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS:-8}" \
|
||||||
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
|
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
|
||||||
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
|
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
|
||||||
'{
|
'{
|
||||||
@@ -16,9 +24,17 @@ runtime_config=$(jq -cn \
|
|||||||
TJWATER_KEYCLOAK_ISSUER: $keycloakIssuer,
|
TJWATER_KEYCLOAK_ISSUER: $keycloakIssuer,
|
||||||
TJWATER_KEYCLOAK_CLIENT_ID: $keycloakClientId,
|
TJWATER_KEYCLOAK_CLIENT_ID: $keycloakClientId,
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
|
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
|
||||||
|
TJWATER_TIANDITU_TOKEN: $tiandituToken,
|
||||||
TJWATER_MAP_URL: $mapUrl,
|
TJWATER_MAP_URL: $mapUrl,
|
||||||
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
|
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
|
||||||
TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl,
|
TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl,
|
||||||
|
TJWATER_AGENT_URL: $agentApiBaseUrl,
|
||||||
|
TJWATER_WORKBENCH_RUNTIME_MODE: $workbenchRuntimeMode,
|
||||||
|
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: $peripheralSampleApiBaseUrl,
|
||||||
|
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: $peripheralSampleTimeMode,
|
||||||
|
TJWATER_PERIPHERAL_SAMPLE_DEMO_START: $peripheralSampleDemoStart,
|
||||||
|
TJWATER_PERIPHERAL_SAMPLE_DEMO_END: $peripheralSampleDemoEnd,
|
||||||
|
TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS: $peripheralSampleRecentHours,
|
||||||
TJWATER_ENABLE_DEV_PANEL: $enableDevPanel,
|
TJWATER_ENABLE_DEV_PANEL: $enableDevPanel,
|
||||||
TJWATER_ENABLE_MSW: $enableMsw
|
TJWATER_ENABLE_MSW: $enableMsw
|
||||||
}')
|
}')
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# 亚克力设计类别与组件使用清单
|
||||||
|
|
||||||
|
本文档记录当前前端的亚克力材质体系及实际使用位置。样式定义以 `src/styles.css` 为准,组件映射以 `src/features/` 下的现有代码为准。
|
||||||
|
|
||||||
|
## 总览
|
||||||
|
|
||||||
|
当前材质体系分为 3 组,共 13 个语义类:
|
||||||
|
|
||||||
|
| 分组 | 数量 | 类别 | 是否使用背景模糊 |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| 外层亚克力与临时玻璃 | 4 | 导航、面板、独立控件、临时任务浮层 | 是 |
|
||||||
|
| 内部实体表面 | 4 | Dock、控件面、内容凹槽、阅读面 | 否 |
|
||||||
|
| 语义状态表面 | 5 | 信息、正常、警告、危险、Agent 推理 | 否 |
|
||||||
|
|
||||||
|
只有第一组属于真正的亚克力或玻璃外壳。第二组用于亚克力壳层内部,保证文字、表单和密集数据不受底图干扰。第三组只表达业务状态,不承担界面层级。
|
||||||
|
|
||||||
|
## 1. 外层亚克力与临时玻璃
|
||||||
|
|
||||||
|
这组样式包含冷色半透明底色、饱和度、明度混合、细噪点、边框和阴影,并使用 `backdrop-filter`。同一区域只应保留一个带模糊的外层壳体,内部子元素应改用实体表面。
|
||||||
|
|
||||||
|
### `acrylic-navigation`
|
||||||
|
|
||||||
|
- 用途:全局导航栏。
|
||||||
|
- 模糊:使用面板级模糊,默认 22px,浅色底图 24px,卫星底图 20px。
|
||||||
|
- 特征:保留底部分隔和下投影,不使用顶部高光。
|
||||||
|
- 当前组件:
|
||||||
|
- `WorkbenchTopBar`,工作台顶部 Header。
|
||||||
|
- 文件:`src/features/workbench/components/workbench-top-bar.tsx`。
|
||||||
|
|
||||||
|
### `acrylic-panel`
|
||||||
|
|
||||||
|
- 用途:承载持续显示或需要集中阅读的主要浮动面板。
|
||||||
|
- 模糊:使用面板级模糊,20px 至 24px。
|
||||||
|
- 当前组件:
|
||||||
|
- `AgentCommandPanel`,Agent 命令面板外壳。
|
||||||
|
- `AgentCollapsedRail`,Agent 收起状态侧栏。
|
||||||
|
- `ScheduledConditionFeed`,预约任务与工况面板,通过 `MAP_TOOL_PANEL_SURFACE_CLASS_NAME` 间接使用。
|
||||||
|
- `LayerControl`、`ControlPanel`、`Legend`、`BaseLayersControl`,地图工具面板,通过共享材质常量间接使用。
|
||||||
|
- `FeaturePopover`,要素详情浮层,通过 `MAP_FOCUS_SURFACE_CLASS_NAME` 间接使用。
|
||||||
|
- `FeatureInsightPanel`,要素洞察面板。
|
||||||
|
- `MapDevPanel`,地图开发调试面板。
|
||||||
|
- `AlertMenu`、`ScenarioMenu`、`UserMenu`,顶部导航下拉菜单。
|
||||||
|
- `MapNotice`,地图通知。
|
||||||
|
- 直接或间接涉及文件:
|
||||||
|
- `src/features/agent/components/agent-command-panel.tsx`
|
||||||
|
- `src/features/agent/components/agent-collapsed-rail.tsx`
|
||||||
|
- `src/features/map/core/components/map-control-styles.ts`
|
||||||
|
- `src/features/map/core/components/notice.tsx`
|
||||||
|
- `src/features/workbench/components/scheduled-condition-feed.tsx`
|
||||||
|
- `src/features/workbench/components/feature-popover.tsx`
|
||||||
|
- `src/features/workbench/components/feature-insight-panel.tsx`
|
||||||
|
- `src/features/workbench/components/map-dev-panel.tsx`
|
||||||
|
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
|
||||||
|
|
||||||
|
### `acrylic-control`
|
||||||
|
|
||||||
|
- 用途:始终悬浮在地图上的紧凑型独立控件。
|
||||||
|
- 模糊:使用控件级模糊,默认 18px,浅色底图 20px,卫星底图 16px。
|
||||||
|
- 当前组件:
|
||||||
|
- `Toolbar`,地图主工具栏。
|
||||||
|
- `DrawToolbar`,绘制工具栏。
|
||||||
|
- `ZoomControl`,缩放控件。
|
||||||
|
- `ScaleLine`,比例尺。
|
||||||
|
- `BaseLayersControl`,底图切换入口与预览控件。
|
||||||
|
- `map-workbench-page.tsx` 中的浮动地图控制入口。
|
||||||
|
- 使用方式:上述组件主要通过 `MAP_CONTROL_SURFACE_CLASS_NAME` 间接引用。
|
||||||
|
- 共享定义:`src/features/map/core/components/map-control-styles.ts`。
|
||||||
|
|
||||||
|
### `glass-transient`
|
||||||
|
|
||||||
|
- 用途:短时出现、需要突出当前任务状态的玻璃浮层。
|
||||||
|
- 模糊:沿用控件级模糊,16px 至 20px。
|
||||||
|
- 当前组件:
|
||||||
|
- `AgentTaskTicker` 的活动任务卡片。
|
||||||
|
- 文件:`src/features/workbench/components/agent-task-ticker.tsx`。
|
||||||
|
- 约束:仅用于活动任务浮条,非活动堆叠卡改用 `surface-control`。
|
||||||
|
|
||||||
|
## 2. 内部实体表面
|
||||||
|
|
||||||
|
这组样式不使用 `backdrop-filter`。它们用于亚克力外壳内部,通过不透明度和背景层级提高可读性,避免出现嵌套模糊。
|
||||||
|
|
||||||
|
### `surface-dock`
|
||||||
|
|
||||||
|
- 用途:预留给固定 Dock 或侧边停靠区域。
|
||||||
|
- 当前状态:已在 `src/styles.css` 中定义,暂无组件直接使用。
|
||||||
|
- 适用组件:
|
||||||
|
- 桌面端固定展开的 Agent 侧栏,前提是它从地图浮层改为占据稳定布局宽度的 Dock。
|
||||||
|
- 固定展开的预约工况详情侧栏或图层管理侧栏,前提是侧栏长期贴边并参与工作区布局。
|
||||||
|
- 后续可能增加的对象目录、调度队列等常驻侧栏。
|
||||||
|
- 不适用:临时弹层、悬浮工具面板、下拉菜单和可覆盖地图的短时侧栏。这些组件仍应使用 `acrylic-panel`。
|
||||||
|
|
||||||
|
### `surface-control`
|
||||||
|
|
||||||
|
- 用途:面板内部的 Header、输入区域、筛选器、按钮、紧凑控制区和任务堆叠卡。
|
||||||
|
- 当前组件范围:
|
||||||
|
- Agent 收起侧栏条目、输入操作栏和命令面板快捷入口。
|
||||||
|
- 图层、量测和通用控制面板中的交互控件。
|
||||||
|
- 预约任务面板与工况详情中的筛选器、元信息块和次级操作。
|
||||||
|
- 任务浮条的非活动卡片。
|
||||||
|
- 要素洞察面板的内部按钮。
|
||||||
|
- Header 下拉菜单的内部控制区。
|
||||||
|
- 通用 `IconButton`。
|
||||||
|
- 主要文件:
|
||||||
|
- `src/features/agent/components/agent-collapsed-rail.tsx`
|
||||||
|
- `src/features/agent/components/agent-command-panel.tsx`
|
||||||
|
- `src/features/map/core/components/control-panel.tsx`
|
||||||
|
- `src/features/map/core/components/layer-control.tsx`
|
||||||
|
- `src/features/map/core/components/measure-panel.tsx`
|
||||||
|
- `src/features/workbench/components/agent-task-ticker.tsx`
|
||||||
|
- `src/features/workbench/components/scheduled-condition-feed.tsx`
|
||||||
|
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
|
||||||
|
- `src/features/workbench/components/feature-insight-panel.tsx`
|
||||||
|
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
|
||||||
|
|
||||||
|
### `surface-well`
|
||||||
|
|
||||||
|
- 用途:低密度内容区、空白支撑区、未选中的面板条目和内部凹槽。
|
||||||
|
- 当前组件:
|
||||||
|
- `LayerPanel`、`LayerControl` 的未激活条目。
|
||||||
|
- `MeasurePanel`、`DrawToolbar`、`AnnotationPanel` 的内部区域。
|
||||||
|
- `ControlPanel`、`Legend`、`BaseLayersControl` 的分组与空白区域。
|
||||||
|
- Header 下拉菜单的普通菜单项。
|
||||||
|
- 使用方式:部分组件直接引用,部分通过 `MAP_READABLE_SURFACE_CLASS_NAME` 间接引用。
|
||||||
|
|
||||||
|
### `surface-reading`
|
||||||
|
|
||||||
|
- 用途:消息、报告、表单、属性列表、时间线内容和其他密集阅读区域。
|
||||||
|
- 当前组件范围:
|
||||||
|
- Agent 消息详情、UI Envelope 内容和命令面板空状态。
|
||||||
|
- 预约任务列表、工况详情、KPI、报告内容与下拉列表。
|
||||||
|
- 要素洞察、属性列表和底图名称标签。
|
||||||
|
- Header 下拉菜单中的详情块与空状态。
|
||||||
|
- 主要文件:
|
||||||
|
- `src/features/agent/components/agent-command-panel.tsx`
|
||||||
|
- `src/features/agent/components/agent-message-details.tsx`
|
||||||
|
- `src/features/agent/components/agent-ui-envelope-renderer.tsx`
|
||||||
|
- `src/features/workbench/components/scheduled-condition-feed.tsx`
|
||||||
|
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
|
||||||
|
- `src/features/workbench/components/feature-insight-panel.tsx`
|
||||||
|
- `src/features/workbench/components/feature-property-list.tsx`
|
||||||
|
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
|
||||||
|
- `src/features/map/core/components/base-layers-control.tsx`
|
||||||
|
|
||||||
|
## 3. 语义状态表面
|
||||||
|
|
||||||
|
语义状态表面不使用模糊,只表达业务含义。它们不能替代 `acrylic-panel`、`surface-control` 或 `surface-reading` 来建立界面层级。
|
||||||
|
|
||||||
|
| 类名 | 含义 | 当前使用位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| `material-tone-info` | 信息、液位等蓝色状态 | `FeatureInsightPanel` 的雷达液位区块 |
|
||||||
|
| `material-tone-normal` | 正常、健康、流量或水质状态 | `FeatureInsightPanel` 的水质、综合监测等区块 |
|
||||||
|
| `material-tone-warning` | 风险、告警、超声流量等橙色状态 | `FeatureInsightPanel` 的告警与超声流量区块 |
|
||||||
|
| `material-tone-danger` | 严重故障或事故状态 | 已定义,建议用于严重事故、关键设备故障和高风险工况内容块 |
|
||||||
|
| `material-tone-agent` | Agent 推理或模型建议 | 已定义,建议用于 Agent 解释、推理结论和建议动作内容块 |
|
||||||
|
|
||||||
|
## 未使用样式的建议落点
|
||||||
|
|
||||||
|
### `surface-dock`
|
||||||
|
|
||||||
|
建议用于参与页面布局的常驻停靠区,而不是覆盖在地图上的浮层。现有组件中,以下场景适合在布局调整后使用:
|
||||||
|
|
||||||
|
- `AgentCommandPanel` 的桌面常驻模式。只有当面板固定贴左、占据稳定宽度并推动地图安全区时才使用。
|
||||||
|
- `ScheduledConditionFeed` 或 `ConditionDetailPanel` 的常驻右侧模式。适用于调度员持续对照地图与工况详情的工作流。
|
||||||
|
- `LayerPanel` 的常驻图层目录模式。适用于图层较多、需要持续管理的专业工作台。
|
||||||
|
|
||||||
|
如果组件仍然悬浮在地图上、可以随时关闭或与其他浮层替换,应继续使用 `acrylic-panel`。
|
||||||
|
|
||||||
|
### `material-tone-danger`
|
||||||
|
|
||||||
|
建议用于已经确认的严重业务状态,不用于普通校验失败或短暂接口错误。现有组件中适合的落点包括:
|
||||||
|
|
||||||
|
- `FeatureInsightPanel`:要素状态为 `critical`、严重故障、爆管或关键设备失效时的状态说明区块。
|
||||||
|
- `ScheduledConditionDetailPanel`:工况风险等级为 `critical`,或任务状态为 `error` 且代表真实运行事故时的风险摘要和影响范围区块。
|
||||||
|
- `MapNotice`:仅在需要持续强调严重运行事故时用于通知内容区,不替换通知最外层的 `acrylic-panel`。
|
||||||
|
- Agent 权限或确认卡片:仅当待执行动作具有明确的高风险业务后果时使用,不能仅因提交失败就使用。
|
||||||
|
|
||||||
|
不应使用在网络请求失败、加载失败、表单校验错误等通用技术错误上。这类错误继续使用局部红色图标、边框或文字即可,避免把技术故障误读为管网事故。
|
||||||
|
|
||||||
|
### `material-tone-agent`
|
||||||
|
|
||||||
|
建议用于明确由 Agent 或模型生成的内容,帮助用户区分事实数据、业务状态与机器推理。现有组件中适合的落点包括:
|
||||||
|
|
||||||
|
- `FeatureInsightPanel` 的“Agent 解释”区块。该区块目前使用 `material-tone-warning`,如果内容只是解释或推理而非风险告警,更适合改用 `material-tone-agent`。
|
||||||
|
- `AgentMessageDetails`:推理摘要、模型结论、建议方案等非原始证据内容。
|
||||||
|
- `AgentUiEnvelopeRenderer`:由 Agent 返回的建议面板或注册组件中的结论区块。
|
||||||
|
- `ScheduledConditionDetailPanel`:Agent 针对工况生成的处置建议区块。
|
||||||
|
|
||||||
|
原始监测值、设备属性、人工录入内容和系统事实不应使用该样式。即使这些内容出现在 Agent 面板内,也应继续使用 `surface-reading` 或对应的业务状态色。
|
||||||
|
|
||||||
|
## 共享类映射
|
||||||
|
|
||||||
|
地图核心组件通过 `src/features/map/core/components/map-control-styles.ts` 复用材质类,避免在各组件中重复写样式值。
|
||||||
|
|
||||||
|
| 共享常量 | 实际材质类 | 典型用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| `MAP_CONTROL_SURFACE_CLASS_NAME` | `acrylic-control border` | 工具栏、缩放、比例尺、底图入口 |
|
||||||
|
| `MAP_TOOL_PANEL_SURFACE_CLASS_NAME` | `acrylic-panel border` | 图层、图例、控制和预约任务面板 |
|
||||||
|
| `MAP_FOCUS_SURFACE_CLASS_NAME` | `acrylic-panel border` | 要素详情等焦点浮层 |
|
||||||
|
| `MAP_READABLE_SURFACE_CLASS_NAME` | `surface-well border` | 面板内部低密度区域 |
|
||||||
|
| `MAP_READABLE_SURFACE_STRONG_CLASS_NAME` | `surface-control border` | 面板内部高可读控制区 |
|
||||||
|
|
||||||
|
## 自适应与无障碍规则
|
||||||
|
|
||||||
|
- `data-basemap-tone="light"` 会提高部分面板模糊强度,并降低导航、控件和任务浮条的背景不透明度。
|
||||||
|
- `data-basemap-tone="satellite"` 会提高背景不透明度、轮廓和阴影强度,压制卫星底图纹理。
|
||||||
|
- `prefers-reduced-transparency: reduce` 下,亚克力和玻璃表面会切换到接近不透明的背景并关闭模糊。
|
||||||
|
- 不支持 `backdrop-filter` 的浏览器使用同样的高不透明度回退。
|
||||||
|
- `forced-colors: active` 下,材质背景和边框改用系统颜色,并关闭背景图与阴影。
|
||||||
|
|
||||||
|
## 使用约束
|
||||||
|
|
||||||
|
1. 一个浮动区域只允许最外层使用 `acrylic-*` 或 `glass-transient`。
|
||||||
|
2. 外层亚克力内部使用 `surface-control`、`surface-well` 或 `surface-reading`,不要叠加第二层模糊。
|
||||||
|
3. 密集文字、表格、属性和值必须落在 `surface-reading` 等高可读表面上。
|
||||||
|
4. `material-tone-*` 只表达状态,不表达层级,也不添加模糊。
|
||||||
|
5. 新增地图控件时优先复用 `map-control-styles.ts` 中的共享常量。
|
||||||
|
6. 组件只负责布局、圆角和交互状态,材质颜色、边框和阴影由全局语义类统一维护。
|
||||||
|
|
||||||
|
## 当前待清理项
|
||||||
|
|
||||||
|
- `surface-dock` 可在固定 Dock 落地时启用。如果产品不计划引入参与布局的常驻侧栏,可以移除该样式。
|
||||||
|
- `material-tone-danger` 应等待明确的严重业务状态落点,不要为了消除未使用状态而套用到通用错误提示。
|
||||||
|
- `material-tone-agent` 可优先用于 `FeatureInsightPanel` 的“Agent 解释”和 Agent 建议内容,替代不准确的警告语义。
|
||||||
|
- Agent 面板仍有少量局部类直接使用 `--surface-control`、`--surface-reading` 或自定义透明背景。后续统一材质语义时,应评估是否迁移到对应的 `surface-*` 类。
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# 本地交互式回答
|
||||||
|
|
||||||
|
聊天保留 AI SDK `UIMessage`、AI Elements 与现有 Agent 后端,新增
|
||||||
|
`@json-render/core` / `@json-render/react` 的本地注册组件。
|
||||||
|
组件代码随前端构建发布,无 ChatKit iframe、domain key 或 Vercel/OpenAI
|
||||||
|
托管 UI 请求。模型仍使用 Agent 的既有配置与工具调用能力。
|
||||||
|
|
||||||
|
## 可用内容与交互
|
||||||
|
|
||||||
|
`Stack`、`Section`、`Text`、`Metric`、`Table`、`Chart`、`Evidence`、
|
||||||
|
`MapLocation`、`FollowUpForm` 共九种组件。图表可切换折线、柱状和数据表;
|
||||||
|
证据可折叠并标注来源;地图定位通过现有控制器执行;表单从空值开始,
|
||||||
|
由用户主动提交,提交形成普通用户消息。业务证据是否保存以 Agent 实际工具结果为准。
|
||||||
|
|
||||||
|
Agent 通过 `present_answer` 发布完整的局部界面块,可在两块之间继续输出文字。
|
||||||
|
消息顺序为文字、`data-answer_ui`、后续文字与最终报告。同一轮内相同
|
||||||
|
`block_id` 更新原块;下一轮不会修改上一轮回答。
|
||||||
|
这是按内容块逐步出现的界面,不是逐 token 编译 JSX 或每个 JSON 属性都立即显示。
|
||||||
|
结构化消息与普通聊天共用持久化、历史与断线恢复通道。
|
||||||
|
|
||||||
|
生成期间展开显示过程;`final_answer` 到达后,仅展开最后报告的完整文字与
|
||||||
|
`ui_block_ids` 明确引用的已发布组件,其余文字、界面与工具轨迹默认折叠在
|
||||||
|
“查看处理过程”中。报告组件按引用顺序排列,同一组件不会同时作为报告和过程
|
||||||
|
挂载,避免重复表单。未被报告引用的过程界面只供回看。
|
||||||
|
|
||||||
|
已发布交互界面的普通/协作聊天在最终答复完成、且没有待完成工具时停止本轮原生
|
||||||
|
生成,防止最终报告之后反复自动修改界面。该收尾不标记后台调查任务完成;
|
||||||
|
后台执行继续等待其原有运行生命周期与 Server 完成/失败接口。
|
||||||
|
|
||||||
|
后台调查执行继续走任务报告与任务展示通道,聊天界面块不构成业务完成或浏览器回执。
|
||||||
|
|
||||||
|
## 扩展组件
|
||||||
|
|
||||||
|
1. 在 `src/features/agent/answer-ui/catalog.ts` 增加严格的 Zod 参数定义、用途描述与必要的数据上限。
|
||||||
|
2. 在 `answer-ui-view.tsx` 注册 React 组件。需要工作台交互时通过 Agent 公共 API 的回调接入,保持依赖方向。
|
||||||
|
3. 从本仓库运行 `pnpm export:answer-ui-contract`,更新相邻 Agent 仓库的
|
||||||
|
`src/chat/answer-ui-catalog.json`。组件目录是唯一参数定义源,生成文件需随两边一起交付。
|
||||||
|
4. 如新组件存在跨字段约束,同时更新两端的协议校验并加边界和交互测试。
|
||||||
|
|
||||||
|
导出脚本接受可选目标文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/export-answer-ui-contract.mjs /path/to/agent/src/chat/answer-ui-catalog.json
|
||||||
|
```
|
||||||
|
|
||||||
|
前端沿用 Zod 3,json-render 0.21 两个包通过作用域 override 使用相同 schema
|
||||||
|
运行时;实际组件渲染和参数校验已有聚焦测试。升级 json-render 时必须保留这组
|
||||||
|
兼容性测试,不能只根据包版本判断可用。Agent 原生插件仍使用其 Zod 4 工具边界,
|
||||||
|
执行后再按生成的 JSON Schema 校验组件参数。
|
||||||
|
|
||||||
|
## 校验边界
|
||||||
|
|
||||||
|
两端拒绝未知组件、额外属性、任意脚本/事件定义、缺失引用、循环、重复或游离节点。
|
||||||
|
每块最多 48 节点、深度 12、约 100 KB,图表必须对齐且数值有限;表格每行与列数
|
||||||
|
一致,表单字段名唯一。组件只消费明确的 props,没有动态代码、网络 URL 或任意动作 DSL。
|
||||||
|
无法解析的历史内容显示该块的 `fallback_text`。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm test src/features/agent/answer-ui src/features/workbench/hooks/agent-session-message-state.test.ts
|
||||||
|
pnpm test:browser tests/browser/agent-answer-ui.e2e.ts tests/browser/agent-start-page.e2e.ts
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent 仓库:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run check
|
||||||
|
bun test tests/chat/answer-stream-projection.test.ts tests/chat/answer-ui.test.ts tests/routes/chatStream.test.ts tests/routes/taskCollaboration.test.ts tests/runtime/opencode.test.ts
|
||||||
|
bun run tests/integration/answer-ui-smoke.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
原生 smoke 使用独立临时数据库,不发模型推理请求。浏览器用例用明确标注的演练
|
||||||
|
事件验证组件、更新、历史恢复、键盘展开证据、表单提交和 320/375px 布局,不能当作
|
||||||
|
真实模型已正确选择组件或现场资料已核验的证明。
|
||||||
|
|
||||||
|
前端与 Agent 需一起更新,Agent 进程需重新加载原生插件。无需新增环境变量、
|
||||||
|
domain key 或数据库迁移。当前开发实例通过现有监听机制重新加载;既有聊天与调查工作进程保留。
|
||||||
|
|
||||||
|
## 真实模型验收(显式启用)
|
||||||
|
|
||||||
|
以下浏览器用例直接连接本机 Agent,输入均为明确的演练资料。工作台地图与项目
|
||||||
|
读取使用测试数据,聊天、模型工具调用、流式 UI、聊天持久化与表单回传使用真实
|
||||||
|
服务。每次创建独立验收会话,结束后仅删除该会话,不删除既有用户记录。此用例
|
||||||
|
会发送模型请求,默认浏览器测试不执行它。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TJWATER_ANSWER_UI_REAL_AGENT_URL=http://127.0.0.1:18787 \
|
||||||
|
TJWATER_ANSWER_UI_REAL_MODEL_LABEL=专家 \
|
||||||
|
pnpm test:browser tests/browser/agent-answer-ui-real.e2e.ts --workers=1
|
||||||
|
```
|
||||||
|
|
||||||
|
先在 Agent 仓库启动隔离实例,可指定独立 INTEGRATION_DATA_DIR 保留联调数据。
|
||||||
|
可省略模型标签来使用默认模型。该用例不是生产 OAuth 登录或真实供水业务核验。
|
||||||
|
|
||||||
|
Vite 统一解析 React/React DOM,Playwright 按端口使用独立的依赖缓存,避免同时
|
||||||
|
运行的开发服务将旧临时目录的 React 预构建写回测试缓存。
|
||||||
|
|
||||||
|
消息入口为 `POST /api/v1/responses`,采用 Responses 文本输入、Response 输出和标准 SSE 生命周期。前端 `ResponsesChatTransport` 将网络事件转换为已有 UIMessage 渲染数据;审批、凭据续期、地图动作及 answer-ui 走 `tjwater.*` 扩展事件,不将 AI SDK 的 UIMessage 流作为网络协议。会话、模型及动作回执等接口统一使用 `/api/v1` 前缀,原 `/agent/chat` 路径已移除;Agent 服务和现有会话数据保留。
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# 新增业务组件设计规范
|
||||||
|
|
||||||
|
本规范覆盖调查任务、运行控制、人工复核、外围样板、日期时间与共享表单组件。地图仍是主工作区,侧栏负责定位当前任务、显示状态、给出下一步操作和按需展开证据。
|
||||||
|
|
||||||
|
## 1. 视觉主题
|
||||||
|
|
||||||
|
方向为宝石蓝操作、银灰亚克力、少量香槟重点的浅色专业调度台。界面保持高信息密度,使用稳定的标题、状态、操作、证据顺序。沿用既有字体、布局、圆角和 acrylic 材质,以透底、色调和轻微背景色差区分导航、面板、分组与阅读区。
|
||||||
|
|
||||||
|
## 2. 色彩角色
|
||||||
|
|
||||||
|
采用同色相色阶与低彩度互补色规则。宝石蓝 `#4263C7` 转换为 OKLCH 后约为 `L=0.529317, C=0.160186, h=267.059148°`;中性材质和文字保持该色相,香槟辅助色使用 `(h + 180°) mod 360°`。色阶先计算后转换为 sRGB HEX,均处于 sRGB 色域。透明度不参与色板生成。
|
||||||
|
|
||||||
|
| 角色 | L | C | h | HEX |
|
||||||
|
|---|---:|---:|---:|---|
|
||||||
|
| 宝石蓝主色 | 0.529317 | 0.160186 | 267.06° | `#4263C7` |
|
||||||
|
| 银霜材质 | 0.935 | 0.012 | 267.06° | `#E6E9F2` |
|
||||||
|
| 冷瓷阅读面 | 0.975 | 0.005 | 267.06° | `#F5F7FA` |
|
||||||
|
| 香槟辅助色 | 0.820 | 0.065 | 87.06° | `#D6C294` |
|
||||||
|
| 深墨文字 | 0.280 | 0.025 | 267.06° | `#232935` |
|
||||||
|
|
||||||
|
完整色板见 [宝石蓝配色](gem-blue-palette.svg)。
|
||||||
|
|
||||||
|
- `--surface-navigation`:宝石蓝 8% 与银霜在 OKLab 空间混合的导航色调。浅色混色使用 OKLab,避免低彩度颜色在色相插值时偏色。
|
||||||
|
- `--surface-panel`:银霜色调;`--surface-well` 为其半透明次级面。
|
||||||
|
- `--surface-subtle`:`L=0.950, C=0.010, h=267.06°`,得到 `#EBEEF5`,供桌面 dock、分组和普通控件使用。
|
||||||
|
- `--surface-reading`:冷瓷正文、表单和报告阅读面,保持较高遮蔽度。
|
||||||
|
- `--action-selection-soft`:`L=0.920, C=0.035, h=267.06°`,得到 `#DAE5FD`,文字使用深宝石蓝。
|
||||||
|
- `--action-blue`:主操作 `#4263C7`;hover 将 L 降低 0.055、C 乘 0.95,得到 `#3653B0`;pressed 将 L 降低 0.105、C 乘 0.90,得到 `#2B469C`。
|
||||||
|
- `--surface-border` / `--surface-divider`:保持同色相,分别使用 `(L=0.815, C=0.015)` 和 `(L=0.885, C=0.012)`,得到银灰边界 `#BEC2CD` / `#D5D9E1`。
|
||||||
|
- `--action-focus-ring`:深宝石蓝焦点环;`--ink-muted`:`L=0.450, C=0.030, h=267.06°`,得到辅助文字 `#4E5566`。
|
||||||
|
- `cyan-700`:外围样板入口及其来源标识。
|
||||||
|
- `emerald`:已完成、已通过。
|
||||||
|
- `--status-warning-soft`:香槟色 25% 与冷瓷混合的确认、暂停和结果未知提示底色;同互补色相的文字使用 `(L=0.430, C=0.070)`,得到 `#604D1D`;状态标记使用 `(L=0.600, C=0.090)`,得到 `#977D3C`。
|
||||||
|
- `rose`:失败、冲突或破坏性操作。
|
||||||
|
|
||||||
|
宝石蓝用于工作台操作和选中状态,银灰用于工作台材质层级。Agent 对话区使用独立的低饱和雾蓝灰层次:工作面 `#E8ECF3`、阅读面 `#F8F9FC`、用户消息 `#DDE4F1`,主操作使用岩蓝 `#465F91`。岩蓝与工作台品牌色保持同一视觉语言,同时降低亮度和饱和度,避免输入区显得刺眼。这组颜色按阅读层级和对比度选择,不要求遵循固定的相似色公式,也不扩散到地图和顶部工作台。香槟色只用于确认、警告与 Agent 欢迎标记的小面积点缀,不作为普通面板的整块底色。成功、失败、地图业务图层和数据曲线保留各自语义色。
|
||||||
|
|
||||||
|
## 3. 排版规则
|
||||||
|
|
||||||
|
沿用 Noto Sans SC。侧栏标题使用 14px,业务区标题使用 13px,正文和表单标签使用 12px,辅助信息使用 11px。编号、修订号、时间和数量使用等宽数字。标签左对齐,数值列右对齐。
|
||||||
|
|
||||||
|
## 4. 组件规则
|
||||||
|
|
||||||
|
- `WorkbenchSection` 统一业务区标题、说明、状态和操作,不在内部再次堆叠同级标题卡。
|
||||||
|
- `WorkbenchNotice` 使用左侧状态线传达信息、警告与错误,避免大面积色块。
|
||||||
|
- `WorkbenchEmptyState` 只说明当前为空和下一步,不占用过高空间。
|
||||||
|
- 表单控件高度为 40px,按钮使用现有 8px 或 12px 圆角体系。
|
||||||
|
- 主操作每个业务区最多一个。其余操作使用 outline 或 ghost。
|
||||||
|
|
||||||
|
## 5. 布局原则
|
||||||
|
|
||||||
|
每个业务区按“定位、状态、下一步、证据”排列。侧栏内容使用 12px 至 14px 的紧凑间距。复杂原始对象、请求参数、历史和测试资源按需展开。移动端保持单列,不用横向滚动承载业务字段。
|
||||||
|
|
||||||
|
地图背景延伸到顶部导航背后,地图控件和分析正文仍预留 56px 导航高度。独立浮层使用完整亚克力材质,阅读面和控件内部的填色不额外叠加模糊;任务堆叠保留完整圆角和每层 7px 的错位。时间轴容器不足 640px 时省略统计摘要,优先保证日期、图例和操作按钮互不重叠。
|
||||||
|
|
||||||
|
## 6. 层级与深度
|
||||||
|
|
||||||
|
保持既有亚克力半透明材质,在透底、模糊、噪点和边缘高光上调整色调。桌面 Agent dock 使用雾蓝灰实体底板,导航、历史和底部操作区保留同色调的局部亚克力;正文和文字输入区使用冷白实体阅读面,岩蓝只用于主操作、焦点和交互反馈。文字输入与权限、模型、语音和发送操作栏分开承担材质,聚焦边框仅围绕文字输入区。对话内容直接进入消息流,不在顶部重复展示分析摘要。每个功能区只保留一层模糊,历史展开内容与导航共用材质层。
|
||||||
|
|
||||||
|
桌面普通空会话使用浅雾蓝灰工作面和冷白文字输入区,居中输入组件距面板两侧至少 12px,最大宽度 600px,文字输入区初始高度 160px、字号 16px;多行输入的高度按可用视口限制,保持操作栏和推荐问题可见。模型根据当前用户与项目的近期任务、告警、分析成果和普通会话生成最多三条问题,在输入组件下方独立显示来源与时间,点击直接发送;首次开始对话时,同一个表单以 280ms 位移和尺寸过渡回到底部,随后使用 48px 的紧凑文字区和 40px 操作控件,减少动态效果偏好下则立即切换。移动端保留紧凑输入布局,生成的问题仍使用独立悬浮胶囊。推荐异步加载,不阻塞输入;打开空会话、切换模型或新建空会话时触发,普通重渲染和宽度变化不重新生成,模型失败时不以固定问题冒充生成结果。
|
||||||
|
|
||||||
|
| 材质 | 浅色底图 alpha | 影像底图 alpha |
|
||||||
|
|---|---:|---:|
|
||||||
|
| 导航 | 0.64 | 0.80 |
|
||||||
|
| 浮动业务面板 | 0.56 | 0.72 |
|
||||||
|
| 地图控件 | 0.74 | 0.86 |
|
||||||
|
| Agent 移动抽屉和历史 | 0.82 | 0.88 |
|
||||||
|
| Agent 浮动输入区 | 0.86 | 0.90 |
|
||||||
|
| 正文阅读面 | 0.96 | 0.985 |
|
||||||
|
|
||||||
|
分组标题及次级数据使用银灰色差,不叠加投影。模糊仍为面板 24px、控件及 Agent 浮层 18px。地图和影像中的底色应能透过外层材质;报告正文保留更高遮蔽度,保证阅读。
|
||||||
|
|
||||||
|
## 7. 使用约束
|
||||||
|
|
||||||
|
- 必须优先显示当前状态和可执行的下一步。
|
||||||
|
- 调查计划由 Agent 动态发布。默认呈现本次选中的路线和当前待办,未选路线及旧修订按需展开;条件、并行和审核状态只读取明确的结构化字段。
|
||||||
|
- 必须明确 synthetic、只读、结果未知和未配置状态。
|
||||||
|
- 必须保留完整请求号、对象修订和证据来源的可读入口。
|
||||||
|
- 不用颜色代替文字状态。
|
||||||
|
- 不在一个业务区内放置多个同等醒目的主按钮。
|
||||||
|
- 不用大块空卡显示一句空状态。
|
||||||
|
- 不引入第二套字体、主题或 CSS 方案。
|
||||||
|
|
||||||
|
## 8. 响应式与无障碍
|
||||||
|
|
||||||
|
支持 1280px 桌面和 320px、375px 窄屏。所有交互控件保留可见焦点,触控目标至少 40px。弹层受视口高度和宽度限制。hover 仅在支持 hover 的设备启用,按压使用轻微缩放,并尊重 reduced motion。
|
||||||
|
|
||||||
|
表单字段使用共享 `.form-field` 规则:聚焦时只改变原有 1px 边框的颜色,不叠加外环或浏览器默认轮廓。组合输入由最外层字段容器承担焦点边界,内部输入保持无边框;无边框的亚克力输入区使用 1px 内描边,保持材质与布局不变。无效字段保留错误色,按钮与选择控件保留单独的键盘焦点提示。全局焦点样式仅作为基础层兜底,不能覆盖组件自身规则。
|
||||||
|
|
||||||
|
底图选择卡片的选中态使用 1px 主题边框和勾选标记,不附加常驻外环。键盘聚焦使用共享的 1px 内描边;鼠标选中、聚焦与按压均保持卡片尺寸及半透明表面不变。图层面板、独立底图面板与浮动底图控件使用同一规则。
|
||||||
|
|
||||||
|
普通文字与实际背景对比度至少 4.5:1,焦点和必要的非文字状态标记至少 3:1。透明区域按合成背景计算。不支持模糊或降低透明度时保留不透明的银霜、冷瓷层级;强制颜色模式使用系统颜色。主题由现有 Tailwind 与 CSS 变量统一提供,不新增切换入口。
|
||||||
|
|
||||||
|
## 9. 交互提示
|
||||||
|
|
||||||
|
任务面板先显示任务状态和下一步,再提供计划、目标、运行与证据。运行暂停时突出检查点校验或恢复中的一个主操作。外围面板先显示连接状态、历史回放和待提交来源,测试资源与原始请求回执默认按需展开。
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1120" height="490" viewBox="0 0 1120 490">
|
||||||
|
<rect width="1120" height="490" fill="#F5F7FA"/>
|
||||||
|
<g font-family="Noto Sans SC Variable,Noto Sans CJK SC,Microsoft YaHei,sans-serif" fill="#232935">
|
||||||
|
<text x="32" y="46" font-size="24" font-weight="600">宝石蓝 · 银灰亚克力</text>
|
||||||
|
<text x="32" y="76" font-size="14" fill="#4E5566">同色相色阶 + 低彩度互补色 · 色板为不透明基准,亚克力透明度独立设置</text>
|
||||||
|
<rect x="32" y="102" width="192" height="236" rx="8" fill="#4263C7" stroke="#BEC2CD"/>
|
||||||
|
<text x="32" y="373" font-size="20" font-family="Consolas,monospace">#4263C7</text>
|
||||||
|
<text x="32" y="402" font-size="16">宝石蓝 · 主操作 · 品牌</text>
|
||||||
|
<text x="32" y="430" font-size="12" fill="#4E5566" font-family="Consolas,monospace">L 0.529 C 0.160 h 267.06</text>
|
||||||
|
<rect x="248" y="102" width="192" height="236" rx="8" fill="#E6E9F2" stroke="#BEC2CD"/>
|
||||||
|
<text x="248" y="373" font-size="20" font-family="Consolas,monospace">#E6E9F2</text>
|
||||||
|
<text x="248" y="402" font-size="16">银霜 · 亚克力材质</text>
|
||||||
|
<text x="248" y="430" font-size="12" fill="#4E5566" font-family="Consolas,monospace">L 0.935 C 0.012 h 267.06</text>
|
||||||
|
<rect x="464" y="102" width="192" height="236" rx="8" fill="#F5F7FA" stroke="#BEC2CD"/>
|
||||||
|
<text x="464" y="373" font-size="20" font-family="Consolas,monospace">#F5F7FA</text>
|
||||||
|
<text x="464" y="402" font-size="16">冷瓷 · 阅读 · 表单</text>
|
||||||
|
<text x="464" y="430" font-size="12" fill="#4E5566" font-family="Consolas,monospace">L 0.975 C 0.005 h 267.06</text>
|
||||||
|
<rect x="680" y="102" width="192" height="236" rx="8" fill="#D6C294" stroke="#BEC2CD"/>
|
||||||
|
<text x="680" y="373" font-size="20" font-family="Consolas,monospace">#D6C294</text>
|
||||||
|
<text x="680" y="402" font-size="16">香槟 · 互补暖色点缀</text>
|
||||||
|
<text x="680" y="430" font-size="12" fill="#4E5566" font-family="Consolas,monospace">L 0.820 C 0.065 h 87.06</text>
|
||||||
|
<rect x="896" y="102" width="192" height="236" rx="8" fill="#232935" stroke="#BEC2CD"/>
|
||||||
|
<text x="896" y="373" font-size="20" font-family="Consolas,monospace">#232935</text>
|
||||||
|
<text x="896" y="402" font-size="16">深墨 · 标题 · 正文</text>
|
||||||
|
<text x="896" y="430" font-size="12" fill="#4E5566" font-family="Consolas,monospace">L 0.280 C 0.025 h 267.06</text>
|
||||||
|
<text x="32" y="468" font-size="13" fill="#4E5566">主色 h = 267.06°;同色色阶保持 h;香槟色 h = (267.06° + 180°) mod 360° = 87.06°</text>
|
||||||
|
</g></svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,115 @@
|
|||||||
|
# 非地图面板整理
|
||||||
|
|
||||||
|
2026-09-17,范围限供水前端,不改变地图业务、OpenCode、数据库表或环境配置。
|
||||||
|
|
||||||
|
## 视觉与交互
|
||||||
|
|
||||||
|
沿用现有浅色阅读面、Noto Sans SC 中文字体、蓝色操作强调和状态徽标。优先修复可读性、对齐和窄屏裁切,不引入第二套主题、字体或组件库。
|
||||||
|
|
||||||
|
- `FieldSelect` 复用现有 Radix Select:统一标签、40px 最小高度、长选项换行、禁用态、键盘选择和弹层层级。清空受控值后回到占位提示,不触发额外业务动作。
|
||||||
|
- `Checkbox` 保留原生输入与标签语义,统一勾选、焦点和禁用样式。反馈确认仍须用户主动勾选。
|
||||||
|
- `Disclosure` 保留原生 details/summary 语义,统一展开箭头、焦点和点击区域。
|
||||||
|
- 调查目标和外围历史回放的日期字段单列展示,避免窄面板内日期与时分被裁切。两处均已使用共享自定义日期时间弹窗,保留各自本地时间/UTC 的业务语义。
|
||||||
|
- 手机端 Agent 欢迎区改为紧凑横排,避免被底部输入区遮挡。
|
||||||
|
- 桌面成果专注视图为 Agent 折叠栏预留空间,标题不再被覆盖,仍可重新展开助手。
|
||||||
|
- 成果图表显式使用页面的中文字体栈。此前 ECharts 画布仍以 `11px sans-serif` 绘制中文图例,在测试环境显示方框;浏览器画布探针已验证修复前失败、修复后在 1280/375px 通过,截图可见完整“调度下限”。源码只有这一处 ReactECharts 入口。
|
||||||
|
|
||||||
|
## 检查覆盖
|
||||||
|
|
||||||
|
| 面板 | 本次检查 |
|
||||||
|
| --- | --- |
|
||||||
|
| 调查任务、生命周期、运行与历史结果 | 表单、确认区、折叠内容、长标识、320/375/1280px |
|
||||||
|
| 已保存比较结论、Evidence 查看 | 四分支、加载/空/错误态、只读重试、键盘下拉、320/375/1280px |
|
||||||
|
| 外围样板、模拟数据、历史回放 | 下拉、日期、状态徽标、任务历史、375px |
|
||||||
|
| 反馈关联、人工复核 | 确认勾选、展开内容、禁用与只读边界、窄屏 |
|
||||||
|
| Agent 对话与历史 | 桌面抽屉、320/375px 欢迎区与滚动容器边界 |
|
||||||
|
| 工况列表与详情 | 375px 抽屉布局 |
|
||||||
|
| 成果预览与文档 | 1280/375px,桌面标题与折叠栏几何边界 |
|
||||||
|
| 图层、测量、标注、更多、开发面板 | 非地图浮层边界,开发面板表单控件统一 |
|
||||||
|
|
||||||
|
`FeatureInsightPanel` 当前只有定义,没有运行入口,未伪造页面声称已完成运行检查。实际要素详情使用地图上的 `FeaturePopover`,不在本次非地图面板范围。
|
||||||
|
|
||||||
|
## 验证与限制
|
||||||
|
|
||||||
|
最终代码检查:`pnpm typecheck`、`pnpm lint`、全量 68 文件 / 350 项 Vitest、`pnpm build` 通过。生产构建仍提示部分 chunk 超过 500kB,本次未扩展打包优化范围。调查/反馈/外围/比较/生命周期的 29 项与非地图布局的 5 项专项浏览器用例通过,比较区新增的弹层不透明度和背景色检查另行重跑 6 项通过。非地图布局用例同时覆盖开发面板键盘选项和成果图表画布字体。
|
||||||
|
|
||||||
|
浏览器用例使用显式模拟接口,不代表真实账号、Agent 和 PG 的全链路验收。主要回归在 `tests/browser/non-map-panels-layout.e2e.ts`、`run-comparison.e2e.ts` 及既有调查、反馈、外围与生命周期测试中;截图保存在忽略的 `test-results/non-map-panels/`、`test-results/run-comparison/` 等目录,不作为发布资源。
|
||||||
|
|
||||||
|
本次也运行了完整浏览器套件,当次为 66 通过、33 失败,不能宣称全量通过。已确认的失败包括旧用例依赖 Agent 初始展开或已不存在的“工作台导航”、旧截图基线,以及新移动用例当时选择器不唯一。后者已改为语义化 `role=log` 并在专项回归中通过;其他旧用例与真实地图依赖仍需单独梳理,不通过修改产品默认行为或批量更新截图掩盖失败。
|
||||||
|
|
||||||
|
运行内容来自现有接口,无需手动替换界面占位数据。人工验收时请在桌面全宽与 375px 下检查长任务名称、日期输入、下拉键盘操作和成果标题。
|
||||||
|
|
||||||
|
## 后续交互回归
|
||||||
|
|
||||||
|
继续核查旧失败用例时发现并修复了两处真实缺陷:
|
||||||
|
|
||||||
|
- 窄桌面展开工况详情会自动收起 Agent;此前聊天草稿存在被卸载的面板内部,重新展开时丢失。现在由稳定的工作台 hook 持有草稿,桌面和手机面板共享受控值,新建会话仍清空。此状态只在当前页面内保留,不是刷新后的持久化草稿,也没有新增存储层。面板仍可卸载,避免为了保留文字让隐藏的语音控件继续活动。
|
||||||
|
- 新增调查/外围入口后,1536px 顶栏的异常处置与调查按钮发生重叠。现在保留按钮宽度、收紧组间距,仅在 1800px 以上展开次要按钮文字;较窄桌面省略模型只读提示,完整操作名称仍通过无障碍标签提供。手机标题显示短称“供水”,而不是省略号。
|
||||||
|
|
||||||
|
`workspace-main-frame.e2e.ts` 现在验证默认收起、主动展开后的相邻布局、收起恢复、成果 split/focus 切换,并检查 320/375/1024/1280/1440/1536/1600/1800/1920px 顶栏按钮不重叠、标题不截断。顶栏几何断言修复前失败,修复后通过。截图位于 `test-results/workspace-regression/`。
|
||||||
|
|
||||||
|
`agent-voice-hydration.e2e.ts` 与 `agent-panel-resize.e2e.ts` 改为先通过真实按钮打开助手,再检查语音、几何和草稿状态;不再假定 Agent 初始展开或折叠后保留旧版 72px 导航宽度。工况自动收起场景使用其实际适用的 1280px 宽度,保留了未发送草稿不丢失的断言。布局测试按实时几何等待入场动画完成,不固定动画中途的坐标,也没有加入固定 sleep。
|
||||||
|
|
||||||
|
该轮完整浏览器套件为 79 通过、21 失败,共 100 项。仍有旧导航/默认展开假设、截图基线与动画时机相关失败,不能宣称全量通过,也未批量更新基线。
|
||||||
|
|
||||||
|
## 调查任务日期时间补充
|
||||||
|
|
||||||
|
根因是原先只统一了 `datetime-local` 输入框外观,其日历和时间弹窗仍由浏览器绘制。调查任务的开始、结束时窗已替换为共享 `DateTimeField`:沿用浅色面板、蓝色选中态和现有 Radix 弹层/下拉,不引入新依赖。
|
||||||
|
|
||||||
|
- 日历、月份、年份、小时和分钟均在应用内绘制,窄屏弹窗限制宽高并允许纵向滚动。
|
||||||
|
- 点击确认才更新值,取消或 Esc 放弃草稿并将焦点返回字段;日历支持方向键和 PageUp/PageDown。
|
||||||
|
- 年份编辑保持输入焦点,支持闰年和真实月份天数;无效源值不会被静默截断后保存。
|
||||||
|
- 已关闭任务不可编辑。组件仍输出本地 `YYYY-MM-DDTHH:mm`,原有目标保存逻辑负责转换 UTC,不改变业务时区语义。
|
||||||
|
- `date-time-field.test.tsx` 覆盖组件边界。
|
||||||
|
|
||||||
|
调查任务两个日期字段均已替换;同类的外围历史回放已在后续批次统一,见下节。
|
||||||
|
|
||||||
|
补充后的验证:`pnpm typecheck`、`pnpm lint`、69 文件 / 357 项单元测试和 `pnpm build` 全部通过;日期时间、调查面板、非地图布局、运行控制及任务生命周期的 20 项浏览器回归通过。生产构建仍有既有大 chunk 提示。上述浏览器检查使用模拟接口,不替代真实系统验收。
|
||||||
|
|
||||||
|
日期回归用例做过红绿验证:临时将调查时窗重新接回原生 `datetime-local`,确认/提交用例因缺少自定义字段入口失败;恢复 `DateTimeField` 后重新运行全部 3 项日期用例通过。代码未提交。
|
||||||
|
|
||||||
|
按用户要求,每轮 Playwright 结束后检查其浏览器和临时 Vite 服务是否退出,不关闭用户已有的 5173 开发服务。
|
||||||
|
|
||||||
|
## 外围历史回放日期补充
|
||||||
|
|
||||||
|
外围面板的 `HistoryTime` 同样仍由浏览器绘制日期时间弹窗,已删除该包装并复用 `DateTimeField`。保留“开始(UTC)/结束(UTC)”标签和原来的 UTC 查询拼接,不套用调查任务的本地时间转换。扫描 `src/` 已无原生 date/time/datetime-local 输入入口。
|
||||||
|
|
||||||
|
`peripheral-sample-panel.e2e.ts` 新增实际交互断言:修改起止日期时间后只有点击“读取历史读数”才发送 GET;浏览器为 Asia/Shanghai 时,08:15 仍按 08:15Z 查询;取消不改值、不发查询,也没有任何外围写请求。320/375/1280px 弹窗边界与无横向溢出通过,截图位于忽略的 `test-results/peripheral-datetime/`。
|
||||||
|
|
||||||
|
红绿验证:替换前该用例因缺少自定义日期入口失败,替换后外围面板、调查日期时间、反馈导入共 14 项浏览器测试通过。类型检查、Lint、69 文件 / 357 项单元测试与生产构建通过,构建仍提示既有大 chunk。仍使用模拟接口,不代表真实外围服务验收;完整浏览器套件的失败不因本轮专项通过而视为消除。
|
||||||
|
|
||||||
|
## 问答与流式测试入口修订
|
||||||
|
|
||||||
|
重新运行确认旧问答/流式用例默认寻找尚未展开的 Agent 输入框;显式展开后还暴露了授权项目模拟缺失。请求失败探针记录 `/api/v1/projects` 连接拒绝,Agent 客户端因此未进入回复与模型查询。现在使用明确的测试运行配置和已有项目/SCADA mock,不改产品授权流程;临时日志已移除。
|
||||||
|
|
||||||
|
流式用例另外仍断言旧浮动简报和浮动输入区。当前 `desktop-dock` 分支将简报和输入区放入正常布局,已改为验证简报在对话区上方、输入框在滚动区域下方、分析中操作禁用及区域不重叠;进度窗口几何采样、计划卡片、结束滚动、保持展开、选中文本朗读等原断言仍保留。问答回复的精确请求号与 payload 断言未放宽。两个文件共 3 项重跑通过,其中问答桌面和流式用例此前失败。
|
||||||
|
|
||||||
|
本轮最终完整浏览器套件:103 项中 85 通过、18 失败,耗时 2.7 分钟。失败分布为 `app.e2e.ts` 4 项、`agent-acrylic.e2e.ts` 4 项、开发面板/地图通知/移动转桌面/窄桌面工况/地图相机稳定性各 1 项、视觉基线 5 项。不得将全部失败归因于旧断言,仍需逐项核查实际行为。此前工况动画时机失败在本轮未复现,未作修复,仍作为时序风险保留;问答与流式是本轮明确修订后通过的两项。完整结果与失败上下文位于忽略的 `test-results/non-map-followup-full/`。
|
||||||
|
|
||||||
|
## 历史菜单、Esc 分层与全量回归收尾
|
||||||
|
|
||||||
|
继续核查上述 18 个失败后,确认并修复了以下真实行为问题:
|
||||||
|
|
||||||
|
- 手机 Agent 内的 Radix 模型菜单和权限模式菜单已处理 Esc,并将事件标记为 `defaultPrevented`,但外层 `MobileWorkbenchSheet` 的 window 监听仍无条件关闭抽屉。外层现在只处理未被内层消费的 Esc。模型和权限菜单各自关闭后,焦点返回原触发按钮;没有内层菜单时,Esc 仍关闭抽屉。
|
||||||
|
- Dev Panel 有相同的事件分层缺陷。`FieldSelect` 打开时第一次 Esc 只关闭选择菜单并返回焦点,第二次 Esc 才关闭开发面板。
|
||||||
|
- 手机端已打开 Agent 时跨越 1024px 进入桌面,原逻辑先清空移动抽屉状态,导致用户打开 Agent 的意图丢失。现在先将移动 Agent 状态迁移为桌面展开状态,再清空移动抽屉。
|
||||||
|
- Dev Panel 打开时,地图工具栏仍在面板下面渲染。现在开发面板显示期间不挂载桌面地图工具栏与其工具面板。
|
||||||
|
- 1024px 下展开工况任务会自动折叠 Agent,但 880px 工况面板与 72px 折叠栏重叠 4px。布局变量现显式记录折叠栏宽度,工况面板在该宽度下收敛到 852px,二者保留 24px 间距。
|
||||||
|
|
||||||
|
同时修正了三类失真的回归断言。地图容器变宽或变窄时,MapLibre 的 `resize()` 会发出 `movestart`,但经纬度、缩放、方位角和俯仰并未改变;相机稳定性用例现在直接比较这五项参数。地图通知位置测试改用确定会生成通知的“操作参考”入口。工况材质测试等待入场动画结束后再读取 transform 和 will-change,不再采样动画中间帧。焦点、几何和业务请求断言均保留。
|
||||||
|
|
||||||
|
320px 与 375px 的模型菜单截图位于 `test-results/agent-history-ui/320-model-menu.png` 和 `375-model-menu.png`,长标题重命名截图位于同一目录。模型名称、说明和菜单边界完整,权限模式、模型、语音和发送控件没有重叠。五张过期的工作台视觉基线经逐张检查后更新,覆盖桌面浅色/卫星底图、1920px Agent 收起状态、手机启动器和手机 Agent 半屏;手机工况卫星图无需更新。视觉用例更新后再次无更新参数运行,6/6 通过。
|
||||||
|
|
||||||
|
最终验证结果:`pnpm typecheck`、`pnpm lint`、69 文件 / 357 项 Vitest、`pnpm build` 全部通过。构建继续报告既有的 500kB 以上 chunk 提示,CSS 校验和 TTS bundle 通过。完整 Playwright 套件为 104/104 通过,耗时 2.4 分钟,结果位于忽略的 `test-results/non-map-followup-final-green/`。这些浏览器用例仍使用模拟接口,不替代真实账号、Agent、PG 和外围服务的全链路验收。
|
||||||
|
|
||||||
|
## 新增业务组件统一设计
|
||||||
|
|
||||||
|
2026-09-18 对调查任务与外围样板的新组件完成统一重构。完整规则见 `docs/component-design-system.md`,视觉方向是浅色专业调度台,保留地图为主工作区,并将侧栏内容稳定为“定位、状态、下一步、证据”的阅读顺序。
|
||||||
|
|
||||||
|
- 新增 `WorkbenchSection`、`WorkbenchNotice` 和 `WorkbenchEmptyState`,统一标题、状态、提示和空态,减少同级彩色卡片与大面积空白。
|
||||||
|
- 生命周期确认、定时计划、目标编译、运行控制、运行对比、人工复核和 Evidence 导入统一状态语言与操作层级。
|
||||||
|
- 外围样板将连接与场景、历史回放、待提交来源、已有任务放在前面;synthetic 数据、测试工单、测试数据和 request_id 查询移到辅助区并默认折叠。
|
||||||
|
- `FieldSelect`、`Checkbox`、`Disclosure` 和 `DateTimeField` 统一焦点、hover、按压和禁用反馈。日期时间弹层继续支持 Esc 取消并返回焦点。
|
||||||
|
- 数据表使用等宽数字并将数值右对齐;320、375 和 1280px 下均无横向溢出。
|
||||||
|
|
||||||
|
本轮最终检查为 `pnpm typecheck`、`pnpm lint`、76 文件 / 395 项 Vitest 和 `pnpm build` 通过。新增组件相关的 17 文件 / 118 项单测通过,核心业务面板 Playwright 为 31/31 通过。完整浏览器套件共 110 项,并发运行 108 项通过;2 项在等待 Toast 退出时超过 15 秒,随后单进程精确复跑 2/2 通过。截图位于忽略的 `test-results/task-lifecycle/`、`run-control/`、`run-comparison/`、`peripheral-datetime/` 和 `/tmp/feedback-*.png`。
|
||||||
@@ -4,8 +4,12 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.8.0",
|
"packageManager": "pnpm@11.8.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.22.1 <25"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"export:answer-ui-contract": "node scripts/export-answer-ui-contract.mjs",
|
||||||
"build": "tsc --noEmit && vite build && pnpm build:verify-css && pnpm build:tts",
|
"build": "tsc --noEmit && vite build && pnpm build:verify-css && pnpm build:tts",
|
||||||
"build:verify-css": "node scripts/verify-production-css.mjs",
|
"build:verify-css": "node scripts/verify-production-css.mjs",
|
||||||
"build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs",
|
"build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs",
|
||||||
@@ -20,6 +24,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/react": "^4.0.9",
|
"@ai-sdk/react": "^4.0.9",
|
||||||
"@fontsource-variable/noto-sans-sc": "5.3.0",
|
"@fontsource-variable/noto-sans-sc": "5.3.0",
|
||||||
|
"@json-render/core": "^0.21.0",
|
||||||
|
"@json-render/react": "^0.21.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.14",
|
"@radix-ui/react-accordion": "^1.2.14",
|
||||||
"@radix-ui/react-collapsible": "^1.1.14",
|
"@radix-ui/react-collapsible": "^1.1.14",
|
||||||
"@radix-ui/react-dialog": "^1.1.17",
|
"@radix-ui/react-dialog": "^1.1.17",
|
||||||
@@ -45,9 +51,10 @@
|
|||||||
"katex": "^0.17.0",
|
"katex": "^0.17.0",
|
||||||
"keycloak-js": "26.2.4",
|
"keycloak-js": "26.2.4",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"maplibre-gl": "^4.7.1",
|
"maplibre-gl": "^6.4.1",
|
||||||
"motion": "^12.40.0",
|
"motion": "^12.40.0",
|
||||||
"nanoid": "^5.1.14",
|
"nanoid": "^5.1.14",
|
||||||
|
"pmtiles": "4.3.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"shiki": "^4.2.0",
|
"shiki": "^4.2.0",
|
||||||
@@ -84,7 +91,8 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^7.0.0",
|
"typescript": "^7.0.0",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9",
|
||||||
|
"zod-to-json-schema": "^3.25.2"
|
||||||
},
|
},
|
||||||
"msw": {
|
"msw": {
|
||||||
"workerDirectory": [
|
"workerDirectory": [
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default defineConfig({
|
|||||||
trace: "on-first-retry"
|
trace: "on-first-retry"
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: `TJWATER_AUTH_MODE=disabled TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
|
command: `TJWATER_VITE_CACHE_DIR=node_modules/.vite-playwright-${testPort} TJWATER_AUTH_MODE=disabled TJWATER_AGENT_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
|
||||||
url: baseURL,
|
url: baseURL,
|
||||||
reuseExistingServer: false
|
reuseExistingServer: false
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
'@json-render/core>zod': 3.25.76
|
||||||
|
'@json-render/react>zod': 3.25.76
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -14,6 +18,12 @@ importers:
|
|||||||
'@fontsource-variable/noto-sans-sc':
|
'@fontsource-variable/noto-sans-sc':
|
||||||
specifier: 5.3.0
|
specifier: 5.3.0
|
||||||
version: 5.3.0
|
version: 5.3.0
|
||||||
|
'@json-render/core':
|
||||||
|
specifier: ^0.21.0
|
||||||
|
version: 0.21.0(zod@3.25.76)
|
||||||
|
'@json-render/react':
|
||||||
|
specifier: ^0.21.0
|
||||||
|
version: 0.21.0(react@19.2.7)(zod@3.25.76)
|
||||||
'@radix-ui/react-accordion':
|
'@radix-ui/react-accordion':
|
||||||
specifier: ^1.2.14
|
specifier: ^1.2.14
|
||||||
version: 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
@@ -90,14 +100,17 @@ importers:
|
|||||||
specifier: ^0.468.0
|
specifier: ^0.468.0
|
||||||
version: 0.468.0(react@19.2.7)
|
version: 0.468.0(react@19.2.7)
|
||||||
maplibre-gl:
|
maplibre-gl:
|
||||||
specifier: ^4.7.1
|
specifier: ^6.4.1
|
||||||
version: 4.7.1
|
version: 6.15.0
|
||||||
motion:
|
motion:
|
||||||
specifier: ^12.40.0
|
specifier: ^12.40.0
|
||||||
version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
nanoid:
|
nanoid:
|
||||||
specifier: ^5.1.14
|
specifier: ^5.1.14
|
||||||
version: 5.1.16
|
version: 5.1.16
|
||||||
|
pmtiles:
|
||||||
|
specifier: 4.3.0
|
||||||
|
version: 4.3.0
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.7
|
specifier: ^19.2.7
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
@@ -204,6 +217,9 @@ importers:
|
|||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.1.9
|
specifier: ^4.1.9
|
||||||
version: 4.1.10(@types/node@24.13.3)(jsdom@25.0.1)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0))
|
version: 4.1.10(@types/node@24.13.3)(jsdom@25.0.1)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0))
|
||||||
|
zod-to-json-schema:
|
||||||
|
specifier: ^3.25.2
|
||||||
|
version: 3.25.2(zod@3.25.76)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -737,34 +753,45 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
'@mapbox/geojson-rewind@0.5.2':
|
'@json-render/core@0.21.0':
|
||||||
resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==}
|
resolution: {integrity: sha512-vysEbTjO1y8mEgAsp0tztRyaWCkNsBrG8MhAqbndGK3qmMXjMRdCNtk21+AQRNKdRelFGVAwBgtUNW8tfjQlsw==}
|
||||||
hasBin: true
|
peerDependencies:
|
||||||
|
zod: 3.25.76
|
||||||
|
|
||||||
|
'@json-render/react@0.21.0':
|
||||||
|
resolution: {integrity: sha512-z/fZg0L8fJe2dnnQjWkmMw409iBclSWcOqdjoBy8eLHeBr/7F7VipzkY1CfgA2Fa5uW+zxZ54OvWMtdDcsp/yg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^19.2.3
|
||||||
|
|
||||||
'@mapbox/jsonlint-lines-primitives@2.0.3':
|
'@mapbox/jsonlint-lines-primitives@2.0.3':
|
||||||
resolution: {integrity: sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==}
|
resolution: {integrity: sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==}
|
||||||
engines: {node: '>= 22'}
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
'@mapbox/point-geometry@0.1.0':
|
'@mapbox/point-geometry@1.1.0':
|
||||||
resolution: {integrity: sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==}
|
resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==}
|
||||||
|
|
||||||
'@mapbox/tiny-sdf@2.2.0':
|
'@mapbox/tiny-sdf@2.3.0':
|
||||||
resolution: {integrity: sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==}
|
resolution: {integrity: sha512-gBvcNDBBB66bgQYvnaJ6Jk+VCaMmVq5+IZR2kWPisT88Z8s0kBQt6hfVi8QgRW8Vpg0UWZzBA4ZSyzHHC+VeRA==}
|
||||||
|
|
||||||
'@mapbox/unitbezier@0.0.1':
|
'@mapbox/unitbezier@1.0.0':
|
||||||
resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==}
|
resolution: {integrity: sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==}
|
||||||
|
|
||||||
'@mapbox/vector-tile@1.3.1':
|
'@mapbox/vector-tile@3.0.0':
|
||||||
resolution: {integrity: sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==}
|
resolution: {integrity: sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==}
|
||||||
|
|
||||||
'@mapbox/whoots-js@3.1.0':
|
'@maplibre/geojson-vt@6.3.0':
|
||||||
resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==}
|
resolution: {integrity: sha512-3Zw2jsU9pIJG3G2CQFbLjWj2MlAHodhD7cSY+15iywUuLmmkgzcKRnueVRZ4AcC/5wOtfhidcJAAzBpoBNs3Xg==}
|
||||||
engines: {node: '>=6.0.0'}
|
|
||||||
|
|
||||||
'@maplibre/maplibre-gl-style-spec@20.4.0':
|
'@maplibre/maplibre-gl-style-spec@26.4.4':
|
||||||
resolution: {integrity: sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==}
|
resolution: {integrity: sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@maplibre/mlt@1.3.0':
|
||||||
|
resolution: {integrity: sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==}
|
||||||
|
|
||||||
|
'@maplibre/vt-pbf@4.3.2':
|
||||||
|
resolution: {integrity: sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==}
|
||||||
|
|
||||||
'@mermaid-js/parser@1.2.0':
|
'@mermaid-js/parser@1.2.0':
|
||||||
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
|
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
|
||||||
|
|
||||||
@@ -1725,9 +1752,6 @@ packages:
|
|||||||
'@types/estree@1.0.9':
|
'@types/estree@1.0.9':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
'@types/geojson-vt@3.2.5':
|
|
||||||
resolution: {integrity: sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==}
|
|
||||||
|
|
||||||
'@types/geojson@7946.0.16':
|
'@types/geojson@7946.0.16':
|
||||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||||
|
|
||||||
@@ -1740,12 +1764,6 @@ packages:
|
|||||||
'@types/katex@0.16.8':
|
'@types/katex@0.16.8':
|
||||||
resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
|
resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
|
||||||
|
|
||||||
'@types/mapbox__point-geometry@0.1.4':
|
|
||||||
resolution: {integrity: sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==}
|
|
||||||
|
|
||||||
'@types/mapbox__vector-tile@1.3.4':
|
|
||||||
resolution: {integrity: sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==}
|
|
||||||
|
|
||||||
'@types/mdast@4.0.4':
|
'@types/mdast@4.0.4':
|
||||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||||
|
|
||||||
@@ -1755,9 +1773,6 @@ packages:
|
|||||||
'@types/node@24.13.3':
|
'@types/node@24.13.3':
|
||||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||||
|
|
||||||
'@types/pbf@3.0.5':
|
|
||||||
resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==}
|
|
||||||
|
|
||||||
'@types/react-dom@19.2.3':
|
'@types/react-dom@19.2.3':
|
||||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1772,9 +1787,6 @@ packages:
|
|||||||
'@types/statuses@2.0.6':
|
'@types/statuses@2.0.6':
|
||||||
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
||||||
|
|
||||||
'@types/supercluster@7.1.3':
|
|
||||||
resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==}
|
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7':
|
'@types/trusted-types@2.0.7':
|
||||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
@@ -2019,6 +2031,9 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
bidi-js@1.1.0:
|
||||||
|
resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==}
|
||||||
|
|
||||||
brace-expansion@1.1.16:
|
brace-expansion@1.1.16:
|
||||||
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
|
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
|
||||||
|
|
||||||
@@ -2353,8 +2368,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
earcut@3.2.3:
|
earcut@3.2.4:
|
||||||
resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==}
|
resolution: {integrity: sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==}
|
||||||
|
|
||||||
echarts-for-react@3.0.6:
|
echarts-for-react@3.0.6:
|
||||||
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
|
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
|
||||||
@@ -2456,6 +2471,7 @@ packages:
|
|||||||
eslint@9.39.5:
|
eslint@9.39.5:
|
||||||
resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
|
resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
jiti: '*'
|
jiti: '*'
|
||||||
@@ -2531,6 +2547,9 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
fflate@0.8.3:
|
||||||
|
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -2581,9 +2600,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
geojson-vt@4.0.3:
|
|
||||||
resolution: {integrity: sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==}
|
|
||||||
|
|
||||||
get-caller-file@2.0.5:
|
get-caller-file@2.0.5:
|
||||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||||
engines: {node: 6.* || 8.* || >= 10.*}
|
engines: {node: 6.* || 8.* || >= 10.*}
|
||||||
@@ -2604,10 +2620,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
get-stream@6.0.1:
|
|
||||||
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
|
|
||||||
engines: {node: '>=10'}
|
|
||||||
|
|
||||||
gl-matrix@3.4.4:
|
gl-matrix@3.4.4:
|
||||||
resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
|
resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
|
||||||
|
|
||||||
@@ -2615,10 +2627,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
global-prefix@4.0.0:
|
|
||||||
resolution: {integrity: sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==}
|
|
||||||
engines: {node: '>=16'}
|
|
||||||
|
|
||||||
globals@14.0.0:
|
globals@14.0.0:
|
||||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2724,9 +2732,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
ieee754@1.2.1:
|
|
||||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
|
||||||
|
|
||||||
ignore@5.3.2:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -2746,10 +2751,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
ini@4.1.3:
|
|
||||||
resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
|
|
||||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
|
||||||
|
|
||||||
inline-style-parser@0.2.7:
|
inline-style-parser@0.2.7:
|
||||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||||
|
|
||||||
@@ -2797,10 +2798,6 @@ packages:
|
|||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
isexe@3.1.5:
|
|
||||||
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
|
|
||||||
isomorphic-ws@5.0.0:
|
isomorphic-ws@5.0.0:
|
||||||
resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
|
resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2871,10 +2868,6 @@ packages:
|
|||||||
khroma@2.1.0:
|
khroma@2.1.0:
|
||||||
resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==}
|
resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==}
|
||||||
|
|
||||||
kind-of@6.0.3:
|
|
||||||
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
|
|
||||||
engines: {node: '>=0.10.0'}
|
|
||||||
|
|
||||||
layout-base@1.0.2:
|
layout-base@1.0.2:
|
||||||
resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==}
|
resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==}
|
||||||
|
|
||||||
@@ -2990,8 +2983,8 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
maplibre-gl@4.7.1:
|
maplibre-gl@6.15.0:
|
||||||
resolution: {integrity: sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==}
|
resolution: {integrity: sha512-kxFetbvUHDOVaAP1CYb226Mdk2QhzIPddqM0xJTFD35wu78Mpd6NM7+H68ALjc6Oa0kAOX5EAdMKyvD2u3qfcg==}
|
||||||
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
|
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
|
||||||
|
|
||||||
markdown-table@3.0.4:
|
markdown-table@3.0.4:
|
||||||
@@ -3329,8 +3322,8 @@ packages:
|
|||||||
pathe@2.0.3:
|
pathe@2.0.3:
|
||||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
pbf@3.3.0:
|
pbf@5.1.2:
|
||||||
resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==}
|
resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
@@ -3354,6 +3347,9 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
pmtiles@4.3.0:
|
||||||
|
resolution: {integrity: sha512-wnzQeSiYT/MyO63o7AVxwt7+uKqU0QUy2lHrivM7GvecNy0m1A4voVyGey7bujnEW5Hn+ZzLdvHPoFaqrOzbPA==}
|
||||||
|
|
||||||
points-on-curve@0.2.0:
|
points-on-curve@0.2.0:
|
||||||
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
|
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
|
||||||
|
|
||||||
@@ -3390,9 +3386,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
quickselect@2.0.0:
|
|
||||||
resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==}
|
|
||||||
|
|
||||||
quickselect@3.0.0:
|
quickselect@3.0.0:
|
||||||
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
|
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
|
||||||
|
|
||||||
@@ -3505,6 +3498,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
require-from-string@2.0.2:
|
||||||
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
resolve-from@4.0.0:
|
resolve-from@4.0.0:
|
||||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -3640,9 +3637,6 @@ packages:
|
|||||||
stylis@4.4.0:
|
stylis@4.4.0:
|
||||||
resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
|
resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
|
||||||
|
|
||||||
supercluster@8.0.1:
|
|
||||||
resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==}
|
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3916,9 +3910,6 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
vt-pbf@3.1.3:
|
|
||||||
resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==}
|
|
||||||
|
|
||||||
w3c-xmlserializer@5.0.0:
|
w3c-xmlserializer@5.0.0:
|
||||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3948,11 +3939,6 @@ packages:
|
|||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
which@4.0.0:
|
|
||||||
resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
|
|
||||||
engines: {node: ^16.13.0 || >=18.0.0}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -4004,6 +3990,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
zod-to-json-schema@3.25.2:
|
||||||
|
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^3.25.28 || ^4
|
||||||
|
|
||||||
zod@3.25.76:
|
zod@3.25.76:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
@@ -4542,35 +4533,54 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
'@mapbox/geojson-rewind@0.5.2':
|
'@json-render/core@0.21.0(zod@3.25.76)':
|
||||||
dependencies:
|
dependencies:
|
||||||
get-stream: 6.0.1
|
zod: 3.25.76
|
||||||
minimist: 1.2.8
|
|
||||||
|
'@json-render/react@0.21.0(react@19.2.7)(zod@3.25.76)':
|
||||||
|
dependencies:
|
||||||
|
'@json-render/core': 0.21.0(zod@3.25.76)
|
||||||
|
react: 19.2.7
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- zod
|
||||||
|
|
||||||
'@mapbox/jsonlint-lines-primitives@2.0.3': {}
|
'@mapbox/jsonlint-lines-primitives@2.0.3': {}
|
||||||
|
|
||||||
'@mapbox/point-geometry@0.1.0': {}
|
'@mapbox/point-geometry@1.1.0': {}
|
||||||
|
|
||||||
'@mapbox/tiny-sdf@2.2.0': {}
|
'@mapbox/tiny-sdf@2.3.0': {}
|
||||||
|
|
||||||
'@mapbox/unitbezier@0.0.1': {}
|
'@mapbox/unitbezier@1.0.0': {}
|
||||||
|
|
||||||
'@mapbox/vector-tile@1.3.1':
|
'@mapbox/vector-tile@3.0.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mapbox/point-geometry': 0.1.0
|
'@mapbox/point-geometry': 1.1.0
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
pbf: 5.1.2
|
||||||
|
|
||||||
'@mapbox/whoots-js@3.1.0': {}
|
'@maplibre/geojson-vt@6.3.0':
|
||||||
|
dependencies:
|
||||||
|
kdbush: 4.1.0
|
||||||
|
|
||||||
'@maplibre/maplibre-gl-style-spec@20.4.0':
|
'@maplibre/maplibre-gl-style-spec@26.4.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mapbox/jsonlint-lines-primitives': 2.0.3
|
'@mapbox/jsonlint-lines-primitives': 2.0.3
|
||||||
'@mapbox/unitbezier': 0.0.1
|
'@mapbox/unitbezier': 1.0.0
|
||||||
json-stringify-pretty-compact: 4.0.0
|
json-stringify-pretty-compact: 4.0.0
|
||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
quickselect: 2.0.0
|
quickselect: 3.0.0
|
||||||
rw: 1.3.3
|
|
||||||
tinyqueue: 3.0.0
|
tinyqueue: 3.0.0
|
||||||
|
|
||||||
|
'@maplibre/mlt@1.3.0':
|
||||||
|
dependencies:
|
||||||
|
'@mapbox/point-geometry': 1.1.0
|
||||||
|
|
||||||
|
'@maplibre/vt-pbf@4.3.2':
|
||||||
|
dependencies:
|
||||||
|
'@mapbox/point-geometry': 1.1.0
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
pbf: 5.1.2
|
||||||
|
|
||||||
'@mermaid-js/parser@1.2.0':
|
'@mermaid-js/parser@1.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@chevrotain/types': 11.1.2
|
'@chevrotain/types': 11.1.2
|
||||||
@@ -5487,10 +5497,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
'@types/geojson-vt@3.2.5':
|
|
||||||
dependencies:
|
|
||||||
'@types/geojson': 7946.0.16
|
|
||||||
|
|
||||||
'@types/geojson@7946.0.16': {}
|
'@types/geojson@7946.0.16': {}
|
||||||
|
|
||||||
'@types/hast@3.0.5':
|
'@types/hast@3.0.5':
|
||||||
@@ -5501,14 +5507,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/katex@0.16.8': {}
|
'@types/katex@0.16.8': {}
|
||||||
|
|
||||||
'@types/mapbox__point-geometry@0.1.4': {}
|
|
||||||
|
|
||||||
'@types/mapbox__vector-tile@1.3.4':
|
|
||||||
dependencies:
|
|
||||||
'@types/geojson': 7946.0.16
|
|
||||||
'@types/mapbox__point-geometry': 0.1.4
|
|
||||||
'@types/pbf': 3.0.5
|
|
||||||
|
|
||||||
'@types/mdast@4.0.4':
|
'@types/mdast@4.0.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
@@ -5519,8 +5517,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
|
|
||||||
'@types/pbf@3.0.5': {}
|
|
||||||
|
|
||||||
'@types/react-dom@19.2.3(@types/react@19.2.17)':
|
'@types/react-dom@19.2.3(@types/react@19.2.17)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.17
|
'@types/react': 19.2.17
|
||||||
@@ -5535,10 +5531,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/statuses@2.0.6': {}
|
'@types/statuses@2.0.6': {}
|
||||||
|
|
||||||
'@types/supercluster@7.1.3':
|
|
||||||
dependencies:
|
|
||||||
'@types/geojson': 7946.0.16
|
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7':
|
'@types/trusted-types@2.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -5719,6 +5711,10 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.10.43: {}
|
baseline-browser-mapping@2.10.43: {}
|
||||||
|
|
||||||
|
bidi-js@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
brace-expansion@1.1.16:
|
brace-expansion@1.1.16:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
@@ -6064,7 +6060,7 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
earcut@3.2.3: {}
|
earcut@3.2.4: {}
|
||||||
|
|
||||||
echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.7):
|
echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6271,6 +6267,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.5
|
picomatch: 4.0.5
|
||||||
|
|
||||||
|
fflate@0.8.3: {}
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
flat-cache: 4.0.1
|
flat-cache: 4.0.1
|
||||||
@@ -6314,8 +6312,6 @@ snapshots:
|
|||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
|
|
||||||
geojson-vt@4.0.3: {}
|
|
||||||
|
|
||||||
get-caller-file@2.0.5: {}
|
get-caller-file@2.0.5: {}
|
||||||
|
|
||||||
get-east-asian-width@1.6.0: {}
|
get-east-asian-width@1.6.0: {}
|
||||||
@@ -6340,20 +6336,12 @@ snapshots:
|
|||||||
dunder-proto: 1.0.1
|
dunder-proto: 1.0.1
|
||||||
es-object-atoms: 1.1.2
|
es-object-atoms: 1.1.2
|
||||||
|
|
||||||
get-stream@6.0.1: {}
|
|
||||||
|
|
||||||
gl-matrix@3.4.4: {}
|
gl-matrix@3.4.4: {}
|
||||||
|
|
||||||
glob-parent@6.0.2:
|
glob-parent@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
|
|
||||||
global-prefix@4.0.0:
|
|
||||||
dependencies:
|
|
||||||
ini: 4.1.3
|
|
||||||
kind-of: 6.0.3
|
|
||||||
which: 4.0.0
|
|
||||||
|
|
||||||
globals@14.0.0: {}
|
globals@14.0.0: {}
|
||||||
|
|
||||||
globals@15.15.0: {}
|
globals@15.15.0: {}
|
||||||
@@ -6535,8 +6523,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
ieee754@1.2.1: {}
|
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
@@ -6550,8 +6536,6 @@ snapshots:
|
|||||||
|
|
||||||
indent-string@4.0.0: {}
|
indent-string@4.0.0: {}
|
||||||
|
|
||||||
ini@4.1.3: {}
|
|
||||||
|
|
||||||
inline-style-parser@0.2.7: {}
|
inline-style-parser@0.2.7: {}
|
||||||
|
|
||||||
internmap@1.0.1: {}
|
internmap@1.0.1: {}
|
||||||
@@ -6585,8 +6569,6 @@ snapshots:
|
|||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
isexe@3.1.5: {}
|
|
||||||
|
|
||||||
isomorphic-ws@5.0.0(ws@8.21.1):
|
isomorphic-ws@5.0.0(ws@8.21.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
ws: 8.21.1
|
ws: 8.21.1
|
||||||
@@ -6659,8 +6641,6 @@ snapshots:
|
|||||||
|
|
||||||
khroma@2.1.0: {}
|
khroma@2.1.0: {}
|
||||||
|
|
||||||
kind-of@6.0.3: {}
|
|
||||||
|
|
||||||
layout-base@1.0.2: {}
|
layout-base@1.0.2: {}
|
||||||
|
|
||||||
layout-base@2.0.1: {}
|
layout-base@2.0.1: {}
|
||||||
@@ -6745,34 +6725,26 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
maplibre-gl@4.7.1:
|
maplibre-gl@6.15.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mapbox/geojson-rewind': 0.5.2
|
'@mapbox/point-geometry': 1.1.0
|
||||||
'@mapbox/jsonlint-lines-primitives': 2.0.3
|
'@mapbox/tiny-sdf': 2.3.0
|
||||||
'@mapbox/point-geometry': 0.1.0
|
'@mapbox/unitbezier': 1.0.0
|
||||||
'@mapbox/tiny-sdf': 2.2.0
|
'@mapbox/vector-tile': 3.0.0
|
||||||
'@mapbox/unitbezier': 0.0.1
|
'@maplibre/geojson-vt': 6.3.0
|
||||||
'@mapbox/vector-tile': 1.3.1
|
'@maplibre/maplibre-gl-style-spec': 26.4.4
|
||||||
'@mapbox/whoots-js': 3.1.0
|
'@maplibre/mlt': 1.3.0
|
||||||
'@maplibre/maplibre-gl-style-spec': 20.4.0
|
'@maplibre/vt-pbf': 4.3.2
|
||||||
'@types/geojson': 7946.0.16
|
'@types/geojson': 7946.0.16
|
||||||
'@types/geojson-vt': 3.2.5
|
bidi-js: 1.1.0
|
||||||
'@types/mapbox__point-geometry': 0.1.4
|
earcut: 3.2.4
|
||||||
'@types/mapbox__vector-tile': 1.3.4
|
|
||||||
'@types/pbf': 3.0.5
|
|
||||||
'@types/supercluster': 7.1.3
|
|
||||||
earcut: 3.2.3
|
|
||||||
geojson-vt: 4.0.3
|
|
||||||
gl-matrix: 3.4.4
|
gl-matrix: 3.4.4
|
||||||
global-prefix: 4.0.0
|
|
||||||
kdbush: 4.1.0
|
kdbush: 4.1.0
|
||||||
murmurhash-js: 1.0.0
|
murmurhash-js: 1.0.0
|
||||||
pbf: 3.3.0
|
pbf: 5.1.2
|
||||||
potpack: 2.1.0
|
potpack: 2.1.0
|
||||||
quickselect: 3.0.0
|
quickselect: 3.0.0
|
||||||
supercluster: 8.0.1
|
|
||||||
tinyqueue: 3.0.0
|
tinyqueue: 3.0.0
|
||||||
vt-pbf: 3.1.3
|
|
||||||
|
|
||||||
markdown-table@3.0.4: {}
|
markdown-table@3.0.4: {}
|
||||||
|
|
||||||
@@ -7354,9 +7326,8 @@ snapshots:
|
|||||||
|
|
||||||
pathe@2.0.3: {}
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
pbf@3.3.0:
|
pbf@5.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
ieee754: 1.2.1
|
|
||||||
resolve-protobuf-schema: 2.1.0
|
resolve-protobuf-schema: 2.1.0
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
@@ -7373,6 +7344,10 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.2
|
fsevents: 2.3.2
|
||||||
|
|
||||||
|
pmtiles@4.3.0:
|
||||||
|
dependencies:
|
||||||
|
fflate: 0.8.3
|
||||||
|
|
||||||
points-on-curve@0.2.0: {}
|
points-on-curve@0.2.0: {}
|
||||||
|
|
||||||
points-on-path@0.2.1:
|
points-on-path@0.2.1:
|
||||||
@@ -7404,8 +7379,6 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
quickselect@2.0.0: {}
|
|
||||||
|
|
||||||
quickselect@3.0.0: {}
|
quickselect@3.0.0: {}
|
||||||
|
|
||||||
react-dom@19.2.7(react@19.2.7):
|
react-dom@19.2.7(react@19.2.7):
|
||||||
@@ -7554,6 +7527,8 @@ snapshots:
|
|||||||
|
|
||||||
require-directory@2.1.1: {}
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
|
|
||||||
resolve-protobuf-schema@2.1.0:
|
resolve-protobuf-schema@2.1.0:
|
||||||
@@ -7727,10 +7702,6 @@ snapshots:
|
|||||||
|
|
||||||
stylis@4.4.0: {}
|
stylis@4.4.0: {}
|
||||||
|
|
||||||
supercluster@8.0.1:
|
|
||||||
dependencies:
|
|
||||||
kdbush: 4.1.0
|
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
has-flag: 4.0.0
|
||||||
@@ -7974,12 +7945,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- msw
|
- msw
|
||||||
|
|
||||||
vt-pbf@3.1.3:
|
|
||||||
dependencies:
|
|
||||||
'@mapbox/point-geometry': 0.1.0
|
|
||||||
'@mapbox/vector-tile': 1.3.1
|
|
||||||
pbf: 3.3.0
|
|
||||||
|
|
||||||
w3c-xmlserializer@5.0.0:
|
w3c-xmlserializer@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
xml-name-validator: 5.0.0
|
xml-name-validator: 5.0.0
|
||||||
@@ -8003,10 +7968,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
||||||
which@4.0.0:
|
|
||||||
dependencies:
|
|
||||||
isexe: 3.1.5
|
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
@@ -8044,6 +8005,10 @@ snapshots:
|
|||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
|
zod-to-json-schema@3.25.2(zod@3.25.76):
|
||||||
|
dependencies:
|
||||||
|
zod: 3.25.76
|
||||||
|
|
||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|
||||||
zrender@6.1.0:
|
zrender@6.1.0:
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
packages:
|
packages:
|
||||||
- .
|
- .
|
||||||
|
|
||||||
|
# Keep the catalog and json-render on the existing application schema runtime.
|
||||||
|
overrides:
|
||||||
|
'@json-render/core>zod': 3.25.76
|
||||||
|
'@json-render/react>zod': 3.25.76
|
||||||
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@swc/core': true
|
'@swc/core': true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
|
||||||
|
<circle cx="48" cy="48" r="38" fill="#6F8292" stroke="#fff" stroke-width="5"/>
|
||||||
|
<rect x="25" y="29" width="46" height="38" rx="9" fill="none" stroke="#fff" stroke-width="5"/>
|
||||||
|
<circle cx="48" cy="47" r="10" fill="none" stroke="#fff" stroke-width="4"/>
|
||||||
|
<path d="m48 47 7-6M36 67v7m24-7v7" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 453 B |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
|
||||||
|
<circle cx="48" cy="48" r="38" fill="#6D7F8C" stroke="#fff" stroke-width="5"/>
|
||||||
|
<path d="M29 29h38M48 29v31m-12-12 12 12 12-12" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M25 70c6-5 12-5 18 0s12 5 18 0 12-5 18 0" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 435 B |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
|
||||||
|
<circle cx="48" cy="48" r="38" fill="#287A89" stroke="#fff" stroke-width="5"/>
|
||||||
|
<circle cx="48" cy="48" r="20" fill="none" stroke="#fff" stroke-width="5"/>
|
||||||
|
<path d="m48 48 12-11M22 48h6m40 0h6" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="48" cy="48" r="4" fill="#fff"/>
|
||||||
|
<path d="M37 62h22" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 482 B |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
|
||||||
|
<circle cx="48" cy="48" r="38" fill="#A15F55" stroke="#fff" stroke-width="5"/>
|
||||||
|
<path d="M38 33c0-7 4-11 10-11s10 4 10 11M35 34h26M38 34v32h20V34M29 46h9m20 0h9M26 42h7v9h-7m44-9h-7v9h7M32 68h32" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 382 B |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
|
||||||
|
<rect x="10" y="10" width="76" height="76" rx="20" fill="#526F83" stroke="#fff" stroke-width="5"/>
|
||||||
|
<path d="M28 69V37l20-11 20 11v32M24 69h48" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="48" cy="46" r="8" fill="none" stroke="#fff" stroke-width="4"/>
|
||||||
|
<path d="m48 46 5-4M39 69V58h18v11" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 537 B |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
|
||||||
|
<circle cx="48" cy="48" r="38" fill="#70818F" stroke="#fff" stroke-width="5"/>
|
||||||
|
<path d="M23 61h50M48 61V36h18" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="23" cy="61" r="5" fill="#fff"/>
|
||||||
|
<circle cx="73" cy="61" r="5" fill="#fff"/>
|
||||||
|
<circle cx="67" cy="36" r="5" fill="#fff"/>
|
||||||
|
<circle cx="48" cy="61" r="6" fill="#70818F" stroke="#fff" stroke-width="4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 516 B |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
|
||||||
|
<circle cx="48" cy="48" r="38" fill="#737988" stroke="#fff" stroke-width="5"/>
|
||||||
|
<path d="M24 32 46 48 24 64V32Zm48 0L50 48l22 16V32Z" fill="none" stroke="#fff" stroke-width="6" stroke-linejoin="round"/>
|
||||||
|
<circle cx="48" cy="48" r="5" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 343 B |
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="128" height="128"><path d="M511.948675 512.025215m-486.374101 0a486.374101 486.374101 0 1 0 972.748201 0 486.374101 486.374101 0 1 0-972.748201 0Z" fill="#FFFFFF"></path><path d="M149.958351 874.041137A511.972737 511.972737 0 1 1 511.974273 1023.997952a508.644915 508.644915 0 0 1-362.015922-149.956815zM51.19881 512.025215a460.775464 460.775464 0 1 0 460.775463-460.775464 461.313035 461.313035 0 0 0-460.775463 460.775464z m263.358776 232.794003a258.77662 258.77662 0 0 1-16.8951-363.500643 1179.175609 1179.175609 0 0 1 197.903061-193.62809l20.09493-16.306331 20.043733 16.306331a1178.535643 1178.535643 0 0 1 197.92866 193.62809 258.725423 258.725423 0 0 1-16.869502 363.500643 286.704733 286.704733 0 0 1-402.205782 0z m295.792249-168.055051v126.58526h62.486273v-125.894096a85.62744 85.62744 0 0 0-83.349162-86.728182h-2.841449a86.113814 86.113814 0 0 0-85.934624 81.198876v41.904968a23.29476 23.29476 0 1 1-46.56392 0v-8.319556h-63.075041v8.319556a86.395399 86.395399 0 1 0 172.7652 0v-37.015628a23.269161 23.269161 0 1 1 46.538322 0z m-174.070731-21.426059l77.615067-111.789247h-33.278228v-22.296413h-88.648079v22.296413H358.382452z m122.156695-95.815698a9.522693 9.522693 0 0 0-2.559863 7.449204 10.828223 10.828223 0 0 0 10.393046 10.469842h51.990832a10.469842 10.469842 0 1 0 0-20.939685h-52.937981a9.599489 9.599489 0 0 0-7.014027 3.046238z m-166.391139-49.507763h88.648079v-22.322012h-88.648079z m0-33.483017h88.648079v-11.212203h-88.648079z" fill="#13A8A8"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,5 +1,10 @@
|
|||||||
globalThis.__TJWATER_CONFIG__ = {
|
globalThis.__TJWATER_CONFIG__ = {
|
||||||
TJWATER_AUTH_MODE: "required",
|
TJWATER_AUTH_MODE: "required",
|
||||||
TJWATER_KEYCLOAK_ISSUER: "",
|
TJWATER_KEYCLOAK_ISSUER: "",
|
||||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
|
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||||
|
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
||||||
|
TJWATER_TIANDITU_TOKEN: "",
|
||||||
|
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
|
||||||
|
TJWATER_SERVER_API_BASE_URL: "http://127.0.0.1:8000",
|
||||||
|
TJWATER_WORKBENCH_RUNTIME_MODE: "auto"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import process from "node:process";
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||||
|
import { answerUiCatalog, ANSWER_UI_VERSION } from "../src/features/agent/answer-ui/catalog.ts";
|
||||||
|
|
||||||
|
const target = resolve(process.argv[2] ?? "../next-tjwater-agent/src/chat/answer-ui-catalog.json");
|
||||||
|
const contract = {
|
||||||
|
version: ANSWER_UI_VERSION,
|
||||||
|
description:
|
||||||
|
"Generated from supply frontend src/features/agent/answer-ui/catalog.ts. Regenerate after catalog changes.",
|
||||||
|
schema: answerUiCatalog.jsonSchema(),
|
||||||
|
components: answerUiCatalog.componentNames,
|
||||||
|
props: Object.fromEntries(
|
||||||
|
Object.entries(answerUiCatalog.data.components).map(([name, definition]) => [
|
||||||
|
name,
|
||||||
|
zodToJsonSchema(definition.props, { $refStrategy: "none" })
|
||||||
|
])
|
||||||
|
)
|
||||||
|
};
|
||||||
|
await writeFile(target, `${JSON.stringify(contract, null, 2)}\n`);
|
||||||
|
process.stdout.write(`Exported ${contract.components.length} components to ${target}\n`);
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
|
import { readThemeColor } from "../../tests/browser/support/theme-colors";
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { mockAgentApi } from "../../tests/browser/support/mock-agent-api";
|
||||||
|
import { mockWorkbenchContext } from "../../tests/browser/support/mock-workbench-context";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await mockWorkbenchContext(page);
|
||||||
|
await mockAgentApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
test("renders the migrated WebGIS workbench shell", async ({ page }) => {
|
test("renders the migrated WebGIS workbench shell", async ({ page }) => {
|
||||||
const riveRequests: string[] = [];
|
const riveRequests: string[] = [];
|
||||||
@@ -11,13 +19,17 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
|
|||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.getByText("供水管网智能调度系统")).toBeVisible();
|
await expect(page.getByText("供水管网智能调度系统")).toBeVisible();
|
||||||
await expect(page.getByText("调度工作台")).toBeVisible();
|
await expect(page.getByText("调度工作台")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Agent 命令面板")).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||||
await expect(page.getByLabel("推荐问题")).toBeVisible();
|
await expect(page.getByLabel("推荐问题")).toBeVisible();
|
||||||
|
|
||||||
const runtimeConfig = await page.evaluate(() => globalThis.__TJWATER_CONFIG__);
|
const runtimeConfig = await page.evaluate(() => globalThis.__TJWATER_CONFIG__);
|
||||||
expect(runtimeConfig).toMatchObject({
|
expect(runtimeConfig).toMatchObject({
|
||||||
TJWATER_MAP_URL: expect.any(String),
|
TJWATER_MAP_URL: expect.any(String),
|
||||||
TJWATER_GEOSERVER_WORKSPACE: expect.any(String),
|
TJWATER_GEOSERVER_WORKSPACE: expect.any(String),
|
||||||
TJWATER_AGENT_API_BASE_URL: expect.any(String)
|
TJWATER_SERVER_API_BASE_URL: expect.any(String),
|
||||||
|
TJWATER_AGENT_URL: expect.any(String)
|
||||||
});
|
});
|
||||||
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
|
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
|
||||||
expect.stringMatching(/^(NEXT_PUBLIC_|VITE_)/)
|
expect.stringMatching(/^(NEXT_PUBLIC_|VITE_)/)
|
||||||
@@ -37,7 +49,7 @@ test("animates the compact header menu without overflowing", async ({ page }) =>
|
|||||||
await page.setViewportSize({ width: 375, height: 812 });
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await page.locator('button[aria-label^="查看异常处置面板"]:visible').click();
|
await page.locator('button[aria-label^="查看待办与告警"]:visible').click();
|
||||||
|
|
||||||
const menu = page.locator('[role="menu"][data-state="open"]');
|
const menu = page.locator('[role="menu"][data-state="open"]');
|
||||||
await expect(menu).toBeVisible();
|
await expect(menu).toBeVisible();
|
||||||
@@ -71,8 +83,11 @@ test("renders map notices through a single toaster", async ({ page }) => {
|
|||||||
test("preserves compact typography in header and agent controls", async ({ page }) => {
|
test("preserves compact typography in header and agent controls", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||||
|
|
||||||
const headerControl = page.locator("header.acrylic-navigation button.font-semibold:visible").first();
|
const headerControl = page
|
||||||
|
.locator("header.acrylic-navigation button.font-semibold:visible")
|
||||||
|
.first();
|
||||||
const approvalControl = page.getByRole("button", { name: "权限批准模式" });
|
const approvalControl = page.getByRole("button", { name: "权限批准模式" });
|
||||||
const suggestion = page.locator("[aria-label=推荐问题] button").first();
|
const suggestion = page.locator("[aria-label=推荐问题] button").first();
|
||||||
|
|
||||||
@@ -85,6 +100,7 @@ test("preserves compact typography in header and agent controls", async ({ page
|
|||||||
|
|
||||||
test("lets interactive utilities override surface materials", async ({ page }) => {
|
test("lets interactive utilities override surface materials", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||||
await page.addStyleTag({ content: "*,*::before,*::after{transition:none!important}" });
|
await page.addStyleTag({ content: "*,*::before,*::after{transition:none!important}" });
|
||||||
|
|
||||||
const suggestion = page.locator("[aria-label=推荐问题] button").first();
|
const suggestion = page.locator("[aria-label=推荐问题] button").first();
|
||||||
@@ -112,18 +128,17 @@ test("keeps menu origin and the solid Main Frame styling through Tailwind v4", a
|
|||||||
expect(menuTransformOrigin).not.toBe("50% 50%");
|
expect(menuTransformOrigin).not.toBe("50% 50%");
|
||||||
|
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||||
const navigationMaterial = await navigation.evaluate(
|
const navigation = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||||
(element) => {
|
const navigationMaterial = await navigation.evaluate((element) => {
|
||||||
const style = window.getComputedStyle(element);
|
const style = window.getComputedStyle(element);
|
||||||
return {
|
return {
|
||||||
backdropFilter: style.backdropFilter,
|
backdropFilter: style.backdropFilter,
|
||||||
backgroundColor: style.backgroundColor
|
backgroundColor: style.backgroundColor
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
);
|
|
||||||
expect(navigationMaterial.backdropFilter).toBe("none");
|
expect(navigationMaterial.backdropFilter).toBe("none");
|
||||||
expect(navigationMaterial.backgroundColor).toBe("rgb(237, 241, 245)");
|
expect(navigationMaterial.backgroundColor).toBe(await readThemeColor(navigation, "--agent-dock-surface"));
|
||||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
|
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
|
||||||
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
|
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { StreamingTokenResponse } from "../components/streaming-token-response";
|
||||||
|
import { AnswerUiView, type AnswerUiMapTarget } from "./answer-ui-view";
|
||||||
|
import type { AnswerUiPart } from "./contract";
|
||||||
|
|
||||||
|
export function AnswerPartsView({
|
||||||
|
parts,
|
||||||
|
messageId,
|
||||||
|
disabled,
|
||||||
|
streaming,
|
||||||
|
streamDone,
|
||||||
|
onLocate,
|
||||||
|
onSubmit
|
||||||
|
}: {
|
||||||
|
parts: AnswerUiPart[];
|
||||||
|
messageId: string;
|
||||||
|
disabled: boolean;
|
||||||
|
streaming: boolean;
|
||||||
|
streamDone: boolean;
|
||||||
|
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||||
|
onSubmit?: (text: string) => Promise<void> | void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{parts.map((part, index) =>
|
||||||
|
part.type === "answer-ui" ? (
|
||||||
|
<AnswerUiView
|
||||||
|
key={`ui-${part.block.block_id}`}
|
||||||
|
block={part.block}
|
||||||
|
disabled={disabled}
|
||||||
|
onLocate={onLocate}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StreamingTokenResponse
|
||||||
|
key={`text-${index}`}
|
||||||
|
className="leading-6"
|
||||||
|
content={part.text}
|
||||||
|
messageId={`${messageId}-${index}`}
|
||||||
|
streamDone={streamDone}
|
||||||
|
streaming={streaming}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AgentChatMessage } from "../types";
|
||||||
|
import { getAnswerPresentation } from "./answer-presentation";
|
||||||
|
import type { AnswerUiPart } from "./contract";
|
||||||
|
|
||||||
|
const ui = (id: string): AnswerUiPart => ({
|
||||||
|
type: "answer-ui",
|
||||||
|
block: {
|
||||||
|
version: "answer-ui@1",
|
||||||
|
block_id: id,
|
||||||
|
fallback_text: id,
|
||||||
|
spec: {
|
||||||
|
root: "text",
|
||||||
|
elements: { text: { type: "Text", props: { text: id, tone: "neutral" }, children: [] } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const parts: AnswerUiPart[] = [
|
||||||
|
{ type: "text", text: "正在核对资料。" },
|
||||||
|
ui("report-chart"),
|
||||||
|
ui("temporary"),
|
||||||
|
{ type: "text", text: "查询结束。" }
|
||||||
|
];
|
||||||
|
const message: AgentChatMessage = {
|
||||||
|
id: "a",
|
||||||
|
role: "assistant",
|
||||||
|
content: "正在核对资料。",
|
||||||
|
answerParts: parts
|
||||||
|
};
|
||||||
|
describe("final report and collapsible process", () => {
|
||||||
|
it("shows all incremental content while waiting for final_answer", () => {
|
||||||
|
expect(getAnswerPresentation(message)).toEqual({ report: parts, process: [], final: false });
|
||||||
|
});
|
||||||
|
it("keeps only the final report and explicitly selected components expanded", () => {
|
||||||
|
const view = getAnswerPresentation({
|
||||||
|
...message,
|
||||||
|
finalAnswer: {
|
||||||
|
content: "最后的报告内容。",
|
||||||
|
uiBlockIds: ["report-chart", "report-chart", "missing"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(view.report).toEqual([{ type: "text", text: "最后的报告内容。" }, parts[1]]);
|
||||||
|
expect(view.process).toEqual([parts[0], parts[2], parts[3]]);
|
||||||
|
expect(view.final).toBe(true);
|
||||||
|
});
|
||||||
|
it("folds every UI block when the final report does not reference any", () => {
|
||||||
|
const view = getAnswerPresentation({
|
||||||
|
...message,
|
||||||
|
finalAnswer: { content: "报告", uiBlockIds: [] }
|
||||||
|
});
|
||||||
|
expect(view.report).toEqual([{ type: "text", text: "报告" }]);
|
||||||
|
expect(view.process).toEqual(parts);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { AgentChatMessage } from "../types";
|
||||||
|
import type { AnswerUiPart } from "./contract";
|
||||||
|
|
||||||
|
export function getAnswerPresentation(message: AgentChatMessage): {
|
||||||
|
report: AnswerUiPart[];
|
||||||
|
process: AnswerUiPart[];
|
||||||
|
final: boolean;
|
||||||
|
} {
|
||||||
|
const parts =
|
||||||
|
message.answerParts ??
|
||||||
|
(message.content ? [{ type: "text" as const, text: message.content }] : []);
|
||||||
|
if (!message.finalAnswer) return { report: parts, process: [], final: false };
|
||||||
|
const selected = new Set(message.finalAnswer.uiBlockIds);
|
||||||
|
const report: AnswerUiPart[] = [{ type: "text", text: message.finalAnswer.content }];
|
||||||
|
for (const id of selected) {
|
||||||
|
const part = parts.find((item) => item.type === "answer-ui" && item.block.block_id === id);
|
||||||
|
if (part) report.push(part);
|
||||||
|
}
|
||||||
|
const process = parts.filter((part) =>
|
||||||
|
part.type === "answer-ui"
|
||||||
|
? !selected.has(part.block.block_id)
|
||||||
|
: part.text.trim() !== message.finalAnswer!.content.trim()
|
||||||
|
);
|
||||||
|
return { report, process, final: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { AnswerUiView } from "./answer-ui-view";
|
||||||
|
import { parseAnswerUiBlock } from "./contract";
|
||||||
|
|
||||||
|
function block(type: string, props: Record<string, unknown>) {
|
||||||
|
const parsed = parseAnswerUiBlock({
|
||||||
|
version: "answer-ui@1",
|
||||||
|
block_id: "test",
|
||||||
|
fallback_text: "演练",
|
||||||
|
spec: { root: "node", elements: { node: { type, props, children: [] } } }
|
||||||
|
});
|
||||||
|
if (!parsed) throw new Error("invalid test fixture");
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
describe("local interactive answer renderer", () => {
|
||||||
|
it("renders through json-render and lets readers inspect exact chart data", () => {
|
||||||
|
render(
|
||||||
|
<AnswerUiView
|
||||||
|
block={block("Chart", {
|
||||||
|
title: "演练压力",
|
||||||
|
chart_type: "line",
|
||||||
|
source: "演练数据",
|
||||||
|
x_data: ["08:00", "09:00"],
|
||||||
|
series: [{ name: "压力", data: [0.3, 0.2] }]
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^数据$/ }));
|
||||||
|
expect(screen.getByRole("table")).toHaveTextContent("0.3");
|
||||||
|
expect(screen.getByRole("table")).toHaveTextContent("09:00");
|
||||||
|
expect(screen.getByRole("button", { name: /^数据$/ })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
});
|
||||||
|
it("runs map location only after a user click and reports missing IDs", async () => {
|
||||||
|
const locate = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ located_ids: ["DEMO-P001"], missing_ids: ["DEMO-P002"] });
|
||||||
|
render(
|
||||||
|
<AnswerUiView
|
||||||
|
block={block("MapLocation", {
|
||||||
|
label: "定位演练监测点",
|
||||||
|
layer: "scada",
|
||||||
|
feature_ids: ["DEMO-P001", "DEMO-P002"],
|
||||||
|
source: "演练数据"
|
||||||
|
})}
|
||||||
|
onLocate={locate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(locate).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "定位演练监测点" }));
|
||||||
|
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("另有 1 个未找到"));
|
||||||
|
expect(locate).toHaveBeenCalledWith({
|
||||||
|
layer: "scada",
|
||||||
|
feature_ids: ["DEMO-P001", "DEMO-P002"]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("starts blank, requires input and sends one user message without claiming evidence is saved", async () => {
|
||||||
|
let complete!: () => void;
|
||||||
|
const submit = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
complete = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<AnswerUiView
|
||||||
|
block={block("FollowUpForm", {
|
||||||
|
title: "补充现场资料",
|
||||||
|
description: "由用户提供",
|
||||||
|
fields: [{ name: "location", label: "现场位置", kind: "text", required: true }]
|
||||||
|
})}
|
||||||
|
onSubmit={submit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("现场位置(必填)")).toHaveValue("");
|
||||||
|
fireEvent.submit(screen.getByRole("form", { name: "补充现场资料" }));
|
||||||
|
expect(submit).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("请填写必填资料");
|
||||||
|
fireEvent.change(screen.getByLabelText("现场位置(必填)"), {
|
||||||
|
target: { value: "用户指定位置" }
|
||||||
|
});
|
||||||
|
fireEvent.submit(screen.getByRole("form", { name: "补充现场资料" }));
|
||||||
|
expect(submit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(submit.mock.calls[0]).toEqual([expect.stringContaining("现场位置:用户指定位置")]);
|
||||||
|
expect(screen.getByRole("button", { name: "正在发送…" })).toBeDisabled();
|
||||||
|
complete();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("资料已发送,请查看 Agent 回复")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import { JSONUIProvider, Renderer, defineRegistry } from "@json-render/react";
|
||||||
|
import { createContext, useContext, useId, useState } from "react";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Disclosure } from "@/shared/ui/disclosure";
|
||||||
|
import { DateTimeField } from "@/shared/ui/date-time-field";
|
||||||
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import { Textarea } from "@/shared/ui/textarea";
|
||||||
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import { AgentChartView } from "../components/agent-ui-envelope-renderer";
|
||||||
|
import { answerUiCatalog } from "./catalog";
|
||||||
|
import type { AnswerUiBlock } from "./contract";
|
||||||
|
|
||||||
|
export type AnswerUiMapTarget = {
|
||||||
|
layer: "junctions" | "pipes" | "valves" | "pumps" | "tanks" | "reservoirs" | "scada";
|
||||||
|
feature_ids: string[];
|
||||||
|
};
|
||||||
|
type Interactions = {
|
||||||
|
disabled: boolean;
|
||||||
|
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||||
|
onSubmit?: (text: string) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
const InteractionContext = createContext<Interactions>({ disabled: true });
|
||||||
|
|
||||||
|
const { registry } = defineRegistry(answerUiCatalog, {
|
||||||
|
actions: {},
|
||||||
|
components: {
|
||||||
|
Stack: ({ props, children }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"answer-ui-stack",
|
||||||
|
props.columns === "two" && "answer-ui-stack-comparison",
|
||||||
|
props.gap === "compact" && "answer-ui-stack-compact"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
Section: ({ props, children }) => (
|
||||||
|
<section className="answer-ui-section">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--ink)]">{props.title}</h3>
|
||||||
|
{props.description ? (
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{props.description}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-3 space-y-3">{children}</div>
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
Text: ({ props }) => (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"whitespace-pre-wrap text-sm leading-6",
|
||||||
|
props.tone === "warning"
|
||||||
|
? "text-[var(--status-warning-foreground)]"
|
||||||
|
: props.tone === "info"
|
||||||
|
? "text-[var(--action-blue-hover)]"
|
||||||
|
: "text-[var(--ink)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.text}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
Metric: ({ props }) => (
|
||||||
|
<div className="min-w-0 py-2">
|
||||||
|
<p className="text-xs text-[var(--ink-muted)]">{props.label}</p>
|
||||||
|
<p className="mt-1 break-words text-xl font-semibold tabular-nums text-[var(--ink)]">
|
||||||
|
{props.value}
|
||||||
|
{props.unit ? <span className="ml-1 text-xs font-normal">{props.unit}</span> : null}
|
||||||
|
</p>
|
||||||
|
{props.source ? (
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">来源:{props.source}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
Table: ({ props }) => (
|
||||||
|
<DataTable title={props.title} columns={props.columns} rows={props.rows} />
|
||||||
|
),
|
||||||
|
Chart: ({ props }) => <AnswerChart {...props} />,
|
||||||
|
Evidence: ({ props }) => (
|
||||||
|
<Disclosure label={props.title} className="answer-ui-evidence">
|
||||||
|
<div className="space-y-2 py-2 text-xs leading-5">
|
||||||
|
<p className="text-[var(--ink-muted)]">
|
||||||
|
{
|
||||||
|
(
|
||||||
|
{
|
||||||
|
observed: "查询记录",
|
||||||
|
human_input: "用户提供",
|
||||||
|
inference: "分析推断",
|
||||||
|
demo: "演练数据"
|
||||||
|
} as const
|
||||||
|
)[props.kind]
|
||||||
|
}{" "}
|
||||||
|
· {props.source}
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-wrap">{props.summary}</p>
|
||||||
|
{props.observed_at ? <p>记录时间:{props.observed_at}</p> : null}
|
||||||
|
{props.evidence_id ? <p>资料编号:{props.evidence_id}</p> : null}
|
||||||
|
</div>
|
||||||
|
</Disclosure>
|
||||||
|
),
|
||||||
|
MapLocation: ({ props }) => <LocateButton {...props} />,
|
||||||
|
FollowUpForm: ({ props }) => <FollowUpForm {...props} />
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AnswerUiView({
|
||||||
|
block,
|
||||||
|
disabled = false,
|
||||||
|
onLocate,
|
||||||
|
onSubmit
|
||||||
|
}: { block: AnswerUiBlock } & Partial<Interactions>) {
|
||||||
|
return (
|
||||||
|
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit }}>
|
||||||
|
<div className="answer-ui-root" data-answer-ui={block.block_id}>
|
||||||
|
<JSONUIProvider registry={registry}>
|
||||||
|
<Renderer spec={block.spec} registry={registry} />
|
||||||
|
</JSONUIProvider>
|
||||||
|
</div>
|
||||||
|
</InteractionContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataTable({
|
||||||
|
title,
|
||||||
|
columns,
|
||||||
|
rows
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
columns: string[];
|
||||||
|
rows: string[][];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label={title}
|
||||||
|
tabIndex={0}
|
||||||
|
className="overflow-x-auto rounded-lg border border-[var(--surface-divider)]"
|
||||||
|
>
|
||||||
|
<table className="w-full border-collapse text-left text-xs">
|
||||||
|
<caption className="px-3 py-2 text-left font-medium text-[var(--ink)]">{title}</caption>
|
||||||
|
<thead className="bg-[var(--surface-subtle)]">
|
||||||
|
<tr>
|
||||||
|
{columns.map((column, index) => (
|
||||||
|
<th key={index} className="whitespace-nowrap px-3 py-2 font-medium">
|
||||||
|
{column}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr key={index} className="border-t border-[var(--surface-divider)]">
|
||||||
|
{row.map((cell, cellIndex) => (
|
||||||
|
<td key={cellIndex} className="min-w-24 px-3 py-2 align-top">
|
||||||
|
{cell}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartProps = {
|
||||||
|
title: string;
|
||||||
|
chart_type: "line" | "bar";
|
||||||
|
x_data: string[];
|
||||||
|
series: Array<{ name: string; data: number[] }>;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
function AnswerChart(props: ChartProps) {
|
||||||
|
const [mode, setMode] = useState<"line" | "bar" | "table">(props.chart_type);
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div role="group" aria-label={`${props.title}显示方式`} className="mb-2 flex flex-wrap gap-1">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["line", "折线"],
|
||||||
|
["bar", "柱状"],
|
||||||
|
["table", "数据"]
|
||||||
|
] as const
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-pressed={mode === value}
|
||||||
|
onClick={() => setMode(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{mode === "table" ? (
|
||||||
|
<DataTable
|
||||||
|
title={props.title}
|
||||||
|
columns={["时间或类别", ...props.series.map((series) => series.name)]}
|
||||||
|
rows={props.x_data.map((label, index) => [
|
||||||
|
label,
|
||||||
|
...props.series.map((series) => String(series.data[index]))
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AgentChartView chart={{ ...props, chart_type: mode }} />
|
||||||
|
)}
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-xs leading-5 text-[var(--ink-muted)]">
|
||||||
|
来源:{props.source}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocateButton({
|
||||||
|
label,
|
||||||
|
source,
|
||||||
|
...target
|
||||||
|
}: AnswerUiMapTarget & { label: string; source: string }) {
|
||||||
|
const { disabled, onLocate } = useContext(InteractionContext);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [feedback, setFeedback] = useState<string | null>(null);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={disabled || running || !onLocate}
|
||||||
|
onClick={async () => {
|
||||||
|
setRunning(true);
|
||||||
|
setFeedback(null);
|
||||||
|
try {
|
||||||
|
const result = await onLocate?.(target);
|
||||||
|
const output = result as { located_ids?: string[]; missing_ids?: string[] } | undefined;
|
||||||
|
setFeedback(
|
||||||
|
output?.missing_ids?.length
|
||||||
|
? `已定位 ${output.located_ids?.length ?? 0} 个,另有 ${output.missing_ids.length} 个未找到`
|
||||||
|
: "已定位到地图"
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback(error instanceof Error ? error.message : "地图定位失败");
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{running ? "正在定位…" : label}
|
||||||
|
</Button>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">依据:{source}</p>
|
||||||
|
{feedback ? (
|
||||||
|
<p role="status" className="mt-1 text-xs leading-5">
|
||||||
|
{feedback}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormField = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
kind: "text" | "datetime" | "number";
|
||||||
|
required: boolean;
|
||||||
|
};
|
||||||
|
function FollowUpForm({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
fields
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
fields: FormField[];
|
||||||
|
}) {
|
||||||
|
const { disabled, onSubmit } = useContext(InteractionContext);
|
||||||
|
const prefix = useId();
|
||||||
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [feedback, setFeedback] = useState<string | null>(null);
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="answer-ui-form space-y-3"
|
||||||
|
aria-label={title}
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (fields.some((field) => field.required && !values[field.name]?.trim())) {
|
||||||
|
setFeedback("请填写必填资料。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!onSubmit || disabled || submitting) return;
|
||||||
|
const entries = fields
|
||||||
|
.filter((field) => values[field.name]?.trim())
|
||||||
|
.map(
|
||||||
|
(field) =>
|
||||||
|
`${field.label}:${values[field.name].trim()}${field.kind === "datetime" ? "(北京时间)" : ""}`
|
||||||
|
);
|
||||||
|
if (!entries.length) {
|
||||||
|
setFeedback("请至少填写一项资料。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entries.join("\n").length > 9000) {
|
||||||
|
setFeedback("补充资料过长,请分次发送。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setFeedback(null);
|
||||||
|
try {
|
||||||
|
await onSubmit(
|
||||||
|
`我补充以下资料,请按用户提供的信息处理,说明保存或调查的实际结果:\n${entries.join("\n")}`
|
||||||
|
);
|
||||||
|
setFeedback("资料已发送,请查看 Agent 回复。");
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback(error instanceof Error ? error.message : "资料发送失败,请重试。");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold">{title}</h4>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{description}</p>
|
||||||
|
</div>
|
||||||
|
{fields.map((field) =>
|
||||||
|
field.kind === "datetime" ? (
|
||||||
|
<DateTimeField
|
||||||
|
key={field.name}
|
||||||
|
label={`${field.label}${field.required ? "(必填)" : ""}`}
|
||||||
|
value={values[field.name] ?? ""}
|
||||||
|
disabled={disabled || submitting || !onSubmit}
|
||||||
|
onChange={(value) => setValues((current) => ({ ...current, [field.name]: value }))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div key={field.name} className="space-y-1.5">
|
||||||
|
<label htmlFor={`${prefix}-${field.name}`} className="block text-xs font-medium">
|
||||||
|
{field.label}
|
||||||
|
{field.required ? "(必填)" : ""}
|
||||||
|
</label>
|
||||||
|
{field.kind === "text" ? (
|
||||||
|
<Textarea
|
||||||
|
id={`${prefix}-${field.name}`}
|
||||||
|
value={values[field.name] ?? ""}
|
||||||
|
maxLength={500}
|
||||||
|
disabled={disabled || submitting || !onSubmit}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.currentTarget.value;
|
||||||
|
setValues((current) => ({ ...current, [field.name]: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-${field.name}`}
|
||||||
|
type="number"
|
||||||
|
value={values[field.name] ?? ""}
|
||||||
|
maxLength={500}
|
||||||
|
disabled={disabled || submitting || !onSubmit}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.currentTarget.value;
|
||||||
|
setValues((current) => ({ ...current, [field.name]: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={disabled || submitting || !onSubmit}
|
||||||
|
>
|
||||||
|
{submitting ? "正在发送…" : "发送补充资料"}
|
||||||
|
</Button>
|
||||||
|
{feedback ? (
|
||||||
|
<p role="status" className="text-xs leading-5">
|
||||||
|
{feedback}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { defineCatalog } from "@json-render/core";
|
||||||
|
import { schema } from "@json-render/react/schema";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const shortText = z.string().max(200);
|
||||||
|
const text = z.string().max(4000);
|
||||||
|
const optionalText = shortText.nullable();
|
||||||
|
const featureId = z.string().min(1).max(128);
|
||||||
|
|
||||||
|
/** This is the source of the generated Agent contract, not a remotely hosted registry. */
|
||||||
|
export const answerUiCatalog = defineCatalog(schema, {
|
||||||
|
actions: {},
|
||||||
|
components: {
|
||||||
|
Stack: {
|
||||||
|
props: z
|
||||||
|
.object({ columns: z.enum(["one", "two"]), gap: z.enum(["compact", "normal"]) })
|
||||||
|
.strict(),
|
||||||
|
slots: ["default"],
|
||||||
|
description: "组织内容;双列在窄面板自动改为单列。"
|
||||||
|
},
|
||||||
|
Section: {
|
||||||
|
props: z.object({ title: shortText, description: text.nullable() }).strict(),
|
||||||
|
slots: ["default"],
|
||||||
|
description: "带标题的资料分区,保留真实内容层级。"
|
||||||
|
},
|
||||||
|
Text: {
|
||||||
|
props: z.object({ text, tone: z.enum(["neutral", "info", "warning"]) }).strict(),
|
||||||
|
description: "简短说明、判断边界或资料缺口。"
|
||||||
|
},
|
||||||
|
Metric: {
|
||||||
|
props: z
|
||||||
|
.object({ label: shortText, value: shortText, unit: optionalText, source: optionalText })
|
||||||
|
.strict(),
|
||||||
|
description: "展示真实指标及其单位、来源;不能臆造读数。"
|
||||||
|
},
|
||||||
|
Table: {
|
||||||
|
props: z
|
||||||
|
.object({
|
||||||
|
title: shortText,
|
||||||
|
columns: z.array(shortText).min(1).max(8),
|
||||||
|
rows: z.array(z.array(z.string().max(1000)).max(8)).max(50)
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
description: "按相同字段比较资料,避免无法比较的指标混列。"
|
||||||
|
},
|
||||||
|
Chart: {
|
||||||
|
props: z
|
||||||
|
.object({
|
||||||
|
title: shortText,
|
||||||
|
chart_type: z.enum(["line", "bar"]),
|
||||||
|
x_data: z.array(shortText).min(1).max(100),
|
||||||
|
series: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({ name: shortText, data: z.array(z.number().finite()).min(1).max(100) })
|
||||||
|
.strict()
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(8),
|
||||||
|
source: text
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
description:
|
||||||
|
"展示查询或用户提供的真实数据;series 必须与 x_data 对齐。浏览者可切换图形与数据表。"
|
||||||
|
},
|
||||||
|
Evidence: {
|
||||||
|
props: z
|
||||||
|
.object({
|
||||||
|
title: shortText,
|
||||||
|
summary: text,
|
||||||
|
source: shortText,
|
||||||
|
evidence_id: optionalText,
|
||||||
|
observed_at: optionalText,
|
||||||
|
kind: z.enum(["observed", "human_input", "inference", "demo"])
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
description: "展示证据依据;明确区分查询记录、用户事实、推断和演练数据。"
|
||||||
|
},
|
||||||
|
MapLocation: {
|
||||||
|
props: z
|
||||||
|
.object({
|
||||||
|
label: shortText,
|
||||||
|
layer: z.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]),
|
||||||
|
feature_ids: z.array(featureId).min(1).max(100),
|
||||||
|
source: shortText
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
description: "用户点击后定位已有要素;ID 必须来自查询或用户明确提供,不绘制臆测影响范围。"
|
||||||
|
},
|
||||||
|
FollowUpForm: {
|
||||||
|
props: z
|
||||||
|
.object({
|
||||||
|
title: shortText,
|
||||||
|
description: text,
|
||||||
|
fields: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
name: z.string().regex(/^[a-z][a-z0-9_]{0,39}$/),
|
||||||
|
label: shortText,
|
||||||
|
kind: z.enum(["text", "datetime", "number"]),
|
||||||
|
required: z.boolean()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(12)
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
description:
|
||||||
|
"让用户主动填写并提交补充资料;无默认事实。提交产生用户消息,是否写入业务证据由现有 Agent 工具处理。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ANSWER_UI_VERSION = "answer-ui@1" as const;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseAnswerUiBlock } from "./contract";
|
||||||
|
|
||||||
|
const block = {
|
||||||
|
version: "answer-ui@1",
|
||||||
|
block_id: "reading",
|
||||||
|
fallback_text: "仍待核对",
|
||||||
|
spec: {
|
||||||
|
root: "note",
|
||||||
|
elements: { note: { type: "Text", props: { text: "仍待核对", tone: "neutral" }, children: [] } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
describe("untrusted answer components", () => {
|
||||||
|
it("accepts registered text and rejects arbitrary code, URLs and unknown components", () => {
|
||||||
|
expect(parseAnswerUiBlock(block)).not.toBeNull();
|
||||||
|
for (const node of [
|
||||||
|
{ type: "iframe", props: { src: "https://example.com" }, children: [] },
|
||||||
|
{
|
||||||
|
...block.spec.elements.note,
|
||||||
|
props: { ...block.spec.elements.note.props, script: "alert(1)" }
|
||||||
|
},
|
||||||
|
{ ...block.spec.elements.note, on: { click: { action: "fetch" } } }
|
||||||
|
])
|
||||||
|
expect(
|
||||||
|
parseAnswerUiBlock({ ...block, spec: { root: "note", elements: { note: node } } })
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
it("rejects missing, duplicate, cyclic, unreachable and excessive trees", () => {
|
||||||
|
const stack = { type: "Stack", props: { columns: "one", gap: "normal" }, children: ["note"] };
|
||||||
|
for (const children of [["missing"], ["note", "note"], ["root"]])
|
||||||
|
expect(
|
||||||
|
parseAnswerUiBlock({
|
||||||
|
...block,
|
||||||
|
spec: { root: "root", elements: { root: { ...stack, children }, ...block.spec.elements } }
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseAnswerUiBlock({
|
||||||
|
...block,
|
||||||
|
spec: {
|
||||||
|
...block.spec,
|
||||||
|
elements: { ...block.spec.elements, unused: block.spec.elements.note }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
it("requires aligned, finite chart values and unambiguous form fields", () => {
|
||||||
|
const chart = {
|
||||||
|
type: "Chart",
|
||||||
|
props: {
|
||||||
|
title: "演练压力",
|
||||||
|
chart_type: "line",
|
||||||
|
source: "演练数据",
|
||||||
|
x_data: ["08:00"],
|
||||||
|
series: [{ name: "压力", data: [0.3] }]
|
||||||
|
},
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
const withNode = (node: unknown) => ({
|
||||||
|
...block,
|
||||||
|
spec: { root: "note", elements: { note: node } }
|
||||||
|
});
|
||||||
|
expect(parseAnswerUiBlock(withNode(chart))).not.toBeNull();
|
||||||
|
for (const data of [[0.3, 0.2], [Infinity], [NaN]])
|
||||||
|
expect(
|
||||||
|
parseAnswerUiBlock(
|
||||||
|
withNode({ ...chart, props: { ...chart.props, series: [{ name: "压力", data }] } })
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
const field = { name: "location", label: "位置", kind: "text", required: true };
|
||||||
|
expect(
|
||||||
|
parseAnswerUiBlock(
|
||||||
|
withNode({
|
||||||
|
type: "FollowUpForm",
|
||||||
|
props: { title: "核验", description: "用户补充", fields: [field, field] },
|
||||||
|
children: []
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Spec } from "@json-render/core";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { answerUiCatalog, ANSWER_UI_VERSION } from "./catalog";
|
||||||
|
|
||||||
|
const key = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/);
|
||||||
|
const element = z
|
||||||
|
.object({ type: z.string(), props: z.record(z.unknown()), children: z.array(key).max(48) })
|
||||||
|
.strict();
|
||||||
|
const specSchema = z.object({ root: key, elements: z.record(element) }).strict();
|
||||||
|
const payloadSchema = z
|
||||||
|
.object({
|
||||||
|
version: z.literal(ANSWER_UI_VERSION),
|
||||||
|
block_id: key,
|
||||||
|
fallback_text: z.string().min(1).max(4000),
|
||||||
|
spec: specSchema
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type AnswerUiBlock = {
|
||||||
|
version: typeof ANSWER_UI_VERSION;
|
||||||
|
block_id: string;
|
||||||
|
fallback_text: string;
|
||||||
|
spec: Spec;
|
||||||
|
};
|
||||||
|
export type AnswerUiPart =
|
||||||
|
{ type: "text"; text: string } | { type: "answer-ui"; block: AnswerUiBlock };
|
||||||
|
|
||||||
|
export function parseAnswerUiBlock(value: unknown): AnswerUiBlock | null {
|
||||||
|
const parsed = payloadSchema.safeParse(value);
|
||||||
|
if (!parsed.success || JSON.stringify(parsed.data).length > 100000) return null;
|
||||||
|
const { spec } = parsed.data;
|
||||||
|
const nodes = Object.keys(spec.elements);
|
||||||
|
if (!nodes.length || nodes.length > 48 || nodes.some((name) => !key.safeParse(name).success))
|
||||||
|
return null;
|
||||||
|
const definitions = answerUiCatalog.data.components;
|
||||||
|
for (const node of Object.values(spec.elements)) {
|
||||||
|
const definition = Object.hasOwn(definitions, node.type)
|
||||||
|
? definitions[node.type as keyof typeof definitions]
|
||||||
|
: undefined;
|
||||||
|
if (!definition?.props.safeParse(node.props).success) return null;
|
||||||
|
if (!["Stack", "Section"].includes(node.type) && node.children.length) return null;
|
||||||
|
if (node.type === "Chart") {
|
||||||
|
const data = node.props as { x_data: unknown[]; series: Array<{ data: unknown[] }> };
|
||||||
|
if (data.series.some((series) => series.data.length !== data.x_data.length)) return null;
|
||||||
|
}
|
||||||
|
if (node.type === "Table") {
|
||||||
|
const data = node.props as { columns: unknown[]; rows: unknown[][] };
|
||||||
|
if (data.rows.some((row) => row.length !== data.columns.length)) return null;
|
||||||
|
}
|
||||||
|
if (node.type === "FollowUpForm") {
|
||||||
|
const data = node.props as { fields: Array<{ name: string }> };
|
||||||
|
if (new Set(data.fields.map((field) => field.name)).size !== data.fields.length) return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const visit = (id: string, depth: number): boolean => {
|
||||||
|
if (depth > 12 || visited.has(id) || !Object.hasOwn(spec.elements, id)) return false;
|
||||||
|
visited.add(id);
|
||||||
|
return spec.elements[id].children.every((child) => visit(child, depth + 1));
|
||||||
|
};
|
||||||
|
if (!visit(spec.root, 0) || visited.size !== nodes.length) return null;
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { createAgentApiClient } from "./client";
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it("reads mailbox pages without sending or consuming messages and validates the contract", async () => {
|
||||||
|
const message = { message_id: "m1", task_id: "t1", run_id: "r1", source_session_id: "task-session", target_session_id: "chat-session", direction: "to_chat", text: "现场记录已核对", status: "pending", in_reply_to: null, created_at: "2026-10-13T00:00:00Z", source_owner_id: "private-owner" };
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(Response.json({ items: [message], next_cursor: "m1" }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "p1" });
|
||||||
|
const page = await client.listAgentMessages("chat-session", { cursor: "previous" });
|
||||||
|
expect(page.items[0]).not.toHaveProperty("source_owner_id");
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe("http://agent.local/api/v1/session/chat-session/agent-messages?limit=50&cursor=previous");
|
||||||
|
expect(init.method).toBeUndefined();
|
||||||
|
expect(init.body).toBeUndefined();
|
||||||
|
expect(new Headers(init.headers).get("X-Project-Id")).toBe("p1");
|
||||||
|
fetchMock.mockResolvedValue(Response.json({ items: [{ ...message, target_session_id: null, direction: "to_task" }], next_cursor: null }));
|
||||||
|
expect((await client.listAgentMessages("chat-session")).items[0].target_session_id).toBeNull();
|
||||||
|
fetchMock.mockResolvedValue(Response.json({ items: [{ ...message, status: "invented" }], next_cursor: null }));
|
||||||
|
await expect(client.listAgentMessages("chat-session")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts inbox notifications only for the subscribed session", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response([
|
||||||
|
'event: agent_message\ndata: {"session_id":"other"}\n\n',
|
||||||
|
'event: agent_message\ndata: {"session_id":"main"}\n\n',
|
||||||
|
': heartbeat\n\n'
|
||||||
|
].join(""))));
|
||||||
|
const onMessage = vi.fn();
|
||||||
|
await createAgentApiClient("http://agent.local").streamAgentMessages("main", { onMessage });
|
||||||
|
expect(onMessage).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const agentMessageSchema = z.object({
|
||||||
|
message_id: z.string().min(1),
|
||||||
|
task_id: z.string().min(1),
|
||||||
|
run_id: z.string().min(1),
|
||||||
|
source_session_id: z.string().min(1),
|
||||||
|
target_session_id: z.string().min(1).nullable(),
|
||||||
|
direction: z.enum(["to_task", "to_chat"]),
|
||||||
|
text: z.string(),
|
||||||
|
status: z.enum(["pending", "enqueued", "delivered", "failed"]),
|
||||||
|
in_reply_to: z.string().nullable(),
|
||||||
|
created_at: z.string(),
|
||||||
|
failure_detail: z.string().nullable().optional()
|
||||||
|
});
|
||||||
|
export const agentMessagePageSchema = z.object({
|
||||||
|
items: z.array(agentMessageSchema),
|
||||||
|
next_cursor: z.string().nullable()
|
||||||
|
});
|
||||||
|
export type AgentMailboxMessage = z.infer<typeof agentMessageSchema>;
|
||||||
|
export type AgentMessagePage = z.infer<typeof agentMessagePageSchema>;
|
||||||
@@ -1,7 +1,54 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createAgentApiClient } from "./client";
|
import { createAgentApiClient } from "./client";
|
||||||
|
|
||||||
|
describe("Agent prompt suggestions", () => {
|
||||||
|
it("submits and edits pending input through the scoped Responses and session endpoints", async () => {
|
||||||
|
const input = { id: `msg_${"1".repeat(32)}`, text: "补充压力测点", delivery: "steer", status: "pending" };
|
||||||
|
const fetchMock = vi.fn().mockResolvedValueOnce(Response.json(input)).mockResolvedValueOnce(Response.json({ items: [input] }))
|
||||||
|
.mockResolvedValueOnce(Response.json({ ...input, delivery: "queue" }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project" });
|
||||||
|
await expect(client.submitResponseInput("session-1", input.text, "steer", input.id)).resolves.toEqual(input);
|
||||||
|
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ input: input.text, conversation: "session-1", stream: false, tjwater: { delivery: "steer", input_id: input.id } });
|
||||||
|
await expect(client.listResponseInputs("session-1")).resolves.toEqual([input]);
|
||||||
|
await expect(client.updateResponseInput("session-1", input.id, "queue")).resolves.toEqual({ ...input, delivery: "queue" });
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://agent.local/api/v1/responses");
|
||||||
|
expect(String(fetchMock.mock.calls[2]?.[0])).toContain(`/api/v1/session/session-1/inputs/${input.id}`);
|
||||||
|
for (const [, request] of fetchMock.mock.calls) {
|
||||||
|
expect(new Headers(request?.headers).get("Authorization")).toBe("Bearer token");
|
||||||
|
expect(new Headers(request?.headers).get("X-Project-Id")).toBe("project");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the scoped Agent request and rejects invented activity references", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(Response.json({ suggestions: [{ question: "核对城南压力告警的证据", activity_id: "alert:pressure" }] }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project" });
|
||||||
|
const activities = [{ id: "alert:pressure", kind: "alert" as const, title: "城南压力告警", occurred_at: "2026-10-09T07:00:00Z", origin: "backend" as const }];
|
||||||
|
const controller = new AbortController();
|
||||||
|
expect(await client.generatePromptSuggestions(activities, "test/model", controller.signal)).toEqual([{ question: "核对城南压力告警的证据", activityId: "alert:pressure" }]);
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe("http://agent.local/api/v1/suggestions");
|
||||||
|
expect(new Headers(init.headers).get("Authorization")).toBe("Bearer token");
|
||||||
|
expect(new Headers(init.headers).get("X-Project-Id")).toBe("project");
|
||||||
|
expect(init.signal).toBe(controller.signal);
|
||||||
|
fetchMock.mockResolvedValue(Response.json({ suggestions: [{ question: "引用其他项目", activity_id: "unknown" }] }));
|
||||||
|
await expect(client.generatePromptSuggestions(activities, "test/model")).rejects.toThrow("Invalid Agent suggestion");
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Agent API client sessions", () => {
|
describe("Agent API client sessions", () => {
|
||||||
|
it("forces renewal and sends only the refresh request id with verified context headers", async () => {
|
||||||
|
const getAccessToken = vi.fn(async () => "TEST_NEW_TOKEN");
|
||||||
|
const fetchMock = vi.fn(async () => Response.json({ accepted: true }, { status: 202 }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
await createAgentApiClient("http://agent.local", { getAccessToken, getProjectId: async () => "p" }).refreshCredential("s", "request-1");
|
||||||
|
expect(getAccessToken).toHaveBeenCalledWith(true);
|
||||||
|
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||||
|
expect(new Headers(init.headers).get("Authorization")).toBe("Bearer TEST_NEW_TOKEN");
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({ request_id: "request-1" });
|
||||||
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
@@ -33,7 +80,10 @@ describe("Agent API client sessions", () => {
|
|||||||
status: undefined,
|
status: undefined,
|
||||||
parentSessionId: undefined,
|
parentSessionId: undefined,
|
||||||
isStreaming: undefined,
|
isStreaming: undefined,
|
||||||
runStatus: "running"
|
runStatus: "running",
|
||||||
|
purpose: "ordinary_chat",
|
||||||
|
taskId: undefined,
|
||||||
|
trigger: undefined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "old",
|
id: "old",
|
||||||
@@ -43,7 +93,10 @@ describe("Agent API client sessions", () => {
|
|||||||
status: undefined,
|
status: undefined,
|
||||||
parentSessionId: undefined,
|
parentSessionId: undefined,
|
||||||
isStreaming: undefined,
|
isStreaming: undefined,
|
||||||
runStatus: undefined
|
runStatus: undefined,
|
||||||
|
purpose: "ordinary_chat",
|
||||||
|
taskId: undefined,
|
||||||
|
trigger: undefined
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -79,10 +132,10 @@ describe("Agent API client sessions", () => {
|
|||||||
});
|
});
|
||||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
"http://not-agent.local/api/v1/agent/chat/ui-registry",
|
"http://not-agent.local/api/v1/ui-registry",
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/agent/chat/ui-registry", undefined);
|
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/ui-registry", undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends title update payloads using backend field names", async () => {
|
it("sends title update payloads using backend field names", async () => {
|
||||||
@@ -94,7 +147,7 @@ describe("Agent API client sessions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
"http://agent.local/api/v1/agent/chat/session/session-1/title",
|
"http://agent.local/api/v1/session/session-1/title",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -122,6 +175,20 @@ describe("Agent API client sessions", () => {
|
|||||||
expect(headers.get("Content-Type")).toBe("application/json");
|
expect(headers.get("Content-Type")).toBe("application/json");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends the authorized project context on Agent requests", async () => {
|
||||||
|
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||||
|
new Response(JSON.stringify({ sessions: [] }), { status: 200 })
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const getProjectId = vi.fn().mockResolvedValue("project-117");
|
||||||
|
|
||||||
|
await createAgentApiClient("http://agent.local", { getProjectId }).listSessions();
|
||||||
|
|
||||||
|
expect(getProjectId).toHaveBeenCalledOnce();
|
||||||
|
const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
|
||||||
|
expect(headers.get("X-Project-Id")).toBe("project-117");
|
||||||
|
});
|
||||||
|
|
||||||
it("streams session events from the backend SSE endpoint", async () => {
|
it("streams session events from the backend SSE endpoint", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
import { env } from "@/shared/config/env";
|
import { env } from "@/shared/config/env";
|
||||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||||
|
import { agentMessagePageSchema, type AgentMessagePage } from "./agent-messages";
|
||||||
|
|
||||||
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
|
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
|
||||||
|
export type AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration";
|
||||||
|
|
||||||
|
export type AgentEventTriggerContext = {
|
||||||
|
schema_version: "agent-trigger@1";
|
||||||
|
kind: "event";
|
||||||
|
trigger_id: string;
|
||||||
|
occurred_at: string;
|
||||||
|
source: "peripheral_sample";
|
||||||
|
subject_type: "work_order";
|
||||||
|
subject_id: string;
|
||||||
|
workflow: "water_quality_complaint";
|
||||||
|
payload: {
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
location?: string | null;
|
||||||
|
priority: "low" | "normal" | "high" | "urgent";
|
||||||
|
scenario_code: "WQ-SECONDARY-SUPPLY-001";
|
||||||
|
origin: "synthetic";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type AgentPermissionReply = "once" | "always" | "reject";
|
export type AgentPermissionReply = "once" | "always" | "reject";
|
||||||
|
|
||||||
@@ -17,6 +38,9 @@ export type AgentModelsResponse = {
|
|||||||
models: AgentModelOption[];
|
models: AgentModelOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AgentInputDelivery = "steer" | "queue";
|
||||||
|
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" };
|
||||||
|
|
||||||
export type AgentChatSession = {
|
export type AgentChatSession = {
|
||||||
id?: string;
|
id?: string;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -29,9 +53,15 @@ export type AgentChatSession = {
|
|||||||
updated_at?: number | string;
|
updated_at?: number | string;
|
||||||
is_title_manually_edited?: boolean;
|
is_title_manually_edited?: boolean;
|
||||||
parent_session_id?: string;
|
parent_session_id?: string;
|
||||||
|
created?: boolean;
|
||||||
|
event_duplicate?: boolean;
|
||||||
|
trigger_id?: string;
|
||||||
|
purpose?: AgentSessionPurpose;
|
||||||
|
task_id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentChatSessionSummary = {
|
export type AgentChatSessionSummary = {
|
||||||
|
trigger?: { subjectId: string; workflow: "water_quality_complaint"; occurredAt: string };
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
@@ -40,6 +70,23 @@ export type AgentChatSessionSummary = {
|
|||||||
parentSessionId?: string;
|
parentSessionId?: string;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
runStatus?: AgentRunStatus;
|
runStatus?: AgentRunStatus;
|
||||||
|
purpose: AgentSessionPurpose;
|
||||||
|
taskId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentRecentActivity = {
|
||||||
|
id: string;
|
||||||
|
kind: "alert" | "task" | "analysis" | "session";
|
||||||
|
title: string;
|
||||||
|
occurred_at: string;
|
||||||
|
summary?: string;
|
||||||
|
status?: string;
|
||||||
|
origin: "backend" | "agent" | "demo";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentPromptSuggestion = {
|
||||||
|
question: string;
|
||||||
|
activityId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentLoadedChatSession = AgentChatSessionSummary & {
|
export type AgentLoadedChatSession = AgentChatSessionSummary & {
|
||||||
@@ -49,6 +96,8 @@ export type AgentLoadedChatSession = AgentChatSessionSummary & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AgentSessionStreamDataEventType =
|
export type AgentSessionStreamDataEventType =
|
||||||
|
| "execution_plan"
|
||||||
|
| "final_answer"
|
||||||
| "token"
|
| "token"
|
||||||
| "progress"
|
| "progress"
|
||||||
| "todo_update"
|
| "todo_update"
|
||||||
@@ -59,15 +108,22 @@ export type AgentSessionStreamDataEventType =
|
|||||||
| "tool_call"
|
| "tool_call"
|
||||||
| "frontend_action"
|
| "frontend_action"
|
||||||
| "frontend_action_result"
|
| "frontend_action_result"
|
||||||
|
| "answer_ui"
|
||||||
| "ui_envelope"
|
| "ui_envelope"
|
||||||
| "session_title"
|
| "session_title"
|
||||||
| "done"
|
| "done"
|
||||||
| "error"
|
| "error"
|
||||||
| "auth_required";
|
| "auth_required"
|
||||||
|
| "credential_refresh_required"
|
||||||
|
| "credential_refreshed"
|
||||||
|
| "credential_refresh_failed"
|
||||||
|
| "agent_message"
|
||||||
|
| "user_input";
|
||||||
|
|
||||||
export type AgentSessionStreamEvent =
|
export type AgentSessionStreamEvent =
|
||||||
| {
|
| {
|
||||||
type: "state";
|
type: "state";
|
||||||
|
executionPlans?: unknown[];
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
messages: unknown[];
|
messages: unknown[];
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
@@ -80,8 +136,15 @@ export type AgentSessionStreamEvent =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AgentApiClient = {
|
export type AgentApiClient = {
|
||||||
createSession: () => Promise<AgentChatSession>;
|
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>;
|
||||||
listSessions: () => Promise<AgentChatSessionSummary[]>;
|
listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>;
|
||||||
|
updateResponseInput: (sessionId: string, id: string, delivery: AgentInputDelivery) => Promise<AgentResponseInput>;
|
||||||
|
listAgentMessages: (sessionId: string, options?: { cursor?: string; signal?: AbortSignal }) => Promise<AgentMessagePage>;
|
||||||
|
streamAgentMessages: (sessionId: string, options: { signal?: AbortSignal; onMessage: () => void }) => Promise<void>;
|
||||||
|
generatePromptSuggestions: (activities: AgentRecentActivity[], model: string, signal?: AbortSignal) => Promise<AgentPromptSuggestion[]>;
|
||||||
|
createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise<AgentChatSession>;
|
||||||
|
listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise<AgentChatSessionSummary[]>;
|
||||||
|
createTaskCollaborationSession: (taskId: string) => Promise<AgentChatSession>;
|
||||||
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
|
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
|
||||||
streamSession: (
|
streamSession: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -110,16 +173,18 @@ export type AgentApiClient = {
|
|||||||
options: { sessionId: string; answers: string[][] }
|
options: { sessionId: string; answers: string[][] }
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise<void>;
|
rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise<void>;
|
||||||
|
refreshCredential: (sessionId: string, requestId: string) => Promise<void>;
|
||||||
abort: (sessionId: string) => Promise<void>;
|
abort: (sessionId: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentApiClientOptions = {
|
export type AgentApiClientOptions = {
|
||||||
getAccessToken?: AccessTokenProvider;
|
getAccessToken?: AccessTokenProvider;
|
||||||
|
getProjectId?: () => Promise<string | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
|
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_URL.replace(/\/$/, "")];
|
||||||
|
|
||||||
const CHAT_PATH = "/api/v1/agent/chat";
|
const API_PATH = "/api/v1";
|
||||||
|
|
||||||
export function createAgentApiClient(
|
export function createAgentApiClient(
|
||||||
baseUrls: string | string[] = AGENT_API_BASE_URLS,
|
baseUrls: string | string[] = AGENT_API_BASE_URLS,
|
||||||
@@ -137,7 +202,8 @@ export function createAgentApiClient(
|
|||||||
setActiveBaseUrl,
|
setActiveBaseUrl,
|
||||||
path,
|
path,
|
||||||
init,
|
init,
|
||||||
options.getAccessToken
|
options.getAccessToken,
|
||||||
|
options.getProjectId
|
||||||
);
|
);
|
||||||
const requestJson = async <T,>(path: string, init?: RequestInit) => {
|
const requestJson = async <T,>(path: string, init?: RequestInit) => {
|
||||||
const response = await request(path, init);
|
const response = await request(path, init);
|
||||||
@@ -152,19 +218,69 @@ export function createAgentApiClient(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async createSession() {
|
async submitResponseInput(sessionId, text, delivery, id) {
|
||||||
|
return requestJson<AgentResponseInput>("/responses", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ input: text, conversation: sessionId, stream: false, tjwater: { delivery, input_id: id } })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async listResponseInputs(sessionId) {
|
||||||
|
const result = await requestJson<{ items: AgentResponseInput[] }>(`/session/${encodeURIComponent(sessionId)}/inputs`);
|
||||||
|
return result.items;
|
||||||
|
},
|
||||||
|
async updateResponseInput(sessionId, id, delivery) {
|
||||||
|
return requestJson<AgentResponseInput>(`/session/${encodeURIComponent(sessionId)}/inputs/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delivery })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async generatePromptSuggestions(activities, model, signal) {
|
||||||
|
const result = await requestJson<unknown>("/suggestions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ activities, model }),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!isRecord(result) || !Array.isArray(result.suggestions) || result.suggestions.length < 1 || result.suggestions.length > 3) {
|
||||||
|
throw new Error("Invalid Agent suggestions");
|
||||||
|
}
|
||||||
|
const ids = new Set(activities.map((item) => item.id));
|
||||||
|
const questions = new Set<string>();
|
||||||
|
return result.suggestions.map((item): AgentPromptSuggestion => {
|
||||||
|
if (!isRecord(item) || typeof item.question !== "string" || !item.question.trim() || item.question.length > 180 ||
|
||||||
|
(item.activity_id !== null && (typeof item.activity_id !== "string" || !ids.has(item.activity_id)))) {
|
||||||
|
throw new Error("Invalid Agent suggestion");
|
||||||
|
}
|
||||||
|
const question = item.question.trim();
|
||||||
|
if (questions.has(question)) throw new Error("Duplicate Agent suggestion");
|
||||||
|
questions.add(question);
|
||||||
|
return { question, activityId: item.activity_id as string | null };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async createSession(options) {
|
||||||
return requestJson<AgentChatSession>("/session", {
|
return requestJson<AgentChatSession>("/session", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({})
|
body: JSON.stringify({ trigger_context: options?.triggerContext })
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async listSessions() {
|
async listSessions(listOptions) {
|
||||||
const payload = await requestJson<{ sessions?: unknown[] }>("/sessions");
|
const params = new URLSearchParams();
|
||||||
|
if (listOptions?.purpose) params.set("purpose", listOptions.purpose);
|
||||||
|
if (listOptions?.taskId) params.set("task_id", listOptions.taskId);
|
||||||
|
const payload = await requestJson<{ sessions?: unknown[] }>(
|
||||||
|
`/sessions${params.size ? `?${params.toString()}` : ""}`
|
||||||
|
);
|
||||||
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
|
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createTaskCollaborationSession(taskId) {
|
||||||
|
return requestJson<AgentChatSession>(
|
||||||
|
`/task/${encodeURIComponent(taskId)}/collaboration/session`,
|
||||||
|
{ method: "POST" }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
async getFrontendActionRegistry() {
|
async getFrontendActionRegistry() {
|
||||||
return requestJson<unknown>("/frontend-action-registry");
|
return requestJson<unknown>("/frontend-action-registry");
|
||||||
},
|
},
|
||||||
@@ -207,6 +323,22 @@ export function createAgentApiClient(
|
|||||||
await readSessionSseStream(response, options.onEvent);
|
await readSessionSseStream(response, options.onEvent);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listAgentMessages(sessionId, options) {
|
||||||
|
const query = new URLSearchParams({ limit: "50" });
|
||||||
|
if (options?.cursor) query.set("cursor", options.cursor);
|
||||||
|
return agentMessagePageSchema.parse(await requestJson<unknown>(
|
||||||
|
`/session/${encodeURIComponent(sessionId)}/agent-messages?${query}`, { signal: options?.signal }
|
||||||
|
));
|
||||||
|
},
|
||||||
|
|
||||||
|
async streamAgentMessages(sessionId, options) {
|
||||||
|
const response = await request(`/session/${encodeURIComponent(sessionId)}/agent-messages/stream`, { signal: options.signal });
|
||||||
|
if (!response.ok) throw new Error(getResponseErrorMessage(await response.json(), response.status));
|
||||||
|
await readSessionSseStream(response, (event) => {
|
||||||
|
if (event.type === "agent_message" && event.sessionId === sessionId) options.onMessage();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async updateSessionTitle(sessionId, title, options) {
|
async updateSessionTitle(sessionId, title, options) {
|
||||||
const normalizedTitle = title.trim();
|
const normalizedTitle = title.trim();
|
||||||
if (!normalizedTitle) {
|
if (!normalizedTitle) {
|
||||||
@@ -282,6 +414,13 @@ export function createAgentApiClient(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async refreshCredential(sessionId, requestId) {
|
||||||
|
await options.getAccessToken?.(true);
|
||||||
|
await requestJson(`/session/${encodeURIComponent(sessionId)}/credentials`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ request_id: requestId })
|
||||||
|
});
|
||||||
|
},
|
||||||
async abort(sessionId) {
|
async abort(sessionId) {
|
||||||
await requestJson<unknown>("/abort", {
|
await requestJson<unknown>("/abort", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -298,22 +437,23 @@ async function fetchWithFallback(
|
|||||||
setActiveBaseUrl: (baseUrl: string) => void,
|
setActiveBaseUrl: (baseUrl: string) => void,
|
||||||
path: string,
|
path: string,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
getAccessToken?: AccessTokenProvider
|
getAccessToken?: AccessTokenProvider,
|
||||||
|
getProjectId?: () => Promise<string | undefined>
|
||||||
) {
|
) {
|
||||||
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
let lastResponse: Response | null = null;
|
let lastResponse: Response | null = null;
|
||||||
const accessToken = await getAccessToken?.();
|
const accessToken = await getAccessToken?.();
|
||||||
const requestInit = accessToken
|
const projectId = await getProjectId?.();
|
||||||
? {
|
const hasContextHeaders = Boolean(accessToken || projectId);
|
||||||
...init,
|
const headers = hasContextHeaders ? new Headers(init?.headers) : undefined;
|
||||||
headers: withBearerToken(init?.headers, accessToken)
|
if (accessToken) headers?.set("Authorization", `Bearer ${accessToken}`);
|
||||||
}
|
if (projectId) headers?.set("X-Project-Id", projectId);
|
||||||
: init;
|
const requestInit = hasContextHeaders ? { ...init, headers } : init;
|
||||||
|
|
||||||
for (const baseUrl of orderedBaseUrls) {
|
for (const baseUrl of orderedBaseUrls) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
|
const response = await fetch(`${baseUrl}${API_PATH}${path}`, requestInit);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setActiveBaseUrl(baseUrl);
|
setActiveBaseUrl(baseUrl);
|
||||||
return response;
|
return response;
|
||||||
@@ -337,12 +477,6 @@ async function fetchWithFallback(
|
|||||||
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
|
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
function withBearerToken(headersInit: HeadersInit | undefined, accessToken: string) {
|
|
||||||
const headers = new Headers(headersInit);
|
|
||||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldFallbackOnHttpStatus(status: number) {
|
function shouldFallbackOnHttpStatus(status: number) {
|
||||||
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
|
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
|
||||||
}
|
}
|
||||||
@@ -413,6 +547,10 @@ function readRunStatus(value: unknown): AgentRunStatus | undefined {
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readSessionPurpose(value: unknown): AgentSessionPurpose {
|
||||||
|
return value === "task_execution" || value === "task_collaboration" ? value : "ordinary_chat";
|
||||||
|
}
|
||||||
|
|
||||||
function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
|
function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -431,7 +569,14 @@ function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
|
|||||||
status: typeof value.status === "string" ? value.status : undefined,
|
status: typeof value.status === "string" ? value.status : undefined,
|
||||||
parentSessionId: typeof value.parent_session_id === "string" ? value.parent_session_id : undefined,
|
parentSessionId: typeof value.parent_session_id === "string" ? value.parent_session_id : undefined,
|
||||||
isStreaming: typeof value.is_streaming === "boolean" ? value.is_streaming : undefined,
|
isStreaming: typeof value.is_streaming === "boolean" ? value.is_streaming : undefined,
|
||||||
runStatus: readRunStatus(value.run_status)
|
runStatus: readRunStatus(value.run_status),
|
||||||
|
purpose: readSessionPurpose(value.purpose),
|
||||||
|
taskId: typeof value.task_id === "string" ? value.task_id : undefined,
|
||||||
|
trigger: isRecord(value.trigger) && value.trigger.workflow === "water_quality_complaint" &&
|
||||||
|
typeof value.trigger.subject_id === "string" && typeof value.trigger.occurred_at === "string" &&
|
||||||
|
Number.isFinite(Date.parse(value.trigger.occurred_at))
|
||||||
|
? { subjectId: value.trigger.subject_id, workflow: "water_quality_complaint", occurredAt: value.trigger.occurred_at }
|
||||||
|
: undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +656,7 @@ function emitSessionSseEvent(
|
|||||||
const sessionId = typeof data.session_id === "string" ? data.session_id : "";
|
const sessionId = typeof data.session_id === "string" ? data.session_id : "";
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "state",
|
type: "state",
|
||||||
|
executionPlans: Array.isArray(data.execution_plans) ? data.execution_plans : undefined,
|
||||||
sessionId,
|
sessionId,
|
||||||
messages: Array.isArray(data.messages) ? data.messages : [],
|
messages: Array.isArray(data.messages) ? data.messages : [],
|
||||||
isStreaming: data.is_streaming === true,
|
isStreaming: data.is_streaming === true,
|
||||||
@@ -532,9 +678,11 @@ function emitSessionSseEvent(
|
|||||||
|
|
||||||
function isSessionStreamDataEventType(value: string): value is AgentSessionStreamDataEventType {
|
function isSessionStreamDataEventType(value: string): value is AgentSessionStreamDataEventType {
|
||||||
return (
|
return (
|
||||||
|
value === "final_answer" ||
|
||||||
value === "token" ||
|
value === "token" ||
|
||||||
value === "progress" ||
|
value === "progress" ||
|
||||||
value === "todo_update" ||
|
value === "todo_update" ||
|
||||||
|
value === "execution_plan" ||
|
||||||
value === "permission_request" ||
|
value === "permission_request" ||
|
||||||
value === "permission_response" ||
|
value === "permission_response" ||
|
||||||
value === "question_request" ||
|
value === "question_request" ||
|
||||||
@@ -542,11 +690,17 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
|
|||||||
value === "tool_call" ||
|
value === "tool_call" ||
|
||||||
value === "frontend_action" ||
|
value === "frontend_action" ||
|
||||||
value === "frontend_action_result" ||
|
value === "frontend_action_result" ||
|
||||||
|
value === "answer_ui" ||
|
||||||
value === "ui_envelope" ||
|
value === "ui_envelope" ||
|
||||||
value === "session_title" ||
|
value === "session_title" ||
|
||||||
value === "done" ||
|
value === "done" ||
|
||||||
value === "error" ||
|
value === "error" ||
|
||||||
|
value === "credential_refresh_required" ||
|
||||||
|
value === "credential_refreshed" ||
|
||||||
|
value === "credential_refresh_failed" ||
|
||||||
value === "auth_required"
|
value === "auth_required"
|
||||||
|
|| value === "agent_message"
|
||||||
|
|| value === "user_input"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { UIMessage, UIMessageChunk } from "ai";
|
||||||
|
import { createResponseRequest, responsesToUiStream } from "./responses-transport";
|
||||||
|
|
||||||
|
const bytes = (events: Record<string, unknown>[], partial = false) => {
|
||||||
|
const text = events.map((event) => `event: ${String(event.type)}\r\ndata: ${JSON.stringify(event)}\r\n\r\n`).join("");
|
||||||
|
const encoded = new TextEncoder().encode(text);
|
||||||
|
return new ReadableStream<Uint8Array>({ start(controller) {
|
||||||
|
if (partial) for (let i = 0; i < encoded.length; i += 7) controller.enqueue(encoded.slice(i, i + 7));
|
||||||
|
else controller.enqueue(encoded);
|
||||||
|
controller.close();
|
||||||
|
} });
|
||||||
|
};
|
||||||
|
const collect = async (stream: ReadableStream<UIMessageChunk>) => {
|
||||||
|
const parts: UIMessageChunk[] = [];
|
||||||
|
const reader = stream.getReader();
|
||||||
|
while (true) { const next = await reader.read(); if (next.done) break; parts.push(next.value); }
|
||||||
|
return parts;
|
||||||
|
};
|
||||||
|
const events = (): Record<string, unknown>[] => [
|
||||||
|
{ type: "response.created", sequence_number: 0, response: { id: "resp_1" } },
|
||||||
|
{ type: "response.output_item.added", sequence_number: 1, item: { type: "message", id: "msg_1", role: "assistant", content: [] } },
|
||||||
|
{ type: "response.content_part.added", sequence_number: 2, item_id: "msg_1", content_index: 0, part: { type: "output_text", text: "" } },
|
||||||
|
{ type: "response.output_text.delta", sequence_number: 3, item_id: "msg_1", content_index: 0, delta: "压力待核验" },
|
||||||
|
{ type: "tjwater.permission_request", sequence_number: 4, response_id: "resp_1", data: { request_id: "permission_1" } },
|
||||||
|
{ type: "response.output_text.done", sequence_number: 5, item_id: "msg_1", content_index: 0, text: "压力待核验" },
|
||||||
|
{ type: "response.completed", sequence_number: 6, response: { id: "resp_1", status: "completed" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("Responses transport", () => {
|
||||||
|
it("sends only the latest user input and keeps business options explicit", () => {
|
||||||
|
const messages = [{ id: "old", role: "assistant", parts: [{ type: "text", text: "旧回答" }] }, { id: "new", role: "user", parts: [{ type: "text", text: "继续核查" }] }] as UIMessage[];
|
||||||
|
expect(createResponseRequest(messages, { conversation: "owned", approvalMode: "always" })).toEqual({
|
||||||
|
input: [{ role: "user", content: [{ type: "input_text", text: "继续核查" }] }], stream: true, conversation: "owned",
|
||||||
|
tjwater: { approval_mode: "always", capabilities: [] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("decodes split UTF-8 and CRLF while retaining approval events", async () => {
|
||||||
|
expect(await collect(responsesToUiStream(bytes(events(), true)))).toEqual([
|
||||||
|
{ type: "start", messageId: "msg_1" }, { type: "text-start", id: "msg_1:0" },
|
||||||
|
{ type: "text-delta", id: "msg_1:0", delta: "压力待核验" },
|
||||||
|
{ type: "data-permission_request", data: { request_id: "permission_1" } },
|
||||||
|
{ type: "text-end", id: "msg_1:0" }, { type: "finish", finishReason: "stop" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("reports premature disconnects, mismatched owners and invalid event order", async () => {
|
||||||
|
await expect(collect(responsesToUiStream(bytes(events().slice(0, 5))))).rejects.toThrow("断开");
|
||||||
|
const wrong = events(); wrong[4] = { ...wrong[4], response_id: "other" };
|
||||||
|
await expect(collect(responsesToUiStream(bytes(wrong)))).rejects.toThrow("归属");
|
||||||
|
await expect(collect(responsesToUiStream(bytes([events()[0]!, events()[3]!])))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
it("turns failed and cancelled Responses into visible errors", async () => {
|
||||||
|
const failed = events();
|
||||||
|
failed[6] = { type: "response.failed", sequence_number: 6, response: { id: "resp_1", error: { message: "数据源不可用" } } };
|
||||||
|
expect((await collect(responsesToUiStream(bytes(failed)))).at(-2)).toEqual({ type: "error", errorText: "数据源不可用" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { HttpChatTransport, type UIMessage, type UIMessageChunk } from "ai";
|
||||||
|
|
||||||
|
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
export function createResponseRequest(messages: UIMessage[], options: {
|
||||||
|
conversation?: string; model?: string; approvalMode?: string; capabilities?: string[];
|
||||||
|
triggerContext?: unknown; taskContext?: unknown;
|
||||||
|
}) {
|
||||||
|
const latest = [...messages].reverse().find((message) => message.role === "user");
|
||||||
|
const text = latest?.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim();
|
||||||
|
if (!text) throw new Error("Responses 请求需要用户文本");
|
||||||
|
return {
|
||||||
|
input: [{ role: "user", content: [{ type: "input_text", text }] }], stream: true,
|
||||||
|
...(options.model ? { model: options.model } : {}),
|
||||||
|
...(options.conversation ? { conversation: options.conversation } : {}),
|
||||||
|
tjwater: { approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
|
||||||
|
...(options.triggerContext ? { trigger_context: options.triggerContext } : {}),
|
||||||
|
...(options.taskContext ? { task_context: options.taskContext } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep UI rendering independent of the public Responses wire protocol. */
|
||||||
|
export function responsesToUiStream(stream: ReadableStream<Uint8Array>): ReadableStream<UIMessageChunk> {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let responseId: string | undefined;
|
||||||
|
let messageStarted = false;
|
||||||
|
let messageId: string | undefined;
|
||||||
|
let terminal = false;
|
||||||
|
let sequence = -1;
|
||||||
|
const texts = new Set<string>();
|
||||||
|
const parse = (block: string, controller: TransformStreamDefaultController<UIMessageChunk>) => {
|
||||||
|
if (block.length > 1024 * 1024) throw new Error("Responses 单个流事件过大");
|
||||||
|
const lines = block.split(/\r?\n/);
|
||||||
|
const data = lines.filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
||||||
|
if (!data) return;
|
||||||
|
if (terminal) throw new Error("Responses 流在结束后仍返回事件");
|
||||||
|
const value: unknown = JSON.parse(data);
|
||||||
|
if (!record(value) || typeof value.type !== "string" || !Number.isInteger(value.sequence_number) || (value.sequence_number as number) <= sequence) throw new Error("Responses 流事件格式不正确");
|
||||||
|
sequence = value.sequence_number as number;
|
||||||
|
const named = lines.find((line) => line.startsWith("event:"))?.slice(6).trim();
|
||||||
|
if (named && named !== value.type) throw new Error("Responses 流事件类型不一致");
|
||||||
|
if (value.type === "response.created") {
|
||||||
|
if (responseId || !record(value.response) || typeof value.response.id !== "string") throw new Error("Responses 创建事件不正确");
|
||||||
|
responseId = value.response.id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!responseId) throw new Error("Responses 流缺少创建事件");
|
||||||
|
const textId = () => {
|
||||||
|
if (value.item_id !== messageId || !Number.isInteger(value.content_index) || (value.content_index as number) < 0) throw new Error("Responses 文本归属不正确");
|
||||||
|
return `${messageId}:${String(value.content_index)}`;
|
||||||
|
};
|
||||||
|
if (value.type === "response.output_item.added" && record(value.item) && value.item.type === "message") {
|
||||||
|
if (messageStarted || typeof value.item.id !== "string") throw new Error("Responses 回答消息不正确");
|
||||||
|
messageStarted = true;
|
||||||
|
messageId = value.item.id;
|
||||||
|
controller.enqueue({ type: "start", messageId: value.item.id });
|
||||||
|
} else if (value.type === "response.content_part.added" && record(value.part) && value.part.type === "output_text") {
|
||||||
|
if (!messageStarted) throw new Error("Responses 流缺少回答消息");
|
||||||
|
texts.add(textId());
|
||||||
|
controller.enqueue({ type: "text-start", id: textId() });
|
||||||
|
} else if (value.type === "response.output_text.delta") {
|
||||||
|
if (!texts.has(textId()) || typeof value.delta !== "string") throw new Error("Responses 文本事件顺序不正确");
|
||||||
|
controller.enqueue({ type: "text-delta", id: textId(), delta: value.delta });
|
||||||
|
} else if (value.type === "response.output_text.done") {
|
||||||
|
if (!texts.delete(textId())) throw new Error("Responses 文本结束事件不正确");
|
||||||
|
controller.enqueue({ type: "text-end", id: textId() });
|
||||||
|
} else if (value.type.startsWith("tjwater.")) {
|
||||||
|
if (value.response_id !== responseId || !/^tjwater\.[a-z][a-z0-9_]*$/.test(value.type)) throw new Error("业务流事件归属不正确");
|
||||||
|
controller.enqueue({ type: `data-${value.type.slice(8)}`, data: value.data,
|
||||||
|
...(typeof value.id === "string" ? { id: value.id } : {}), ...(value.transient === true ? { transient: true } : {}) } as UIMessageChunk);
|
||||||
|
} else if (["response.completed", "response.failed", "response.incomplete"].includes(value.type)) {
|
||||||
|
if (!record(value.response) || value.response.id !== responseId || texts.size) throw new Error("Responses 结束事件不正确");
|
||||||
|
terminal = true;
|
||||||
|
if (!messageStarted) controller.enqueue({ type: "start", messageId: responseId });
|
||||||
|
if (value.type !== "response.completed") {
|
||||||
|
const failure = value.response.error;
|
||||||
|
controller.enqueue({ type: "error", errorText: record(failure) && typeof failure.message === "string" ? failure.message : "请求未完成或已中止" });
|
||||||
|
}
|
||||||
|
controller.enqueue({ type: "finish", finishReason: value.type === "response.completed" ? "stop" : "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const flushBlocks = (controller: TransformStreamDefaultController<UIMessageChunk>) => {
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = /\r?\n\r?\n/.exec(buffer))) {
|
||||||
|
const block = buffer.slice(0, match.index);
|
||||||
|
buffer = buffer.slice(match.index + match[0].length);
|
||||||
|
parse(block, controller);
|
||||||
|
}
|
||||||
|
if (buffer.length > 1024 * 1024) throw new Error("Responses 单个流事件过大");
|
||||||
|
};
|
||||||
|
return stream.pipeThrough(new TransformStream<Uint8Array, UIMessageChunk>({
|
||||||
|
transform(chunk, controller) { buffer += decoder.decode(chunk, { stream: true }); flushBlocks(controller); },
|
||||||
|
flush(controller) {
|
||||||
|
buffer += decoder.decode();
|
||||||
|
flushBlocks(controller);
|
||||||
|
if (buffer.trim()) parse(buffer, controller);
|
||||||
|
if (!terminal) throw new Error("Responses 流在完成前断开,请重新打开会话查看状态");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResponsesChatTransport<Message extends UIMessage> extends HttpChatTransport<Message> {
|
||||||
|
protected processResponseStream(stream: ReadableStream<Uint8Array>) {
|
||||||
|
return responsesToUiStream(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,21 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { AgentApiClient } from "./client";
|
import type { AgentApiClient } from "./client";
|
||||||
import { fetchAgentBootstrap, fetchAgentSessions } from "./swr";
|
import { fetchAgentBootstrap, fetchAgentSessions, resolveAgentModelSelection } from "./swr";
|
||||||
|
|
||||||
describe("Agent API SWR helpers", () => {
|
describe("Agent API SWR helpers", () => {
|
||||||
|
it("preserves valid model choices and resolves removed choices against the current catalog", () => {
|
||||||
|
const models = [
|
||||||
|
{ id: "primary/fast", label: "快速", description: "Primary", icon: "bolt" as const },
|
||||||
|
{ id: "secondary/expert", label: "专家", description: "Secondary", icon: "sparkle" as const }
|
||||||
|
];
|
||||||
|
const catalog = { defaultModel: "secondary/expert", models };
|
||||||
|
expect(resolveAgentModelSelection(catalog, "primary/fast")).toBe("primary/fast");
|
||||||
|
expect(resolveAgentModelSelection(catalog, "retired/fast")).toBe("secondary/expert");
|
||||||
|
expect(resolveAgentModelSelection(catalog)).toBe("secondary/expert");
|
||||||
|
expect(resolveAgentModelSelection({ ...catalog, defaultModel: "missing/model" }, "retired/fast")).toBe("primary/fast");
|
||||||
|
expect(resolveAgentModelSelection({ defaultModel: "primary/fast", models: [] }, "primary/fast")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
it("loads bootstrap data with a normalized UI registry", async () => {
|
it("loads bootstrap data with a normalized UI registry", async () => {
|
||||||
const client = {
|
const client = {
|
||||||
getUiRegistry: vi.fn(async () => ({
|
getUiRegistry: vi.fn(async () => ({
|
||||||
@@ -35,13 +48,15 @@ describe("Agent API SWR helpers", () => {
|
|||||||
id: "session-1",
|
id: "session-1",
|
||||||
title: "会话",
|
title: "会话",
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
updatedAt: 2
|
updatedAt: 2,
|
||||||
|
purpose: "ordinary_chat" as const
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
const client = {
|
const client = {
|
||||||
listSessions: vi.fn(async () => sessions)
|
listSessions: vi.fn(async () => sessions)
|
||||||
} as Pick<AgentApiClient, "listSessions"> as AgentApiClient;
|
} as Pick<AgentApiClient, "listSessions"> as AgentApiClient;
|
||||||
|
|
||||||
await expect(fetchAgentSessions(client)).resolves.toBe(sessions);
|
await expect(fetchAgentSessions(client)).resolves.toEqual(sessions);
|
||||||
|
expect(client.listSessions).toHaveBeenCalledWith({ purpose: "ordinary_chat" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentApiClient,
|
AgentApiClient,
|
||||||
AgentChatSessionSummary,
|
AgentChatSessionSummary,
|
||||||
AgentModelOption
|
AgentModelOption,
|
||||||
|
AgentModelsResponse
|
||||||
} from "./client";
|
} from "./client";
|
||||||
import { parseUiRegistry, type UIRegistry } from "../ui-envelope";
|
import { parseUiRegistry, type UIRegistry } from "../ui-envelope";
|
||||||
import { parseFrontendActionRegistry, type FrontendActionRegistry } from "../frontend-action";
|
import { parseFrontendActionRegistry, type FrontendActionRegistry } from "../frontend-action";
|
||||||
@@ -16,6 +17,12 @@ export type AgentBootstrapData = {
|
|||||||
defaultModel: string;
|
defaultModel: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function resolveAgentModelSelection(catalog: AgentModelsResponse, current = ""): string {
|
||||||
|
if (catalog.models.some((model) => model.id === current)) return current;
|
||||||
|
if (catalog.models.some((model) => model.id === catalog.defaultModel)) return catalog.defaultModel;
|
||||||
|
return catalog.models[0]?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchAgentBootstrap(client: AgentApiClient): Promise<AgentBootstrapData> {
|
export async function fetchAgentBootstrap(client: AgentApiClient): Promise<AgentBootstrapData> {
|
||||||
const [rawRegistry, rawFrontendActionRegistry, modelsResponse] = await Promise.all([
|
const [rawRegistry, rawFrontendActionRegistry, modelsResponse] = await Promise.all([
|
||||||
client.getUiRegistry(),
|
client.getUiRegistry(),
|
||||||
@@ -32,5 +39,7 @@ export async function fetchAgentBootstrap(client: AgentApiClient): Promise<Agent
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAgentSessions(client: AgentApiClient): Promise<AgentChatSessionSummary[]> {
|
export async function fetchAgentSessions(client: AgentApiClient): Promise<AgentChatSessionSummary[]> {
|
||||||
return client.listSessions();
|
const sessions = await client.listSessions({ purpose: "ordinary_chat" });
|
||||||
|
// Keep the product boundary even while an older Agent service is rolling forward.
|
||||||
|
return sessions.filter((session) => session.purpose === "ordinary_chat");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function AgentCollapsedRail({
|
|||||||
<aside
|
<aside
|
||||||
aria-label="Agent 折叠栏"
|
aria-label="Agent 折叠栏"
|
||||||
className={cn(
|
className={cn(
|
||||||
"agent-rail-enter surface-dock pointer-events-auto w-[72px] rounded-2xl border p-1.5 shadow-[0_8px_24px_rgba(15,23,42,0.1)]"
|
"agent-rail-enter acrylic-control pointer-events-auto w-[72px] rounded-2xl border p-1.5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -28,7 +28,7 @@ export function AgentCollapsedRail({
|
|||||||
aria-label={expandLabel}
|
aria-label={expandLabel}
|
||||||
title={expandLabel}
|
title={expandLabel}
|
||||||
onClick={onExpand}
|
onClick={onExpand}
|
||||||
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border hover:bg-white"
|
className="agent-rail-item group relative flex h-14 w-full items-center justify-center rounded-xl border border-transparent bg-transparent hover:bg-[var(--action-selection-soft)]"
|
||||||
>
|
>
|
||||||
<AgentPersona className="h-11 w-11" state={personaState} />
|
<AgentPersona className="h-11 w-11" state={personaState} />
|
||||||
<span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5">
|
<span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5">
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function AgentHistoryPanel({
|
|||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-semibold text-slate-950">历史记录</p>
|
<p className="text-sm font-semibold text-slate-950">历史记录</p>
|
||||||
<p className="mt-0.5 text-xs text-slate-500">
|
<p className="mt-0.5 text-xs text-[var(--ink-muted)]">
|
||||||
{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}
|
{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +103,7 @@ export function AgentHistoryPanel({
|
|||||||
{loading && sessions.length === 0 ? (
|
{loading && sessions.length === 0 ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="history-loading"
|
key="history-loading"
|
||||||
className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-slate-500"
|
className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-[var(--ink-muted)]"
|
||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
@@ -128,7 +128,7 @@ export function AgentHistoryPanel({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors",
|
"agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors",
|
||||||
active && !editing
|
active && !editing
|
||||||
? "agent-history-item-active border border-blue-200"
|
? "agent-history-item-active border border-[var(--surface-border)]"
|
||||||
: "border border-transparent"
|
: "border border-transparent"
|
||||||
)}
|
)}
|
||||||
initial="initial"
|
initial="initial"
|
||||||
@@ -153,7 +153,7 @@ export function AgentHistoryPanel({
|
|||||||
>
|
>
|
||||||
删除“{session.title}”?
|
删除“{session.title}”?
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">
|
||||||
删除后无法从历史记录中恢复
|
删除后无法从历史记录中恢复
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -172,7 +172,7 @@ export function AgentHistoryPanel({
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="agent-history-rename-input h-8 rounded-lg bg-white text-xs"
|
className="agent-history-rename-input h-8 rounded-lg bg-[var(--surface-reading)] text-xs"
|
||||||
value={draftTitle}
|
value={draftTitle}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onChange={(event) => setDraftTitle(event.currentTarget.value)}
|
onChange={(event) => setDraftTitle(event.currentTarget.value)}
|
||||||
@@ -201,7 +201,7 @@ export function AgentHistoryPanel({
|
|||||||
>
|
>
|
||||||
{session.title}
|
{session.title}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">
|
||||||
{formatSessionUpdatedAt(session.updatedAt)}
|
{formatSessionUpdatedAt(session.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
@@ -214,7 +214,7 @@ export function AgentHistoryPanel({
|
|||||||
{itemLoading || saving || deleting ? (
|
{itemLoading || saving || deleting ? (
|
||||||
<Loader2
|
<Loader2
|
||||||
size={13}
|
size={13}
|
||||||
className="animate-spin text-blue-600"
|
className="animate-spin text-[var(--action-blue-hover)]"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -289,7 +289,7 @@ export function AgentHistoryPanel({
|
|||||||
title="删除对话"
|
title="删除对话"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-slate-500 hover:bg-red-50 hover:text-red-600"
|
className="text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600"
|
||||||
disabled={itemLoading || deleting || !onDeleteSession}
|
disabled={itemLoading || deleting || !onDeleteSession}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
cancelRename();
|
cancelRename();
|
||||||
@@ -307,7 +307,7 @@ export function AgentHistoryPanel({
|
|||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="history-empty"
|
key="history-empty"
|
||||||
className="agent-history-state rounded-xl px-3 py-3 text-xs text-slate-500"
|
className="agent-history-state rounded-xl px-3 py-3 text-xs text-[var(--ink-muted)]"
|
||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import { AnimatePresence, motion } from "motion/react";
|
|||||||
import { useId, useState, type ReactNode } from "react";
|
import { useId, useState, type ReactNode } from "react";
|
||||||
import { cn } from "@/shared/ui/cn";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Checkbox } from "@/shared/ui/checkbox";
|
||||||
|
import { Radio } from "@/shared/ui/radio";
|
||||||
|
import { Textarea } from "@/shared/ui/textarea";
|
||||||
import {
|
import {
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
StatusDot,
|
StatusDot,
|
||||||
@@ -218,9 +221,9 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
|||||||
return (
|
return (
|
||||||
<div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress>
|
<div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress>
|
||||||
<div className="flex min-h-8 items-center gap-2 px-0.5">
|
<div className="flex min-h-8 items-center gap-2 px-0.5">
|
||||||
<ListTree size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
<ListTree size={15} className="shrink-0 text-[var(--action-blue-hover)]" aria-hidden="true" />
|
||||||
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-slate-700">执行进度</span>
|
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-slate-700">执行进度</span>
|
||||||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-slate-500">
|
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-[var(--ink-muted)]">
|
||||||
{running ? (
|
{running ? (
|
||||||
<StatusDot tone="info" activity="live" aria-hidden="true" />
|
<StatusDot tone="info" activity="live" aria-hidden="true" />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -236,8 +239,8 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"-my-1 -mr-1.5 grid h-10 w-10 shrink-0 place-items-center rounded-md text-slate-400 transition-colors [@media(hover:hover)]:hover:text-blue-600",
|
"-my-1 -mr-1.5 grid h-10 w-10 shrink-0 place-items-center rounded-md text-[var(--ink-muted)] transition-colors [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]",
|
||||||
expanded && "text-blue-500"
|
expanded && "text-[var(--action-blue-hover)]"
|
||||||
)}
|
)}
|
||||||
aria-controls={listId}
|
aria-controls={listId}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
@@ -296,7 +299,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
|||||||
{item.detail ? (
|
{item.detail ? (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"block leading-5 text-slate-500",
|
"block leading-5 text-[var(--ink-muted)]",
|
||||||
compact ? "truncate" : "break-words"
|
compact ? "truncate" : "break-words"
|
||||||
)}
|
)}
|
||||||
title={item.detail}
|
title={item.detail}
|
||||||
@@ -312,7 +315,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
|||||||
className={cn(
|
className={cn(
|
||||||
"min-w-12 pt-0.5 text-right text-xs font-semibold",
|
"min-w-12 pt-0.5 text-right text-xs font-semibold",
|
||||||
item.status === "running"
|
item.status === "running"
|
||||||
? "text-blue-600"
|
? "text-[var(--action-blue-hover)]"
|
||||||
: [
|
: [
|
||||||
`status-tone-${progressStatusMeta[item.status].tone}`,
|
`status-tone-${progressStatusMeta[item.status].tone}`,
|
||||||
"text-[var(--status-foreground)]"
|
"text-[var(--status-foreground)]"
|
||||||
@@ -429,7 +432,7 @@ function PermissionRequestCard({
|
|||||||
<p className="truncate font-semibold text-slate-800" title={permission.target ?? permission.permission}>
|
<p className="truncate font-semibold text-slate-800" title={permission.target ?? permission.permission}>
|
||||||
{getPermissionTitle(permission)}
|
{getPermissionTitle(permission)}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 truncate font-mono text-xs text-slate-500" title={target}>
|
<p className="mt-0.5 truncate font-mono text-xs text-[var(--ink-muted)]" title={target}>
|
||||||
{target}
|
{target}
|
||||||
</p>
|
</p>
|
||||||
{permission.error ? (
|
{permission.error ? (
|
||||||
@@ -470,7 +473,7 @@ function PermissionRequestCard({
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
|
className="h-8 border-[var(--surface-border)] bg-[var(--action-soft)] px-2 text-xs text-[var(--action-blue-hover)] hover:bg-[var(--action-selection-soft)]"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => reply("always")}
|
onClick={() => reply("always")}
|
||||||
>
|
>
|
||||||
@@ -506,7 +509,7 @@ function QuestionList({
|
|||||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AgentNestedBlock icon={HelpCircle} iconClassName="text-blue-600" title="补充信息" floating>
|
<AgentNestedBlock icon={HelpCircle} iconClassName="text-[var(--action-blue-hover)]" title="补充信息" floating>
|
||||||
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
|
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
|
||||||
<AnimatePresence initial={false} mode="popLayout">
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
{questions.slice(-3).map((request) => (
|
{questions.slice(-3).map((request) => (
|
||||||
@@ -587,7 +590,7 @@ function QuestionRequestCard({
|
|||||||
<p className="status-tone-danger mt-2 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{request.error}</p>
|
<p className="status-tone-danger mt-2 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{request.error}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{request.answers?.length ? (
|
{request.answers?.length ? (
|
||||||
<p className="mt-2 truncate text-slate-500" title={request.answers.map((answer) => answer.join("、")).join(";")}>
|
<p className="mt-2 truncate text-[var(--ink-muted)]" title={request.answers.map((answer) => answer.join("、")).join(";")}>
|
||||||
已答:{request.answers.map((answer) => answer.join("、")).join(";")}
|
已答:{request.answers.map((answer) => answer.join("、")).join(";")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -617,7 +620,7 @@ function QuestionRequestCard({
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
|
className="h-8 border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs text-slate-600 hover:bg-[var(--surface-reading)]"
|
||||||
disabled={submitting || !onRejectQuestion}
|
disabled={submitting || !onRejectQuestion}
|
||||||
onClick={reject}
|
onClick={reject}
|
||||||
>
|
>
|
||||||
@@ -686,21 +689,20 @@ function QuestionInput({
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{question.options.map((option) => {
|
{question.options.map((option) => {
|
||||||
const checked = value.selected.includes(option.label);
|
const checked = value.selected.includes(option.label);
|
||||||
|
const Choice = question.multiple ? Checkbox : Radio;
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={option.label}
|
key={option.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer items-start gap-2 rounded-md border px-2 py-1.5 transition-colors",
|
"flex cursor-pointer items-start gap-2 rounded-md border px-2 py-1.5 transition-colors",
|
||||||
checked
|
checked
|
||||||
? "border-blue-300 bg-blue-50/70 text-blue-700"
|
? "border-[var(--palette-brand)] bg-[var(--action-soft)]/70 text-[var(--action-blue-hover)]"
|
||||||
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
|
: "border-[var(--surface-divider)]/80 text-slate-600 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)]/70",
|
||||||
disabled && "cursor-default opacity-60"
|
disabled && "cursor-default opacity-60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<Choice
|
||||||
type={question.multiple ? "checkbox" : "radio"}
|
|
||||||
name={name}
|
name={name}
|
||||||
className="mt-0.5 h-3.5 w-3.5 accent-blue-600"
|
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(event) => toggleOption(option.label, event.currentTarget.checked)}
|
onChange={(event) => toggleOption(option.label, event.currentTarget.checked)}
|
||||||
@@ -708,7 +710,7 @@ function QuestionInput({
|
|||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block font-semibold text-slate-700">{option.label}</span>
|
<span className="block font-semibold text-slate-700">{option.label}</span>
|
||||||
{option.description ? (
|
{option.description ? (
|
||||||
<span className="block leading-4 text-slate-500">{option.description}</span>
|
<span className="block leading-4 text-[var(--ink-muted)]">{option.description}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -719,27 +721,24 @@ function QuestionInput({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer items-center gap-2 rounded-md border px-2 py-2 transition-colors",
|
"flex cursor-pointer items-center gap-2 rounded-md border px-2 py-2 transition-colors",
|
||||||
value.customSelected
|
value.customSelected
|
||||||
? "border-blue-300 bg-blue-50/70 text-blue-700"
|
? "border-[var(--palette-brand)] bg-[var(--action-soft)]/70 text-[var(--action-blue-hover)]"
|
||||||
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
|
: "border-[var(--surface-divider)]/80 text-slate-600 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)]/70",
|
||||||
disabled && "cursor-default opacity-60"
|
disabled && "cursor-default opacity-60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
{question.multiple ? (
|
||||||
type={question.multiple ? "checkbox" : "radio"}
|
<Checkbox name={name} checked={value.customSelected} disabled={disabled} onChange={(event) => toggleCustom(event.currentTarget.checked)} />
|
||||||
name={name}
|
) : (
|
||||||
className="h-3.5 w-3.5 accent-blue-600"
|
<Radio name={name} checked={value.customSelected} disabled={disabled} onChange={(event) => toggleCustom(event.currentTarget.checked)} />
|
||||||
checked={value.customSelected}
|
)}
|
||||||
disabled={disabled}
|
|
||||||
onChange={(event) => toggleCustom(event.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
<span className="font-semibold text-slate-700">自定义回答</span>
|
<span className="font-semibold text-slate-700">自定义回答</span>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{showCustomInput ? (
|
{showCustomInput ? (
|
||||||
<textarea
|
<Textarea
|
||||||
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-hidden transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
|
className="min-h-16 resize-none border-[var(--surface-divider)]/80 px-2 py-1.5 text-xs leading-5 text-slate-700 shadow-none md:text-xs disabled:opacity-60"
|
||||||
aria-label="自定义回答内容"
|
aria-label="自定义回答内容"
|
||||||
placeholder="输入自定义回答"
|
placeholder="输入自定义回答"
|
||||||
value={value.custom}
|
value={value.custom}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Mail } from "lucide-react";
|
||||||
|
import { Disclosure } from "@/shared/ui/disclosure";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import type { AgentMailboxMessage } from "../api/agent-messages";
|
||||||
|
|
||||||
|
const messageStatusLabels = { pending: "待接收", enqueued: "已排队", delivered: "已投递", failed: "投递失败" };
|
||||||
|
|
||||||
|
export function AgentMessageInbox({ messages, loading, error, hasMore, loadingMore, onLoadMore, onRefresh }: {
|
||||||
|
messages: AgentMailboxMessage[];
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadingMore?: boolean;
|
||||||
|
onLoadMore?: () => Promise<void>;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}) {
|
||||||
|
if (!messages.length && !loading && !error) return null;
|
||||||
|
const received = messages.filter((message) => message.direction === "to_chat").length;
|
||||||
|
return <Disclosure label={<span className="flex items-center gap-2 px-3"><Mail size={14} />任务消息{received ? ` · 收到 ${received} 条` : ""}</span>} initiallyOpen className="surface-reading rounded-xl">
|
||||||
|
<div className="space-y-3 p-3 text-xs" aria-live="polite">
|
||||||
|
<p className="text-[var(--ink-muted)]">新消息会在此显示。由你决定是否继续发送指令。</p>
|
||||||
|
{loading ? <p className="text-[var(--ink-muted)]">正在加载任务消息…</p> : null}
|
||||||
|
{error ? <div className="flex items-center justify-between gap-2"><p className="text-[var(--status-warning-foreground)]">{error}</p><Button variant="ghost" size="sm" onClick={onRefresh}>重试</Button></div> : null}
|
||||||
|
{messages.map((message) => <article key={message.message_id} className="space-y-1.5 border-t border-[var(--surface-divider)] pt-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 text-[var(--ink-muted)]">
|
||||||
|
<span>{message.direction === "to_chat" ? "调查任务来信" : "发给调查任务"}</span>
|
||||||
|
<span>{messageStatusLabels[message.status]}</span>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--ink)]">{message.text}</p>
|
||||||
|
<p className="break-all text-[var(--ink-muted)]">task_id: <span className="select-text font-mono">{message.task_id}</span></p>
|
||||||
|
<p className="break-all text-[var(--ink-muted)]">run_id: <span className="select-text font-mono">{message.run_id}</span></p>
|
||||||
|
{message.failure_detail ? <p className="break-words text-[var(--status-danger-foreground)]">{message.failure_detail}</p> : null}
|
||||||
|
</article>)}
|
||||||
|
{hasMore ? <Button variant="ghost" size="sm" disabled={loadingMore} onClick={() => void onLoadMore?.().catch(() => undefined)}>{loadingMore ? "正在加载…" : "加载更多消息"}</Button> : null}
|
||||||
|
</div>
|
||||||
|
</Disclosure>;
|
||||||
|
}
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { ClipboardCheck, Crosshair, MapPinned, Radio, Route } from "lucide-react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { Button } from "@/shared/ui/button";
|
|
||||||
import { StatusDot, type StatusTone } from "@/shared/ui/status";
|
|
||||||
import { countPendingAgentInputs } from "../message-state";
|
|
||||||
import type { AgentChatMessage } from "../types";
|
|
||||||
|
|
||||||
export function AgentOperationalBrief({
|
|
||||||
messages,
|
|
||||||
statusLabel,
|
|
||||||
streaming,
|
|
||||||
onSubmitCommand
|
|
||||||
}: {
|
|
||||||
messages: AgentChatMessage[];
|
|
||||||
statusLabel: string;
|
|
||||||
streaming: boolean;
|
|
||||||
onSubmitCommand: (prompt: string) => void;
|
|
||||||
}) {
|
|
||||||
const brief = createOperationalBrief(messages, statusLabel, streaming);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="agent-panel-control rounded-2xl p-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusDot tone={brief.statusTone} activity={streaming ? "live" : "static"} size="md" />
|
|
||||||
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 truncate text-xs leading-5 text-slate-600" title={brief.task}>
|
|
||||||
{brief.task}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl className="mt-3 grid grid-cols-3 gap-2">
|
|
||||||
<AgentBriefMetric
|
|
||||||
icon={<Radio size={13} aria-hidden="true" />}
|
|
||||||
label="证据"
|
|
||||||
value={brief.evidence}
|
|
||||||
/>
|
|
||||||
<AgentBriefMetric
|
|
||||||
icon={<Route size={13} aria-hidden="true" />}
|
|
||||||
label="计划"
|
|
||||||
value={brief.plan}
|
|
||||||
/>
|
|
||||||
<AgentBriefMetric
|
|
||||||
icon={<ClipboardCheck size={13} aria-hidden="true" />}
|
|
||||||
label="确认"
|
|
||||||
value={brief.confirmation}
|
|
||||||
/>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full text-xs"
|
|
||||||
disabled={streaming}
|
|
||||||
onClick={() => onSubmitCommand(brief.primaryCommand)}
|
|
||||||
>
|
|
||||||
<MapPinned size={14} aria-hidden="true" />
|
|
||||||
预览影响
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full text-xs"
|
|
||||||
disabled={streaming}
|
|
||||||
onClick={() => onSubmitCommand(brief.secondaryCommand)}
|
|
||||||
>
|
|
||||||
<Crosshair size={14} aria-hidden="true" />
|
|
||||||
检查证据
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AgentBriefMetric({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
value
|
|
||||||
}: {
|
|
||||||
icon: ReactNode;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="agent-brief-metric min-w-0 rounded-xl border px-2 py-2">
|
|
||||||
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-600">
|
|
||||||
<span className="text-blue-600">{icon}</span>
|
|
||||||
{label}
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 truncate text-xs font-semibold text-slate-900" title={value}>
|
|
||||||
{value}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createOperationalBrief(
|
|
||||||
messages: AgentChatMessage[],
|
|
||||||
statusLabel: string,
|
|
||||||
streaming: boolean
|
|
||||||
) {
|
|
||||||
const lastUserMessage = [...messages]
|
|
||||||
.reverse()
|
|
||||||
.find((message) => message.role === "user" && message.content.trim());
|
|
||||||
const progressItems = messages.flatMap((message) => message.progress ?? []);
|
|
||||||
const todoItems = messages.flatMap((message) => message.todos?.todos ?? []);
|
|
||||||
const latestProgress = progressItems.at(-1);
|
|
||||||
const activeTodo =
|
|
||||||
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
|
|
||||||
[...todoItems].reverse().find((todo) => todo.status === "pending");
|
|
||||||
const pendingConfirmations = countPendingAgentInputs(messages);
|
|
||||||
const completedSteps = progressItems.filter((item) => item.status === "completed").length;
|
|
||||||
const task = lastUserMessage?.content.trim() || "等待调度指令";
|
|
||||||
const evidence = latestProgress?.detail || latestProgress?.title || statusLabel;
|
|
||||||
const plan =
|
|
||||||
activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
|
|
||||||
const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认";
|
|
||||||
const stateLabel =
|
|
||||||
pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
|
|
||||||
const statusTone: StatusTone =
|
|
||||||
pendingConfirmations > 0 ? "warning" : streaming ? "info" : "success";
|
|
||||||
const promptContext = lastUserMessage?.content.trim() || "当前供水管网运行态势";
|
|
||||||
|
|
||||||
return {
|
|
||||||
task,
|
|
||||||
evidence,
|
|
||||||
plan,
|
|
||||||
confirmation,
|
|
||||||
stateLabel,
|
|
||||||
statusTone,
|
|
||||||
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
|
|
||||||
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -44,7 +44,7 @@ function useElementVisibility() {
|
|||||||
|
|
||||||
function PersonaFallback() {
|
function PersonaFallback() {
|
||||||
return (
|
return (
|
||||||
<span className="grid h-full w-full place-items-center rounded-full border border-slate-300/70 bg-slate-100 text-slate-500">
|
<span className="grid h-full w-full place-items-center rounded-full border border-[var(--surface-border)]/70 bg-[var(--surface-control)] text-[var(--ink-muted)]">
|
||||||
<Bot className="h-2/5 w-2/5" aria-hidden="true" />
|
<Bot className="h-2/5 w-2/5" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { StatusIcon } from "@/shared/ui/status";
|
|||||||
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
|
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
|
||||||
import type { AgentUiResult } from "../types";
|
import type { AgentUiResult } from "../types";
|
||||||
import type { UIEnvelope } from "../ui-envelope";
|
import type { UIEnvelope } from "../ui-envelope";
|
||||||
|
import type { parseFrontendChart } from "../frontend-action/chart";
|
||||||
import {
|
import {
|
||||||
agentLayoutTransition,
|
agentLayoutTransition,
|
||||||
agentPanelItemVariants,
|
agentPanelItemVariants,
|
||||||
@@ -47,18 +48,18 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
|||||||
const { envelope } = result;
|
const { envelope } = result;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-xs">
|
<div data-agent-ui-result={result.id} className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-xs">
|
||||||
<div className="mb-3 flex items-center justify-between gap-2">
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-blue-100 text-blue-700">
|
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)]">
|
||||||
{getEnvelopeIcon(envelope)}
|
{getEnvelopeIcon(envelope)}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-semibold text-slate-950">{getEnvelopeTitle(envelope)}</p>
|
<p className="truncate text-sm font-semibold text-slate-950">{getEnvelopeTitle(envelope)}</p>
|
||||||
<p className="text-xs text-slate-500">{getSurfaceLabel(envelope.surface)}</p>
|
<p className="text-xs text-[var(--ink-muted)]">{getSurfaceLabel(envelope.surface)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">
|
<span className="shrink-0 rounded-full bg-[var(--surface-control)] px-2 py-1 text-xs font-semibold text-slate-600">
|
||||||
agent-ui@1
|
agent-ui@1
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,9 +71,13 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AgentChartView({ chart }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>> }) {
|
||||||
|
return <SafeChartEnvelope envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data: { x_data: chart.x_data, series: chart.series } }} />;
|
||||||
|
}
|
||||||
|
|
||||||
function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind: "chart" }> }) {
|
function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind: "chart" }> }) {
|
||||||
const spec = parseChartSpec(envelope.spec);
|
const spec = parseChartSpec(envelope.spec);
|
||||||
const data = normalizeSafeChartData(envelope.data);
|
const data = normalizeSafeChartData(envelope.data, 100);
|
||||||
|
|
||||||
if (!data || data.series.length === 0 || data.xData.length === 0) {
|
if (!data || data.series.length === 0 || data.xData.length === 0) {
|
||||||
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
|
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
|
||||||
@@ -85,7 +90,7 @@ function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind:
|
|||||||
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
|
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
|
||||||
{spec.title ?? "Agent 图表"}
|
{spec.title ?? "Agent 图表"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
<p className="mt-1 text-xs text-[var(--ink-muted)]">
|
||||||
{spec.chartType === "bar" ? "柱状图" : "折线图"}
|
{spec.chartType === "bar" ? "柱状图" : "折线图"}
|
||||||
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
|
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
|
||||||
</p>
|
</p>
|
||||||
@@ -100,7 +105,7 @@ function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SafeChartSvg spec={spec} data={data} />
|
<SafeChartSvg spec={spec} data={data} />
|
||||||
{spec.xAxisName ? <p className="mt-2 text-center text-xs text-slate-500">{spec.xAxisName}</p> : null}
|
{spec.xAxisName ? <p className="mt-2 text-center text-xs text-[var(--ink-muted)]">{spec.xAxisName}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -255,7 +260,8 @@ function RegisteredComponentEnvelope({
|
|||||||
if (envelope.component === "HistoryPanel") {
|
if (envelope.component === "HistoryPanel") {
|
||||||
return (
|
return (
|
||||||
<TrustedPanelShell icon={<History size={15} aria-hidden="true" />} title="历史数据面板">
|
<TrustedPanelShell icon={<History size={15} aria-hidden="true" />} title="历史数据面板">
|
||||||
<PropRows props={envelope.props} labels={{ feature_id: "资产 ID", featureId: "资产 ID", metric: "指标", range: "时间范围" }} />
|
<PropRows props={envelope.props} labels={{ feature_id: "资产 ID", featureId: "资产 ID", metric: "指标", range: "时间范围", start_time: "开始时间", end_time: "结束时间" }} />
|
||||||
|
{envelope.data ? <SafeChartEnvelope envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: "监测历史", chart_type: "line" }, data: envelope.data }} /> : null}
|
||||||
</TrustedPanelShell>
|
</TrustedPanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -263,7 +269,7 @@ function RegisteredComponentEnvelope({
|
|||||||
if (envelope.component === "ScadaPanel") {
|
if (envelope.component === "ScadaPanel") {
|
||||||
return (
|
return (
|
||||||
<TrustedPanelShell icon={<MonitorCog size={15} aria-hidden="true" />} title="SCADA 面板">
|
<TrustedPanelShell icon={<MonitorCog size={15} aria-hidden="true" />} title="SCADA 面板">
|
||||||
<PropRows props={envelope.props} labels={{ device_id: "设备 ID", deviceId: "设备 ID", metric: "指标", range: "时间范围" }} />
|
<PropRows props={envelope.props} labels={{ device_id: "设备 ID", deviceId: "设备 ID", metric: "指标", range: "时间范围", start_time: "开始时间", end_time: "结束时间" }} />
|
||||||
</TrustedPanelShell>
|
</TrustedPanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -302,7 +308,7 @@ function PropRows({ props, labels }: { props: unknown; labels: Record<string, st
|
|||||||
<dl className="grid grid-cols-2 gap-2">
|
<dl className="grid grid-cols-2 gap-2">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<div key={row.label} className="surface-reading min-w-0 rounded-lg px-2.5 py-2">
|
<div key={row.label} className="surface-reading min-w-0 rounded-lg px-2.5 py-2">
|
||||||
<dt className="truncate text-xs text-slate-500">{row.label}</dt>
|
<dt className="truncate text-xs text-[var(--ink-muted)]">{row.label}</dt>
|
||||||
<dd className="mt-1 truncate text-sm font-semibold text-slate-900" title={row.value}>
|
<dd className="mt-1 truncate text-sm font-semibold text-slate-900" title={row.value}>
|
||||||
{row.value}
|
{row.value}
|
||||||
</dd>
|
</dd>
|
||||||
@@ -381,7 +387,7 @@ function chartStroke(index: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function chartColorClass(index: number) {
|
function chartColorClass(index: number) {
|
||||||
return ["bg-blue-600", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-blue-600";
|
return ["bg-[var(--action-blue)]", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-[var(--action-blue)]";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatChartNumber(value: number) {
|
function formatChartNumber(value: number) {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { executionPlanStages, parseExecutionPlan, readExecutionPlanHistory } from "./execution-plan";
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
schema_version: "execution-plan@1", session_id: "s1", revision: 1, updated_at: "2026-09-23T01:00:00.000Z",
|
||||||
|
title: "调查", reason: "开始",
|
||||||
|
nodes: [{ id: "a", title: '水质"] --> injected["<script>', status: "waiting", owner: "human", detail: "等待采样", evidence: [] }], edges: []
|
||||||
|
};
|
||||||
|
describe("execution plan projection", () => {
|
||||||
|
it("groups independent checks before their shared downstream decision", () => {
|
||||||
|
const plan = parseExecutionPlan({ ...snapshot,
|
||||||
|
nodes: ["a", "b", "c", "d"].map((id) => ({ ...snapshot.nodes[0], id })),
|
||||||
|
edges: [{ from: "a", to: "b", label: "" }, { from: "a", to: "c", label: "" }, { from: "b", to: "d", label: "" }, { from: "c", to: "d", label: "" }]
|
||||||
|
}, "s1")!;
|
||||||
|
expect(executionPlanStages(plan).map((stage) => stage.map((node) => node.id))).toEqual([["a"], ["b", "c"], ["d"]]);
|
||||||
|
});
|
||||||
|
it("rejects other sessions and dangling dependencies", () => {
|
||||||
|
expect(parseExecutionPlan(snapshot, "s2")).toBeNull();
|
||||||
|
expect(parseExecutionPlan({ ...snapshot, edges: [{ from: "a", to: "b", label: "" }] }, "s1")).toBeNull();
|
||||||
|
});
|
||||||
|
it("restores and deduplicates UI and active-run snapshots without completing waiting work", () => {
|
||||||
|
const history = readExecutionPlanHistory([{ parts: [{ type: "data-execution_plan", data: snapshot }] }, { executionPlans: [snapshot] }], "s1");
|
||||||
|
expect(history).toHaveLength(1);
|
||||||
|
expect(history[0].nodes[0].status).toBe("waiting");
|
||||||
|
});
|
||||||
|
it("rejects cyclic dependencies rather than guessing their order", () => {
|
||||||
|
expect(parseExecutionPlan({ ...snapshot, nodes: [snapshot.nodes[0], { ...snapshot.nodes[0], id: "b" }], edges: [{ from: "a", to: "b", label: "" }, { from: "b", to: "a", label: "" }] }, "s1")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const id = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/);
|
||||||
|
const schema = z.object({
|
||||||
|
schema_version: z.enum(["execution-plan@1", "execution-plan@2"]),
|
||||||
|
session_id: z.string().min(1),
|
||||||
|
revision: z.number().int().positive(),
|
||||||
|
updated_at: z.string().datetime(),
|
||||||
|
title: z.string().min(1).max(120),
|
||||||
|
reason: z.string().min(1).max(1000),
|
||||||
|
nodes: z.array(z.object({
|
||||||
|
id, title: z.string().min(1).max(120),
|
||||||
|
status: z.enum(["pending", "running", "waiting", "completed", "failed", "skipped", "cancelled"]),
|
||||||
|
owner: z.enum(["agent", "human"]),
|
||||||
|
detail: z.string().max(2000), evidence: z.array(z.string().min(1).max(1000)).max(12),
|
||||||
|
kind: z.enum(["activity", "decision", "parallel_split", "parallel_join", "gate"]).optional(),
|
||||||
|
selected_edge_id: id.nullable().optional(), gate_ref: z.string().uuid().nullable().optional(),
|
||||||
|
revisits_node_id: id.nullable().optional()
|
||||||
|
})).min(1).max(40),
|
||||||
|
edges: z.array(z.object({ id: id.optional(), from: id, to: id, label: z.string().max(120) })).max(80)
|
||||||
|
}).refine((plan) => {
|
||||||
|
const ids = new Set(plan.nodes.map((node) => node.id));
|
||||||
|
return ids.size === plan.nodes.length && plan.edges.every((edge) => ids.has(edge.from) && ids.has(edge.to) && edge.from !== edge.to);
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AgentExecutionPlan = z.infer<typeof schema>;
|
||||||
|
export function parseExecutionPlan(value: unknown, sessionId: string): AgentExecutionPlan | null {
|
||||||
|
const parsed = schema.safeParse(value);
|
||||||
|
return parsed.success && parsed.data.session_id === sessionId && executionPlanStages(parsed.data).length ? parsed.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readExecutionPlanHistory(messages: unknown[], sessionId: string) {
|
||||||
|
const plans = messages.flatMap((message) => {
|
||||||
|
const value = (message as { executionPlans?: unknown } | null)?.executionPlans;
|
||||||
|
const parts = (message as { parts?: unknown } | null)?.parts;
|
||||||
|
const data = Array.isArray(parts) ? parts.filter((part) => part?.type === "data-execution_plan").map((part) => part.data) : [];
|
||||||
|
return [...(Array.isArray(value) ? value : []), ...data];
|
||||||
|
}).map((value) => parseExecutionPlan(value, sessionId)).filter((plan): plan is AgentExecutionPlan => plan !== null);
|
||||||
|
return [...new Map(plans.map((plan) => [plan.revision, plan])).values()].sort((a, b) => a.revision - b.revision);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const executionStatusLabels = {
|
||||||
|
pending: "待执行", running: "执行中", waiting: "等待资料/人工", completed: "已完成",
|
||||||
|
failed: "失败", skipped: "不适用", cancelled: "已取消"
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/** Same-stage nodes have no dependency on one another; this describes eligibility, not a scheduler. */
|
||||||
|
export function executionPlanStages(plan: AgentExecutionPlan): AgentExecutionPlan["nodes"][] {
|
||||||
|
const remaining = new Set(plan.nodes.map((node) => node.id));
|
||||||
|
const stages: AgentExecutionPlan["nodes"][] = [];
|
||||||
|
while (remaining.size) {
|
||||||
|
const nodes = plan.nodes.filter((node) => remaining.has(node.id) &&
|
||||||
|
plan.edges.filter((edge) => edge.to === node.id).every((edge) => !remaining.has(edge.from)));
|
||||||
|
if (!nodes.length) return []; // Malformed cyclic history is never converted into a guessed sequence.
|
||||||
|
stages.push(nodes);
|
||||||
|
nodes.forEach((node) => remaining.delete(node.id));
|
||||||
|
}
|
||||||
|
return stages;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { appendExecutionReportText, readExecutionReport, updateExecutionReportProgress } from "./execution-report";
|
||||||
|
|
||||||
|
describe("execution report", () => {
|
||||||
|
it("uses assistant output and public tool records without exposing reasoning or inventing completion", () => {
|
||||||
|
const report = readExecutionReport([
|
||||||
|
{ role: "user", content: "不应成为调查结论" },
|
||||||
|
{ id: "a", role: "assistant", parts: [
|
||||||
|
{ type: "text", text: "疑似二次供水问题,待现场核验。" },
|
||||||
|
{ type: "data-progress", data: { id: "private", phase: "planning", title: "推理内容", status: "running" } },
|
||||||
|
{ type: "data-progress", data: { id: "tool", phase: "tool", title: "核对记录", status: "running" } }
|
||||||
|
] }
|
||||||
|
]);
|
||||||
|
expect(report.responses).toEqual([{ id: "a", text: "疑似二次供水问题,待现场核验。" }]);
|
||||||
|
expect(report.activities).toHaveLength(1);
|
||||||
|
expect(report.activities[0].status).toBe("running");
|
||||||
|
expect(updateExecutionReportProgress(report, { id: "tool", phase: "tool", title: "查询失败", status: "error" }).activities[0].status).toBe("error");
|
||||||
|
});
|
||||||
|
it("joins resumed chunks to the active snapshot despite differing transport IDs", () => {
|
||||||
|
const report = readExecutionReport([{ id: "legacy", role: "assistant", content: "上游指标正常。" }]);
|
||||||
|
const next = appendExecutionReportText(report, { message_id: "ui-assistant", content: "等待现场采样。" });
|
||||||
|
expect(next.responses).toEqual([{ id: "ui-assistant", text: "上游指标正常。等待现场采样。" }]);
|
||||||
|
});
|
||||||
|
it("keeps a new empty assistant turn separate from the previous report", () => {
|
||||||
|
const report = readExecutionReport([{ id: "old", role: "assistant", content: "前序结论" }, { id: "current", role: "assistant", content: "" }]);
|
||||||
|
const next = appendExecutionReportText(report, { message_id: "current-ui", content: "最新进展" });
|
||||||
|
expect(next.responses.map((response) => response.text)).toEqual(["前序结论", "最新进展"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export type AgentExecutionReport = {
|
||||||
|
responses: { id: string; text: string }[];
|
||||||
|
activities: { id: string; title: string; detail?: string; status: "running" | "completed" | "error" }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const record = (value: unknown): Record<string, unknown> | null =>
|
||||||
|
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||||
|
|
||||||
|
export function updateExecutionReportProgress(report: AgentExecutionReport, value: unknown): AgentExecutionReport {
|
||||||
|
const progress = record(value);
|
||||||
|
// The report contains public execution facts, not the model's reasoning stream.
|
||||||
|
if (!progress || !["tool", "start", "execution_plan"].includes(String(progress.phase)) ||
|
||||||
|
typeof progress.id !== "string" || typeof progress.title !== "string" ||
|
||||||
|
!["running", "completed", "error"].includes(String(progress.status))) return report;
|
||||||
|
const activity = {
|
||||||
|
id: progress.id, title: progress.title,
|
||||||
|
detail: typeof progress.detail === "string" ? progress.detail : undefined,
|
||||||
|
status: progress.status as "running" | "completed" | "error"
|
||||||
|
};
|
||||||
|
const existing = report.activities.findIndex((item) => item.id === activity.id);
|
||||||
|
return { ...report, activities: existing < 0 ? [...report.activities, activity].slice(-8) :
|
||||||
|
report.activities.map((item, index) => index === existing ? activity : item) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readExecutionReport(messages: unknown[]): AgentExecutionReport {
|
||||||
|
let report: AgentExecutionReport = { responses: [], activities: [] };
|
||||||
|
messages.forEach((value, index) => {
|
||||||
|
const message = record(value);
|
||||||
|
if (!message || message.role !== "assistant") return;
|
||||||
|
const parts = Array.isArray(message.parts) ? message.parts.map(record).filter((part) => part !== null) : [];
|
||||||
|
const text = parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("") ||
|
||||||
|
(typeof message.content === "string" ? message.content : "");
|
||||||
|
report.responses.push({ id: typeof message.id === "string" ? message.id : `response-${index}`, text });
|
||||||
|
for (const progress of Array.isArray(message.progress) ? message.progress : []) report = updateExecutionReportProgress(report, progress);
|
||||||
|
for (const part of parts) if (part.type === "data-progress") report = updateExecutionReportProgress(report, part.data);
|
||||||
|
});
|
||||||
|
return { ...report, responses: report.responses.slice(-3) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendExecutionReportText(report: AgentExecutionReport, value: unknown): AgentExecutionReport {
|
||||||
|
const data = record(value);
|
||||||
|
if (!data || typeof data.content !== "string") return report;
|
||||||
|
const id = typeof data.message_id === "string" ? data.message_id : "current-response";
|
||||||
|
const matching = report.responses.findIndex((response) => response.id === id);
|
||||||
|
// Active-run snapshots and UI stream chunks currently use different assistant IDs.
|
||||||
|
const existing = matching < 0 ? report.responses.length - 1 : matching;
|
||||||
|
return { ...report, responses: existing < 0 ? [...report.responses, { id, text: data.content }].slice(-3) :
|
||||||
|
report.responses.map((response, index) => index === existing ? { ...response, id, text: response.text + data.content } : response) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseFrontendChart } from "./chart";
|
||||||
|
|
||||||
|
describe("frontend chart boundary", () => {
|
||||||
|
const chart = { title: "压力", chart_type: "line", x_data: ["t1", "t2"], series: [{ name: "D1", data: [20, 21] }] };
|
||||||
|
it("accepts finite aligned series and rejects code, unaligned data and excessive points", () => {
|
||||||
|
expect(parseFrontendChart(chart)).not.toBeNull();
|
||||||
|
expect(parseFrontendChart({ ...chart, formatter: "function(){}" })).toBeNull();
|
||||||
|
expect(parseFrontendChart({ ...chart, series: [{ name: "D1", data: [20] }] })).toBeNull();
|
||||||
|
expect(parseFrontendChart({ ...chart, series: [{ name: "D1", data: [20, Infinity] }] })).toBeNull();
|
||||||
|
expect(parseFrontendChart({ ...chart, x_data: Array(101).fill("t") })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const chartSchema = z.object({
|
||||||
|
title: z.string().max(200), chart_type: z.enum(["line", "bar"]),
|
||||||
|
x_data: z.array(z.string().max(128)).min(1).max(100),
|
||||||
|
series: z.array(z.object({ name: z.string().max(128), data: z.array(z.number().finite()).min(1).max(100) }).strict()).min(1).max(8),
|
||||||
|
}).strict().refine((value) => value.series.every((series) => series.data.length === value.x_data.length));
|
||||||
|
export const parseFrontendChart = (value: unknown) => {
|
||||||
|
const parsed = chartSchema.safeParse(value);
|
||||||
|
return parsed.success ? parsed.data : null;
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("SCADA frontend actions", () => {
|
describe("SCADA frontend actions", () => {
|
||||||
it("accepts only the two registered SCADA action names", () => {
|
it("accepts registered view actions and rejects unknown action names", () => {
|
||||||
expect(parseFrontendActionRegistry({
|
expect(parseFrontendActionRegistry({
|
||||||
schema_version: "frontend-action-registry@1",
|
schema_version: "frontend-action-registry@1",
|
||||||
actions: [
|
actions: [
|
||||||
@@ -23,9 +23,10 @@ describe("SCADA frontend actions", () => {
|
|||||||
]
|
]
|
||||||
})?.actions.map((action) => action.id)).toEqual([
|
})?.actions.map((action) => action.id)).toEqual([
|
||||||
"render_scada_analysis",
|
"render_scada_analysis",
|
||||||
"clear_scada_analysis"
|
"clear_scada_analysis",
|
||||||
|
"zoom_to_map"
|
||||||
]);
|
]);
|
||||||
expect(parseFrontendActionRequest(createRequest("zoom_to_map"))).toBeNull();
|
expect(parseFrontendActionRequest(createRequest("execute_script"))).toBeNull();
|
||||||
expect(parseFrontendActionRequest(createRequest("render_scada_analysis"))).not.toBeNull();
|
expect(parseFrontendActionRequest(createRequest("render_scada_analysis"))).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ function createRequest(name: string) {
|
|||||||
toolCallId: "call-1",
|
toolCallId: "call-1",
|
||||||
sessionId: "session-1",
|
sessionId: "session-1",
|
||||||
name,
|
name,
|
||||||
params: { items: [{ sensor_id: "MP01", level: "high" }] },
|
params: { items: [{ device_id: "MP01", level: "high" }] },
|
||||||
issuedAt: Date.now(),
|
issuedAt: Date.now(),
|
||||||
expiresAt: Date.now() + 15_000
|
expiresAt: Date.now() + 15_000
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const FRONTEND_ACTION_NAMES = ["render_scada_analysis", "clear_scada_analysis"] as const;
|
export const FRONTEND_ACTION_NAMES = ["render_scada_analysis", "clear_scada_analysis", "locate_features", "zoom_to_map", "show_chart", "view_history"] as const;
|
||||||
|
export { parseFrontendChart } from "./chart";
|
||||||
export type FrontendActionName = (typeof FRONTEND_ACTION_NAMES)[number];
|
export type FrontendActionName = (typeof FRONTEND_ACTION_NAMES)[number];
|
||||||
export type FrontendActionRegistry = { schema_version: "frontend-action-registry@1"; actions: Array<{ id: FrontendActionName; version: "frontend-action@1" }> };
|
export type FrontendActionRegistry = { schema_version: "frontend-action-registry@1"; actions: Array<{ id: FrontendActionName; version: "frontend-action@1" }> };
|
||||||
export type FrontendActionRequest = { version: "frontend-action@1"; actionId: string; toolCallId: string; sessionId: string; name: FrontendActionName; params: Record<string, unknown>; issuedAt: number; expiresAt: number };
|
export type FrontendActionRequest = { version: "frontend-action@1"; actionId: string; toolCallId: string; sessionId: string; name: FrontendActionName; params: Record<string, unknown>; issuedAt: number; expiresAt: number };
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import type { AgentApiClient } from "../api/client";
|
||||||
|
|
||||||
|
export function useAgentMessageInbox(client: AgentApiClient, sessionId: string | null) {
|
||||||
|
const inbox = useSWR(sessionId ? ["agent-message-inbox", sessionId] : null,
|
||||||
|
([, id]) => client.listAgentMessages(id), { revalidateOnFocus: true, shouldRetryOnError: false });
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const mutate = inbox.mutate;
|
||||||
|
const refresh = useCallback(() => { void mutate().catch(() => undefined); }, [mutate]);
|
||||||
|
const refreshRef = useRef(refresh);
|
||||||
|
useEffect(() => { refreshRef.current = refresh; }, [refresh]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let retryDelay = 1500;
|
||||||
|
function subscribe() {
|
||||||
|
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { retryDelay = 1500; refreshRef.current(); } })
|
||||||
|
.catch(() => { retryDelay = Math.min(retryDelay * 2, 30_000); })
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) timer = setTimeout(subscribe, retryDelay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
subscribe();
|
||||||
|
return () => { controller.abort(); clearTimeout(timer); };
|
||||||
|
}, [client, sessionId]);
|
||||||
|
return {
|
||||||
|
messages: inbox.data?.items ?? [],
|
||||||
|
hasMore: Boolean(inbox.data?.next_cursor),
|
||||||
|
loading: inbox.isLoading,
|
||||||
|
error: inbox.error ? "任务消息暂时无法加载" : null,
|
||||||
|
refresh,
|
||||||
|
loadingMore,
|
||||||
|
loadMore: async () => {
|
||||||
|
if (!sessionId || !inbox.data?.next_cursor || loadingMore) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const page = await client.listAgentMessages(sessionId, { cursor: inbox.data.next_cursor });
|
||||||
|
await inbox.mutate((current) => current ? { items: [...current.items, ...page.items.filter((item) => !current.items.some((existing) => existing.message_id === item.message_id))], next_cursor: page.next_cursor } : page, { revalidate: false });
|
||||||
|
} finally { setLoadingMore(false); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,23 +1,37 @@
|
|||||||
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
|
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
|
||||||
|
export { agentTaskDiscussionContextSchema, agentTaskContextReferenceSchema, type AgentTaskDiscussionContext, type AgentTaskContextReference } from "./task-context";
|
||||||
|
export { readExecutionReport, updateExecutionReportProgress, appendExecutionReportText, type AgentExecutionReport } from "./execution-report";
|
||||||
|
export { parseExecutionPlan, readExecutionPlanHistory, executionPlanStages, executionStatusLabels, type AgentExecutionPlan } from "./execution-plan";
|
||||||
export { AgentCommandPanel } from "./components/agent-command-panel";
|
export { AgentCommandPanel } from "./components/agent-command-panel";
|
||||||
|
export { AgentChartView } from "./components/agent-ui-envelope-renderer";
|
||||||
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
|
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
|
||||||
export type {
|
export type {
|
||||||
AgentCommandPanelPresentation,
|
AgentCommandPanelPresentation,
|
||||||
|
AgentConversationProjectOption,
|
||||||
AgentWorkspaceContextItem
|
AgentWorkspaceContextItem
|
||||||
} from "./components/agent-command-panel";
|
} from "./components/agent-command-panel";
|
||||||
export { AgentPersona } from "./components/agent-persona";
|
export { AgentPersona } from "./components/agent-persona";
|
||||||
export { createAgentApiClient } from "./api/client";
|
export { createAgentApiClient } from "./api/client";
|
||||||
|
export { useAgentMessageInbox } from "./hooks/use-agent-message-inbox";
|
||||||
|
export type { AgentMailboxMessage } from "./api/agent-messages";
|
||||||
export type {
|
export type {
|
||||||
AgentApiClient,
|
AgentApiClient,
|
||||||
AgentApiClientOptions,
|
AgentApiClientOptions,
|
||||||
|
AgentInputDelivery,
|
||||||
|
AgentResponseInput,
|
||||||
AgentChatSessionSummary,
|
AgentChatSessionSummary,
|
||||||
|
AgentSessionPurpose,
|
||||||
|
AgentEventTriggerContext,
|
||||||
AgentLoadedChatSession,
|
AgentLoadedChatSession,
|
||||||
AgentSessionStreamEvent
|
AgentSessionStreamEvent,
|
||||||
|
AgentRecentActivity,
|
||||||
|
AgentPromptSuggestion
|
||||||
} from "./api/client";
|
} from "./api/client";
|
||||||
export {
|
export {
|
||||||
agentBootstrapKey,
|
agentBootstrapKey,
|
||||||
agentSessionsKey,
|
agentSessionsKey,
|
||||||
fetchAgentBootstrap,
|
fetchAgentBootstrap,
|
||||||
|
resolveAgentModelSelection,
|
||||||
fetchAgentSessions,
|
fetchAgentSessions,
|
||||||
type AgentBootstrapData
|
type AgentBootstrapData
|
||||||
} from "./api/swr";
|
} from "./api/swr";
|
||||||
@@ -77,3 +91,11 @@ export type {
|
|||||||
AgentUiResult
|
AgentUiResult
|
||||||
} from "./types";
|
} from "./types";
|
||||||
export type { SafeChartData, SafeChartSeries, SafeChartSpec } from "./chart-data";
|
export type { SafeChartData, SafeChartSeries, SafeChartSpec } from "./chart-data";
|
||||||
|
|
||||||
|
export { parseFrontendChart } from "./frontend-action/chart";
|
||||||
|
|
||||||
|
export { parseAnswerUiBlock } from "./answer-ui/contract";
|
||||||
|
export type { AnswerUiBlock, AnswerUiPart } from "./answer-ui/contract";
|
||||||
|
export type { AnswerUiMapTarget } from "./answer-ui/answer-ui-view";
|
||||||
|
|
||||||
|
export { ResponsesChatTransport, createResponseRequest } from "./api/responses-transport";
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const agentTaskDiscussionContextSchema = z
|
||||||
|
.object({
|
||||||
|
report_revision: z.number().int().positive().optional(),
|
||||||
|
evidence_id: z.string().min(1).max(128).optional(),
|
||||||
|
gap_id: z.string().min(1).max(128).optional()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type AgentTaskDiscussionContext = z.infer<typeof agentTaskDiscussionContextSchema>;
|
||||||
|
|
||||||
|
export const agentTaskContextReferenceSchema = z
|
||||||
|
.object({
|
||||||
|
task_id: z.string().uuid(),
|
||||||
|
execution_generation: z.number().int().positive().nullable(),
|
||||||
|
report_revision: z.number().int().positive().nullable()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type AgentTaskContextReference = z.infer<typeof agentTaskContextReferenceSchema>;
|
||||||
@@ -2,10 +2,13 @@ export type AgentChatMessage = {
|
|||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
|
answerParts?: import("./answer-ui/contract").AnswerUiPart[];
|
||||||
|
finalAnswer?: { content: string; uiBlockIds: string[] };
|
||||||
progress?: AgentChatProgress[];
|
progress?: AgentChatProgress[];
|
||||||
permissions?: AgentPermissionRequest[];
|
permissions?: AgentPermissionRequest[];
|
||||||
questions?: AgentQuestionRequest[];
|
questions?: AgentQuestionRequest[];
|
||||||
todos?: AgentTodoUpdate;
|
todos?: AgentTodoUpdate;
|
||||||
|
taskContext?: import("./task-context").AgentTaskContextReference;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentStreamRenderMessageState = {
|
export type AgentStreamRenderMessageState = {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ describe("toTrustedMapAction", () => {
|
|||||||
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
|
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
|
||||||
type: "locate_features",
|
type: "locate_features",
|
||||||
featureIds: ["P1", "P2"],
|
featureIds: ["P1", "P2"],
|
||||||
layer: "geo_pipes_mat",
|
layer: "pipes",
|
||||||
fallbackText: undefined
|
fallbackText: undefined
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -83,7 +83,7 @@ describe("toTrustedMapAction", () => {
|
|||||||
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
|
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
|
||||||
type: "locate_features",
|
type: "locate_features",
|
||||||
featureIds: ["42"],
|
featureIds: ["42"],
|
||||||
layer: "geo_junctions_mat"
|
layer: "junctions"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ describe("toTrustedMapAction", () => {
|
|||||||
expect(toTrustedMapAction("locate_scada", { scada_id: "S-1" })).toMatchObject({
|
expect(toTrustedMapAction("locate_scada", { scada_id: "S-1" })).toMatchObject({
|
||||||
type: "locate_features",
|
type: "locate_features",
|
||||||
featureIds: ["S-1"],
|
featureIds: ["S-1"],
|
||||||
layer: "geo_scada"
|
layer: "scada"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -164,30 +164,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
|
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
|
||||||
locate_junctions: "geo_junctions_mat",
|
locate_junctions: "junctions",
|
||||||
locate_pipes: "geo_pipes_mat",
|
locate_pipes: "pipes",
|
||||||
locate_valves: "geo_valves",
|
locate_valves: "valves",
|
||||||
locate_reservoirs: "geo_reservoirs",
|
locate_reservoirs: "reservoirs",
|
||||||
locate_scada: "geo_scada",
|
locate_scada: "scada",
|
||||||
locate_pumps: "geo_pumps",
|
locate_pumps: "pumps",
|
||||||
locate_tanks: "geo_tanks"
|
locate_tanks: "tanks"
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
|
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
|
||||||
const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada"];
|
const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada", "pumps", "tanks"];
|
||||||
const ALLOWED_LAYER_IDS = new Set(SUPPLY_SOURCE_IDS);
|
const ALLOWED_LAYER_IDS = new Set(SUPPLY_SOURCE_IDS);
|
||||||
const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]);
|
const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]);
|
||||||
const ALLOWED_LOCATE_LAYERS = new Set([
|
const ALLOWED_LOCATE_LAYERS = new Set([
|
||||||
...SUPPLY_SOURCE_IDS,
|
...SUPPLY_SOURCE_IDS
|
||||||
"pumps",
|
|
||||||
"tanks",
|
|
||||||
"geo_junctions_mat",
|
|
||||||
"geo_pipes_mat",
|
|
||||||
"geo_valves",
|
|
||||||
"geo_reservoirs",
|
|
||||||
"geo_scada",
|
|
||||||
"geo_pumps",
|
|
||||||
"geo_tanks"
|
|
||||||
]);
|
]);
|
||||||
const WEB_MERCATOR_RADIUS = 6378137;
|
const WEB_MERCATOR_RADIUS = 6378137;
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function MapAnnotationPanel({
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className={cn("px-3 py-2 text-xs text-slate-500", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
|
<div className={cn("px-3 py-2 text-xs text-[var(--ink-muted)]", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
|
||||||
{emptyText}
|
{emptyText}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
|
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
|
||||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
||||||
} from "./map-control-styles";
|
} from "./map-control-styles";
|
||||||
|
|
||||||
@@ -85,12 +86,12 @@ export function BaseLayersControl({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 px-1">
|
<div className="flex items-center gap-2 px-1">
|
||||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-[var(--action-soft)] text-[var(--action-blue-hover)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||||
<Map size={17} aria-hidden="true" />
|
<Map size={17} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||||
<p className="mt-0.5 truncate text-xs text-slate-500">
|
<p className="mt-0.5 truncate text-xs text-[var(--ink-muted)]">
|
||||||
当前 {activeLayer?.label ?? "--"}
|
当前 {activeLayer?.label ?? "--"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,10 +111,11 @@ export function BaseLayersControl({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[76px] flex-col p-1 text-left transition duration-150",
|
"flex min-h-[76px] flex-col p-1 text-left transition duration-150",
|
||||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
|
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
|
||||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
active
|
active
|
||||||
? "border-blue-500 text-blue-700 shadow-xs ring-2 ring-blue-100"
|
? "border-[var(--action-blue)] text-[var(--action-blue-hover)] shadow-xs"
|
||||||
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
|
: "text-slate-700 hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40",
|
||||||
option.disabled && "cursor-not-allowed opacity-50"
|
option.disabled && "cursor-not-allowed opacity-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -163,8 +165,9 @@ export function BaseLayersControl({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150",
|
"flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150",
|
||||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
|
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
|
||||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
active ? "border-blue-500 shadow-xs ring-2 ring-blue-100" : "border-slate-200 hover:border-blue-300 hover:bg-blue-50/40",
|
active ? "border-[var(--action-blue)] shadow-xs" : "border-[var(--surface-divider)] hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40",
|
||||||
option.disabled && "cursor-not-allowed opacity-50"
|
option.disabled && "cursor-not-allowed opacity-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -184,11 +187,11 @@ export function BaseLayersControl({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
||||||
title={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
title={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
||||||
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs transition hover:border-blue-300 hover:text-blue-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs transition hover:border-[var(--palette-brand)] hover:text-[var(--action-blue-hover)]", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME, MAP_SELECTION_CARD_FOCUS_CLASS_NAME)}
|
||||||
>
|
>
|
||||||
<span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
<span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||||
{activeLayer ? (
|
{activeLayer ? (
|
||||||
<span className={cn("absolute inset-0", activeLayer.swatchClassName ?? "bg-slate-100")} />
|
<span className={cn("absolute inset-0", activeLayer.swatchClassName ?? "bg-[var(--surface-control)]")} />
|
||||||
) : (
|
) : (
|
||||||
<Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
<Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
)}
|
)}
|
||||||
@@ -213,11 +216,11 @@ function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
|
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
option.swatchClassName ?? "bg-slate-100"
|
option.swatchClassName ?? "bg-[var(--surface-control)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{active ? (
|
{active ? (
|
||||||
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white shadow-xs">
|
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-[var(--action-blue)] text-white shadow-xs">
|
||||||
<Check size={10} aria-hidden="true" />
|
<Check size={10} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { getCombinedCameraAction, setThreeDimensionalNavigation } from "./camera-actions";
|
||||||
|
|
||||||
|
describe("combined camera mode action", () => {
|
||||||
|
it.each([
|
||||||
|
{ bearing: 0, pitch: 0, expected: "enter-3d" },
|
||||||
|
{ bearing: 0.4, pitch: 50, expected: "exit-3d" },
|
||||||
|
{ bearing: 0.6, pitch: 50, expected: "reset-north" },
|
||||||
|
{ bearing: -35, pitch: 45, expected: "reset-north" }
|
||||||
|
] as const)(
|
||||||
|
"resolves $expected for bearing=$bearing pitch=$pitch",
|
||||||
|
({ bearing, pitch, expected }) => {
|
||||||
|
expect(getCombinedCameraAction(bearing, pitch)).toBe(expected);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("enables and disables only the 3D navigation gestures", () => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const handlers = {
|
||||||
|
dragRotate: { enable: vi.fn(), disable: vi.fn() },
|
||||||
|
getCanvas: () => canvas,
|
||||||
|
touchPitch: { enable: vi.fn(), disable: vi.fn() },
|
||||||
|
touchZoomRotate: { enableRotation: vi.fn(), disableRotation: vi.fn() }
|
||||||
|
};
|
||||||
|
const map = handlers as unknown as MapLibreMap;
|
||||||
|
|
||||||
|
setThreeDimensionalNavigation(map, true);
|
||||||
|
expect(handlers.dragRotate.enable).toHaveBeenCalledOnce();
|
||||||
|
expect(handlers.touchPitch.enable).toHaveBeenCalledOnce();
|
||||||
|
expect(handlers.touchZoomRotate.enableRotation).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
setThreeDimensionalNavigation(map, false);
|
||||||
|
expect(handlers.dragRotate.disable).toHaveBeenCalledOnce();
|
||||||
|
expect(handlers.touchPitch.disable).toHaveBeenCalledOnce();
|
||||||
|
expect(handlers.touchZoomRotate.disableRotation).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overrides the browser context menu while 3D navigation is enabled", () => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const onMapMouseDown = vi.fn();
|
||||||
|
canvas.addEventListener("mousedown", onMapMouseDown);
|
||||||
|
const map = {
|
||||||
|
dragRotate: { enable: vi.fn(), disable: vi.fn() },
|
||||||
|
getCanvas: () => canvas,
|
||||||
|
touchPitch: { enable: vi.fn(), disable: vi.fn() },
|
||||||
|
touchZoomRotate: { enableRotation: vi.fn(), disableRotation: vi.fn() }
|
||||||
|
} as unknown as MapLibreMap;
|
||||||
|
|
||||||
|
setThreeDimensionalNavigation(map, true);
|
||||||
|
canvas.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 2 }));
|
||||||
|
expect(onMapMouseDown).toHaveBeenCalledOnce();
|
||||||
|
expect(
|
||||||
|
canvas.dispatchEvent(
|
||||||
|
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2 })
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
canvas.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, ctrlKey: true }));
|
||||||
|
expect(onMapMouseDown).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
setThreeDimensionalNavigation(map, false);
|
||||||
|
expect(
|
||||||
|
canvas.dispatchEvent(
|
||||||
|
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2 })
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
|
|
||||||
|
export type CombinedCameraAction = "enter-3d" | "reset-north" | "exit-3d";
|
||||||
|
|
||||||
|
export const CAMERA_MODE_ANGLE_TOLERANCE = 0.5;
|
||||||
|
|
||||||
|
const browserContextMenuOverrides = new WeakMap<MapLibreMap, () => void>();
|
||||||
|
|
||||||
|
export function getCombinedCameraAction(bearing: number, pitch: number): CombinedCameraAction {
|
||||||
|
if (pitch <= CAMERA_MODE_ANGLE_TOLERANCE) return "enter-3d";
|
||||||
|
if (Math.abs(bearing) > CAMERA_MODE_ANGLE_TOLERANCE) return "reset-north";
|
||||||
|
return "exit-3d";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setThreeDimensionalNavigation(map: MapLibreMap, enabled: boolean) {
|
||||||
|
if (enabled) {
|
||||||
|
overrideBrowserContextMenu(map);
|
||||||
|
map.dragRotate.enable();
|
||||||
|
map.touchPitch.enable();
|
||||||
|
map.touchZoomRotate.enableRotation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
browserContextMenuOverrides.get(map)?.();
|
||||||
|
map.dragRotate.disable();
|
||||||
|
map.touchPitch.disable();
|
||||||
|
map.touchZoomRotate.disableRotation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function overrideBrowserContextMenu(map: MapLibreMap) {
|
||||||
|
if (browserContextMenuOverrides.has(map)) return;
|
||||||
|
const canvas = map.getCanvas();
|
||||||
|
const preventBrowserContextMenu = (event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener("contextmenu", preventBrowserContextMenu, true);
|
||||||
|
browserContextMenuOverrides.set(map, () => {
|
||||||
|
canvas.removeEventListener("contextmenu", preventBrowserContextMenu, true);
|
||||||
|
browserContextMenuOverrides.delete(map);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export type MapControlPanelProps = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
widthClassName?: string;
|
widthClassName?: string;
|
||||||
|
contentClassName?: string;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
@@ -25,6 +26,7 @@ export function MapControlPanel({
|
|||||||
description,
|
description,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
widthClassName = "w-[292px]",
|
widthClassName = "w-[292px]",
|
||||||
|
contentClassName,
|
||||||
visible = true,
|
visible = true,
|
||||||
children
|
children
|
||||||
}: MapControlPanelProps) {
|
}: MapControlPanelProps) {
|
||||||
@@ -43,15 +45,15 @@ export function MapControlPanel({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn("relative mb-3 flex items-center gap-2.5 px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
|
<div className={cn("relative mb-3 flex items-center gap-2.5 px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
|
||||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-100 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||||
<Icon size={17} aria-hidden="true" />
|
<Icon size={17} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||||
{description ? <p className="mt-0.5 truncate text-xs text-slate-500">{description}</p> : null}
|
{description ? <p className="mt-0.5 truncate text-xs text-[var(--ink-muted)]">{description}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative space-y-3">{children}</div>
|
<div className={cn("relative space-y-3", contentClassName)}>{children}</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,7 +61,7 @@ export function MapControlPanel({
|
|||||||
export function MapPanelSection({ title, children }: { title: string; children: ReactNode }) {
|
export function MapPanelSection({ title, children }: { title: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500">{title}</div>
|
<div className="mb-1.5 px-1 text-xs font-semibold text-[var(--ink-muted)]">{title}</div>
|
||||||
<div className="space-y-1.5">{children}</div>
|
<div className="space-y-1.5">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -83,7 +85,7 @@ export function MapModeButton({ icon: Icon, label, active = false, disabled = fa
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex h-14 flex-col items-center justify-center gap-1 text-xs font-semibold transition duration-150",
|
"flex h-14 flex-col items-center justify-center gap-1 text-xs font-semibold transition duration-150",
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
active ? "bg-blue-600 text-white" : "surface-control text-slate-600 hover:text-blue-700",
|
active ? "bg-[var(--action-blue)] text-white" : "surface-control text-slate-600 hover:text-[var(--action-blue-hover)]",
|
||||||
disabled && "cursor-not-allowed opacity-55 hover:text-slate-600"
|
disabled && "cursor-not-allowed opacity-55 hover:text-slate-600"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -110,7 +112,7 @@ export function MapActionChip({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700 transition duration-150 hover:border-blue-200 hover:text-blue-700",
|
"flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700 transition duration-150 hover:border-[var(--surface-border)] hover:text-[var(--action-blue-hover)]",
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
disabled && "cursor-not-allowed opacity-55 hover:border-white/70 hover:text-slate-700"
|
disabled && "cursor-not-allowed opacity-55 hover:border-white/70 hover:text-slate-700"
|
||||||
@@ -161,17 +163,17 @@ export function MapActionRow({
|
|||||||
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left",
|
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left",
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
isPrimary
|
isPrimary
|
||||||
? "border-blue-600 bg-blue-600 text-white shadow-lg shadow-blue-600/25 hover:bg-blue-700"
|
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/25 hover:bg-[var(--action-blue-hover)]"
|
||||||
: strong
|
: strong
|
||||||
? "border-blue-100 bg-blue-50/95 text-blue-800"
|
? "border-[var(--surface-border)] bg-[var(--action-soft)]/95 text-[var(--action-blue-pressed)]"
|
||||||
: "surface-control border-transparent text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
|
: "surface-control border-transparent text-slate-700 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)] hover:text-[var(--action-blue-hover)]",
|
||||||
isDanger && "hover:border-red-100 hover:text-red-700",
|
isDanger && "hover:border-red-100 hover:text-red-700",
|
||||||
selected && !isPrimary && "border-blue-200 bg-blue-50 text-blue-800",
|
selected && !isPrimary && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-pressed)]",
|
||||||
muted && "opacity-70",
|
muted && "opacity-70",
|
||||||
disabled &&
|
disabled &&
|
||||||
(isPrimary
|
(isPrimary
|
||||||
? "cursor-not-allowed opacity-55 hover:border-blue-600 hover:bg-blue-600 hover:text-white"
|
? "cursor-not-allowed opacity-55 hover:border-[var(--action-blue)] hover:bg-[var(--action-blue-hover)] hover:text-white"
|
||||||
: "cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-white/95 hover:text-slate-700"),
|
: "cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-[var(--surface-reading)]/95 hover:text-slate-700"),
|
||||||
disabled && "shadow-none"
|
disabled && "shadow-none"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -180,10 +182,10 @@ export function MapActionRow({
|
|||||||
"grid h-8 w-8 shrink-0 place-items-center",
|
"grid h-8 w-8 shrink-0 place-items-center",
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
isPrimary
|
isPrimary
|
||||||
? "bg-white/16 text-white"
|
? "bg-[var(--surface-reading)]/16 text-white"
|
||||||
: selected || strong
|
: selected || strong
|
||||||
? "bg-blue-600 text-white"
|
? "bg-[var(--action-blue)] text-white"
|
||||||
: "bg-slate-100 text-slate-500",
|
: "bg-[var(--surface-control)] text-[var(--ink-muted)]",
|
||||||
isDanger && "bg-red-50 text-red-600"
|
isDanger && "bg-red-50 text-red-600"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -191,19 +193,19 @@ export function MapActionRow({
|
|||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-semibold">{label}</span>
|
<span className="block truncate text-sm font-semibold">{label}</span>
|
||||||
<span className={cn("block truncate text-xs", isPrimary ? "text-blue-100" : "text-slate-500")}>{description}</span>
|
<span className={cn("block truncate text-xs", isPrimary ? "text-[var(--palette-paper)]" : "text-[var(--ink-muted)]")}>{description}</span>
|
||||||
</span>
|
</span>
|
||||||
{status ? (
|
{status ? (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold",
|
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold",
|
||||||
isPrimary ? "bg-white/15 text-white" : "bg-slate-100 text-slate-500"
|
isPrimary ? "bg-[var(--surface-reading)]/15 text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight size={15} aria-hidden="true" className={cn("shrink-0", isPrimary ? "text-blue-100" : "text-slate-400")} />
|
<ChevronRight size={15} aria-hidden="true" className={cn("shrink-0", isPrimary ? "text-[var(--palette-paper)]" : "text-[var(--ink-muted)]")} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -212,7 +214,7 @@ export function MapActionRow({
|
|||||||
export function MapMetricTile({ label, value }: { label: string; value: string }) {
|
export function MapMetricTile({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
|
<div className={cn("px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
|
||||||
<div className="text-xs text-slate-500">{label}</div>
|
<div className="text-xs text-[var(--ink-muted)]">{label}</div>
|
||||||
<div className="mt-1 truncate text-sm font-semibold text-slate-900">{value}</div>
|
<div className="mt-1 truncate text-sm font-semibold text-slate-900">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,19 +53,19 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
|
|||||||
"flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold transition duration-150",
|
"flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold transition duration-150",
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
item.active
|
item.active
|
||||||
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
|
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
|
||||||
: "text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
|
: "text-slate-700 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)] hover:text-[var(--action-blue-hover)]",
|
||||||
!item.active && MAP_READABLE_SURFACE_CLASS_NAME,
|
!item.active && MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
isDanger && "hover:border-red-100 hover:text-red-700",
|
isDanger && "hover:border-red-100 hover:text-red-700",
|
||||||
item.disabled &&
|
item.disabled &&
|
||||||
"cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-white/95 hover:text-slate-700"
|
"cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-[var(--surface-reading)]/95 hover:text-slate-700"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid h-7 w-7 shrink-0 place-items-center",
|
"grid h-7 w-7 shrink-0 place-items-center",
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
item.active ? "bg-white/16 text-white" : "bg-slate-100 text-slate-500",
|
item.active ? "bg-[var(--surface-reading)]/16 text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]",
|
||||||
isDanger && "bg-red-50 text-red-600"
|
isDanger && "bg-red-50 text-red-600"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -108,8 +108,8 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
|
|||||||
"grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-600",
|
"grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-600",
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
item.active
|
item.active
|
||||||
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
|
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
|
||||||
: "hover:bg-blue-50 hover:text-blue-700",
|
: "hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]",
|
||||||
isDanger && "hover:bg-red-50 hover:text-red-700",
|
isDanger && "hover:bg-red-50 hover:text-red-700",
|
||||||
item.disabled && "opacity-45 hover:bg-transparent hover:text-slate-600"
|
item.disabled && "opacity-45 hover:bg-transparent hover:text-slate-600"
|
||||||
)}
|
)}
|
||||||
@@ -120,7 +120,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
|
|||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="top"
|
side="top"
|
||||||
sideOffset={7}
|
sideOffset={7}
|
||||||
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
|
className="border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
|
|||||||
@@ -11,9 +11,26 @@ export type MapLayerControlItem = {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
featureCount?: number;
|
||||||
|
minZoom?: number;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
|
group?: string;
|
||||||
|
groupLabel?: string;
|
||||||
|
groupDescription?: string;
|
||||||
|
groupDefaultOpen?: boolean;
|
||||||
|
collection?: string;
|
||||||
|
collectionLabel?: string;
|
||||||
|
visual?: MapLayerControlVisual;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapLayerControlVisual = {
|
||||||
|
kind: "line" | "line-stack" | "point" | "ring" | "area" | "image";
|
||||||
|
color?: string;
|
||||||
|
colors?: string[];
|
||||||
|
dashed?: boolean;
|
||||||
|
imageSrc?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MapLayerControlProps = {
|
type MapLayerControlProps = {
|
||||||
@@ -41,12 +58,12 @@ export function MapLayerControl({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3 px-1">
|
<div className="flex items-center justify-between gap-3 px-1">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-[var(--action-soft)] text-[var(--action-blue-hover)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||||
<Layers3 size={17} aria-hidden="true" />
|
<Layers3 size={17} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||||
<p className="mt-0.5 text-xs text-slate-500">
|
<p className="mt-0.5 text-xs text-[var(--ink-muted)]">
|
||||||
{items.filter((item) => item.visible).length}/{items.length} 可见
|
{items.filter((item) => item.visible).length}/{items.length} 可见
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,8 +86,8 @@ export function MapLayerControl({
|
|||||||
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150",
|
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150",
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
item.visible
|
item.visible
|
||||||
? "surface-control border-blue-100 text-slate-900"
|
? "surface-control border-[var(--surface-border)] text-slate-900"
|
||||||
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
|
: "surface-well border-transparent text-[var(--ink-muted)] hover:border-[var(--surface-divider)] hover:bg-[var(--surface-reading)]",
|
||||||
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -78,19 +95,19 @@ export function MapLayerControl({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"grid h-8 w-8 shrink-0 place-items-center",
|
"grid h-8 w-8 shrink-0 place-items-center",
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
item.visible ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400"
|
item.visible ? "bg-[var(--action-blue)] text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
|
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate font-semibold">{item.label}</span>
|
<span className="block truncate font-semibold">{item.label}</span>
|
||||||
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
|
{item.description ? <span className="block truncate text-xs text-[var(--ink-muted)]">{item.description}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-2.5 w-2.5 rounded-full",
|
"h-2.5 w-2.5 rounded-full",
|
||||||
item.visible ? "bg-blue-600" : "bg-slate-300"
|
item.visible ? "bg-[var(--action-blue)]" : "bg-slate-300"
|
||||||
)}
|
)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { Check, Eye, EyeOff, Lock, Map } from "lucide-react";
|
import { Check, ChevronRight, Eye, EyeOff, Lock, Map as MapIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { cn } from "@/shared/ui/cn";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import type { BaseLayerOption } from "./base-layers-control";
|
import type { BaseLayerOption } from "./base-layers-control";
|
||||||
import type { MapLayerControlItem } from "./layer-control";
|
import type { MapLayerControlItem, MapLayerControlVisual } from "./layer-control";
|
||||||
import {
|
import {
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
MAP_READABLE_SURFACE_STRONG_CLASS_NAME,
|
||||||
|
MAP_SELECTION_CARD_FOCUS_CLASS_NAME
|
||||||
} from "./map-control-styles";
|
} from "./map-control-styles";
|
||||||
|
|
||||||
export type MapLayerPanelProps = {
|
export type MapLayerPanelProps = {
|
||||||
@@ -43,47 +45,241 @@ type LayerVisibilitySectionProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySectionProps) {
|
function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySectionProps) {
|
||||||
|
const groups = groupLayerItems(items);
|
||||||
|
const [openGroupIds, setOpenGroupIds] = useState(
|
||||||
|
() => new Set(groups.filter((group) => group.defaultOpen).map((group) => group.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGroupToggle = (groupId: string, open: boolean) => {
|
||||||
|
setOpenGroupIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (open) next.add(groupId);
|
||||||
|
else next.delete(groupId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500">业务图层</div>
|
{groups.map((group) => (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<details
|
||||||
{items.map((item) => {
|
key={group.id}
|
||||||
|
open={openGroupIds.has(group.id)}
|
||||||
|
onToggle={(event) => handleGroupToggle(group.id, event.currentTarget.open)}
|
||||||
|
className="surface-well group/layer overflow-hidden rounded-xl border"
|
||||||
|
>
|
||||||
|
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-2.5 py-1.5 text-left marker:content-none active:scale-[0.99]">
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="shrink-0 text-[var(--ink-muted)] transition-transform duration-150 ease-out motion-reduce:transition-none group-open/layer:rotate-90"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-xs font-semibold text-slate-700">{group.label}</span>
|
||||||
|
{group.description ? (
|
||||||
|
<span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">
|
||||||
|
{group.description}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 rounded-full bg-[var(--surface-reading)]/75 px-2 py-0.5 text-[10px] font-medium tabular-nums text-[var(--ink-muted)] shadow-xs shadow-slate-950/5">
|
||||||
|
{group.items.filter((item) => item.visible).length}/{group.items.length}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="border-t border-[var(--surface-divider)]/70 bg-[var(--surface-reading)]/45 p-1.5">
|
||||||
|
{groupLayerItemsByCollection(group.items).map((collection) => (
|
||||||
|
<div key={collection.id} className="not-first:mt-2.5">
|
||||||
|
{collection.label ? (
|
||||||
|
<div className="mb-1 flex items-center gap-2 px-1.5 text-[10px] font-medium text-[var(--ink-muted)]">
|
||||||
|
<span className="truncate">{collection.label}</span>
|
||||||
|
<span className="h-px min-w-3 flex-1 bg-[var(--surface-divider)]/80" aria-hidden="true" />
|
||||||
|
<span className="shrink-0 tabular-nums">{collection.items.length} 层</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{collection.items.map((item) => (
|
||||||
|
<LayerVisibilityRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onToggleLayer={onToggleLayer}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupLayerItems(items: MapLayerControlItem[]) {
|
||||||
|
const groups = new Map<string, {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
defaultOpen: boolean;
|
||||||
|
items: MapLayerControlItem[];
|
||||||
|
}>();
|
||||||
|
items.forEach((item) => {
|
||||||
|
const id = item.group ?? "business";
|
||||||
|
const existing = groups.get(id);
|
||||||
|
if (existing) {
|
||||||
|
existing.items.push(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
groups.set(id, {
|
||||||
|
id,
|
||||||
|
label: item.groupLabel ?? "业务图层",
|
||||||
|
description: item.groupDescription,
|
||||||
|
defaultOpen: item.groupDefaultOpen ?? true,
|
||||||
|
items: [item]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return [...groups.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupLayerItemsByCollection(items: MapLayerControlItem[]) {
|
||||||
|
const collections = new Map<string, {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
items: MapLayerControlItem[];
|
||||||
|
}>();
|
||||||
|
items.forEach((item) => {
|
||||||
|
const id = item.collection ?? "default";
|
||||||
|
const existing = collections.get(id);
|
||||||
|
if (existing) {
|
||||||
|
existing.items.push(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
collections.set(id, {
|
||||||
|
id,
|
||||||
|
label: item.collectionLabel,
|
||||||
|
items: [item]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return [...collections.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function LayerVisibilityRow({
|
||||||
|
item,
|
||||||
|
onToggleLayer
|
||||||
|
}: {
|
||||||
|
item: MapLayerControlItem;
|
||||||
|
onToggleLayer: (id: string, visible: boolean) => void;
|
||||||
|
}) {
|
||||||
const Icon = item.visible ? Eye : EyeOff;
|
const Icon = item.visible ? Eye : EyeOff;
|
||||||
const disabled = item.disabled || item.locked;
|
const disabled = item.disabled || item.locked;
|
||||||
|
const meta = formatLayerMeta(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-pressed={item.visible}
|
aria-pressed={item.visible}
|
||||||
aria-label={`${item.label}${item.description ? `,${item.description}` : ""}`}
|
aria-label={`${item.label}${meta ? `,${meta}` : ""}`}
|
||||||
title={item.description}
|
title={meta}
|
||||||
onClick={() => onToggleLayer(item.id, !item.visible)}
|
onClick={() => onToggleLayer(item.id, !item.visible)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex min-h-[76px] w-full flex-col items-center justify-center gap-1.5 border px-1.5 py-2 text-center",
|
"flex min-h-12 w-full items-center gap-2.5 border px-2 py-1.5 text-left transition-[background-color,border-color,color,box-shadow,transform] duration-150 ease-out motion-reduce:transition-none active:scale-[0.98] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1",
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
item.visible
|
item.visible
|
||||||
? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-xs shadow-blue-950/5"
|
? "border-[var(--surface-border)]/90 bg-[var(--surface-reading)] text-slate-950 shadow-xs shadow-slate-950/5"
|
||||||
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
|
: "border-transparent bg-[var(--surface-control)]/55 text-slate-600 [@media(hover:hover)]:hover:border-[var(--surface-divider)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/75",
|
||||||
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
disabled && "cursor-not-allowed opacity-55 active:scale-100"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<LayerVisual visual={item.visual} visible={item.visible} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-xs font-semibold leading-tight">{item.label}</span>
|
||||||
|
{meta ? (
|
||||||
|
<span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">
|
||||||
|
{meta}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid h-8 w-8 shrink-0 place-items-center transition-colors",
|
"grid h-8 w-8 shrink-0 place-items-center rounded-lg transition-[background-color,color,opacity] duration-150 motion-reduce:transition-none",
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
item.visible ? "bg-[var(--action-soft)] text-[var(--action-blue-hover)]" : "bg-transparent text-[var(--ink-muted)]"
|
||||||
item.visible ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
|
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
|
||||||
</span>
|
</span>
|
||||||
<span className="block w-full truncate text-xs font-semibold leading-tight">{item.label}</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
</div>
|
|
||||||
</div>
|
function formatLayerMeta(item: MapLayerControlItem) {
|
||||||
|
const details = [item.description];
|
||||||
|
if (item.featureCount !== undefined) details.push(formatFeatureCount(item.featureCount));
|
||||||
|
if (item.minZoom !== undefined) details.push(`${item.minZoom} 级起`);
|
||||||
|
return details.filter(Boolean).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFeatureCount(count: number) {
|
||||||
|
return `${new Intl.NumberFormat("zh-CN", {
|
||||||
|
notation: count >= 10_000 ? "compact" : "standard",
|
||||||
|
maximumFractionDigits: 1
|
||||||
|
}).format(count)} 要素`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LayerVisual({
|
||||||
|
visual,
|
||||||
|
visible
|
||||||
|
}: {
|
||||||
|
visual?: MapLayerControlVisual;
|
||||||
|
visible: boolean;
|
||||||
|
}) {
|
||||||
|
const color = visual?.color ?? "#64748B";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-[var(--surface-reading)] shadow-inner shadow-slate-950/5 transition-opacity duration-150 motion-reduce:transition-none",
|
||||||
|
!visible && "opacity-45"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{visual?.kind === "image" && visual.imageSrc ? (
|
||||||
|
<img src={visual.imageSrc} alt="" width={24} height={24} />
|
||||||
|
) : visual?.kind === "line-stack" ? (
|
||||||
|
<svg width="26" height="22" viewBox="0 0 26 22">
|
||||||
|
{(visual.colors?.length ? visual.colors : [color, color, color]).slice(0, 3).map((lineColor, index) => (
|
||||||
|
<g key={`${lineColor}-${index}`}>
|
||||||
|
<line x1="2" y1={5 + index * 6} x2="24" y2={5 + index * 6} stroke="white" strokeWidth={4 + index} strokeLinecap="round" />
|
||||||
|
<line x1="2" y1={5 + index * 6} x2="24" y2={5 + index * 6} stroke={lineColor} strokeWidth={1.5 + index * 0.7} strokeLinecap="round" />
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
) : visual?.kind === "line" ? (
|
||||||
|
<svg width="26" height="12" viewBox="0 0 26 12">
|
||||||
|
<line x1="2" y1="6" x2="24" y2="6" stroke="white" strokeWidth="6" strokeLinecap="round" />
|
||||||
|
<line
|
||||||
|
x1="2"
|
||||||
|
y1="6"
|
||||||
|
x2="24"
|
||||||
|
y2="6"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={visual.dashed ? "5 3" : undefined}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : visual?.kind === "ring" ? (
|
||||||
|
<span className="h-3.5 w-3.5 rounded-full border-[3px] bg-white" style={{ borderColor: color }} />
|
||||||
|
) : visual?.kind === "area" ? (
|
||||||
|
<span
|
||||||
|
className="h-4 w-5 rounded-[4px] border"
|
||||||
|
style={{ backgroundColor: `${color}24`, borderColor: color }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="h-3.5 w-3.5 rounded-full"
|
||||||
|
style={{ backgroundColor: color, boxShadow: `0 0 0 2px white, 0 0 0 3px ${color}` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +293,8 @@ function BaseLayerSection({ options, activeLayerId, onSelectLayer }: BaseLayerSe
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1.5 flex items-center justify-between px-1">
|
<div className="mb-1.5 flex items-center justify-between px-1">
|
||||||
<span className="text-xs font-semibold text-slate-500">底图</span>
|
<span className="text-xs font-semibold text-[var(--ink-muted)]">底图</span>
|
||||||
<Map size={14} aria-hidden="true" className="text-slate-400" />
|
<MapIcon size={14} aria-hidden="true" className="text-[var(--ink-muted)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
@@ -114,10 +310,11 @@ function BaseLayerSection({ options, activeLayerId, onSelectLayer }: BaseLayerSe
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[76px] flex-col p-1 text-left transition duration-150",
|
"flex min-h-[76px] flex-col p-1 text-left transition duration-150",
|
||||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
|
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
|
||||||
active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME,
|
active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
active
|
active
|
||||||
? "border-blue-500 text-blue-700 ring-2 ring-blue-100"
|
? "border-[var(--action-blue)] text-[var(--action-blue-hover)]"
|
||||||
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
|
: "text-slate-700 hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40",
|
||||||
option.disabled && "cursor-not-allowed opacity-50"
|
option.disabled && "cursor-not-allowed opacity-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -144,11 +341,11 @@ function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
|
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
option.swatchClassName ?? "bg-slate-100"
|
option.swatchClassName ?? "bg-[var(--surface-control)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{active ? (
|
{active ? (
|
||||||
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white">
|
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-[var(--action-blue)] text-white">
|
||||||
<Check size={10} aria-hidden="true" />
|
<Check size={10} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { cn } from "@/shared/ui/cn";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import { StatusDot } from "@/shared/ui/status";
|
|
||||||
import {
|
import {
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
|
||||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
|
||||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
||||||
} from "./map-control-styles";
|
} from "./map-control-styles";
|
||||||
|
|
||||||
@@ -14,9 +11,15 @@ export type MapLegendItem = {
|
|||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
layerControlId?: string;
|
||||||
|
group?: string;
|
||||||
|
groupLabel?: string;
|
||||||
|
groupDescription?: string;
|
||||||
|
groupLayout?: "list" | "grid";
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
imageSrc?: string;
|
imageSrc?: string;
|
||||||
shape?: "line" | "dashed-line" | "dash-dot-line" | "dot" | "ring" | "square";
|
lineWidth?: number;
|
||||||
|
shape?: "line" | "dashed-line" | "dash-dot-line" | "dot" | "ring" | "square" | "area";
|
||||||
};
|
};
|
||||||
|
|
||||||
type MapLegendProps = {
|
type MapLegendProps = {
|
||||||
@@ -37,7 +40,7 @@ export function MapLegend({ title = "图例", items, className = "" }: MapLegend
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<h2 className="px-1 text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
<h2 className="px-1 text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||||
<p className="mt-0.5 px-1 text-xs text-slate-500">业务图层语义</p>
|
<p className="mt-0.5 px-1 text-xs text-[var(--ink-muted)]">当前地图符号与分级</p>
|
||||||
<MapLegendList items={items} className="mt-3" />
|
<MapLegendList items={items} className="mt-3" />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -46,44 +49,134 @@ export function MapLegend({ title = "图例", items, className = "" }: MapLegend
|
|||||||
type MapLegendListProps = {
|
type MapLegendListProps = {
|
||||||
items: MapLegendItem[];
|
items: MapLegendItem[];
|
||||||
className?: string;
|
className?: string;
|
||||||
showStatusDot?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MapLegendList({ items, className = "", showStatusDot = false }: MapLegendListProps) {
|
export function MapLegendList({ items, className = "" }: MapLegendListProps) {
|
||||||
|
const groups = groupLegendItems(items);
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
return (
|
return (
|
||||||
<ul className={cn("space-y-1.5", className)}>
|
<div className={cn("surface-well rounded-xl border px-3 py-4 text-center", className)}>
|
||||||
{items.map((item) => {
|
<p className="text-xs font-semibold text-slate-700">暂无可见业务图层</p>
|
||||||
const Icon = item.icon;
|
<p className="mt-1 text-[11px] leading-relaxed text-[var(--ink-muted)]">请先在图层面板中打开需要查看的内容。</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<div className={cn("space-y-2", className)}>
|
||||||
key={item.id}
|
{groups.map((group) => (
|
||||||
className={cn("flex min-h-10 items-center gap-2.5 px-2.5 py-2 text-sm text-slate-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
<section
|
||||||
|
key={group.id}
|
||||||
|
aria-label={group.label}
|
||||||
|
className="surface-well overflow-hidden rounded-xl border"
|
||||||
>
|
>
|
||||||
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
<div className="flex min-h-10 items-center gap-2 px-2.5 py-1.5">
|
||||||
{item.imageSrc ? <img src={item.imageSrc} alt="" width={24} height={24} /> : Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate font-semibold">{item.label}</span>
|
<span className="block truncate text-xs font-semibold text-slate-700">{group.label}</span>
|
||||||
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
|
{group.description ? (
|
||||||
|
<span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">
|
||||||
|
{group.description}
|
||||||
</span>
|
</span>
|
||||||
{showStatusDot ? <StatusDot tone="success" size="md" /> : null}
|
) : null}
|
||||||
</li>
|
</span>
|
||||||
);
|
<span className="shrink-0 rounded-full bg-[var(--surface-reading)]/75 px-2 py-0.5 text-[10px] font-medium tabular-nums text-[var(--ink-muted)] shadow-xs shadow-slate-950/5">
|
||||||
})}
|
{group.items.length} 类
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
className={cn(
|
||||||
|
"border-t border-[var(--surface-divider)]/70",
|
||||||
|
group.layout === "grid"
|
||||||
|
? "grid grid-cols-2 gap-px bg-[var(--surface-divider)]/70"
|
||||||
|
: "divide-y divide-[var(--surface-divider)]/70 bg-[var(--surface-reading)]/45"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<LegendItem key={item.id} item={item} compact={group.layout === "grid"} />
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapLegendSwatch({ color, shape = "dot" }: Pick<MapLegendItem, "color" | "shape">) {
|
function groupLegendItems(items: MapLegendItem[]) {
|
||||||
if (shape === "line") {
|
const groups = new Map<string, {
|
||||||
return <span className="h-1 w-6 rounded-full" style={{ backgroundColor: color }} />;
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
layout: "list" | "grid";
|
||||||
|
items: MapLegendItem[];
|
||||||
|
}>();
|
||||||
|
items.forEach((item) => {
|
||||||
|
const id = item.group ?? "legend";
|
||||||
|
const existing = groups.get(id);
|
||||||
|
if (existing) {
|
||||||
|
existing.items.push(item);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
groups.set(id, {
|
||||||
|
id,
|
||||||
|
label: item.groupLabel ?? "地图符号",
|
||||||
|
description: item.groupDescription,
|
||||||
|
layout: item.groupLayout ?? "list",
|
||||||
|
items: [item]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return [...groups.values()];
|
||||||
|
}
|
||||||
|
|
||||||
if (shape === "dashed-line" || shape === "dash-dot-line") {
|
function LegendItem({ item, compact }: { item: MapLegendItem; compact: boolean }) {
|
||||||
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<svg width="24" height="8" viewBox="0 0 24 8" aria-hidden="true">
|
<li
|
||||||
<line x1="1" y1="4" x2="23" y2="4" stroke={color} strokeWidth="3" strokeDasharray={shape === "dashed-line" ? "5 3" : "8 3 2 3"} />
|
className={cn(
|
||||||
|
"flex items-center text-slate-700",
|
||||||
|
compact ? "min-h-11 gap-2 bg-[var(--surface-reading)]/70 px-2 py-1.5" : "min-h-9 gap-2.5 px-2.5 py-1.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"grid shrink-0 place-items-center bg-[var(--surface-reading)] shadow-inner shadow-slate-950/5",
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
compact ? "h-7 w-7" : "h-7 w-9"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.imageSrc ? (
|
||||||
|
<img src={item.imageSrc} alt="" width={24} height={24} />
|
||||||
|
) : Icon ? (
|
||||||
|
<Icon size={15} style={{ color: item.color }} />
|
||||||
|
) : (
|
||||||
|
<MapLegendSwatch color={item.color} shape={item.shape} lineWidth={item.lineWidth} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-[11px] font-semibold leading-tight">{item.label}</span>
|
||||||
|
{compact && item.description ? (
|
||||||
|
<span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">{item.description}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{!compact && item.description ? (
|
||||||
|
<span className="shrink-0 text-[10px] text-[var(--ink-muted)]">{item.description}</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapLegendSwatch({
|
||||||
|
color,
|
||||||
|
shape = "dot",
|
||||||
|
lineWidth = 3
|
||||||
|
}: Pick<MapLegendItem, "color" | "shape" | "lineWidth">) {
|
||||||
|
if (shape === "line" || shape === "dashed-line" || shape === "dash-dot-line") {
|
||||||
|
const dashArray = shape === "dashed-line" ? "5 3" : shape === "dash-dot-line" ? "8 3 2 3" : undefined;
|
||||||
|
return (
|
||||||
|
<svg width="28" height="12" viewBox="0 0 28 12" aria-hidden="true">
|
||||||
|
<line x1="2" y1="6" x2="26" y2="6" stroke="white" strokeWidth={lineWidth + 3} strokeLinecap="round" strokeDasharray={dashArray} />
|
||||||
|
<line x1="2" y1="6" x2="26" y2="6" stroke={color} strokeWidth={lineWidth} strokeLinecap="round" strokeDasharray={dashArray} />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,6 +185,15 @@ export function MapLegendSwatch({ color, shape = "dot" }: Pick<MapLegendItem, "c
|
|||||||
return <span className="h-4 w-4 rounded-sm" style={{ backgroundColor: color }} />;
|
return <span className="h-4 w-4 rounded-sm" style={{ backgroundColor: color }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shape === "area") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="h-4 w-5 rounded-[4px] border"
|
||||||
|
style={{ backgroundColor: `${color}24`, borderColor: color }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (shape === "ring") {
|
if (shape === "ring") {
|
||||||
return <span className="h-4 w-4 rounded-full border-[3px] bg-white" style={{ borderColor: color }} />;
|
return <span className="h-4 w-4 rounded-full border-[3px] bg-white" style={{ borderColor: color }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export const MAP_COMPACT_RADIUS_CLASS_NAME = MAP_CONTROL_RADIUS_CLASS_NAME;
|
|||||||
|
|
||||||
export const MAP_ICON_CELL_RADIUS_CLASS_NAME = "rounded-lg";
|
export const MAP_ICON_CELL_RADIUS_CLASS_NAME = "rounded-lg";
|
||||||
|
|
||||||
|
export const MAP_SELECTION_CARD_FOCUS_CLASS_NAME =
|
||||||
|
"outline-hidden focus-visible:outline-1 focus-visible:outline-solid focus-visible:outline-[var(--action-focus-ring)] focus-visible:-outline-offset-1";
|
||||||
|
|
||||||
export const MAP_CONTROL_SURFACE_CLASS_NAME =
|
export const MAP_CONTROL_SURFACE_CLASS_NAME =
|
||||||
"acrylic-control border";
|
"acrylic-control border";
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { Copy, Ruler, Trash2 } from "lucide-react";
|
import { ChevronRight, Copy, Ruler, Trash2 } from "lucide-react";
|
||||||
import { cn } from "@/shared/ui/cn";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import {
|
|
||||||
MapActionRow,
|
|
||||||
MapModeButton,
|
|
||||||
MapPanelSection
|
|
||||||
} from "./control-panel";
|
|
||||||
import {
|
import {
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
||||||
@@ -36,12 +32,18 @@ export type MapMeasureAction = {
|
|||||||
description: string;
|
description: string;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
strong?: boolean;
|
strong?: boolean;
|
||||||
variant?: "default" | "primary";
|
variant?: "default" | "primary" | "active";
|
||||||
tone?: "default" | "danger";
|
tone?: "default" | "danger";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MapMeasureStatus = {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
tone?: "idle" | "active" | "ready" | "disabled";
|
||||||
|
};
|
||||||
|
|
||||||
export type MapMeasurePanelProps = {
|
export type MapMeasurePanelProps = {
|
||||||
modes: MapMeasureMode[];
|
modes: MapMeasureMode[];
|
||||||
activeModeId: string;
|
activeModeId: string;
|
||||||
@@ -52,6 +54,7 @@ export type MapMeasurePanelProps = {
|
|||||||
units: MapMeasureUnit[];
|
units: MapMeasureUnit[];
|
||||||
activeUnitId: string;
|
activeUnitId: string;
|
||||||
actions: MapMeasureAction[];
|
actions: MapMeasureAction[];
|
||||||
|
status: MapMeasureStatus;
|
||||||
onSelectMode?: (id: string) => void;
|
onSelectMode?: (id: string) => void;
|
||||||
onSelectUnit?: (id: string) => void;
|
onSelectUnit?: (id: string) => void;
|
||||||
};
|
};
|
||||||
@@ -66,62 +69,79 @@ export function MapMeasurePanel({
|
|||||||
units,
|
units,
|
||||||
activeUnitId,
|
activeUnitId,
|
||||||
actions,
|
actions,
|
||||||
|
status,
|
||||||
onSelectMode,
|
onSelectMode,
|
||||||
onSelectUnit
|
onSelectUnit
|
||||||
}: MapMeasurePanelProps) {
|
}: MapMeasurePanelProps) {
|
||||||
|
const primaryAction = actions[0];
|
||||||
|
const secondaryActions = actions.slice(1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<MapPanelSection title="模式">
|
<section>
|
||||||
<div className={cn("surface-well grid grid-cols-3 gap-1.5 border p-1.5", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
<div className="mb-1.5 px-1 text-xs font-semibold text-[var(--ink-muted)] max-lg:hidden">
|
||||||
|
测量类型
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="测量类型"
|
||||||
|
className={cn("surface-well grid grid-cols-3 gap-1 border p-1", MAP_READABLE_RADIUS_CLASS_NAME)}
|
||||||
|
>
|
||||||
{modes.map((mode) => (
|
{modes.map((mode) => (
|
||||||
<MapModeButton
|
<button
|
||||||
key={mode.id}
|
key={mode.id}
|
||||||
icon={mode.icon}
|
type="button"
|
||||||
label={mode.label}
|
aria-pressed={mode.id === activeModeId}
|
||||||
active={mode.id === activeModeId}
|
|
||||||
disabled={mode.disabled}
|
disabled={mode.disabled}
|
||||||
onClick={() => onSelectMode?.(mode.id)}
|
onClick={() => onSelectMode?.(mode.id)}
|
||||||
/>
|
className={cn(
|
||||||
))}
|
"flex h-10 min-w-0 touch-manipulation items-center justify-center gap-1.5 px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none",
|
||||||
</div>
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
</MapPanelSection>
|
mode.id === activeModeId
|
||||||
|
? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
|
||||||
<MapPanelSection title="结果">
|
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/85 [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]",
|
||||||
<div className={cn("border border-blue-100 bg-blue-50/95 p-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
mode.disabled && "cursor-not-allowed opacity-45 active:scale-100"
|
||||||
<div className={cn("p-3", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
|
)}
|
||||||
<div className="min-w-0 border-b border-slate-100 pb-3">
|
|
||||||
<div className="text-xs font-semibold text-blue-700">{resultLabel}</div>
|
|
||||||
<div className="mt-1 flex min-w-0 items-baseline gap-1.5 text-slate-950">
|
|
||||||
<span className="truncate text-2xl font-semibold leading-none">{resultValue}</span>
|
|
||||||
<span className="shrink-0 text-xs font-semibold text-slate-500">{resultUnit}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{metrics.length > 0 ? (
|
|
||||||
<div className="mt-2 grid grid-cols-[repeat(auto-fit,minmax(92px,1fr))] gap-1.5">
|
|
||||||
{metrics.map((metric) => (
|
|
||||||
<div
|
|
||||||
key={metric.label}
|
|
||||||
className={cn("min-w-0 px-2.5 py-1.5 text-left", MAP_COMPACT_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
|
||||||
>
|
>
|
||||||
<div className="truncate text-xs text-slate-500">{metric.label}</div>
|
<mode.icon size={15} strokeWidth={2} aria-hidden="true" className="shrink-0" />
|
||||||
<div className="mt-0.5 truncate text-sm font-semibold text-slate-900">{metric.value}</div>
|
<span className="truncate">{mode.label}</span>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-label="测量读数"
|
||||||
|
className={cn("overflow-hidden", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}
|
||||||
|
>
|
||||||
|
<div className="px-3.5 py-2.5">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 pt-0.5">
|
||||||
|
<div className="text-xs font-semibold text-[var(--action-blue-hover)]">{resultLabel}</div>
|
||||||
|
<div className="mt-1.5 flex min-w-0 items-baseline gap-1.5 text-slate-950">
|
||||||
|
<span className="truncate text-[32px] font-semibold leading-none tracking-[-0.02em] tabular-nums">
|
||||||
|
{resultValue}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs font-semibold text-[var(--ink-muted)]">{resultUnit}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid grid-cols-[repeat(auto-fit,minmax(0,1fr))] gap-1.5">
|
</div>
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="测量单位"
|
||||||
|
className={cn("surface-well grid shrink-0 grid-cols-2 gap-1 border p-1", MAP_READABLE_RADIUS_CLASS_NAME)}
|
||||||
|
>
|
||||||
{units.map((unit) => (
|
{units.map((unit) => (
|
||||||
<button
|
<button
|
||||||
key={unit.id}
|
key={unit.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-pressed={unit.id === activeUnitId}
|
||||||
onClick={() => onSelectUnit?.(unit.id)}
|
onClick={() => onSelectUnit?.(unit.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 text-xs font-semibold",
|
"h-10 min-w-10 touch-manipulation px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none",
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
unit.id === activeUnitId
|
unit.id === activeUnitId
|
||||||
? "bg-blue-600 text-white"
|
? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
|
||||||
: "surface-control text-slate-600 hover:text-blue-700"
|
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/85 [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{unit.label}
|
{unit.label}
|
||||||
@@ -129,24 +149,144 @@ export function MapMeasurePanel({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MapPanelSection>
|
</div>
|
||||||
|
|
||||||
<MapPanelSection title="操作">
|
{metrics.length > 0 ? (
|
||||||
{actions.map((action) => (
|
<dl className="grid grid-cols-[repeat(auto-fit,minmax(92px,1fr))] border-t border-[var(--surface-divider)]/80">
|
||||||
<MapActionRow
|
{metrics.map((metric, index) => (
|
||||||
key={action.id}
|
<div
|
||||||
icon={action.icon ?? getDefaultActionIcon(action.id)}
|
key={metric.label}
|
||||||
label={action.label}
|
className={cn("min-w-0 px-3.5 py-2", index > 0 && "border-l border-[var(--surface-divider)]/80")}
|
||||||
description={action.description}
|
>
|
||||||
strong={action.strong}
|
<dt className="truncate text-[11px] text-[var(--ink-muted)]">{metric.label}</dt>
|
||||||
variant={action.variant}
|
<dd className="mt-0.5 truncate text-sm font-semibold tabular-nums text-slate-900">
|
||||||
tone={action.tone}
|
{metric.value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-10 items-center gap-2.5 border-t px-3.5 py-2",
|
||||||
|
status.tone === "active"
|
||||||
|
? "border-[var(--surface-border)] bg-[var(--action-soft)]/90"
|
||||||
|
: status.tone === "ready"
|
||||||
|
? "border-emerald-100 bg-emerald-50/80"
|
||||||
|
: "border-[var(--surface-divider)]/80 bg-[var(--surface-reading)]/75"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-2 w-2 shrink-0 rounded-full",
|
||||||
|
status.tone === "active"
|
||||||
|
? "bg-[var(--action-blue)] motion-safe:animate-pulse"
|
||||||
|
: status.tone === "ready"
|
||||||
|
? "bg-emerald-500"
|
||||||
|
: status.tone === "disabled"
|
||||||
|
? "bg-[var(--status-warning-mark)]"
|
||||||
|
: "bg-slate-400"
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="flex min-w-0 items-baseline gap-2">
|
||||||
|
<div className="shrink-0 text-xs font-semibold text-slate-800">{status.label}</div>
|
||||||
|
<div className="truncate text-[11px] text-[var(--ink-muted)]">{status.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{primaryAction ? (
|
||||||
|
<section aria-label="测量操作" className="space-y-2">
|
||||||
|
<MeasureActionButton action={primaryAction} primary />
|
||||||
|
{secondaryActions.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{secondaryActions.map((action) => (
|
||||||
|
<MeasureActionButton key={action.id} action={action} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MeasureActionButton({
|
||||||
|
action,
|
||||||
|
primary = false
|
||||||
|
}: {
|
||||||
|
action: MapMeasureAction;
|
||||||
|
primary?: boolean;
|
||||||
|
}) {
|
||||||
|
const Icon = action.icon ?? getDefaultActionIcon(action.id);
|
||||||
|
const danger = action.tone === "danger";
|
||||||
|
const active = action.variant === "active";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={action.description}
|
||||||
|
data-state={active ? "active" : undefined}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
onClick={action.onClick}
|
onClick={action.onClick}
|
||||||
/>
|
className={cn(
|
||||||
))}
|
"flex w-full touch-manipulation items-center gap-2 font-semibold transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-[0.98] motion-reduce:transition-none",
|
||||||
</MapPanelSection>
|
primary ? "h-12 justify-start border px-2.5 text-sm" : "h-10 justify-center border text-xs",
|
||||||
</div>
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
primary
|
||||||
|
? active
|
||||||
|
? "border-[var(--palette-brand)] bg-[var(--surface-reading)] text-slate-950 shadow-[0_3px_10px_rgba(37,99,235,0.14)] ring-1 ring-inset ring-[var(--surface-border)] [@media(hover:hover)]:hover:border-[var(--palette-brand)] [@media(hover:hover)]:hover:bg-[var(--action-soft)]/70"
|
||||||
|
: action.variant === "primary"
|
||||||
|
? "border-[var(--surface-divider)] bg-[var(--surface-reading)]/90 text-slate-900 shadow-xs [@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]"
|
||||||
|
: "border-slate-800 bg-slate-800 text-white shadow-xs [@media(hover:hover)]:hover:border-slate-900 [@media(hover:hover)]:hover:bg-slate-900"
|
||||||
|
: cn(
|
||||||
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
|
danger
|
||||||
|
? "text-red-600 [@media(hover:hover)]:hover:border-red-200 [@media(hover:hover)]:hover:bg-red-50"
|
||||||
|
: "text-slate-700 [@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
|
||||||
|
),
|
||||||
|
action.disabled &&
|
||||||
|
"cursor-not-allowed opacity-45 shadow-none active:scale-100 [@media(hover:hover)]:hover:border-[var(--surface-divider)] [@media(hover:hover)]:hover:bg-transparent [@media(hover:hover)]:hover:text-slate-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{primary ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-8 w-8 shrink-0 place-items-center rounded-lg",
|
||||||
|
active
|
||||||
|
? "bg-[var(--action-blue)] text-white"
|
||||||
|
: "bg-[var(--surface-reading)] text-slate-700 ring-1 ring-inset ring-[var(--surface-divider)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={15} strokeWidth={2.2} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Icon size={15} strokeWidth={2} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{primary ? (
|
||||||
|
<span className="min-w-0 flex-1 text-left">
|
||||||
|
<span className="block truncate text-sm font-semibold leading-4">{action.label}</span>
|
||||||
|
<span className="mt-0.5 block truncate text-[10px] font-medium leading-3 text-[var(--ink-muted)]">
|
||||||
|
{action.description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>{action.label}</span>
|
||||||
|
)}
|
||||||
|
{primary ? (
|
||||||
|
active ? (
|
||||||
|
<span className="flex shrink-0 items-center gap-1 rounded-full bg-[var(--action-soft)] px-2 py-1 text-[10px] font-semibold text-[var(--action-blue-hover)] ring-1 ring-inset ring-[var(--surface-border)]">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-[var(--action-blue)] motion-safe:animate-pulse" aria-hidden="true" />
|
||||||
|
进行中
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={16} aria-hidden="true" className="shrink-0 text-[var(--ink-muted)]" />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function showMapNotice(options: MapNoticeOptions) {
|
|||||||
: undefined,
|
: undefined,
|
||||||
classNames: {
|
classNames: {
|
||||||
cancelButton: cn(
|
cancelButton: cn(
|
||||||
"absolute right-2 top-2 z-10 grid h-8 w-8 place-items-center border border-transparent bg-transparent p-0 text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700",
|
"pointer-events-auto absolute right-2 top-2 z-10 grid h-8 w-8 place-items-center border border-transparent bg-transparent p-0 text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:border-[var(--surface-divider)] hover:bg-[var(--surface-reading)] hover:text-slate-700",
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -117,7 +117,10 @@ function MapNoticeAction({ label, onClick }: { label: string; onClick: () => voi
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn("mt-2 border-blue-100 bg-blue-50 text-blue-700", MAP_COMPACT_RADIUS_CLASS_NAME)}
|
className={cn(
|
||||||
|
"pointer-events-auto mt-2 border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ export function MapToaster() {
|
|||||||
toastOptions={{
|
toastOptions={{
|
||||||
unstyled: true,
|
unstyled: true,
|
||||||
classNames: {
|
classNames: {
|
||||||
toast: "group pointer-events-auto",
|
toast: "group pointer-events-none",
|
||||||
closeButton:
|
closeButton:
|
||||||
"absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:bg-white/70 hover:text-slate-700"
|
"pointer-events-auto absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:bg-[var(--surface-reading)]/70 hover:text-slate-700"
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -90,7 +90,7 @@ export function MapSourceStatusNotice({
|
|||||||
tone: getSourceStatusTone(status),
|
tone: getSourceStatusTone(status),
|
||||||
title: getSourceStatusTitle(sourceName, status),
|
title: getSourceStatusTitle(sourceName, status),
|
||||||
message: message ?? getSourceStatusMessage(status),
|
message: message ?? getSourceStatusMessage(status),
|
||||||
duration: status === "online" ? 2600 : Infinity,
|
duration: status === "online" ? 2600 : status === "degraded" ? 8000 : Infinity,
|
||||||
actionLabel,
|
actionLabel,
|
||||||
onAction
|
onAction
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -115,17 +115,16 @@ export function MapScaleLine({
|
|||||||
|
|
||||||
<ScaleLineDivider />
|
<ScaleLineDivider />
|
||||||
|
|
||||||
<div className="flex min-w-0 items-center justify-end gap-2.5 overflow-hidden whitespace-nowrap text-slate-700">
|
<div className="flex min-w-0 items-center justify-end gap-2 overflow-hidden whitespace-nowrap text-slate-700">
|
||||||
<span className="tabular-nums text-slate-800">{scale.zoom}</span>
|
<span className="tabular-nums text-slate-800">{scale.zoom}</span>
|
||||||
<span className="text-slate-600">EPSG:3857</span>
|
|
||||||
{coordinateReadout ? (
|
{coordinateReadout ? (
|
||||||
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums text-slate-800")} title={coordinateReadout.lngLatText}>
|
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-left tabular-nums text-slate-800")} title={coordinateReadout.lngLatText}>
|
||||||
{coordinateReadout.mercatorText}
|
{coordinateReadout.mercatorText}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums")}>{coordinateLabel}</span>
|
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-left tabular-nums")}>{coordinateLabel}</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-slate-500">Mapbox/OSM</span>
|
<span className="shrink-0 text-slate-600">EPSG:3857</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ export function MapToolbar({
|
|||||||
"relative grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-700",
|
"relative grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-700",
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
tool.active
|
tool.active
|
||||||
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
|
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
|
||||||
: MAP_CONTROL_HOVER_CLASS_NAME,
|
: MAP_CONTROL_HOVER_CLASS_NAME,
|
||||||
tool.disabled && "text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400 hover:ring-0"
|
tool.disabled && "text-[var(--ink-muted)] opacity-45 hover:bg-transparent hover:text-[var(--ink-muted)] hover:ring-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={20} strokeWidth={2.15} aria-hidden="true" />
|
<Icon size={20} strokeWidth={2.15} aria-hidden="true" />
|
||||||
@@ -87,7 +87,7 @@ export function MapToolbar({
|
|||||||
<TooltipContent
|
<TooltipContent
|
||||||
side={isVertical ? "left" : "top"}
|
side={isVertical ? "left" : "top"}
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
|
className="border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
import { Home, Minus, Plus, type LucideIcon } from "lucide-react";
|
import { Home, Minus, Navigation2, Plus, type LucideIcon } from "lucide-react";
|
||||||
import { type RefObject, useCallback } from "react";
|
import { type ReactNode, type RefObject, useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger
|
|
||||||
} from "@/shared/ui/tooltip";
|
|
||||||
import { cn } from "@/shared/ui/cn";
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import {
|
||||||
|
CAMERA_MODE_ANGLE_TOLERANCE,
|
||||||
|
getCombinedCameraAction,
|
||||||
|
setThreeDimensionalNavigation
|
||||||
|
} from "./camera-actions";
|
||||||
import {
|
import {
|
||||||
MAP_CONTROL_HOVER_CLASS_NAME,
|
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||||
@@ -19,10 +19,60 @@ type MapZoomProps = {
|
|||||||
mapRef: RefObject<MapLibreMap | null>;
|
mapRef: RefObject<MapLibreMap | null>;
|
||||||
mapReady: boolean;
|
mapReady: boolean;
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
|
reducedMotion?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomProps) {
|
export function MapZoom({
|
||||||
|
mapRef,
|
||||||
|
mapReady,
|
||||||
|
onHome,
|
||||||
|
reducedMotion = false,
|
||||||
|
className = ""
|
||||||
|
}: MapZoomProps) {
|
||||||
|
const [orientation, setOrientation] = useState({ bearing: 0, pitch: 0 });
|
||||||
|
const is3d = orientation.pitch > CAMERA_MODE_ANGLE_TOLERANCE;
|
||||||
|
const cameraAction = getCombinedCameraAction(orientation.bearing, orientation.pitch);
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !mapReady) return;
|
||||||
|
const syncOrientation = () => {
|
||||||
|
const nextOrientation = { bearing: map.getBearing(), pitch: map.getPitch() };
|
||||||
|
setOrientation(nextOrientation);
|
||||||
|
setThreeDimensionalNavigation(map, nextOrientation.pitch > CAMERA_MODE_ANGLE_TOLERANCE);
|
||||||
|
};
|
||||||
|
syncOrientation();
|
||||||
|
map.on("pitch", syncOrientation);
|
||||||
|
map.on("rotate", syncOrientation);
|
||||||
|
return () => {
|
||||||
|
map.off("pitch", syncOrientation);
|
||||||
|
map.off("rotate", syncOrientation);
|
||||||
|
};
|
||||||
|
}, [mapRef, mapReady]);
|
||||||
|
|
||||||
|
const handleCameraMode = useCallback(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
const action = getCombinedCameraAction(map.getBearing(), map.getPitch());
|
||||||
|
const duration = reducedMotion ? 0 : 350;
|
||||||
|
|
||||||
|
if (action === "enter-3d") {
|
||||||
|
setThreeDimensionalNavigation(map, true);
|
||||||
|
map.easeTo({
|
||||||
|
pitch: window.matchMedia("(max-width: 639px)").matches ? 45 : 50,
|
||||||
|
duration
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "reset-north") {
|
||||||
|
map.easeTo({ bearing: 0, duration });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setThreeDimensionalNavigation(map, false);
|
||||||
|
map.easeTo({ pitch: 0, bearing: 0, duration });
|
||||||
|
}, [mapRef, reducedMotion]);
|
||||||
const handleZoomIn = useCallback(() => {
|
const handleZoomIn = useCallback(() => {
|
||||||
mapRef.current?.zoomIn({ duration: 260 });
|
mapRef.current?.zoomIn({ duration: 260 });
|
||||||
}, [mapRef]);
|
}, [mapRef]);
|
||||||
@@ -36,10 +86,7 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
|
|||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="地图缩放"
|
aria-label="地图缩放"
|
||||||
className={cn(
|
className={cn("pointer-events-auto flex w-12 flex-col gap-2", className)}
|
||||||
"pointer-events-auto flex w-12 flex-col gap-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -48,34 +95,33 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
|
|||||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ControlButton
|
<ControlButton icon={Plus} label="放大" disabled={!mapReady} onClick={handleZoomIn} />
|
||||||
icon={Plus}
|
<Divider />
|
||||||
label="放大"
|
<ControlButton icon={Minus} label="缩小" disabled={!mapReady} onClick={handleZoomOut} />
|
||||||
disabled={!mapReady}
|
|
||||||
onClick={handleZoomIn}
|
|
||||||
/>
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<ControlButton
|
<ControlButton
|
||||||
icon={Minus}
|
content={
|
||||||
label="缩小"
|
<CompassModeIndicator
|
||||||
|
bearing={orientation.bearing}
|
||||||
|
pitch={orientation.pitch}
|
||||||
|
mode={is3d ? "3D" : "2D"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
cameraAction === "reset-north"
|
||||||
|
? "恢复北向"
|
||||||
|
: cameraAction === "exit-3d"
|
||||||
|
? "切换到二维"
|
||||||
|
: "切换到三维"
|
||||||
|
}
|
||||||
|
pressed={is3d}
|
||||||
disabled={!mapReady}
|
disabled={!mapReady}
|
||||||
onClick={handleZoomOut}
|
onClick={handleCameraMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className={cn("p-1", MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}>
|
||||||
className={cn(
|
<ControlButton icon={Home} label="缩放到全局" disabled={!mapReady} onClick={onHome} />
|
||||||
"p-1",
|
|
||||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
|
||||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ControlButton
|
|
||||||
icon={Home}
|
|
||||||
label="缩放到全局"
|
|
||||||
disabled={!mapReady}
|
|
||||||
onClick={onHome}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -83,19 +129,29 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ControlButtonProps = {
|
type ControlButtonProps = {
|
||||||
icon: LucideIcon;
|
icon?: LucideIcon;
|
||||||
|
content?: ReactNode;
|
||||||
|
pressed?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonProps) {
|
function ControlButton({
|
||||||
|
icon: Icon,
|
||||||
|
content,
|
||||||
|
pressed,
|
||||||
|
label,
|
||||||
|
disabled,
|
||||||
|
onClick
|
||||||
|
}: ControlButtonProps) {
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
aria-pressed={pressed}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -103,17 +159,17 @@ function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonPr
|
|||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
"bg-transparent text-slate-700",
|
"bg-transparent text-slate-700",
|
||||||
MAP_CONTROL_HOVER_CLASS_NAME,
|
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||||
disabled &&
|
pressed && "bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
|
||||||
"text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400"
|
disabled && "text-[var(--ink-muted)] opacity-45 hover:bg-transparent hover:text-[var(--ink-muted)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={18} strokeWidth={2.2} aria-hidden="true" />
|
{Icon ? <Icon size={18} strokeWidth={2.2} aria-hidden="true" /> : content}
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="left"
|
side="left"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
|
className="border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -121,11 +177,45 @@ function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonPr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Divider() {
|
function CompassModeIndicator({
|
||||||
|
bearing,
|
||||||
|
pitch,
|
||||||
|
mode
|
||||||
|
}: {
|
||||||
|
bearing: number;
|
||||||
|
pitch: number;
|
||||||
|
mode: "2D" | "3D";
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<span
|
||||||
className="my-0.5 h-px w-3.5 self-center bg-slate-300/70"
|
className="relative grid h-7 w-7 place-items-center"
|
||||||
|
style={{ perspective: "70px" }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
data-map-compass="true"
|
||||||
|
data-bearing={bearing.toFixed(1)}
|
||||||
|
data-pitch={pitch.toFixed(1)}
|
||||||
|
data-map-mode={mode.toLowerCase()}
|
||||||
|
>
|
||||||
|
<Navigation2
|
||||||
|
size={17}
|
||||||
|
strokeWidth={2.2}
|
||||||
|
className="transition-transform duration-150 ease-out motion-reduce:transition-none"
|
||||||
|
style={{ transform: `rotateX(${Math.min(pitch, 60) * 0.65}deg) rotateZ(${-bearing}deg)` }}
|
||||||
/>
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute -bottom-0.5 -right-0.5 min-w-4 rounded-[4px] px-0.5 text-center text-[8px] font-bold leading-3 tracking-[-0.02em] shadow-xs ring-1",
|
||||||
|
mode === "3D"
|
||||||
|
? "bg-[var(--action-blue)] text-white ring-[var(--action-blue)]/30"
|
||||||
|
: "bg-[var(--surface-reading)] text-slate-600 ring-[var(--surface-border)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{mode}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Divider() {
|
||||||
|
return <div className="my-0.5 h-px w-3.5 self-center bg-slate-300/70" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ export {
|
|||||||
type MapDrawToolbarProps,
|
type MapDrawToolbarProps,
|
||||||
type MapDrawTool
|
type MapDrawTool
|
||||||
} from "./components/draw-toolbar";
|
} from "./components/draw-toolbar";
|
||||||
export { MapLayerControl, type MapLayerControlItem } from "./components/layer-control";
|
export {
|
||||||
|
MapLayerControl,
|
||||||
|
type MapLayerControlItem,
|
||||||
|
type MapLayerControlVisual
|
||||||
|
} from "./components/layer-control";
|
||||||
export { MapLayerPanel, type MapLayerPanelProps } from "./components/layer-panel";
|
export { MapLayerPanel, type MapLayerPanelProps } from "./components/layer-panel";
|
||||||
export { MapLegend, MapLegendList, MapLegendSwatch, type MapLegendItem } from "./components/legend";
|
export { MapLegend, MapLegendList, MapLegendSwatch, type MapLegendItem } from "./components/legend";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fetchElementGisProperties } from "./gis-properties-client";
|
||||||
|
|
||||||
|
describe("GIS properties client", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads validated attributes for the selected project element", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
element_type: "pipes",
|
||||||
|
element_id: "P/117",
|
||||||
|
groups: [{
|
||||||
|
dataset_key: "fx_pipe_inventory_20200811",
|
||||||
|
dataset_title: "奉贤 DN100 及以上管网台账",
|
||||||
|
source_date: "2020-08-11",
|
||||||
|
layer_key: "fx_20200811_dn100_pipes",
|
||||||
|
layer_name: "DN100 及以上管线台账",
|
||||||
|
source_fid: 117,
|
||||||
|
match_method: "geometry",
|
||||||
|
match_distance_m: 0.12,
|
||||||
|
attributes: { PIPE_D: 300, MATERIAL: "DI" }
|
||||||
|
}]
|
||||||
|
}), { status: 200, headers: { "Content-Type": "application/json" } }));
|
||||||
|
|
||||||
|
const groups = await fetchElementGisProperties(
|
||||||
|
"pipes",
|
||||||
|
"P/117",
|
||||||
|
"project-117",
|
||||||
|
async () => "read-token"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(groups[0].attributes).toEqual({ PIPE_D: 300, MATERIAL: "DI" });
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe("http://127.0.0.1:8000/api/v1/elements/pipes/P%2F117/gis-properties");
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
expect(headers.get("Authorization")).toBe("Bearer read-token");
|
||||||
|
expect(headers.get("X-Project-Id")).toBe("project-117");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||||
|
import { env } from "@/shared/config/env";
|
||||||
|
import type { WaterNetworkSourceId } from "../map/sources";
|
||||||
|
import {
|
||||||
|
createWorkbenchHeaders,
|
||||||
|
findOwnedWorkbenchProject,
|
||||||
|
getServerError
|
||||||
|
} from "./project-client";
|
||||||
|
|
||||||
|
const gisAttributeGroupSchema = z.object({
|
||||||
|
dataset_key: z.string().min(1),
|
||||||
|
dataset_title: z.string().min(1),
|
||||||
|
source_date: z.string().date(),
|
||||||
|
layer_key: z.string().min(1),
|
||||||
|
layer_name: z.string().min(1),
|
||||||
|
source_fid: z.number().int(),
|
||||||
|
match_method: z.enum(["node_pair", "geometry", "source"]),
|
||||||
|
match_distance_m: z.number().finite().nonnegative().nullable(),
|
||||||
|
attributes: z.record(z.unknown())
|
||||||
|
});
|
||||||
|
|
||||||
|
const elementGisPropertiesSchema = z.object({
|
||||||
|
element_type: z.enum([
|
||||||
|
"pipes",
|
||||||
|
"junctions",
|
||||||
|
"valves",
|
||||||
|
"reservoirs",
|
||||||
|
"pumps",
|
||||||
|
"tanks",
|
||||||
|
"scada"
|
||||||
|
]),
|
||||||
|
element_id: z.string().min(1),
|
||||||
|
groups: z.array(gisAttributeGroupSchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceGisFeaturePropertiesSchema = z.object({
|
||||||
|
layer_key: z.string().min(1),
|
||||||
|
source_fid: z.number().int().nonnegative(),
|
||||||
|
group: gisAttributeGroupSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GisAttributeGroup = z.infer<typeof gisAttributeGroupSchema>;
|
||||||
|
|
||||||
|
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
||||||
|
|
||||||
|
export async function fetchElementGisProperties(
|
||||||
|
elementType: WaterNetworkSourceId,
|
||||||
|
elementId: string,
|
||||||
|
projectId: string | null,
|
||||||
|
getAccessToken?: AccessTokenProvider,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<GisAttributeGroup[]> {
|
||||||
|
const ownedProject = projectId
|
||||||
|
? null
|
||||||
|
: await findOwnedWorkbenchProject(getAccessToken, signal);
|
||||||
|
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
|
||||||
|
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
|
||||||
|
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
|
||||||
|
const path = [
|
||||||
|
"/api/v1/elements",
|
||||||
|
encodeURIComponent(elementType),
|
||||||
|
encodeURIComponent(elementId),
|
||||||
|
"gis-properties"
|
||||||
|
].join("/");
|
||||||
|
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
|
||||||
|
headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
const payload: unknown = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(getServerError(payload, response.status, "GIS 台账属性查询"));
|
||||||
|
}
|
||||||
|
const result = elementGisPropertiesSchema.parse(payload);
|
||||||
|
if (result.element_type !== elementType || result.element_id !== elementId) {
|
||||||
|
throw new Error("GIS 台账属性与所选元素不一致");
|
||||||
|
}
|
||||||
|
return result.groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSourceGisFeatureProperties(
|
||||||
|
layerKey: string,
|
||||||
|
sourceFid: number,
|
||||||
|
projectId: string | null,
|
||||||
|
getAccessToken?: AccessTokenProvider,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<GisAttributeGroup[]> {
|
||||||
|
const ownedProject = projectId
|
||||||
|
? null
|
||||||
|
: await findOwnedWorkbenchProject(getAccessToken, signal);
|
||||||
|
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
|
||||||
|
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
|
||||||
|
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
|
||||||
|
const path = [
|
||||||
|
"/api/v1/source-gis/layers",
|
||||||
|
encodeURIComponent(layerKey),
|
||||||
|
"features",
|
||||||
|
encodeURIComponent(String(sourceFid)),
|
||||||
|
"properties"
|
||||||
|
].join("/");
|
||||||
|
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
|
||||||
|
headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
const payload: unknown = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(getServerError(payload, response.status, "源 GIS 属性查询"));
|
||||||
|
}
|
||||||
|
const result = sourceGisFeaturePropertiesSchema.parse(payload);
|
||||||
|
if (result.layer_key !== layerKey || result.source_fid !== sourceFid) {
|
||||||
|
throw new Error("源 GIS 属性与所选要素不一致");
|
||||||
|
}
|
||||||
|
return [result.group];
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { investigationPresentationEventSchema } from "./investigation-presentation";
|
||||||
|
|
||||||
|
const event = (command: unknown) => ({ task_id: "71000000-0000-4000-8000-000000000001", run_id: "71000000-0000-4000-8000-000000000002", execution_generation: 1, command });
|
||||||
|
const overlay = { action: "set_overlay", overlay_id: "scope", title: "投诉记录分布", mode: "heatmap", evidence_id: "complaints",
|
||||||
|
data: { type: "FeatureCollection", features: [{ type: "Feature", properties: { weight: 2 }, geometry: { type: "Point", coordinates: [121.4, 31.1] } }] } };
|
||||||
|
|
||||||
|
describe("investigation presentation boundary", () => {
|
||||||
|
it("accepts only bounded registered target actions", () => {
|
||||||
|
expect(investigationPresentationEventSchema.safeParse(event({ action: "highlight_features", layer: "scada", feature_ids: ["P1"], effect: "pulse" })).success).toBe(true);
|
||||||
|
expect(investigationPresentationEventSchema.safeParse(event({ action: "highlight_features", layer: "drainage", feature_ids: ["P1"], effect: "pulse" })).success).toBe(false);
|
||||||
|
expect(investigationPresentationEventSchema.safeParse(event({ action: "run_script", script: "alert(1)" })).success).toBe(false);
|
||||||
|
});
|
||||||
|
it("rejects untrusted chart fields and mismatched series", () => {
|
||||||
|
const chart = { title: "投诉时段压力", chart_type: "line", x_data: ["08:00", "09:00"], series: [{ name: "P1", data: [20, 21] }] };
|
||||||
|
const command = { action: "open_chart", chart_id: "p1", chart, evidence_refs: ["pressure"] };
|
||||||
|
expect(investigationPresentationEventSchema.safeParse(event(command)).success).toBe(true);
|
||||||
|
expect(investigationPresentationEventSchema.safeParse(event({ ...command, chart: { ...chart, formatter: "script" } })).success).toBe(false);
|
||||||
|
expect(investigationPresentationEventSchema.safeParse(event({ ...command, chart: { ...chart, series: [{ name: "P1", data: [20] }] } })).success).toBe(false);
|
||||||
|
});
|
||||||
|
it("keeps heatmap coordinates and weights finite and point-only", () => {
|
||||||
|
expect(investigationPresentationEventSchema.safeParse(event(overlay)).success).toBe(true);
|
||||||
|
for (const feature of [
|
||||||
|
{ type: "Feature", properties: { weight: -1 }, geometry: { type: "Point", coordinates: [121.4, 31.1] } },
|
||||||
|
{ type: "Feature", properties: { weight: "2" }, geometry: { type: "Point", coordinates: [121.4, 31.1] } },
|
||||||
|
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [Infinity, 31.1] } },
|
||||||
|
{ type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: [[121.4, 31.1], [121.5, 31.2]] } }
|
||||||
|
]) expect(investigationPresentationEventSchema.safeParse(event({ ...overlay, data: { type: "FeatureCollection", features: [feature] } })).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { FeatureCollection, Geometry } from "geojson";
|
||||||
|
import { parseFrontendChart } from "@/features/agent";
|
||||||
|
import { investigationMapTargetSchema } from "./investigation-report";
|
||||||
|
|
||||||
|
const id = z.string().trim().min(1).max(128);
|
||||||
|
const chartSchema = z.custom<NonNullable<ReturnType<typeof parseFrontendChart>>>((value) => parseFrontendChart(value) !== null);
|
||||||
|
const position = z.tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)]).rest(z.number().finite()).refine((value) => value.length <= 3);
|
||||||
|
const line = z.array(position).min(2).max(20000);
|
||||||
|
const ring = z.array(position).min(4).max(20000).refine((value) => value[0][0] === value.at(-1)![0] && value[0][1] === value.at(-1)![1]);
|
||||||
|
const geometrySchema = z.discriminatedUnion("type", [
|
||||||
|
z.object({ type: z.literal("Point"), coordinates: position }).strict(),
|
||||||
|
z.object({ type: z.literal("MultiPoint"), coordinates: z.array(position).min(1).max(20000) }).strict(),
|
||||||
|
z.object({ type: z.literal("LineString"), coordinates: line }).strict(),
|
||||||
|
z.object({ type: z.literal("MultiLineString"), coordinates: z.array(line).min(1).max(20000) }).strict(),
|
||||||
|
z.object({ type: z.literal("Polygon"), coordinates: z.array(ring).min(1).max(20000) }).strict(),
|
||||||
|
z.object({ type: z.literal("MultiPolygon"), coordinates: z.array(z.array(ring).min(1).max(20000)).min(1).max(20000) }).strict()
|
||||||
|
]).refine((geometry) => {
|
||||||
|
const count = (coordinates: unknown): number => !Array.isArray(coordinates) ? 0
|
||||||
|
: typeof coordinates[0] === "number" ? 1 : coordinates.reduce<number>((total, item) => total + count(item), 0);
|
||||||
|
return count(geometry.coordinates) <= 20000;
|
||||||
|
});
|
||||||
|
export const investigationOverlayDataSchema = z.object({
|
||||||
|
type: z.literal("FeatureCollection"),
|
||||||
|
features: z.array(z.object({
|
||||||
|
type: z.literal("Feature"),
|
||||||
|
id: z.union([z.string(), z.number().finite()]).optional(),
|
||||||
|
properties: z.record(z.string(), z.unknown()).nullable(),
|
||||||
|
geometry: geometrySchema
|
||||||
|
}).strict()).min(1).max(1000)
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const overlaySchema = z.object({
|
||||||
|
action: z.literal("set_overlay"), overlay_id: id, title: z.string().trim().min(1).max(200),
|
||||||
|
mode: z.enum(["geometry", "heatmap"]), evidence_id: z.string().trim().min(1), data: investigationOverlayDataSchema
|
||||||
|
}).strict();
|
||||||
|
const commandSchema = z.discriminatedUnion("action", [
|
||||||
|
z.object({ action: z.literal("highlight_features"), layer: investigationMapTargetSchema.shape.source_id,
|
||||||
|
feature_ids: z.array(id).min(1).max(100), effect: z.enum(["steady", "pulse"]) }).strict(),
|
||||||
|
z.object({ action: z.literal("clear_highlights") }).strict(),
|
||||||
|
z.object({ action: z.literal("open_chart"), chart_id: id, chart: chartSchema, evidence_refs: z.array(z.string().trim().min(1)).min(1).max(100) }).strict(),
|
||||||
|
z.object({ action: z.literal("close_chart"), chart_id: id }).strict(),
|
||||||
|
overlaySchema,
|
||||||
|
z.object({ action: z.literal("clear_overlay"), overlay_id: id }).strict()
|
||||||
|
]).superRefine((command, ctx) => {
|
||||||
|
if (command.action !== "set_overlay" || command.mode !== "heatmap") return;
|
||||||
|
if (command.data.features.some((feature) => feature.geometry.type !== "Point"
|
||||||
|
|| (feature.properties?.weight !== undefined && (typeof feature.properties.weight !== "number" || !Number.isFinite(feature.properties.weight) || feature.properties.weight < 0)))) {
|
||||||
|
ctx.addIssue({ code: "custom", message: "投诉热力只接受点和有限非负权重" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const investigationPresentationEventSchema = z.object({
|
||||||
|
task_id: z.string().uuid(), run_id: z.string().uuid(), execution_generation: z.number().int().positive(), command: commandSchema
|
||||||
|
});
|
||||||
|
export const investigationPresentationCursorSchema = z.object({
|
||||||
|
task_id: z.string().uuid(), execution_generation: z.number().int().positive(),
|
||||||
|
active_run: z.object({ run_id: z.string().uuid(), generation: z.number().int().positive(),
|
||||||
|
status: z.enum(["queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"]) }).strict().nullable(),
|
||||||
|
event_cursor: z.number().int().nonnegative()
|
||||||
|
}).strict();
|
||||||
|
export type InvestigationPresentationCommand = z.infer<typeof commandSchema>;
|
||||||
|
export type InvestigationPresentationEvent = z.infer<typeof investigationPresentationEventSchema>;
|
||||||
|
export type InvestigationPresentationCursor = z.infer<typeof investigationPresentationCursorSchema>;
|
||||||
|
export type InvestigationOverlay = Extract<InvestigationPresentationCommand, { action: "set_overlay" }> & { visible: boolean };
|
||||||
|
export type InvestigationOverlayData = FeatureCollection<Geometry>;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { investigationReportSchema } from "./investigation-report";
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
title: "调查报告",
|
||||||
|
current_conclusion: "无法确定原因",
|
||||||
|
conclusion_boundary: "仅有上游监测",
|
||||||
|
evidence_refs: ["query-1"],
|
||||||
|
recommendations: [],
|
||||||
|
pending_items: []
|
||||||
|
};
|
||||||
|
const result = {
|
||||||
|
...base,
|
||||||
|
stage: "final",
|
||||||
|
outcome: "insufficient_data",
|
||||||
|
stop_reason: "缺少采样",
|
||||||
|
data_gaps: [
|
||||||
|
{
|
||||||
|
gap_id: "g1",
|
||||||
|
node_id: "read",
|
||||||
|
missing_data: "末梢采样",
|
||||||
|
scope: "投诉小区,投诉时段",
|
||||||
|
impact: "无法确定异常来源",
|
||||||
|
required_data: "该时段采样结果",
|
||||||
|
evidence_refs: ["query-1"],
|
||||||
|
blocking: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("investigation report read contract", () => {
|
||||||
|
it("preserves unknown historical facts instead of inventing a result or empty gap list", () => {
|
||||||
|
const legacy = investigationReportSchema.parse(base);
|
||||||
|
expect(legacy.outcome).toBeUndefined();
|
||||||
|
expect(legacy.data_gaps).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
investigationReportSchema.parse({
|
||||||
|
...base,
|
||||||
|
stage: null,
|
||||||
|
outcome: null,
|
||||||
|
data_gaps: null,
|
||||||
|
stop_reason: null
|
||||||
|
}).data_gaps
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
it("accepts an evidenced final insufficient-data result", () => {
|
||||||
|
expect(investigationReportSchema.parse(result).outcome).toBe("insufficient_data");
|
||||||
|
});
|
||||||
|
it("reads a narrative diagram without requiring a legacy execution plan", () => {
|
||||||
|
const value = investigationReportSchema.parse({
|
||||||
|
...result,
|
||||||
|
data_gaps: [{ ...result.data_gaps[0], node_id: null }],
|
||||||
|
diagram: {
|
||||||
|
title: "调查脉络",
|
||||||
|
mermaid: 'flowchart TD\nA["查询"] --> B["判断"]',
|
||||||
|
node_refs: [{ node_id: "A", label: "查询", evidence_refs: ["query-1"] }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(value.diagram?.node_refs[0]?.action_refs).toEqual([]);
|
||||||
|
expect(value.data_gaps?.[0]?.node_id).toBeNull();
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
{ outcome: "other" },
|
||||||
|
{ stage: "interim" },
|
||||||
|
{ stop_reason: null },
|
||||||
|
{ data_gaps: [] },
|
||||||
|
{ recommendations: ["自动派单"] },
|
||||||
|
{ pending_items: ["等人工"] },
|
||||||
|
{ outcome: "concluded" },
|
||||||
|
{ data_gaps: [{ ...result.data_gaps[0], evidence_refs: [] }] },
|
||||||
|
{ data_gaps: [result.data_gaps[0], result.data_gaps[0]] },
|
||||||
|
{ schema: "invented" }
|
||||||
|
])("rejects inconsistent business facts %j", (changes) => {
|
||||||
|
expect(() => investigationReportSchema.parse({ ...result, ...changes })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { AgentTaskDiscussionContext } from "@/features/agent";
|
||||||
|
|
||||||
|
export type InvestigationDiscussion = {
|
||||||
|
prompt?: string;
|
||||||
|
context?: AgentTaskDiscussionContext;
|
||||||
|
// Display metadata from the selected report, never the task's current round.
|
||||||
|
reportGeneration?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = z.string().trim().min(1);
|
||||||
|
export const investigationMapTargetSchema = z.object({
|
||||||
|
source_id: z.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]),
|
||||||
|
feature_id: text.max(128)
|
||||||
|
}).strict();
|
||||||
|
export const investigationDiagramSchema = z
|
||||||
|
.object({
|
||||||
|
title: text,
|
||||||
|
mermaid: text
|
||||||
|
.max(20000)
|
||||||
|
.refine(
|
||||||
|
(source) =>
|
||||||
|
/^\s*(?:flowchart|graph)\s+(?:TD|TB|BT|LR|RL)\b/.test(source) &&
|
||||||
|
!source.includes("```") &&
|
||||||
|
!source.includes("%%{") &&
|
||||||
|
!/(?:^|[;\n])\s*click\b/.test(source),
|
||||||
|
"调查图只接受不含指令或回调的 Mermaid 流程描述"
|
||||||
|
),
|
||||||
|
node_refs: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
node_id: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
|
||||||
|
label: text,
|
||||||
|
status: z.enum(["pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled"]).nullish(),
|
||||||
|
map_targets: z.array(investigationMapTargetSchema).max(100).optional(),
|
||||||
|
evidence_refs: z.array(text).default([]),
|
||||||
|
action_refs: z.array(z.string().uuid()).default([])
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
)
|
||||||
|
.max(100)
|
||||||
|
.default([])
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.superRefine((diagram, ctx) => {
|
||||||
|
if (new Set(diagram.node_refs.map((node) => node.node_id)).size !== diagram.node_refs.length) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "调查图节点引用重复" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const dataGapSchema = z
|
||||||
|
.object({
|
||||||
|
gap_id: text,
|
||||||
|
node_id: text.nullish(),
|
||||||
|
missing_data: text,
|
||||||
|
scope: text,
|
||||||
|
impact: text,
|
||||||
|
required_data: text,
|
||||||
|
evidence_refs: z.array(text).min(1),
|
||||||
|
blocking: z.boolean()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
/** Historical omissions remain unknown. Never infer outcomes from report prose. */
|
||||||
|
export const investigationReportSchema = z
|
||||||
|
.object({
|
||||||
|
title: text,
|
||||||
|
current_conclusion: text,
|
||||||
|
conclusion_boundary: text,
|
||||||
|
evidence_refs: z.array(text),
|
||||||
|
recommendations: z.array(text),
|
||||||
|
pending_items: z.array(text),
|
||||||
|
stage: z.enum(["interim", "final"]).nullish(),
|
||||||
|
outcome: z.enum(["concluded", "insufficient_data"]).nullish(),
|
||||||
|
stop_reason: text.nullish(),
|
||||||
|
data_gaps: z.array(dataGapSchema).nullish(),
|
||||||
|
diagram: investigationDiagramSchema.nullish()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.superRefine((report, ctx) => {
|
||||||
|
const invalid = (message: string) => ctx.addIssue({ code: z.ZodIssueCode.custom, message });
|
||||||
|
if (report.outcome == null) {
|
||||||
|
if (report.stage != null || report.stop_reason != null || report.data_gaps != null)
|
||||||
|
invalid("报告结构化字段必须一起提供");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (report.stage == null || report.data_gaps == null) {
|
||||||
|
invalid("报告缺少阶段或数据缺口清单");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (new Set(report.data_gaps.map((gap) => gap.gap_id)).size !== report.data_gaps.length)
|
||||||
|
invalid("数据缺口编号重复");
|
||||||
|
const blocking = report.data_gaps.some((gap) => gap.blocking);
|
||||||
|
if (report.outcome === "insufficient_data") {
|
||||||
|
if (report.stage !== "final" || !report.stop_reason || !blocking)
|
||||||
|
invalid("证据不足必须提供最终报告、结束原因和关键缺口");
|
||||||
|
if (report.recommendations.length || report.pending_items.length)
|
||||||
|
invalid("证据不足退出后不再生成后续处置建议或待办");
|
||||||
|
} else if (report.stop_reason != null || blocking)
|
||||||
|
invalid("已形成结论不能包含结束原因或阻塞缺口");
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InvestigationReport = z.infer<typeof investigationReportSchema>;
|
||||||
|
export type InvestigationDiagram = z.infer<typeof investigationDiagramSchema>;
|
||||||
|
export type InvestigationMapTarget = z.infer<typeof investigationMapTargetSchema>;
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
fetchOperationalTaskEvidenceSnapshot,
|
||||||
|
fetchOperationalTaskNotifications,
|
||||||
|
parseOperationalTask,
|
||||||
|
parseOperationalTaskNotification
|
||||||
|
} from "./operational-task-client";
|
||||||
|
|
||||||
|
vi.mock("./project-client", () => ({
|
||||||
|
findOwnedWorkbenchProject: vi.fn(async () => ({ project: { project_id: "project" }, accessToken: "test" })),
|
||||||
|
createWorkbenchHeaders: vi.fn(() => ({})),
|
||||||
|
getServerError: vi.fn(() => "请求失败")
|
||||||
|
}));
|
||||||
|
|
||||||
|
const id = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
|
||||||
|
const time = "2026-09-23T01:00:00Z";
|
||||||
|
function task(generation: number | null | undefined = 2) {
|
||||||
|
const snapshot = { revision: 1, source_session_id: "session", published_at: time, published_by: "agent",
|
||||||
|
...(generation === undefined ? {} : { execution_generation: generation }) };
|
||||||
|
return {
|
||||||
|
project_id: id, task_id: id, task_type: "event_investigation", workflow: "water_quality_complaint",
|
||||||
|
lifecycle_status: "waiting_human", active_run: null,
|
||||||
|
status: "waiting_human", priority: "normal", revision: 1, execution_generation: 2,
|
||||||
|
execution_request_id: id, title: "水质投诉", summary: "调查中",
|
||||||
|
source: { kind: "external_system", external_id: null, external_revision: null, event_kind: null },
|
||||||
|
trigger: { kind: "event", occurred_at: time, detail: "投诉上报" },
|
||||||
|
scheduled_for: null, agent_session_id: "session", agent_lease_expires_at: null,
|
||||||
|
current_plan_revision: 1, current_report_revision: 1, created_at: time, updated_at: time,
|
||||||
|
latest_plan: { ...snapshot, plan: { schema: "execution-plan@1", title: "调查", reason: "投诉",
|
||||||
|
nodes: [{ id: "check", title: "核查", owner: "agent", status: "completed", detail: "已核查", evidence_refs: [], action_refs: [] }], edges: [] } },
|
||||||
|
latest_report: { ...snapshot, report: { title: "调查报告", current_conclusion: "待采样",
|
||||||
|
conclusion_boundary: "尚无现场证据", evidence_refs: [], recommendations: [], pending_items: ["采样"] } },
|
||||||
|
evidence: [], actions: [], permissions: { can_publish_artifacts: false, can_propose_actions: false,
|
||||||
|
can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false,
|
||||||
|
can_decide_commands: false, can_execute_commands: false }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
describe("operational task contract", () => {
|
||||||
|
it("accepts execution generation on both plan and report", () => {
|
||||||
|
const parsed = parseOperationalTask(task());
|
||||||
|
expect(parsed.latest_plan).toHaveProperty("execution_generation", 2);
|
||||||
|
expect(parsed.latest_report).toHaveProperty("execution_generation", 2);
|
||||||
|
});
|
||||||
|
it("accepts legacy null or missing snapshot generation", () => {
|
||||||
|
expect(() => parseOperationalTask(task(null))).not.toThrow();
|
||||||
|
const legacy = task();
|
||||||
|
Reflect.deleteProperty(legacy.latest_plan, "execution_generation");
|
||||||
|
Reflect.deleteProperty(legacy.latest_report, "execution_generation");
|
||||||
|
expect(() => parseOperationalTask(legacy)).not.toThrow();
|
||||||
|
});
|
||||||
|
it("accepts a task without any plan or report", () => {
|
||||||
|
expect(parseOperationalTask({ ...task(), latest_plan: null, latest_report: null }).latest_report).toBeNull();
|
||||||
|
});
|
||||||
|
it("reads ongoing progress without needing a conclusion or execution plan", () => {
|
||||||
|
const progress = { revision: 1, run_id: id, execution_generation: 2, published_at: time, source_session_id: "session",
|
||||||
|
progress: { summary: "正在核对投诉时段记录", diagram: { title: "当前核查", mermaid: "flowchart TD\nA[读取记录]", node_refs: [{ node_id: "A", label: "读取记录", status: "running", map_targets: [{ source_id: "scada", feature_id: "P1" }] }] } } };
|
||||||
|
const parsed = parseOperationalTask({ ...task(), latest_plan: null, latest_report: null, latest_progress: progress });
|
||||||
|
expect(parsed.latest_progress?.progress.diagram?.node_refs[0].status).toBe("running");
|
||||||
|
expect(parsed.latest_progress?.progress.diagram?.node_refs[0].map_targets?.[0].feature_id).toBe("P1");
|
||||||
|
expect(() => parseOperationalTask({ ...task(), latest_progress: { ...progress, execution_generation: 0 } })).toThrow();
|
||||||
|
});
|
||||||
|
it("accepts a withdrawn peripheral source timestamp", () => {
|
||||||
|
const value = task();
|
||||||
|
expect(parseOperationalTask({
|
||||||
|
...value,
|
||||||
|
source: { ...value.source, deleted_at: time }
|
||||||
|
}).source.deleted_at).toBe(time);
|
||||||
|
expect(() => parseOperationalTask({
|
||||||
|
...value,
|
||||||
|
source: { ...value.source, deleted_at: "not-a-date" }
|
||||||
|
})).toThrow();
|
||||||
|
});
|
||||||
|
it("keeps unknown fields and invalid generations rejected", () => {
|
||||||
|
expect(() => parseOperationalTask(task(0))).toThrow();
|
||||||
|
const value = task();
|
||||||
|
expect(() => parseOperationalTask({ ...value, latest_plan: { ...value.latest_plan, unexpected: true } })).toThrow();
|
||||||
|
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, unexpected: true } })).toThrow();
|
||||||
|
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, execution_generation: -1 } })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("operational notification contract", () => {
|
||||||
|
const notification = {
|
||||||
|
notification_id: id,
|
||||||
|
task_id: id,
|
||||||
|
kind: "action_approval_pending",
|
||||||
|
title: "待审批工单",
|
||||||
|
description: "请审核派单内容",
|
||||||
|
required_permission: null,
|
||||||
|
status: "unread",
|
||||||
|
version: 1,
|
||||||
|
created_at: time,
|
||||||
|
read_at: null,
|
||||||
|
read_by: null
|
||||||
|
};
|
||||||
|
|
||||||
|
it("keeps legacy notifications active by default", () => {
|
||||||
|
expect(parseOperationalTaskNotification(notification)).toMatchObject({
|
||||||
|
lifecycle_status: "active",
|
||||||
|
subject_type: "task",
|
||||||
|
subject_id: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts resolved notifications with a business subject", () => {
|
||||||
|
expect(parseOperationalTaskNotification({
|
||||||
|
...notification,
|
||||||
|
lifecycle_status: "resolved",
|
||||||
|
subject_type: "action",
|
||||||
|
subject_id: id,
|
||||||
|
resolved_at: time,
|
||||||
|
resolution_reason: "action_approved"
|
||||||
|
}).lifecycle_status).toBe("resolved");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests the active inbox instead of hiding already read work", async () => {
|
||||||
|
let request: Request | undefined;
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async (input, init) => {
|
||||||
|
request = new Request(input, init);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
items: [{
|
||||||
|
...notification,
|
||||||
|
lifecycle_status: "active",
|
||||||
|
status: "read",
|
||||||
|
version: 2,
|
||||||
|
read_at: time,
|
||||||
|
read_by: `principal:${id}`
|
||||||
|
}],
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
next_offset: null
|
||||||
|
}), { status: 200 });
|
||||||
|
}));
|
||||||
|
|
||||||
|
const items = await fetchOperationalTaskNotifications();
|
||||||
|
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0]?.status).toBe("read");
|
||||||
|
expect(new URL(request!.url).searchParams.get("unread_only")).toBe("false");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("reads persisted MCP evidence without expanding task access-request scopes", async () => {
|
||||||
|
const snapshot = { source: "query_model_baseline_series", source_ref: "mcp:fixture", queried_at: time,
|
||||||
|
origin: "system_data", data: { semantics: "model_baseline", data: [] }, truncated: true };
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => Response.json(snapshot)));
|
||||||
|
expect(await fetchOperationalTaskEvidenceSnapshot(id, "mcp:fixture")).toEqual(snapshot);
|
||||||
|
for (const source of ["run_simulation", "run_burst_detection", "run_burst_location", "analyze_valve_isolation", "run_leakage_identification", "run_contaminant_simulation", "analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation", "optimize_sensor_placement"]) {
|
||||||
|
const analysis = { ...snapshot, source, data: { semantics: "analysis", data: {} } };
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => Response.json(analysis)));
|
||||||
|
expect(await fetchOperationalTaskEvidenceSnapshot(id, "mcp:fixture")).toEqual(analysis);
|
||||||
|
}
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ ...snapshot, source: "arbitrary_tool" })));
|
||||||
|
await expect(fetchOperationalTaskEvidenceSnapshot(id, "mcp:fixture")).rejects.toThrow();
|
||||||
|
});
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report";
|
||||||
|
import { investigationPresentationCursorSchema } from "./investigation-presentation";
|
||||||
|
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||||
|
import { env } from "@/shared/config/env";
|
||||||
|
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
|
||||||
|
|
||||||
|
const taskStatusSchema = z.enum(["queued", "running", "waiting_human", "completed", "failed", "cancelled"]);
|
||||||
|
const taskPrioritySchema = z.enum(["normal", "urgent"]);
|
||||||
|
const workbenchDateTimeSchema = z.string().datetime({ offset: true });
|
||||||
|
const taskSourceSchema = z.object({
|
||||||
|
kind: z.enum(["schedule", "external_system", "operator", "agent"]),
|
||||||
|
external_id: z.string().min(1).nullable(),
|
||||||
|
external_revision: z.number().int().positive().nullable(),
|
||||||
|
event_kind: z.string().min(1).nullable(),
|
||||||
|
deleted_at: workbenchDateTimeSchema.nullish()
|
||||||
|
}).strict();
|
||||||
|
const taskTriggerSchema = z.object({
|
||||||
|
kind: z.enum(["schedule", "external_sync", "event", "manual"]),
|
||||||
|
occurred_at: workbenchDateTimeSchema,
|
||||||
|
detail: z.string().min(1)
|
||||||
|
}).strict();
|
||||||
|
const operationalTaskRunSchema = z.object({
|
||||||
|
run_id: z.string().uuid(),
|
||||||
|
task_id: z.string().uuid(),
|
||||||
|
generation: z.number().int().positive(),
|
||||||
|
execution_request_id: z.string().uuid(),
|
||||||
|
status: z.enum(["queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"]),
|
||||||
|
revision: z.number().int().positive(),
|
||||||
|
agent_session_id: z.string().min(1).nullable(),
|
||||||
|
lease_expires_at: workbenchDateTimeSchema.nullable(),
|
||||||
|
heartbeat_at: workbenchDateTimeSchema.nullable(),
|
||||||
|
started_at: workbenchDateTimeSchema.nullable(),
|
||||||
|
finished_at: workbenchDateTimeSchema.nullable(),
|
||||||
|
failure_code: z.string().min(1).nullable(),
|
||||||
|
failure_detail: z.string().min(1).nullable(),
|
||||||
|
dispatch_attempts: z.number().int().nonnegative(),
|
||||||
|
next_dispatch_at: workbenchDateTimeSchema,
|
||||||
|
created_at: workbenchDateTimeSchema,
|
||||||
|
updated_at: workbenchDateTimeSchema
|
||||||
|
}).strict();
|
||||||
|
const taskSummarySchema = z.object({
|
||||||
|
project_id: z.string().uuid(),
|
||||||
|
task_id: z.string().uuid(),
|
||||||
|
task_type: z.enum(["scheduled_flow", "external_work_order", "event_investigation"]),
|
||||||
|
workflow: z.string().min(1),
|
||||||
|
lifecycle_status: z.enum(["open", "waiting_human", "completed", "failed", "cancelled"]),
|
||||||
|
active_run: operationalTaskRunSchema.nullable(),
|
||||||
|
latest_run: operationalTaskRunSchema.nullable().optional(),
|
||||||
|
status: taskStatusSchema,
|
||||||
|
priority: taskPrioritySchema,
|
||||||
|
revision: z.number().int().positive(),
|
||||||
|
execution_generation: z.number().int().positive(),
|
||||||
|
execution_request_id: z.string().uuid(),
|
||||||
|
title: z.string().min(1),
|
||||||
|
summary: z.string().min(1),
|
||||||
|
source: taskSourceSchema,
|
||||||
|
trigger: taskTriggerSchema,
|
||||||
|
scheduled_for: workbenchDateTimeSchema.nullable(),
|
||||||
|
agent_session_id: z.string().min(1).nullable(),
|
||||||
|
agent_lease_expires_at: workbenchDateTimeSchema.nullable(),
|
||||||
|
current_plan_revision: z.number().int().positive().nullable(),
|
||||||
|
current_report_revision: z.number().int().positive().nullable(),
|
||||||
|
created_at: workbenchDateTimeSchema,
|
||||||
|
updated_at: workbenchDateTimeSchema
|
||||||
|
}).strict();
|
||||||
|
const evidenceSchema = z.object({
|
||||||
|
evidence_id: z.string().min(1),
|
||||||
|
kind: z.enum(["system_data", "external_feedback", "human_input", "synthetic"]),
|
||||||
|
summary: z.string().min(1),
|
||||||
|
source_label: z.string().min(1),
|
||||||
|
source_ref: z.string().min(1).nullable(),
|
||||||
|
observed_at: workbenchDateTimeSchema,
|
||||||
|
recorded_at: workbenchDateTimeSchema,
|
||||||
|
recorded_by: z.string().min(1),
|
||||||
|
has_snapshot: z.boolean()
|
||||||
|
}).strict();
|
||||||
|
const planNodeSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
title: z.string().min(1),
|
||||||
|
owner: z.enum(["agent", "human"]),
|
||||||
|
status: z.enum(["pending", "waiting", "running", "completed", "failed", "skipped", "cancelled"]),
|
||||||
|
detail: z.string().min(1),
|
||||||
|
evidence_refs: z.array(z.string().min(1)),
|
||||||
|
action_refs: z.array(z.string().uuid()),
|
||||||
|
kind: z.enum(["activity", "decision", "parallel_split", "parallel_join", "gate"]).nullish(),
|
||||||
|
selected_edge_id: z.string().min(1).nullish(),
|
||||||
|
gate_ref: z.string().uuid().nullish(),
|
||||||
|
revisits_node_id: z.string().min(1).nullish()
|
||||||
|
}).strict();
|
||||||
|
const planInputSchema = z.object({
|
||||||
|
schema: z.enum(["execution-plan@1", "execution-plan@2"]),
|
||||||
|
title: z.string().min(1),
|
||||||
|
reason: z.string().min(1),
|
||||||
|
nodes: z.array(planNodeSchema).min(1).max(100),
|
||||||
|
edges: z.array(z.object({
|
||||||
|
id: z.string().min(1).nullish(),
|
||||||
|
from: z.string().min(1),
|
||||||
|
to: z.string().min(1),
|
||||||
|
condition: z.string().min(1).nullable()
|
||||||
|
}).strict()).max(300)
|
||||||
|
}).strict();
|
||||||
|
const planSnapshotSchema = z.object({
|
||||||
|
revision: z.number().int().positive(),
|
||||||
|
execution_generation: z.number().int().positive().nullish(),
|
||||||
|
source_session_id: z.string().min(1),
|
||||||
|
published_at: workbenchDateTimeSchema,
|
||||||
|
published_by: z.string().min(1),
|
||||||
|
plan: planInputSchema
|
||||||
|
}).strict();
|
||||||
|
const reportInputSchema = investigationReportSchema;
|
||||||
|
const reportRevisionSchema = z.object({
|
||||||
|
revision: z.number().int().positive(),
|
||||||
|
execution_generation: z.number().int().positive().nullish(),
|
||||||
|
source_session_id: z.string().min(1),
|
||||||
|
published_at: workbenchDateTimeSchema,
|
||||||
|
published_by: z.string().min(1),
|
||||||
|
report: reportInputSchema
|
||||||
|
}).strict();
|
||||||
|
const progressSnapshotSchema = z.object({
|
||||||
|
revision: z.number().int().positive(),
|
||||||
|
run_id: z.string().uuid(),
|
||||||
|
execution_generation: z.number().int().positive(),
|
||||||
|
published_at: workbenchDateTimeSchema,
|
||||||
|
source_session_id: z.string().min(1),
|
||||||
|
progress: z.object({ summary: z.string().trim().min(1), diagram: investigationDiagramSchema.nullable() }).strict()
|
||||||
|
}).strict();
|
||||||
|
const reportPageSchema = z.object({
|
||||||
|
items: z.array(reportRevisionSchema),
|
||||||
|
next_offset: z.number().int().nonnegative().nullable(),
|
||||||
|
}).strict();
|
||||||
|
const actionReceiptSchema = z.object({
|
||||||
|
adapter: z.string().min(1),
|
||||||
|
receipt_id: z.string().min(1),
|
||||||
|
submitted_at: workbenchDateTimeSchema,
|
||||||
|
completed_at: workbenchDateTimeSchema.nullable(),
|
||||||
|
external_status: z.string().min(1),
|
||||||
|
detail: z.string().min(1)
|
||||||
|
}).strict();
|
||||||
|
const actionSchema = z.object({
|
||||||
|
action_id: z.string().uuid(),
|
||||||
|
task_id: z.string().uuid(),
|
||||||
|
version: z.number().int().positive(),
|
||||||
|
kind: z.enum(["create_work_order", "issue_command"]),
|
||||||
|
reason: z.string().min(1),
|
||||||
|
payload: z.record(z.string(), z.unknown()),
|
||||||
|
evidence_refs: z.array(z.string().min(1)),
|
||||||
|
node_id: z.string().min(1).nullable(),
|
||||||
|
required_approval_permission: z.string().min(1),
|
||||||
|
required_execution_permission: z.string().min(1),
|
||||||
|
approval_status: z.enum(["pending", "approved", "rejected"]),
|
||||||
|
execution_status: z.enum(["not_submitted", "submitted", "succeeded", "failed", "unsupported", "unknown"]),
|
||||||
|
execution_request_id: z.string().uuid().nullable(),
|
||||||
|
proposed_by: z.string().min(1),
|
||||||
|
proposed_at: workbenchDateTimeSchema,
|
||||||
|
edited_by: z.string().min(1).nullish(),
|
||||||
|
edited_at: workbenchDateTimeSchema.nullish(),
|
||||||
|
decided_by: z.string().min(1).nullable(),
|
||||||
|
decided_at: workbenchDateTimeSchema.nullable(),
|
||||||
|
decision_comment: z.string().min(1).nullable(),
|
||||||
|
executed_by: z.string().min(1).nullable(),
|
||||||
|
executed_at: workbenchDateTimeSchema.nullable(),
|
||||||
|
receipt: actionReceiptSchema.nullable(),
|
||||||
|
execution_detail: z.string().min(1).nullable()
|
||||||
|
}).strict();
|
||||||
|
const accessRequestSchema = z.object({
|
||||||
|
access_request_id: z.string().uuid(),
|
||||||
|
task_id: z.string().uuid(),
|
||||||
|
node_id: z.string().min(1),
|
||||||
|
scopes: z.array(z.string().min(1)),
|
||||||
|
reason: z.string().min(1),
|
||||||
|
status: z.enum(["pending", "approved", "rejected"]),
|
||||||
|
approved_scopes: z.array(z.string().min(1)),
|
||||||
|
version: z.number().int().positive(),
|
||||||
|
requested_by: z.string().min(1),
|
||||||
|
requested_at: workbenchDateTimeSchema,
|
||||||
|
decided_by: z.string().min(1).nullable(),
|
||||||
|
decided_at: workbenchDateTimeSchema.nullable(),
|
||||||
|
comment: z.string().min(1).nullable()
|
||||||
|
}).strict();
|
||||||
|
const taskPermissionsSchema = z.object({
|
||||||
|
can_publish_artifacts: z.boolean(),
|
||||||
|
can_propose_actions: z.boolean(),
|
||||||
|
can_sync_external_feedback: z.boolean(),
|
||||||
|
can_decide_work_orders: z.boolean(),
|
||||||
|
can_execute_work_orders: z.boolean(),
|
||||||
|
can_decide_commands: z.boolean(),
|
||||||
|
can_execute_commands: z.boolean(),
|
||||||
|
can_decide_access: z.boolean().default(false)
|
||||||
|
}).strict();
|
||||||
|
const externalWorkOrderActivitySchema = z.object({
|
||||||
|
sequence: z.number().int().positive(),
|
||||||
|
occurred_at: workbenchDateTimeSchema,
|
||||||
|
actor: z.string().min(1),
|
||||||
|
kind: z.enum(["created", "updated", "feedback"]),
|
||||||
|
title: z.string().min(1),
|
||||||
|
detail: z.string().min(1)
|
||||||
|
}).strict();
|
||||||
|
const externalWorkOrderSnapshotSchema = z.preprocess((value) => {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
const { subtasks: _legacySubtasks, ...flat } = value as Record<string, unknown>;
|
||||||
|
return flat;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}, z.object({
|
||||||
|
work_order_id: z.string().uuid(),
|
||||||
|
revision: z.number().int().positive(),
|
||||||
|
title: z.string().min(1),
|
||||||
|
summary: z.string().min(1),
|
||||||
|
status: z.enum(["draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"]),
|
||||||
|
priority: z.enum(["low", "normal", "high", "urgent"]),
|
||||||
|
assignee: z.string().min(1).nullable(),
|
||||||
|
scheduled_at: workbenchDateTimeSchema.nullable(),
|
||||||
|
work_order_type: z.enum(["general", "water_quality_complaint", "burst_report"]),
|
||||||
|
location: z.string().min(1).nullable(),
|
||||||
|
scenario_code: z.string().min(1).nullable(),
|
||||||
|
feedback_requirements: z.array(z.string().min(1)).default([]),
|
||||||
|
activity: z.array(externalWorkOrderActivitySchema).default([]),
|
||||||
|
origin: z.literal("synthetic"),
|
||||||
|
updated_at: workbenchDateTimeSchema
|
||||||
|
}).strict());
|
||||||
|
const operationalTaskSchema = taskSummarySchema.extend({
|
||||||
|
latest_plan: planSnapshotSchema.nullable(),
|
||||||
|
latest_report: reportRevisionSchema.nullable(),
|
||||||
|
latest_progress: progressSnapshotSchema.nullish(),
|
||||||
|
evidence: z.array(evidenceSchema),
|
||||||
|
actions: z.array(actionSchema),
|
||||||
|
access_requests: z.array(accessRequestSchema).default([]),
|
||||||
|
permissions: taskPermissionsSchema,
|
||||||
|
work_order_snapshot: externalWorkOrderSnapshotSchema.nullable().default(null)
|
||||||
|
}).strict();
|
||||||
|
const notificationSchema = z.object({
|
||||||
|
notification_id: z.string().uuid(),
|
||||||
|
task_id: z.string().uuid(),
|
||||||
|
kind: z.enum([
|
||||||
|
"task_created",
|
||||||
|
"action_pending",
|
||||||
|
"report_updated",
|
||||||
|
"task_completed",
|
||||||
|
"task_failed",
|
||||||
|
"run_delivery_degraded",
|
||||||
|
"access_approval_pending",
|
||||||
|
"action_approval_pending",
|
||||||
|
"human_input_required",
|
||||||
|
"external_coordination_failed"
|
||||||
|
]),
|
||||||
|
title: z.string().min(1),
|
||||||
|
description: z.string().min(1),
|
||||||
|
lifecycle_status: z.enum(["active", "resolved"]).default("active"),
|
||||||
|
subject_type: z.enum(["task", "run", "access_request", "action"]).default("task"),
|
||||||
|
subject_id: z.string().uuid().nullable().default(null),
|
||||||
|
report_reference: z.object({ execution_generation: z.number().int().positive(), revision: z.number().int().positive() }).strict().nullish(),
|
||||||
|
resolved_at: workbenchDateTimeSchema.nullable().default(null),
|
||||||
|
resolution_reason: z.string().min(1).nullable().default(null),
|
||||||
|
status: z.enum(["unread", "read"]),
|
||||||
|
version: z.number().int().positive(),
|
||||||
|
required_permission: z.string().min(1).nullable(),
|
||||||
|
created_at: workbenchDateTimeSchema,
|
||||||
|
read_at: workbenchDateTimeSchema.nullable(),
|
||||||
|
read_by: z.string().min(1).nullable()
|
||||||
|
}).strict();
|
||||||
|
const notificationPageSchema = z.object({
|
||||||
|
items: z.array(notificationSchema),
|
||||||
|
limit: z.number().int().positive(),
|
||||||
|
offset: z.number().int().nonnegative(),
|
||||||
|
next_offset: z.number().int().nonnegative().nullable()
|
||||||
|
}).strict();
|
||||||
|
const scheduleSchema = z.object({
|
||||||
|
schedule_id: z.string().uuid(),
|
||||||
|
schedule_key: z.string().min(1),
|
||||||
|
title: z.string().min(1),
|
||||||
|
summary: z.string().min(1),
|
||||||
|
interval_seconds: z.number().int().min(60).max(31_536_000),
|
||||||
|
next_run_at: workbenchDateTimeSchema,
|
||||||
|
priority: taskPrioritySchema,
|
||||||
|
enabled: z.boolean(),
|
||||||
|
version: z.number().int().positive(),
|
||||||
|
created_at: workbenchDateTimeSchema,
|
||||||
|
updated_at: workbenchDateTimeSchema
|
||||||
|
}).strict();
|
||||||
|
const schedulePageSchema = z.object({ items: z.array(scheduleSchema) }).strict();
|
||||||
|
const taskDataSnapshotSchema = z.object({
|
||||||
|
// Snapshot sources include product MCP queries; access-request scopes remain unchanged.
|
||||||
|
source: z.enum(["workbench_runtime", "scada_devices", "scada_readings", "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data",
|
||||||
|
"get_analysis_run",
|
||||||
|
"get_element_gis_properties",
|
||||||
|
"get_element_properties",
|
||||||
|
"get_model_baseline_at_time",
|
||||||
|
"get_network_options",
|
||||||
|
"get_network_schema",
|
||||||
|
"get_scada_device",
|
||||||
|
"get_sensor_placement_run",
|
||||||
|
"get_source_gis_properties",
|
||||||
|
"get_task_context",
|
||||||
|
"get_task_evidence",
|
||||||
|
"get_workbench_runtime",
|
||||||
|
"list_analysis_results",
|
||||||
|
"list_analysis_runs",
|
||||||
|
"list_elements",
|
||||||
|
"list_scada_devices",
|
||||||
|
"list_sensor_placement_runs",
|
||||||
|
"query_analysis_series",
|
||||||
|
"query_analysis_values",
|
||||||
|
"query_element_history",
|
||||||
|
"query_model_baseline_field",
|
||||||
|
"query_model_baseline_series",
|
||||||
|
"query_peripheral_data",
|
||||||
|
"query_scada_readings",
|
||||||
|
"run_burst_detection",
|
||||||
|
"run_simulation",
|
||||||
|
"run_contaminant_simulation",
|
||||||
|
"analyze_valve_closure",
|
||||||
|
"analyze_flushing",
|
||||||
|
"analyze_pressure_regulation",
|
||||||
|
"optimize_sensor_placement",
|
||||||
|
"run_burst_location",
|
||||||
|
"analyze_valve_isolation",
|
||||||
|
"run_leakage_identification",
|
||||||
|
]),
|
||||||
|
source_ref: z.string().min(1),
|
||||||
|
queried_at: workbenchDateTimeSchema,
|
||||||
|
origin: z.enum(["system_data", "synthetic"]),
|
||||||
|
data: z.unknown(),
|
||||||
|
truncated: z.boolean()
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export type OperationalTaskSummary = z.infer<typeof taskSummarySchema>;
|
||||||
|
export type OperationalTask = z.infer<typeof operationalTaskSchema>;
|
||||||
|
export type OperationalTaskPlan = z.infer<typeof planInputSchema>;
|
||||||
|
export type OperationalTaskAction = z.infer<typeof actionSchema>;
|
||||||
|
export type OperationalTaskAccessRequest = z.infer<typeof accessRequestSchema>;
|
||||||
|
export type OperationalTaskEvidence = z.infer<typeof evidenceSchema>;
|
||||||
|
export type OperationalTaskNotification = z.infer<typeof notificationSchema>;
|
||||||
|
export type OperationalTaskReport = z.infer<typeof reportRevisionSchema>;
|
||||||
|
export type OperationalTaskReportPage = z.infer<typeof reportPageSchema>;
|
||||||
|
export type OperationalTaskProgress = z.infer<typeof progressSnapshotSchema>;
|
||||||
|
export type OperationalTaskSchedule = z.infer<typeof scheduleSchema>;
|
||||||
|
export type OperationalTaskDataSnapshot = z.infer<typeof taskDataSnapshotSchema>;
|
||||||
|
export type ExternalWorkOrderSnapshot = z.infer<typeof externalWorkOrderSnapshotSchema>;
|
||||||
|
export type OperationalTaskDecision = "approved" | "rejected";
|
||||||
|
|
||||||
|
export function parseOperationalTask(value: unknown) {
|
||||||
|
return operationalTaskSchema.parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOperationalTaskSummary(value: unknown) {
|
||||||
|
return taskSummarySchema.parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOperationalTaskNotification(value: unknown) {
|
||||||
|
return notificationSchema.parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateOperationalTaskInput = {
|
||||||
|
request_id: string;
|
||||||
|
task_type: OperationalTask["task_type"];
|
||||||
|
workflow: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
priority: OperationalTask["priority"];
|
||||||
|
source: OperationalTask["source"];
|
||||||
|
trigger: OperationalTask["trigger"];
|
||||||
|
scheduled_for?: string;
|
||||||
|
agent_session_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
||||||
|
|
||||||
|
async function operationalTaskRequest(path: string, getAccessToken?: AccessTokenProvider, init?: RequestInit) {
|
||||||
|
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, init?.signal ?? undefined);
|
||||||
|
const headers = new Headers(createWorkbenchHeaders(accessToken, project.project_id));
|
||||||
|
if (init?.body) headers.set("Content-Type", "application/json");
|
||||||
|
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1${path}`, { ...init, headers });
|
||||||
|
const payload: unknown = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) throw new Error(getServerError(payload, response.status, "工况任务请求"));
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOperationalTask(taskId: string, getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
|
||||||
|
return operationalTaskSchema.parse(await operationalTaskRequest(
|
||||||
|
`/operational-tasks/${encodeURIComponent(taskId)}`,
|
||||||
|
getAccessToken,
|
||||||
|
{ signal }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOperationalTaskReports(taskId: string, offset: number, getAccessToken?: AccessTokenProvider) {
|
||||||
|
return reportPageSchema.parse(await operationalTaskRequest(
|
||||||
|
`/operational-tasks/${encodeURIComponent(taskId)}/reports?limit=20&offset=${offset}`, getAccessToken,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOperationalTaskPresentationCursor(taskId: string, getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
|
||||||
|
return investigationPresentationCursorSchema.parse(await operationalTaskRequest(
|
||||||
|
`/operational-tasks/${encodeURIComponent(taskId)}/presentation-cursor`, getAccessToken, { signal }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOperationalTask(input: CreateOperationalTaskInput, getAccessToken?: AccessTokenProvider) {
|
||||||
|
return operationalTaskSchema.parse(await operationalTaskRequest(
|
||||||
|
"/operational-tasks",
|
||||||
|
getAccessToken,
|
||||||
|
{ method: "POST", body: JSON.stringify(input) }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decideOperationalTaskAction(taskId: string, actionId: string, input: {
|
||||||
|
request_id: string;
|
||||||
|
expected_version: number;
|
||||||
|
decision: OperationalTaskDecision;
|
||||||
|
comment?: string;
|
||||||
|
}, getAccessToken?: AccessTokenProvider) {
|
||||||
|
return actionSchema.parse(await operationalTaskRequest(
|
||||||
|
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/decision`,
|
||||||
|
getAccessToken,
|
||||||
|
{ method: "POST", body: JSON.stringify(input) }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeOperationalTaskAction(taskId: string, actionId: string, input: {
|
||||||
|
request_id: string;
|
||||||
|
expected_version: number;
|
||||||
|
}, getAccessToken?: AccessTokenProvider) {
|
||||||
|
return actionSchema.parse(await operationalTaskRequest(
|
||||||
|
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/execute`,
|
||||||
|
getAccessToken,
|
||||||
|
{ method: "POST", body: JSON.stringify(input) }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOperationalWorkOrderDraft(taskId: string, actionId: string, input: {
|
||||||
|
request_id: string; expected_version: number; payload: Record<string, unknown>;
|
||||||
|
}, getAccessToken?: AccessTokenProvider) {
|
||||||
|
return actionSchema.parse(await operationalTaskRequest(
|
||||||
|
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/draft`,
|
||||||
|
getAccessToken, { method: "PATCH", body: JSON.stringify(input) }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmOperationalWorkOrderDispatch(taskId: string, actionId: string, input: {
|
||||||
|
request_id: string; expected_version: number;
|
||||||
|
}, getAccessToken?: AccessTokenProvider) {
|
||||||
|
return actionSchema.parse(await operationalTaskRequest(
|
||||||
|
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/confirm-dispatch`,
|
||||||
|
getAccessToken, { method: "POST", body: JSON.stringify(input) }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decideOperationalTaskAccess(taskId: string, accessRequestId: string, input: {
|
||||||
|
request_id: string; expected_version: number; decision: "approved" | "rejected";
|
||||||
|
approved_scopes: string[]; comment?: string;
|
||||||
|
}, getAccessToken?: AccessTokenProvider) {
|
||||||
|
return accessRequestSchema.parse(await operationalTaskRequest(
|
||||||
|
`/operational-tasks/${encodeURIComponent(taskId)}/access-requests/${encodeURIComponent(accessRequestId)}/decision`,
|
||||||
|
getAccessToken, { method: "POST", body: JSON.stringify(input) }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOperationalTaskNotifications(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
|
||||||
|
return notificationPageSchema.parse(await operationalTaskRequest(
|
||||||
|
"/operational-task-notifications?unread_only=false&limit=100&offset=0",
|
||||||
|
getAccessToken,
|
||||||
|
{ signal }
|
||||||
|
)).items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acknowledgeOperationalTaskNotification(notificationId: string, version: number, requestId: string, getAccessToken?: AccessTokenProvider) {
|
||||||
|
return notificationSchema.parse(await operationalTaskRequest(
|
||||||
|
`/operational-task-notifications/${encodeURIComponent(notificationId)}/acknowledge`,
|
||||||
|
getAccessToken,
|
||||||
|
{ method: "POST", body: JSON.stringify({ request_id: requestId, expected_version: version }) }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOperationalTaskSchedules(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
|
||||||
|
return schedulePageSchema.parse(await operationalTaskRequest(
|
||||||
|
"/operational-tasks/schedules",
|
||||||
|
getAccessToken,
|
||||||
|
{ signal }
|
||||||
|
)).items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOperationalTaskSchedule(input: {
|
||||||
|
request_id: string;
|
||||||
|
schedule_key: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
interval_seconds: number;
|
||||||
|
next_run_at: string;
|
||||||
|
priority: "normal" | "urgent";
|
||||||
|
}, getAccessToken?: AccessTokenProvider) {
|
||||||
|
return scheduleSchema.parse(await operationalTaskRequest(
|
||||||
|
"/operational-tasks/schedules",
|
||||||
|
getAccessToken,
|
||||||
|
{ method: "POST", body: JSON.stringify(input) }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconcileOperationalTaskAction(taskId: string, actionId: string, input: {
|
||||||
|
request_id: string;
|
||||||
|
expected_version: number;
|
||||||
|
}, getAccessToken?: AccessTokenProvider) {
|
||||||
|
return actionSchema.parse(await operationalTaskRequest(
|
||||||
|
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/reconcile`,
|
||||||
|
getAccessToken,
|
||||||
|
{ method: "POST", body: JSON.stringify(input) }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOperationalTaskEvidenceSnapshot(taskId: string, evidenceId: string, getAccessToken?: AccessTokenProvider) {
|
||||||
|
return taskDataSnapshotSchema.parse(await operationalTaskRequest(
|
||||||
|
`/operational-tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidenceId)}/snapshot`,
|
||||||
|
getAccessToken
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOperationalRequestId() {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { searchPlaces } from "./place-search-client";
|
||||||
|
|
||||||
|
const item = { id: "place-1", name: "南桥路", address: "奉贤区南桥路", longitude: 121.441891, latitude: 31.220188, category: "道路名", district: "奉贤区" };
|
||||||
|
const results = { items: [item], total: 1, truncated: false, region: "奉贤区" };
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
describe("Fengxian place search client", () => {
|
||||||
|
it("uses the new endpoint with trimmed keywords, identity and project headers", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(results));
|
||||||
|
expect(await searchPlaces(" 南桥路 ", "project-1", async () => "read-token", undefined)).toEqual(results);
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe("http://127.0.0.1:8000/api/v1/place-search-requests");
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(init?.body as string)).toEqual({ keyword: "南桥路" });
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
expect(headers.get("Authorization")).toBe("Bearer read-token");
|
||||||
|
expect(headers.get("X-Project-Id")).toBe("project-1");
|
||||||
|
expect(headers.get("Content-Type")).toBe("application/json");
|
||||||
|
});
|
||||||
|
it("accepts empty search results", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ...results, items: [], total: 0 }));
|
||||||
|
expect((await searchPlaces("未知位置", "project-1")).items).toEqual([]);
|
||||||
|
});
|
||||||
|
it("accepts at most ten Fengxian candidates", async () => {
|
||||||
|
const capped = { ...results, items: Array.from({ length: 10 }, (_, index) => ({ ...item, id: `place-${index}` })), total: 10, truncated: true };
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(capped));
|
||||||
|
expect(await searchPlaces("南桥路", "project-1")).toEqual(capped);
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
{ ...results, items: [{ ...item, longitude: 181 }] },
|
||||||
|
{ ...results, items: [{ ...item, latitude: 91 }] },
|
||||||
|
{ ...results, items: [item, item] },
|
||||||
|
{ ...results, region: "北京市" },
|
||||||
|
{ ...results, items: [{ ...item, district: "徐汇区" }] },
|
||||||
|
{ ...results, total: 0 },
|
||||||
|
{ ...results, total: 11 },
|
||||||
|
{ ...results, items: Array.from({ length: 11 }, (_, index) => ({ ...item, id: `place-${index}` })) },
|
||||||
|
{ status: "0", location: { lon: "121", lat: "31" } }
|
||||||
|
])("rejects malformed, mismatched or legacy responses: %j", async (payload) => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(payload));
|
||||||
|
await expect(searchPlaces("南桥路", "project-1")).rejects.toThrow("未获得有效的地名搜索结果");
|
||||||
|
});
|
||||||
|
it("does not fetch blank keywords", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch");
|
||||||
|
await expect(searchPlaces(" ", "project-1")).rejects.toThrow("请输入");
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it.each([403, 502, 503, 504])("preserves server failures for HTTP %s", async (status) => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ detail: "查询暂不可用" }, { status }));
|
||||||
|
await expect(searchPlaces("南桥路", "project-1")).rejects.toThrow("查询暂不可用");
|
||||||
|
});
|
||||||
|
it("does not fetch after cancellation during token refresh", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch");
|
||||||
|
const controller = new AbortController();
|
||||||
|
await expect(searchPlaces("南桥路", "project-1", async () => {
|
||||||
|
controller.abort(); return "read-token";
|
||||||
|
}, controller.signal)).rejects.toThrow();
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||||
|
import { env } from "@/shared/config/env";
|
||||||
|
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
|
||||||
|
|
||||||
|
export const MAX_PLACE_SEARCH_RESULTS = 10;
|
||||||
|
|
||||||
|
const placeSchema = z.object({
|
||||||
|
id: z.string().min(1), name: z.string().trim().min(1), address: z.string(),
|
||||||
|
longitude: z.number().finite().min(-180).max(180),
|
||||||
|
latitude: z.number().finite().min(-90).max(90),
|
||||||
|
category: z.string().nullable(), district: z.literal("奉贤区")
|
||||||
|
});
|
||||||
|
const resultsSchema = z.object({
|
||||||
|
items: z.array(placeSchema).max(MAX_PLACE_SEARCH_RESULTS), total: z.number().int().min(0).max(MAX_PLACE_SEARCH_RESULTS),
|
||||||
|
truncated: z.boolean().default(false), region: z.literal("奉贤区")
|
||||||
|
});
|
||||||
|
export type PlaceSearchItem = z.infer<typeof placeSchema>;
|
||||||
|
export type PlaceSearchResults = z.infer<typeof resultsSchema>;
|
||||||
|
|
||||||
|
export async function searchPlaces(
|
||||||
|
keyword: string,
|
||||||
|
projectId: string | null,
|
||||||
|
getAccessToken?: AccessTokenProvider,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PlaceSearchResults> {
|
||||||
|
const query = keyword.trim();
|
||||||
|
if (!query) throw new Error("请输入需要搜索的地址或地名");
|
||||||
|
if (query.length > 120) throw new Error("查询关键字不能超过120个字符");
|
||||||
|
const requestSignal = signal
|
||||||
|
? AbortSignal.any([signal, AbortSignal.timeout(20_000)])
|
||||||
|
: AbortSignal.timeout(20_000);
|
||||||
|
const ownedProject = projectId ? null : await findOwnedWorkbenchProject(getAccessToken, requestSignal);
|
||||||
|
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
|
||||||
|
requestSignal.throwIfAborted();
|
||||||
|
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
|
||||||
|
if (!resolvedProjectId) throw new Error("未找到地名搜索所属项目");
|
||||||
|
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/place-search-requests`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: createWorkbenchHeaders(accessToken, resolvedProjectId, true),
|
||||||
|
body: JSON.stringify({ keyword: query }),
|
||||||
|
signal: requestSignal
|
||||||
|
});
|
||||||
|
const payload: unknown = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) throw new Error(getServerError(payload, response.status, "地名搜索"));
|
||||||
|
const parsed = resultsSchema.safeParse(payload);
|
||||||
|
if (!parsed.success
|
||||||
|
|| new Set(parsed.data.items.map((item) => item.id)).size !== parsed.data.items.length
|
||||||
|
|| parsed.data.total !== parsed.data.items.length) {
|
||||||
|
throw new Error("未获得有效的地名搜索结果,请稍后重试");
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
fetchWorkbenchProjectPresentation,
|
||||||
|
findOwnedWorkbenchProject,
|
||||||
|
parseProjectMapExtent
|
||||||
|
} from "./project-client";
|
||||||
|
|
||||||
|
describe("workbench project client", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects the active project owned by the configured GeoServer workspace", async () => {
|
||||||
|
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({
|
||||||
|
items: [
|
||||||
|
{ project_id: "inactive", gs_workspace: "tjwater_next", status: "disabled" },
|
||||||
|
{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
limit: 1000,
|
||||||
|
offset: 0
|
||||||
|
}), { status: 200 }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await findOwnedWorkbenchProject(async () => "token");
|
||||||
|
|
||||||
|
expect(result.project.project_id).toBe("supply");
|
||||||
|
const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
|
||||||
|
expect(headers.get("Authorization")).toBe("Bearer token");
|
||||||
|
expect(headers.get("X-Project-Id")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not infer a project when the authorized list has no workspace match", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
|
||||||
|
items: [], total: 0, limit: 1000, offset: 0
|
||||||
|
}), { status: 200 })));
|
||||||
|
|
||||||
|
await expect(findOwnedWorkbenchProject()).rejects.toThrow("授权项目");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares project lookup while cancelling only the caller that aborts", async () => {
|
||||||
|
const projectPage = JSON.stringify({
|
||||||
|
items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }],
|
||||||
|
total: 1,
|
||||||
|
limit: 1000,
|
||||||
|
offset: 0
|
||||||
|
});
|
||||||
|
let resolveFetch!: (response: Response) => void;
|
||||||
|
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) =>
|
||||||
|
new Promise<Response>((resolve, reject) => {
|
||||||
|
resolveFetch = resolve;
|
||||||
|
init?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const getAccessToken = async () => null;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const cancelled = findOwnedWorkbenchProject(getAccessToken, controller.signal);
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||||
|
const active = findOwnedWorkbenchProject(getAccessToken);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(cancelled).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
resolveFetch(new Response(projectPage, { status: 200 }));
|
||||||
|
await expect(active).resolves.toMatchObject({ project: { project_id: "supply" } });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes tokens, expires project metadata and evicts failed shared lookups", async () => {
|
||||||
|
const clock = vi.spyOn(Date, "now").mockReturnValue(0);
|
||||||
|
const page = { items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }],
|
||||||
|
total: 1, limit: 1000, offset: 0 };
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ detail: "temporary" }), { status: 503 }))
|
||||||
|
.mockImplementation(async () => new Response(JSON.stringify(page), { status: 200 }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
let token = "first";
|
||||||
|
const provider = vi.fn(async () => token);
|
||||||
|
await expect(findOwnedWorkbenchProject(provider)).rejects.toThrow("temporary");
|
||||||
|
await findOwnedWorkbenchProject(provider);
|
||||||
|
token = "renewed";
|
||||||
|
expect((await findOwnedWorkbenchProject(provider)).accessToken).toBe("renewed");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
clock.mockReturnValue(5 * 60_000);
|
||||||
|
await findOwnedWorkbenchProject(provider);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
expect(new Headers(fetchMock.mock.calls[2][1].headers).get("Authorization")).toBe("Bearer renewed");
|
||||||
|
await findOwnedWorkbenchProject(async () => "another-identity");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads the authoritative project extent and access identity independently", async () => {
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const path = new URL(String(input)).pathname;
|
||||||
|
if (path === "/api/v1/projects") {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
items: [{
|
||||||
|
project_id: "supply",
|
||||||
|
name: "北区供水项目",
|
||||||
|
code: "north-supply",
|
||||||
|
gs_workspace: "tjwater_next",
|
||||||
|
status: "active",
|
||||||
|
project_role: "member"
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
limit: 1000,
|
||||||
|
offset: 0
|
||||||
|
}), { status: 200 });
|
||||||
|
}
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
expect(headers.get("Authorization")).toBe("Bearer token");
|
||||||
|
expect(headers.get("X-Project-Id")).toBe("supply");
|
||||||
|
if (path === "/api/v1/projects/current/metadata") {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
project_id: "supply",
|
||||||
|
name: "北区供水项目",
|
||||||
|
code: "north-supply",
|
||||||
|
gs_workspace: "tjwater_next",
|
||||||
|
map_extent: { bbox: [13358338.895, 3503549.844, 13369470.85, 3516410.23], zoom: 13 },
|
||||||
|
status: "active",
|
||||||
|
project_role: "member"
|
||||||
|
}), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
user_id: "user-1",
|
||||||
|
username: "north.operator",
|
||||||
|
system_role: "user",
|
||||||
|
is_system_admin: false,
|
||||||
|
project_id: "supply",
|
||||||
|
project_role: "member",
|
||||||
|
permissions: ["webgis.view"]
|
||||||
|
}), { status: 200 });
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const presentation = await fetchWorkbenchProjectPresentation(async () => "token");
|
||||||
|
|
||||||
|
expect(presentation).toMatchObject({
|
||||||
|
projectId: "supply",
|
||||||
|
projectName: "北区供水项目",
|
||||||
|
username: "north.operator",
|
||||||
|
projectRole: "member",
|
||||||
|
metadataAvailable: true,
|
||||||
|
accessContextAvailable: true,
|
||||||
|
networkView: {
|
||||||
|
bbox3857: [13358338.895, 3503549.844, 13369470.85, 3516410.23]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed or reversed project extents without losing the fallback path", () => {
|
||||||
|
expect(parseProjectMapExtent({ bbox: [10, 20, 5, 30] })).toBeNull();
|
||||||
|
expect(parseProjectMapExtent({ bbox: [0, 0, 30_000_000, 1] })).toBeNull();
|
||||||
|
expect(parseProjectMapExtent({ xmin: 1, ymin: 2, xmax: 3, ymax: 4 })).toEqual({
|
||||||
|
bbox3857: [1, 2, 3, 4]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||