Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88bbb1e13f | ||
|
|
1e6d08f5d4 | ||
|
|
c0bccbbaf2 |
+14
-3
@@ -1,4 +1,6 @@
|
|||||||
FROM node:24-alpine AS build
|
ARG NODE_IMAGE=node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
|
||||||
|
|
||||||
|
FROM ${NODE_IMAGE} AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
@@ -9,15 +11,24 @@ RUN pnpm install --frozen-lockfile
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
FROM caddy:2.10.2-alpine
|
FROM caddy:2.10.2-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d AS caddy-bin
|
||||||
|
|
||||||
RUN apk add --no-cache jq nodejs
|
FROM ${NODE_IMAGE} AS runner
|
||||||
|
|
||||||
|
RUN 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/drainage-frontend-entrypoint
|
COPY docker-entrypoint.sh /usr/local/bin/drainage-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/drainage-frontend/edge-tts-server.cjs
|
COPY --from=build /app/dist-server/edge-tts-server.cjs /usr/local/lib/drainage-frontend/edge-tts-server.cjs
|
||||||
RUN chmod 755 /usr/local/bin/drainage-frontend-entrypoint
|
RUN chmod 755 /usr/local/bin/drainage-frontend-entrypoint
|
||||||
|
|
||||||
|
WORKDIR /srv
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
ENTRYPOINT ["/usr/local/bin/drainage-frontend-entrypoint"]
|
ENTRYPOINT ["/usr/local/bin/drainage-frontend-entrypoint"]
|
||||||
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 状态语言
|
||||||
|
|
||||||
|
系统使用五种语义状态。业务模块保留自己的领域枚举,并在展示边界映射到共享
|
||||||
|
`StatusTone`,不要让展示文案、接口字符串或 Tailwind 原色类直接决定状态样式。
|
||||||
|
|
||||||
|
| Tone | 用途 | 典型状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `neutral` | 尚未开始或不再活动 | 预约、草案、待办、取消、中止、跳过、未分级 |
|
||||||
|
| `info` | 正在发生的过程 | 执行中、提交中、加载中、连接中 |
|
||||||
|
| `success` | 已完成或健康 | 完成、在线、正常、已批准 |
|
||||||
|
| `warning` | 需要关注但未达到异常 | 关注、降级、待复核、待复令、待确认 |
|
||||||
|
| `danger` | 已异常或操作失败 | 异常、失败、离线、高风险、事故、权限拒绝 |
|
||||||
|
|
||||||
|
## 报告风险
|
||||||
|
|
||||||
|
报告风险固定为三档:
|
||||||
|
|
||||||
|
- `normal` 映射 `success`,显示“正常”。
|
||||||
|
- `attention` 映射 `warning`,显示“关注”。
|
||||||
|
- `critical` 映射 `danger`,显示“异常”。
|
||||||
|
|
||||||
|
`info` 只表示过程,不参与风险等级。不得把 `attention` 合并到正常或异常。
|
||||||
|
|
||||||
|
## 表达规则
|
||||||
|
|
||||||
|
- 状态标签使用 `StatusBadge`,小型指示使用 `StatusDot`,图标节点使用 `StatusIcon`。
|
||||||
|
- 大面积容器只使用柔和背景,禁止实心红色或橙色状态卡片。
|
||||||
|
- `warning` 和 `danger` 必须同时提供文字或图标,不能只依赖颜色。
|
||||||
|
- 只有进行中的状态可以使用 `live` 或 `loading` 动效。
|
||||||
|
- “复令”是提交处置结果的动作,执行完成但尚未提交时显示“待复令”,使用静态
|
||||||
|
`warning`;不得继续显示为“执行中”或提前显示为完成态。
|
||||||
|
- 动效必须在 `prefers-reduced-motion` 下停止。
|
||||||
|
- 蓝色选中态属于交互反馈,不作为业务风险状态。
|
||||||
|
- 紫色只表示 Agent 身份,不表示未知或更高风险。
|
||||||
|
|
||||||
|
## 地图状态
|
||||||
|
|
||||||
|
地图与面板共享语义,但保留独立色值以适应浅色底图、影像底图和白色 casing。
|
||||||
|
新增地图状态时,应先补充 `map-colors.ts`,再通过对比度测试验证,不要在图层定义中
|
||||||
|
写入新的颜色字面量。
|
||||||
+6
-2
@@ -4,6 +4,9 @@
|
|||||||
"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",
|
||||||
"build": "tsc --noEmit && vite build && pnpm build:tts",
|
"build": "tsc --noEmit && vite build && pnpm build:tts",
|
||||||
@@ -21,6 +24,7 @@
|
|||||||
"@deck.gl/core": "^9.3.7",
|
"@deck.gl/core": "^9.3.7",
|
||||||
"@deck.gl/layers": "^9.3.7",
|
"@deck.gl/layers": "^9.3.7",
|
||||||
"@deck.gl/mapbox": "^9.3.7",
|
"@deck.gl/mapbox": "^9.3.7",
|
||||||
|
"@fontsource-variable/noto-sans-sc": "^5.3.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,7 +49,7 @@
|
|||||||
"edge-tts-ts": "^1.0.0",
|
"edge-tts-ts": "^1.0.0",
|
||||||
"katex": "^0.17.0",
|
"katex": "^0.17.0",
|
||||||
"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",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
@@ -72,10 +76,10 @@
|
|||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@vitejs/plugin-react-swc": "^4.0.2",
|
"@vitejs/plugin-react-swc": "^4.0.2",
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.16",
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
"esbuild": "^0.28.0",
|
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"msw": "^2.6.8",
|
"msw": "^2.6.8",
|
||||||
|
|||||||
Generated
+87
-167
@@ -20,6 +20,9 @@ importers:
|
|||||||
'@deck.gl/mapbox':
|
'@deck.gl/mapbox':
|
||||||
specifier: ^9.3.7
|
specifier: ^9.3.7
|
||||||
version: 9.3.7(@deck.gl/core@9.3.7)(@luma.gl/core@9.3.6)(@math.gl/web-mercator@4.1.0)
|
version: 9.3.7(@deck.gl/core@9.3.7)(@luma.gl/core@9.3.6)(@math.gl/web-mercator@4.1.0)
|
||||||
|
'@fontsource-variable/noto-sans-sc':
|
||||||
|
specifier: ^5.3.0
|
||||||
|
version: 5.3.0
|
||||||
'@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)
|
||||||
@@ -93,8 +96,8 @@ 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)
|
||||||
@@ -681,6 +684,9 @@ packages:
|
|||||||
'@floating-ui/utils@0.2.12':
|
'@floating-ui/utils@0.2.12':
|
||||||
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
||||||
|
|
||||||
|
'@fontsource-variable/noto-sans-sc@5.3.0':
|
||||||
|
resolution: {integrity: sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==}
|
||||||
|
|
||||||
'@humanfs/core@0.19.2':
|
'@humanfs/core@0.19.2':
|
||||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -801,34 +807,38 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@luma.gl/core': ~9.3.0
|
'@luma.gl/core': ~9.3.0
|
||||||
|
|
||||||
'@mapbox/geojson-rewind@0.5.2':
|
|
||||||
resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
'@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.2.0':
|
||||||
resolution: {integrity: sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==}
|
resolution: {integrity: sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==}
|
||||||
|
|
||||||
'@mapbox/unitbezier@0.0.1':
|
'@mapbox/tiny-sdf@2.3.0':
|
||||||
resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==}
|
resolution: {integrity: sha512-gBvcNDBBB66bgQYvnaJ6Jk+VCaMmVq5+IZR2kWPisT88Z8s0kBQt6hfVi8QgRW8Vpg0UWZzBA4ZSyzHHC+VeRA==}
|
||||||
|
|
||||||
'@mapbox/vector-tile@1.3.1':
|
'@mapbox/unitbezier@1.0.0':
|
||||||
resolution: {integrity: sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==}
|
resolution: {integrity: sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==}
|
||||||
|
|
||||||
'@mapbox/whoots-js@3.1.0':
|
'@mapbox/vector-tile@3.0.0':
|
||||||
resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==}
|
resolution: {integrity: sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==}
|
||||||
engines: {node: '>=6.0.0'}
|
|
||||||
|
|
||||||
'@maplibre/maplibre-gl-style-spec@20.4.0':
|
'@maplibre/geojson-vt@6.3.0':
|
||||||
resolution: {integrity: sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==}
|
resolution: {integrity: sha512-3Zw2jsU9pIJG3G2CQFbLjWj2MlAHodhD7cSY+15iywUuLmmkgzcKRnueVRZ4AcC/5wOtfhidcJAAzBpoBNs3Xg==}
|
||||||
|
|
||||||
|
'@maplibre/maplibre-gl-style-spec@26.4.4':
|
||||||
|
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==}
|
||||||
|
|
||||||
'@math.gl/core@4.1.0':
|
'@math.gl/core@4.1.0':
|
||||||
resolution: {integrity: sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==}
|
resolution: {integrity: sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==}
|
||||||
|
|
||||||
@@ -1819,9 +1829,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==}
|
||||||
|
|
||||||
@@ -1834,12 +1841,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==}
|
||||||
|
|
||||||
@@ -1852,9 +1853,6 @@ packages:
|
|||||||
'@types/offscreencanvas@2019.7.3':
|
'@types/offscreencanvas@2019.7.3':
|
||||||
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
|
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
|
||||||
|
|
||||||
'@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:
|
||||||
@@ -1869,9 +1867,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==}
|
||||||
|
|
||||||
@@ -2124,6 +2119,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==}
|
||||||
|
|
||||||
@@ -2478,8 +2476,8 @@ packages:
|
|||||||
earcut@2.2.4:
|
earcut@2.2.4:
|
||||||
resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==}
|
resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==}
|
||||||
|
|
||||||
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==}
|
||||||
@@ -2581,6 +2579,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: '*'
|
||||||
@@ -2718,9 +2717,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.*}
|
||||||
@@ -2741,10 +2737,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==}
|
||||||
|
|
||||||
@@ -2752,10 +2744,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'}
|
||||||
@@ -2861,9 +2849,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'}
|
||||||
@@ -2883,10 +2868,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==}
|
||||||
|
|
||||||
@@ -2934,10 +2915,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:
|
||||||
@@ -3009,10 +2986,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==}
|
||||||
|
|
||||||
@@ -3131,8 +3104,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:
|
||||||
@@ -3473,8 +3446,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:
|
||||||
@@ -3534,9 +3507,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==}
|
||||||
|
|
||||||
@@ -3649,6 +3619,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'}
|
||||||
@@ -3784,9 +3758,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'}
|
||||||
@@ -4068,9 +4039,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'}
|
||||||
@@ -4100,11 +4068,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'}
|
||||||
@@ -4671,6 +4634,8 @@ snapshots:
|
|||||||
|
|
||||||
'@floating-ui/utils@0.2.12': {}
|
'@floating-ui/utils@0.2.12': {}
|
||||||
|
|
||||||
|
'@fontsource-variable/noto-sans-sc@5.3.0': {}
|
||||||
|
|
||||||
'@humanfs/core@0.19.2':
|
'@humanfs/core@0.19.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@humanfs/types': 0.15.0
|
'@humanfs/types': 0.15.0
|
||||||
@@ -4817,35 +4782,45 @@ snapshots:
|
|||||||
'@math.gl/types': 4.1.0
|
'@math.gl/types': 4.1.0
|
||||||
'@probe.gl/env': 4.1.1
|
'@probe.gl/env': 4.1.1
|
||||||
|
|
||||||
'@mapbox/geojson-rewind@0.5.2':
|
|
||||||
dependencies:
|
|
||||||
get-stream: 6.0.1
|
|
||||||
minimist: 1.2.8
|
|
||||||
|
|
||||||
'@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.2.0': {}
|
||||||
|
|
||||||
'@mapbox/unitbezier@0.0.1': {}
|
'@mapbox/tiny-sdf@2.3.0': {}
|
||||||
|
|
||||||
'@mapbox/vector-tile@1.3.1':
|
'@mapbox/unitbezier@1.0.0': {}
|
||||||
|
|
||||||
|
'@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
|
||||||
|
|
||||||
'@math.gl/core@4.1.0':
|
'@math.gl/core@4.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@math.gl/types': 4.1.0
|
'@math.gl/types': 4.1.0
|
||||||
@@ -5789,10 +5764,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':
|
||||||
@@ -5803,14 +5774,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
|
||||||
@@ -5823,8 +5786,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/offscreencanvas@2019.7.3': {}
|
'@types/offscreencanvas@2019.7.3': {}
|
||||||
|
|
||||||
'@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
|
||||||
@@ -5839,10 +5800,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
|
||||||
|
|
||||||
@@ -6039,6 +5996,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
|
||||||
@@ -6404,7 +6365,7 @@ snapshots:
|
|||||||
|
|
||||||
earcut@2.2.4: {}
|
earcut@2.2.4: {}
|
||||||
|
|
||||||
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:
|
||||||
@@ -6658,8 +6619,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: {}
|
||||||
@@ -6684,20 +6643,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: {}
|
||||||
@@ -6879,8 +6830,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:
|
||||||
@@ -6894,8 +6843,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: {}
|
||||||
@@ -6929,8 +6876,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
|
||||||
@@ -7003,8 +6948,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: {}
|
||||||
@@ -7091,34 +7034,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: {}
|
||||||
|
|
||||||
@@ -7702,9 +7637,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: {}
|
||||||
@@ -7752,8 +7686,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):
|
||||||
@@ -7902,6 +7834,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:
|
||||||
@@ -8075,10 +8009,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
|
||||||
@@ -8329,12 +8259,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
|
||||||
@@ -8358,10 +8282,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
|
||||||
|
|||||||
+4
-4
@@ -82,7 +82,7 @@ test("preserves compact typography in header and agent controls", async ({ page
|
|||||||
await expect(approvalControl).toHaveCSS("font-size", "12px");
|
await expect(approvalControl).toHaveCSS("font-size", "12px");
|
||||||
await expect(approvalControl).toHaveCSS("line-height", "16px");
|
await expect(approvalControl).toHaveCSS("line-height", "16px");
|
||||||
await expect(suggestion).toHaveCSS("font-size", "12px");
|
await expect(suggestion).toHaveCSS("font-size", "12px");
|
||||||
await expect(suggestion).toHaveCSS("font-weight", "500");
|
await expect(suggestion).toHaveCSS("font-weight", "600");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lets interactive utilities override surface materials", async ({ page }) => {
|
test("lets interactive utilities override surface materials", async ({ page }) => {
|
||||||
@@ -102,7 +102,7 @@ test("lets interactive utilities override surface materials", async ({ page }) =
|
|||||||
expect(hoverBackground).not.toBe(restingBackground);
|
expect(hoverBackground).not.toBe(restingBackground);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preserves v3 menu and panel styling through Tailwind v4", async ({ page }) => {
|
test("keeps menu origin and integrated composer styling through Tailwind v4", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
@@ -118,6 +118,6 @@ test("preserves v3 menu and panel styling through Tailwind v4", async ({ page })
|
|||||||
const commandShadow = await agentCommandControl.evaluate(
|
const commandShadow = await agentCommandControl.evaluate(
|
||||||
(element) => window.getComputedStyle(element).boxShadow
|
(element) => window.getComputedStyle(element).boxShadow
|
||||||
);
|
);
|
||||||
expect(commandShadow).toContain("rgba(0, 0, 0, 0.05) 0px 1px 2px 0px");
|
expect(commandShadow).toBe("none");
|
||||||
expect(commandShadow).not.toContain("0px 1px 3px 0px");
|
await expect(agentCommandControl).toHaveCSS("border-top-width", "0px");
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { MapToaster } from "@/features/map/core/components/notice";
|
import { MapToaster } from "@/features/map/core";
|
||||||
import { MapWorkbenchPage } from "@/features/workbench";
|
import { MapWorkbenchPage } from "@/features/workbench";
|
||||||
import { AppProviders } from "./providers";
|
import { AppProviders } from "./providers";
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
|
|
||||||
import { AgentPersona } from "./agent-persona";
|
import { AgentPersona } from "./agent-persona";
|
||||||
|
|
||||||
type AgentCollapsedRailProps = {
|
type AgentCollapsedRailProps = {
|
||||||
@@ -23,8 +20,7 @@ export function AgentCollapsedRail({
|
|||||||
<aside
|
<aside
|
||||||
aria-label="Agent 折叠栏"
|
aria-label="Agent 折叠栏"
|
||||||
className={cn(
|
className={cn(
|
||||||
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] border p-1.5",
|
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] rounded-2xl border p-1.5"
|
||||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -32,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 transition-[background-color,transform] hover:bg-white active:scale-95"
|
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border hover:bg-white"
|
||||||
>
|
>
|
||||||
<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">
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -18,13 +16,14 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Square,
|
Square,
|
||||||
Volume2
|
Volume2,
|
||||||
|
X
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
|
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||||
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
import { showMapNotice } from "@/features/map/core";
|
||||||
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
|
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -49,7 +48,8 @@ import {
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@/shared/ui/dropdown-menu";
|
} from "@/shared/ui/dropdown-menu";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import { StatusBadge, StatusDot, type StatusTone } from "@/shared/ui/status";
|
||||||
import type { AgentChatSessionSummary } from "../api/client";
|
import type { AgentChatSessionSummary } from "../api/client";
|
||||||
import { AgentPersona } from "./agent-persona";
|
import { AgentPersona } from "./agent-persona";
|
||||||
import {
|
import {
|
||||||
@@ -72,6 +72,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
AgentApprovalMode,
|
AgentApprovalMode,
|
||||||
AgentChatMessage,
|
AgentChatMessage,
|
||||||
|
AgentConnectionStatus,
|
||||||
AgentModelOption,
|
AgentModelOption,
|
||||||
AgentPermissionReply,
|
AgentPermissionReply,
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
@@ -80,7 +81,10 @@ import type {
|
|||||||
AgentUiResult
|
AgentUiResult
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
|
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
|
||||||
|
|
||||||
type AgentCommandPanelProps = {
|
type AgentCommandPanelProps = {
|
||||||
|
presentation?: AgentCommandPanelPresentation;
|
||||||
collapsing?: boolean;
|
collapsing?: boolean;
|
||||||
personaState?: PersonaState;
|
personaState?: PersonaState;
|
||||||
sessionTitle?: string;
|
sessionTitle?: string;
|
||||||
@@ -88,6 +92,7 @@ type AgentCommandPanelProps = {
|
|||||||
sessionHistoryLoading?: boolean;
|
sessionHistoryLoading?: boolean;
|
||||||
activeSessionId?: string | null;
|
activeSessionId?: string | null;
|
||||||
statusLabel?: string;
|
statusLabel?: string;
|
||||||
|
connectionStatus?: AgentConnectionStatus;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
messages?: AgentChatMessage[];
|
messages?: AgentChatMessage[];
|
||||||
streamRenderState?: AgentStreamRenderState;
|
streamRenderState?: AgentStreamRenderState;
|
||||||
@@ -127,6 +132,7 @@ const AGENT_CAPABILITIES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function AgentCommandPanel({
|
export function AgentCommandPanel({
|
||||||
|
presentation = "desktop-dock",
|
||||||
collapsing = false,
|
collapsing = false,
|
||||||
personaState,
|
personaState,
|
||||||
sessionTitle = "新对话",
|
sessionTitle = "新对话",
|
||||||
@@ -134,6 +140,7 @@ export function AgentCommandPanel({
|
|||||||
sessionHistoryLoading = false,
|
sessionHistoryLoading = false,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
statusLabel = "Agent 后端连接中",
|
statusLabel = "Agent 后端连接中",
|
||||||
|
connectionStatus = "connecting",
|
||||||
streaming = false,
|
streaming = false,
|
||||||
messages = [],
|
messages = [],
|
||||||
streamRenderState = {},
|
streamRenderState = {},
|
||||||
@@ -159,6 +166,12 @@ export function AgentCommandPanel({
|
|||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||||
const [scrollRequestId, setScrollRequestId] = useState(0);
|
const [scrollRequestId, setScrollRequestId] = useState(0);
|
||||||
|
const historyPanelId = useId();
|
||||||
|
const historyTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const historyPanelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const desktopFloating = presentation === "desktop-floating";
|
||||||
|
const floatingPresentation = desktopFloating || presentation === "mobile-sheet";
|
||||||
|
const hasConversation = messages.length > 0 || streaming;
|
||||||
const trimmedPrompt = prompt.trim();
|
const trimmedPrompt = prompt.trim();
|
||||||
const {
|
const {
|
||||||
speechState,
|
speechState,
|
||||||
@@ -194,6 +207,46 @@ export function AgentCommandPanel({
|
|||||||
}
|
}
|
||||||
}, [historyOpen, onRefreshHistory]);
|
}, [historyOpen, onRefreshHistory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!historyOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeHistoryOnOutsidePointer = (event: PointerEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
historyPanelRef.current?.contains(target) ||
|
||||||
|
historyTriggerRef.current?.contains(target)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHistoryOpen(false);
|
||||||
|
};
|
||||||
|
const closeHistoryOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setHistoryOpen(false);
|
||||||
|
window.requestAnimationFrame(() => historyTriggerRef.current?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
|
||||||
|
document.addEventListener("keydown", closeHistoryOnEscape);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
|
||||||
|
document.removeEventListener("keydown", closeHistoryOnEscape);
|
||||||
|
};
|
||||||
|
}, [historyOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stopSpeech();
|
stopSpeech();
|
||||||
}, [activeSessionId, stopSpeech]);
|
}, [activeSessionId, stopSpeech]);
|
||||||
@@ -213,138 +266,217 @@ export function AgentCommandPanel({
|
|||||||
setPrompt(nextPrompt);
|
setPrompt(nextPrompt);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const historyPanel = (
|
||||||
|
<AgentHistoryPanel
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
loadingSessionId={loadingSessionId}
|
||||||
|
loading={sessionHistoryLoading}
|
||||||
|
sessions={sessionHistory}
|
||||||
|
onRefresh={onRefreshHistory}
|
||||||
|
onSelectSession={(nextSessionId) => {
|
||||||
|
setLoadingSessionId(nextSessionId);
|
||||||
|
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
|
||||||
|
.then(() => setHistoryOpen(false))
|
||||||
|
.finally(() => setLoadingSessionId(null));
|
||||||
|
}}
|
||||||
|
onRenameSession={onRenameHistorySession}
|
||||||
|
onDeleteSession={onDeleteHistorySession}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const operationalBrief = (
|
||||||
|
<AgentOperationalBrief
|
||||||
|
messages={messages}
|
||||||
|
statusLabel={statusLabel}
|
||||||
|
streaming={streaming}
|
||||||
|
onSubmitCommand={submitPrompt}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MotionConfig reducedMotion="user">
|
<MotionConfig reducedMotion="user">
|
||||||
<aside
|
<aside
|
||||||
aria-label="Agent 命令面板"
|
aria-label="Agent 命令面板"
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
|
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
|
||||||
"agent-panel-shell",
|
"agent-panel-shell",
|
||||||
"acrylic-panel border",
|
presentation === "desktop-dock"
|
||||||
"rounded-2xl",
|
? "acrylic-panel overflow-hidden rounded-r-2xl border-y border-r"
|
||||||
|
: desktopFloating
|
||||||
|
? "acrylic-panel overflow-visible rounded-2xl border"
|
||||||
|
: "overflow-hidden rounded-none border-0 bg-transparent",
|
||||||
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
|
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
|
<Agent
|
||||||
<header className="agent-panel-header">
|
className={cn(
|
||||||
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
|
"relative flex h-full min-h-0 flex-col border-0 bg-transparent",
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
|
floatingPresentation && "gap-1 overflow-visible p-1"
|
||||||
<AgentPersona className="h-12 w-12" state={personaState} />
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
>
|
||||||
<div className="min-w-0">
|
<div className="relative z-30 h-16 shrink-0">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<header
|
||||||
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
|
className={cn(
|
||||||
对话主题
|
"agent-panel-header agent-panel-header-expandable inset-x-0 top-0 overflow-hidden",
|
||||||
|
floatingPresentation && "agent-panel-integrated-header",
|
||||||
|
presentation === "mobile-sheet" && "agent-panel-header-mobile",
|
||||||
|
historyOpen && "agent-panel-header-expanded"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
|
||||||
|
<AgentPersona className="h-12 w-12" state={personaState} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
|
||||||
|
对话主题
|
||||||
|
</p>
|
||||||
|
<StatusBadge
|
||||||
|
tone={agentConnectionMetadata[connectionStatus].tone}
|
||||||
|
icon={
|
||||||
|
<StatusDot
|
||||||
|
tone={agentConnectionMetadata[connectionStatus].tone}
|
||||||
|
activity={connectionStatus === "connecting" ? "live" : "static"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{agentConnectionMetadata[connectionStatus].label}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="truncate text-sm font-semibold text-slate-950"
|
||||||
|
title={sessionTitle}
|
||||||
|
>
|
||||||
|
{sessionTitle}
|
||||||
</p>
|
</p>
|
||||||
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-2 text-xs font-semibold text-slate-600">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"h-1.5 w-1.5 rounded-full",
|
|
||||||
getAgentConnectionClassName(statusLabel)
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{getAgentConnectionLabel(statusLabel)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
|
||||||
className="truncate text-sm font-semibold text-slate-950"
|
|
||||||
title={sessionTitle}
|
|
||||||
>
|
|
||||||
{sessionTitle}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
aria-label="新建 Agent 对话"
|
||||||
aria-label="新建 Agent 对话"
|
title="新建 Agent 对话"
|
||||||
title="新建 Agent 对话"
|
onClick={() => {
|
||||||
onClick={() => {
|
setHistoryOpen(false);
|
||||||
setHistoryOpen(false);
|
setPrompt("");
|
||||||
setPrompt("");
|
void Promise.resolve(onStartNewSession?.());
|
||||||
void Promise.resolve(onStartNewSession?.());
|
}}
|
||||||
}}
|
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||||
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
|
||||||
>
|
|
||||||
<Plus size={17} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="打开 Agent 历史记录"
|
|
||||||
title="打开 Agent 历史记录"
|
|
||||||
onClick={() => setHistoryOpen((open) => !open)}
|
|
||||||
className={cn(
|
|
||||||
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
|
|
||||||
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<History size={17} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="折叠 Agent 面板"
|
|
||||||
title="折叠 Agent 面板"
|
|
||||||
onClick={onCollapse}
|
|
||||||
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={17} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{historyOpen ? (
|
|
||||||
<motion.div
|
|
||||||
key="history"
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
exit="exit"
|
|
||||||
variants={agentPanelSectionVariants}
|
|
||||||
style={{ overflow: "hidden" }}
|
|
||||||
>
|
>
|
||||||
<AgentHistoryPanel
|
<Plus size={17} aria-hidden="true" />
|
||||||
activeSessionId={activeSessionId}
|
</button>
|
||||||
loadingSessionId={loadingSessionId}
|
<button
|
||||||
loading={sessionHistoryLoading}
|
ref={historyTriggerRef}
|
||||||
sessions={sessionHistory}
|
type="button"
|
||||||
onRefresh={onRefreshHistory}
|
aria-label="打开 Agent 历史记录"
|
||||||
onSelectSession={(nextSessionId) => {
|
title="打开 Agent 历史记录"
|
||||||
setLoadingSessionId(nextSessionId);
|
aria-expanded={historyOpen}
|
||||||
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
|
aria-controls={historyPanelId}
|
||||||
.then(() => setHistoryOpen(false))
|
onClick={() => setHistoryOpen((open) => !open)}
|
||||||
.finally(() => setLoadingSessionId(null));
|
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||||
}}
|
>
|
||||||
onRenameSession={onRenameHistorySession}
|
<History size={17} aria-hidden="true" />
|
||||||
onDeleteSession={onDeleteHistorySession}
|
</button>
|
||||||
/>
|
{presentation === "mobile-sheet" ? (
|
||||||
</motion.div>
|
<button
|
||||||
) : null}
|
type="button"
|
||||||
</AnimatePresence>
|
aria-label="关闭 Agent 面板"
|
||||||
|
title="关闭 Agent 面板"
|
||||||
|
onClick={onCollapse}
|
||||||
|
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||||
|
>
|
||||||
|
<X size={17} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="折叠 Agent 面板"
|
||||||
|
title="折叠 Agent 面板"
|
||||||
|
onClick={onCollapse}
|
||||||
|
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={17} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{historyOpen ? (
|
||||||
|
<motion.div
|
||||||
|
ref={historyPanelRef}
|
||||||
|
key="history"
|
||||||
|
id={historyPanelId}
|
||||||
|
role="region"
|
||||||
|
aria-label="Agent 历史记录"
|
||||||
|
className="agent-panel-floating-acrylic agent-panel-history-extension"
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
variants={agentPanelSectionVariants}
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
{historyPanel}
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{floatingPresentation ? (
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{messages.length > 0 || streaming ? (
|
{hasConversation && !historyOpen ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="operational-brief"
|
key="operational-brief"
|
||||||
className="space-y-2 px-3 pb-3"
|
className="agent-panel-operational-float absolute z-20 overflow-hidden rounded-2xl"
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
variants={agentPanelSectionVariants}
|
||||||
|
>
|
||||||
|
{operationalBrief}
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
) : (
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{hasConversation ? (
|
||||||
|
<motion.div
|
||||||
|
key="operational-brief"
|
||||||
|
aria-hidden={historyOpen}
|
||||||
|
className={cn(
|
||||||
|
"space-y-2 px-3 pb-3",
|
||||||
|
historyOpen && "invisible pointer-events-none"
|
||||||
|
)}
|
||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
variants={agentPanelSectionVariants}
|
variants={agentPanelSectionVariants}
|
||||||
style={{ overflow: "hidden" }}
|
style={{ overflow: "hidden" }}
|
||||||
>
|
>
|
||||||
<AgentOperationalBrief
|
{operationalBrief}
|
||||||
messages={messages}
|
|
||||||
statusLabel={statusLabel}
|
|
||||||
streaming={streaming}
|
|
||||||
onSubmitCommand={submitPrompt}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</header>
|
)}
|
||||||
|
|
||||||
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||||
<Conversation className="agent-panel-conversation min-h-0">
|
<Conversation
|
||||||
|
className={cn(
|
||||||
|
"agent-panel-conversation min-h-0",
|
||||||
|
floatingPresentation && "agent-panel-conversation-canvas"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<ConversationContent
|
<ConversationContent
|
||||||
className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}
|
className={cn(
|
||||||
|
"gap-4 p-3",
|
||||||
|
messages.length === 0 && "min-h-full",
|
||||||
|
floatingPresentation && "agent-panel-conversation-content",
|
||||||
|
floatingPresentation &&
|
||||||
|
hasConversation &&
|
||||||
|
"agent-panel-conversation-content-has-brief",
|
||||||
|
floatingPresentation &&
|
||||||
|
!hasConversation &&
|
||||||
|
"agent-panel-conversation-content-empty"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<AnimatePresence mode="popLayout" initial={false}>
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
{messages.length > 0 ? (
|
{messages.length > 0 ? (
|
||||||
@@ -373,7 +505,7 @@ export function AgentCommandPanel({
|
|||||||
exit="exit"
|
exit="exit"
|
||||||
variants={agentPanelItemVariants}
|
variants={agentPanelItemVariants}
|
||||||
>
|
>
|
||||||
<AgentEmptyState />
|
<AgentEmptyState compact={presentation === "mobile-sheet"} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -384,14 +516,32 @@ export function AgentCommandPanel({
|
|||||||
activeSessionId={activeSessionId}
|
activeSessionId={activeSessionId}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
scrollRequestId={scrollRequestId}
|
scrollRequestId={scrollRequestId}
|
||||||
|
streaming={streaming}
|
||||||
|
/>
|
||||||
|
<ConversationScrollButton
|
||||||
|
className={cn(
|
||||||
|
"agent-panel-floating-acrylic agent-conversation-scroll-button",
|
||||||
|
floatingPresentation ? "bottom-[10.25rem] z-30" : "bottom-3"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<ConversationScrollButton className="bottom-3" />
|
|
||||||
</Conversation>
|
</Conversation>
|
||||||
</AgentContent>
|
</AgentContent>
|
||||||
|
|
||||||
<div className="agent-panel-band relative border-t border-slate-200 p-3">
|
<div
|
||||||
{messages.length === 0 && !streaming ? (
|
className={cn(
|
||||||
<div className="agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
|
"agent-panel-band relative",
|
||||||
|
floatingPresentation
|
||||||
|
? "agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
|
||||||
|
: "border-t border-slate-200 p-3"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!hasConversation ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2",
|
||||||
|
floatingPresentation && "bg-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Suggestions
|
<Suggestions
|
||||||
aria-label="推荐问题"
|
aria-label="推荐问题"
|
||||||
role="group"
|
role="group"
|
||||||
@@ -411,7 +561,7 @@ export function AgentCommandPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<PromptInput
|
<PromptInput
|
||||||
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs transition-[border-color,box-shadow] has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:ring-0!"
|
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:ring-0!"
|
||||||
onSubmit={(message) => {
|
onSubmit={(message) => {
|
||||||
const nextPrompt = message.text.trim();
|
const nextPrompt = message.text.trim();
|
||||||
if (nextPrompt) {
|
if (nextPrompt) {
|
||||||
@@ -423,7 +573,7 @@ export function AgentCommandPanel({
|
|||||||
<PromptInputTextarea
|
<PromptInputTextarea
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||||||
className="min-h-14 text-sm"
|
className={cn("text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
|
||||||
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
|
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
|
||||||
/>
|
/>
|
||||||
</PromptInputBody>
|
</PromptInputBody>
|
||||||
@@ -435,7 +585,7 @@ export function AgentCommandPanel({
|
|||||||
onValueChange={onApprovalModeChange}
|
onValueChange={onApprovalModeChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<AgentModelSelect
|
<AgentModelSelect
|
||||||
models={modelOptions}
|
models={modelOptions}
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
@@ -458,7 +608,13 @@ export function AgentCommandPanel({
|
|||||||
status={streaming ? "streaming" : "ready"}
|
status={streaming ? "streaming" : "ready"}
|
||||||
onStop={onStopPrompt}
|
onStop={onStopPrompt}
|
||||||
>
|
>
|
||||||
{streaming ? undefined : <SendHorizontal size={15} aria-hidden="true" />}
|
{streaming ? undefined : (
|
||||||
|
<SendHorizontal
|
||||||
|
size={15}
|
||||||
|
style={{ transform: "rotate(-45deg)" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</PromptInputSubmit>
|
</PromptInputSubmit>
|
||||||
</div>
|
</div>
|
||||||
</PromptInputFooter>
|
</PromptInputFooter>
|
||||||
@@ -497,29 +653,38 @@ function VoiceInputButton({
|
|||||||
aria-pressed={isListening}
|
aria-pressed={isListening}
|
||||||
title={label}
|
title={label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border",
|
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border after:absolute after:-inset-1 after:content-['']",
|
||||||
"border-transparent bg-transparent text-blue-600 shadow-none",
|
"border-transparent bg-transparent shadow-none",
|
||||||
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
|
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
|
||||||
"hover:bg-slate-50 hover:text-blue-700",
|
|
||||||
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1",
|
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1",
|
||||||
"disabled:cursor-not-allowed disabled:opacity-45",
|
"disabled:cursor-not-allowed disabled:opacity-45",
|
||||||
isListening && "bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
|
isListening
|
||||||
|
? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
|
||||||
|
: "text-slate-600 [@media(hover:hover)]:hover:border-[var(--action-border-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
whileTap={reduceMotion ? undefined : { scale: 0.94 }}
|
|
||||||
>
|
>
|
||||||
{isListening ? (
|
{isListening ? (
|
||||||
<motion.span
|
<motion.span
|
||||||
data-slot="voice-input-pulse"
|
data-slot="voice-input-pulse"
|
||||||
className="pointer-events-none absolute inset-0 rounded-lg bg-red-400/20"
|
className="pointer-events-none absolute inset-0 rounded-lg bg-[var(--status-mark)] opacity-20"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
animate={
|
animate={
|
||||||
reduceMotion ? { opacity: 0.38, scale: 1 } : { opacity: [0.4, 0], scale: [1, 1.28] }
|
reduceMotion
|
||||||
|
? { opacity: 0.38, scale: 1 }
|
||||||
|
: { opacity: [0, 0.4, 0], scale: [0.9, 1, 1.28] }
|
||||||
}
|
}
|
||||||
transition={
|
transition={
|
||||||
reduceMotion ? { duration: 0.16 } : { duration: 1.7, ease: "easeOut", repeat: Infinity }
|
reduceMotion
|
||||||
|
? { duration: 0.16 }
|
||||||
|
: {
|
||||||
|
duration: 1.7,
|
||||||
|
ease: "easeOut",
|
||||||
|
repeat: Infinity,
|
||||||
|
times: [0, 0.12, 1]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -539,7 +704,7 @@ function VoiceInputButton({
|
|||||||
{VOICE_WAVE_BARS.map((bar) => (
|
{VOICE_WAVE_BARS.map((bar) => (
|
||||||
<motion.span
|
<motion.span
|
||||||
key={bar.height}
|
key={bar.height}
|
||||||
className="w-px rounded-full bg-red-500"
|
className="w-px rounded-full bg-[var(--status-mark)]"
|
||||||
style={{ height: bar.height }}
|
style={{ height: bar.height }}
|
||||||
animate={
|
animate={
|
||||||
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
|
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
|
||||||
@@ -598,7 +763,8 @@ function ApprovalModeSelect({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 shrink-0 gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
size="sm"
|
||||||
|
className="shrink-0 gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
||||||
disabled={disabled || !onValueChange}
|
disabled={disabled || !onValueChange}
|
||||||
aria-label="权限批准模式"
|
aria-label="权限批准模式"
|
||||||
>
|
>
|
||||||
@@ -639,17 +805,24 @@ function ApprovalModeSelect({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-1 items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
|
"mt-1 items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
|
||||||
!requestApproval && "border-emerald-200 bg-emerald-50 focus:bg-emerald-50"
|
!requestApproval &&
|
||||||
|
"status-tone-success border-[var(--status-border)] bg-[var(--status-soft)] focus:bg-[var(--status-soft)]"
|
||||||
)}
|
)}
|
||||||
onSelect={() => onValueChange?.("always")}
|
onSelect={() => onValueChange?.("always")}
|
||||||
>
|
>
|
||||||
<ShieldCheck className="mt-0.5 text-emerald-600" aria-hidden="true" />
|
<ShieldCheck
|
||||||
|
className="status-tone-success mt-0.5 text-[var(--status-foreground)]"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<span>
|
<span>
|
||||||
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
|
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
|
||||||
<span className="block text-xs leading-5 text-slate-500">自动允许本轮权限请求</span>
|
<span className="block text-xs leading-5 text-slate-500">自动允许本轮权限请求</span>
|
||||||
</span>
|
</span>
|
||||||
{!requestApproval ? (
|
{!requestApproval ? (
|
||||||
<CheckCircle2 className="ml-auto mt-0.5 text-emerald-600" aria-hidden="true" />
|
<CheckCircle2
|
||||||
|
className="status-tone-success ml-auto mt-0.5 text-[var(--status-foreground)]"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -659,29 +832,36 @@ function ApprovalModeSelect({
|
|||||||
|
|
||||||
type AgentConversationScrollSnapshot = {
|
type AgentConversationScrollSnapshot = {
|
||||||
activeSessionId?: string | null;
|
activeSessionId?: string | null;
|
||||||
|
lastMessageRevision: string;
|
||||||
lastUserMessageId: string | null;
|
lastUserMessageId: string | null;
|
||||||
scrollRequestId: number;
|
scrollRequestId: number;
|
||||||
|
streaming: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function AgentConversationScrollManager({
|
function AgentConversationScrollManager({
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
messages,
|
messages,
|
||||||
scrollRequestId
|
scrollRequestId,
|
||||||
|
streaming
|
||||||
}: {
|
}: {
|
||||||
activeSessionId?: string | null;
|
activeSessionId?: string | null;
|
||||||
messages: AgentChatMessage[];
|
messages: AgentChatMessage[];
|
||||||
scrollRequestId: number;
|
scrollRequestId: number;
|
||||||
|
streaming: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { scrollToBottom } = useStickToBottomContext();
|
const { scrollToBottom } = useStickToBottomContext();
|
||||||
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
|
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
|
||||||
const lastUserMessageId = getLastUserMessageId(messages);
|
const lastUserMessageId = getLastUserMessageId(messages);
|
||||||
|
const lastMessageRevision = getLastMessageRevision(messages);
|
||||||
const snapshot = useMemo<AgentConversationScrollSnapshot>(
|
const snapshot = useMemo<AgentConversationScrollSnapshot>(
|
||||||
() => ({
|
() => ({
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
|
lastMessageRevision,
|
||||||
lastUserMessageId,
|
lastUserMessageId,
|
||||||
scrollRequestId
|
scrollRequestId,
|
||||||
|
streaming
|
||||||
}),
|
}),
|
||||||
[activeSessionId, lastUserMessageId, scrollRequestId]
|
[activeSessionId, lastMessageRevision, lastUserMessageId, scrollRequestId, streaming]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -693,7 +873,10 @@ function AgentConversationScrollManager({
|
|||||||
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
|
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
|
||||||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
|
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
|
||||||
(Boolean(snapshot.lastUserMessageId) &&
|
(Boolean(snapshot.lastUserMessageId) &&
|
||||||
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
|
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId) ||
|
||||||
|
(snapshot.streaming &&
|
||||||
|
previousSnapshot.lastMessageRevision !== snapshot.lastMessageRevision) ||
|
||||||
|
previousSnapshot.streaming !== snapshot.streaming;
|
||||||
|
|
||||||
if (!forceScroll) {
|
if (!forceScroll) {
|
||||||
return;
|
return;
|
||||||
@@ -713,6 +896,19 @@ function AgentConversationScrollManager({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLastMessageRevision(messages: AgentChatMessage[]) {
|
||||||
|
const lastMessage = messages.at(-1);
|
||||||
|
|
||||||
|
if (!lastMessage) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressRevision =
|
||||||
|
lastMessage.progress?.map((item) => `${item.id}:${item.status}`).join("|") ?? "";
|
||||||
|
|
||||||
|
return `${lastMessage.id}:${lastMessage.content.length}:${progressRevision}`;
|
||||||
|
}
|
||||||
|
|
||||||
function getLastUserMessageId(messages: AgentChatMessage[]) {
|
function getLastUserMessageId(messages: AgentChatMessage[]) {
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
if (messages[index].role === "user") {
|
if (messages[index].role === "user") {
|
||||||
@@ -746,7 +942,8 @@ function AgentModelSelect({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 w-24 shrink-0 justify-between gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
size="sm"
|
||||||
|
className="w-24 shrink-0 justify-between gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
||||||
disabled={!onValueChange}
|
disabled={!onValueChange}
|
||||||
aria-label="选择 Agent 模型"
|
aria-label="选择 Agent 模型"
|
||||||
>
|
>
|
||||||
@@ -801,13 +998,11 @@ function AgentModelSelect({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAgentConnectionLabel(statusLabel: string) {
|
const agentConnectionMetadata = {
|
||||||
return /失败|不可用|错误|降级/.test(statusLabel) ? "离线" : "在线";
|
connecting: { label: "连接中", tone: "info" },
|
||||||
}
|
online: { label: "在线", tone: "success" },
|
||||||
|
offline: { label: "离线", tone: "danger" }
|
||||||
function getAgentConnectionClassName(statusLabel: string) {
|
} satisfies Record<AgentConnectionStatus, { label: string; tone: StatusTone }>;
|
||||||
return /失败|不可用|错误|降级/.test(statusLabel) ? "bg-red-500" : "bg-emerald-500";
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) {
|
function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) {
|
||||||
return model?.icon === "bolt" ? <FastModelIcon size={size} /> : <ExpertModelIcon size={size} />;
|
return model?.icon === "bolt" ? <FastModelIcon size={size} /> : <ExpertModelIcon size={size} />;
|
||||||
@@ -875,50 +1070,66 @@ function AgentReadyMark() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentEmptyState() {
|
function AgentEmptyState({ compact = false }: { compact?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-labelledby="agent-empty-state-title"
|
aria-labelledby="agent-empty-state-title"
|
||||||
className="surface-reading mx-auto w-full max-w-[360px] overflow-hidden rounded-2xl border border-slate-200/80 shadow-[0_1px_2px_rgba(15,23,42,0.06),0_12px_32px_rgba(15,23,42,0.07)]"
|
className="mx-auto w-full max-w-[360px]"
|
||||||
lang="zh-CN"
|
lang="zh-CN"
|
||||||
>
|
>
|
||||||
<div className="px-4 py-5">
|
<div className={cn("px-2", compact ? "py-2" : "py-4")}>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="grid h-[72px] w-[72px] shrink-0 place-items-center rounded-2xl bg-[oklch(0.97_0.018_292)] shadow-[inset_0_0_0_1px_oklch(0.91_0.03_292),0_12px_30px_rgba(88,71,120,0.10)]">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"surface-control grid shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]",
|
||||||
|
compact ? "h-14 w-14" : "h-[72px] w-[72px]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<AgentReadyMark />
|
<AgentReadyMark />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 text-left">
|
<div className="min-w-0 flex-1 text-left">
|
||||||
<h2
|
<h2
|
||||||
id="agent-empty-state-title"
|
id="agent-empty-state-title"
|
||||||
className="text-balance text-lg font-semibold leading-7 text-slate-900"
|
className={cn(
|
||||||
|
"text-balance font-semibold text-slate-950",
|
||||||
|
compact ? "text-base leading-6" : "text-lg leading-7"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
我已就绪,请描述任务
|
我已就绪,请描述任务
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-5 text-pretty text-left text-sm leading-6 text-slate-500">
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-pretty text-left text-sm leading-6 text-[#334155]",
|
||||||
|
compact ? "mt-3" : "mt-5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。
|
直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul
|
<ul
|
||||||
className="grid grid-cols-2 border-t border-slate-200/70 bg-slate-50/55"
|
className={cn(
|
||||||
|
"surface-control grid grid-cols-2 overflow-hidden rounded-xl shadow-[inset_0_0_0_1px_rgba(148,163,184,0.32)]",
|
||||||
|
compact && "hidden"
|
||||||
|
)}
|
||||||
aria-label="Agent 分析能力"
|
aria-label="Agent 分析能力"
|
||||||
>
|
>
|
||||||
{AGENT_CAPABILITIES.map(({ icon: Icon, label }, index) => (
|
{AGENT_CAPABILITIES.map(({ icon: Icon, label }, index) => (
|
||||||
<li
|
<li
|
||||||
key={label}
|
key={label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[72px] items-center gap-3 px-3.5 py-3 text-left",
|
"flex min-h-20 items-center gap-3 px-3.5 py-3 text-left",
|
||||||
index % 2 === 0 && "border-r border-slate-200/70",
|
index % 2 === 0 && "border-r border-slate-300/80",
|
||||||
index < 2 && "border-b border-slate-200/70"
|
index < 2 && "border-b border-slate-300/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-600">
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[#f9fbfe] text-[#334155] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]">
|
||||||
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
|
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-medium leading-5 text-slate-700">{label}</span>
|
<span className="text-sm font-semibold leading-5 text-[#1e293b]">{label}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -980,9 +1191,10 @@ function BackendMessageList({
|
|||||||
>
|
>
|
||||||
<MessageContent
|
<MessageContent
|
||||||
className={cn(
|
className={cn(
|
||||||
message.role === "user" && "bg-blue-600 px-3 py-2 text-white",
|
message.role === "user" &&
|
||||||
|
"agent-panel-user-message rounded-2xl px-3 py-3 text-slate-800",
|
||||||
message.role === "assistant" &&
|
message.role === "assistant" &&
|
||||||
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700 shadow-xs"
|
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -1168,21 +1380,16 @@ function AgentSpeechMessage({
|
|||||||
<motion.button
|
<motion.button
|
||||||
key="speech-selection-action"
|
key="speech-selection-action"
|
||||||
type="button"
|
type="button"
|
||||||
className="glass-transient pointer-events-auto inline-flex h-10 origin-bottom items-center gap-2 rounded-xl border px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
className="agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
||||||
initial={{ opacity: 0, y: 8, scale: 0.95 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{
|
animate={{
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
transition: { duration: 0.12, ease: [0.16, 1, 0.3, 1] }
|
||||||
scale: 1,
|
|
||||||
transition: { duration: 0.15, ease: [0.16, 1, 0.3, 1] }
|
|
||||||
}}
|
}}
|
||||||
exit={{
|
exit={{
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
y: 0,
|
transition: { duration: 0.08, ease: [0.4, 0, 1, 1] }
|
||||||
scale: 0.95,
|
|
||||||
transition: { duration: 0.1, ease: [0.4, 0, 1, 1] }
|
|
||||||
}}
|
}}
|
||||||
whileTap={{ scale: 0.96 }}
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
onClick={speakFromSelection}
|
onClick={speakFromSelection}
|
||||||
>
|
>
|
||||||
@@ -1270,20 +1477,21 @@ function SpeechIconButton({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={label}
|
title={label}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500",
|
"shrink-0 text-slate-500 hover:bg-slate-100 disabled:cursor-wait",
|
||||||
"transition-[color,background-color,transform] hover:bg-slate-100 active:scale-95 disabled:cursor-wait",
|
|
||||||
tone === "primary" && "text-blue-600",
|
tone === "primary" && "text-blue-600",
|
||||||
tone === "danger" && "text-red-600"
|
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
|
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
import { Input } from "@/shared/ui/input";
|
import { Input } from "@/shared/ui/input";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import { StatusBadge, StatusDot } from "@/shared/ui/status";
|
||||||
import type { AgentChatSessionSummary } from "../api/client";
|
import type { AgentChatSessionSummary } from "../api/client";
|
||||||
import {
|
import {
|
||||||
agentLayoutTransition,
|
agentLayoutTransition,
|
||||||
@@ -75,30 +75,35 @@ 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">{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}</p>
|
<p className="mt-0.5 text-xs text-slate-500">
|
||||||
|
{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="刷新 Agent 历史记录"
|
aria-label="刷新 Agent 历史记录"
|
||||||
title="刷新 Agent 历史记录"
|
title="刷新 Agent 历史记录"
|
||||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-50"
|
size="icon"
|
||||||
disabled={loading}
|
variant="ghost"
|
||||||
|
disabled={loading && sessions.length === 0}
|
||||||
|
loading={loading && sessions.length > 0}
|
||||||
|
loadingLabel="正在刷新 Agent 历史记录"
|
||||||
onClick={() => void Promise.resolve(onRefresh?.())}
|
onClick={() => void Promise.resolve(onRefresh?.())}
|
||||||
>
|
>
|
||||||
{loading ? (
|
<RefreshCw size={15} aria-hidden="true" />
|
||||||
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
|
</Button>
|
||||||
) : (
|
|
||||||
<RefreshCw size={15} aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div className="max-h-56 space-y-1 overflow-y-auto pr-1" variants={agentPanelListVariants} animate="animate">
|
<motion.div
|
||||||
|
className="max-h-56 space-y-1 overflow-y-auto pr-1"
|
||||||
|
variants={agentPanelListVariants}
|
||||||
|
animate="animate"
|
||||||
|
>
|
||||||
<AnimatePresence initial={false} mode="popLayout">
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
{loading && sessions.length === 0 ? (
|
{loading && sessions.length === 0 ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="history-loading"
|
key="history-loading"
|
||||||
className="flex items-center gap-2 rounded-xl bg-slate-50 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-slate-500"
|
||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
@@ -121,8 +126,10 @@ export function AgentHistoryPanel({
|
|||||||
key={session.id}
|
key={session.id}
|
||||||
layout
|
layout
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition hover:bg-white",
|
"agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors",
|
||||||
active ? "border border-blue-200 bg-blue-50/80" : "border border-transparent bg-slate-50/80"
|
active && !editing
|
||||||
|
? "agent-history-item-active border border-blue-200"
|
||||||
|
: "border border-transparent"
|
||||||
)}
|
)}
|
||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
@@ -132,8 +139,18 @@ export function AgentHistoryPanel({
|
|||||||
>
|
>
|
||||||
<AnimatePresence initial={false} mode="popLayout">
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
{confirmingDelete ? (
|
{confirmingDelete ? (
|
||||||
<motion.div key="confirm-delete" className="min-w-0" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
<motion.div
|
||||||
<span className="block truncate text-xs font-semibold text-red-700" title={session.title}>
|
key="confirm-delete"
|
||||||
|
className="min-w-0"
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
variants={agentPanelItemVariants}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="status-tone-danger block truncate text-xs font-semibold text-[var(--status-foreground)]"
|
||||||
|
title={session.title}
|
||||||
|
>
|
||||||
删除“{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-slate-500">
|
||||||
@@ -155,7 +172,7 @@ export function AgentHistoryPanel({
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="h-8 rounded-lg bg-white text-xs"
|
className="agent-history-rename-input h-8 rounded-lg bg-white text-xs"
|
||||||
value={draftTitle}
|
value={draftTitle}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onChange={(event) => setDraftTitle(event.currentTarget.value)}
|
onChange={(event) => setDraftTitle(event.currentTarget.value)}
|
||||||
@@ -170,7 +187,7 @@ export function AgentHistoryPanel({
|
|||||||
<motion.button
|
<motion.button
|
||||||
key="session-summary"
|
key="session-summary"
|
||||||
type="button"
|
type="button"
|
||||||
className="min-w-0 text-left"
|
className="agent-history-session-trigger min-w-0 text-left"
|
||||||
disabled={itemLoading || deleting}
|
disabled={itemLoading || deleting}
|
||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
@@ -178,7 +195,10 @@ export function AgentHistoryPanel({
|
|||||||
variants={agentPanelItemVariants}
|
variants={agentPanelItemVariants}
|
||||||
onClick={() => onSelectSession(session.id)}
|
onClick={() => onSelectSession(session.id)}
|
||||||
>
|
>
|
||||||
<span className="block truncate text-xs font-semibold text-slate-900" title={session.title}>
|
<span
|
||||||
|
className="block truncate text-xs font-semibold text-slate-900"
|
||||||
|
title={session.title}
|
||||||
|
>
|
||||||
{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-slate-500">
|
||||||
@@ -187,79 +207,89 @@ export function AgentHistoryPanel({
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-2">
|
||||||
{session.runStatus === "running" || session.isStreaming ? (
|
{session.runStatus === "running" || session.isStreaming ? (
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
<StatusDot tone="info" activity="live" />
|
||||||
) : null}
|
) : null}
|
||||||
{itemLoading || saving || deleting ? (
|
{itemLoading || saving || deleting ? (
|
||||||
<Loader2 size={13} className="animate-spin text-blue-600" aria-hidden="true" />
|
<Loader2
|
||||||
|
size={13}
|
||||||
|
className="animate-spin text-blue-600"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{active && !editing && !confirmingDelete ? (
|
{active && !editing && !confirmingDelete ? (
|
||||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
|
<StatusBadge tone="info">当前</StatusBadge>
|
||||||
当前
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
{confirmingDelete ? (
|
{confirmingDelete ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-7 rounded-lg bg-red-600 px-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
|
variant="destructive"
|
||||||
disabled={deleting}
|
size="sm"
|
||||||
|
loading={deleting}
|
||||||
|
loadingLabel="正在删除对话"
|
||||||
onClick={() => confirmDelete(session.id)}
|
onClick={() => confirmDelete(session.id)}
|
||||||
>
|
>
|
||||||
确认
|
确认
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="取消删除"
|
aria-label="取消删除"
|
||||||
title="取消删除"
|
title="取消删除"
|
||||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
onClick={() => setConfirmingDeleteSessionId(null)}
|
onClick={() => setConfirmingDeleteSessionId(null)}
|
||||||
>
|
>
|
||||||
<X size={14} aria-hidden="true" />
|
<X size={14} aria-hidden="true" />
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : editing ? (
|
) : editing ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="保存对话主题"
|
aria-label="保存对话主题"
|
||||||
title="保存对话主题"
|
title="保存对话主题"
|
||||||
className="grid h-7 w-7 place-items-center rounded-lg text-blue-600 transition hover:bg-white disabled:opacity-50"
|
size="icon-sm"
|
||||||
disabled={saving}
|
loading={saving}
|
||||||
|
loadingLabel="正在保存对话主题"
|
||||||
onClick={() => saveRename(session)}
|
onClick={() => saveRename(session)}
|
||||||
>
|
>
|
||||||
<Check size={14} aria-hidden="true" />
|
<Check size={14} aria-hidden="true" />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="取消重命名"
|
aria-label="取消重命名"
|
||||||
title="取消重命名"
|
title="取消重命名"
|
||||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={cancelRename}
|
onClick={cancelRename}
|
||||||
>
|
>
|
||||||
<X size={14} aria-hidden="true" />
|
<X size={14} aria-hidden="true" />
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="重命名对话"
|
aria-label="重命名对话"
|
||||||
title="重命名对话"
|
title="重命名对话"
|
||||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:opacity-50"
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
disabled={itemLoading || deleting || !onRenameSession}
|
disabled={itemLoading || deleting || !onRenameSession}
|
||||||
onClick={() => beginRename(session)}
|
onClick={() => beginRename(session)}
|
||||||
>
|
>
|
||||||
<Pencil size={14} aria-hidden="true" />
|
<Pencil size={14} aria-hidden="true" />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="删除对话"
|
aria-label="删除对话"
|
||||||
title="删除对话"
|
title="删除对话"
|
||||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-slate-500 hover:bg-red-50 hover:text-red-600"
|
||||||
disabled={itemLoading || deleting || !onDeleteSession}
|
disabled={itemLoading || deleting || !onDeleteSession}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
cancelRename();
|
cancelRename();
|
||||||
@@ -267,7 +297,7 @@ export function AgentHistoryPanel({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} aria-hidden="true" />
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -275,7 +305,14 @@ export function AgentHistoryPanel({
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<motion.div key="history-empty" className="rounded-xl bg-slate-50 px-3 py-3 text-xs text-slate-500" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
<motion.div
|
||||||
|
key="history-empty"
|
||||||
|
className="agent-history-state rounded-xl px-3 py-3 text-xs text-slate-500"
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
variants={agentPanelItemVariants}
|
||||||
|
>
|
||||||
暂无历史记录
|
暂无历史记录
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { AgentMessageDetails } from "./agent-message-details";
|
||||||
|
import type { AgentQuestionInfo, AgentQuestionRequest } from "../types";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("AgentMessageDetails question answers", () => {
|
||||||
|
it("submits a custom answer instead of the selected option for a single-choice question", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onReplyQuestion = vi.fn();
|
||||||
|
|
||||||
|
renderQuestion(
|
||||||
|
{
|
||||||
|
header: "分析范围",
|
||||||
|
question: "你想分析哪一部分?",
|
||||||
|
options: [
|
||||||
|
{ label: "雨污混接分析", description: "检查混接问题" },
|
||||||
|
{ label: "水质异常分析", description: "检查水质指标" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
onReplyQuestion
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("radio", { name: /雨污混接分析/ }));
|
||||||
|
await user.click(screen.getByRole("radio", { name: "自定义回答" }));
|
||||||
|
await user.type(screen.getByPlaceholderText("输入自定义回答"), "检查高峰时段压力");
|
||||||
|
await user.click(screen.getByRole("button", { name: "提交回答" }));
|
||||||
|
|
||||||
|
expect(onReplyQuestion).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ requestId: "question-1" }),
|
||||||
|
[["检查高峰时段压力"]]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines selected options with a custom answer for a multiple-choice question", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onReplyQuestion = vi.fn();
|
||||||
|
|
||||||
|
renderQuestion(
|
||||||
|
{
|
||||||
|
header: "分析内容",
|
||||||
|
question: "需要包含哪些内容?",
|
||||||
|
options: [
|
||||||
|
{ label: "压力", description: "分析节点压力" },
|
||||||
|
{ label: "流量", description: "分析管段流量" }
|
||||||
|
],
|
||||||
|
multiple: true,
|
||||||
|
custom: true
|
||||||
|
},
|
||||||
|
onReplyQuestion
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("checkbox", { name: /压力/ }));
|
||||||
|
await user.click(screen.getByRole("checkbox", { name: "自定义回答" }));
|
||||||
|
await user.type(screen.getByPlaceholderText("输入自定义回答"), "补充水龄");
|
||||||
|
await user.click(screen.getByRole("button", { name: "提交回答" }));
|
||||||
|
|
||||||
|
expect(onReplyQuestion).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ requestId: "question-1" }),
|
||||||
|
[["压力", "补充水龄"]]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderQuestion(
|
||||||
|
question: AgentQuestionInfo,
|
||||||
|
onReplyQuestion: (request: AgentQuestionRequest, answers: string[][]) => void
|
||||||
|
) {
|
||||||
|
render(
|
||||||
|
<AgentMessageDetails
|
||||||
|
message={{
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "需要补充信息",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
requestId: "question-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
questions: [question],
|
||||||
|
createdAt: 100,
|
||||||
|
status: "pending"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
onReplyQuestion={onReplyQuestion}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -12,9 +10,16 @@ import {
|
|||||||
type LucideIcon
|
type LucideIcon
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useState, type ReactNode } from "react";
|
import { useId, useState, type ReactNode } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import {
|
||||||
|
StatusBadge,
|
||||||
|
StatusDot,
|
||||||
|
StatusIcon as SemanticStatusIcon,
|
||||||
|
type StatusActivity,
|
||||||
|
type StatusTone
|
||||||
|
} from "@/shared/ui/status";
|
||||||
import {
|
import {
|
||||||
agentEase,
|
agentEase,
|
||||||
agentLayoutTransition,
|
agentLayoutTransition,
|
||||||
@@ -39,63 +44,64 @@ type QuestionStatus = AgentQuestionRequest["status"];
|
|||||||
|
|
||||||
const STREAMING_PROGRESS_LIMIT = 3;
|
const STREAMING_PROGRESS_LIMIT = 3;
|
||||||
|
|
||||||
const progressStatusMeta: Record<ProgressStatus, { label: string; className: string; dotClassName: string }> = {
|
type AgentStatusMeta = {
|
||||||
|
label: string;
|
||||||
|
tone: StatusTone;
|
||||||
|
activity?: StatusActivity;
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressStatusMeta: Record<ProgressStatus, AgentStatusMeta> = {
|
||||||
running: {
|
running: {
|
||||||
label: "进行中",
|
label: "进行中",
|
||||||
className: "bg-blue-100 text-blue-700",
|
tone: "info",
|
||||||
dotClassName: "border-blue-500 bg-blue-100"
|
activity: "live"
|
||||||
},
|
},
|
||||||
completed: {
|
completed: {
|
||||||
label: "完成",
|
label: "已完成",
|
||||||
className: "bg-emerald-100 text-emerald-700",
|
tone: "success"
|
||||||
dotClassName: "border-emerald-500 bg-emerald-100"
|
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
label: "失败",
|
label: "失败",
|
||||||
className: "bg-red-100 text-red-700",
|
tone: "danger"
|
||||||
dotClassName: "border-red-500 bg-red-100"
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const todoStatusMeta: Record<TodoStatus, { label: string; className: string; dotClassName: string }> = {
|
const todoStatusMeta: Record<TodoStatus, AgentStatusMeta> = {
|
||||||
completed: {
|
completed: {
|
||||||
label: "完成",
|
label: "完成",
|
||||||
className: "bg-emerald-100 text-emerald-700",
|
tone: "success"
|
||||||
dotClassName: "border-emerald-500 bg-emerald-500"
|
|
||||||
},
|
},
|
||||||
in_progress: {
|
in_progress: {
|
||||||
label: "进行中",
|
label: "进行中",
|
||||||
className: "bg-blue-100 text-blue-700",
|
tone: "info",
|
||||||
dotClassName: "border-blue-500 bg-blue-100"
|
activity: "live"
|
||||||
},
|
},
|
||||||
pending: {
|
pending: {
|
||||||
label: "待办",
|
label: "待办",
|
||||||
className: "bg-slate-100 text-slate-600",
|
tone: "neutral"
|
||||||
dotClassName: "border-slate-300 bg-white"
|
|
||||||
},
|
},
|
||||||
cancelled: {
|
cancelled: {
|
||||||
label: "取消",
|
label: "取消",
|
||||||
className: "bg-slate-200 text-slate-500",
|
tone: "neutral"
|
||||||
dotClassName: "border-slate-300 bg-slate-200"
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const permissionStatusMeta: Record<PermissionStatus, { label: string; className: string }> = {
|
const permissionStatusMeta: Record<PermissionStatus, AgentStatusMeta> = {
|
||||||
approved_always: { label: "已始终允许", className: "bg-emerald-100 text-emerald-700" },
|
approved_always: { label: "已始终允许", tone: "success" },
|
||||||
approved_once: { label: "已允许一次", className: "bg-emerald-100 text-emerald-700" },
|
approved_once: { label: "已允许一次", tone: "success" },
|
||||||
rejected: { label: "已拒绝", className: "bg-red-100 text-red-700" },
|
rejected: { label: "已拒绝", tone: "danger" },
|
||||||
aborted: { label: "已中止", className: "bg-slate-200 text-slate-500" },
|
aborted: { label: "已中止", tone: "neutral" },
|
||||||
error: { label: "失败", className: "bg-red-100 text-red-700" },
|
error: { label: "失败", tone: "danger" },
|
||||||
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
|
submitting: { label: "提交中", tone: "info", activity: "live" },
|
||||||
pending: { label: "待确认", className: "bg-orange-100 text-orange-700" }
|
pending: { label: "待确认", tone: "warning" }
|
||||||
};
|
};
|
||||||
|
|
||||||
const questionStatusMeta: Record<QuestionStatus, { label: string; className: string }> = {
|
const questionStatusMeta: Record<QuestionStatus, AgentStatusMeta> = {
|
||||||
answered: { label: "已回答", className: "bg-emerald-100 text-emerald-700" },
|
answered: { label: "已回答", tone: "success" },
|
||||||
rejected: { label: "已跳过", className: "bg-slate-200 text-slate-500" },
|
rejected: { label: "已跳过", tone: "neutral" },
|
||||||
error: { label: "失败", className: "bg-red-100 text-red-700" },
|
error: { label: "失败", tone: "danger" },
|
||||||
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
|
submitting: { label: "提交中", tone: "info", activity: "live" },
|
||||||
pending: { label: "待回答", className: "bg-blue-100 text-blue-700" }
|
pending: { label: "待回答", tone: "info" }
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AgentMessageDetails({
|
export function AgentMessageDetails({
|
||||||
@@ -200,6 +206,7 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
|
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const listId = useId();
|
||||||
const compact = running && !expanded;
|
const compact = running && !expanded;
|
||||||
const visibleProgress = expanded
|
const visibleProgress = expanded
|
||||||
? progress
|
? progress
|
||||||
@@ -209,33 +216,47 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
|||||||
const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
|
const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="agent-panel-nested rounded-xl p-2.5">
|
<div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress>
|
||||||
<button
|
<div className="flex min-h-8 items-center gap-2 px-0.5">
|
||||||
type="button"
|
<ListTree size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||||
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
|
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-slate-700">执行进度</span>
|
||||||
aria-expanded={expanded}
|
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-slate-500">
|
||||||
onClick={() => setExpanded((current) => !current)}
|
{running ? (
|
||||||
>
|
<StatusDot tone="info" activity="live" aria-hidden="true" />
|
||||||
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
|
) : null}
|
||||||
<span>执行进度</span>
|
<span>{running ? (expanded ? "全部" : "最近") : "步骤"}</span>
|
||||||
<span className="flex-1" />
|
<span className="font-mono tabular-nums text-slate-600">
|
||||||
<span className="shrink-0 font-normal text-slate-400">
|
{expanded || !running
|
||||||
{expanded
|
? progress.length
|
||||||
? `全部 ${progress.length} 条`
|
: Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}
|
||||||
: running
|
<span className="px-0.5 text-slate-300">/</span>
|
||||||
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)} 条`
|
{progress.length}
|
||||||
: `共 ${progress.length} 条`}
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<button
|
||||||
size={14}
|
type="button"
|
||||||
className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
|
className={cn(
|
||||||
aria-hidden="true"
|
"-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",
|
||||||
/>
|
expanded && "text-blue-500"
|
||||||
</button>
|
)}
|
||||||
|
aria-controls={listId}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-label={expanded ? "收起执行进度" : "展开执行进度"}
|
||||||
|
data-state={expanded ? "open" : "closed"}
|
||||||
|
onClick={() => setExpanded((current) => !current)}
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={cn("transition-transform duration-150", expanded && "rotate-180")}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<AnimatePresence initial={false} mode="wait">
|
<AnimatePresence initial={false} mode="wait">
|
||||||
{visibleProgress.length ? (
|
{visibleProgress.length ? (
|
||||||
<motion.ol
|
<motion.ol
|
||||||
key={listMode}
|
key={listMode}
|
||||||
|
id={listId}
|
||||||
aria-label={expanded ? "全部执行进度" : "最近执行进度"}
|
aria-label={expanded ? "全部执行进度" : "最近执行进度"}
|
||||||
className="mt-2 flex flex-col gap-1.5 overflow-hidden"
|
className="mt-2 flex flex-col gap-1.5 overflow-hidden"
|
||||||
initial={{ height: 0, opacity: 0 }}
|
initial={{ height: 0, opacity: 0 }}
|
||||||
@@ -256,26 +277,11 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
|||||||
exit={{ opacity: 0, y: -2 }}
|
exit={{ opacity: 0, y: -2 }}
|
||||||
transition={{ duration: 0.16, ease: agentEase }}
|
transition={{ duration: 0.16, ease: agentEase }}
|
||||||
>
|
>
|
||||||
<motion.span
|
<StatusDot
|
||||||
aria-hidden="true"
|
tone={progressStatusMeta[item.status].tone}
|
||||||
className={cn(
|
activity={progressStatusMeta[item.status].activity}
|
||||||
"mt-0.5 h-3.5 w-3.5 rounded-full border transition-colors duration-150",
|
size="md"
|
||||||
progressStatusMeta[item.status].dotClassName
|
className="mt-1"
|
||||||
)}
|
|
||||||
animate={
|
|
||||||
item.status === "running"
|
|
||||||
? { opacity: [0.45, 1, 0.45], scale: 1 }
|
|
||||||
: item.status === "error"
|
|
||||||
? { opacity: [0.55, 1, 1], scale: [1, 1.28, 1] }
|
|
||||||
: { opacity: 1, scale: 1 }
|
|
||||||
}
|
|
||||||
transition={
|
|
||||||
item.status === "running"
|
|
||||||
? { duration: 1.2, ease: "easeInOut", repeat: Infinity }
|
|
||||||
: item.status === "error"
|
|
||||||
? { duration: 0.28, ease: agentEase }
|
|
||||||
: { duration: 0.14 }
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span
|
<span
|
||||||
@@ -304,21 +310,18 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
|||||||
key={item.status}
|
key={item.status}
|
||||||
data-progress-status={item.status}
|
data-progress-status={item.status}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-12 rounded-full px-2 py-0.5 text-center font-semibold",
|
"min-w-12 pt-0.5 text-right text-xs font-semibold",
|
||||||
progressStatusMeta[item.status].className
|
item.status === "running"
|
||||||
|
? "text-blue-600"
|
||||||
|
: [
|
||||||
|
`status-tone-${progressStatusMeta[item.status].tone}`,
|
||||||
|
"text-[var(--status-foreground)]"
|
||||||
|
]
|
||||||
)}
|
)}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={
|
animate={{ opacity: 1 }}
|
||||||
item.status === "error"
|
|
||||||
? { opacity: [0, 1, 1], x: [0, -2, 2, -1, 0] }
|
|
||||||
: { opacity: 1, x: 0 }
|
|
||||||
}
|
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={
|
transition={{ duration: 0.14 }}
|
||||||
item.status === "error"
|
|
||||||
? { duration: 0.24, ease: agentEase }
|
|
||||||
: { duration: 0.14 }
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{progressStatusMeta[item.status].label}
|
{progressStatusMeta[item.status].label}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
@@ -335,7 +338,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
|||||||
|
|
||||||
function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
|
function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
|
||||||
return (
|
return (
|
||||||
<AgentNestedBlock icon={ListChecks} iconClassName="text-emerald-600" title="计划">
|
<AgentNestedBlock icon={ListChecks} iconClassName="status-tone-success text-[var(--status-foreground)]" title="计划">
|
||||||
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
|
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
|
||||||
<AnimatePresence initial={false} mode="popLayout">
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
{todoUpdate.todos.slice(0, 6).map((todo) => (
|
{todoUpdate.todos.slice(0, 6).map((todo) => (
|
||||||
@@ -348,20 +351,24 @@ function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
|
|||||||
variants={agentPanelItemVariants}
|
variants={agentPanelItemVariants}
|
||||||
transition={agentLayoutTransition}
|
transition={agentLayoutTransition}
|
||||||
>
|
>
|
||||||
<span
|
<SemanticStatusIcon
|
||||||
className={cn(
|
tone={todoStatusMeta[todo.status].tone}
|
||||||
"mt-0.5 grid h-3.5 w-3.5 place-items-center rounded-full border",
|
activity={todoStatusMeta[todo.status].activity}
|
||||||
todoStatusMeta[todo.status].dotClassName
|
shape="circle"
|
||||||
)}
|
size="xs"
|
||||||
|
className="mt-0.5"
|
||||||
>
|
>
|
||||||
{todo.status === "completed" ? <CheckCircle2 size={10} className="text-white" aria-hidden="true" /> : null}
|
{todo.status === "completed" ? <CheckCircle2 size={10} /> : null}
|
||||||
</span>
|
</SemanticStatusIcon>
|
||||||
<span className="min-w-0 truncate text-slate-700" title={todo.content}>
|
<span className="min-w-0 truncate text-slate-700" title={todo.content}>
|
||||||
{todo.content}
|
{todo.content}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn("rounded-full px-2 py-0.5 font-semibold", todoStatusMeta[todo.status].className)}>
|
<StatusBadge
|
||||||
|
tone={todoStatusMeta[todo.status].tone}
|
||||||
|
activity={todoStatusMeta[todo.status].activity}
|
||||||
|
>
|
||||||
{todoStatusMeta[todo.status].label}
|
{todoStatusMeta[todo.status].label}
|
||||||
</span>
|
</StatusBadge>
|
||||||
</motion.li>
|
</motion.li>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -378,7 +385,7 @@ function PermissionList({
|
|||||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求">
|
<AgentNestedBlock icon={LockKeyhole} iconClassName="status-tone-warning text-[var(--status-foreground)]" title="权限请求">
|
||||||
<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">
|
||||||
{permissions.slice(-4).map((permission) => (
|
{permissions.slice(-4).map((permission) => (
|
||||||
@@ -426,18 +433,22 @@ function PermissionRequestCard({
|
|||||||
{target}
|
{target}
|
||||||
</p>
|
</p>
|
||||||
{permission.error ? (
|
{permission.error ? (
|
||||||
<p className="mt-1 line-clamp-2 text-xs leading-4 text-red-600">{permission.error}</p>
|
<p className="status-tone-danger mt-1 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{permission.error}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<span className={cn("self-start rounded-full px-2 py-0.5 font-semibold", permissionStatusMeta[permission.status].className)}>
|
<StatusBadge
|
||||||
|
tone={permissionStatusMeta[permission.status].tone}
|
||||||
|
activity={permissionStatusMeta[permission.status].activity}
|
||||||
|
className="self-start"
|
||||||
|
>
|
||||||
{permissionStatusMeta[permission.status].label}
|
{permissionStatusMeta[permission.status].label}
|
||||||
</span>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{actionable ? (
|
{actionable ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="permission-actions"
|
key="permission-actions"
|
||||||
className="mt-2 flex flex-wrap items-center gap-1.5"
|
className="mt-2 flex flex-wrap items-center gap-2"
|
||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
@@ -447,7 +458,7 @@ function PermissionRequestCard({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
|
className="h-8 px-2 text-xs"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => reply("once")}
|
onClick={() => reply("once")}
|
||||||
>
|
>
|
||||||
@@ -459,7 +470,7 @@ function PermissionRequestCard({
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 rounded-md border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
|
className="h-8 border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => reply("always")}
|
onClick={() => reply("always")}
|
||||||
>
|
>
|
||||||
@@ -471,7 +482,7 @@ function PermissionRequestCard({
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 rounded-md border-red-200 bg-red-50 px-2 text-xs text-red-700 hover:bg-red-100"
|
className="status-tone-danger h-8 border-[var(--status-border)] bg-[var(--status-soft)] px-2 text-xs text-[var(--status-foreground)] [@media(hover:hover)]:hover:brightness-95"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => reply("reject")}
|
onClick={() => reply("reject")}
|
||||||
>
|
>
|
||||||
@@ -549,9 +560,12 @@ function QuestionRequestCard({
|
|||||||
<span className="min-w-0 truncate font-semibold text-slate-800">
|
<span className="min-w-0 truncate font-semibold text-slate-800">
|
||||||
{request.questions[0]?.header || "问题"}
|
{request.questions[0]?.header || "问题"}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn("rounded-full px-2 py-0.5 font-semibold", questionStatusMeta[request.status].className)}>
|
<StatusBadge
|
||||||
|
tone={questionStatusMeta[request.status].tone}
|
||||||
|
activity={questionStatusMeta[request.status].activity}
|
||||||
|
>
|
||||||
{questionStatusMeta[request.status].label}
|
{questionStatusMeta[request.status].label}
|
||||||
</span>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -560,7 +574,7 @@ function QuestionRequestCard({
|
|||||||
key={`${request.requestId}-${questionIndex}`}
|
key={`${request.requestId}-${questionIndex}`}
|
||||||
question={question}
|
question={question}
|
||||||
questionIndex={questionIndex}
|
questionIndex={questionIndex}
|
||||||
value={draftAnswers[questionIndex] ?? { selected: [], custom: "" }}
|
value={draftAnswers[questionIndex] ?? { selected: [], customSelected: false, custom: "" }}
|
||||||
disabled={!actionable || submitting}
|
disabled={!actionable || submitting}
|
||||||
onChange={(nextValue) =>
|
onChange={(nextValue) =>
|
||||||
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
|
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
|
||||||
@@ -570,7 +584,7 @@ function QuestionRequestCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{request.error ? (
|
{request.error ? (
|
||||||
<p className="mt-2 line-clamp-2 text-xs leading-4 text-red-600">{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-slate-500" title={request.answers.map((answer) => answer.join("、")).join(";")}>
|
||||||
@@ -582,7 +596,7 @@ function QuestionRequestCard({
|
|||||||
{actionable ? (
|
{actionable ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="question-actions"
|
key="question-actions"
|
||||||
className="mt-2 flex flex-wrap items-center gap-1.5"
|
className="mt-2 flex flex-wrap items-center gap-2"
|
||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
@@ -592,7 +606,7 @@ function QuestionRequestCard({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
|
className="h-8 px-2 text-xs"
|
||||||
disabled={disabled || !canSubmit}
|
disabled={disabled || !canSubmit}
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
>
|
>
|
||||||
@@ -603,7 +617,7 @@ function QuestionRequestCard({
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 rounded-md border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
|
className="h-8 border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
|
||||||
disabled={submitting || !onRejectQuestion}
|
disabled={submitting || !onRejectQuestion}
|
||||||
onClick={reject}
|
onClick={reject}
|
||||||
>
|
>
|
||||||
@@ -619,6 +633,7 @@ function QuestionRequestCard({
|
|||||||
|
|
||||||
type QuestionDraftAnswer = {
|
type QuestionDraftAnswer = {
|
||||||
selected: string[];
|
selected: string[];
|
||||||
|
customSelected: boolean;
|
||||||
custom: string;
|
custom: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -635,8 +650,11 @@ function QuestionInput({
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onChange: (value: QuestionDraftAnswer) => void;
|
onChange: (value: QuestionDraftAnswer) => void;
|
||||||
}) {
|
}) {
|
||||||
const name = `agent-question-${questionIndex}-${question.question}`;
|
const inputId = useId();
|
||||||
const showCustomInput = question.custom || question.options.length === 0;
|
const name = `agent-question-${questionIndex}-${inputId}`;
|
||||||
|
const customEnabled = question.custom !== false;
|
||||||
|
const showCustomChoice = customEnabled && question.options.length > 0;
|
||||||
|
const showCustomInput = customEnabled && value.customSelected;
|
||||||
|
|
||||||
const toggleOption = (label: string, checked: boolean) => {
|
const toggleOption = (label: string, checked: boolean) => {
|
||||||
if (question.multiple) {
|
if (question.multiple) {
|
||||||
@@ -648,21 +666,36 @@ function QuestionInput({
|
|||||||
}
|
}
|
||||||
onChange({
|
onChange({
|
||||||
...value,
|
...value,
|
||||||
selected: checked ? [label] : []
|
selected: checked ? [label] : [],
|
||||||
|
customSelected: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCustom = (checked: boolean) => {
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
selected: question.multiple ? value.selected : [],
|
||||||
|
customSelected: checked
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="leading-5 text-slate-700">{question.question}</p>
|
<p className="leading-5 text-slate-700">{question.question}</p>
|
||||||
{question.options.length ? (
|
{question.options.length || showCustomChoice ? (
|
||||||
<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);
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={option.label}
|
key={option.label}
|
||||||
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200/80 px-2 py-1.5 text-slate-600"
|
className={cn(
|
||||||
|
"flex cursor-pointer items-start gap-2 rounded-md border px-2 py-1.5 transition-colors",
|
||||||
|
checked
|
||||||
|
? "border-blue-300 bg-blue-50/70 text-blue-700"
|
||||||
|
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
|
||||||
|
disabled && "cursor-default opacity-60"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type={question.multiple ? "checkbox" : "radio"}
|
type={question.multiple ? "checkbox" : "radio"}
|
||||||
@@ -681,12 +714,34 @@ function QuestionInput({
|
|||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{showCustomChoice ? (
|
||||||
|
<label
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center gap-2 rounded-md border px-2 py-2 transition-colors",
|
||||||
|
value.customSelected
|
||||||
|
? "border-blue-300 bg-blue-50/70 text-blue-700"
|
||||||
|
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
|
||||||
|
disabled && "cursor-default opacity-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type={question.multiple ? "checkbox" : "radio"}
|
||||||
|
name={name}
|
||||||
|
className="h-3.5 w-3.5 accent-blue-600"
|
||||||
|
checked={value.customSelected}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => toggleCustom(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span className="font-semibold text-slate-700">自定义回答</span>
|
||||||
|
</label>
|
||||||
|
) : 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 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"
|
||||||
placeholder="输入回答"
|
aria-label="自定义回答内容"
|
||||||
|
placeholder="输入自定义回答"
|
||||||
value={value.custom}
|
value={value.custom}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(event) => onChange({ ...value, custom: event.currentTarget.value })}
|
onChange={(event) => onChange({ ...value, custom: event.currentTarget.value })}
|
||||||
@@ -697,16 +752,25 @@ function QuestionInput({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createInitialQuestionAnswers(request: AgentQuestionRequest): QuestionDraftAnswer[] {
|
function createInitialQuestionAnswers(request: AgentQuestionRequest): QuestionDraftAnswer[] {
|
||||||
return request.questions.map((question, index) => ({
|
return request.questions.map((question, index) => {
|
||||||
selected: request.answers?.[index] ?? [],
|
const optionLabels = new Set(question.options.map((option) => option.label));
|
||||||
custom: ""
|
const savedAnswers = request.answers?.[index] ?? [];
|
||||||
}));
|
const selected = savedAnswers.filter((answer) => optionLabels.has(answer));
|
||||||
|
const customAnswers = savedAnswers.filter((answer) => !optionLabels.has(answer));
|
||||||
|
const customEnabled = question.custom !== false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
selected,
|
||||||
|
customSelected: customEnabled && (question.options.length === 0 || customAnswers.length > 0),
|
||||||
|
custom: customAnswers.join(";")
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
|
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
|
||||||
return draftAnswers.map((answer) => {
|
return draftAnswers.map((answer) => {
|
||||||
const custom = answer.custom.trim();
|
const custom = answer.custom.trim();
|
||||||
return custom ? [...answer.selected, custom] : answer.selected;
|
return answer.customSelected && custom ? [...answer.selected, custom] : answer.selected;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Variants } from "motion/react";
|
import type { Variants } from "motion/react";
|
||||||
|
|
||||||
export const AGENT_MOTION_DURATION_MS = 180;
|
export const AGENT_MOTION_DURATION_MS = 180;
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
"use client";
|
import { ClipboardCheck, Crosshair, MapPinned, Radio, Route } from "lucide-react";
|
||||||
|
|
||||||
import {
|
|
||||||
ClipboardCheck,
|
|
||||||
Crosshair,
|
|
||||||
MapPinned,
|
|
||||||
Radio,
|
|
||||||
Route
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { Button } from "@/shared/ui/button";
|
||||||
import type {
|
import { StatusDot, type StatusTone } from "@/shared/ui/status";
|
||||||
AgentChatMessage
|
import { countPendingAgentInputs } from "../message-state";
|
||||||
} from "../types";
|
import type { AgentChatMessage } from "../types";
|
||||||
|
|
||||||
export function AgentOperationalBrief({
|
export function AgentOperationalBrief({
|
||||||
messages,
|
messages,
|
||||||
@@ -28,52 +20,71 @@ export function AgentOperationalBrief({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="agent-panel-control rounded-2xl p-3">
|
<div className="agent-panel-control rounded-2xl p-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="min-w-0">
|
||||||
<div className="min-w-0">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<StatusDot tone={brief.statusTone} activity={streaming ? "live" : "static"} size="md" />
|
||||||
<span className={cn("h-2 w-2 rounded-full", brief.statusDotClassName)} />
|
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
|
||||||
<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-500" title={brief.task}>
|
|
||||||
{brief.task}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-1 truncate text-xs leading-5 text-slate-600" title={brief.task}>
|
||||||
|
{brief.task}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl className="mt-3 grid grid-cols-3 gap-2">
|
<dl className="mt-3 grid grid-cols-3 gap-2">
|
||||||
<AgentBriefMetric icon={<Radio size={13} aria-hidden="true" />} label="证据" value={brief.evidence} />
|
<AgentBriefMetric
|
||||||
<AgentBriefMetric icon={<Route size={13} aria-hidden="true" />} label="计划" value={brief.plan} />
|
icon={<Radio size={13} aria-hidden="true" />}
|
||||||
<AgentBriefMetric icon={<ClipboardCheck size={13} aria-hidden="true" />} label="确认" value={brief.confirmation} />
|
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>
|
</dl>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="agent-panel-primary-action flex h-9 items-center justify-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
|
className="w-full text-xs"
|
||||||
disabled={streaming}
|
disabled={streaming}
|
||||||
onClick={() => onSubmitCommand(brief.primaryCommand)}
|
onClick={() => onSubmitCommand(brief.primaryCommand)}
|
||||||
>
|
>
|
||||||
<MapPinned size={14} aria-hidden="true" />
|
<MapPinned size={14} aria-hidden="true" />
|
||||||
预览影响
|
预览影响
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="surface-control flex h-9 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold text-slate-700 transition-colors hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
variant="outline"
|
||||||
|
className="w-full text-xs"
|
||||||
disabled={streaming}
|
disabled={streaming}
|
||||||
onClick={() => onSubmitCommand(brief.secondaryCommand)}
|
onClick={() => onSubmitCommand(brief.secondaryCommand)}
|
||||||
>
|
>
|
||||||
<Crosshair size={14} aria-hidden="true" />
|
<Crosshair size={14} aria-hidden="true" />
|
||||||
检查证据
|
检查证据
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
function AgentBriefMetric({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="surface-reading min-w-0 rounded-xl border px-2 py-2">
|
<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-500">
|
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-600">
|
||||||
<span className="text-blue-600">{icon}</span>
|
<span className="text-blue-600">{icon}</span>
|
||||||
{label}
|
{label}
|
||||||
</dt>
|
</dt>
|
||||||
@@ -84,26 +95,31 @@ function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: stri
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOperationalBrief(messages: AgentChatMessage[], statusLabel: string, streaming: boolean) {
|
function createOperationalBrief(
|
||||||
const lastUserMessage = [...messages].reverse().find((message) => message.role === "user" && message.content.trim());
|
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 progressItems = messages.flatMap((message) => message.progress ?? []);
|
||||||
const todoItems = messages.flatMap((message) => message.todos?.todos ?? []);
|
const todoItems = messages.flatMap((message) => message.todos?.todos ?? []);
|
||||||
const permissionItems = messages.flatMap((message) => message.permissions ?? []);
|
|
||||||
const questionItems = messages.flatMap((message) => message.questions ?? []);
|
|
||||||
const latestProgress = progressItems.at(-1);
|
const latestProgress = progressItems.at(-1);
|
||||||
const activeTodo =
|
const activeTodo =
|
||||||
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
|
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
|
||||||
[...todoItems].reverse().find((todo) => todo.status === "pending");
|
[...todoItems].reverse().find((todo) => todo.status === "pending");
|
||||||
const pendingConfirmations =
|
const pendingConfirmations = countPendingAgentInputs(messages);
|
||||||
permissionItems.filter((permission) => permission.status === "pending" || permission.status === "error").length +
|
|
||||||
questionItems.filter((question) => question.status === "pending" || question.status === "error").length;
|
|
||||||
const completedSteps = progressItems.filter((item) => item.status === "completed").length;
|
const completedSteps = progressItems.filter((item) => item.status === "completed").length;
|
||||||
const task = lastUserMessage?.content.trim() || "等待调度指令";
|
const task = lastUserMessage?.content.trim() || "等待调度指令";
|
||||||
const evidence = latestProgress?.detail || latestProgress?.title || statusLabel;
|
const evidence = latestProgress?.detail || latestProgress?.title || statusLabel;
|
||||||
const plan = activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
|
const plan =
|
||||||
|
activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
|
||||||
const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认";
|
const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认";
|
||||||
const stateLabel = pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
|
const stateLabel =
|
||||||
const statusDotClassName = pendingConfirmations > 0 ? "bg-orange-500" : streaming ? "bg-blue-500" : "bg-emerald-500";
|
pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
|
||||||
|
const statusTone: StatusTone =
|
||||||
|
pendingConfirmations > 0 ? "warning" : streaming ? "info" : "success";
|
||||||
const promptContext = lastUserMessage?.content.trim() || "当前排水管网运行态势";
|
const promptContext = lastUserMessage?.content.trim() || "当前排水管网运行态势";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -112,7 +128,7 @@ function createOperationalBrief(messages: AgentChatMessage[], statusLabel: strin
|
|||||||
plan,
|
plan,
|
||||||
confirmation,
|
confirmation,
|
||||||
stateLabel,
|
stateLabel,
|
||||||
statusDotClassName,
|
statusTone,
|
||||||
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
|
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
|
||||||
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
|
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Bot } from "lucide-react";
|
import { Bot } from "lucide-react";
|
||||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
|
||||||
const LazyPersona = lazy(async () => {
|
const LazyPersona = lazy(async () => {
|
||||||
const module = await import("@/shared/ai-elements/persona");
|
const module = await import("@/shared/ai-elements/persona");
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
|
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
|
||||||
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
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";
|
||||||
@@ -284,7 +283,7 @@ function TrustedPanelShell({
|
|||||||
return (
|
return (
|
||||||
<div className="agent-panel-nested rounded-xl p-3">
|
<div className="agent-panel-nested rounded-xl p-3">
|
||||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-950">
|
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-950">
|
||||||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-emerald-100 text-emerald-700">{icon}</span>
|
<StatusIcon tone="success" size="md">{icon}</StatusIcon>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
"use client";
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { MessageResponse } from "@/shared/ai-elements/message";
|
import { MessageResponse } from "@/shared/ai-elements/message";
|
||||||
|
|
||||||
type StreamingTokenResponseProps = {
|
type StreamingTokenResponseProps = {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import {
|
import {
|
||||||
splitSpeechTextIntoChunks,
|
splitSpeechTextIntoChunks,
|
||||||
|
|||||||
@@ -1,19 +1,63 @@
|
|||||||
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
|
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
|
||||||
export { AgentCommandPanel } from "./components/agent-command-panel";
|
export { AgentCommandPanel } from "./components/agent-command-panel";
|
||||||
|
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
|
||||||
|
export type { AgentCommandPanelPresentation } from "./components/agent-command-panel";
|
||||||
export { AgentPersona } from "./components/agent-persona";
|
export { AgentPersona } from "./components/agent-persona";
|
||||||
|
export { createAgentApiClient } from "./api/client";
|
||||||
|
export type {
|
||||||
|
AgentApiClient,
|
||||||
|
AgentChatSessionSummary,
|
||||||
|
AgentLoadedChatSession,
|
||||||
|
AgentSessionStreamEvent
|
||||||
|
} from "./api/client";
|
||||||
|
export {
|
||||||
|
agentBootstrapKey,
|
||||||
|
agentSessionsKey,
|
||||||
|
fetchAgentBootstrap,
|
||||||
|
fetchAgentSessions,
|
||||||
|
type AgentBootstrapData
|
||||||
|
} from "./api/swr";
|
||||||
export { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data";
|
export { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data";
|
||||||
|
export {
|
||||||
|
FRONTEND_ACTION_NAMES,
|
||||||
|
parseFrontendActionRegistry,
|
||||||
|
parseFrontendActionRequest,
|
||||||
|
readActionResults,
|
||||||
|
storeActionResult,
|
||||||
|
type FrontendActionName,
|
||||||
|
type FrontendActionRegistry,
|
||||||
|
type FrontendActionRequest,
|
||||||
|
type FrontendActionResult
|
||||||
|
} from "./frontend-action";
|
||||||
|
export { FrontendActionExecutor } from "./frontend-action/executor";
|
||||||
export {
|
export {
|
||||||
applyPermissionResponse,
|
applyPermissionResponse,
|
||||||
applyQuestionResponse,
|
applyQuestionResponse,
|
||||||
|
cancelRunningTodos,
|
||||||
|
completeRunningProgress,
|
||||||
|
finalizeAssistantMessageAfterAbort,
|
||||||
toTodoUpdate,
|
toTodoUpdate,
|
||||||
upsertPermission,
|
upsertPermission,
|
||||||
upsertProgress,
|
upsertProgress,
|
||||||
upsertQuestion
|
upsertQuestion
|
||||||
} from "./session-state";
|
} from "./session-state";
|
||||||
|
export {
|
||||||
|
isUiEnvelopeAllowed,
|
||||||
|
parseUiEnvelope,
|
||||||
|
parseUiEnvelopePayload,
|
||||||
|
parseUiRegistry,
|
||||||
|
toTrustedMapAction,
|
||||||
|
type TrustedMapAction,
|
||||||
|
type UIEnvelope,
|
||||||
|
type UIEnvelopePayload,
|
||||||
|
type UIRegistry,
|
||||||
|
type UISurface
|
||||||
|
} from "./ui-envelope";
|
||||||
export type {
|
export type {
|
||||||
AgentApprovalMode,
|
AgentApprovalMode,
|
||||||
AgentChatMessage,
|
AgentChatMessage,
|
||||||
AgentChatProgress,
|
AgentChatProgress,
|
||||||
|
AgentConnectionStatus,
|
||||||
AgentInteractionToolRef,
|
AgentInteractionToolRef,
|
||||||
AgentModelOption,
|
AgentModelOption,
|
||||||
AgentPermissionReply,
|
AgentPermissionReply,
|
||||||
@@ -28,8 +72,4 @@ export type {
|
|||||||
AgentTodoUpdate,
|
AgentTodoUpdate,
|
||||||
AgentUiResult
|
AgentUiResult
|
||||||
} from "./types";
|
} from "./types";
|
||||||
export type {
|
export type { SafeChartData, SafeChartSeries, SafeChartSpec } from "./chart-data";
|
||||||
SafeChartData,
|
|
||||||
SafeChartSeries,
|
|
||||||
SafeChartSpec
|
|
||||||
} from "./chart-data";
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
|
||||||
|
import type { AgentChatMessage } from "./types";
|
||||||
|
|
||||||
|
describe("Agent message state", () => {
|
||||||
|
it("counts pending and failed operator inputs from one shared rule", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
permissions: [
|
||||||
|
{ status: "pending" },
|
||||||
|
{ status: "approved_once" }
|
||||||
|
],
|
||||||
|
questions: [
|
||||||
|
{ status: "error" },
|
||||||
|
{ status: "answered" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
] as AgentChatMessage[];
|
||||||
|
|
||||||
|
expect(countPendingAgentInputs(messages)).toBe(2);
|
||||||
|
expect(hasPendingAgentInput(messages)).toBe(true);
|
||||||
|
expect(hasPendingAgentInput([])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { AgentChatMessage } from "./types";
|
||||||
|
|
||||||
|
export function countPendingAgentInputs(messages: AgentChatMessage[]) {
|
||||||
|
return messages.reduce((total, message) => {
|
||||||
|
const pendingPermissions =
|
||||||
|
message.permissions?.filter(
|
||||||
|
(permission) => permission.status === "pending" || permission.status === "error"
|
||||||
|
).length ?? 0;
|
||||||
|
const pendingQuestions =
|
||||||
|
message.questions?.filter(
|
||||||
|
(question) => question.status === "pending" || question.status === "error"
|
||||||
|
).length ?? 0;
|
||||||
|
return total + pendingPermissions + pendingQuestions;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPendingAgentInput(messages: AgentChatMessage[]) {
|
||||||
|
return countPendingAgentInputs(messages) > 0;
|
||||||
|
}
|
||||||
@@ -94,6 +94,33 @@ describe("agent session state", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves an actionable question id when its tool placeholder arrives later", () => {
|
||||||
|
const questions = upsertQuestion(undefined, {
|
||||||
|
session_id: "session-1",
|
||||||
|
request_id: "question-1",
|
||||||
|
tool: { messageID: "message-1", callID: "call-1" },
|
||||||
|
questions: [{ header: "范围", question: "选择范围", options: [] }],
|
||||||
|
created_at: 100
|
||||||
|
});
|
||||||
|
const withPlaceholder = upsertQuestion(questions, {
|
||||||
|
session_id: "session-1",
|
||||||
|
request_id: "call-1",
|
||||||
|
tool: { messageID: "message-1", callID: "call-1" },
|
||||||
|
questions: [{ header: "范围", question: "选择范围", options: [] }],
|
||||||
|
created_at: 120
|
||||||
|
});
|
||||||
|
const rejected = applyQuestionResponse(withPlaceholder, {
|
||||||
|
request_id: "question-1",
|
||||||
|
rejected: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(withPlaceholder).toHaveLength(1);
|
||||||
|
expect(rejected?.[0]).toMatchObject({
|
||||||
|
requestId: "question-1",
|
||||||
|
status: "rejected"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("finalizes open assistant state after abort", () => {
|
it("finalizes open assistant state after abort", () => {
|
||||||
const message: AgentChatMessage = {
|
const message: AgentChatMessage = {
|
||||||
id: "assistant-1",
|
id: "assistant-1",
|
||||||
|
|||||||
@@ -183,11 +183,24 @@ export function upsertQuestion(questions: AgentQuestionRequest[] | undefined, da
|
|||||||
const index = next.findIndex((question) => isSameQuestion(question, nextItem));
|
const index = next.findIndex((question) => isSameQuestion(question, nextItem));
|
||||||
|
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
next[index] = {
|
const current = next[index];
|
||||||
...next[index],
|
const currentIsActionable = Boolean(current.tool?.callID && current.requestId !== current.tool.callID);
|
||||||
...nextItem,
|
const nextIsToolPlaceholder = Boolean(nextItem.tool?.callID && nextItem.requestId === nextItem.tool.callID);
|
||||||
status: next[index].status === "submitting" ? "submitting" : nextItem.status
|
|
||||||
};
|
next[index] =
|
||||||
|
currentIsActionable && nextIsToolPlaceholder
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
sessionId: nextItem.sessionId,
|
||||||
|
questions: nextItem.questions,
|
||||||
|
tool: nextItem.tool ?? current.tool,
|
||||||
|
createdAt: nextItem.createdAt
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...current,
|
||||||
|
...nextItem,
|
||||||
|
status: current.status === "submitting" ? "submitting" : nextItem.status
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
next.push(nextItem);
|
next.push(nextItem);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export type AgentStreamRenderMessageState = {
|
|||||||
|
|
||||||
export type AgentStreamRenderState = Record<string, AgentStreamRenderMessageState>;
|
export type AgentStreamRenderState = Record<string, AgentStreamRenderMessageState>;
|
||||||
|
|
||||||
|
export type AgentConnectionStatus = "connecting" | "online" | "offline";
|
||||||
|
|
||||||
export type AgentChatProgress = {
|
export type AgentChatProgress = {
|
||||||
id: string;
|
id: string;
|
||||||
phase: string;
|
phase: string;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe("toTrustedMapAction", () => {
|
|||||||
expect(toTrustedMapAction("apply_layer_style", {})).toBeNull();
|
expect(toTrustedMapAction("apply_layer_style", {})).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
toTrustedMapAction("apply_layer_style", {
|
toTrustedMapAction("apply_layer_style", {
|
||||||
layer_group_id: "conduits",
|
layer_group_id: "pipes",
|
||||||
layer_id: "scada",
|
layer_id: "scada",
|
||||||
visible: true
|
visible: true
|
||||||
})
|
})
|
||||||
@@ -70,11 +70,11 @@ describe("toTrustedMapAction", () => {
|
|||||||
expect(action?.type === "zoom_to_map" ? action.center[1] : undefined).toBeCloseTo(39.1, 3);
|
expect(action?.type === "zoom_to_map" ? action.center[1] : undefined).toBeCloseTo(39.1, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes drainage locate tool actions", () => {
|
it("normalizes legacy locate tool actions", () => {
|
||||||
expect(toTrustedMapAction("locate_conduits", { conduit_ids: "C1,C2" })).toEqual({
|
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
|
||||||
type: "locate_features",
|
type: "locate_features",
|
||||||
featureIds: ["C1", "C2"],
|
featureIds: ["P1", "P2"],
|
||||||
layer: "geo_conduits_mat",
|
layer: "geo_pipes_mat",
|
||||||
fallbackText: undefined
|
fallbackText: undefined
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -87,26 +87,26 @@ describe("toTrustedMapAction", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it("accepts drainage layer visibility and scada locate actions", () => {
|
||||||
["locate_orifices", "orifice_id", "geo_orifices_mat"],
|
expect(toTrustedMapAction("apply_layer_style", { layer_id: "scada", visible: false })).toMatchObject({
|
||||||
["locate_outfalls", "outfall_id", "geo_outfalls_mat"],
|
type: "apply_layer_style",
|
||||||
["locate_pumps", "pump_id", "geo_pumps_mat"],
|
layerGroupId: "scada",
|
||||||
["locate_scada", "scada_id", "geo_scadas_mat"]
|
layerId: "scada",
|
||||||
])("accepts %s for a drainage source", (action, key, layer) => {
|
visible: false
|
||||||
expect(toTrustedMapAction(action, { [key]: "asset-1" })).toMatchObject({
|
});
|
||||||
|
expect(toTrustedMapAction("locate_scada", { scada_id: "S-1" })).toMatchObject({
|
||||||
type: "locate_features",
|
type: "locate_features",
|
||||||
featureIds: ["asset-1"],
|
featureIds: ["S-1"],
|
||||||
layer
|
layer: "geo_scada"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects stale supply layers and accepts drainage visibility controls", () => {
|
it("rejects unknown layers and accepts known visibility controls", () => {
|
||||||
expect(toTrustedMapAction("locate_pipes", { pipe_ids: ["P1"] })).toBeNull();
|
expect(toTrustedMapAction("locate_features", { ids: ["P1"], layer: "geo_unknown" })).toBeNull();
|
||||||
expect(toTrustedMapAction("locate_features", { ids: ["P1"], layer: "geo_pipes_mat" })).toBeNull();
|
expect(toTrustedMapAction("apply_layer_style", { layer_id: "pipes", visible: true })).toMatchObject({
|
||||||
expect(toTrustedMapAction("apply_layer_style", { layer_id: "conduits", visible: true })).toMatchObject({
|
|
||||||
type: "apply_layer_style",
|
type: "apply_layer_style",
|
||||||
layerGroupId: "conduits",
|
layerGroupId: "pipes",
|
||||||
layerId: "conduits",
|
layerId: "pipes",
|
||||||
visible: true
|
visible: true
|
||||||
});
|
});
|
||||||
expect(toTrustedMapAction("apply_layer_style", { layer_group_id: "simulation", visible: false })).toMatchObject({
|
expect(toTrustedMapAction("apply_layer_style", { layer_group_id: "simulation", visible: false })).toMatchObject({
|
||||||
@@ -114,6 +114,6 @@ describe("toTrustedMapAction", () => {
|
|||||||
layerGroupId: "simulation",
|
layerGroupId: "simulation",
|
||||||
visible: false
|
visible: false
|
||||||
});
|
});
|
||||||
expect(toTrustedMapAction("apply_layer_style", { layer_id: "pipes", visible: true })).toBeNull();
|
expect(toTrustedMapAction("apply_layer_style", { layer_id: "conduits", visible: true })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -164,26 +164,30 @@ 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_conduits: "geo_conduits_mat",
|
|
||||||
locate_junctions: "geo_junctions_mat",
|
locate_junctions: "geo_junctions_mat",
|
||||||
locate_orifices: "geo_orifices_mat",
|
locate_pipes: "geo_pipes_mat",
|
||||||
locate_outfalls: "geo_outfalls_mat",
|
locate_valves: "geo_valves",
|
||||||
locate_pumps: "geo_pumps_mat",
|
locate_reservoirs: "geo_reservoirs",
|
||||||
locate_scada: "geo_scadas_mat"
|
locate_scada: "geo_scada",
|
||||||
|
locate_pumps: "geo_pumps",
|
||||||
|
locate_tanks: "geo_tanks"
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
|
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
|
||||||
const DRAINAGE_SOURCE_IDS = ["conduits", "junctions", "orifices", "outfalls", "pumps", "scada"];
|
const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada"];
|
||||||
const ALLOWED_LAYER_IDS = new Set(DRAINAGE_SOURCE_IDS);
|
const ALLOWED_LAYER_IDS = new Set(SUPPLY_SOURCE_IDS);
|
||||||
const ALLOWED_LAYER_GROUP_IDS = new Set([...DRAINAGE_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([
|
||||||
...DRAINAGE_SOURCE_IDS,
|
...SUPPLY_SOURCE_IDS,
|
||||||
"geo_conduits_mat",
|
"pumps",
|
||||||
|
"tanks",
|
||||||
"geo_junctions_mat",
|
"geo_junctions_mat",
|
||||||
"geo_orifices_mat",
|
"geo_pipes_mat",
|
||||||
"geo_outfalls_mat",
|
"geo_valves",
|
||||||
"geo_pumps_mat",
|
"geo_reservoirs",
|
||||||
"geo_scadas_mat"
|
"geo_scada",
|
||||||
|
"geo_pumps",
|
||||||
|
"geo_tanks"
|
||||||
]);
|
]);
|
||||||
const WEB_MERCATOR_RADIUS = 6378137;
|
const WEB_MERCATOR_RADIUS = 6378137;
|
||||||
|
|
||||||
@@ -198,16 +202,18 @@ const LOCATE_ID_PARAM_KEYS = [
|
|||||||
"node_id",
|
"node_id",
|
||||||
"junction_ids",
|
"junction_ids",
|
||||||
"junction_id",
|
"junction_id",
|
||||||
"conduit_ids",
|
"pipe_ids",
|
||||||
"conduit_id",
|
"pipe_id",
|
||||||
"orifice_ids",
|
"valve_ids",
|
||||||
"orifice_id",
|
"valve_id",
|
||||||
"outfall_ids",
|
"reservoir_ids",
|
||||||
"outfall_id",
|
"reservoir_id",
|
||||||
|
"scada_ids",
|
||||||
|
"scada_id",
|
||||||
"pump_ids",
|
"pump_ids",
|
||||||
"pump_id",
|
"pump_id",
|
||||||
"scada_ids",
|
"tank_ids",
|
||||||
"scada_id"
|
"tank_id"
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function readLocateIds(params: Record<string, unknown>) {
|
function readLocateIds(params: Record<string, unknown>) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { Download, ListChecks, MapPinned, Plus, Share2 } from "lucide-react";
|
import { Download, ListChecks, MapPinned, Plus, Share2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import {
|
import {
|
||||||
MapActionRow,
|
MapActionRow,
|
||||||
MapPanelSection
|
MapPanelSection
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Check, Map } from "lucide-react";
|
import { Check, Map } from "lucide-react";
|
||||||
import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react";
|
import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import {
|
import {
|
||||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import {
|
import {
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
@@ -158,7 +158,7 @@ export function MapActionRow({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left transition duration-150 active:translate-y-px active:scale-[0.99]",
|
"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-blue-600 bg-blue-600 text-white shadow-lg shadow-blue-600/25 hover:bg-blue-700"
|
||||||
@@ -172,7 +172,7 @@ export function MapActionRow({
|
|||||||
(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-blue-600 hover:bg-blue-600 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-white/95 hover:text-slate-700"),
|
||||||
disabled && "active:translate-y-0 active:scale-100"
|
disabled && "shadow-none"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -55,7 +53,7 @@ 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-md shadow-blue-600/20"
|
? "border-blue-600 bg-blue-600 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-blue-100 hover:bg-white hover:text-blue-700",
|
||||||
!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",
|
||||||
@@ -87,7 +85,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
|
|||||||
role="toolbar"
|
role="toolbar"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto inline-flex p-1",
|
"pointer-events-auto inline-flex gap-1 p-1",
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||||
className
|
className
|
||||||
@@ -107,14 +105,13 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
|
|||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
onClick={item.onClick}
|
onClick={item.onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid h-8 w-8 shrink-0 place-items-center text-slate-600 transition duration-150",
|
"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,
|
||||||
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
|
||||||
item.active
|
item.active
|
||||||
? "bg-blue-600 text-white shadow-xs"
|
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
|
||||||
: "hover:bg-blue-50 hover:text-blue-700",
|
: "hover:bg-blue-50 hover:text-blue-700",
|
||||||
isDanger && "hover:bg-red-50 hover:text-red-700",
|
isDanger && "hover:bg-red-50 hover:text-red-700",
|
||||||
item.disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-slate-600"
|
item.disabled && "opacity-45 hover:bg-transparent hover:text-slate-600"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={16} aria-hidden="true" />
|
<Icon size={16} aria-hidden="true" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Eye, EyeOff, Layers3, Lock } from "lucide-react";
|
import { Eye, EyeOff, Layers3, Lock } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import {
|
import {
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Check, Eye, EyeOff, Lock, Map } from "lucide-react";
|
import { Check, Eye, EyeOff, Lock, Map } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
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 } from "./layer-control";
|
||||||
import {
|
import {
|
||||||
@@ -61,7 +61,7 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
|
|||||||
title={item.description}
|
title={item.description}
|
||||||
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 transition duration-150 active:translate-y-px active:scale-[0.98]",
|
"relative flex min-h-[76px] w-full flex-col items-center justify-center gap-1.5 border px-1.5 py-2 text-center",
|
||||||
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-blue-200 bg-blue-50/90 text-blue-950 shadow-xs shadow-blue-950/5"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
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,
|
||||||
@@ -66,7 +67,7 @@ export function MapLegendList({ items, className = "", showStatusDot = false }:
|
|||||||
<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-slate-500">{item.description}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
{showStatusDot ? <span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" /> : null}
|
{showStatusDot ? <StatusDot tone="success" size="md" /> : null}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { buttonInteractionVariants } from "@/shared/ui/button-interaction";
|
||||||
|
|
||||||
export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl";
|
export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl";
|
||||||
|
|
||||||
export const MAP_CONTROL_RADIUS_CLASS_NAME = "rounded-xl";
|
export const MAP_CONTROL_RADIUS_CLASS_NAME = "rounded-xl";
|
||||||
@@ -24,4 +26,4 @@ export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME =
|
|||||||
"surface-control border";
|
"surface-control border";
|
||||||
|
|
||||||
export const MAP_CONTROL_HOVER_CLASS_NAME =
|
export const MAP_CONTROL_HOVER_CLASS_NAME =
|
||||||
"hover:bg-blue-100/70 hover:text-blue-700 hover:ring-1 hover:ring-blue-200/80 active:bg-blue-100";
|
buttonInteractionVariants({ tone: "neutral" });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { Copy, Ruler, Trash2 } from "lucide-react";
|
import { Copy, Ruler, Trash2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import {
|
import {
|
||||||
MapActionRow,
|
MapActionRow,
|
||||||
MapModeButton,
|
MapModeButton,
|
||||||
@@ -117,7 +117,7 @@ export function MapMeasurePanel({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectUnit?.(unit.id)}
|
onClick={() => onSelectUnit?.(unit.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-7 text-xs font-semibold transition",
|
"h-10 text-xs font-semibold",
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
unit.id === activeUnitId
|
unit.id === activeUnitId
|
||||||
? "bg-blue-600 text-white"
|
? "bg-blue-600 text-white"
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { toast, Toaster } from "sonner";
|
import { toast, Toaster } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles";
|
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles";
|
||||||
import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation";
|
import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation";
|
||||||
import type { MapNoticeOptions, MapNoticePosition } from "./notice-types";
|
import type { MapNoticeOptions, MapNoticePosition } from "./notice-types";
|
||||||
@@ -46,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-7 w-7 place-items-center border border-transparent bg-transparent p-0 text-slate-400 transition hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25",
|
"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",
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import {
|
||||||
|
StatusIcon as SemanticStatusIcon,
|
||||||
|
type StatusTone
|
||||||
|
} from "@/shared/ui/status";
|
||||||
import {
|
import {
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
@@ -10,37 +13,13 @@ import {
|
|||||||
} from "./map-control-styles";
|
} from "./map-control-styles";
|
||||||
import type { MapNoticeOptions, MapNoticeTone } from "./notice-types";
|
import type { MapNoticeOptions, MapNoticeTone } from "./notice-types";
|
||||||
|
|
||||||
const toneClassNames: Record<MapNoticeTone, string> = {
|
const noticeTones = {
|
||||||
info: "acrylic-panel text-slate-900",
|
info: "info",
|
||||||
success: "acrylic-panel text-slate-900",
|
success: "success",
|
||||||
warning: "acrylic-panel text-slate-900",
|
warning: "warning",
|
||||||
error: "acrylic-panel text-slate-900",
|
error: "danger",
|
||||||
loading: "acrylic-panel text-slate-900"
|
loading: "info"
|
||||||
};
|
} satisfies Record<MapNoticeTone, StatusTone>;
|
||||||
|
|
||||||
const iconClassNames: Record<MapNoticeTone, string> = {
|
|
||||||
info: "text-blue-600",
|
|
||||||
success: "text-green-600",
|
|
||||||
warning: "text-orange-500",
|
|
||||||
error: "text-red-500",
|
|
||||||
loading: "text-blue-600"
|
|
||||||
};
|
|
||||||
|
|
||||||
const noticeAccentClassNames: Record<MapNoticeTone, string> = {
|
|
||||||
info: "bg-blue-600",
|
|
||||||
success: "bg-green-500",
|
|
||||||
warning: "bg-orange-500",
|
|
||||||
error: "bg-red-500",
|
|
||||||
loading: "bg-blue-600"
|
|
||||||
};
|
|
||||||
|
|
||||||
const noticeIconSurfaceClassNames: Record<MapNoticeTone, string> = {
|
|
||||||
info: "border-blue-100 bg-blue-50",
|
|
||||||
success: "border-green-100 bg-green-50",
|
|
||||||
warning: "border-orange-100 bg-orange-50",
|
|
||||||
error: "border-red-100 bg-red-50",
|
|
||||||
loading: "border-blue-100 bg-blue-50"
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MapNoticeContent({
|
export function MapNoticeContent({
|
||||||
title,
|
title,
|
||||||
@@ -57,7 +36,11 @@ export function MapNoticeContent({
|
|||||||
<MapNoticeBody
|
<MapNoticeBody
|
||||||
title={title}
|
title={title}
|
||||||
message={message}
|
message={message}
|
||||||
action={actionLabel && onAction ? <MapNoticeAction label={actionLabel} onClick={onAction} /> : null}
|
action={
|
||||||
|
actionLabel && onAction ? (
|
||||||
|
<MapNoticeAction label={actionLabel} onClick={onAction} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</MapNoticeFrame>
|
</MapNoticeFrame>
|
||||||
);
|
);
|
||||||
@@ -77,29 +60,31 @@ function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: Rea
|
|||||||
<div
|
<div
|
||||||
role={tone === "error" || tone === "warning" ? "alert" : "status"}
|
role={tone === "error" || tone === "warning" ? "alert" : "status"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm",
|
"glass-transient relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm text-slate-900",
|
||||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
toneClassNames[tone]
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])} aria-hidden="true" />
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType<typeof getNoticeIcon> }) {
|
function MapNoticeIcon({
|
||||||
|
tone,
|
||||||
|
icon: Icon
|
||||||
|
}: {
|
||||||
|
tone: MapNoticeTone;
|
||||||
|
icon: ReturnType<typeof getNoticeIcon>;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<span
|
<SemanticStatusIcon
|
||||||
className={cn(
|
tone={noticeTones[tone]}
|
||||||
"mt-0.5 grid h-7 w-7 shrink-0 place-items-center border",
|
activity={tone === "loading" ? "loading" : "static"}
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
size="md"
|
||||||
iconClassNames[tone],
|
className={cn("mt-0.5", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
|
||||||
noticeIconSurfaceClassNames[tone]
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
|
<Icon size={17} />
|
||||||
</span>
|
</SemanticStatusIcon>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,8 +99,12 @@ function MapNoticeBody({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
{title ? <span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> : null}
|
{title ? (
|
||||||
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>{message}</span>
|
<span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span>
|
||||||
|
) : null}
|
||||||
|
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
{action}
|
{action}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -123,13 +112,15 @@ function MapNoticeBody({
|
|||||||
|
|
||||||
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
|
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn("mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100", MAP_COMPACT_RADIUS_CLASS_NAME)}
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className={cn("mt-2 border-blue-100 bg-blue-50 text-blue-700", MAP_COMPACT_RADIUS_CLASS_NAME)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { dismissMapNotice, showMapNotice } from "./notice-actions";
|
import { dismissMapNotice, showMapNotice } from "./notice-actions";
|
||||||
@@ -19,7 +17,7 @@ export function MapToaster() {
|
|||||||
classNames: {
|
classNames: {
|
||||||
toast: "group pointer-events-auto",
|
toast: "group pointer-events-auto",
|
||||||
closeButton:
|
closeButton:
|
||||||
"absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-lg border border-transparent text-slate-400 transition hover:bg-white/70 hover:text-slate-700"
|
"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"
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
import { type RefObject, useEffect, useState } from "react";
|
import { type RefObject, useEffect, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import { MAP_CONTROL_SURFACE_CLASS_NAME } from "./map-control-styles";
|
import { MAP_CONTROL_SURFACE_CLASS_NAME } from "./map-control-styles";
|
||||||
|
|
||||||
type ScaleLineState = {
|
type ScaleLineState = {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -46,10 +44,10 @@ export function MapToolbar({
|
|||||||
<nav
|
<nav
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto p-[3px]",
|
"pointer-events-auto p-1",
|
||||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||||
isVertical ? "flex w-[46px] flex-col" : "inline-flex",
|
isVertical ? "flex w-12 flex-col" : "inline-flex",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -69,19 +67,18 @@ export function MapToolbar({
|
|||||||
disabled={tool.disabled}
|
disabled={tool.disabled}
|
||||||
onClick={tool.onClick}
|
onClick={tool.onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative grid h-[38px] w-[38px] shrink-0 place-items-center text-slate-700 transition duration-150",
|
"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,
|
||||||
"focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
|
||||||
tool.active
|
tool.active
|
||||||
? "bg-blue-50 text-blue-800 ring-1 ring-blue-300/70 hover:bg-blue-100 hover:text-blue-900"
|
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
|
||||||
: MAP_CONTROL_HOVER_CLASS_NAME,
|
: MAP_CONTROL_HOVER_CLASS_NAME,
|
||||||
tool.disabled && "cursor-not-allowed text-slate-400 opacity-60 hover:bg-transparent hover:text-slate-400 hover:ring-0 active:bg-transparent"
|
tool.disabled && "text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400 hover:ring-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={20} strokeWidth={2.15} aria-hidden="true" />
|
<Icon size={20} strokeWidth={2.15} aria-hidden="true" />
|
||||||
<span className="sr-only">{tool.label}</span>
|
<span className="sr-only">{tool.label}</span>
|
||||||
{tool.badge ? (
|
{tool.badge ? (
|
||||||
<span className="absolute -right-1 -top-1 grid min-w-4 place-items-center rounded-full bg-red-500 px-1 text-xs font-semibold leading-4 text-white ring-2 ring-white">
|
<span className="status-tone-danger absolute -right-1 -top-1 grid min-w-4 place-items-center rounded-full bg-[var(--status-mark)] px-1 text-xs font-semibold leading-4 text-white ring-2 ring-white">
|
||||||
{tool.badge}
|
{tool.badge}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
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, Plus, type LucideIcon } from "lucide-react";
|
||||||
import { type RefObject, useCallback } from "react";
|
import { type RefObject, useCallback } from "react";
|
||||||
@@ -9,7 +7,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger
|
TooltipTrigger
|
||||||
} from "@/shared/ui/tooltip";
|
} from "@/shared/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import {
|
import {
|
||||||
MAP_CONTROL_HOVER_CLASS_NAME,
|
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||||
@@ -39,13 +37,13 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
|
|||||||
role="group"
|
role="group"
|
||||||
aria-label="地图缩放"
|
aria-label="地图缩放"
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto flex w-[46px] flex-col gap-2",
|
"pointer-events-auto flex w-12 flex-col gap-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col p-[3px]",
|
"flex flex-col p-1",
|
||||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||||
)}
|
)}
|
||||||
@@ -67,7 +65,7 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-[3px]",
|
"p-1",
|
||||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||||
)}
|
)}
|
||||||
@@ -101,13 +99,12 @@ function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonPr
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid h-[38px] w-[38px] place-items-center transition duration-150",
|
"grid h-10 w-10 place-items-center border border-transparent",
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
|
||||||
"bg-transparent text-slate-700",
|
"bg-transparent text-slate-700",
|
||||||
MAP_CONTROL_HOVER_CLASS_NAME,
|
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||||
disabled &&
|
disabled &&
|
||||||
"cursor-not-allowed text-slate-400 opacity-55 hover:bg-transparent hover:text-slate-400 active:bg-transparent"
|
"text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={18} strokeWidth={2.2} aria-hidden="true" />
|
<Icon size={18} strokeWidth={2.2} aria-hidden="true" />
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export {
|
||||||
|
MapAnnotationPanel,
|
||||||
|
type MapAnnotationItem,
|
||||||
|
type MapAnnotationPanelProps,
|
||||||
|
type MapAnnotationShareAction
|
||||||
|
} from "./components/annotation-panel";
|
||||||
|
export { BaseLayersControl, type BaseLayerOption } from "./components/base-layers-control";
|
||||||
|
export {
|
||||||
|
MapActionChip,
|
||||||
|
MapActionRow,
|
||||||
|
MapControlPanel,
|
||||||
|
MapMetricTile,
|
||||||
|
MapModeButton,
|
||||||
|
MapPanelSection,
|
||||||
|
type MapActionRowProps,
|
||||||
|
type MapControlPanelProps,
|
||||||
|
type MapModeButtonProps
|
||||||
|
} from "./components/control-panel";
|
||||||
|
export {
|
||||||
|
MapDrawToolbar,
|
||||||
|
type MapDrawToolbarProps,
|
||||||
|
type MapDrawTool
|
||||||
|
} from "./components/draw-toolbar";
|
||||||
|
export { MapLayerControl, type MapLayerControlItem } from "./components/layer-control";
|
||||||
|
export { MapLayerPanel, type MapLayerPanelProps } from "./components/layer-panel";
|
||||||
|
export { MapLegend, MapLegendList, MapLegendSwatch, type MapLegendItem } from "./components/legend";
|
||||||
|
export {
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||||
|
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||||
|
MAP_FOCUS_SURFACE_CLASS_NAME,
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
|
MAP_READABLE_SURFACE_STRONG_CLASS_NAME,
|
||||||
|
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
||||||
|
} from "./components/map-control-styles";
|
||||||
|
export {
|
||||||
|
MapMeasurePanel,
|
||||||
|
type MapMeasureAction,
|
||||||
|
type MapMeasureMode,
|
||||||
|
type MapMeasurePanelProps,
|
||||||
|
type MapMeasureResultMetric,
|
||||||
|
type MapMeasureUnit
|
||||||
|
} from "./components/measure-panel";
|
||||||
|
export { dismissMapNotice, showMapNotice } from "./components/notice-actions";
|
||||||
|
export {
|
||||||
|
MapErrorNotice,
|
||||||
|
MapLoadingNotice,
|
||||||
|
MapSourceStatusNotice,
|
||||||
|
MapToaster,
|
||||||
|
type MapNoticeOptions,
|
||||||
|
type MapNoticePosition,
|
||||||
|
type MapNoticeTone,
|
||||||
|
type MapSourceStatus,
|
||||||
|
type MapSourceStatusNoticeProps
|
||||||
|
} from "./components/notice";
|
||||||
|
export { MapScaleLine } from "./components/scale-line";
|
||||||
|
export { MapToolbar, type MapToolbarItem } from "./components/toolbar";
|
||||||
|
export { MapZoom } from "./components/zoom";
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import type {
|
import type {
|
||||||
ScheduledConditionRecord
|
ScheduledConditionRecord
|
||||||
} from "@/features/workbench/types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
createVisibleTickerCards,
|
createVisibleTickerCards,
|
||||||
type TickerStackCard
|
type TickerStackCard
|
||||||
@@ -441,7 +439,10 @@ function TickerProgressBar({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<motion.span
|
<motion.span
|
||||||
className={cn("block h-full rounded-full", muted ? "bg-blue-300" : "bg-blue-500")}
|
className={cn(
|
||||||
|
"status-tone-info block h-full rounded-full",
|
||||||
|
muted ? "bg-[var(--status-border)]" : "bg-[var(--status-mark)]"
|
||||||
|
)}
|
||||||
animate={{ width: progressLabel }}
|
animate={{ width: progressLabel }}
|
||||||
initial={false}
|
initial={false}
|
||||||
transition={{ duration: 0.34, ease: tickerEase }}
|
transition={{ duration: 0.34, ease: tickerEase }}
|
||||||
@@ -454,9 +455,9 @@ function TickerStatusDot({ progressValue }: { progressValue?: number }) {
|
|||||||
const progress = typeof progressValue === "number" ? Math.min(100, Math.max(0, progressValue)) : 0;
|
const progress = typeof progressValue === "number" ? Math.min(100, Math.max(0, progressValue)) : 0;
|
||||||
const dashOffset = 44 - (44 * progress) / 100;
|
const dashOffset = 44 - (44 * progress) / 100;
|
||||||
return (
|
return (
|
||||||
<span className="grid h-3.5 w-3.5 place-items-center" aria-hidden="true">
|
<span className="status-tone-info grid h-3.5 w-3.5 place-items-center" aria-hidden="true">
|
||||||
<svg className="h-3.5 w-3.5 -rotate-90" viewBox="0 0 16 16">
|
<svg className="h-3.5 w-3.5 -rotate-90" viewBox="0 0 16 16">
|
||||||
<circle cx="8" cy="8" r="7" fill="none" strokeWidth="2" className="stroke-blue-100/90" />
|
<circle cx="8" cy="8" r="7" fill="none" strokeWidth="2" className="stroke-[var(--status-border)]" />
|
||||||
<motion.circle
|
<motion.circle
|
||||||
cx="8"
|
cx="8"
|
||||||
cy="8"
|
cy="8"
|
||||||
@@ -464,13 +465,13 @@ function TickerStatusDot({ progressValue }: { progressValue?: number }) {
|
|||||||
fill="none"
|
fill="none"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
className="stroke-blue-600"
|
className="stroke-[var(--status-foreground)]"
|
||||||
strokeDasharray="44"
|
strokeDasharray="44"
|
||||||
animate={{ strokeDashoffset: dashOffset }}
|
animate={{ strokeDashoffset: dashOffset }}
|
||||||
initial={false}
|
initial={false}
|
||||||
transition={{ duration: 0.34, ease: tickerEase }}
|
transition={{ duration: 0.34, ease: tickerEase }}
|
||||||
/>
|
/>
|
||||||
<circle cx="8" cy="8" r="3.5" className="fill-blue-600" />
|
<circle cx="8" cy="8" r="3.5" className="fill-[var(--status-mark)]" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
ScheduledConditionRiskLevel,
|
ScheduledConditionRiskLevel,
|
||||||
ScheduledConditionStatus
|
ScheduledConditionStatus
|
||||||
} from "@/features/workbench/types";
|
} from "../types";
|
||||||
|
|
||||||
export function getConditionReportRiskLevel(
|
export function getConditionReportRiskLevel(
|
||||||
status: ScheduledConditionStatus,
|
status: ScheduledConditionStatus,
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
"use client";
|
import { Target, TriangleAlert, X } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { Check, Copy, Target, TriangleAlert, X } from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { SCADA_ICON_PATHS } from "../map/scada";
|
|
||||||
import type { DetailFeature } from "../types";
|
import type { DetailFeature } from "../types";
|
||||||
import {
|
import {
|
||||||
formatFeaturePropertyValue,
|
|
||||||
getFeatureInsightMetrics,
|
getFeatureInsightMetrics,
|
||||||
getLocalizedFeatureProperties
|
getLocalizedFeatureProperties
|
||||||
} from "../utils/feature-properties";
|
} from "../utils/feature-properties";
|
||||||
@@ -19,15 +13,6 @@ type FeatureInsightPanelProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelProps) {
|
export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelProps) {
|
||||||
const [copiedScadaUuid, setCopiedScadaUuid] = useState(false);
|
|
||||||
const copyResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
if (copyResetTimeoutRef.current) {
|
|
||||||
clearTimeout(copyResetTimeoutRef.current);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const metrics = useMemo(() => {
|
const metrics = useMemo(() => {
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
return [];
|
return [];
|
||||||
@@ -41,42 +26,9 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return feature.layer === "scada"
|
return getLocalizedFeatureProperties(feature.properties);
|
||||||
? []
|
|
||||||
: getLocalizedFeatureProperties(feature.properties);
|
|
||||||
}, [feature]);
|
}, [feature]);
|
||||||
|
|
||||||
const scadaQueryUuid = feature?.layer === "scada"
|
|
||||||
? getCopyablePropertyValue(feature.properties.sensor_id ?? feature.id)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const handleCopyScadaUuid = async () => {
|
|
||||||
if (!scadaQueryUuid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!navigator.clipboard) {
|
|
||||||
throw new Error("Clipboard API unavailable");
|
|
||||||
}
|
|
||||||
|
|
||||||
await navigator.clipboard.writeText(scadaQueryUuid);
|
|
||||||
setCopiedScadaUuid(true);
|
|
||||||
|
|
||||||
if (copyResetTimeoutRef.current) {
|
|
||||||
clearTimeout(copyResetTimeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
copyResetTimeoutRef.current = setTimeout(() => setCopiedScadaUuid(false), 1500);
|
|
||||||
} catch {
|
|
||||||
showMapNotice({
|
|
||||||
tone: "error",
|
|
||||||
title: "无法复制 UUID",
|
|
||||||
message: "请手动选择查询 UUID 后复制。"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="acrylic-panel pointer-events-auto flex h-full min-h-0 flex-col rounded border">
|
<aside className="acrylic-panel pointer-events-auto flex h-full min-h-0 flex-col rounded border">
|
||||||
<div className="border-b border-slate-200/80 p-4">
|
<div className="border-b border-slate-200/80 p-4">
|
||||||
@@ -98,73 +50,24 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-slate-600">
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
{feature ? feature.subtitle : "点击地图上的管线或节点后,这里会展示属性、SCADA 摘要和 Agent 解释。"}
|
{feature ? feature.subtitle : "点击地图上的管线或节点后,这里会展示属性和 Agent 解释。"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||||
{feature ? (
|
{feature ? (
|
||||||
<>
|
<>
|
||||||
{feature.layer === "scada" ? (
|
|
||||||
<h3 className="mb-2 text-sm font-semibold text-slate-900">监测数据</h3>
|
|
||||||
) : null}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{metrics.map(({ label, value }, index) => (
|
{metrics.map(({ label, value }) => (
|
||||||
<div
|
<div key={label} className="surface-reading rounded border p-3">
|
||||||
key={label}
|
|
||||||
className={cn(
|
|
||||||
"surface-reading rounded border p-3",
|
|
||||||
feature.layer === "scada" && index === metrics.length - 1 && "col-span-2"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="text-xs text-slate-500">{label}</p>
|
<p className="text-xs text-slate-500">{label}</p>
|
||||||
<p className={cn(
|
<p className="mt-1 truncate text-sm font-semibold text-slate-900 tabular-nums">{value}</p>
|
||||||
"mt-1 truncate text-sm font-semibold text-slate-900 tabular-nums",
|
|
||||||
value === "暂无" && "font-medium text-slate-400"
|
|
||||||
)}>{value}</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{feature.layer === "scada" ? (
|
<section className="material-tone-agent mt-4 rounded border border-violet-100 p-3">
|
||||||
<section className={cn("mt-4 rounded border p-3", getScadaPresentation(feature.properties.kind).sectionClassName)}>
|
<div className="flex items-center gap-2 text-sm font-semibold text-violet-800">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<img
|
|
||||||
src={getScadaPresentation(feature.properties.kind).iconPath}
|
|
||||||
alt=""
|
|
||||||
width={42}
|
|
||||||
height={42}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className={cn("truncate text-sm font-semibold", getScadaPresentation(feature.properties.kind).labelClassName)}>{formatFeaturePropertyValue("sensor_name", feature.properties.sensor_name)}</p>
|
|
||||||
<p className="mt-0.5 truncate text-xs text-slate-500 tabular-nums">{formatFeaturePropertyValue("sensor_id", feature.properties.sensor_id ?? feature.id)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{scadaQueryUuid ? (
|
|
||||||
<div className="mt-3 rounded border border-white/70 bg-white/65 px-3 py-2 shadow-xs">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<p className="text-xs font-semibold text-slate-500">传感器 ID</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
|
|
||||||
title={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
|
|
||||||
onClick={() => void handleCopyScadaUuid()}
|
|
||||||
className="relative grid h-8 w-8 shrink-0 place-items-center rounded border border-slate-200/80 bg-white/80 text-slate-500 transition-[background-color,color,transform] duration-150 hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
|
||||||
>
|
|
||||||
<Copy size={14} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copiedScadaUuid ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
|
|
||||||
<Check size={15} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copiedScadaUuid ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 break-all font-mono text-xs font-semibold leading-5 text-slate-900">{scadaQueryUuid}</p>
|
|
||||||
<p className="mt-1 text-xs leading-5 text-slate-500">SCADA 时序查询 sensor_id</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<p className="mt-2 text-sm leading-6 text-slate-700">设备类型为综合监测点,数据源为 geo_scadas_mat。</p>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="material-tone-warning mt-4 rounded border border-orange-100 p-3">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-orange-900">
|
|
||||||
<TriangleAlert size={16} aria-hidden="true" />
|
<TriangleAlert size={16} aria-hidden="true" />
|
||||||
Agent 解释
|
Agent 解释
|
||||||
</div>
|
</div>
|
||||||
@@ -173,14 +76,12 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{feature.layer !== "scada" ? (
|
<section className="mt-4">
|
||||||
<section className="mt-4">
|
<h3 className="text-sm font-semibold text-slate-900">属性</h3>
|
||||||
<h3 className="text-sm font-semibold text-slate-900">属性</h3>
|
<div className="mt-2">
|
||||||
<div className="mt-2">
|
<FeaturePropertyList entries={propertyEntries} />
|
||||||
<FeaturePropertyList entries={propertyEntries} />
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||||
@@ -194,28 +95,6 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="sr-only" aria-live="polite">{copiedScadaUuid ? "编号 UUID 已复制" : ""}</span>
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCopyablePropertyValue(value: unknown) {
|
|
||||||
if (typeof value === "string" && value.trim().length > 0) {
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "number" || typeof value === "bigint") {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getScadaPresentation(rawKind: unknown) {
|
|
||||||
const kind = String(rawKind ?? "");
|
|
||||||
if (kind === "water_quality") return { iconPath: SCADA_ICON_PATHS.waterQuality, sectionClassName: "material-tone-normal border-teal-200", labelClassName: "text-teal-800" };
|
|
||||||
if (kind === "radar_level") return { iconPath: SCADA_ICON_PATHS.radarLevel, sectionClassName: "material-tone-info border-blue-200", labelClassName: "text-blue-800" };
|
|
||||||
if (kind === "ultrasonic_flow") return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, sectionClassName: "material-tone-warning border-orange-200", labelClassName: "text-orange-800" };
|
|
||||||
if (kind === "integrated_monitoring" || kind === "综合监测点") return { iconPath: SCADA_ICON_PATHS.integratedMonitoring, sectionClassName: "material-tone-normal border-cyan-200", labelClassName: "text-cyan-800" };
|
|
||||||
return { iconPath: SCADA_ICON_PATHS.integratedMonitoring, sectionClassName: "material-tone-normal border-cyan-200", labelClassName: "text-cyan-800" };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Check, Copy, Database, X } from "lucide-react";
|
import { Check, Copy, Database, X } from "lucide-react";
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
MAP_FOCUS_SURFACE_CLASS_NAME,
|
MAP_FOCUS_SURFACE_CLASS_NAME,
|
||||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||||
} from "@/features/map/core/components/map-control-styles";
|
} from "@/features/map/core";
|
||||||
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
import { showMapNotice } from "@/features/map/core";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import type { WaterNetworkSourceId } from "../map/sources";
|
import type { WaterNetworkSourceId } from "../map/sources";
|
||||||
import type { DetailFeature } from "../types";
|
import type { DetailFeature } from "../types";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import type { LocalizedFeatureProperty } from "../utils/feature-properties";
|
import type { LocalizedFeatureProperty } from "../utils/feature-properties";
|
||||||
|
|
||||||
type FeaturePropertyListProps = {
|
type FeaturePropertyListProps = {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { ChevronDown, Crosshair, LocateFixed, RotateCcw, X } from "lucide-react";
|
import { ChevronDown, Crosshair, LocateFixed, RotateCcw, X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import {
|
import {
|
||||||
LAYER_GROUP_GEOMETRIES,
|
LAYER_GROUP_GEOMETRIES,
|
||||||
type LayerGroupId,
|
type LayerGroupId,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
|
||||||
|
|
||||||
|
export type MobileWorkbenchSheetSnap = "half" | "full";
|
||||||
|
|
||||||
|
export function getMobileWorkbenchSheetHeight(
|
||||||
|
snap: MobileWorkbenchSheetSnap,
|
||||||
|
viewportHeight: number
|
||||||
|
) {
|
||||||
|
if (snap === "half") {
|
||||||
|
return Math.round(viewportHeight * WORKBENCH_LAYOUT.mobileSheet.halfViewportRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(
|
||||||
|
Math.round(viewportHeight * WORKBENCH_LAYOUT.mobileSheet.halfViewportRatio),
|
||||||
|
viewportHeight - WORKBENCH_LAYOUT.mobileSheet.fullTopGap
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getMobileWorkbenchSheetHeight } from "./mobile-workbench-sheet-layout";
|
||||||
|
|
||||||
|
describe("mobile workbench sheet heights", () => {
|
||||||
|
it("keeps the two deterministic snap heights", () => {
|
||||||
|
expect(getMobileWorkbenchSheetHeight("half", 844)).toBe(439);
|
||||||
|
expect(getMobileWorkbenchSheetHeight("full", 844)).toBe(776);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always leaves the configured map strip visible at full height", () => {
|
||||||
|
const viewportHeight = 667;
|
||||||
|
const sheetHeight = getMobileWorkbenchSheetHeight("full", viewportHeight);
|
||||||
|
|
||||||
|
expect(viewportHeight - sheetHeight).toBe(68);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type PointerEvent,
|
||||||
|
type ReactNode
|
||||||
|
} from "react";
|
||||||
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import {
|
||||||
|
getMobileWorkbenchSheetHeight,
|
||||||
|
type MobileWorkbenchSheetSnap
|
||||||
|
} from "./mobile-workbench-sheet-layout";
|
||||||
|
|
||||||
|
export type { MobileWorkbenchSheetSnap } from "./mobile-workbench-sheet-layout";
|
||||||
|
|
||||||
|
type MobileWorkbenchSheetProps = {
|
||||||
|
label: string;
|
||||||
|
snap: MobileWorkbenchSheetSnap;
|
||||||
|
onSnapChange: (snap: MobileWorkbenchSheetSnap) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SNAP_ORDER: MobileWorkbenchSheetSnap[] = ["half", "full"];
|
||||||
|
|
||||||
|
export function MobileWorkbenchSheet({
|
||||||
|
label,
|
||||||
|
snap,
|
||||||
|
onSnapChange,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}: MobileWorkbenchSheetProps) {
|
||||||
|
const pointerStartRef = useRef<{ y: number; height: number } | null>(null);
|
||||||
|
const draggedRef = useRef(false);
|
||||||
|
const [dragHeight, setDragHeight] = useState<number | null>(null);
|
||||||
|
const [viewportHeight, setViewportHeight] = useState(
|
||||||
|
typeof window === "undefined" ? 844 : window.innerHeight
|
||||||
|
);
|
||||||
|
const restingHeight = getMobileWorkbenchSheetHeight(snap, viewportHeight);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateViewportHeight = () => setViewportHeight(window.innerHeight);
|
||||||
|
window.addEventListener("resize", updateViewportHeight);
|
||||||
|
window.visualViewport?.addEventListener("resize", updateViewportHeight);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", updateViewportHeight);
|
||||||
|
window.visualViewport?.removeEventListener("resize", updateViewportHeight);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (event: globalThis.KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleEscape);
|
||||||
|
return () => window.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent<HTMLButtonElement>) {
|
||||||
|
pointerStartRef.current = {
|
||||||
|
y: event.clientY,
|
||||||
|
height: restingHeight
|
||||||
|
};
|
||||||
|
draggedRef.current = false;
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent<HTMLButtonElement>) {
|
||||||
|
const start = pointerStartRef.current;
|
||||||
|
if (!start) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = start.y - event.clientY;
|
||||||
|
if (Math.abs(delta) > 4) {
|
||||||
|
draggedRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minHeight = getMobileWorkbenchSheetHeight("half", window.innerHeight);
|
||||||
|
const maxHeight = getMobileWorkbenchSheetHeight("full", window.innerHeight);
|
||||||
|
setDragHeight(Math.min(maxHeight, Math.max(minHeight, start.height + delta)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event: PointerEvent<HTMLButtonElement>) {
|
||||||
|
if (!pointerStartRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalHeight = dragHeight ?? pointerStartRef.current.height;
|
||||||
|
pointerStartRef.current = null;
|
||||||
|
setDragHeight(null);
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
|
||||||
|
if (!draggedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSnapChange(getClosestSnap(finalHeight, window.innerHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (draggedRef.current) {
|
||||||
|
draggedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = SNAP_ORDER.indexOf(snap);
|
||||||
|
onSnapChange(SNAP_ORDER[(currentIndex + 1) % SNAP_ORDER.length] ?? "half");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
onSnapChange(getAdjacentSnap(snap, 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
onSnapChange(getAdjacentSnap(snap, -1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Home") {
|
||||||
|
event.preventDefault();
|
||||||
|
onSnapChange("half");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "End") {
|
||||||
|
event.preventDefault();
|
||||||
|
onSnapChange("full");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={label}
|
||||||
|
data-snap={snap}
|
||||||
|
className={cn(
|
||||||
|
"mobile-workbench-sheet acrylic-panel pointer-events-auto absolute bottom-0 left-0 right-0 z-40 flex min-h-0 flex-col overflow-hidden rounded-t-[20px] border-x border-t transition-[height] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none lg:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ height: dragHeight ?? restingHeight }}
|
||||||
|
>
|
||||||
|
<div className="mobile-workbench-sheet-handle relative z-40 flex h-7 shrink-0 items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${label}高度,当前${getSnapLabel(snap)}`}
|
||||||
|
className="group flex h-7 flex-1 touch-none items-center justify-center"
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
>
|
||||||
|
<span className="h-1 w-10 rounded-full bg-slate-400/70 transition-[width,background-color] group-focus-visible:w-12 group-focus-visible:bg-blue-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overscroll-contain">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdjacentSnap(
|
||||||
|
current: MobileWorkbenchSheetSnap,
|
||||||
|
direction: -1 | 1
|
||||||
|
): MobileWorkbenchSheetSnap {
|
||||||
|
const currentIndex = SNAP_ORDER.indexOf(current);
|
||||||
|
const nextIndex = Math.min(SNAP_ORDER.length - 1, Math.max(0, currentIndex + direction));
|
||||||
|
return SNAP_ORDER[nextIndex] ?? current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClosestSnap(height: number, viewportHeight: number): MobileWorkbenchSheetSnap {
|
||||||
|
return SNAP_ORDER.reduce((closest, candidate) => {
|
||||||
|
const candidateDistance = Math.abs(
|
||||||
|
getMobileWorkbenchSheetHeight(candidate, viewportHeight) - height
|
||||||
|
);
|
||||||
|
const closestDistance = Math.abs(
|
||||||
|
getMobileWorkbenchSheetHeight(closest, viewportHeight) - height
|
||||||
|
);
|
||||||
|
return candidateDistance < closestDistance ? candidate : closest;
|
||||||
|
}, "half");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapLabel(snap: MobileWorkbenchSheetSnap) {
|
||||||
|
if (snap === "half") {
|
||||||
|
return "半屏档";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "全屏档";
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Bot,
|
Bot,
|
||||||
@@ -20,13 +18,18 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { showMapNotice } from "@/features/map/core";
|
||||||
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import {
|
||||||
|
StatusBadge,
|
||||||
|
StatusIcon as SemanticStatusIcon,
|
||||||
|
type StatusTone
|
||||||
|
} from "@/shared/ui/status";
|
||||||
import {
|
import {
|
||||||
getRunningWorkflowDefinition,
|
getRunningWorkflowDefinition,
|
||||||
getRunningWorkflowState,
|
getRunningWorkflowState,
|
||||||
getWorkflowStepStatus
|
getWorkflowStepStatus
|
||||||
} from "@/features/workbench/data/running-workflows";
|
} from "../data/running-workflows";
|
||||||
import type {
|
import type {
|
||||||
ScheduledConditionAnalysisInsight,
|
ScheduledConditionAnalysisInsight,
|
||||||
ScheduledConditionItem,
|
ScheduledConditionItem,
|
||||||
@@ -34,30 +37,31 @@ import type {
|
|||||||
ScheduledConditionRecord,
|
ScheduledConditionRecord,
|
||||||
ScheduledConditionRiskLevel,
|
ScheduledConditionRiskLevel,
|
||||||
ScheduledWorkOrderItem
|
ScheduledWorkOrderItem
|
||||||
} from "@/features/workbench/types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
formatDuration,
|
formatDuration,
|
||||||
formatTime,
|
formatTime,
|
||||||
formatTimeRange,
|
formatTimeRange,
|
||||||
|
getStatusActivity,
|
||||||
|
getStatusTone,
|
||||||
getRiskLabel,
|
getRiskLabel,
|
||||||
getWorkOrderCurrentStage,
|
getWorkOrderCurrentStage,
|
||||||
getWorkOrderStagePillClassName,
|
getWorkOrderStageActivity,
|
||||||
statusClassNames,
|
getWorkOrderStageTone,
|
||||||
statusLabels
|
statusLabels
|
||||||
} from "./scheduled-condition-feed-utils";
|
} from "./scheduled-condition-feed-utils";
|
||||||
import { getConditionReportRiskLevel } from "./condition-report-risk";
|
import { getConditionReportRiskLevel } from "./condition-report-risk";
|
||||||
|
|
||||||
const RUNNING_WORKFLOW_TICK_MS = 1_000;
|
const RUNNING_WORKFLOW_TICK_MS = 1_000;
|
||||||
const STATUS_PILL_CLASS_NAME = "rounded-full border px-2 py-0.5 text-xs font-semibold leading-4";
|
|
||||||
const NEUTRAL_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
|
const NEUTRAL_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
|
||||||
const INSET_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
|
const INSET_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
|
||||||
const REPORT_META_TILE_CLASS_NAME = "surface-control rounded-lg border border-slate-200/70 px-2 py-1.5";
|
const REPORT_META_TILE_CLASS_NAME = "surface-control rounded-lg border border-slate-200/70 px-2 py-1.5";
|
||||||
|
|
||||||
const riskIndicatorClassNames: Record<ScheduledConditionRiskLevel, string> = {
|
const riskTones = {
|
||||||
normal: "border-slate-200 bg-slate-50 text-slate-600",
|
normal: "success",
|
||||||
attention: "border-orange-200 bg-orange-50 text-orange-700",
|
attention: "warning",
|
||||||
critical: "border-red-200 bg-red-50 text-red-700"
|
critical: "danger"
|
||||||
};
|
} satisfies Record<ScheduledConditionRiskLevel, StatusTone>;
|
||||||
|
|
||||||
type IncidentOperation = {
|
type IncidentOperation = {
|
||||||
target: string;
|
target: string;
|
||||||
@@ -88,6 +92,7 @@ type ExecutionStep = {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
status: ExecutionStepStatus;
|
status: ExecutionStepStatus;
|
||||||
|
statusLabel?: string;
|
||||||
meta?: ExecutionStepMeta[];
|
meta?: ExecutionStepMeta[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,15 +112,21 @@ export function StatusPill({
|
|||||||
const currentStage = isWorkOrder ? getWorkOrderCurrentStage(condition) : null;
|
const currentStage = isWorkOrder ? getWorkOrderCurrentStage(condition) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<StatusBadge
|
||||||
className={cn(
|
tone={
|
||||||
STATUS_PILL_CLASS_NAME,
|
isWorkOrder
|
||||||
isWorkOrder ? getWorkOrderStagePillClassName(currentStage?.id) : statusClassNames[condition.status],
|
? getWorkOrderStageTone(currentStage?.id)
|
||||||
className
|
: getStatusTone(condition.status)
|
||||||
)}
|
}
|
||||||
|
activity={
|
||||||
|
isWorkOrder
|
||||||
|
? getWorkOrderStageActivity(currentStage?.id)
|
||||||
|
: getStatusActivity(condition.status)
|
||||||
|
}
|
||||||
|
className={className}
|
||||||
>
|
>
|
||||||
{isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]}
|
{isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]}
|
||||||
</span>
|
</StatusBadge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +146,7 @@ export function ConditionDetailPanel({ condition, onContinueConversation }: Cond
|
|||||||
<AnimatePresence initial={false} mode="wait">
|
<AnimatePresence initial={false} mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={detailKey}
|
key={detailKey}
|
||||||
className="min-h-0"
|
className="relative h-full min-h-0"
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -6 }}
|
exit={{ opacity: 0, y: -6 }}
|
||||||
@@ -200,7 +211,7 @@ function ConditionRecordDetailPanel({
|
|||||||
<DecisionSummaryBlock
|
<DecisionSummaryBlock
|
||||||
icon={ShieldAlert}
|
icon={ShieldAlert}
|
||||||
title="异常判断"
|
title="异常判断"
|
||||||
iconClassName="text-red-600"
|
iconClassName="status-tone-danger text-[var(--status-foreground)]"
|
||||||
summary={insight.summary}
|
summary={insight.summary}
|
||||||
notes={insight.notes}
|
notes={insight.notes}
|
||||||
/>
|
/>
|
||||||
@@ -223,7 +234,7 @@ function ConditionRecordDetailPanel({
|
|||||||
<DecisionSummaryBlock
|
<DecisionSummaryBlock
|
||||||
icon={Lightbulb}
|
icon={Lightbulb}
|
||||||
title="关注说明"
|
title="关注说明"
|
||||||
iconClassName="text-orange-600"
|
iconClassName="status-tone-warning text-[var(--status-foreground)]"
|
||||||
summary={insight.summary}
|
summary={insight.summary}
|
||||||
notes={insight.notes}
|
notes={insight.notes}
|
||||||
/>
|
/>
|
||||||
@@ -522,7 +533,9 @@ function IncidentWorkOrderPanel({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"mt-1 flex min-h-9 w-full items-center justify-center gap-1.5 px-3 text-xs font-semibold leading-4 transition-colors",
|
"mt-1 flex min-h-9 w-full items-center justify-center gap-1.5 px-3 text-xs font-semibold leading-4 transition-colors",
|
||||||
"rounded-xl",
|
"rounded-xl",
|
||||||
submitted ? "cursor-not-allowed bg-green-50 text-green-700" : "bg-blue-600 text-white hover:bg-blue-700"
|
submitted
|
||||||
|
? "status-tone-success cursor-not-allowed bg-[var(--status-soft)] text-[var(--status-foreground)]"
|
||||||
|
: "bg-blue-600 text-white hover:bg-blue-700"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{submitted ? <CheckCircle2 size={14} aria-hidden="true" /> : <Send size={14} aria-hidden="true" />}
|
{submitted ? <CheckCircle2 size={14} aria-hidden="true" /> : <Send size={14} aria-hidden="true" />}
|
||||||
@@ -545,9 +558,9 @@ function IncidentOperationList({
|
|||||||
<article key={`${operation.target}-${operation.action}`} className="px-2.5 py-2">
|
<article key={`${operation.target}-${operation.action}`} className="px-2.5 py-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<SectionEyebrow icon={Wrench} label={operation.action} />
|
<SectionEyebrow icon={Wrench} label={operation.action} />
|
||||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", submitted ? "border-green-100 bg-green-50 text-green-700" : "border-slate-200 bg-white text-slate-500")}>
|
<StatusBadge tone={submitted ? "success" : "neutral"}>
|
||||||
{submitted ? "已通知" : operation.elementType}
|
{submitted ? "已通知" : operation.elementType}
|
||||||
</span>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<dl className="mt-1.5 grid gap-1 text-xs leading-5">
|
<dl className="mt-1.5 grid gap-1 text-xs leading-5">
|
||||||
<div className="grid grid-cols-[56px_1fr] gap-2">
|
<div className="grid grid-cols-[56px_1fr] gap-2">
|
||||||
@@ -647,10 +660,9 @@ function ConditionStatusReport({
|
|||||||
|
|
||||||
function LifecyclePill() {
|
function LifecyclePill() {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full border border-green-100 bg-green-50 px-2 py-0.5 text-xs font-semibold leading-4 text-green-700">
|
<StatusBadge tone="success" icon={<CheckCircle2 size={12} />}>
|
||||||
<CheckCircle2 size={12} aria-hidden="true" />
|
完成
|
||||||
<span>完成</span>
|
</StatusBadge>
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,23 +671,16 @@ function RiskBadge({ riskLevel }: { riskLevel: ScheduledConditionRiskLevel }) {
|
|||||||
const label = getRiskLabel(riskLevel);
|
const label = getRiskLabel(riskLevel);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<StatusBadge
|
||||||
className={cn(
|
tone={riskTones[riskLevel]}
|
||||||
"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-semibold leading-4",
|
icon={<Icon size={12} />}
|
||||||
getRiskIndicatorClassName(riskLevel)
|
|
||||||
)}
|
|
||||||
aria-label={`风险等级:${label}`}
|
aria-label={`风险等级:${label}`}
|
||||||
>
|
>
|
||||||
<Icon size={12} aria-hidden="true" />
|
{label}
|
||||||
<span>{label}</span>
|
</StatusBadge>
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRiskIndicatorClassName(riskLevel: ScheduledConditionRiskLevel) {
|
|
||||||
return riskIndicatorClassNames[riskLevel];
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReportMetaTile({ label, value }: { label: string; value: string }) {
|
function ReportMetaTile({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={REPORT_META_TILE_CLASS_NAME}>
|
<div className={REPORT_META_TILE_CLASS_NAME}>
|
||||||
@@ -711,7 +716,7 @@ function ReportSection({
|
|||||||
|
|
||||||
function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("surface-reading min-w-0 rounded-xl border px-2.5 py-2", getKpiSurfaceClassName(kpi.status))}>
|
<div className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2.5 py-2">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-xs font-semibold text-slate-500" title={kpi.label}>
|
<div className="truncate text-xs font-semibold text-slate-500" title={kpi.label}>
|
||||||
@@ -722,9 +727,9 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
|||||||
{kpi.unit ? <span className="text-xs font-semibold text-slate-500">{kpi.unit}</span> : null}
|
{kpi.unit ? <span className="text-xs font-semibold text-slate-500">{kpi.unit}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={cn("shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getRiskIndicatorClassName(kpi.status))}>
|
<StatusBadge tone={riskTones[kpi.status]}>
|
||||||
{getRiskLabel(kpi.status)}
|
{getRiskLabel(kpi.status)}
|
||||||
</span>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 text-xs leading-4 text-slate-600">{kpi.description}</p>
|
<p className="mt-1.5 text-xs leading-4 text-slate-600">{kpi.description}</p>
|
||||||
{kpi.threshold || kpi.baseline ? (
|
{kpi.threshold || kpi.baseline ? (
|
||||||
@@ -737,18 +742,6 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKpiSurfaceClassName(riskLevel: ScheduledConditionRiskLevel) {
|
|
||||||
if (riskLevel === "critical") {
|
|
||||||
return "border-red-200/80";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (riskLevel === "attention") {
|
|
||||||
return "border-orange-200/80";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "border-slate-200/70";
|
|
||||||
}
|
|
||||||
|
|
||||||
function DecisionActionStrip({
|
function DecisionActionStrip({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
title,
|
title,
|
||||||
@@ -808,64 +801,36 @@ function SectionEyebrow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTaskExecutionBadgeClassName(tone: ExecutionBadgeTone) {
|
function getTaskExecutionBadgeTone(tone: ExecutionBadgeTone): StatusTone {
|
||||||
if (tone === "completed") {
|
if (tone === "completed") {
|
||||||
return "border-green-100 bg-green-50 text-green-700";
|
return "success";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tone === "confirmation") {
|
if (tone === "confirmation") {
|
||||||
return "border-orange-100 bg-orange-50 text-orange-700";
|
return "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tone === "active") {
|
if (tone === "active") {
|
||||||
return "border-blue-100 bg-blue-50 text-blue-700";
|
return "info";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "border-slate-200 bg-slate-50 text-slate-600";
|
return "neutral";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTaskExecutionStepIconClassName(status: ExecutionStepStatus) {
|
function getTaskExecutionStepTone(status: ExecutionStepStatus): StatusTone {
|
||||||
if (status === "done") {
|
if (status === "done") {
|
||||||
return "bg-green-500 text-white";
|
return "success";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "current") {
|
if (status === "current") {
|
||||||
return "bg-blue-600 text-white";
|
return "info";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "confirmation") {
|
if (status === "confirmation") {
|
||||||
return "bg-orange-500 text-white";
|
return "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "bg-slate-100 text-slate-400";
|
return "neutral";
|
||||||
}
|
|
||||||
|
|
||||||
function getTaskExecutionStepSurfaceClassName(status: ExecutionStepStatus) {
|
|
||||||
if (status === "current") {
|
|
||||||
return "surface-reading border-blue-100";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "confirmation") {
|
|
||||||
return "surface-reading border-orange-100";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "surface-reading border-slate-200/70";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTaskExecutionStepStatusClassName(status: ExecutionStepStatus) {
|
|
||||||
if (status === "done") {
|
|
||||||
return "border-green-100 bg-green-50 text-green-700";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "current") {
|
|
||||||
return "border-blue-100 bg-blue-50 text-blue-700";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "confirmation") {
|
|
||||||
return "border-orange-100 bg-orange-50 text-orange-700";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "border-slate-200 bg-white text-slate-500";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTaskExecutionStepStatusLabel(status: ExecutionStepStatus) {
|
function getTaskExecutionStepStatusLabel(status: ExecutionStepStatus) {
|
||||||
@@ -1003,9 +968,12 @@ function TaskExecutionPanel({
|
|||||||
<div className="border-b border-slate-200/70 px-3 py-3">
|
<div className="border-b border-slate-200/70 px-3 py-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<SectionTitle icon={Icon} title={title} />
|
<SectionTitle icon={Icon} title={title} />
|
||||||
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-semibold leading-4", getTaskExecutionBadgeClassName(badgeTone))}>
|
<StatusBadge
|
||||||
|
tone={getTaskExecutionBadgeTone(badgeTone)}
|
||||||
|
activity={badgeTone === "active" ? "live" : "static"}
|
||||||
|
>
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
{summary ? <p className="mt-2 text-xs leading-5 text-slate-700">{summary}</p> : null}
|
{summary ? <p className="mt-2 text-xs leading-5 text-slate-700">{summary}</p> : null}
|
||||||
{meta?.length ? (
|
{meta?.length ? (
|
||||||
@@ -1044,29 +1012,30 @@ function TaskExecutionStepRow({
|
|||||||
{!isLast ? (
|
{!isLast ? (
|
||||||
<span className="absolute bottom-[-0.65rem] top-7 w-px bg-slate-200" aria-hidden="true" />
|
<span className="absolute bottom-[-0.65rem] top-7 w-px bg-slate-200" aria-hidden="true" />
|
||||||
) : null}
|
) : null}
|
||||||
<span className={cn("relative z-10 grid h-7 w-7 place-items-center rounded-full ring-4 ring-white", getTaskExecutionStepIconClassName(step.status))}>
|
<SemanticStatusIcon
|
||||||
|
tone={getTaskExecutionStepTone(step.status)}
|
||||||
|
activity={step.status === "current" ? "loading" : "static"}
|
||||||
|
shape="circle"
|
||||||
|
size="md"
|
||||||
|
className="relative z-10 ring-4 ring-white"
|
||||||
|
>
|
||||||
{step.status === "done" ? (
|
{step.status === "done" ? (
|
||||||
<CheckCircle2 size={14} aria-hidden="true" />
|
<CheckCircle2 size={14} />
|
||||||
) : step.status === "current" ? (
|
) : step.status === "current" ? (
|
||||||
<Loader2 size={14} aria-hidden="true" className="animate-spin" />
|
<Loader2 size={14} />
|
||||||
) : step.status === "confirmation" ? (
|
) : step.status === "confirmation" ? (
|
||||||
<AlertTriangle size={14} aria-hidden="true" />
|
<AlertTriangle size={14} />
|
||||||
) : (
|
) : (
|
||||||
<Clock3 size={14} aria-hidden="true" />
|
<Clock3 size={14} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</SemanticStatusIcon>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2.5 py-2">
|
||||||
className={cn(
|
|
||||||
"min-w-0 rounded-xl border px-2.5 py-2",
|
|
||||||
getTaskExecutionStepSurfaceClassName(step.status)
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
<span className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||||
<span className="text-xs font-semibold text-slate-900">{step.title}</span>
|
<span className="text-xs font-semibold text-slate-900">{step.title}</span>
|
||||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getTaskExecutionStepStatusClassName(step.status))}>
|
<StatusBadge tone={getTaskExecutionStepTone(step.status)}>
|
||||||
{getTaskExecutionStepStatusLabel(step.status)}
|
{step.statusLabel ?? getTaskExecutionStepStatusLabel(step.status)}
|
||||||
</span>
|
</StatusBadge>
|
||||||
</span>
|
</span>
|
||||||
{step.meta?.length ? (
|
{step.meta?.length ? (
|
||||||
<span className="mt-1 grid gap-1 text-xs leading-4 text-slate-500 sm:grid-cols-[68px_1fr]">
|
<span className="mt-1 grid gap-1 text-xs leading-4 text-slate-500 sm:grid-cols-[68px_1fr]">
|
||||||
@@ -1154,7 +1123,9 @@ function RunningEvidencePreview({ items }: { items: string[] }) {
|
|||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<span className="line-clamp-1">{item}</span>
|
<span className="line-clamp-1">{item}</span>
|
||||||
<span className="mt-0.5 rounded-full bg-blue-50 px-1.5 py-0.5 text-xs font-semibold leading-4 text-blue-700">采集中</span>
|
<StatusBadge tone="info" activity="loading" className="mt-0.5">
|
||||||
|
采集中
|
||||||
|
</StatusBadge>
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getStatusActivity,
|
||||||
|
getStatusTone,
|
||||||
|
getWorkOrderStageActivity,
|
||||||
|
getWorkOrderStageTone
|
||||||
|
} from "./scheduled-condition-feed-utils";
|
||||||
|
|
||||||
|
describe("scheduled condition status presentation", () => {
|
||||||
|
it("maps lifecycle states to the shared semantic language", () => {
|
||||||
|
expect({
|
||||||
|
pending: getStatusTone("pending"),
|
||||||
|
running: getStatusTone("running"),
|
||||||
|
awaitingReply: getStatusTone("awaiting_reply"),
|
||||||
|
completed: getStatusTone("completed"),
|
||||||
|
warning: getStatusTone("warning"),
|
||||||
|
error: getStatusTone("error")
|
||||||
|
}).toEqual({
|
||||||
|
pending: "neutral",
|
||||||
|
running: "info",
|
||||||
|
awaitingReply: "warning",
|
||||||
|
completed: "success",
|
||||||
|
warning: "warning",
|
||||||
|
error: "danger"
|
||||||
|
});
|
||||||
|
expect(getStatusActivity("running")).toBe("loading");
|
||||||
|
expect(getStatusActivity("awaiting_reply")).toBe("static");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps work-order dispatch, execution, and reply visually distinct", () => {
|
||||||
|
expect({
|
||||||
|
dispatch: getWorkOrderStageTone("dispatch"),
|
||||||
|
execution: getWorkOrderStageTone("execution"),
|
||||||
|
reply: getWorkOrderStageTone("reply")
|
||||||
|
}).toEqual({
|
||||||
|
dispatch: "neutral",
|
||||||
|
execution: "info",
|
||||||
|
reply: "warning"
|
||||||
|
});
|
||||||
|
expect(getWorkOrderStageActivity("execution")).toBe("loading");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,51 +5,75 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
XCircle
|
XCircle
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import type { StatusActivity, StatusTone } from "@/shared/ui/status";
|
||||||
import type {
|
import type {
|
||||||
ScheduledConditionItem,
|
ScheduledConditionItem,
|
||||||
ScheduledConditionStatus,
|
ScheduledConditionItemStatus,
|
||||||
ScheduledWorkOrderItem,
|
ScheduledWorkOrderItem,
|
||||||
ScheduledWorkOrderStage
|
ScheduledWorkOrderStage
|
||||||
} from "@/features/workbench/types";
|
} from "../types";
|
||||||
|
|
||||||
export const CONDITION_FILTER_ALL = "__all__";
|
export const CONDITION_FILTER_ALL = "__all__";
|
||||||
|
|
||||||
export const statusLabels: Record<ScheduledConditionStatus, string> = {
|
export const statusLabels: Record<ScheduledConditionItemStatus, string> = {
|
||||||
completed: "完成",
|
completed: "完成",
|
||||||
running: "执行中",
|
running: "执行中",
|
||||||
|
awaiting_reply: "待复令",
|
||||||
warning: "关注",
|
warning: "关注",
|
||||||
error: "异常",
|
error: "异常",
|
||||||
pending: "预约"
|
pending: "预约"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const statusClassNames: Record<ScheduledConditionStatus, string> = {
|
const statusTones = {
|
||||||
completed: "border-green-100 bg-green-50 text-green-700",
|
completed: "success",
|
||||||
running: "border-blue-100 bg-blue-50 text-blue-700",
|
running: "info",
|
||||||
warning: "border-orange-100 bg-orange-50 text-orange-700",
|
awaiting_reply: "warning",
|
||||||
error: "border-red-100 bg-red-50 text-red-700",
|
warning: "warning",
|
||||||
pending: "border-slate-200 bg-slate-100 text-slate-600"
|
error: "danger",
|
||||||
};
|
pending: "neutral"
|
||||||
|
} satisfies Record<ScheduledConditionItemStatus, StatusTone>;
|
||||||
|
|
||||||
const statusIcons: Record<ScheduledConditionStatus, typeof CheckCircle2> = {
|
const statusActivities = {
|
||||||
|
completed: "static",
|
||||||
|
running: "loading",
|
||||||
|
awaiting_reply: "static",
|
||||||
|
warning: "static",
|
||||||
|
error: "static",
|
||||||
|
pending: "static"
|
||||||
|
} satisfies Record<ScheduledConditionItemStatus, StatusActivity>;
|
||||||
|
|
||||||
|
const statusIcons: Record<ScheduledConditionItemStatus, typeof CheckCircle2> = {
|
||||||
completed: CheckCircle2,
|
completed: CheckCircle2,
|
||||||
running: Loader2,
|
running: Loader2,
|
||||||
|
awaiting_reply: Clock3,
|
||||||
warning: AlertTriangle,
|
warning: AlertTriangle,
|
||||||
error: XCircle,
|
error: XCircle,
|
||||||
pending: Clock3
|
pending: Clock3
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusIconClassNames: Record<ScheduledConditionStatus, string> = {
|
const workOrderStageTones = {
|
||||||
completed: "bg-green-50 text-green-700",
|
dispatch: "neutral",
|
||||||
running: "bg-blue-50 text-blue-700",
|
execution: "info",
|
||||||
warning: "bg-orange-50 text-orange-700",
|
reply: "warning"
|
||||||
error: "bg-red-50 text-red-700",
|
} satisfies Record<ScheduledWorkOrderStage["id"], StatusTone>;
|
||||||
pending: "bg-slate-100 text-slate-600"
|
|
||||||
};
|
|
||||||
|
|
||||||
const conditionStatusOrder: ScheduledConditionStatus[] = ["running", "warning", "error", "completed", "pending"];
|
const workOrderStageActivities = {
|
||||||
|
dispatch: "static",
|
||||||
|
execution: "loading",
|
||||||
|
reply: "static"
|
||||||
|
} satisfies Record<ScheduledWorkOrderStage["id"], StatusActivity>;
|
||||||
|
|
||||||
|
const conditionStatusOrder: ScheduledConditionItemStatus[] = [
|
||||||
|
"running",
|
||||||
|
"awaiting_reply",
|
||||||
|
"warning",
|
||||||
|
"error",
|
||||||
|
"completed",
|
||||||
|
"pending"
|
||||||
|
];
|
||||||
|
|
||||||
export type ConditionTaskFilterValue = typeof CONDITION_FILTER_ALL | string;
|
export type ConditionTaskFilterValue = typeof CONDITION_FILTER_ALL | string;
|
||||||
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionStatus;
|
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionItemStatus;
|
||||||
|
|
||||||
export type ConditionTaskFilterOption = {
|
export type ConditionTaskFilterOption = {
|
||||||
value: ConditionTaskFilterValue;
|
value: ConditionTaskFilterValue;
|
||||||
@@ -58,7 +82,7 @@ export type ConditionTaskFilterOption = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ConditionStatusFilterOption = {
|
export type ConditionStatusFilterOption = {
|
||||||
value: ScheduledConditionStatus;
|
value: ScheduledConditionItemStatus;
|
||||||
label: string;
|
label: string;
|
||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
@@ -88,7 +112,7 @@ export function createTaskFilterOptions(conditions: ScheduledConditionItem[]): C
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createStatusFilterOptions(conditions: ScheduledConditionItem[]): ConditionStatusFilterOption[] {
|
export function createStatusFilterOptions(conditions: ScheduledConditionItem[]): ConditionStatusFilterOption[] {
|
||||||
const statusCounts = new Map<ScheduledConditionStatus, number>();
|
const statusCounts = new Map<ScheduledConditionItemStatus, number>();
|
||||||
|
|
||||||
conditions.forEach((condition) => {
|
conditions.forEach((condition) => {
|
||||||
statusCounts.set(condition.status, (statusCounts.get(condition.status) ?? 0) + 1);
|
statusCounts.set(condition.status, (statusCounts.get(condition.status) ?? 0) + 1);
|
||||||
@@ -121,7 +145,7 @@ export function formatTimeRange(value: string, durationMinutes: number | undefin
|
|||||||
return `${startTime} - ${endTime}`;
|
return `${startTime} - ${endTime}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionStatus) {
|
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionItemStatus) {
|
||||||
if (status === "running") {
|
if (status === "running") {
|
||||||
return "执行中";
|
return "执行中";
|
||||||
}
|
}
|
||||||
@@ -154,22 +178,22 @@ export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWorkOrderStagePillClassName(stageId?: ScheduledWorkOrderStage["id"]) {
|
export function getWorkOrderStageTone(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||||
if (stageId === "execution") {
|
return stageId ? workOrderStageTones[stageId] : "neutral";
|
||||||
return "border-blue-100 bg-blue-50 text-blue-700";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stageId === "reply") {
|
|
||||||
return "border-green-100 bg-green-50 text-green-700";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "border-indigo-100 bg-indigo-50 text-indigo-700";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStatusIcon(status: ScheduledConditionStatus) {
|
export function getWorkOrderStageActivity(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||||
|
return stageId ? workOrderStageActivities[stageId] : "static";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusIcon(status: ScheduledConditionItemStatus) {
|
||||||
return statusIcons[status];
|
return statusIcons[status];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStatusIconClassName(status: ScheduledConditionStatus) {
|
export function getStatusTone(status: ScheduledConditionItemStatus) {
|
||||||
return statusIconClassNames[status];
|
return statusTones[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusActivity(status: ScheduledConditionItemStatus) {
|
||||||
|
return statusActivities[status];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
History,
|
History,
|
||||||
|
X,
|
||||||
XCircle
|
XCircle
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
|
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME, showMapNotice } from "@/features/map/core";
|
||||||
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
import { isGeneratedScheduledConditionSessionId } from "../data/scheduled-conditions";
|
||||||
import { isGeneratedScheduledConditionSessionId } from "@/features/workbench/data/scheduled-conditions";
|
import type { ScheduledConditionItem } from "../types";
|
||||||
import type { ScheduledConditionItem } from "@/features/workbench/types";
|
import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts";
|
||||||
import { createConditionConversationPrompt } from "@/features/workbench/utils/scheduled-condition-prompts";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -23,6 +21,7 @@ import {
|
|||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@/shared/ui/dropdown-menu";
|
} from "@/shared/ui/dropdown-menu";
|
||||||
|
import { StatusIcon as SemanticStatusIcon } from "@/shared/ui/status";
|
||||||
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
|
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
|
||||||
import {
|
import {
|
||||||
CONDITION_FILTER_ALL,
|
CONDITION_FILTER_ALL,
|
||||||
@@ -30,8 +29,12 @@ import {
|
|||||||
createTaskFilterOptions,
|
createTaskFilterOptions,
|
||||||
formatTime,
|
formatTime,
|
||||||
getConditionTime,
|
getConditionTime,
|
||||||
|
getStatusActivity,
|
||||||
getStatusIcon,
|
getStatusIcon,
|
||||||
getStatusIconClassName,
|
getStatusTone,
|
||||||
|
getWorkOrderCurrentStage,
|
||||||
|
getWorkOrderStageActivity,
|
||||||
|
getWorkOrderStageTone,
|
||||||
groupScheduledConditions,
|
groupScheduledConditions,
|
||||||
type ConditionStatusFilterOption,
|
type ConditionStatusFilterOption,
|
||||||
type ConditionStatusFilterValue,
|
type ConditionStatusFilterValue,
|
||||||
@@ -39,10 +42,18 @@ import {
|
|||||||
type ConditionTaskFilterValue
|
type ConditionTaskFilterValue
|
||||||
} from "./scheduled-condition-feed-utils";
|
} from "./scheduled-condition-feed-utils";
|
||||||
|
|
||||||
const CONDITION_CONTENT_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
|
const CONDITION_CONTENT_SURFACE_CLASS_NAME =
|
||||||
const CONDITION_CONTROL_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
|
"surface-reading rounded-xl border border-slate-200/70";
|
||||||
|
const CONDITION_CONTROL_SURFACE_CLASS_NAME =
|
||||||
|
"surface-control rounded-xl border border-slate-200/70";
|
||||||
|
|
||||||
|
export type ScheduledConditionFeedPresentation =
|
||||||
|
| "desktop-dock"
|
||||||
|
| "desktop-floating"
|
||||||
|
| "mobile-sheet";
|
||||||
|
|
||||||
type ScheduledConditionFeedProps = {
|
type ScheduledConditionFeedProps = {
|
||||||
|
presentation?: ScheduledConditionFeedPresentation;
|
||||||
conditions?: ScheduledConditionItem[];
|
conditions?: ScheduledConditionItem[];
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
focusRequest?: ScheduledConditionFeedFocusRequest | null;
|
focusRequest?: ScheduledConditionFeedFocusRequest | null;
|
||||||
@@ -53,6 +64,7 @@ type ScheduledConditionFeedProps = {
|
|||||||
onFocusRequestHandled?: (requestId: number) => void;
|
onFocusRequestHandled?: (requestId: number) => void;
|
||||||
onSelectedConditionChange?: (conditionId: string | null) => void;
|
onSelectedConditionChange?: (conditionId: string | null) => void;
|
||||||
onExpandAgent: () => void;
|
onExpandAgent: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
|
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
|
||||||
onSubmitPrompt: (prompt: string) => void | Promise<void>;
|
onSubmitPrompt: (prompt: string) => void | Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -63,6 +75,7 @@ type ScheduledConditionFeedFocusRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ScheduledConditionFeed({
|
export function ScheduledConditionFeed({
|
||||||
|
presentation = "desktop-dock",
|
||||||
conditions = [],
|
conditions = [],
|
||||||
expanded: controlledExpanded,
|
expanded: controlledExpanded,
|
||||||
focusRequest,
|
focusRequest,
|
||||||
@@ -73,13 +86,19 @@ export function ScheduledConditionFeed({
|
|||||||
onFocusRequestHandled,
|
onFocusRequestHandled,
|
||||||
onSelectedConditionChange,
|
onSelectedConditionChange,
|
||||||
onExpandAgent,
|
onExpandAgent,
|
||||||
|
onClose,
|
||||||
onLoadHistorySession,
|
onLoadHistorySession,
|
||||||
onSubmitPrompt
|
onSubmitPrompt
|
||||||
}: ScheduledConditionFeedProps) {
|
}: ScheduledConditionFeedProps) {
|
||||||
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
|
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
|
||||||
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<string | null>(null);
|
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
|
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
|
||||||
const [statusFilter, setStatusFilter] = useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
const [statusFilter, setStatusFilter] =
|
||||||
|
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||||
|
const mobileSheet = presentation === "mobile-sheet";
|
||||||
|
const desktopFloating = presentation === "desktop-floating";
|
||||||
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
||||||
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
||||||
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
|
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
|
||||||
@@ -92,10 +111,16 @@ export function ScheduledConditionFeed({
|
|||||||
);
|
);
|
||||||
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
|
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
|
||||||
const taskFilteredConditions = useMemo(
|
const taskFilteredConditions = useMemo(
|
||||||
() => conditions.filter((condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter),
|
() =>
|
||||||
|
conditions.filter(
|
||||||
|
(condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter
|
||||||
|
),
|
||||||
[conditions, taskFilter]
|
[conditions, taskFilter]
|
||||||
);
|
);
|
||||||
const statusFilterOptions = useMemo(() => createStatusFilterOptions(taskFilteredConditions), [taskFilteredConditions]);
|
const statusFilterOptions = useMemo(
|
||||||
|
() => createStatusFilterOptions(taskFilteredConditions),
|
||||||
|
[taskFilteredConditions]
|
||||||
|
);
|
||||||
const taskFilteredConditionCount = taskFilteredConditions.length;
|
const taskFilteredConditionCount = taskFilteredConditions.length;
|
||||||
const filteredConditions = useMemo(
|
const filteredConditions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -104,7 +129,10 @@ export function ScheduledConditionFeed({
|
|||||||
),
|
),
|
||||||
[statusFilter, taskFilteredConditions]
|
[statusFilter, taskFilteredConditions]
|
||||||
);
|
);
|
||||||
const { recentConditions, futureWorkOrders } = useMemo(() => groupScheduledConditions(filteredConditions), [filteredConditions]);
|
const { recentConditions, futureWorkOrders } = useMemo(
|
||||||
|
() => groupScheduledConditions(filteredConditions),
|
||||||
|
[filteredConditions]
|
||||||
|
);
|
||||||
const timelineConditions = useMemo(
|
const timelineConditions = useMemo(
|
||||||
() => [...recentConditions, ...futureWorkOrders],
|
() => [...recentConditions, ...futureWorkOrders],
|
||||||
[futureWorkOrders, recentConditions]
|
[futureWorkOrders, recentConditions]
|
||||||
@@ -173,7 +201,9 @@ export function ScheduledConditionFeed({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetCondition = allTimelineConditions.find((condition) => condition.id === focusConditionId);
|
const targetCondition = allTimelineConditions.find(
|
||||||
|
(condition) => condition.id === focusConditionId
|
||||||
|
);
|
||||||
if (!targetCondition) {
|
if (!targetCondition) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -183,11 +213,20 @@ export function ScheduledConditionFeed({
|
|||||||
updateSelectedConditionId(targetCondition.id);
|
updateSelectedConditionId(targetCondition.id);
|
||||||
updateExpanded(true);
|
updateExpanded(true);
|
||||||
onFocusRequestHandled?.(focusRequestId);
|
onFocusRequestHandled?.(focusRequestId);
|
||||||
}, [allTimelineConditions, focusConditionId, focusRequestId, onFocusRequestHandled, updateExpanded, updateSelectedConditionId]);
|
}, [
|
||||||
|
allTimelineConditions,
|
||||||
|
focusConditionId,
|
||||||
|
focusRequestId,
|
||||||
|
onFocusRequestHandled,
|
||||||
|
updateExpanded,
|
||||||
|
updateSelectedConditionId
|
||||||
|
]);
|
||||||
|
|
||||||
function handleToggleExpanded() {
|
function handleToggleExpanded() {
|
||||||
updateExpanded(!expanded);
|
updateExpanded(!expanded);
|
||||||
updateSelectedConditionId(selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null);
|
updateSelectedConditionId(
|
||||||
|
selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectTimelineCondition(conditionId: string) {
|
function handleSelectTimelineCondition(conditionId: string) {
|
||||||
@@ -229,7 +268,8 @@ export function ScheduledConditionFeed({
|
|||||||
id: "scheduled-condition-generated-session",
|
id: "scheduled-condition-generated-session",
|
||||||
tone: "error",
|
tone: "error",
|
||||||
title: "工况对话发起失败",
|
title: "工况对话发起失败",
|
||||||
message: submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
|
message:
|
||||||
|
submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -243,15 +283,39 @@ export function ScheduledConditionFeed({
|
|||||||
aria-label="工况任务"
|
aria-label="工况任务"
|
||||||
aria-hidden={!visible}
|
aria-hidden={!visible}
|
||||||
className={cn(
|
className={cn(
|
||||||
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
|
"scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none",
|
||||||
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit",
|
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit",
|
||||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
mobileSheet
|
||||||
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
|
? "scheduled-feed-mobile flex h-full w-full max-w-none flex-col border-0 bg-transparent shadow-none"
|
||||||
"rounded-2xl"
|
: cn(
|
||||||
|
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||||
|
"max-w-[calc(100vw-7rem)]",
|
||||||
|
desktopFloating
|
||||||
|
? cn(
|
||||||
|
"rounded-2xl",
|
||||||
|
expanded
|
||||||
|
? "h-[calc(100dvh-8rem)]"
|
||||||
|
: "max-h-[calc(100dvh-8rem)]"
|
||||||
|
)
|
||||||
|
: "h-full rounded-l-2xl rounded-r-none",
|
||||||
|
expanded
|
||||||
|
? "scheduled-feed-panel-expanded flex flex-col"
|
||||||
|
: "w-[var(--workbench-condition-width)]"
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", CONDITION_CONTROL_SURFACE_CLASS_NAME)}>
|
<header
|
||||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", "rounded-lg")}>
|
className={cn(
|
||||||
|
"flex items-center gap-2.5 px-2.5 py-2",
|
||||||
|
CONDITION_CONTROL_SURFACE_CLASS_NAME
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700",
|
||||||
|
"rounded-lg"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<CalendarClock size={17} aria-hidden="true" />
|
<CalendarClock size={17} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -274,18 +338,39 @@ export function ScheduledConditionFeed({
|
|||||||
/>
|
/>
|
||||||
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
|
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{mobileSheet && onClose ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭工况任务"
|
||||||
|
title="关闭工况任务"
|
||||||
|
onClick={onClose}
|
||||||
|
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||||
|
>
|
||||||
|
<X size={17} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"scheduled-feed-layout mt-3 grid min-h-0",
|
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||||
expanded ? "scheduled-feed-layout-expanded" : "scheduled-feed-layout-collapsed"
|
mobileSheet
|
||||||
|
? "flex flex-1"
|
||||||
|
: expanded
|
||||||
|
? "scheduled-feed-layout-expanded flex-1"
|
||||||
|
: "scheduled-feed-layout-collapsed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-0 min-w-0 overflow-hidden",
|
"relative min-h-0 min-w-0 overflow-hidden",
|
||||||
expanded ? "scheduled-feed-detail-enter" : "pointer-events-none opacity-0"
|
mobileSheet
|
||||||
|
? expanded
|
||||||
|
? "scheduled-feed-detail-enter h-full w-full"
|
||||||
|
: "hidden"
|
||||||
|
: expanded
|
||||||
|
? "scheduled-feed-detail-enter h-full"
|
||||||
|
: "pointer-events-none opacity-0"
|
||||||
)}
|
)}
|
||||||
aria-hidden={!expanded}
|
aria-hidden={!expanded}
|
||||||
>
|
>
|
||||||
@@ -298,9 +383,15 @@ export function ScheduledConditionFeed({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-h-0 min-w-0 overflow-hidden",
|
||||||
|
mobileSheet && (expanded ? "hidden" : "h-full w-full")
|
||||||
|
)}
|
||||||
|
>
|
||||||
<ConditionTimelinePanel
|
<ConditionTimelinePanel
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
|
presentation={presentation}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
recentConditions={recentConditions}
|
recentConditions={recentConditions}
|
||||||
futureWorkOrders={futureWorkOrders}
|
futureWorkOrders={futureWorkOrders}
|
||||||
@@ -308,7 +399,7 @@ export function ScheduledConditionFeed({
|
|||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
taskFilterOptions={taskFilterOptions}
|
taskFilterOptions={taskFilterOptions}
|
||||||
statusFilterOptions={statusFilterOptions}
|
statusFilterOptions={statusFilterOptions}
|
||||||
selectedConditionId={expanded ? selectedCondition?.id ?? null : null}
|
selectedConditionId={expanded ? (selectedCondition?.id ?? null) : null}
|
||||||
totalConditionCount={totalConditionCount}
|
totalConditionCount={totalConditionCount}
|
||||||
taskFilteredConditionCount={taskFilteredConditionCount}
|
taskFilteredConditionCount={taskFilteredConditionCount}
|
||||||
filteredConditionCount={filteredConditionCount}
|
filteredConditionCount={filteredConditionCount}
|
||||||
@@ -328,6 +419,7 @@ export function ScheduledConditionFeed({
|
|||||||
|
|
||||||
type ConditionTimelinePanelProps = {
|
type ConditionTimelinePanelProps = {
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
|
presentation: ScheduledConditionFeedPresentation;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
recentConditions: ScheduledConditionItem[];
|
recentConditions: ScheduledConditionItem[];
|
||||||
futureWorkOrders: ScheduledConditionItem[];
|
futureWorkOrders: ScheduledConditionItem[];
|
||||||
@@ -347,6 +439,7 @@ type ConditionTimelinePanelProps = {
|
|||||||
|
|
||||||
function ConditionTimelinePanel({
|
function ConditionTimelinePanel({
|
||||||
expanded,
|
expanded,
|
||||||
|
presentation,
|
||||||
loading,
|
loading,
|
||||||
recentConditions,
|
recentConditions,
|
||||||
futureWorkOrders,
|
futureWorkOrders,
|
||||||
@@ -368,9 +461,14 @@ function ConditionTimelinePanel({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-busy={loading}
|
aria-busy={loading}
|
||||||
|
data-testid="scheduled-condition-timeline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 flex-col overflow-hidden p-2.5",
|
"flex min-h-0 flex-col overflow-hidden p-2.5",
|
||||||
expanded ? "h-[calc(100dvh-14rem)] max-h-[calc(100dvh-14rem)]" : "h-[420px] max-h-[420px]",
|
presentation === "mobile-sheet"
|
||||||
|
? "h-full max-h-none"
|
||||||
|
: expanded
|
||||||
|
? "h-full max-h-none"
|
||||||
|
: "h-[420px] max-h-[420px]",
|
||||||
CONDITION_CONTENT_SURFACE_CLASS_NAME
|
CONDITION_CONTENT_SURFACE_CLASS_NAME
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -388,10 +486,15 @@ function ConditionTimelinePanel({
|
|||||||
onResetFilters={onResetFilters}
|
onResetFilters={onResetFilters}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{expanded && !loading && futureWorkOrders.length > 0 ? (
|
{(expanded || presentation === "mobile-sheet") && !loading && futureWorkOrders.length > 0 ? (
|
||||||
<section className="mb-2">
|
<section className="mb-2">
|
||||||
<ConditionGroupHeader icon={ClipboardList} title="预约任务" count={futureWorkOrders.length} compact />
|
<ConditionGroupHeader
|
||||||
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
|
icon={ClipboardList}
|
||||||
|
title="预约任务"
|
||||||
|
count={futureWorkOrders.length}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
<div className="scheduled-feed-scroll -mr-2 max-h-[132px] overflow-y-auto pl-0.5 pr-2 pt-0.5">
|
||||||
<ConditionRows
|
<ConditionRows
|
||||||
conditions={futureWorkOrders}
|
conditions={futureWorkOrders}
|
||||||
selectedConditionId={selectedConditionId}
|
selectedConditionId={selectedConditionId}
|
||||||
@@ -400,7 +503,11 @@ function ConditionTimelinePanel({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<ConditionGroupHeader icon={History} title="最近工况" count={loading ? 0 : recentConditions.length} />
|
<ConditionGroupHeader
|
||||||
|
icon={History}
|
||||||
|
title="最近工况"
|
||||||
|
count={loading ? 0 : recentConditions.length}
|
||||||
|
/>
|
||||||
<div className="scheduled-feed-scroll -mr-2 min-h-0 flex-1 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5">
|
<div className="scheduled-feed-scroll -mr-2 min-h-0 flex-1 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ConditionTimelineSkeleton rows={expanded ? 7 : 6} />
|
<ConditionTimelineSkeleton rows={expanded ? 7 : 6} />
|
||||||
@@ -413,7 +520,11 @@ function ConditionTimelinePanel({
|
|||||||
) : (
|
) : (
|
||||||
<ConditionEmptyState
|
<ConditionEmptyState
|
||||||
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
|
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
|
||||||
description={filterActive ? "调整任务或状态筛选后查看其他工况记录。" : "新的工况任务完成后会出现在这里。"}
|
description={
|
||||||
|
filterActive
|
||||||
|
? "调整任务或状态筛选后查看其他工况记录。"
|
||||||
|
: "新的工况任务完成后会出现在这里。"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -449,7 +560,7 @@ function ConditionFilterBar({
|
|||||||
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(CONDITION_CONTENT_SURFACE_CLASS_NAME, "mb-2 p-2")}>
|
<div className={cn(CONDITION_CONTROL_SURFACE_CLASS_NAME, "mb-2 p-2")}>
|
||||||
<div className="mb-2 flex items-center justify-between gap-2 px-1">
|
<div className="mb-2 flex items-center justify-between gap-2 px-1">
|
||||||
<span className="text-xs font-semibold text-slate-700">筛选</span>
|
<span className="text-xs font-semibold text-slate-700">筛选</span>
|
||||||
<span className="shrink-0 text-xs font-semibold text-slate-400">
|
<span className="shrink-0 text-xs font-semibold text-slate-400">
|
||||||
@@ -480,7 +591,7 @@ function ConditionFilterBar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onResetFilters}
|
onClick={onResetFilters}
|
||||||
className="surface-control mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 transition-[background-color,transform] hover:bg-blue-50 active:scale-95"
|
className="surface-control relative mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 after:absolute after:-inset-1 after:content-[''] hover:bg-blue-50"
|
||||||
>
|
>
|
||||||
<XCircle size={12} aria-hidden="true" />
|
<XCircle size={12} aria-hidden="true" />
|
||||||
重置筛选
|
重置筛选
|
||||||
@@ -522,10 +633,15 @@ function ConditionFilterDropdown<TValue extends string>({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate font-semibold text-slate-800" title={selectedOption?.label}>
|
<span
|
||||||
|
className="block truncate font-semibold text-slate-800"
|
||||||
|
title={selectedOption?.label}
|
||||||
|
>
|
||||||
{selectedOption?.label ?? "全部"}
|
{selectedOption?.label ?? "全部"}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-xs leading-3 text-slate-400">{selectedOption?.count ?? 0} 条</span>
|
<span className="block text-xs leading-3 text-slate-400">
|
||||||
|
{selectedOption?.count ?? 0} 条
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={13}
|
size={13}
|
||||||
@@ -539,7 +655,10 @@ function ConditionFilterDropdown<TValue extends string>({
|
|||||||
sideOffset={6}
|
sideOffset={6}
|
||||||
className="surface-reading scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-slate-200/70 p-1.5 text-slate-700 shadow-md shadow-slate-900/10"
|
className="surface-reading scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-slate-200/70 p-1.5 text-slate-700 shadow-md shadow-slate-900/10"
|
||||||
>
|
>
|
||||||
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
|
<DropdownMenuRadioGroup
|
||||||
|
value={value}
|
||||||
|
onValueChange={(nextValue) => onValueChange(nextValue as TValue)}
|
||||||
|
>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<DropdownMenuRadioItem
|
<DropdownMenuRadioItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
@@ -585,7 +704,10 @@ function ConditionTimelineSkeleton({ rows }: { rows: number }) {
|
|||||||
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-slate-200/80" />
|
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-slate-200/80" />
|
||||||
<span className="relative flex min-h-12 justify-center pt-1.5">
|
<span className="relative flex min-h-12 justify-center pt-1.5">
|
||||||
{index < rows - 1 ? (
|
{index < rows - 1 ? (
|
||||||
<span className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200" aria-hidden="true" />
|
<span
|
||||||
|
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
|
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
|
||||||
</span>
|
</span>
|
||||||
@@ -608,10 +730,7 @@ function ConditionDetailSkeleton() {
|
|||||||
return (
|
return (
|
||||||
<DetailScroll>
|
<DetailScroll>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="relative min-h-[420px] overflow-hidden px-4 py-3"
|
||||||
"surface-reading relative min-h-[420px] overflow-hidden border border-slate-200/70 px-4 py-3",
|
|
||||||
"rounded-xl"
|
|
||||||
)}
|
|
||||||
role="status"
|
role="status"
|
||||||
aria-label="工况详情加载中"
|
aria-label="工况详情加载中"
|
||||||
>
|
>
|
||||||
@@ -629,7 +748,10 @@ function ConditionDetailSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid gap-2">
|
<div className="mt-4 grid gap-2">
|
||||||
{Array.from({ length: 5 }).map((_, index) => (
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
<div key={index} className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3">
|
<div
|
||||||
|
key={index}
|
||||||
|
className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3"
|
||||||
|
>
|
||||||
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" />
|
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" />
|
||||||
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" />
|
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" />
|
||||||
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" />
|
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" />
|
||||||
@@ -643,8 +765,18 @@ function ConditionDetailSkeleton() {
|
|||||||
|
|
||||||
function DetailScroll({ children }: { children: React.ReactNode }) {
|
function DetailScroll({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
|
<section
|
||||||
{children}
|
className={cn(
|
||||||
|
CONDITION_CONTENT_SURFACE_CLASS_NAME,
|
||||||
|
"scheduled-feed-detail-viewport absolute inset-0 overflow-hidden"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="scheduled-condition-detail-scroll"
|
||||||
|
className="scheduled-feed-scroll absolute inset-x-0 inset-y-2 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -656,9 +788,19 @@ type ConditionGroupHeaderProps = {
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ConditionGroupHeader({ icon: Icon, title, count, compact = false }: ConditionGroupHeaderProps) {
|
function ConditionGroupHeader({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
compact = false
|
||||||
|
}: ConditionGroupHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500", compact ? "mb-1" : "mb-2")}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500",
|
||||||
|
compact ? "mb-1" : "mb-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Icon size={13} aria-hidden="true" />
|
<Icon size={13} aria-hidden="true" />
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
@@ -697,9 +839,21 @@ type ConditionTimelineRowProps = {
|
|||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ConditionTimelineRow({ condition, selected, isLast, onSelect }: ConditionTimelineRowProps) {
|
function ConditionTimelineRow({
|
||||||
const StatusIcon = getStatusIcon(condition.status);
|
condition,
|
||||||
|
selected,
|
||||||
|
isLast,
|
||||||
|
onSelect
|
||||||
|
}: ConditionTimelineRowProps) {
|
||||||
|
const Icon = getStatusIcon(condition.status);
|
||||||
const isWorkOrder = condition.kind === "work_order";
|
const isWorkOrder = condition.kind === "work_order";
|
||||||
|
const workOrderStage = isWorkOrder ? getWorkOrderCurrentStage(condition)?.id : undefined;
|
||||||
|
const tone = isWorkOrder
|
||||||
|
? getWorkOrderStageTone(workOrderStage)
|
||||||
|
: getStatusTone(condition.status);
|
||||||
|
const activity = isWorkOrder
|
||||||
|
? getWorkOrderStageActivity(workOrderStage)
|
||||||
|
: getStatusActivity(condition.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -708,44 +862,48 @@ function ConditionTimelineRow({ condition, selected, isLast, onSelect }: Conditi
|
|||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl border px-1 py-1 text-left outline-hidden transition-colors focus-visible:bg-blue-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-200",
|
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl border px-1 py-1 text-left outline-hidden transition-colors focus-visible:bg-blue-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-200",
|
||||||
selected ? "surface-reading border-blue-100 text-blue-900" : "border-transparent hover:bg-slate-50"
|
selected
|
||||||
|
? "surface-reading border-blue-100 text-blue-900"
|
||||||
|
: "border-transparent hover:bg-slate-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">{formatTime(condition.scheduledAt)}</span>
|
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">
|
||||||
<span
|
{formatTime(condition.scheduledAt)}
|
||||||
className="relative flex min-h-12 justify-center pt-1.5"
|
</span>
|
||||||
>
|
<span className="relative flex min-h-12 justify-center pt-1.5">
|
||||||
{!isLast ? (
|
{!isLast ? (
|
||||||
<span
|
<span
|
||||||
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200 transition-colors group-hover:bg-blue-200 group-focus-visible:bg-blue-300"
|
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200 transition-colors group-hover:bg-blue-200 group-focus-visible:bg-blue-300"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<span
|
<SemanticStatusIcon
|
||||||
|
tone={tone}
|
||||||
|
activity={activity}
|
||||||
|
shape="circle"
|
||||||
|
data-selected={selected ? "true" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-10 grid h-6 w-6 place-items-center rounded-full ring-4 ring-white",
|
"relative z-10 ring-4 ring-white",
|
||||||
selected
|
selected && "group-focus-visible:ring-blue-100"
|
||||||
? "bg-blue-600 text-white group-focus-visible:ring-blue-100"
|
|
||||||
: isWorkOrder
|
|
||||||
? "bg-blue-50 text-blue-700 group-focus-visible:bg-blue-100 group-focus-visible:text-blue-800"
|
|
||||||
: getStatusIconClassName(condition.status)
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<StatusIcon size={12} aria-hidden="true" className={condition.status === "running" ? "animate-spin" : undefined} />
|
<Icon size={12} />
|
||||||
</span>
|
</SemanticStatusIcon>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 px-2 py-1.5 transition-colors",
|
"min-w-0 px-2 py-1.5 transition-colors",
|
||||||
selected
|
selected ? "text-blue-900" : "text-slate-700"
|
||||||
? "text-blue-900"
|
|
||||||
: "text-slate-700"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-xs font-semibold leading-4">{condition.title}</span>
|
<span className="block truncate text-xs font-semibold leading-4">
|
||||||
<span className="block truncate text-xs leading-4 text-slate-500">{condition.summary}</span>
|
{condition.title}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs leading-4 text-slate-500">
|
||||||
|
{condition.summary}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<StatusPill condition={condition} className="shrink-0 text-xs" />
|
<StatusPill condition={condition} className="shrink-0 text-xs" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -23,21 +23,20 @@ import {
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
MapAnnotationPanel,
|
MapAnnotationPanel,
|
||||||
type MapAnnotationItem,
|
|
||||||
type MapAnnotationShareAction
|
|
||||||
} from "@/features/map/core/components/annotation-panel";
|
|
||||||
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
|
|
||||||
import {
|
|
||||||
MapActionRow,
|
MapActionRow,
|
||||||
MapControlPanel,
|
MapControlPanel,
|
||||||
|
MapDrawToolbar,
|
||||||
|
MapLayerPanel,
|
||||||
|
MapLegendList,
|
||||||
MapMetricTile,
|
MapMetricTile,
|
||||||
MapPanelSection
|
MapMeasurePanel,
|
||||||
} from "@/features/map/core/components/control-panel";
|
MapPanelSection,
|
||||||
import { MapDrawToolbar } from "@/features/map/core/components/draw-toolbar";
|
type BaseLayerOption,
|
||||||
import { MapLayerPanel } from "@/features/map/core/components/layer-panel";
|
type MapAnnotationItem,
|
||||||
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
|
type MapAnnotationShareAction,
|
||||||
import { MapLegendList, type MapLegendItem } from "@/features/map/core/components/legend";
|
type MapLayerControlItem,
|
||||||
import { MapMeasurePanel } from "@/features/map/core/components/measure-panel";
|
type MapLegendItem
|
||||||
|
} from "@/features/map/core";
|
||||||
import {
|
import {
|
||||||
type WorkbenchDrawingAnnotation,
|
type WorkbenchDrawingAnnotation,
|
||||||
type WorkbenchDrawMode
|
type WorkbenchDrawMode
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
|
||||||
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import { useEffect, type ComponentProps } from "react";
|
||||||
|
import { getAgentPanelDefaultWidth, getAgentPanelMaxWidth } from "../layout/workbench-layout";
|
||||||
|
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
|
||||||
|
|
||||||
|
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
|
||||||
|
|
||||||
|
type WorkbenchAgentPanelsProps = {
|
||||||
|
panelProps: AgentPanelProps;
|
||||||
|
panelOpen: boolean;
|
||||||
|
panelCollapsing: boolean;
|
||||||
|
conditionExpanded: boolean;
|
||||||
|
personaState: ComponentProps<typeof AgentPersona>["state"];
|
||||||
|
statusLabel: string;
|
||||||
|
viewportWidth: number;
|
||||||
|
onCollapsePanel: () => void;
|
||||||
|
onExpandPanel: () => void;
|
||||||
|
onWidthCommit?: (width: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkbenchAgentPanels({
|
||||||
|
panelProps,
|
||||||
|
panelOpen,
|
||||||
|
panelCollapsing,
|
||||||
|
conditionExpanded,
|
||||||
|
personaState,
|
||||||
|
statusLabel,
|
||||||
|
viewportWidth,
|
||||||
|
onCollapsePanel,
|
||||||
|
onExpandPanel,
|
||||||
|
onWidthCommit
|
||||||
|
}: WorkbenchAgentPanelsProps) {
|
||||||
|
const resize = useAgentPanelResize(viewportWidth, conditionExpanded);
|
||||||
|
const defaultWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
|
||||||
|
const committedWidth = resize.committedWidth ?? defaultWidth;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onWidthCommit?.(committedWidth);
|
||||||
|
}, [committedWidth, onWidthCommit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={resize.panelRef}
|
||||||
|
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden max-w-[720px] lg:block"
|
||||||
|
style={{ width: resize.width ?? defaultWidth }}
|
||||||
|
>
|
||||||
|
{panelOpen ? (
|
||||||
|
<>
|
||||||
|
<AgentCommandPanel
|
||||||
|
{...panelProps}
|
||||||
|
presentation="desktop-floating"
|
||||||
|
collapsing={panelCollapsing}
|
||||||
|
onCollapse={onCollapsePanel}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-label="调整 Agent 面板宽度"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-valuemax={getAgentPanelMaxWidth(viewportWidth, conditionExpanded)}
|
||||||
|
aria-valuemin={defaultWidth}
|
||||||
|
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
|
||||||
|
className={cn(
|
||||||
|
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-hidden",
|
||||||
|
resize.resizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
|
||||||
|
)}
|
||||||
|
role="separator"
|
||||||
|
tabIndex={0}
|
||||||
|
title="拖拽调整 Agent 面板宽度"
|
||||||
|
onKeyDown={resize.handleKeyDown}
|
||||||
|
onPointerDown={resize.handlePointerDown}
|
||||||
|
>
|
||||||
|
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-xs transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<AgentCollapsedRail
|
||||||
|
personaState={personaState}
|
||||||
|
statusLabel={statusLabel}
|
||||||
|
onExpand={onExpandPanel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Bell,
|
Bell,
|
||||||
@@ -24,8 +22,14 @@ 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_RADIUS_CLASS_NAME
|
||||||
} from "@/features/map/core/components/map-control-styles";
|
} from "@/features/map/core";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import {
|
||||||
|
StatusBadge,
|
||||||
|
StatusDot,
|
||||||
|
StatusIcon as SemanticStatusIcon,
|
||||||
|
type StatusTone
|
||||||
|
} from "@/shared/ui/status";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -38,6 +42,7 @@ import {
|
|||||||
} from "@/shared/ui/dropdown-menu";
|
} from "@/shared/ui/dropdown-menu";
|
||||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||||
import {
|
import {
|
||||||
|
compactHeaderControlButtonClassName,
|
||||||
compactHeaderTextClassName,
|
compactHeaderTextClassName,
|
||||||
headerControlButtonClassName,
|
headerControlButtonClassName,
|
||||||
headerControlIconClassName
|
headerControlIconClassName
|
||||||
@@ -49,6 +54,12 @@ const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
|
|||||||
review: "待复核"
|
review: "待复核"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scenarioStatusTones = {
|
||||||
|
active: "info",
|
||||||
|
draft: "neutral",
|
||||||
|
review: "warning"
|
||||||
|
} satisfies Record<WorkbenchScenario["status"], StatusTone>;
|
||||||
|
|
||||||
const menuSurfaceClassName = cn(
|
const menuSurfaceClassName = cn(
|
||||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||||
"acrylic-panel border p-2 text-slate-900"
|
"acrylic-panel border p-2 text-slate-900"
|
||||||
@@ -114,9 +125,9 @@ export function ScenarioMenu({
|
|||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="flex items-center justify-between gap-2">
|
<span className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate font-semibold text-slate-900">{scenario.name}</span>
|
<span className="truncate font-semibold text-slate-900">{scenario.name}</span>
|
||||||
<span className="shrink-0 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-xs font-medium text-slate-500">
|
<StatusBadge tone={scenarioStatusTones[scenario.status]}>
|
||||||
{scenarioStatusLabels[scenario.status]}
|
{scenarioStatusLabels[scenario.status]}
|
||||||
</span>
|
</StatusBadge>
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 block text-xs leading-5 text-slate-500">{scenario.description}</span>
|
<span className="mt-1 block text-xs leading-5 text-slate-500">{scenario.description}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -163,29 +174,38 @@ export function AlertMenu({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"group relative px-2",
|
"group relative px-2",
|
||||||
headerControlButtonClassName,
|
headerControlButtonClassName,
|
||||||
hasAlerts && "hover:bg-red-50/60 hover:text-red-800 data-[state=open]:bg-red-50/70 data-[state=open]:text-red-800",
|
hasAlerts && !compact &&
|
||||||
compact && "h-10 w-10 justify-center px-0"
|
"status-tone-danger border-transparent bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_45%)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] [@media(hover:hover)]:hover:text-[var(--status-foreground)] data-[state=open]:border-transparent data-[state=open]:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] data-[state=open]:text-[var(--status-foreground)]",
|
||||||
|
compact && compactHeaderControlButtonClassName
|
||||||
)}
|
)}
|
||||||
aria-label={`查看异常处置面板,当前 ${alerts.length} 条待复核工况`}
|
aria-label={`查看异常处置面板,当前 ${alerts.length} 条待复核工况`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
headerControlIconClassName,
|
headerControlIconClassName,
|
||||||
hasAlerts &&
|
hasAlerts ? "status-icon status-tone-danger" : "bg-transparent!"
|
||||||
"bg-red-50 text-red-700 group-hover:bg-red-50 group-hover:text-red-800 group-data-[state=open]:bg-red-50 group-data-[state=open]:text-red-800"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Bell size={14} aria-hidden="true" />
|
<Bell size={14} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<span className={compact ? "sr-only" : compactHeaderTextClassName}>异常处置</span>
|
<span className={compact ? "sr-only" : compactHeaderTextClassName}>异常处置</span>
|
||||||
{compact ? (
|
{compact ? (
|
||||||
<span className={cn("absolute right-0 top-0 grid h-4 min-w-4 place-items-center rounded-full border px-1 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
|
<StatusBadge
|
||||||
|
tone={hasAlerts ? "danger" : "neutral"}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 top-0 min-h-4 min-w-4 justify-center px-1 py-0 leading-none",
|
||||||
|
!hasAlerts && "border-transparent! bg-transparent!"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{alerts.length}
|
{alerts.length}
|
||||||
</span>
|
</StatusBadge>
|
||||||
) : (
|
) : (
|
||||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
|
<StatusBadge
|
||||||
|
tone={hasAlerts ? "danger" : "neutral"}
|
||||||
|
className={cn("leading-none", !hasAlerts && "border-transparent! bg-transparent!")}
|
||||||
|
>
|
||||||
{alerts.length}
|
{alerts.length}
|
||||||
</span>
|
</StatusBadge>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -201,7 +221,7 @@ export function AlertMenu({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={alert.id}
|
key={alert.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-red-100",
|
"status-tone-danger group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-[var(--status-border)]",
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
menuReadableRowClassName
|
menuReadableRowClassName
|
||||||
)}
|
)}
|
||||||
@@ -209,20 +229,22 @@ export function AlertMenu({
|
|||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<span className="h-2 w-2 shrink-0 rounded-full bg-red-300 ring-2 ring-red-50" aria-hidden="true" />
|
<StatusDot tone="danger" size="md" />
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
|
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
|
||||||
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-slate-400 tabular-nums">{alert.time}</span>
|
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-slate-400 tabular-nums">{alert.time}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
|
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-red-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
<span className={cn("status-tone-danger grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||||
<ChevronRight size={14} />
|
<ChevronRight size={14} />
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)) : (
|
)) : (
|
||||||
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<CheckCircle2 size={18} className="mx-auto text-emerald-600" aria-hidden="true" />
|
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
|
||||||
|
<CheckCircle2 size={16} />
|
||||||
|
</SemanticStatusIcon>
|
||||||
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p>
|
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p>
|
||||||
<p className="mt-0.5 text-xs leading-4 text-slate-500">新的工况提醒会进入这里。</p>
|
<p className="mt-0.5 text-xs leading-4 text-slate-500">新的工况提醒会进入这里。</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,7 +298,11 @@ export function UserMenu({
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn("group px-2", headerControlButtonClassName)}
|
className={cn(
|
||||||
|
"group lg:h-8 lg:w-auto lg:justify-start lg:px-2",
|
||||||
|
headerControlButtonClassName,
|
||||||
|
compactHeaderControlButtonClassName
|
||||||
|
)}
|
||||||
aria-label="打开用户菜单"
|
aria-label="打开用户菜单"
|
||||||
>
|
>
|
||||||
<span className={headerControlIconClassName}>
|
<span className={headerControlIconClassName}>
|
||||||
@@ -318,15 +344,13 @@ function AlertQueueSummary({
|
|||||||
<div className={cn("surface-reading overflow-hidden px-3 py-3", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
<div className={cn("surface-reading overflow-hidden px-3 py-3", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-start gap-2.5">
|
<div className="flex min-w-0 items-start gap-2.5">
|
||||||
<span
|
<SemanticStatusIcon
|
||||||
className={cn(
|
tone={hasAlerts ? "danger" : "success"}
|
||||||
"grid h-8 w-8 shrink-0 place-items-center",
|
size="md"
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
className={cn("h-8 w-8", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
|
||||||
hasAlerts ? "bg-red-50 text-red-700" : "bg-emerald-50 text-emerald-700"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{hasAlerts ? <Bell size={16} aria-hidden="true" /> : <CheckCircle2 size={16} aria-hidden="true" />}
|
{hasAlerts ? <Bell size={16} /> : <CheckCircle2 size={16} />}
|
||||||
</span>
|
</SemanticStatusIcon>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">异常处置</h2>
|
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">异常处置</h2>
|
||||||
<p className="mt-0.5 text-xs leading-5 text-slate-500">
|
<p className="mt-0.5 text-xs leading-5 text-slate-500">
|
||||||
@@ -334,17 +358,12 @@ function AlertQueueSummary({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<StatusBadge tone={hasAlerts ? "danger" : "success"}>
|
||||||
className={cn(
|
|
||||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold leading-5",
|
|
||||||
hasAlerts ? "bg-red-50 text-red-800 ring-1 ring-red-100" : "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{hasAlerts ? "需复核" : "正常"}
|
{hasAlerts ? "需复核" : "正常"}
|
||||||
</span>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-slate-500">
|
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-slate-500">
|
||||||
<span className={cn("h-1.5 w-1.5 rounded-full", conditionFeedVisible ? "bg-blue-500" : "bg-slate-300")} aria-hidden="true" />
|
<StatusDot tone={conditionFeedVisible ? "info" : "neutral"} />
|
||||||
<span className="truncate">{conditionFeedVisible ? "工况面板已显示,可查看详情。" : "点击条目可打开工况任务。"}</span>
|
<span className="truncate">{conditionFeedVisible ? "工况面板已显示,可查看详情。" : "点击条目可打开工况任务。"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -429,9 +448,9 @@ function MenuHeader({
|
|||||||
{Icon ? <Icon size={13} className="shrink-0 text-slate-500" aria-hidden="true" /> : null}
|
{Icon ? <Icon size={13} className="shrink-0 text-slate-500" aria-hidden="true" /> : null}
|
||||||
<span className="truncate text-sm font-semibold text-slate-950">{title}</span>
|
<span className="truncate text-sm font-semibold text-slate-950">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "text-red-600")}>{meta}</p>
|
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "status-tone-danger text-[var(--status-foreground)]")}>{meta}</p>
|
||||||
</div>
|
</div>
|
||||||
{tone === "warning" ? <span className="h-2 w-2 shrink-0 rounded-full bg-red-500" aria-hidden="true" /> : null}
|
{tone === "warning" ? <StatusDot tone="danger" size="md" /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -471,7 +490,7 @@ function MenuAction({
|
|||||||
"grid h-7 w-7 shrink-0 place-items-center border",
|
"grid h-7 w-7 shrink-0 place-items-center border",
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
tone === "primary" && "border-blue-100 bg-blue-50 text-blue-700",
|
tone === "primary" && "border-blue-100 bg-blue-50 text-blue-700",
|
||||||
tone === "warning" && "border-orange-100 bg-orange-50 text-orange-700",
|
tone === "warning" && "status-icon status-tone-warning",
|
||||||
tone === "default" && "border-slate-100 bg-slate-50 text-slate-600"
|
tone === "default" && "border-slate-100 bg-slate-50 text-slate-600"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
|
import { buttonInteractionVariants } from "@/shared/ui/button-interaction";
|
||||||
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
|
||||||
export const headerControlButtonClassName =
|
export const headerControlButtonClassName =
|
||||||
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
|
cn(
|
||||||
|
buttonInteractionVariants({ tone: "neutral" }),
|
||||||
|
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold after:absolute after:-inset-1 after:content-['']"
|
||||||
|
);
|
||||||
|
|
||||||
|
export const compactHeaderControlButtonClassName =
|
||||||
|
"h-10 w-10 justify-center px-0";
|
||||||
|
|
||||||
export const headerControlIconClassName =
|
export const headerControlIconClassName =
|
||||||
"grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
|
"header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
|
||||||
|
|
||||||
export const compactHeaderTextClassName = "hidden 2xl:inline";
|
export const compactHeaderTextClassName = "hidden 2xl:inline";
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -11,10 +9,8 @@ import {
|
|||||||
FlaskConical,
|
FlaskConical,
|
||||||
type LucideIcon
|
type LucideIcon
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
import { cn } from "@/shared/ui/cn";
|
||||||
} from "@/features/map/core/components/map-control-styles";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||||
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
|
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
|
||||||
import {
|
import {
|
||||||
@@ -79,7 +75,8 @@ export function WorkbenchTopBar({
|
|||||||
onExportConfig
|
onExportConfig
|
||||||
}: WorkbenchTopBarProps) {
|
}: WorkbenchTopBarProps) {
|
||||||
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
||||||
const activeScenario = scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
|
const activeScenario =
|
||||||
|
scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
|
||||||
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
|
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
|
||||||
setOpenMenu(open ? menuId : null);
|
setOpenMenu(open ? menuId : null);
|
||||||
};
|
};
|
||||||
@@ -87,11 +84,18 @@ export function WorkbenchTopBar({
|
|||||||
return (
|
return (
|
||||||
<header className="acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b px-3 sm:px-4 md:px-5">
|
<header className="acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b px-3 sm:px-4 md:px-5">
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
<div className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20",
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Droplets size={18} aria-hidden="true" />
|
<Droplets size={18} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">排水管网智能调度平台</h1>
|
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">
|
||||||
|
排水管网智能调度平台
|
||||||
|
</h1>
|
||||||
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block">调度工作台</p>
|
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block">调度工作台</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,19 +138,24 @@ export function WorkbenchTopBar({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{devPanelEnabled ? <div className="hidden lg:block">
|
{devPanelEnabled ? (
|
||||||
<button
|
<div className="hidden lg:block">
|
||||||
type="button"
|
<button
|
||||||
aria-label="切换地图 Dev Panel"
|
type="button"
|
||||||
aria-pressed={devPanelOpen}
|
aria-label="切换地图 Dev Panel"
|
||||||
onClick={onToggleDevPanel}
|
aria-pressed={devPanelOpen}
|
||||||
className={cn("group px-2 active:scale-95", headerControlButtonClassName, devPanelOpen && "bg-blue-50/80 text-blue-700")}
|
data-selection-tone="soft"
|
||||||
>
|
onClick={onToggleDevPanel}
|
||||||
<span className={cn(headerControlIconClassName, devPanelOpen && "bg-blue-600/10 text-blue-700")}><FlaskConical size={14} aria-hidden="true" /></span>
|
className={cn("group px-2", headerControlButtonClassName)}
|
||||||
<span className={compactHeaderTextClassName}>Dev</span>
|
>
|
||||||
</button>
|
<span className={headerControlIconClassName}>
|
||||||
</div> : null}
|
<FlaskConical size={14} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className={compactHeaderTextClassName}>Dev</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
<AlertMenu
|
<AlertMenu
|
||||||
compact
|
compact
|
||||||
@@ -193,20 +202,12 @@ function TaskTickerToggle({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
aria-label={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
||||||
aria-pressed={visible}
|
aria-pressed={visible}
|
||||||
|
data-selection-tone="soft"
|
||||||
title={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
title={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className={cn(
|
className={cn("group px-2", headerControlButtonClassName)}
|
||||||
"group px-2",
|
|
||||||
headerControlButtonClassName,
|
|
||||||
visible && "bg-blue-50/80 text-blue-700"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span
|
<span className={headerControlIconClassName}>
|
||||||
className={cn(
|
|
||||||
headerControlIconClassName,
|
|
||||||
visible && "bg-blue-600/10 text-blue-700"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon size={14} aria-hidden="true" />
|
<Icon size={14} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<span className={compactHeaderTextClassName}>任务浮条</span>
|
<span className={compactHeaderTextClassName}>任务浮条</span>
|
||||||
@@ -214,30 +215,18 @@ function TaskTickerToggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConditionFeedToggle({
|
function ConditionFeedToggle({ visible, onToggle }: { visible: boolean; onToggle: () => void }) {
|
||||||
visible,
|
|
||||||
onToggle
|
|
||||||
}: {
|
|
||||||
visible: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="工况任务"
|
||||||
aria-pressed={visible}
|
aria-pressed={visible}
|
||||||
|
data-selection-tone="soft"
|
||||||
|
title="工况任务"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className={cn(
|
className={cn("group px-2", headerControlButtonClassName)}
|
||||||
"group px-2",
|
|
||||||
headerControlButtonClassName,
|
|
||||||
visible && "bg-blue-50/80 text-blue-700"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span
|
<span className={headerControlIconClassName}>
|
||||||
className={cn(
|
|
||||||
headerControlIconClassName,
|
|
||||||
visible && "bg-blue-600/10 text-blue-700"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CalendarClock size={14} aria-hidden="true" />
|
<CalendarClock size={14} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<span className={compactHeaderTextClassName}>工况任务</span>
|
<span className={compactHeaderTextClassName}>工况任务</span>
|
||||||
@@ -245,12 +234,22 @@ function ConditionFeedToggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HeaderReadout({ icon: Icon, label, value }: { icon?: LucideIcon; label: string; value: string }) {
|
function HeaderReadout({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
icon?: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
|
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
|
||||||
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
|
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
|
||||||
<span className="hidden text-xs font-medium text-slate-500 2xl:inline">{label}</span>
|
<span className="hidden text-xs font-medium text-slate-500 2xl:inline">{label}</span>
|
||||||
<span className="max-w-16 truncate text-sm font-semibold text-slate-800 2xl:max-w-28">{value}</span>
|
<span className="max-w-40 shrink-0 truncate text-sm font-semibold text-slate-800">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type {
|
|||||||
ScheduledConditionRecord,
|
ScheduledConditionRecord,
|
||||||
ScheduledConditionStatus,
|
ScheduledConditionStatus,
|
||||||
ScheduledConditionTaskId
|
ScheduledConditionTaskId
|
||||||
} from "@/features/workbench/types";
|
} from "../types";
|
||||||
|
|
||||||
export type RunningWorkflowStepDefinition = {
|
export type RunningWorkflowStepDefinition = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -28,4 +28,16 @@ describe("createOperationalScheduledConditions", () => {
|
|||||||
expect(currentCondition?.status).toBe("running");
|
expect(currentCondition?.status).toBe("running");
|
||||||
expect(currentCondition?.durationMinutes).toBe(5);
|
expect(currentCondition?.durationMinutes).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("labels the scheduled work order execution stage as running", () => {
|
||||||
|
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T11:45:30+08:00"));
|
||||||
|
const workOrder = conditions.find(
|
||||||
|
(condition) => condition.kind === "work_order" && condition.status === "running"
|
||||||
|
);
|
||||||
|
const executionStage = workOrder?.kind === "work_order"
|
||||||
|
? workOrder.stages.find((stage) => stage.id === "execution")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
expect(executionStage?.title).toBe("执行中");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -332,6 +332,8 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
|
|||||||
.map((workOrder) => {
|
.map((workOrder) => {
|
||||||
const scheduledAtDate = setMinutesOfDay(now, workOrder.startMinute);
|
const scheduledAtDate = setMinutesOfDay(now, workOrder.startMinute);
|
||||||
const replyDeadlineMinute = workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes;
|
const replyDeadlineMinute = workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes;
|
||||||
|
const stages = createWorkOrderStages(workOrder, now, scheduledAtDate, currentMinutesOfDay);
|
||||||
|
const currentStage = stages.find((stage) => stage.status === "current");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `manual-work-order-${workOrder.id}-${formatCompactLocalTime(scheduledAtDate)}`,
|
id: `manual-work-order-${workOrder.id}-${formatCompactLocalTime(scheduledAtDate)}`,
|
||||||
@@ -340,7 +342,12 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
|
|||||||
scheduledAt: formatLocalIsoWithOffset(scheduledAtDate),
|
scheduledAt: formatLocalIsoWithOffset(scheduledAtDate),
|
||||||
title: workOrder.title,
|
title: workOrder.title,
|
||||||
summary: workOrder.summary,
|
summary: workOrder.summary,
|
||||||
status: currentMinutesOfDay < workOrder.startMinute ? ("pending" as const) : ("running" as const),
|
status:
|
||||||
|
currentMinutesOfDay < workOrder.startMinute
|
||||||
|
? ("pending" as const)
|
||||||
|
: currentStage?.id === "reply"
|
||||||
|
? ("awaiting_reply" as const)
|
||||||
|
: ("running" as const),
|
||||||
riskLevel: workOrder.riskLevel,
|
riskLevel: workOrder.riskLevel,
|
||||||
updatedAt: now.getTime(),
|
updatedAt: now.getTime(),
|
||||||
detail: workOrder.detail,
|
detail: workOrder.detail,
|
||||||
@@ -352,7 +359,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
|
|||||||
assignee: workOrder.assignee,
|
assignee: workOrder.assignee,
|
||||||
priority: workOrder.priority,
|
priority: workOrder.priority,
|
||||||
replyWindowMinutes: workOrder.replyWindowMinutes,
|
replyWindowMinutes: workOrder.replyWindowMinutes,
|
||||||
stages: createWorkOrderStages(workOrder, now, scheduledAtDate, currentMinutesOfDay),
|
stages,
|
||||||
replyRequirements:
|
replyRequirements:
|
||||||
currentMinutesOfDay > replyDeadlineMinute
|
currentMinutesOfDay > replyDeadlineMinute
|
||||||
? []
|
? []
|
||||||
@@ -410,7 +417,7 @@ function createWorkOrderStages(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "execution" as const,
|
id: "execution" as const,
|
||||||
title: "执行",
|
title: "执行中",
|
||||||
status:
|
status:
|
||||||
currentMinutesOfDay >= executionEndMinute
|
currentMinutesOfDay >= executionEndMinute
|
||||||
? ("done" as const)
|
? ("done" as const)
|
||||||
@@ -770,7 +777,7 @@ function createDispatchInstructions(signal: TaskSignal): DispatchInstruction[] {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: "检查井液位观察",
|
action: "检查井液位观察",
|
||||||
target: "高水位片区检查井液位点 P-117",
|
target: "高水位片区检查井液位点 J-117",
|
||||||
command: "将该点加入 15 分钟高频复核队列",
|
command: "将该点加入 15 分钟高频复核队列",
|
||||||
verification: "若液位高于 2.80m 持续两轮,转人工调度确认"
|
verification: "若液位高于 2.80m 持续两轮,转人工调度确认"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import type {
|
|
||||||
AgentChatMessage,
|
|
||||||
AgentPermissionReply,
|
|
||||||
AgentPermissionStatus,
|
|
||||||
AgentQuestionRequest,
|
|
||||||
AgentStreamRenderState
|
|
||||||
} from "@/features/agent";
|
|
||||||
import type { AgentSessionStreamEvent } from "@/features/agent/api/client";
|
|
||||||
import {
|
import {
|
||||||
applyPermissionResponse,
|
applyPermissionResponse,
|
||||||
applyQuestionResponse,
|
applyQuestionResponse,
|
||||||
toTodoUpdate,
|
toTodoUpdate,
|
||||||
upsertPermission,
|
upsertPermission,
|
||||||
upsertProgress,
|
upsertProgress,
|
||||||
upsertQuestion
|
upsertQuestion,
|
||||||
} from "@/features/agent/session-state";
|
type AgentChatMessage,
|
||||||
|
type AgentPermissionReply,
|
||||||
|
type AgentPermissionStatus,
|
||||||
|
type AgentQuestionRequest,
|
||||||
|
type AgentSessionStreamEvent,
|
||||||
|
type AgentStreamRenderState
|
||||||
|
} from "@/features/agent";
|
||||||
|
|
||||||
type AgentUiDataParts = {
|
type AgentUiDataParts = {
|
||||||
progress: Record<string, unknown>;
|
progress: Record<string, unknown>;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
|
type PointerEvent as ReactPointerEvent
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
WORKBENCH_LAYOUT,
|
||||||
|
clampAgentPanelWidth,
|
||||||
|
getAgentPanelDefaultWidth,
|
||||||
|
getAgentPanelMaxWidth
|
||||||
|
} from "../layout/workbench-layout";
|
||||||
|
|
||||||
|
export function useAgentPanelResize(viewportWidth: number, conditionExpanded = false) {
|
||||||
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const resizeLeftRef = useRef(0);
|
||||||
|
const cleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
const widthRef = useRef<number | null>(null);
|
||||||
|
const [width, setWidth] = useState<number | null>(null);
|
||||||
|
const [committedWidth, setCommittedWidth] = useState<number | null>(null);
|
||||||
|
const [resizing, setResizing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => () => cleanupRef.current?.(), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (widthRef.current === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextWidth = clampAgentPanelWidth(widthRef.current, viewportWidth, conditionExpanded);
|
||||||
|
widthRef.current = nextWidth;
|
||||||
|
setWidth(nextWidth);
|
||||||
|
setCommittedWidth(nextWidth);
|
||||||
|
}, [conditionExpanded, viewportWidth]);
|
||||||
|
|
||||||
|
const resize = useCallback(
|
||||||
|
(clientX: number) => {
|
||||||
|
const nextWidth = clampAgentPanelWidth(
|
||||||
|
clientX - resizeLeftRef.current,
|
||||||
|
window.innerWidth,
|
||||||
|
conditionExpanded
|
||||||
|
);
|
||||||
|
widthRef.current = nextWidth;
|
||||||
|
setWidth(nextWidth);
|
||||||
|
},
|
||||||
|
[conditionExpanded]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (event.button !== 0 || !panelRef.current) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
resizeLeftRef.current = panelRef.current.getBoundingClientRect().left;
|
||||||
|
setResizing(true);
|
||||||
|
resize(event.clientX);
|
||||||
|
|
||||||
|
cleanupRef.current?.();
|
||||||
|
const handlePointerMove = (pointerEvent: PointerEvent) => {
|
||||||
|
pointerEvent.preventDefault();
|
||||||
|
resize(pointerEvent.clientX);
|
||||||
|
};
|
||||||
|
const stopResizing = () => {
|
||||||
|
cleanupRef.current?.();
|
||||||
|
setResizing(false);
|
||||||
|
setCommittedWidth(widthRef.current);
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
|
window.removeEventListener("pointerup", stopResizing);
|
||||||
|
window.removeEventListener("pointercancel", stopResizing);
|
||||||
|
cleanupRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
cleanupRef.current = cleanup;
|
||||||
|
window.addEventListener("pointermove", handlePointerMove);
|
||||||
|
window.addEventListener("pointerup", stopResizing);
|
||||||
|
window.addEventListener("pointercancel", stopResizing);
|
||||||
|
},
|
||||||
|
[resize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const currentWidth =
|
||||||
|
panelRef.current?.getBoundingClientRect().width ?? WORKBENCH_LAYOUT.desktop.agentWidth;
|
||||||
|
let nextWidth = currentWidth;
|
||||||
|
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
nextWidth -= 16;
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
nextWidth += 16;
|
||||||
|
} else if (event.key === "Home") {
|
||||||
|
nextWidth = getAgentPanelDefaultWidth(window.innerWidth, conditionExpanded);
|
||||||
|
} else if (event.key === "End") {
|
||||||
|
nextWidth = getAgentPanelMaxWidth(window.innerWidth, conditionExpanded);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const clampedWidth = clampAgentPanelWidth(nextWidth, window.innerWidth, conditionExpanded);
|
||||||
|
widthRef.current = clampedWidth;
|
||||||
|
setWidth(clampedWidth);
|
||||||
|
setCommittedWidth(clampedWidth);
|
||||||
|
},
|
||||||
|
[conditionExpanded]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
committedWidth,
|
||||||
|
handleKeyDown,
|
||||||
|
handlePointerDown,
|
||||||
|
panelRef,
|
||||||
|
resizing,
|
||||||
|
width
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Map as MapLibreMap, MapLayerMouseEvent } from "maplibre-gl";
|
import type { Map as MapLibreMap, MapLayerMouseEvent } from "maplibre-gl";
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -11,7 +9,6 @@ import {
|
|||||||
} from "../map/feature-adapter";
|
} from "../map/feature-adapter";
|
||||||
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
|
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
|
||||||
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
|
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
|
||||||
import { SCADA_HIT_LAYER_ID } from "../map/scada";
|
|
||||||
|
|
||||||
export type MapFeatureInteractionState = {
|
export type MapFeatureInteractionState = {
|
||||||
source: WaterNetworkSourceId;
|
source: WaterNetworkSourceId;
|
||||||
@@ -107,7 +104,7 @@ export function useMapInteractions({
|
|||||||
if (feature) onSelectFeature(toDetailFeature(feature));
|
if (feature) onSelectFeature(toDetailFeature(feature));
|
||||||
};
|
};
|
||||||
|
|
||||||
const hitLayerIds = [SCADA_HIT_LAYER_ID, ...INTERACTIVE_HIT_LAYER_IDS];
|
const hitLayerIds = [...INTERACTIVE_HIT_LAYER_IDS];
|
||||||
map.on("mousemove", hitLayerIds, handleMouseMove);
|
map.on("mousemove", hitLayerIds, handleMouseMove);
|
||||||
map.on("mouseleave", hitLayerIds, handleMouseLeave);
|
map.on("mouseleave", hitLayerIds, handleMouseLeave);
|
||||||
map.on("click", hitLayerIds, handleClick);
|
map.on("click", hitLayerIds, handleClick);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
@@ -7,34 +5,30 @@ import useSWR from "swr";
|
|||||||
import useSWRImmutable from "swr/immutable";
|
import useSWRImmutable from "swr/immutable";
|
||||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||||
import { env } from "@/shared/config/env";
|
import { env } from "@/shared/config/env";
|
||||||
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
import { showMapNotice } from "@/features/map/core";
|
||||||
import type {
|
|
||||||
AgentApprovalMode,
|
|
||||||
AgentChatMessage,
|
|
||||||
AgentModelOption,
|
|
||||||
AgentPermissionReply,
|
|
||||||
AgentPermissionRequest,
|
|
||||||
AgentQuestionRequest,
|
|
||||||
AgentStreamRenderState
|
|
||||||
} from "@/features/agent";
|
|
||||||
import {
|
|
||||||
createAgentApiClient,
|
|
||||||
type AgentSessionStreamEvent
|
|
||||||
} from "@/features/agent/api/client";
|
|
||||||
import {
|
import {
|
||||||
|
FrontendActionExecutor,
|
||||||
agentBootstrapKey,
|
agentBootstrapKey,
|
||||||
agentSessionsKey,
|
agentSessionsKey,
|
||||||
|
createAgentApiClient,
|
||||||
fetchAgentBootstrap,
|
fetchAgentBootstrap,
|
||||||
fetchAgentSessions
|
fetchAgentSessions,
|
||||||
} from "@/features/agent/api/swr";
|
hasPendingAgentInput,
|
||||||
import {
|
|
||||||
isUiEnvelopeAllowed,
|
isUiEnvelopeAllowed,
|
||||||
parseUiEnvelopePayload,
|
parseUiEnvelopePayload,
|
||||||
|
type AgentApprovalMode,
|
||||||
|
type AgentChatMessage,
|
||||||
|
type AgentConnectionStatus,
|
||||||
|
type AgentModelOption,
|
||||||
|
type AgentPermissionReply,
|
||||||
|
type AgentPermissionRequest,
|
||||||
|
type AgentQuestionRequest,
|
||||||
|
type AgentSessionStreamEvent,
|
||||||
|
type AgentStreamRenderState,
|
||||||
|
type FrontendActionRequest,
|
||||||
type UIEnvelopePayload,
|
type UIEnvelopePayload,
|
||||||
type UIRegistry
|
type UIRegistry
|
||||||
} from "@/features/agent/ui-envelope";
|
} from "@/features/agent";
|
||||||
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
|
|
||||||
import { FrontendActionExecutor } from "@/features/agent/frontend-action/executor";
|
|
||||||
import {
|
import {
|
||||||
applySessionStreamEvent,
|
applySessionStreamEvent,
|
||||||
createCompletedStreamRenderState,
|
createCompletedStreamRenderState,
|
||||||
@@ -53,8 +47,6 @@ import {
|
|||||||
|
|
||||||
const AGENT_PANEL_COLLAPSE_MS = 180;
|
const AGENT_PANEL_COLLAPSE_MS = 180;
|
||||||
|
|
||||||
type AgentRuntimeAvailability = "connecting" | "connected" | "unavailable" | "failed";
|
|
||||||
|
|
||||||
type UseWorkbenchAgentOptions = {
|
type UseWorkbenchAgentOptions = {
|
||||||
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
|
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
|
||||||
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
|
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
|
||||||
@@ -79,7 +71,8 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
|||||||
const [registry, setRegistry] = useState<UIRegistry | null>(null);
|
const [registry, setRegistry] = useState<UIRegistry | null>(null);
|
||||||
const [sessionTitle, setSessionTitle] = useState("新对话");
|
const [sessionTitle, setSessionTitle] = useState("新对话");
|
||||||
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
|
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
|
||||||
const [runtimeAvailability, setRuntimeAvailability] = useState<AgentRuntimeAvailability>("connecting");
|
const [connectionStatus, setConnectionStatus] =
|
||||||
|
useState<AgentConnectionStatus>("connecting");
|
||||||
const [resumedStreaming, setResumedStreaming] = useState(false);
|
const [resumedStreaming, setResumedStreaming] = useState(false);
|
||||||
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
||||||
const [selectedModel, setSelectedModel] = useState("");
|
const [selectedModel, setSelectedModel] = useState("");
|
||||||
@@ -148,6 +141,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
|||||||
|
|
||||||
const handleDataPart = useCallback(
|
const handleDataPart = useCallback(
|
||||||
(part: AgentDataPart) => {
|
(part: AgentDataPart) => {
|
||||||
|
setConnectionStatus("online");
|
||||||
const eventSessionId = getDataString(part.data, "session_id");
|
const eventSessionId = getDataString(part.data, "session_id");
|
||||||
if (eventSessionId && sessionIdRef.current && eventSessionId !== sessionIdRef.current) {
|
if (eventSessionId && sessionIdRef.current && eventSessionId !== sessionIdRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -234,7 +228,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
|||||||
onData: (part) => handleDataPart(part as AgentDataPart),
|
onData: (part) => handleDataPart(part as AgentDataPart),
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||||
setRuntimeAvailability("failed");
|
setConnectionStatus("offline");
|
||||||
setStatusLabel("Agent 后端请求失败");
|
setStatusLabel("Agent 后端请求失败");
|
||||||
showMapNotice({
|
showMapNotice({
|
||||||
id: "agent-stream-status",
|
id: "agent-stream-status",
|
||||||
@@ -245,6 +239,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
|||||||
},
|
},
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||||
|
setConnectionStatus("online");
|
||||||
setStatusLabel("Agent 分析完成");
|
setStatusLabel("Agent 分析完成");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -311,7 +306,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
|||||||
setModelOptions(backendConnection.models);
|
setModelOptions(backendConnection.models);
|
||||||
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
|
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
|
||||||
if (chatRef.current.status === "ready") {
|
if (chatRef.current.status === "ready") {
|
||||||
setRuntimeAvailability("connected");
|
setConnectionStatus("online");
|
||||||
setStatusLabel("准备就绪");
|
setStatusLabel("准备就绪");
|
||||||
}
|
}
|
||||||
}, [backendConnection]);
|
}, [backendConnection]);
|
||||||
@@ -321,7 +316,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setRuntimeAvailability("unavailable");
|
setConnectionStatus("offline");
|
||||||
setStatusLabel("Agent 后端不可用");
|
setStatusLabel("Agent 后端不可用");
|
||||||
}, [backendConnectionError]);
|
}, [backendConnectionError]);
|
||||||
|
|
||||||
@@ -379,10 +374,9 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
|||||||
const personaState = useMemo(
|
const personaState = useMemo(
|
||||||
() => deriveAgentPersonaState(messages, {
|
() => deriveAgentPersonaState(messages, {
|
||||||
chatStatus: chat.status,
|
chatStatus: chat.status,
|
||||||
runtimeAvailability,
|
connectionStatus
|
||||||
statusLabel
|
|
||||||
}),
|
}),
|
||||||
[chat.status, messages, runtimeAvailability, statusLabel]
|
[chat.status, connectionStatus, messages]
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshSessionHistory = useCallback(async () => {
|
const refreshSessionHistory = useCallback(async () => {
|
||||||
@@ -486,7 +480,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setRuntimeAvailability("failed");
|
setConnectionStatus("offline");
|
||||||
setStatusLabel("Agent 会话流恢复失败");
|
setStatusLabel("Agent 会话流恢复失败");
|
||||||
showMapNotice({
|
showMapNotice({
|
||||||
id: "agent-stream-status",
|
id: "agent-stream-status",
|
||||||
@@ -597,7 +591,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
|||||||
clearSessionId();
|
clearSessionId();
|
||||||
setSessionTitle("新对话");
|
setSessionTitle("新对话");
|
||||||
setStatusLabel(nextStatusLabel);
|
setStatusLabel(nextStatusLabel);
|
||||||
setRuntimeAvailability("connected");
|
setConnectionStatus("online");
|
||||||
setPermissionOverrides({});
|
setPermissionOverrides({});
|
||||||
setQuestionOverrides({});
|
setQuestionOverrides({});
|
||||||
setStreamRenderState({});
|
setStreamRenderState({});
|
||||||
@@ -801,6 +795,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
|||||||
modelOptions,
|
modelOptions,
|
||||||
openMobilePanel,
|
openMobilePanel,
|
||||||
closeMobilePanel,
|
closeMobilePanel,
|
||||||
|
connectionStatus,
|
||||||
panelCollapsing,
|
panelCollapsing,
|
||||||
panelOpen,
|
panelOpen,
|
||||||
personaState,
|
personaState,
|
||||||
@@ -832,23 +827,17 @@ function deriveAgentPersonaState(
|
|||||||
messages: AgentChatMessage[],
|
messages: AgentChatMessage[],
|
||||||
{
|
{
|
||||||
chatStatus,
|
chatStatus,
|
||||||
runtimeAvailability,
|
connectionStatus
|
||||||
statusLabel
|
|
||||||
}: {
|
}: {
|
||||||
chatStatus: string;
|
chatStatus: string;
|
||||||
runtimeAvailability: AgentRuntimeAvailability;
|
connectionStatus: AgentConnectionStatus;
|
||||||
statusLabel: string;
|
|
||||||
}
|
}
|
||||||
): PersonaState {
|
): PersonaState {
|
||||||
if (
|
if (connectionStatus === "offline") {
|
||||||
runtimeAvailability === "unavailable" ||
|
|
||||||
runtimeAvailability === "failed" ||
|
|
||||||
/失败|不可用|错误|降级/.test(statusLabel)
|
|
||||||
) {
|
|
||||||
return "asleep";
|
return "asleep";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPendingOperatorInput(messages)) {
|
if (hasPendingAgentInput(messages)) {
|
||||||
return "listening";
|
return "listening";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,7 +845,7 @@ function deriveAgentPersonaState(
|
|||||||
chatStatus === "submitted" ||
|
chatStatus === "submitted" ||
|
||||||
chatStatus === "streaming" ||
|
chatStatus === "streaming" ||
|
||||||
hasRunningSessionWork(messages) ||
|
hasRunningSessionWork(messages) ||
|
||||||
runtimeAvailability === "connecting"
|
connectionStatus === "connecting"
|
||||||
) {
|
) {
|
||||||
return "thinking";
|
return "thinking";
|
||||||
}
|
}
|
||||||
@@ -864,14 +853,6 @@ function deriveAgentPersonaState(
|
|||||||
return "idle";
|
return "idle";
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasPendingOperatorInput(messages: AgentChatMessage[]) {
|
|
||||||
return messages.some(
|
|
||||||
(message) =>
|
|
||||||
message.permissions?.some((permission) => permission.status === "pending" || permission.status === "error") ||
|
|
||||||
message.questions?.some((question) => question.status === "pending" || question.status === "error")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasRunningSessionWork(messages: AgentChatMessage[]) {
|
function hasRunningSessionWork(messages: AgentChatMessage[]) {
|
||||||
const latestProgress = messages.flatMap((message) => message.progress ?? []).at(-1);
|
const latestProgress = messages.flatMap((message) => message.progress ?? []).at(-1);
|
||||||
const latestTodo = messages.flatMap((message) => message.todos?.todos ?? []).at(-1);
|
const latestTodo = messages.flatMap((message) => message.todos?.todos ?? []).at(-1);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl";
|
import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
|
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
|
||||||
import { getResponsiveWorkbenchPadding } from "../map/camera";
|
import { getResponsiveWorkbenchPadding } from "../map/camera";
|
||||||
@@ -9,15 +7,31 @@ export function useWorkbenchMapController({
|
|||||||
mapRef,
|
mapRef,
|
||||||
mapReady,
|
mapReady,
|
||||||
leftPanelOpen,
|
leftPanelOpen,
|
||||||
rightPanelOpen
|
rightPanelOpen,
|
||||||
|
conditionPanelExpanded = false,
|
||||||
|
agentPanelWidth
|
||||||
}: {
|
}: {
|
||||||
mapRef: RefObject<MapLibreMap | null>;
|
mapRef: RefObject<MapLibreMap | null>;
|
||||||
mapReady: boolean;
|
mapReady: boolean;
|
||||||
leftPanelOpen: boolean;
|
leftPanelOpen: boolean;
|
||||||
rightPanelOpen: boolean;
|
rightPanelOpen: boolean;
|
||||||
|
conditionPanelExpanded?: boolean;
|
||||||
|
agentPanelWidth?: number;
|
||||||
}) {
|
}) {
|
||||||
const valuesRef = useRef({ mapReady, leftPanelOpen, rightPanelOpen });
|
const valuesRef = useRef({
|
||||||
valuesRef.current = { mapReady, leftPanelOpen, rightPanelOpen };
|
mapReady,
|
||||||
|
leftPanelOpen,
|
||||||
|
rightPanelOpen,
|
||||||
|
conditionPanelExpanded,
|
||||||
|
agentPanelWidth
|
||||||
|
});
|
||||||
|
valuesRef.current = {
|
||||||
|
mapReady,
|
||||||
|
leftPanelOpen,
|
||||||
|
rightPanelOpen,
|
||||||
|
conditionPanelExpanded,
|
||||||
|
agentPanelWidth
|
||||||
|
};
|
||||||
const controllerRef = useRef<WorkbenchMapController | null>(null);
|
const controllerRef = useRef<WorkbenchMapController | null>(null);
|
||||||
if (!controllerRef.current) {
|
if (!controllerRef.current) {
|
||||||
controllerRef.current = new WorkbenchMapController({
|
controllerRef.current = new WorkbenchMapController({
|
||||||
@@ -26,14 +40,22 @@ export function useWorkbenchMapController({
|
|||||||
getPadding: () => {
|
getPadding: () => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
return map
|
return map
|
||||||
? getResponsiveWorkbenchPadding(map, valuesRef.current.leftPanelOpen, valuesRef.current.rightPanelOpen)
|
? getResponsiveWorkbenchPadding(
|
||||||
|
map,
|
||||||
|
valuesRef.current.leftPanelOpen,
|
||||||
|
valuesRef.current.rightPanelOpen
|
||||||
|
)
|
||||||
: { top: 48, right: 48, bottom: 48, left: 48 };
|
: { top: 48, right: 48, bottom: 48, left: 48 };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = controllerRef.current;
|
const controller = controllerRef.current;
|
||||||
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
const state = useSyncExternalStore(
|
||||||
|
controller.subscribe,
|
||||||
|
controller.getSnapshot,
|
||||||
|
controller.getSnapshot
|
||||||
|
);
|
||||||
useEffect(() => () => controller.destroy(), [controller]);
|
useEffect(() => () => controller.destroy(), [controller]);
|
||||||
return { controller, state };
|
return { controller, state };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
|
||||||
import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
|
import * as maplibregl from "maplibre-gl";
|
||||||
|
import type { Map as MapLibreMap, MapSourceDataEvent } from "maplibre-gl";
|
||||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||||
import { env } from "@/shared/config/env";
|
import { env } from "@/shared/config/env";
|
||||||
import type { DetailFeature } from "../types";
|
import type { DetailFeature } from "../types";
|
||||||
@@ -130,7 +129,7 @@ export function useWorkbenchMap({
|
|||||||
pitch: 0,
|
pitch: 0,
|
||||||
bearing: 0,
|
bearing: 0,
|
||||||
maxZoom: MAP_MAX_ZOOM,
|
maxZoom: MAP_MAX_ZOOM,
|
||||||
preserveDrawingBuffer: true,
|
canvasContextAttributes: { preserveDrawingBuffer: true },
|
||||||
attributionControl: false
|
attributionControl: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Map as MapLibreMap, MapGeoJSONFeature, MapMouseEvent } from "maplibre-gl";
|
import type { Map as MapLibreMap, MapGeoJSONFeature, MapMouseEvent } from "maplibre-gl";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
formatHeaderDataTime,
|
||||||
|
getMsUntilNextHeaderDataTimeTick
|
||||||
|
} from "./use-workbench-runtime-data";
|
||||||
|
|
||||||
|
describe("workbench runtime data", () => {
|
||||||
|
it("snaps the header time to the current five-minute interval", () => {
|
||||||
|
expect(formatHeaderDataTime(new Date(2026, 6, 22, 15, 43, 31))).toBe("2026-07-22 15:40");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schedules the next header refresh on the next interval boundary", () => {
|
||||||
|
expect(getMsUntilNextHeaderDataTimeTick(new Date(2026, 6, 22, 15, 43, 30))).toBe(90_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
SCHEDULED_CONDITION_REFRESH_INTERVAL_MS,
|
||||||
|
createOperationalScheduledConditions
|
||||||
|
} from "../data/scheduled-conditions";
|
||||||
|
import type { ScheduledConditionItem } from "../types";
|
||||||
|
|
||||||
|
const HEADER_DATA_TIME_STEP_MINUTES = 5;
|
||||||
|
const INITIAL_HEADER_DATA_TIME = "同步中";
|
||||||
|
|
||||||
|
export function formatHeaderDataTime(date: Date) {
|
||||||
|
const snappedMinutes =
|
||||||
|
Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES;
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(snappedMinutes).padStart(2, "0");
|
||||||
|
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMsUntilNextHeaderDataTimeTick(date: Date) {
|
||||||
|
const nextTick = new Date(date);
|
||||||
|
const nextMinutes =
|
||||||
|
Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES +
|
||||||
|
HEADER_DATA_TIME_STEP_MINUTES;
|
||||||
|
|
||||||
|
nextTick.setMinutes(nextMinutes, 0, 0);
|
||||||
|
return Math.max(nextTick.getTime() - date.getTime(), 1_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkbenchRuntimeData() {
|
||||||
|
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
|
||||||
|
const [scheduledConditions, setScheduledConditions] = useState<ScheduledConditionItem[]>([]);
|
||||||
|
const [scheduledConditionsLoading, setScheduledConditionsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId: number;
|
||||||
|
|
||||||
|
const refreshHeaderDataTime = () => {
|
||||||
|
const now = new Date();
|
||||||
|
setHeaderDataTime(formatHeaderDataTime(now));
|
||||||
|
timeoutId = window.setTimeout(refreshHeaderDataTime, getMsUntilNextHeaderDataTimeTick(now));
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshHeaderDataTime();
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const refreshScheduledConditions = () => {
|
||||||
|
setScheduledConditions(createOperationalScheduledConditions(new Date()));
|
||||||
|
setScheduledConditionsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshScheduledConditions();
|
||||||
|
const intervalId = window.setInterval(
|
||||||
|
refreshScheduledConditions,
|
||||||
|
SCHEDULED_CONDITION_REFRESH_INTERVAL_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
headerDataTime,
|
||||||
|
scheduledConditions,
|
||||||
|
scheduledConditionsLoading
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,5 @@ export type {
|
|||||||
LayerGroupId,
|
LayerGroupId,
|
||||||
LayerGroupStylePatch,
|
LayerGroupStylePatch,
|
||||||
LineLayerGroupStylePatch,
|
LineLayerGroupStylePatch,
|
||||||
PointLayerGroupStylePatch,
|
PointLayerGroupStylePatch
|
||||||
ScadaLayerGroupStylePatch
|
|
||||||
} from "./map/layer-group-style";
|
} from "./map/layer-group-style";
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { getWorkbenchBasemapTone } from "./workbench-layout";
|
import {
|
||||||
|
clampAgentPanelWidth,
|
||||||
|
getAgentPanelDefaultWidth,
|
||||||
|
getAgentPanelMaxWidth,
|
||||||
|
getWorkbenchBasemapTone,
|
||||||
|
getWorkbenchCameraPadding,
|
||||||
|
getWorkbenchViewportLayout
|
||||||
|
} from "./workbench-layout";
|
||||||
|
|
||||||
describe("workbench basemap surface tone", () => {
|
describe("workbench basemap surface tone", () => {
|
||||||
it.each([
|
it.each([
|
||||||
@@ -11,3 +18,68 @@ describe("workbench basemap surface tone", () => {
|
|||||||
expect(getWorkbenchBasemapTone(baseLayerId)).toBe(expectedTone);
|
expect(getWorkbenchBasemapTone(baseLayerId)).toBe(expectedTone);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("workbench floating panels", () => {
|
||||||
|
it("uses bounded desktop and wide panel widths", () => {
|
||||||
|
expect(getWorkbenchViewportLayout(1440)).toMatchObject({
|
||||||
|
agentWidth: 500,
|
||||||
|
conditionWidth: 432,
|
||||||
|
conditionExpandedWidth: 880,
|
||||||
|
toolbarWidth: 48
|
||||||
|
});
|
||||||
|
expect(getWorkbenchViewportLayout(1536)).toMatchObject({
|
||||||
|
agentWidth: 540,
|
||||||
|
conditionExpandedWidth: 960
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks the expanded condition panel in camera padding", () => {
|
||||||
|
expect(
|
||||||
|
getWorkbenchCameraPadding(1440, {
|
||||||
|
agentOpen: true,
|
||||||
|
conditionOpen: true,
|
||||||
|
conditionExpanded: false
|
||||||
|
})
|
||||||
|
).toEqual({ top: 72, right: 504, bottom: 32, left: 524 });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getWorkbenchCameraPadding(1440, {
|
||||||
|
agentOpen: true,
|
||||||
|
conditionOpen: true,
|
||||||
|
conditionExpanded: true
|
||||||
|
})
|
||||||
|
).toEqual({ top: 72, right: 952, bottom: 32, left: 476 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the committed Agent width without exceeding the hard limit", () => {
|
||||||
|
expect(
|
||||||
|
getWorkbenchCameraPadding(1920, {
|
||||||
|
agentOpen: true,
|
||||||
|
agentWidth: 620,
|
||||||
|
conditionOpen: true
|
||||||
|
})
|
||||||
|
).toEqual({ top: 72, right: 504, bottom: 32, left: 644 });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getWorkbenchCameraPadding(1920, {
|
||||||
|
agentOpen: true,
|
||||||
|
agentWidth: 900,
|
||||||
|
conditionOpen: false
|
||||||
|
}).left
|
||||||
|
).toBe(744);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses responsive defaults and preserves the map corridor at maximum width", () => {
|
||||||
|
expect(getAgentPanelDefaultWidth(390)).toBe(500);
|
||||||
|
expect(getAgentPanelDefaultWidth(1024)).toBe(484);
|
||||||
|
expect(getAgentPanelDefaultWidth(1440)).toBe(500);
|
||||||
|
expect(getAgentPanelDefaultWidth(1536)).toBe(540);
|
||||||
|
expect(getAgentPanelMaxWidth(1024)).toBe(484);
|
||||||
|
expect(clampAgentPanelWidth(620, 1024)).toBe(484);
|
||||||
|
expect(getAgentPanelMaxWidth(1280)).toBe(516);
|
||||||
|
expect(getAgentPanelMaxWidth(1440)).toBe(676);
|
||||||
|
expect(getAgentPanelMaxWidth(1536)).toBe(720);
|
||||||
|
expect(getAgentPanelMaxWidth(1920)).toBe(720);
|
||||||
|
expect(getAgentPanelMaxWidth(1920, true)).toBe(628);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,32 +4,80 @@ export const WORKBENCH_LAYOUT = {
|
|||||||
persistentConditionMinWidth: 1280,
|
persistentConditionMinWidth: 1280,
|
||||||
wideMinWidth: 1536,
|
wideMinWidth: 1536,
|
||||||
collapsedAgentWidth: 72,
|
collapsedAgentWidth: 72,
|
||||||
maxAgentViewportRatio: 0.5,
|
maxAgentWidth: 720,
|
||||||
|
agentPanelLeftInset: 12,
|
||||||
|
conditionPanelRightInset: 64,
|
||||||
|
minimumAgentMapCorridor: 256,
|
||||||
mapEdgeGap: 24,
|
mapEdgeGap: 24,
|
||||||
desktop: {
|
desktop: {
|
||||||
agentWidth: 460,
|
agentWidth: 500,
|
||||||
conditionWidth: 432,
|
conditionWidth: 432,
|
||||||
|
conditionExpandedWidth: 880,
|
||||||
toolbarWidth: 48,
|
toolbarWidth: 48,
|
||||||
tickerWidth: 420
|
tickerWidth: 420
|
||||||
},
|
},
|
||||||
wide: {
|
wide: {
|
||||||
agentWidth: 500,
|
agentWidth: 540,
|
||||||
conditionWidth: 432,
|
conditionWidth: 432,
|
||||||
|
conditionExpandedWidth: 960,
|
||||||
toolbarWidth: 48,
|
toolbarWidth: 48,
|
||||||
tickerWidth: 460
|
tickerWidth: 460
|
||||||
|
},
|
||||||
|
mobileSheet: {
|
||||||
|
halfViewportRatio: 0.52,
|
||||||
|
fullTopGap: 68
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function clampAgentPanelWidth(width: number, viewportWidth: number) {
|
export function clampAgentPanelWidth(
|
||||||
const minWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
|
width: number,
|
||||||
const maxWidth = viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
|
viewportWidth: number,
|
||||||
|
conditionExpanded = false
|
||||||
|
) {
|
||||||
|
const minWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
|
||||||
|
|
||||||
return Math.round(Math.min(Math.max(width, minWidth), maxWidth));
|
return Math.round(
|
||||||
|
Math.min(Math.max(width, minWidth), getAgentPanelMaxWidth(viewportWidth, conditionExpanded))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentPanelDefaultWidth(viewportWidth: number, conditionExpanded = false) {
|
||||||
|
const layout = getWorkbenchViewportLayout(viewportWidth);
|
||||||
|
if (viewportWidth < WORKBENCH_LAYOUT.desktopMinWidth) {
|
||||||
|
return layout.agentWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditionWidth = conditionExpanded ? layout.conditionExpandedWidth : layout.conditionWidth;
|
||||||
|
const collisionSafeWidth =
|
||||||
|
viewportWidth -
|
||||||
|
WORKBENCH_LAYOUT.agentPanelLeftInset -
|
||||||
|
WORKBENCH_LAYOUT.mapEdgeGap -
|
||||||
|
conditionWidth -
|
||||||
|
WORKBENCH_LAYOUT.mapEdgeGap -
|
||||||
|
layout.toolbarWidth;
|
||||||
|
|
||||||
|
return Math.min(layout.agentWidth, collisionSafeWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentPanelMaxWidth(viewportWidth: number, conditionExpanded = false) {
|
||||||
|
const layout = getWorkbenchViewportLayout(viewportWidth);
|
||||||
|
const conditionWidth = conditionExpanded ? layout.conditionExpandedWidth : layout.conditionWidth;
|
||||||
|
const defaultWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
|
||||||
|
const widthWithMapCorridor =
|
||||||
|
viewportWidth -
|
||||||
|
WORKBENCH_LAYOUT.agentPanelLeftInset -
|
||||||
|
WORKBENCH_LAYOUT.conditionPanelRightInset -
|
||||||
|
conditionWidth -
|
||||||
|
WORKBENCH_LAYOUT.minimumAgentMapCorridor;
|
||||||
|
|
||||||
|
return Math.max(defaultWidth, Math.min(WORKBENCH_LAYOUT.maxAgentWidth, widthWithMapCorridor));
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkbenchPanelState = {
|
export type WorkbenchPanelState = {
|
||||||
agentOpen: boolean;
|
agentOpen: boolean;
|
||||||
|
agentWidth?: number;
|
||||||
conditionOpen: boolean;
|
conditionOpen: boolean;
|
||||||
|
conditionExpanded?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkbenchBasemapTone = "light" | "satellite" | "none";
|
export type WorkbenchBasemapTone = "light" | "satellite" | "none";
|
||||||
@@ -37,6 +85,7 @@ export type WorkbenchBasemapTone = "light" | "satellite" | "none";
|
|||||||
export type WorkbenchViewportLayout = {
|
export type WorkbenchViewportLayout = {
|
||||||
agentWidth: number;
|
agentWidth: number;
|
||||||
conditionWidth: number;
|
conditionWidth: number;
|
||||||
|
conditionExpandedWidth: number;
|
||||||
toolbarWidth: number;
|
toolbarWidth: number;
|
||||||
tickerWidth: number;
|
tickerWidth: number;
|
||||||
};
|
};
|
||||||
@@ -65,8 +114,23 @@ export function getWorkbenchCameraPadding(viewportWidth: number, panels: Workben
|
|||||||
}
|
}
|
||||||
|
|
||||||
const layout = getWorkbenchViewportLayout(viewportWidth);
|
const layout = getWorkbenchViewportLayout(viewportWidth);
|
||||||
const agentWidth = panels.agentOpen ? layout.agentWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
|
const agentWidth = panels.agentOpen
|
||||||
const conditionWidth = panels.conditionOpen ? layout.conditionWidth : 0;
|
? panels.agentWidth === undefined
|
||||||
|
? getAgentPanelDefaultWidth(
|
||||||
|
viewportWidth,
|
||||||
|
panels.conditionOpen && Boolean(panels.conditionExpanded)
|
||||||
|
)
|
||||||
|
: clampAgentPanelWidth(
|
||||||
|
panels.agentWidth,
|
||||||
|
viewportWidth,
|
||||||
|
panels.conditionOpen && Boolean(panels.conditionExpanded)
|
||||||
|
)
|
||||||
|
: WORKBENCH_LAYOUT.collapsedAgentWidth;
|
||||||
|
const conditionWidth = panels.conditionOpen
|
||||||
|
? panels.conditionExpanded
|
||||||
|
? layout.conditionExpandedWidth
|
||||||
|
: layout.conditionWidth
|
||||||
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top: WORKBENCH_LAYOUT.headerHeight + 16,
|
top: WORKBENCH_LAYOUT.headerHeight + 16,
|
||||||
@@ -82,6 +146,8 @@ export function getWorkbenchLayoutCssVariables() {
|
|||||||
"--workbench-agent-width-wide": `${WORKBENCH_LAYOUT.wide.agentWidth}px`,
|
"--workbench-agent-width-wide": `${WORKBENCH_LAYOUT.wide.agentWidth}px`,
|
||||||
"--workbench-condition-width": `${WORKBENCH_LAYOUT.desktop.conditionWidth}px`,
|
"--workbench-condition-width": `${WORKBENCH_LAYOUT.desktop.conditionWidth}px`,
|
||||||
"--workbench-condition-width-wide": `${WORKBENCH_LAYOUT.wide.conditionWidth}px`,
|
"--workbench-condition-width-wide": `${WORKBENCH_LAYOUT.wide.conditionWidth}px`,
|
||||||
|
"--workbench-condition-expanded-width": `${WORKBENCH_LAYOUT.desktop.conditionExpandedWidth}px`,
|
||||||
|
"--workbench-condition-expanded-width-wide": `${WORKBENCH_LAYOUT.wide.conditionExpandedWidth}px`,
|
||||||
"--workbench-toolbar-width": `${WORKBENCH_LAYOUT.desktop.toolbarWidth}px`,
|
"--workbench-toolbar-width": `${WORKBENCH_LAYOUT.desktop.toolbarWidth}px`,
|
||||||
"--workbench-ticker-width": `${WORKBENCH_LAYOUT.desktop.tickerWidth}px`,
|
"--workbench-ticker-width": `${WORKBENCH_LAYOUT.desktop.tickerWidth}px`,
|
||||||
"--workbench-ticker-width-wide": `${WORKBENCH_LAYOUT.wide.tickerWidth}px`
|
"--workbench-ticker-width-wide": `${WORKBENCH_LAYOUT.wide.tickerWidth}px`
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -8,39 +5,39 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
type CSSProperties,
|
type CSSProperties
|
||||||
type KeyboardEvent as ReactKeyboardEvent,
|
|
||||||
type PointerEvent as ReactPointerEvent
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
|
import { CalendarClock } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
AgentCollapsedRail,
|
|
||||||
AgentCommandPanel,
|
AgentCommandPanel,
|
||||||
AgentPersona,
|
AgentPersona,
|
||||||
|
toTrustedMapAction,
|
||||||
|
type FrontendActionRequest,
|
||||||
|
type UIEnvelopePayload,
|
||||||
type AgentUiResult
|
type AgentUiResult
|
||||||
} from "@/features/agent";
|
} from "@/features/agent";
|
||||||
import { toTrustedMapAction, type UIEnvelopePayload } from "@/features/agent/ui-envelope";
|
|
||||||
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
|
|
||||||
import {
|
import {
|
||||||
MapErrorNotice,
|
MapErrorNotice,
|
||||||
MapLoadingNotice,
|
MapLoadingNotice,
|
||||||
|
MapScaleLine,
|
||||||
MapSourceStatusNotice,
|
MapSourceStatusNotice,
|
||||||
|
MapToolbar,
|
||||||
|
MapZoom,
|
||||||
|
showMapNotice,
|
||||||
|
type MapToolbarItem,
|
||||||
type MapSourceStatus
|
type MapSourceStatus
|
||||||
} from "@/features/map/core/components/notice";
|
} from "@/features/map/core";
|
||||||
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
|
||||||
import { MapScaleLine } from "@/features/map/core/components/scale-line";
|
|
||||||
import { MapToolbar, type MapToolbarItem } from "@/features/map/core/components/toolbar";
|
|
||||||
import { MapZoom } from "@/features/map/core/components/zoom";
|
|
||||||
import {
|
|
||||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
|
||||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
|
||||||
} from "@/features/map/core/components/map-control-styles";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { env } from "@/shared/config/env";
|
import { env } from "@/shared/config/env";
|
||||||
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
||||||
import { MapDevPanel } from "./components/map-dev-panel";
|
import { MapDevPanel } from "./components/map-dev-panel";
|
||||||
|
import {
|
||||||
|
MobileWorkbenchSheet,
|
||||||
|
type MobileWorkbenchSheetSnap
|
||||||
|
} from "./components/mobile-workbench-sheet";
|
||||||
import { FeaturePopover } from "./components/feature-popover";
|
import { FeaturePopover } from "./components/feature-popover";
|
||||||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||||
|
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
||||||
import {
|
import {
|
||||||
ToolbarPanel,
|
ToolbarPanel,
|
||||||
type ExportViewPreset,
|
type ExportViewPreset,
|
||||||
@@ -48,15 +45,12 @@ import {
|
|||||||
type ToolbarToolId
|
type ToolbarToolId
|
||||||
} from "./components/toolbar-panel";
|
} from "./components/toolbar-panel";
|
||||||
import { WorkbenchTopBar } from "./components/workbench-top-bar";
|
import { WorkbenchTopBar } from "./components/workbench-top-bar";
|
||||||
import {
|
|
||||||
SCHEDULED_CONDITION_REFRESH_INTERVAL_MS,
|
|
||||||
createOperationalScheduledConditions
|
|
||||||
} from "./data/scheduled-conditions";
|
|
||||||
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
||||||
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
||||||
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
|
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
|
||||||
import { useWorkbenchMap } from "./hooks/use-workbench-map";
|
import { useWorkbenchMap } from "./hooks/use-workbench-map";
|
||||||
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
|
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
|
||||||
|
import { useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
|
||||||
import { toMapFeatureReference } from "./hooks/use-map-interactions";
|
import { toMapFeatureReference } from "./hooks/use-map-interactions";
|
||||||
import {
|
import {
|
||||||
useWorkbenchMeasurement,
|
useWorkbenchMeasurement,
|
||||||
@@ -65,20 +59,18 @@ import {
|
|||||||
} from "./hooks/use-workbench-measurement";
|
} from "./hooks/use-workbench-measurement";
|
||||||
import {
|
import {
|
||||||
WORKBENCH_LAYOUT,
|
WORKBENCH_LAYOUT,
|
||||||
clampAgentPanelWidth,
|
|
||||||
getWorkbenchBasemapTone,
|
getWorkbenchBasemapTone,
|
||||||
getWorkbenchLayoutCssVariables,
|
getWorkbenchLayoutCssVariables
|
||||||
getWorkbenchViewportLayout
|
|
||||||
} from "./layout/workbench-layout";
|
} from "./layout/workbench-layout";
|
||||||
import { getResponsiveWorkbenchPadding } from "./map/camera";
|
import { getResponsiveWorkbenchPadding } from "./map/camera";
|
||||||
import { exportMapViewImage } from "./map/export-view";
|
import { exportMapViewImage } from "./map/export-view";
|
||||||
import { parseScadaAnalysisItems } from "./map/scada-analysis";
|
import { parseScadaAnalysisItems } from "./map/scada-analysis";
|
||||||
import {
|
import {
|
||||||
BASE_LAYER_IDS,
|
|
||||||
BASE_LAYER_OPTIONS,
|
BASE_LAYER_OPTIONS,
|
||||||
INITIAL_LAYER_VISIBILITY,
|
INITIAL_LAYER_VISIBILITY,
|
||||||
MAP_LEGEND_ITEMS,
|
MAP_LEGEND_ITEMS,
|
||||||
WORKBENCH_LAYER_GROUPS,
|
WORKBENCH_LAYER_GROUPS,
|
||||||
|
applyBaseLayerVisibility,
|
||||||
createLayerControlItems,
|
createLayerControlItems,
|
||||||
getWorkbenchLayerIds
|
getWorkbenchLayerIds
|
||||||
} from "./map/map-control-config";
|
} from "./map/map-control-config";
|
||||||
@@ -89,53 +81,34 @@ import type {
|
|||||||
ScheduledConditionRecord,
|
ScheduledConditionRecord,
|
||||||
WorkbenchAlert
|
WorkbenchAlert
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import {
|
||||||
|
createScheduledConditionAlerts,
|
||||||
|
getScheduledConditionIdFromAlertId
|
||||||
|
} from "./utils/scheduled-condition-alerts";
|
||||||
import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts";
|
import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts";
|
||||||
|
|
||||||
const HEADER_DATA_TIME_STEP_MINUTES = 5;
|
|
||||||
const INITIAL_HEADER_DATA_TIME = "同步中";
|
|
||||||
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
|
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
|
||||||
|
|
||||||
function formatHeaderDataTime(date: Date) {
|
|
||||||
const snappedMinutes =
|
|
||||||
Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES;
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(date.getDate()).padStart(2, "0");
|
|
||||||
const hours = String(date.getHours()).padStart(2, "0");
|
|
||||||
const minutes = String(snappedMinutes).padStart(2, "0");
|
|
||||||
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMsUntilNextHeaderDataTimeTick(date: Date) {
|
|
||||||
const nextTick = new Date(date);
|
|
||||||
const nextMinutes =
|
|
||||||
Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES +
|
|
||||||
HEADER_DATA_TIME_STEP_MINUTES;
|
|
||||||
|
|
||||||
nextTick.setMinutes(nextMinutes, 0, 0);
|
|
||||||
|
|
||||||
return Math.max(nextTick.getTime() - date.getTime(), 1_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MapWorkbenchPage() {
|
export function MapWorkbenchPage() {
|
||||||
const hasMapboxToken = Boolean(env.DRAINAGE_MAPBOX_ACCESS_TOKEN);
|
const hasMapboxToken = Boolean(env.DRAINAGE_MAPBOX_ACCESS_TOKEN);
|
||||||
const devPanelEnabled = env.DRAINAGE_ENABLE_DEV_PANEL;
|
const devPanelEnabled = env.DRAINAGE_ENABLE_DEV_PANEL;
|
||||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const desktopAgentPanelRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const agentPanelResizeLeftRef = useRef(0);
|
|
||||||
const agentPanelResizeCleanupRef = useRef<(() => void) | null>(null);
|
|
||||||
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
|
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
|
||||||
const [devPanelOpen, setDevPanelOpen] = useState(false);
|
const [devPanelOpen, setDevPanelOpen] = useState(false);
|
||||||
const [impactVisible, setImpactVisible] = useState(false);
|
const [impactVisible, setImpactVisible] = useState(false);
|
||||||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||||
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
|
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
|
||||||
const [agentPanelWidth, setAgentPanelWidth] = useState<number | null>(null);
|
const [agentPanelWidth, setAgentPanelWidth] = useState<number>(
|
||||||
const [agentPanelResizing, setAgentPanelResizing] = useState(false);
|
WORKBENCH_LAYOUT.desktop.agentWidth
|
||||||
|
);
|
||||||
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
||||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
||||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
|
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
|
||||||
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
||||||
|
const [mobileSheet, setMobileSheet] = useState<"agent" | "condition" | null>(null);
|
||||||
|
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
|
||||||
|
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
|
||||||
|
const prefersReducedMotion = useReducedMotion();
|
||||||
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
|
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
|
||||||
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
|
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
|
||||||
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
|
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
|
||||||
@@ -153,9 +126,8 @@ export function MapWorkbenchPage() {
|
|||||||
const [layerVisibility, setLayerVisibility] =
|
const [layerVisibility, setLayerVisibility] =
|
||||||
useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY);
|
useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY);
|
||||||
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
|
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
|
||||||
const [scheduledConditions, setScheduledConditions] = useState<ScheduledConditionItem[]>([]);
|
const { headerDataTime, scheduledConditions, scheduledConditionsLoading } =
|
||||||
const [scheduledConditionsLoading, setScheduledConditionsLoading] = useState(true);
|
useWorkbenchRuntimeData();
|
||||||
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
|
|
||||||
const baseLayerOptions = useMemo(
|
const baseLayerOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
BASE_LAYER_OPTIONS.map((option) => ({
|
BASE_LAYER_OPTIONS.map((option) => ({
|
||||||
@@ -174,9 +146,9 @@ export function MapWorkbenchPage() {
|
|||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
setIsLargeScreen(mediaQuery.matches);
|
setIsLargeScreen(mediaQuery.matches);
|
||||||
setViewportWidth(window.innerWidth);
|
setViewportWidth(window.innerWidth);
|
||||||
setAgentPanelWidth((currentWidth) =>
|
if (mediaQuery.matches) {
|
||||||
currentWidth === null ? null : clampAgentPanelWidth(currentWidth, window.innerWidth)
|
setMobileSheet(null);
|
||||||
);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleChange();
|
handleChange();
|
||||||
@@ -190,38 +162,6 @@ export function MapWorkbenchPage() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => () => agentPanelResizeCleanupRef.current?.(), []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let timeoutId: number;
|
|
||||||
|
|
||||||
const refreshHeaderDataTime = () => {
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
setHeaderDataTime(formatHeaderDataTime(now));
|
|
||||||
timeoutId = window.setTimeout(refreshHeaderDataTime, getMsUntilNextHeaderDataTimeTick(now));
|
|
||||||
};
|
|
||||||
|
|
||||||
refreshHeaderDataTime();
|
|
||||||
|
|
||||||
return () => window.clearTimeout(timeoutId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const refreshScheduledConditions = () => {
|
|
||||||
setScheduledConditions(createOperationalScheduledConditions(new Date()));
|
|
||||||
setScheduledConditionsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
refreshScheduledConditions();
|
|
||||||
const intervalId = window.setInterval(
|
|
||||||
refreshScheduledConditions,
|
|
||||||
SCHEDULED_CONDITION_REFRESH_INTERVAL_MS
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => window.clearInterval(intervalId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const shouldShowConditionFeed = conditionFeedVisible && !activeToolId;
|
const shouldShowConditionFeed = conditionFeedVisible && !activeToolId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -243,15 +183,72 @@ export function MapWorkbenchPage() {
|
|||||||
});
|
});
|
||||||
function openAgentPanelForViewport() {
|
function openAgentPanelForViewport() {
|
||||||
if (isLargeScreen) {
|
if (isLargeScreen) {
|
||||||
|
if (conditionFeedExpanded && viewportWidth < 1440) {
|
||||||
|
setConditionFeedExpanded(false);
|
||||||
|
setAgentCollapsedForCondition(false);
|
||||||
|
}
|
||||||
agent.expandPanel();
|
agent.expandPanel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
agent.openMobilePanel();
|
setMobileSheet("agent");
|
||||||
|
setMobileSheetSnap("half");
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeMobileSheet = useCallback(() => {
|
||||||
|
setMobileSheet(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleConditionExpandedChange(nextExpanded: boolean) {
|
||||||
|
if (nextExpanded && isLargeScreen && viewportWidth < 1440) {
|
||||||
|
setAgentCollapsedForCondition(agent.panelOpen);
|
||||||
|
agent.collapsePanel();
|
||||||
|
} else if (!nextExpanded && agentCollapsedForCondition) {
|
||||||
|
setAgentCollapsedForCondition(false);
|
||||||
|
agent.expandPanel();
|
||||||
|
}
|
||||||
|
setConditionFeedExpanded(nextExpanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConditionFeedExpanded(false);
|
||||||
|
if (agentCollapsedForCondition) {
|
||||||
|
setAgentCollapsedForCondition(false);
|
||||||
|
agent.expandPanel();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
agent,
|
||||||
|
agentCollapsedForCondition,
|
||||||
|
conditionFeedExpanded,
|
||||||
|
isLargeScreen,
|
||||||
|
shouldShowConditionFeed
|
||||||
|
]);
|
||||||
|
|
||||||
|
function toggleConditionFeedForViewport() {
|
||||||
|
setActiveToolId(null);
|
||||||
|
if (isLargeScreen) {
|
||||||
|
setConditionFeedVisible((current) => !current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileSheet === "condition") {
|
||||||
|
closeMobileSheet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConditionFeedExpanded(false);
|
||||||
|
setMobileSheetSnap("half");
|
||||||
|
setMobileSheet("condition");
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftPanelOpen = isLargeScreen && agent.panelOpen;
|
const leftPanelOpen = isLargeScreen && agent.panelOpen;
|
||||||
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
||||||
|
const rightPanelExpanded =
|
||||||
|
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen;
|
||||||
|
|
||||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
||||||
containerRef: mapContainerRef,
|
containerRef: mapContainerRef,
|
||||||
@@ -265,7 +262,9 @@ export function MapWorkbenchPage() {
|
|||||||
mapRef,
|
mapRef,
|
||||||
mapReady,
|
mapReady,
|
||||||
leftPanelOpen,
|
leftPanelOpen,
|
||||||
rightPanelOpen
|
rightPanelOpen,
|
||||||
|
conditionPanelExpanded: rightPanelExpanded,
|
||||||
|
agentPanelWidth
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeAgentUiResults = useMemo(
|
const activeAgentUiResults = useMemo(
|
||||||
@@ -299,10 +298,9 @@ export function MapWorkbenchPage() {
|
|||||||
mode: activeMeasureModeId,
|
mode: activeMeasureModeId,
|
||||||
unit: activeMeasureUnitId
|
unit: activeMeasureUnitId
|
||||||
});
|
});
|
||||||
const prefersReducedMotion = useReducedMotion();
|
|
||||||
const taskTickerAvailable = runningTickerConditions.length > 0;
|
const taskTickerAvailable = runningTickerConditions.length > 0;
|
||||||
const shouldShowTaskTicker =
|
const shouldShowTaskTicker =
|
||||||
!agent.mobileOpen && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
|
mobileSheet === null && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
|
||||||
const taskTickerEnterState = prefersReducedMotion
|
const taskTickerEnterState = prefersReducedMotion
|
||||||
? {
|
? {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
@@ -565,6 +563,10 @@ export function MapWorkbenchPage() {
|
|||||||
|
|
||||||
setConditionFeedVisible(true);
|
setConditionFeedVisible(true);
|
||||||
setActiveToolId(null);
|
setActiveToolId(null);
|
||||||
|
if (!isLargeScreen) {
|
||||||
|
setMobileSheet("condition");
|
||||||
|
setMobileSheetSnap("half");
|
||||||
|
}
|
||||||
setConditionFocusRequest((current) => ({
|
setConditionFocusRequest((current) => ({
|
||||||
conditionId,
|
conditionId,
|
||||||
requestId: (current?.requestId ?? 0) + 1
|
requestId: (current?.requestId ?? 0) + 1
|
||||||
@@ -808,6 +810,7 @@ export function MapWorkbenchPage() {
|
|||||||
sessionHistoryLoading: agent.sessionHistoryLoading,
|
sessionHistoryLoading: agent.sessionHistoryLoading,
|
||||||
activeSessionId: agent.sessionId,
|
activeSessionId: agent.sessionId,
|
||||||
statusLabel: agent.statusLabel,
|
statusLabel: agent.statusLabel,
|
||||||
|
connectionStatus: agent.connectionStatus,
|
||||||
streaming: agent.streaming,
|
streaming: agent.streaming,
|
||||||
messages: agent.messages,
|
messages: agent.messages,
|
||||||
streamRenderState: agent.streamRenderState,
|
streamRenderState: agent.streamRenderState,
|
||||||
@@ -829,74 +832,18 @@ export function MapWorkbenchPage() {
|
|||||||
onRejectQuestion: agent.rejectQuestion
|
onRejectQuestion: agent.rejectQuestion
|
||||||
};
|
};
|
||||||
|
|
||||||
const resizeAgentPanel = useCallback((clientX: number) => {
|
|
||||||
setAgentPanelWidth(
|
|
||||||
clampAgentPanelWidth(clientX - agentPanelResizeLeftRef.current, window.innerWidth)
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAgentPanelResizeStart = useCallback(
|
|
||||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
||||||
if (event.button !== 0 || !desktopAgentPanelRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
agentPanelResizeLeftRef.current = desktopAgentPanelRef.current.getBoundingClientRect().left;
|
|
||||||
setAgentPanelResizing(true);
|
|
||||||
resizeAgentPanel(event.clientX);
|
|
||||||
|
|
||||||
agentPanelResizeCleanupRef.current?.();
|
|
||||||
const handlePointerMove = (pointerEvent: PointerEvent) => {
|
|
||||||
pointerEvent.preventDefault();
|
|
||||||
resizeAgentPanel(pointerEvent.clientX);
|
|
||||||
};
|
|
||||||
const stopResizing = () => {
|
|
||||||
agentPanelResizeCleanupRef.current?.();
|
|
||||||
setAgentPanelResizing(false);
|
|
||||||
};
|
|
||||||
const cleanup = () => {
|
|
||||||
window.removeEventListener("pointermove", handlePointerMove);
|
|
||||||
window.removeEventListener("pointerup", stopResizing);
|
|
||||||
window.removeEventListener("pointercancel", stopResizing);
|
|
||||||
agentPanelResizeCleanupRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
agentPanelResizeCleanupRef.current = cleanup;
|
|
||||||
window.addEventListener("pointermove", handlePointerMove);
|
|
||||||
window.addEventListener("pointerup", stopResizing);
|
|
||||||
window.addEventListener("pointercancel", stopResizing);
|
|
||||||
},
|
|
||||||
[resizeAgentPanel]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAgentPanelResizeKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
||||||
const currentWidth =
|
|
||||||
desktopAgentPanelRef.current?.getBoundingClientRect().width ??
|
|
||||||
WORKBENCH_LAYOUT.desktop.agentWidth;
|
|
||||||
let nextWidth = currentWidth;
|
|
||||||
|
|
||||||
if (event.key === "ArrowLeft") {
|
|
||||||
nextWidth -= 16;
|
|
||||||
} else if (event.key === "ArrowRight") {
|
|
||||||
nextWidth += 16;
|
|
||||||
} else if (event.key === "Home") {
|
|
||||||
nextWidth = getWorkbenchViewportLayout(window.innerWidth).agentWidth;
|
|
||||||
} else if (event.key === "End") {
|
|
||||||
nextWidth = window.innerWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
setAgentPanelWidth(clampAgentPanelWidth(nextWidth, window.innerWidth));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
|
className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
|
||||||
data-basemap-tone={basemapTone}
|
data-basemap-tone={basemapTone}
|
||||||
style={WORKBENCH_LAYOUT_CSS_VARIABLES as CSSProperties}
|
style={
|
||||||
|
{
|
||||||
|
...WORKBENCH_LAYOUT_CSS_VARIABLES,
|
||||||
|
"--workbench-agent-current-width": `${
|
||||||
|
leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth
|
||||||
|
}px`
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={mapContainerRef}
|
ref={mapContainerRef}
|
||||||
@@ -907,22 +854,19 @@ export function MapWorkbenchPage() {
|
|||||||
|
|
||||||
<WorkbenchTopBar
|
<WorkbenchTopBar
|
||||||
dataTime={headerDataTime}
|
dataTime={headerDataTime}
|
||||||
modelName="DrainFlow 1.0.0"
|
modelName="AquaDispatch v1.0"
|
||||||
scenarios={WORKBENCH_SCENARIOS}
|
scenarios={WORKBENCH_SCENARIOS}
|
||||||
activeScenarioId={activeScenarioId}
|
activeScenarioId={activeScenarioId}
|
||||||
alerts={workbenchAlerts}
|
alerts={workbenchAlerts}
|
||||||
user={WORKBENCH_USER}
|
user={WORKBENCH_USER}
|
||||||
conditionFeedVisible={shouldShowConditionFeed}
|
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
||||||
taskTickerAvailable={taskTickerAvailable}
|
taskTickerAvailable={taskTickerAvailable}
|
||||||
taskTickerVisible={taskTickerVisible}
|
taskTickerVisible={taskTickerVisible}
|
||||||
devPanelEnabled={devPanelEnabled}
|
devPanelEnabled={devPanelEnabled}
|
||||||
devPanelOpen={devPanelOpen}
|
devPanelOpen={devPanelOpen}
|
||||||
onSelectScenario={handleSelectScenario}
|
onSelectScenario={handleSelectScenario}
|
||||||
onSelectAlert={handleSelectAlert}
|
onSelectAlert={handleSelectAlert}
|
||||||
onToggleConditionFeed={() => {
|
onToggleConditionFeed={toggleConditionFeedForViewport}
|
||||||
setConditionFeedVisible((current) => !current);
|
|
||||||
setActiveToolId(null);
|
|
||||||
}}
|
|
||||||
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
||||||
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
||||||
onPreviewScenario={handlePreviewScenario}
|
onPreviewScenario={handlePreviewScenario}
|
||||||
@@ -935,50 +879,21 @@ export function MapWorkbenchPage() {
|
|||||||
onExportConfig={handleExportConfig}
|
onExportConfig={handleExportConfig}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<WorkbenchAgentPanels
|
||||||
ref={desktopAgentPanelRef}
|
panelProps={agentPanelProps}
|
||||||
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] max-w-[50vw] 2xl:w-[var(--workbench-agent-width-wide)] lg:block"
|
panelOpen={agent.panelOpen}
|
||||||
style={agentPanelWidth === null ? undefined : { width: agentPanelWidth }}
|
panelCollapsing={agent.panelCollapsing}
|
||||||
>
|
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
|
||||||
{agent.panelOpen ? (
|
personaState={agent.personaState}
|
||||||
<>
|
statusLabel={agent.statusLabel}
|
||||||
<AgentCommandPanel
|
viewportWidth={viewportWidth}
|
||||||
{...agentPanelProps}
|
onCollapsePanel={agent.collapsePanel}
|
||||||
collapsing={agent.panelCollapsing}
|
onExpandPanel={agent.expandPanel}
|
||||||
onCollapse={agent.collapsePanel}
|
onWidthCommit={setAgentPanelWidth}
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
aria-label="调整 Agent 面板宽度"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
aria-valuemax={Math.round(viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio)}
|
|
||||||
aria-valuemin={getWorkbenchViewportLayout(viewportWidth).agentWidth}
|
|
||||||
aria-valuenow={Math.round(
|
|
||||||
agentPanelWidth ?? getWorkbenchViewportLayout(viewportWidth).agentWidth
|
|
||||||
)}
|
|
||||||
className={cn(
|
|
||||||
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-hidden",
|
|
||||||
agentPanelResizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
|
|
||||||
)}
|
|
||||||
role="separator"
|
|
||||||
tabIndex={0}
|
|
||||||
title="拖拽调整 Agent 面板宽度"
|
|
||||||
onKeyDown={handleAgentPanelResizeKeyDown}
|
|
||||||
onPointerDown={handleAgentPanelResizeStart}
|
|
||||||
>
|
|
||||||
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-xs transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<AgentCollapsedRail
|
|
||||||
personaState={agent.personaState}
|
|
||||||
statusLabel={agent.statusLabel}
|
|
||||||
onExpand={agent.expandPanel}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!devPanelOpen ? (
|
{!devPanelOpen ? (
|
||||||
<div className="absolute right-2 top-24 z-20">
|
<div className="absolute right-2 top-24 z-20 hidden lg:block">
|
||||||
<MapToolbar items={toolbarItems} className="hidden lg:block" />
|
<MapToolbar items={toolbarItems} className="hidden lg:block" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -986,13 +901,14 @@ export function MapWorkbenchPage() {
|
|||||||
{conditionFeedMounted && !devPanelOpen ? (
|
{conditionFeedMounted && !devPanelOpen ? (
|
||||||
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
|
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
|
||||||
<ScheduledConditionFeed
|
<ScheduledConditionFeed
|
||||||
|
presentation="desktop-floating"
|
||||||
conditions={scheduledConditions}
|
conditions={scheduledConditions}
|
||||||
expanded={conditionFeedExpanded}
|
expanded={conditionFeedExpanded}
|
||||||
focusRequest={conditionFocusRequest}
|
focusRequest={conditionFocusRequest}
|
||||||
loading={scheduledConditionsLoading}
|
loading={scheduledConditionsLoading}
|
||||||
visible={shouldShowConditionFeed}
|
visible={shouldShowConditionFeed}
|
||||||
selectedConditionId={selectedConditionId}
|
selectedConditionId={selectedConditionId}
|
||||||
onExpandedChange={setConditionFeedExpanded}
|
onExpandedChange={handleConditionExpandedChange}
|
||||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||||
onSelectedConditionChange={setSelectedConditionId}
|
onSelectedConditionChange={setSelectedConditionId}
|
||||||
onExpandAgent={openAgentPanelForViewport}
|
onExpandAgent={openAgentPanelForViewport}
|
||||||
@@ -1017,24 +933,8 @@ export function MapWorkbenchPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
|
{mobileSheet === null ? (
|
||||||
{agent.mobileOpen ? (
|
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden">
|
||||||
<div className="h-[calc(100dvh-8.5rem)] min-h-[420px]">
|
|
||||||
<AgentCommandPanel
|
|
||||||
{...agentPanelProps}
|
|
||||||
collapsing={agent.mobilePanelCollapsing}
|
|
||||||
onCollapse={agent.closeMobilePanel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!agent.mobileOpen ? (
|
|
||||||
<MobileAgentToggle personaState={agent.personaState} onOpen={agent.openMobilePanel} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!agent.mobileOpen ? (
|
|
||||||
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
|
|
||||||
<ToolbarPanel
|
<ToolbarPanel
|
||||||
{...toolbarPanelProps}
|
{...toolbarPanelProps}
|
||||||
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
|
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
|
||||||
@@ -1072,16 +972,70 @@ export function MapWorkbenchPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<div className="absolute bottom-3 left-3 right-3 z-20 md:hidden">
|
<div className="absolute bottom-3 left-3 right-3 z-30 lg:hidden">
|
||||||
{!agent.mobileOpen ? (
|
{mobileSheet === null ? (
|
||||||
<MapToolbar
|
<div className="flex items-center justify-center gap-2">
|
||||||
items={toolbarItems}
|
<button
|
||||||
orientation="horizontal"
|
type="button"
|
||||||
className="w-full justify-center"
|
aria-label="打开 Agent 面板"
|
||||||
/>
|
title="打开 Agent 面板"
|
||||||
|
onClick={openAgentPanelForViewport}
|
||||||
|
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700"
|
||||||
|
>
|
||||||
|
<AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} />
|
||||||
|
</button>
|
||||||
|
<MapToolbar
|
||||||
|
items={toolbarItems}
|
||||||
|
orientation="horizontal"
|
||||||
|
className="h-11 justify-center"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="打开工况任务"
|
||||||
|
title="打开工况任务"
|
||||||
|
onClick={toggleConditionFeedForViewport}
|
||||||
|
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-blue-700"
|
||||||
|
>
|
||||||
|
<CalendarClock size={20} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{mobileSheet ? (
|
||||||
|
<MobileWorkbenchSheet
|
||||||
|
label={mobileSheet === "agent" ? "Agent 工作台抽屉" : "工况任务抽屉"}
|
||||||
|
snap={mobileSheetSnap}
|
||||||
|
onSnapChange={setMobileSheetSnap}
|
||||||
|
onClose={closeMobileSheet}
|
||||||
|
>
|
||||||
|
{mobileSheet === "agent" ? (
|
||||||
|
<AgentCommandPanel
|
||||||
|
{...agentPanelProps}
|
||||||
|
presentation="mobile-sheet"
|
||||||
|
onCollapse={closeMobileSheet}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScheduledConditionFeed
|
||||||
|
presentation="mobile-sheet"
|
||||||
|
conditions={scheduledConditions}
|
||||||
|
expanded={conditionFeedExpanded}
|
||||||
|
focusRequest={conditionFocusRequest}
|
||||||
|
loading={scheduledConditionsLoading}
|
||||||
|
visible
|
||||||
|
selectedConditionId={selectedConditionId}
|
||||||
|
onExpandedChange={handleConditionExpandedChange}
|
||||||
|
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||||
|
onSelectedConditionChange={setSelectedConditionId}
|
||||||
|
onExpandAgent={openAgentPanelForViewport}
|
||||||
|
onClose={closeMobileSheet}
|
||||||
|
onLoadHistorySession={handleLoadAgentHistorySession}
|
||||||
|
onSubmitPrompt={agent.submitPrompt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MobileWorkbenchSheet>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<FeaturePopover feature={detailFeature} onClose={() => setDetailFeature(null)} />
|
<FeaturePopover feature={detailFeature} onClose={() => setDetailFeature(null)} />
|
||||||
|
|
||||||
{!mapReady && !mapError ? (
|
{!mapReady && !mapError ? (
|
||||||
@@ -1118,18 +1072,6 @@ function isNoticeSourceStatus(sourceStatus: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBaseLayerVisibility(map: MapLibreMap, activeBaseLayerId: string) {
|
|
||||||
BASE_LAYER_IDS.forEach((layerId) => {
|
|
||||||
if (map.getLayer(layerId)) {
|
|
||||||
map.setLayoutProperty(
|
|
||||||
layerId,
|
|
||||||
"visibility",
|
|
||||||
activeBaseLayerId === layerId ? "visible" : "none"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getExportTargetLongEdge(preset: ExportViewPreset) {
|
function getExportTargetLongEdge(preset: ExportViewPreset) {
|
||||||
if (preset === "4k") {
|
if (preset === "4k") {
|
||||||
return 3840;
|
return 3840;
|
||||||
@@ -1145,67 +1087,3 @@ function getExportPresetLabel(preset: ExportViewPreset) {
|
|||||||
|
|
||||||
return "当前分辨率";
|
return "当前分辨率";
|
||||||
}
|
}
|
||||||
|
|
||||||
function createScheduledConditionAlerts(conditions: ScheduledConditionItem[]): WorkbenchAlert[] {
|
|
||||||
return conditions
|
|
||||||
.filter(isAlertCondition)
|
|
||||||
.sort((a, b) => getScheduledConditionTimestamp(b) - getScheduledConditionTimestamp(a))
|
|
||||||
.map((condition) => {
|
|
||||||
return {
|
|
||||||
id: `scheduled-condition-alert-${condition.id}`,
|
|
||||||
title: `待复核:${condition.title}`,
|
|
||||||
description: condition.detail ?? condition.summary,
|
|
||||||
time: formatScheduledConditionTime(condition.scheduledAt)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAlertCondition(condition: ScheduledConditionItem) {
|
|
||||||
return condition.kind === "condition" && condition.status === "error";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getScheduledConditionTimestamp(condition: ScheduledConditionItem) {
|
|
||||||
const scheduledAt = Date.parse(condition.scheduledAt);
|
|
||||||
return Number.isNaN(scheduledAt) ? condition.updatedAt : scheduledAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatScheduledConditionTime(value: string) {
|
|
||||||
const match = value.match(/T(\d{2}:\d{2})/);
|
|
||||||
return match?.[1] ?? value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
|
|
||||||
|
|
||||||
function getScheduledConditionIdFromAlertId(alertId: string) {
|
|
||||||
if (!alertId.startsWith(SCHEDULED_CONDITION_ALERT_ID_PREFIX)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return alertId.slice(SCHEDULED_CONDITION_ALERT_ID_PREFIX.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileAgentToggle({
|
|
||||||
personaState,
|
|
||||||
onOpen
|
|
||||||
}: {
|
|
||||||
personaState: ComponentProps<typeof AgentPersona>["state"];
|
|
||||||
onOpen: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="absolute bottom-20 left-3 z-30 lg:hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="打开 Agent 面板"
|
|
||||||
title="打开 Agent 面板"
|
|
||||||
onClick={onOpen}
|
|
||||||
className={cn(
|
|
||||||
"grid h-12 w-12 place-items-center text-violet-700",
|
|
||||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
|
||||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AgentPersona className="pointer-events-none h-10 w-10" state={personaState} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ function createMap(width = 1280, height = 720) {
|
|||||||
describe("workbench camera padding", () => {
|
describe("workbench camera padding", () => {
|
||||||
it.each([
|
it.each([
|
||||||
{ agentOpen: false, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 96 } },
|
{ agentOpen: false, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 96 } },
|
||||||
{ agentOpen: true, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 484 } },
|
{ agentOpen: true, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 524 } },
|
||||||
{ agentOpen: false, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 96 } },
|
{ agentOpen: false, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 96 } },
|
||||||
{ agentOpen: true, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 484 } }
|
{ agentOpen: true, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 524 } }
|
||||||
])("covers agent=$agentOpen and condition=$conditionOpen", ({ agentOpen, conditionOpen, expected }) => {
|
])("covers agent=$agentOpen and condition=$conditionOpen", ({ agentOpen, conditionOpen, expected }) => {
|
||||||
expect(getWorkbenchPadding(agentOpen, conditionOpen)).toEqual(expected);
|
expect(getWorkbenchPadding(agentOpen, conditionOpen)).toEqual(expected);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
|
|||||||
|
|
||||||
type LngLatBounds = [[number, number], [number, number]];
|
type LngLatBounds = [[number, number], [number, number]];
|
||||||
type WebMercatorBbox = readonly [number, number, number, number];
|
type WebMercatorBbox = readonly [number, number, number, number];
|
||||||
|
type CompletePaddingOptions = Required<PaddingOptions>;
|
||||||
|
|
||||||
export type NetworkViewParams = {
|
export type NetworkViewParams = {
|
||||||
bbox3857: WebMercatorBbox;
|
bbox3857: WebMercatorBbox;
|
||||||
@@ -13,19 +14,22 @@ export type NetworkViewParams = {
|
|||||||
|
|
||||||
const WEB_MERCATOR_RADIUS = 6378137;
|
const WEB_MERCATOR_RADIUS = 6378137;
|
||||||
const GLOBAL_VIEW_MAX_ZOOM = 13;
|
const GLOBAL_VIEW_MAX_ZOOM = 13;
|
||||||
const GLOBAL_VIEW_PADDING: PaddingOptions = { top: 96, right: 48, bottom: 48, left: 48 };
|
const GLOBAL_VIEW_PADDING: CompletePaddingOptions = { top: 96, right: 48, bottom: 48, left: 48 };
|
||||||
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
|
export function getWorkbenchPadding(
|
||||||
return getWorkbenchCameraPadding(1280, {
|
leftPanelOpen: boolean,
|
||||||
|
rightPanelOpen: boolean
|
||||||
|
): CompletePaddingOptions {
|
||||||
|
return completePadding(getWorkbenchCameraPadding(1280, {
|
||||||
agentOpen: leftPanelOpen,
|
agentOpen: leftPanelOpen,
|
||||||
conditionOpen: rightPanelOpen
|
conditionOpen: rightPanelOpen
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getResponsiveWorkbenchPadding(
|
export function getResponsiveWorkbenchPadding(
|
||||||
map: MapLibreMap,
|
map: MapLibreMap,
|
||||||
leftPanelOpen: boolean,
|
leftPanelOpen: boolean,
|
||||||
rightPanelOpen: boolean
|
rightPanelOpen: boolean
|
||||||
): PaddingOptions {
|
): CompletePaddingOptions {
|
||||||
const width = getMapViewportSize(map).width;
|
const width = getMapViewportSize(map).width;
|
||||||
return getResponsivePadding(
|
return getResponsivePadding(
|
||||||
map,
|
map,
|
||||||
@@ -51,10 +55,14 @@ export function fitNetworkBounds(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): PaddingOptions {
|
export function getResponsivePadding(
|
||||||
|
map: MapLibreMap,
|
||||||
|
padding: PaddingOptions
|
||||||
|
): CompletePaddingOptions {
|
||||||
|
const complete = completePadding(padding);
|
||||||
const { width, height } = getMapViewportSize(map);
|
const { width, height } = getMapViewportSize(map);
|
||||||
const horizontalPadding = padding.left + padding.right;
|
const horizontalPadding = complete.left + complete.right;
|
||||||
const verticalPadding = padding.top + padding.bottom;
|
const verticalPadding = complete.top + complete.bottom;
|
||||||
const maxHorizontalPadding = width * 0.58;
|
const maxHorizontalPadding = width * 0.58;
|
||||||
const maxVerticalPadding = height * 0.58;
|
const maxVerticalPadding = height * 0.58;
|
||||||
const horizontalScale = horizontalPadding > 0 ? maxHorizontalPadding / horizontalPadding : 1;
|
const horizontalScale = horizontalPadding > 0 ? maxHorizontalPadding / horizontalPadding : 1;
|
||||||
@@ -62,14 +70,23 @@ export function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions):
|
|||||||
const scale = Math.min(1, horizontalScale, verticalScale);
|
const scale = Math.min(1, horizontalScale, verticalScale);
|
||||||
|
|
||||||
if (scale >= 1) {
|
if (scale >= 1) {
|
||||||
return padding;
|
return complete;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top: Math.round(padding.top * scale),
|
top: Math.round(complete.top * scale),
|
||||||
right: Math.round(padding.right * scale),
|
right: Math.round(complete.right * scale),
|
||||||
bottom: Math.round(padding.bottom * scale),
|
bottom: Math.round(complete.bottom * scale),
|
||||||
left: Math.round(padding.left * scale)
|
left: Math.round(complete.left * scale)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function completePadding(padding: PaddingOptions): CompletePaddingOptions {
|
||||||
|
return {
|
||||||
|
top: padding.top ?? 0,
|
||||||
|
right: padding.right ?? 0,
|
||||||
|
bottom: padding.bottom ?? 0,
|
||||||
|
left: padding.left ?? 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import maplibregl, { type Map as MapLibreMap, type StyleSpecification } from "maplibre-gl";
|
import * as maplibregl from "maplibre-gl";
|
||||||
|
import type { Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
|
||||||
import type { MapFeatureInteractionState } from "../hooks/use-map-interactions";
|
import type { MapFeatureInteractionState } from "../hooks/use-map-interactions";
|
||||||
|
|
||||||
const MAX_EXPORT_DIMENSION = 4096;
|
const MAX_EXPORT_DIMENSION = 4096;
|
||||||
@@ -37,7 +38,7 @@ export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEd
|
|||||||
zoom: map.getZoom(),
|
zoom: map.getZoom(),
|
||||||
pitch: map.getPitch(),
|
pitch: map.getPitch(),
|
||||||
bearing: map.getBearing(),
|
bearing: map.getBearing(),
|
||||||
preserveDrawingBuffer: true,
|
canvasContextAttributes: { preserveDrawingBuffer: true },
|
||||||
interactive: false,
|
interactive: false,
|
||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
fadeDuration: 0
|
fadeDuration: 0
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { env } from "@/shared/config/env";
|
|||||||
|
|
||||||
export const MAP_URL = env.DRAINAGE_MAP_URL.replace(/\/$/, "");
|
export const MAP_URL = env.DRAINAGE_MAP_URL.replace(/\/$/, "");
|
||||||
|
|
||||||
export const GEOSERVER_WORKSPACE = (
|
export const GEOSERVER_WORKSPACE = env.DRAINAGE_GEOSERVER_WORKSPACE.trim().replace(
|
||||||
env.DRAINAGE_GEOSERVER_WORKSPACE
|
/^\/+|\/+$/g,
|
||||||
).trim().replace(/^\/+|\/+$/g, "");
|
""
|
||||||
|
);
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
|
|
||||||
|
type PaintPropertyName = Parameters<MapLibreMap["setPaintProperty"]>[1];
|
||||||
|
type LayoutPropertyName = Parameters<MapLibreMap["setLayoutProperty"]>[1];
|
||||||
import { DRAINAGE_LAYER_VISUALS } from "./map-layer-visuals";
|
import { DRAINAGE_LAYER_VISUALS } from "./map-layer-visuals";
|
||||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
|
|
||||||
@@ -195,11 +198,11 @@ function stateColor(color: string): unknown {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPaint(map: MapLibreMap, layerId: string, property: string, value: unknown) {
|
function setPaint(map: MapLibreMap, layerId: string, property: PaintPropertyName, value: unknown) {
|
||||||
if (map.getLayer(layerId)) map.setPaintProperty(layerId, property, (value ?? null) as never);
|
if (map.getLayer(layerId)) map.setPaintProperty(layerId, property, (value ?? null) as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLayout(map: MapLibreMap, layerId: string, property: string, value: unknown) {
|
function setLayout(map: MapLibreMap, layerId: string, property: LayoutPropertyName, value: unknown) {
|
||||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, property, value as never);
|
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, property, value as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
|
import type { BaseLayerOption, MapLayerControlItem, MapLegendItem } from "@/features/map/core";
|
||||||
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
|
import { GEOSERVER_WORKSPACE } from "./geoserver-config";
|
||||||
import type { MapLegendItem } from "@/features/map/core/components/legend";
|
|
||||||
import { GEOSERVER_WORKSPACE } from "../../../lib/config";
|
|
||||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
import {
|
import {
|
||||||
SOURCE_LAYERS,
|
SOURCE_LAYERS,
|
||||||
@@ -58,6 +56,18 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
|
|||||||
|
|
||||||
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
|
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
|
||||||
|
|
||||||
|
export function applyBaseLayerVisibility(map: MapLibreMap, activeBaseLayerId: string) {
|
||||||
|
BASE_LAYER_IDS.forEach((layerId) => {
|
||||||
|
if (map.getLayer(layerId)) {
|
||||||
|
map.setLayoutProperty(
|
||||||
|
layerId,
|
||||||
|
"visibility",
|
||||||
|
activeBaseLayerId === layerId ? "visible" : "none"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
|
export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
|
||||||
{
|
{
|
||||||
id: "mapbox-light",
|
id: "mapbox-light",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FeatureCollection } from "geojson";
|
import type { FeatureCollection } from "geojson";
|
||||||
import { GEOSERVER_WORKSPACE, MAP_URL } from "../../../lib/config";
|
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
||||||
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
|
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
|
||||||
|
|
||||||
export const MAP_FEATURE_ID_FIELDS: Record<WaterNetworkSourceId, "id" | "sensor_id"> = {
|
export const MAP_FEATURE_ID_FIELDS: Record<WaterNetworkSourceId, "id" | "sensor_id"> = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { VectorSourceSpecification, StyleSpecification } from "maplibre-gl";
|
import type { VectorSourceSpecification, StyleSpecification } from "maplibre-gl";
|
||||||
import { GEOSERVER_WORKSPACE, MAP_URL } from "../../../lib/config";
|
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
||||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
|
|
||||||
export const WATER_NETWORK_GLOBAL_VIEW = {
|
export const WATER_NETWORK_GLOBAL_VIEW = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BarChart3, Layers3, MapPinned, MoreHorizontal, Ruler } from "lucide-react";
|
import { BarChart3, Layers3, MapPinned, MoreHorizontal, Ruler } from "lucide-react";
|
||||||
import type { MapToolbarItem } from "@/features/map/core/components/toolbar";
|
import type { MapToolbarItem } from "@/features/map/core";
|
||||||
|
|
||||||
export const waterNetworkToolbarItems: MapToolbarItem[] = [
|
export const waterNetworkToolbarItems: MapToolbarItem[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export type WorkbenchUser = {
|
|||||||
|
|
||||||
export type ScheduledConditionStatus = "pending" | "running" | "completed" | "warning" | "error";
|
export type ScheduledConditionStatus = "pending" | "running" | "completed" | "warning" | "error";
|
||||||
|
|
||||||
|
export type ScheduledWorkOrderStatus = "pending" | "running" | "awaiting_reply";
|
||||||
|
|
||||||
|
export type ScheduledConditionItemStatus = ScheduledConditionStatus | ScheduledWorkOrderStatus;
|
||||||
|
|
||||||
export type ScheduledConditionRiskLevel = "normal" | "attention" | "critical";
|
export type ScheduledConditionRiskLevel = "normal" | "attention" | "critical";
|
||||||
|
|
||||||
export type ScheduledConditionTaskId = "scada-diagnosis" | "smart-dispatch" | "pump-energy" | "network-simulation";
|
export type ScheduledConditionTaskId = "scada-diagnosis" | "smart-dispatch" | "pump-energy" | "network-simulation";
|
||||||
@@ -71,12 +75,12 @@ export type ScheduledConditionReport = {
|
|||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ScheduledConditionBase = {
|
type ScheduledConditionBase<TStatus extends ScheduledConditionItemStatus> = {
|
||||||
id: string;
|
id: string;
|
||||||
scheduledAt: string;
|
scheduledAt: string;
|
||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
status: ScheduledConditionStatus;
|
status: TStatus;
|
||||||
riskLevel: ScheduledConditionRiskLevel;
|
riskLevel: ScheduledConditionRiskLevel;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
@@ -84,7 +88,7 @@ type ScheduledConditionBase = {
|
|||||||
durationMinutes?: number;
|
durationMinutes?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScheduledConditionRecord = ScheduledConditionBase & {
|
export type ScheduledConditionRecord = ScheduledConditionBase<ScheduledConditionStatus> & {
|
||||||
kind: "condition";
|
kind: "condition";
|
||||||
taskId: ScheduledConditionTaskId;
|
taskId: ScheduledConditionTaskId;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -95,7 +99,7 @@ export type ScheduledConditionRecord = ScheduledConditionBase & {
|
|||||||
report?: ScheduledConditionReport;
|
report?: ScheduledConditionReport;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScheduledWorkOrderItem = ScheduledConditionBase & {
|
export type ScheduledWorkOrderItem = ScheduledConditionBase<ScheduledWorkOrderStatus> & {
|
||||||
kind: "work_order";
|
kind: "work_order";
|
||||||
code: string;
|
code: string;
|
||||||
source: string;
|
source: string;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ScheduledConditionItem, WorkbenchAlert } from "../types";
|
||||||
|
|
||||||
|
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
|
||||||
|
|
||||||
|
export function createScheduledConditionAlerts(
|
||||||
|
conditions: ScheduledConditionItem[]
|
||||||
|
): WorkbenchAlert[] {
|
||||||
|
return conditions
|
||||||
|
.filter(isAlertCondition)
|
||||||
|
.sort((a, b) => getScheduledConditionTimestamp(b) - getScheduledConditionTimestamp(a))
|
||||||
|
.map((condition) => ({
|
||||||
|
id: `${SCHEDULED_CONDITION_ALERT_ID_PREFIX}${condition.id}`,
|
||||||
|
title: `待复核:${condition.title}`,
|
||||||
|
description: condition.detail ?? condition.summary,
|
||||||
|
time: formatScheduledConditionTime(condition.scheduledAt)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScheduledConditionIdFromAlertId(alertId: string) {
|
||||||
|
if (!alertId.startsWith(SCHEDULED_CONDITION_ALERT_ID_PREFIX)) return null;
|
||||||
|
return alertId.slice(SCHEDULED_CONDITION_ALERT_ID_PREFIX.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAlertCondition(condition: ScheduledConditionItem) {
|
||||||
|
return condition.kind === "condition" && condition.status === "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScheduledConditionTimestamp(condition: ScheduledConditionItem) {
|
||||||
|
const scheduledAt = Date.parse(condition.scheduledAt);
|
||||||
|
return Number.isNaN(scheduledAt) ? condition.updatedAt : scheduledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScheduledConditionTime(value: string) {
|
||||||
|
const match = value.match(/T(\d{2}:\d{2})/);
|
||||||
|
return match?.[1] ?? value;
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { getRunningWorkflowDefinition } from "@/features/workbench/data/running-workflows";
|
import { getRunningWorkflowDefinition } from "../data/running-workflows";
|
||||||
import type { ScheduledConditionItem, ScheduledConditionStatus, WorkbenchAlert } from "@/features/workbench/types";
|
import type { ScheduledConditionItem, ScheduledConditionItemStatus, WorkbenchAlert } from "../types";
|
||||||
|
|
||||||
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
|
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
|
||||||
|
|
||||||
const statusLabels: Record<ScheduledConditionStatus, string> = {
|
const statusLabels: Record<ScheduledConditionItemStatus, string> = {
|
||||||
completed: "完成",
|
completed: "完成",
|
||||||
running: "执行中",
|
running: "执行中",
|
||||||
|
awaiting_reply: "待复令",
|
||||||
warning: "关注",
|
warning: "关注",
|
||||||
error: "异常",
|
error: "异常",
|
||||||
pending: "预约"
|
pending: "预约"
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { setWorkerUrl } from "maplibre-gl";
|
||||||
|
import maplibreWorkerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
|
||||||
|
|
||||||
import { App } from "@/app/app";
|
import { App } from "@/app/app";
|
||||||
import { env } from "@/shared/config/env";
|
import { env } from "@/shared/config/env";
|
||||||
@@ -8,6 +10,8 @@ import "maplibre-gl/dist/maplibre-gl.css";
|
|||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
import "streamdown/styles.css";
|
import "streamdown/styles.css";
|
||||||
|
|
||||||
|
setWorkerUrl(maplibreWorkerUrl);
|
||||||
|
|
||||||
async function prepareMocks() {
|
async function prepareMocks() {
|
||||||
if (!env.DRAINAGE_ENABLE_MSW) return;
|
if (!env.DRAINAGE_ENABLE_MSW) return;
|
||||||
const { worker } = await import("@/mocks/browser");
|
const { worker } = await import("@/mocks/browser");
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
@@ -7,7 +5,7 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/shared/ui/accordion";
|
} from "@/shared/ui/accordion";
|
||||||
import { Badge } from "@/shared/ui/badge";
|
import { Badge } from "@/shared/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import type { Tool } from "ai";
|
import type { Tool } from "ai";
|
||||||
import { BotIcon } from "lucide-react";
|
import { BotIcon } from "lucide-react";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -8,7 +6,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/ui/select";
|
} from "@/shared/ui/select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||||
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
|
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
|
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import {
|
import {
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
@@ -11,7 +9,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/shared/ui/tooltip";
|
} from "@/shared/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import { cjk } from "@streamdown/cjk";
|
import { cjk } from "@streamdown/cjk";
|
||||||
import { code } from "@streamdown/code";
|
import { code } from "@streamdown/code";
|
||||||
import { createMathPlugin } from "@streamdown/math";
|
import { createMathPlugin } from "@streamdown/math";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RiveParameters } from "@rive-app/react-webgl2";
|
import type { RiveParameters } from "@rive-app/react-webgl2";
|
||||||
import { useRive, useStateMachineInput } from "@rive-app/react-webgl2";
|
import { useRive, useStateMachineInput } from "@rive-app/react-webgl2";
|
||||||
import { memo, useEffect, useState } from "react";
|
import { memo, useEffect, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
|
||||||
const RIVE_SOURCE = "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv";
|
const RIVE_SOURCE = "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv";
|
||||||
const STATE_MACHINE = "default";
|
const STATE_MACHINE = "default";
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -15,7 +13,7 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/shared/ui/collapsible";
|
} from "@/shared/ui/collapsible";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import { ChevronsUpDownIcon } from "lucide-react";
|
import { ChevronsUpDownIcon } from "lucide-react";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { createContext, useContext, useMemo } from "react";
|
import { createContext, useContext, useMemo } from "react";
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -39,7 +37,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/shared/ui/tooltip";
|
} from "@/shared/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";
|
import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";
|
||||||
import {
|
import {
|
||||||
CornerDownLeftIcon,
|
CornerDownLeftIcon,
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
"use client";
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { MotionProps } from "motion/react";
|
import type { MotionProps } from "motion/react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import type { CSSProperties, ElementType, JSX } from "react";
|
import type { CSSProperties, ElementType, JSX } from "react";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user