From d97bbf7cf53d55b1066f8d775bf9e7c22287634e Mon Sep 17 00:00:00 2001 From: Codex Local Date: Thu, 27 Aug 2026 21:02:37 +0800 Subject: [PATCH] feat: add customer portal and RTC voice diagnostics --- cmd/rtc-api-probe/main.go | 251 ++++ cmd/speech-api-probe/main.go | 67 + deploy/local-rtc/compose.override.yaml | 5 +- deploy/web.nginx.conf | 5 +- design-qa.md | 52 + docs/设备终端接口文档.md | 1223 +++++++++++++++++ internal/app/app.go | 8 +- internal/orchestration/provider_processing.go | 5 + internal/voiceprovider/volcengine.go | 11 +- internal/voiceprovider/volcengine_test.go | 17 + web/package-lock.json | 978 ++++++++++++- web/package.json | 4 +- web/src/components/StatusCard.tsx | 10 +- web/src/main.tsx | 46 +- web/src/pages/CustomerPortal.tsx | 209 +++ web/src/pages/PlatformOverview.tsx | 375 +++-- web/src/pages/SaaSWorkspace.tsx | 230 +++- web/src/styles.css | 718 ++++++++++ 18 files changed, 4045 insertions(+), 169 deletions(-) create mode 100644 cmd/rtc-api-probe/main.go create mode 100644 cmd/speech-api-probe/main.go create mode 100644 design-qa.md create mode 100644 docs/设备终端接口文档.md create mode 100644 web/src/pages/CustomerPortal.tsx diff --git a/cmd/rtc-api-probe/main.go b/cmd/rtc-api-probe/main.go new file mode 100644 index 0000000..ed6f7ac --- /dev/null +++ b/cmd/rtc-api-probe/main.go @@ -0,0 +1,251 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + volcbase "github.com/volcengine/volc-sdk-golang/base" +) + +type responseEnvelope struct { + ResponseMetadata struct { + Error *struct { + Code string `json:"Code"` + Message string `json:"Message"` + } `json:"Error"` + } `json:"ResponseMetadata"` +} + +func main() { + akFile := flag.String("ak-file", "", "file containing the Volcengine AccessKey ID") + skFile := flag.String("sk-file", "", "file containing the Volcengine SecretAccessKey") + endpoint := flag.String("endpoint", "https://rtc.volcengineapi.com", "RTC OpenAPI endpoint") + rtcAppID := flag.String("rtc-app-id", "", "RTC application ID used for staged validation probes") + templateFile := flag.String("template-file", "", "voice-chat template bundle used for ProviderParams probes") + roomID := flag.String("room-id", "", "RTC room ID to inspect without starting a task") + flag.Parse() + if *akFile == "" || *skFile == "" { + panic("-ak-file and -sk-file are required") + } + ak := readSecret(*akFile) + sk := readSecret(*skFile) + credentials := volcbase.Credentials{AccessKeyID: ak, SecretAccessKey: sk, Service: "rtc", Region: "cn-north-1"} + client := &http.Client{Timeout: 15 * time.Second} + if *rtcAppID != "" && *roomID != "" { + queryRoom(client, credentials, *endpoint, *rtcAppID, *roomID) + return + } + for _, version := range []string{"2024-06-01", "2024-12-01", "2025-06-01"} { + probe(client, credentials, *endpoint, version, "empty", map[string]any{}) + } + if *rtcAppID != "" { + version := "2024-12-01" + identity := map[string]any{ + "AppId": *rtcAppID, "RoomId": "probe-room", "TaskId": "probe-task", + } + agent := map[string]any{ + "TargetUserId": []string{"probe-user"}, "UserId": "probe-agent", + } + probe(client, credentials, *endpoint, version, "app-only", map[string]any{"AppId": *rtcAppID}) + probe(client, credentials, *endpoint, version, "identity-only", map[string]any{ + "AppId": *rtcAppID, "RoomId": "probe-room", "TaskId": "probe-task", + }) + probe(client, credentials, *endpoint, version, "empty-config", map[string]any{ + "AppId": *rtcAppID, "RoomId": "probe-room", "TaskId": "probe-task", + "Config": map[string]any{}, "AgentConfig": map[string]any{}, + }) + probe(client, credentials, *endpoint, version, "agent-valid", merge(identity, map[string]any{ + "Config": map[string]any{}, "AgentConfig": agent, + })) + probe(client, credentials, *endpoint, version, "s2s-shell", merge(identity, map[string]any{ + "Config": map[string]any{"S2SConfig": map[string]any{}}, "AgentConfig": agent, + })) + probe(client, credentials, *endpoint, version, "s2s-provider", merge(identity, map[string]any{ + "Config": map[string]any{"S2SConfig": map[string]any{ + "Provider": "volcano", "OutputMode": 0, + }}, "AgentConfig": agent, + })) + probe(client, credentials, *endpoint, version, "s2s-params-empty", merge(identity, map[string]any{ + "Config": map[string]any{"S2SConfig": map[string]any{ + "Provider": "volcano", "OutputMode": 0, "ProviderParams": map[string]any{}, + }}, "AgentConfig": agent, + })) + if *templateFile != "" { + providerParams := readProviderParams(*templateFile) + for _, item := range []struct { + label string + keys []string + }{ + {label: "params-app", keys: []string{"app"}}, + {label: "params-dialog", keys: []string{"dialog"}}, + {label: "params-asr", keys: []string{"asr"}}, + {label: "params-tts", keys: []string{"tts"}}, + {label: "params-full", keys: []string{"app", "dialog", "asr", "tts"}}, + } { + selected := make(map[string]any, len(item.keys)) + for _, key := range item.keys { + if value, ok := providerParams[key]; ok { + selected[key] = value + } + } + probeIdentity := merge(identity, map[string]any{"TaskId": "probe-" + item.label}) + payload := merge(probeIdentity, map[string]any{ + "Config": map[string]any{"S2SConfig": map[string]any{ + "Provider": "volcano", "OutputMode": 0, "ProviderParams": selected, + }}, "AgentConfig": agent, + }) + if probeAction(client, credentials, *endpoint, version, item.label, "StartVoiceChat", payload) { + probeAction(client, credentials, *endpoint, version, item.label+"-cleanup", "StopVoiceChat", probeIdentity) + } + } + fullTemplate := readHybridTemplate(*templateFile) + fullIdentity := merge(identity, map[string]any{"TaskId": "probe-full-template"}) + fullTemplate = merge(fullTemplate, fullIdentity) + fullAgent, _ := fullTemplate["AgentConfig"].(map[string]any) + fullAgent["TargetUserId"] = []string{"probe-user"} + fullAgent["UserId"] = "probe-agent" + if probeAction(client, credentials, *endpoint, version, "full-template", "StartVoiceChat", fullTemplate) { + probeAction(client, credentials, *endpoint, version, "full-template-cleanup", "StopVoiceChat", fullIdentity) + } + } + probeAction(client, credentials, *endpoint, version, "cleanup", "StopVoiceChat", identity) + } +} + +func queryRoom(client *http.Client, credentials volcbase.Credentials, endpoint, appID, roomID string) { + target, err := url.Parse(endpoint) + if err != nil { + panic(err) + } + query := target.Query() + query.Set("Action", "GetRoomOnlineUsers") + query.Set("Version", "2023-11-01") + query.Set("AppId", appID) + query.Set("RoomId", roomID) + query.Set("Property", "false") + target.RawQuery = query.Encode() + request, err := http.NewRequest(http.MethodGet, target.String(), nil) + if err != nil { + panic(err) + } + request.Host = target.Host + request.Header.Set("Accept", "application/json") + credentials.Sign(request) + response, err := client.Do(request) + if err != nil { + panic(err) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if err != nil { + panic(err) + } + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + panic(err) + } + encoded, _ := json.Marshal(decoded) + fmt.Printf("room-status=%d response=%s\n", response.StatusCode, encoded) +} + +func readProviderParams(path string) map[string]any { + template := readHybridTemplate(path) + config, _ := template["Config"].(map[string]any) + s2s, _ := config["S2SConfig"].(map[string]any) + params, _ := s2s["ProviderParams"].(map[string]any) + if params == nil { + panic("template does not contain s2s_hybrid Config.S2SConfig.ProviderParams") + } + return params +} + +func readHybridTemplate(path string) map[string]any { + raw, err := os.ReadFile(path) + if err != nil { + panic(err) + } + var bundle map[string]any + if err := json.Unmarshal(raw, &bundle); err != nil { + panic(err) + } + templates, _ := bundle["templates"].(map[string]any) + template, _ := templates["s2s_hybrid"].(map[string]any) + if template == nil { + panic("template does not contain s2s_hybrid") + } + return template +} + +func merge(base map[string]any, extra map[string]any) map[string]any { + result := make(map[string]any, len(base)+len(extra)) + for key, value := range base { + result[key] = value + } + for key, value := range extra { + result[key] = value + } + return result +} + +func readSecret(path string) string { + value, err := os.ReadFile(path) + if err != nil { + panic(err) + } + return strings.TrimSpace(string(value)) +} + +func probe(client *http.Client, credentials volcbase.Credentials, endpoint, version, label string, payload map[string]any) { + probeAction(client, credentials, endpoint, version, label, "StartVoiceChat", payload) +} + +func probeAction(client *http.Client, credentials volcbase.Credentials, endpoint, version, label, action string, payload map[string]any) bool { + target, err := url.Parse(endpoint) + if err != nil { + panic(err) + } + query := target.Query() + query.Set("Action", action) + query.Set("Version", version) + target.RawQuery = query.Encode() + bodyBytes, err := json.Marshal(payload) + if err != nil { + panic(err) + } + request, err := http.NewRequest(http.MethodPost, target.String(), bytes.NewReader(bodyBytes)) + if err != nil { + panic(err) + } + request.Host = target.Host + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + credentials.Sign(request) + response, err := client.Do(request) + if err != nil { + fmt.Printf("label=%s version=%s transport_error=%v\n", label, version, err) + return false + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if err != nil { + fmt.Printf("label=%s version=%s status=%d read_error=%v\n", label, version, response.StatusCode, err) + return false + } + var envelope responseEnvelope + _ = json.Unmarshal(body, &envelope) + if envelope.ResponseMetadata.Error == nil { + fmt.Printf("label=%s version=%s status=%d code=none\n", label, version, response.StatusCode) + return response.StatusCode >= 200 && response.StatusCode < 300 + } + fmt.Printf("label=%s version=%s status=%d code=%s message=%s\n", label, version, response.StatusCode, + envelope.ResponseMetadata.Error.Code, envelope.ResponseMetadata.Error.Message) + return false +} diff --git a/cmd/speech-api-probe/main.go b/cmd/speech-api-probe/main.go new file mode 100644 index 0000000..e2cd4c9 --- /dev/null +++ b/cmd/speech-api-probe/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + volcbase "github.com/volcengine/volc-sdk-golang/base" +) + +func main() { + akFile := flag.String("ak-file", "", "file containing the Volcengine AccessKey ID") + skFile := flag.String("sk-file", "", "file containing the Volcengine SecretAccessKey") + resourceID := flag.String("resource-id", "volc.speech.dialog", "speech service resource ID") + projectName := flag.String("project", "default", "Volcengine project name") + flag.Parse() + if *akFile == "" || *skFile == "" { + panic("-ak-file and -sk-file are required") + } + target, _ := url.Parse("https://open.volcengineapi.com") + query := target.Query() + query.Set("Action", "QuotaMonitoring") + query.Set("Version", "2025-05-21") + target.RawQuery = query.Encode() + today := time.Now().Format("2006-01-02") + body, _ := json.Marshal(map[string]any{ + "ProjectName": *projectName, + "ResourceID": *resourceID, + "Start": today, + "End": today, + "Mode": "daily", + "QuotaType": "concurrency", + }) + request, _ := http.NewRequest(http.MethodPost, target.String(), bytes.NewReader(body)) + request.Host = target.Host + request.Header.Set("Content-Type", "application/json; charset=UTF-8") + credentials := volcbase.Credentials{ + AccessKeyID: readSecret(*akFile), SecretAccessKey: readSecret(*skFile), + Service: "speech_saas_prod", Region: "cn-beijing", + } + credentials.Sign(request) + response, err := (&http.Client{Timeout: 15 * time.Second}).Do(request) + if err != nil { + panic(err) + } + defer response.Body.Close() + responseBody, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + var decoded map[string]any + _ = json.Unmarshal(responseBody, &decoded) + encoded, _ := json.Marshal(decoded) + fmt.Printf("status=%d response=%s\n", response.StatusCode, encoded) +} + +func readSecret(path string) string { + value, err := os.ReadFile(path) + if err != nil { + panic(err) + } + return strings.TrimSpace(string(value)) +} diff --git a/deploy/local-rtc/compose.override.yaml b/deploy/local-rtc/compose.override.yaml index 37b3bb8..dbc79cb 100644 --- a/deploy/local-rtc/compose.override.yaml +++ b/deploy/local-rtc/compose.override.yaml @@ -11,12 +11,15 @@ services: worker: environment: VOICE_PROVIDER: volcengine + # StartVoiceChat must use the same real RTC AppId as the API token issuer. + RTC_APP_ID: ${RTC_APP_ID} VOLC_ACCESS_KEY_ID: "" VOLC_ACCESS_KEY_ID_FILE: /run/secrets/local-rtc/volc-access-key-id VOLC_SECRET_ACCESS_KEY: "" VOLC_SECRET_ACCESS_KEY_FILE: /run/secrets/local-rtc/volc-secret-access-key VOLC_RTC_REGION: cn-north-1 - VOLC_RTC_API_VERSION: 2024-12-01 + # Quote date-shaped API versions so YAML does not coerce them into timestamps. + VOLC_RTC_API_VERSION: "2024-12-01" VOLC_VOICE_CHAT_TEMPLATE_FILE: /run/secrets/local-rtc/voice-chat-template.json volumes: - ./deploy/local-rtc/runtime/volc-access-key-id:/run/secrets/local-rtc/volc-access-key-id:ro diff --git a/deploy/web.nginx.conf b/deploy/web.nginx.conf index c41a466..b8259f9 100644 --- a/deploy/web.nginx.conf +++ b/deploy/web.nginx.conf @@ -32,7 +32,10 @@ server { add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always; - add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https: http://localhost:*; img-src 'self' data: blob: https:; media-src 'self' blob: https: http://localhost:*; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' https: http://localhost:*" always; + # Ant Design v6 generates component styles at runtime and also relies on + # safe element-level layout styles. Scripts remain restricted to self; + # only CSS is allowed inline. + add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https: http://localhost:*; img-src 'self' data: blob: https:; media-src 'self' blob: https: http://localhost:*; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' https: http://localhost:*" always; } location = /runtime-config.js { diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..e661bbb --- /dev/null +++ b/design-qa.md @@ -0,0 +1,52 @@ +# Design QA + +- source visual truth path: `C:\Users\19926\AppData\Local\Temp\codex-clipboard-22a7b04e-194a-47ee-9fef-f98b31a70009.png` +- implementation URL: `http://localhost:3100/` +- implementation screenshot path: pending browser-capture permission +- intended viewport: 3120 x 1810 CSS px, deviceScaleFactor 1 +- source pixels: 3120 x 1810 +- implementation pixels: pending +- density normalization: pending +- state: independent family customer portal, existing browser login session if available + +**Full-view comparison evidence** + +- The source visual was opened at original resolution and used to define the target shell: fixed white header, light-gray sidebar, white content cards, compact enterprise spacing, blue-purple primary color, restrained borders and shadows. +- The customer implementation now uses a fixed Ant Design header/sidebar and a family-only information architecture. Operations menus, platform process cards, dependency checks, build version and milestone content are not present in the served bundle. +- The implementation is compiled and served successfully, but a browser-rendered screenshot has not yet been captured. A code-only comparison is not sufficient for visual approval. + +**Focused region comparison evidence** + +- Pending browser evidence for the customer header/sidebar, family dashboard, business forms, tables, empty states, authenticated profile and responsive layout. + +**Findings** + +- [P1] Browser-rendered comparison evidence is missing. + Location: full implementation at `http://localhost:3100/`. + Evidence: the source image is available, but no implementation screenshot has been captured at the matching viewport. + Impact: typography, spacing, overflow, border radii, and above-the-fold density cannot be visually verified. + Fix: capture the implementation in the user's chosen local browser at 3120 x 1810, combine it with the source image, compare visible differences, fix any P0/P1/P2 issues, and recapture. + +**Open Questions** + +- Permission is required before using Playwright directly for the local screenshot capture. + +**Implementation Checklist** + +- Capture the implementation at the matching desktop viewport and state. +- Compare the source and implementation in one combined visual input. +- Verify typography, layout rhythm, color tokens, icon fidelity, and app-specific copy. +- Test sidebar navigation, refresh, login CTA, and the main workspace controls. +- Check browser console errors and horizontal overflow. +- Apply and re-capture any P0/P1/P2 fixes. + +**Follow-up Polish** + +- Evaluate whether the production bundle should be code-split after visual QA; this is a performance refinement and does not block the current style redesign. + +**Comparison History** + +- Iteration 0: combined SaaS console build and HTTP smoke test passed; visual comparison blocked because browser capture has not been authorized. +- Iteration 1: family customer portal built and served at port 3100; production bundle contains the customer brand and excludes the operations menu labels; TypeScript/Vite build, Docker rebuild, HTTP 200 and API dependency readiness passed. Visual comparison remains blocked pending browser-capture authorization. + +final result: blocked diff --git a/docs/设备终端接口文档.md b/docs/设备终端接口文档.md new file mode 100644 index 0000000..1b9bb8e --- /dev/null +++ b/docs/设备终端接口文档.md @@ -0,0 +1,1223 @@ +# 数据采集终端—云端系统设备接口文档 + +> 文档版本:1.0 +> 设备接口版本:`/device/v2` +> OpenAPI 契约版本:`0.68.0` +> 本地联调地址:`http://localhost:58080` +> 契约来源:`openapi/openapi.yaml`、实际 HTTP 路由及设备处理代码 +> 编制日期:2026-08-27 + +## 1. 文档范围 + +本文档面向终端固件、Windows 模拟设备和设备联调人员,说明终端直接调用云端的全部接口,包括: + +- 设备启动、身份校验和配置同步; +- 双向语音通话创建、入房确认、智能体启动状态、心跳和停止; +- 设备遥测、错误和命令结果上报; +- 云端命令拉取与执行回执; +- 固件私有下载; +- 通话终稿补传; +- 双轨录音分片声明、对象存储直传、校验查询和本地删除回执。 + +本文档详细描述的是设备使用 HMAC 身份直接调用的 `/device/v2` 接口。家庭或运营人员使用的 `/api/v1/devices`、设备绑定、固件发布和灰度管理接口在第 15 章列出边界,但它们不由设备固件直接调用。火山 RTC/豆包回调也不属于设备接口。 + +## 2. 总体通信关系 + +```mermaid +flowchart LR + D[终端设备] -->|HTTPS 控制请求 + HMAC| API[云端 API] + API --> PG[(PostgreSQL)] + API --> REDIS[(Redis)] + API -->|可靠任务| W[Worker] + W -->|StartVoiceChat / StopVoiceChat| V[火山语音服务] + D <-->|RTC 实时音频| RTC[火山 RTC 房间] + V -->|豆包智能体加入| RTC + D -->|录音文件 PUT| OSS[对象存储] + API -->|只签发短期地址| D + W -->|流式校验录音| OSS +``` + +重要边界: + +1. 设备的控制请求经过本项目 API;实时麦克风音频不经过本项目 API 和数据库。 +2. 设备和豆包智能体分别直接加入同一个火山 RTC 房间。 +3. 录音文件使用短期单对象地址直接上传对象存储,不经过业务 API 传输文件字节。 +4. RTC Token、上传地址和固件下载地址都是短期凭证,只允许保存在设备运行内存中。 + +## 3. 接口总览 + +| 序号 | 方法 | 路径 | 用途 | 幂等键 | +|---:|---|---|---|---| +| 1 | GET | `/device/v2/bootstrap` | 启动登记、版本与时钟检查 | 否 | +| 2 | GET | `/device/v2/config` | 拉取期望配置和绑定信息 | 否,支持 ETag | +| 3 | GET | `/device/v2/commands` | 拉取本设备待执行命令 | 否 | +| 4 | POST | `/device/v2/commands/{id}/ack` | 回报命令接收、成功或失败 | 否,业务状态幂等 | +| 5 | GET | `/device/v2/firmware-releases/{id}/download` | 获取固件私有短期下载地址 | 否 | +| 6 | POST | `/device/v2/conversations` | 创建通话并取得 RTC 房间资料 | 是 | +| 7 | POST | `/device/v2/conversations/{id}/activate` | 确认设备入房并启动豆包智能体 | 是 | +| 8 | GET | `/device/v2/conversations/{id}` | 查询权威通话和智能体状态 | 否 | +| 9 | POST | `/device/v2/conversations/{id}/heartbeat` | 通话心跳和租约续期 | 否,按序号去重 | +| 10 | POST | `/device/v2/conversations/{id}/stop` | 停止通话和豆包任务 | 是 | +| 11 | POST | `/device/v2/conversations/{id}/turns:batch` | 补传设备侧最终字幕 | 是 | +| 12 | POST | `/device/v2/telemetry:batch` | 批量上报状态、错误和命令结果 | 是 | +| 13 | POST | `/device/v2/conversations/{id}/upload-batches` | 声明录音分片并取得上传地址 | 是 | +| 14 | POST | `/device/v2/upload-batches/{id}/complete` | 声明上传完成并触发校验 | 否,业务状态幂等 | +| 15 | GET | `/device/v2/upload-batches/{id}` | 查询录音校验结果 | 否 | +| 16 | POST | `/device/v2/media-segments/{id}/local-delete-ack` | 回报本地录音密文删除结果 | 否,成功回执幂等 | + +## 4. 通用协议约定 + +### 4.1 传输与数据格式 + +- 生产环境必须使用 HTTPS;本机开发环境可使用 HTTP。 +- JSON 请求使用 `Content-Type: application/json; charset=utf-8`。 +- 时间统一使用 RFC3339 UTC,例如 `2026-08-27T06:30:00Z`。 +- UUID 使用带连字符的标准格式。 +- 未声明可为空的字段不要发送 `null`,直接省略。 +- 设备必须忽略响应中的未知非必填字段,以保持 `/device/v2` 向后兼容。 +- 请求对象声明 `additionalProperties: false` 时,不得发送未定义字段。 + +### 4.2 统一成功响应 + +除 304、固件下载重定向等特殊响应外,成功响应统一为: + +```json +{ + "request_id": "01J6...", + "data": {} +} +``` + +`request_id` 是本次云端请求关联编号,设备日志应保存该值,但不得把密钥、签名、Token 或临时对象地址写入日志。 + +### 4.3 统一错误响应 + +```json +{ + "request_id": "01J6...", + "error": { + "code": "VALIDATION_FAILED", + "message": "请求字段不符合契约", + "retryable": false, + "field_errors": [ + { + "field": "body.device_time", + "message": "must be a valid RFC3339 timestamp" + } + ], + "details": {} + } +} +``` + +设备应优先使用 `error.code` 和 `retryable` 决定动作,不解析或匹配 `message` 文本。 + +## 5. 设备 HMAC 身份认证 + +### 5.1 必填请求头 + +所有 `/device/v2` 请求都必须包含: + +| 请求头 | 格式 | 说明 | +|---|---|---| +| `X-Device-Id` | UUID | 已登记的设备公开编号 | +| `X-Device-Timestamp` | RFC3339 | 本次请求 UTC 时间;默认允许前后 5 分钟误差 | +| `X-Device-Nonce` | 16~128 字符 | 每次 HTTP 尝试都必须重新生成的随机数 | +| `X-Device-Content-SHA256` | 64 位小写十六进制 | 原始 HTTP 正文字节的 SHA-256 | +| `X-Device-Signature` | Base64URL,无 `=` 填充 | HMAC-SHA256 签名结果 | + +POST JSON 的摘要必须针对真正发送的原始 UTF-8 字节计算。GET 或空正文请求也必须计算空字节摘要: + +```text +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +``` + +### 5.2 签名原文 + +使用单个换行符按以下顺序拼接,最后一行后不增加换行: + +```text +UPPERCASE_HTTP_METHOD +REQUEST_URI_WITH_EXACT_QUERY_STRING +X-Device-Timestamp原值 +X-Device-Nonce原值 +X-Device-Content-SHA256原值 +``` + +例如: + +```text +GET +/device/v2/bootstrap?firmware_version=1.2.0&device_time=2026-08-27T06%3A30%3A00Z +2026-08-27T06:30:01Z +62d4b688f77b4baaa87a38d315c2f3b1 +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +``` + +计算方法: + +```text +signature = Base64URL_NoPadding( + HMAC_SHA256(device_secret, canonical_string) +) +``` + +注意事项: + +- 查询参数的顺序、百分号编码和大小写必须与实际请求 URI 完全一致。 +- `Nonce` 不是业务幂等键。业务请求重试时保留原 `Idempotency-Key` 和正文,但必须生成新 `Nonce`、时间戳和签名。 +- 不允许在设备镜像、日志或前端页面中明文输出设备密钥。 +- 云端 Redis 或设备身份数据库不可用时鉴权关闭失败并返回 503,不会绕过签名。 + +### 5.3 幂等键 + +需要幂等保护的接口必须包含: + +```http +Idempotency-Key: 3ca4e514-cbde-41fc-aa32-6f1ed460cb01 +``` + +长度为 16~128 字符。规则如下: + +1. 同一设备、同一接口、同一幂等键和相同正文:返回第一次的业务结果。 +2. 同一幂等键对应不同正文:返回 409 `IDEMPOTENCY_KEY_REUSED`。 +3. 网络超时且无法确认结果时:使用原幂等键、原正文重试,但重新生成 HMAC 请求头。 +4. 返回已有结果时,响应可能包含 `Idempotency-Replayed: true`。 + +## 6. 启动与配置接口 + +### 6.1 设备启动登记 + +`GET /device/v2/bootstrap` + +用于设备开机、重连或固件升级后向云端上报运行版本,并取得服务器时间、设备状态、绑定、最低版本策略和期望配置版本。 + +查询参数: + +| 参数 | 必填 | 类型/限制 | 说明 | +|---|---|---|---| +| `firmware_version` | 是 | string,1~80 | 当前固件版本 | +| `device_time` | 是 | RFC3339 | 设备当前时间 | +| `board_rev` | 否 | string,≤80 | 硬件板卡版本 | +| `rtc_sdk_version` | 否 | string,≤80 | RTC SDK 版本 | +| `codec` | 否 | string,≤40 | 当前首选音频编码 | +| `sample_rate` | 否 | integer,≥1 | 当前采样率 | +| `network` | 否 | string,≤80 | 网络类型或描述 | + +成功响应示例: + +```json +{ + "request_id": "01J6BOOTSTRAP", + "data": { + "server_time": "2026-08-27T06:30:01Z", + "clock_skew_ms": 850, + "device": { + "device_id": "10000000-0000-0000-0000-000000000511", + "status": "registered", + "model": "simulated-terminal", + "hardware_revision": "sim-rev-a", + "minimum_security_version": "security-v1", + "minimum_firmware_version": "0.1.0", + "firmware_compatible": true, + "config_revision": 3, + "binding": { + "bound": true, + "household_id": "20000000-0000-0000-0000-000000000001", + "subject_id": "30000000-0000-0000-0000-000000000001", + "subject_name": "演示长者" + } + } + } +} +``` + +设备处理: + +- `status=registered` 且 `binding.bound=true` 才能进入正常业务。 +- `quarantined`:停止通话能力,只保留受控诊断或升级。 +- `revoked`:立即停止业务并进入重新登记/售后流程。 +- `firmware_compatible=false`:进入升级提示,不要创建新通话;云端仍会在创建通话时二次阻断。 +- 使用 `server_time` 和 `clock_skew_ms` 校正后续签名时间。 + +主要状态码:200、400、401、503。 + +### 6.2 获取设备配置 + +`GET /device/v2/config` + +请求可携带上次响应的强 ETag: + +```http +If-None-Match: "v3" +``` + +成功响应头: + +```http +ETag: "v4" +``` + +成功响应示例: + +```json +{ + "request_id": "01J6CONFIG", + "data": { + "device_id": "10000000-0000-0000-0000-000000000511", + "revision": 4, + "desired_config": { + "volume": { "speaker": 60 }, + "audio": { "sample_rate": 16000 }, + "interaction": {}, + "upload": {}, + "rtc": {}, + "policy_refs": {} + }, + "reported_revision": 3, + "binding": { + "bound": true, + "household_id": "20000000-0000-0000-0000-000000000001", + "subject_id": "30000000-0000-0000-0000-000000000001", + "subject_name": "演示长者" + } + } +} +``` + +- 配置未变化返回 304,无响应正文。 +- 设备只在完整校验新配置成功后切换本地生效版本。 +- 应保留上一次有效配置;503 时不能清空本地配置。 +- 应通过心跳或遥测上报实际应用的 `reported_config_revision`。 + +主要状态码:200、304、401、503。 + +## 7. 双向语音通话接口 + +### 7.1 创建通话并分配 RTC 房间 + +`POST /device/v2/conversations` + +必须携带 `Idempotency-Key`。本接口只分配房间和短期凭证,不调用豆包 `StartVoiceChat`。 + +请求字段: + +| 字段 | 必填 | 类型/限制 | 说明 | +|---|---|---|---| +| `device_time` | 是 | RFC3339 | 设备时间 | +| `monotonic_ms` | 否 | int64,≥0 | 开机后单调时钟毫秒数 | +| `capabilities.rtc` | 是 | `true` | 支持 RTC | +| `capabilities.audio_tracks` | 是 | `near`/`far` 数组 | 支持的音频轨道,至少一项且唯一 | +| `capabilities.upload_resume` | 是 | boolean | 是否支持断点/恢复上传 | +| `capabilities.rtc_credential_renewal` | 否 | boolean | 明确为 true 才允许心跳续签 RTC 凭证 | +| `network.type` | 是 | string,1~40 | 网络类型,如 wifi/ethernet/4g | +| `network.rssi` | 否 | integer | 信号强度 | +| `requested_goal` | 是 | 固定 `open_conversation` | 设备技术目标,不作为豆包提示词 | + +请求示例: + +```json +{ + "device_time": "2026-08-27T06:31:00Z", + "monotonic_ms": 15820, + "capabilities": { + "rtc": true, + "audio_tracks": ["near", "far"], + "upload_resume": true, + "rtc_credential_renewal": true + }, + "network": { + "type": "wifi", + "rssi": -48 + }, + "requested_goal": "open_conversation" +} +``` + +201 响应示例: + +```json +{ + "request_id": "01J6CREATE", + "data": { + "conversation_id": "0829059e-4485-46b9-87ea-5a2fcefc6c35", + "status": "ROOM_ISSUED", + "rtc": { + "app_id": "", + "room_id": "r_25a5f0e5825645d099540604e17ee15c", + "user_id": "d_10000000000000000000000000000511", + "agent_user_id": "a_0829059e448546b987ea5a2fcefc6c35", + "token": "", + "expires_at": "2026-08-27T06:41:00Z" + }, + "privacy": { + "retention_mode": "transcript_only", + "record_near": false, + "record_far": false, + "upload_allowed": false + }, + "session": { + "engine_mode": "s2s_hybrid", + "heartbeat_interval_sec": 10, + "allocation_expires_at": "2026-08-27T06:33:00Z" + } + } +} +``` + +设备处理: + +1. Token 只保存在内存中,不写日志和磁盘。 +2. 在 `allocation_expires_at` 前使用 `rtc.room_id + rtc.user_id + rtc.token` 加入 RTC。 +3. 发布麦克风近端音频,并订阅 `agent_user_id` 的智能体音频。 +4. 成功入房后调用激活接口。 +5. `session.engine_mode` 仅供云端诊断,终端不得据此切换采集、编码、发布或播放逻辑。 + +主要状态码: + +| HTTP | 错误码/情况 | 设备动作 | +|---:|---|---| +| 201 | 创建成功 | 加入 RTC 后激活 | +| 403 | `CONSENT_REQUIRED` | 停止并等待授权 | +| 409 | `DEVICE_NOT_BOUND` | 提示先绑定设备 | +| 409 | `CONVERSATION_ALREADY_ACTIVE` | 查询或恢复已有通话 | +| 409 | `IDEMPOTENCY_KEY_REUSED` | 更正调用逻辑,不能换正文重用同键 | +| 422 | `AGENT_CONTEXT_PREPARATION_FAILED` | 不入房,提示背景资料准备失败 | +| 426 | `FIRMWARE_UPDATE_REQUIRED` | 进入固件升级流程 | +| 429 | `USAGE_QUOTA_EXCEEDED` | 停止自动重试并提示额度/并发限制 | +| 503 | `RUNTIME_CONFIG_UNAVAILABLE` 等 | 按 `retryable` 退避重试 | + +### 7.2 确认入房并启动智能体 + +`POST /device/v2/conversations/{id}/activate` + +必须携带 `Idempotency-Key`。只有 RTC 加入成功后调用。 + +请求字段: + +| 字段 | 必填 | 限制 | 说明 | +|---|---|---|---| +| `joined_at` | 是 | RFC3339 | 实际入房时间 | +| `room_id` | 是 | 1~200 | 必须等于创建响应的房间号 | +| `rtc_user_id` | 是 | 1~200 | 必须等于创建响应的设备 RTC 用户号 | +| `rtc_join_result` | 是 | 固定 `success` | 只接受成功入房确认 | +| `config_revision` | 是 | int64,≥1 | 本通话使用的配置版本 | +| `connection_reference` | 否 | ≤255 | 设备侧连接关联号,不能包含凭证 | + +请求示例: + +```json +{ + "joined_at": "2026-08-27T06:31:03Z", + "room_id": "r_25a5f0e5825645d099540604e17ee15c", + "rtc_user_id": "d_10000000000000000000000000000511", + "rtc_join_result": "success", + "config_revision": 4, + "connection_reference": "rtc-session-00001" +} +``` + +202 响应示例: + +```json +{ + "request_id": "01J6ACTIVATE", + "data": { + "conversation_id": "0829059e-4485-46b9-87ea-5a2fcefc6c35", + "status": "STARTING_AGENT", + "accepted_at": "2026-08-27T06:31:04Z" + } +} +``` + +响应表示云端已经受理,不表示豆包一定已经进房。随后必须调用通话状态查询接口,不能不断更换幂等键重复激活。 + +主要状态码:202、400、401、404、409、503。 + +### 7.3 查询权威通话状态 + +`GET /device/v2/conversations/{id}` + +200 响应示例: + +```json +{ + "request_id": "01J6STATE", + "data": { + "conversation_id": "0829059e-4485-46b9-87ea-5a2fcefc6c35", + "status": "ACTIVE", + "action": "continue" + } +} +``` + +字段说明: + +| 字段 | 取值 | 设备动作 | +|---|---|---| +| `status` | `PREPARING`、`ROOM_ISSUED`、`STARTING_AGENT`、`ACTIVE`、`STOPPING`、`COMPLETED`、`FAILED`、`EXPIRED` | 云端权威状态 | +| `action` | `wait` | 保持 RTC 房间并有限次数继续查询 | +| `action` | `continue` | 豆包已激活,进入正常双向语音和心跳 | +| `action` | `leave_room` | 立即退出 RTC 房间并结束本地通话流程 | +| `failure_code` | 可选稳定错误码 | 仅用于展示和诊断,不显示供应商原始错误 | + +失败示例: + +```json +{ + "request_id": "01J6STATEFAILED", + "data": { + "conversation_id": "0829059e-4485-46b9-87ea-5a2fcefc6c35", + "status": "FAILED", + "action": "leave_room", + "failure_code": "provider_start_rejected" + } +} +``` + +推荐轮询:首次等待 500 ms,之后每 1 秒查询,累计不超过房间预留有效期;达到上限仍未收敛时按本地超时处理并调用停止接口。 + +主要状态码:200、400、401、404、503。 + +### 7.4 通话心跳 + +`POST /device/v2/conversations/{id}/heartbeat` + +进入 `STARTING_AGENT` 或 `ACTIVE` 后,按创建响应的 `heartbeat_interval_sec` 调用。 + +请求主要字段: + +| 字段 | 必填 | 说明 | +|---|---|---| +| `sequence_no` | 是 | 从 1 开始严格递增;进程重启后应从耐久状态恢复 | +| `device_time` | 是 | RFC3339 UTC | +| `monotonic_ms` | 是 | 单调时钟毫秒数 | +| `conversation_status` | 是 | 设备当前观察的通话状态 | +| `rtc.joined/uplink/downlink` | 是 | RTC 入房、上行和下行状态 | +| `network.type` | 是 | 当前网络类型 | +| `network.rssi` | 否 | 信号强度 | +| `firmware_version` | 是 | 当前固件版本 | +| `reported_config_revision` | 是 | 已实际应用的配置版本 | +| `recording_queue.pending_segments` | 是 | 待上传录音数 | +| `recording_queue.failed_segments` | 是 | 上传失败录音数 | +| `recording_queue.last_failure_code/at` | 否 | 最近稳定失败码和时间 | +| `rtc_credential_expires_at` | 否 | 当前 RTC Token 到期时间;声明续签能力时使用 | +| `last_error` | 否 | ≤80 的脱敏稳定错误码,不得传自由文本堆栈 | +| `command_results` | 是 | 最多 20 项;当前可为空数组 | + +请求示例: + +```json +{ + "sequence_no": 1, + "device_time": "2026-08-27T06:31:15Z", + "monotonic_ms": 30820, + "conversation_status": "ACTIVE", + "rtc": { + "joined": true, + "uplink": true, + "downlink": true + }, + "network": { + "type": "wifi", + "rssi": -50 + }, + "firmware_version": "1.2.0", + "reported_config_revision": 4, + "recording_queue": { + "pending_segments": 0, + "failed_segments": 0 + }, + "rtc_credential_expires_at": "2026-08-27T06:41:00Z", + "command_results": [] +} +``` + +200 响应示例: + +```json +{ + "request_id": "01J6HEARTBEAT", + "data": { + "lease_expires_at": "2026-08-27T06:32:05Z", + "actions": [ + { "type": "continue" } + ], + "desired_config_revision": 4, + "commands": [] + } +} +``` + +`actions[].type`: + +- `continue`:继续通话; +- `stop`:尽快停止并离房; +- `stop_after_turn`:当前话轮完成后停止; +- `renew_rtc_credential`:响应同一动作的 `rtc` 字段包含新 RTCGrant,设备先替换内存凭证,再在下一次心跳报告新到期时间。 + +同序号或更小序号不会续租,也不会覆盖较新的设备状态。报告配置版本高于云端期望版本会返回 409。 + +主要状态码:200、400、401、404、409、503。 + +### 7.5 停止通话 + +`POST /device/v2/conversations/{id}/stop` + +必须携带 `Idempotency-Key`。 + +请求字段: + +| 字段 | 必填 | 说明 | +|---|---|---| +| `reason` | 是 | 1~64 字符稳定原因码,字符集 `[A-Za-z0-9_.:-]` | +| `device_time` | 是 | RFC3339 | +| `last_heartbeat_sequence` | 是 | 最后成功心跳序号,可为 0 | +| `rtc_left` | 是 | 请求时是否已经退出 RTC | +| `recording.near_segments` | 是 | 本地近端录音分片数量 | +| `recording.far_segments` | 是 | 本地远端录音分片数量 | + +请求示例: + +```json +{ + "reason": "user_hangup", + "device_time": "2026-08-27T06:35:00Z", + "last_heartbeat_sequence": 23, + "rtc_left": true, + "recording": { + "near_segments": 0, + "far_segments": 0 + } +} +``` + +202 响应示例: + +```json +{ + "request_id": "01J6STOP", + "data": { + "conversation_id": "0829059e-4485-46b9-87ea-5a2fcefc6c35", + "status": "STOPPING", + "accepted_at": "2026-08-27T06:35:01Z" + } +} +``` + +允许的响应状态为 `STOPPING`、`COMPLETED`、`FAILED` 或 `EXPIRED`。相同停止请求不会重复调用供应商 Stop。 + +主要状态码:202、400、401、404、409、503。 + +## 8. 设备遥测接口 + +### 8.1 批量上报遥测 + +`POST /device/v2/telemetry:batch` + +必须携带 `Idempotency-Key`。每批 1~500 条,同一批内 `event_key` 和 `sequence_no` 分别唯一。 + +事件公共字段: + +| 字段 | 必填 | 说明 | +|---|---|---| +| `event_key` | 是 | 设备内稳定唯一键,1~255 字符 | +| `sequence_no` | 是 | 设备内严格正整数单调序号 | +| `occurred_at` | 是 | 事件发生时间 | +| `event_type` | 是 | `status`、`error`、`command_result` | +| `conversation_id` | 否 | 一旦填写,必须属于当前签名设备 | +| `status/error/command_result` | 条件必填 | 只允许携带与 `event_type` 同名的一个对象 | + +状态事件: + +```json +{ + "event_key": "status.boot.000001", + "sequence_no": 1, + "occurred_at": "2026-08-27T06:30:00Z", + "event_type": "status", + "status": { + "firmware_version": "1.2.0", + "reported_config_revision": 4, + "network": { "type": "wifi", "rssi": -48 }, + "rtc": { "joined": false, "uplink": false, "downlink": false }, + "recording_queue": { "pending_segments": 0, "failed_segments": 0 } + } +} +``` + +错误事件: + +```json +{ + "event_key": "error.audio.000002", + "sequence_no": 2, + "occurred_at": "2026-08-27T06:30:10Z", + "event_type": "error", + "error": { + "code": "AUDIO_DEVICE_UNAVAILABLE", + "severity": "error" + } +} +``` + +命令结果事件: + +```json +{ + "event_key": "command.9e70.result", + "sequence_no": 3, + "occurred_at": "2026-08-27T06:30:20Z", + "event_type": "command_result", + "command_result": { + "command_id": "9e7006eb-6cd2-4e60-82ba-e24ba45158bd", + "status": "SUCCEEDED", + "result_summary": { + "result_code": "OK" + } + } +} +``` + +完整请求: + +```json +{ + "events": [ + { + "event_key": "status.boot.000001", + "sequence_no": 1, + "occurred_at": "2026-08-27T06:30:00Z", + "event_type": "status", + "status": { + "firmware_version": "1.2.0", + "reported_config_revision": 4, + "network": { "type": "wifi", "rssi": -48 }, + "rtc": { "joined": false, "uplink": false, "downlink": false }, + "recording_queue": { "pending_segments": 0, "failed_segments": 0 } + } + } + ] +} +``` + +200 响应: + +```json +{ + "request_id": "01J6TELEMETRY", + "data": { + "accepted_count": 1, + "duplicate_count": 0, + "desired_config_revision": 4, + "actions": [ + { "type": "pull_commands" } + ] + } +} +``` + +响应动作包括 `refresh_config`、`pull_commands` 和 `stop_conversation`。结果对象递归禁止出现 `secret`、`token`、`password`、`credential`、`private_key`、`api_key` 等敏感字段。 + +主要状态码:200、400、401、404、409、503。 + +## 9. 云端设备命令接口 + +### 9.1 拉取命令 + +`GET /device/v2/commands?limit=10` + +`limit` 可选,范围 1~20,默认 10。只返回当前设备未过期、未终结的命令。 + +响应示例: + +```json +{ + "request_id": "01J6COMMANDS", + "data": { + "items": [ + { + "id": "9e7006eb-6cd2-4e60-82ba-e24ba45158bd", + "type": "diagnostics", + "payload": { + "scope": ["audio", "network", "storage"] + }, + "status": "DELIVERED", + "expires_at": "2026-08-27T06:40:00Z", + "delivery_attempts": 1, + "created_at": "2026-08-27T06:35:00Z" + } + ] + } +} +``` + +命令类型: + +| 类型 | 用途 | +|---|---| +| `apply_config` | 拉取并应用指定配置 | +| `reboot` | 受控重启设备 | +| `diagnostics` | 执行音频、网络、存储等有限诊断 | +| `firmware_update` | 下载、验签、安装并确认固件 | + +命令主状态机: + +```text +QUEUED → DELIVERED → ACKNOWLEDGED → SUCCEEDED / FAILED + └────────→ EXPIRED / CANCELLED +``` + +设备进程重启后可以重新拉取 `ACKNOWLEDGED` 但未终结的命令,用于恢复执行。 + +主要状态码:200、401、503。 + +### 9.2 命令执行回执 + +`POST /device/v2/commands/{id}/ack` + +请求: + +```json +{ + "status": "ACKNOWLEDGED", + "result_summary": { + "phase": "running", + "progress_percent": 10 + } +} +``` + +`status` 只允许 `ACKNOWLEDGED`、`SUCCEEDED`、`FAILED`。同阶段或相同终态重复提交保持幂等,禁止状态回退。 + +固件命令可用的阶段包括: + +- `downloading` +- `installing` +- `staging` +- `reboot_pending` +- `boot_verifying` +- `confirmed` +- `rolled_back` + +成功结果应包含 `installed_version`;A/B 启动健康失败并自动回退时应提交: + +```json +{ + "status": "FAILED", + "result_summary": { + "phase": "rolled_back", + "progress_percent": 95, + "result_code": "BOOT_HEALTH_FAILED_ROLLED_BACK", + "rollback_applied": true, + "active_slot": "A", + "failed_slot": "B", + "rollback_version": "1.1.0" + } +} +``` + +主要状态码:200、400、401、409。 + +## 10. 固件下载接口 + +### 10.1 获取私有固件地址 + +`GET /device/v2/firmware-releases/{id}/download` + +只有属于当前灰度批次且版本仍可用的设备能够取得地址。响应签发约 5 分钟有效的私有下载地址,设备必须: + +1. 只接受云端固定固件下载接口返回的地址; +2. 限制下载对象大小为 1 字节~256 MiB; +3. 下载后校验命令清单中的小写 SHA-256; +4. 使用安全区预置的 Ed25519 公钥验证 `firmware-manifest-v1`; +5. 校验型号、硬件版本、制品大小、版本和发布编号; +6. 全部校验通过后才允许写入非活动分区或执行安装。 + +不要信任命令 `payload` 临时携带的公钥,不要把下载地址写入持久日志。 + +主要状态码:200、401、403。 + +## 11. 通话终稿补传接口 + +### 11.1 批量补传最终话轮 + +`POST /device/v2/conversations/{id}/turns:batch` + +必须携带 `Idempotency-Key`。用于供应商最终转写缺失或不完整时补交设备侧终稿,每批 1~500 条。 + +话轮字段: + +| 字段 | 必填 | 限制 | +|---|---|---| +| `turn_key` | 是 | 1~255,通话内稳定唯一 | +| `sequence_no` | 是 | 正整数,批内唯一 | +| `speaker` | 是 | `subject` 或 `agent` | +| `start_at` | 是 | RFC3339 | +| `end_at` | 是 | RFC3339,不早于 `start_at` | +| `final_text` | 是 | 1~20000 字符 | +| `interrupted` | 是 | 是否被打断 | + +请求示例: + +```json +{ + "turns": [ + { + "turn_key": "device-turn-0001", + "sequence_no": 1, + "speaker": "subject", + "start_at": "2026-08-27T06:32:00Z", + "end_at": "2026-08-27T06:32:03Z", + "final_text": "今天天气怎么样?", + "interrupted": false + } + ] +} +``` + +响应: + +```json +{ + "request_id": "01J6TURNS", + "data": { + "conversation_id": "0829059e-4485-46b9-87ea-5a2fcefc6c35", + "accepted_count": 1, + "duplicate_count": 0 + } +} +``` + +相同终稿计入重复数;相同 `turn_key` 或 `sequence_no` 对应不同内容时整批返回 409 `TRANSCRIPT_TURN_CONFLICT`,设备不得覆盖云端内容。 + +主要状态码:200、400、401、403、404、409、503。 + +## 12. 双轨录音上传接口 + +录音上传只在创建响应满足以下条件时允许: + +```text +privacy.retention_mode = transcript_and_audio +privacy.upload_allowed = true +对应 track 的 record_near / record_far = true +``` + +当前编码基线只接受可解析的 WAV PCM 16 位: + +- `codec=wav_pcm_s16le` +- `content_type=audio/wav` +- 单片时长 1~600000 ms +- 单片大小 1~67108864 字节 +- 每批最多 1000 片 + +### 12.1 声明上传批次 + +`POST /device/v2/conversations/{id}/upload-batches` + +必须携带 `Idempotency-Key`。 + +请求: + +```json +{ + "segments": [ + { + "track": "near", + "sequence_no": 0, + "codec": "wav_pcm_s16le", + "content_type": "audio/wav", + "duration_ms": 10000, + "expected_size": 320044, + "expected_digest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } + ] +} +``` + +201 响应的 `data` 为上传批次: + +| 字段 | 说明 | +|---|---| +| `id` | 批次 UUID | +| `conversation_id` | 所属通话 | +| `status` | `PENDING/UPLOADING/VERIFYING/COMPLETED/FAILED/EXPIRED` | +| `segment_count` | 分片数 | +| `expected_total_size` | 声明总字节数 | +| `verified_count/rejected_count` | 校验成功/拒绝数量 | +| `verification_attempts` | 校验尝试次数 | +| `last_error_code` | 批次稳定失败码 | +| `expires_at` | 批次到期时间 | +| `segments` | 分片结果数组 | + +新分片包含短期 `upload_url` 和 `upload_until`。设备使用 HTTP PUT 将该分片原始字节直接上传,不得把多个分片或其他内容写入同一 URL。 + +主要状态码:201、400、401、403、409。 + +### 12.2 声明批次上传完成 + +`POST /device/v2/upload-batches/{id}/complete` + +请求无 JSON 正文,但仍需发送空正文 SHA-256。平台返回 202,并把批次置为 `VERIFYING` 或返回已经完成的结果。重复提交不会重复创建校验任务。 + +主要状态码:202、401、404、409、410。 + +### 12.3 查询批次校验结果 + +`GET /device/v2/upload-batches/{id}` + +分片状态: + +```text +DECLARED → AUTHORIZED → UPLOADED → VERIFYING → VERIFIED / REJECTED +``` + +关键字段: + +| 字段 | 说明 | +|---|---| +| `id` | 分片 UUID | +| `track` | `near` 或 `far` | +| `sequence_no` | 轨道内序号 | +| `expected_size/digest` | 声明大小和 SHA-256 | +| `status` | 当前分片状态 | +| `last_error_code` | 校验失败稳定码 | +| `verified_at` | 校验成功时间 | +| `local_delete_after` | 只有 VERIFIED 才返回;此时间之后才能删除本地密文 | +| `local_delete_status` | 最近一次本地删除回执 `DELETED/FAILED` | + +建议每 1~2 秒轮询,达到批次 `expires_at` 或本地等待上限后停止。对象暂时不可见时平台可能保持 `VERIFYING` 并后台重试。 + +主要状态码:200、401、404。 + +### 12.4 回报本地密文删除结果 + +`POST /device/v2/media-segments/{id}/local-delete-ack` + +只能在 `local_delete_after` 之后删除设备本地加密录音副本并回报。 + +成功: + +```json +{ + "status": "DELETED" +} +``` + +失败: + +```json +{ + "status": "FAILED", + "error_code": "LOCAL_STORAGE_DELETE_FAILED" +} +``` + +- `FAILED` 时 `error_code` 必填;`DELETED` 时禁止携带。 +- 过早提交成功回执返回 409。 +- `DELETED` 重复回报幂等。 +- 此回执只代表设备副本已删除,不删除云端已验证对象。 + +主要状态码:200、400、401、404、409。 + +## 13. 业务流程 + +### 13.1 设备开机与配置同步 + +```mermaid +sequenceDiagram + participant D as 设备 + participant A as 云端API + D->>A: GET /bootstrap + HMAC + A-->>D: server_time、状态、绑定、版本门禁 + alt 固件不兼容 + D->>D: 进入升级流程,禁止新通话 + else 固件兼容 + D->>A: GET /config + If-None-Match + A-->>D: 200 新配置或 304 未变化 + D->>D: 校验并原子应用配置 + D->>A: POST /telemetry:batch 上报实际版本 + end +``` + +### 13.2 双向语音通话 + +```mermaid +sequenceDiagram + participant D as 设备 + participant A as 云端API + participant R as 火山RTC + participant W as Worker + participant B as 豆包智能体 + D->>A: POST /conversations + A-->>D: conversation_id + room/token + D->>R: 加入房间并发布麦克风 + D->>A: POST /conversations/{id}/activate + A-->>D: 202 STARTING_AGENT + A->>W: agent_start_requested + W->>B: StartVoiceChat + B->>R: 智能体加入同一房间 + loop 有限轮询 + D->>A: GET /conversations/{id} + A-->>D: wait / continue / leave_room + end + loop 通话中 + D->>A: POST /heartbeat + A-->>D: continue/stop/renew + config/commands + D->>R: 上行麦克风音频 + R-->>D: 下行豆包语音 + end + D->>A: POST /stop + A->>W: agent_stop_requested + W->>B: StopVoiceChat + D->>R: 退出房间 +``` + +### 13.3 设备命令 + +```mermaid +sequenceDiagram + participant D as 设备 + participant A as 云端API + D->>A: GET /commands + A-->>D: 待执行命令 + D->>A: POST /commands/{id}/ack ACKNOWLEDGED + D->>D: 执行命令并保存本地状态 + D->>A: POST /commands/{id}/ack SUCCEEDED/FAILED + A-->>D: 最终命令状态 +``` + +### 13.4 录音上传 + +```mermaid +sequenceDiagram + participant D as 设备 + participant A as 云端API + participant O as 对象存储 + participant W as Worker + D->>A: POST /conversations/{id}/upload-batches + A-->>D: 分片ID + 短期PUT地址 + D->>O: PUT 分片字节 + D->>A: POST /upload-batches/{id}/complete + A->>W: media.verify_requested + W->>O: 校验大小、摘要、WAV与时长 + loop 查询结果 + D->>A: GET /upload-batches/{id} + A-->>D: VERIFYING/VERIFIED/REJECTED + end + D->>D: 到 local_delete_after 后删除本地密文 + D->>A: POST /media-segments/{id}/local-delete-ack +``` + +## 14. 错误码与重试策略 + +| HTTP | 错误码 | 是否自动重试 | 处理建议 | +|---:|---|---|---| +| 400 | `VALIDATION_FAILED` | 否 | 修正字段,重新生成随机数和签名 | +| 401 | `DEVICE_AUTHENTICATION_FAILED` | 否 | 检查设备登记、时钟、摘要、签名或重放 | +| 403 | `CONSENT_REQUIRED` | 否 | 等待用户有效授权 | +| 403 | `TRANSCRIPT_STORAGE_NOT_ALLOWED` | 否 | 停止补传,不能绕过留存策略 | +| 404 | `TELEMETRY_CONVERSATION_NOT_FOUND` | 否 | 删除错误通话引用 | +| 409 | `DEVICE_NOT_BOUND` | 否 | 完成设备绑定 | +| 409 | `CONVERSATION_ALREADY_ACTIVE` | 否 | 恢复或查询已有通话 | +| 409 | `IDEMPOTENCY_KEY_REUSED` | 否 | 不同正文必须使用新业务键 | +| 409 | `ROOM_ALLOCATION_EXPIRED` | 否 | 重新创建通话和房间 | +| 409 | `RTC_ALLOCATION_MISMATCH` | 否 | 丢弃本地房间资料并重新创建 | +| 409 | `CONFIG_VERSION_CONFLICT` | 否 | 重新获取设备配置 | +| 409 | `CONVERSATION_STATE_CONFLICT` | 否 | 查询权威状态,禁止强制覆盖 | +| 409 | `TRANSCRIPT_TURN_CONFLICT` | 否 | 保存冲突明细,转人工对账 | +| 409 | `TELEMETRY_EVENT_CONFLICT` | 否 | 停止推进确认点,检查事件键和序号 | +| 409 | `DEVICE_COMMAND_STATE_CONFLICT` | 否 | 重新拉取命令,禁止状态回退 | +| 422 | `AGENT_CONTEXT_PREPARATION_FAILED` | 否 | 等待修正长者背景资料后新建通话 | +| 426 | `FIRMWARE_UPDATE_REQUIRED` | 否 | 执行固件升级 | +| 429 | `USAGE_QUOTA_EXCEEDED` | 延迟/人工 | 提示额度或并发限制 | +| 503 | `DEVICE_AUTHENTICATION_UNAVAILABLE` | 是 | 指数退避,使用新随机数重试 | +| 503 | `DEVICE_CONFIG_UNAVAILABLE` | 是 | 保留旧配置并退避重试 | +| 503 | `RUNTIME_CONFIG_UNAVAILABLE` | 有条件 | 停止创建通话,等待平台恢复路由 | +| 503 | `TRANSCRIPT_BATCH_UNAVAILABLE` | 是 | 原正文、原幂等键,新 HMAC 头重试 | +| 503 | `DEVICE_TELEMETRY_UNAVAILABLE` | 是 | 保留事件队列,原正文和幂等键重试 | +| 503 | `CONVERSATION_HEARTBEAT_UNAVAILABLE` | 是 | 同序号、新 HMAC 头退避重试 | +| 503 | `CONVERSATION_STOP_UNAVAILABLE` | 是 | 原正文和幂等键、新 HMAC 头重试 | + +建议重试参数: + +- 初始等待 500 ms; +- 指数退避 0.5、1、2、4、8、15、30 秒; +- 加入 0~20% 随机抖动; +- 单次业务的最大重试时间受 RTC 房间、上传批次或命令过期时间约束; +- `retryable=false` 时不得自动循环重试。 + +## 15. 与设备相关但不由终端直接调用的管理接口 + +下列接口由 SaaS 管理后台或运营后台调用,使用 OIDC 用户身份和权限控制,不使用设备 HMAC: + +| 方法 | 路径 | 用途 | +|---|---|---| +| POST | `/api/v1/device-claims` | 家庭成员使用设备屏幕显示的绑定码绑定设备 | +| GET | `/api/v1/devices` | 查询当前家庭可管理设备 | +| GET | `/api/v1/devices/{id}` | 查询设备详情 | +| GET | `/api/v1/devices/{id}/presence` | 查询设备在线和当前通话状态 | +| GET | `/api/v1/devices/{id}/twin` | 查询设备期望/实际配置影子 | +| PATCH | `/api/v1/devices/{id}/twin` | 修改期望配置,需 `If-Match` | +| GET | `/api/v1/devices/{id}/commands` | 查询设备命令记录 | +| POST | `/api/v1/devices/{id}/commands` | 创建配置、重启、诊断或固件命令 | +| GET/POST | `/api/v1/households/{id}/firmware-signing-keys` | 管理固件 Ed25519 信任根 | +| POST | `/api/v1/households/{id}/firmware-signing-keys/revoke` | 吊销签名公钥 | +| GET/POST | `/api/v1/households/{id}/firmware-releases` | 查询或创建固件版本 | +| POST | `/api/v1/firmware-releases/{id}/upload-session` | 创建固件上传会话 | +| POST | `/api/v1/firmware-releases/{id}/finalize` | 完成固件制品校验与发布 | +| GET | `/api/v1/firmware-releases/{id}/impact` | 查询版本、设备和命令影响 | +| POST | `/api/v1/firmware-releases/{id}/pause` | 暂停版本 | +| POST | `/api/v1/firmware-releases/{id}/resume` | 恢复版本 | +| POST | `/api/v1/firmware-releases/{id}/retire` | 退役版本 | +| GET/POST | `/api/v1/households/{id}/firmware-rollouts` | 查询或创建灰度批次 | +| GET | `/api/v1/firmware-rollouts/{id}` | 查询灰度批次状态 | +| POST | `/api/v1/firmware-rollouts/{id}/pause` | 暂停灰度 | +| POST | `/api/v1/firmware-rollouts/{id}/rollback` | 回滚到已验证版本 | + +## 16. 设备实现检查清单 + +### 16.1 身份与安全 + +- [ ] 每台设备使用独立密钥,密钥位于安全存储区。 +- [ ] 每次请求生成新 Nonce,网络重试也不复用。 +- [ ] 对实际发送的原始正文字节计算 SHA-256。 +- [ ] 签名 URI 与实际请求路径、查询串完全一致。 +- [ ] 不记录设备密钥、HMAC 签名、RTC Token、上传地址和下载地址。 +- [ ] 生产环境强制 HTTPS 并校验证书。 + +### 16.2 离线与幂等 + +- [ ] 关键请求正文和幂等键在成功确认前持久化。 +- [ ] 重试保留原正文与 Idempotency-Key,但更新 HMAC 请求头。 +- [ ] 遥测事件键和序号跨进程重启保持单调或稳定唯一。 +- [ ] 命令 `ACKNOWLEDGED` 后保存本地执行进度,重启可恢复。 +- [ ] 保留最近一次有效配置,配置拉取失败不清空。 + +### 16.3 实时语音 + +- [ ] 创建通话后先加入 RTC,再调用 activate。 +- [ ] 识别 `wait/continue/leave_room`,失败时及时离房。 +- [ ] 按服务器心跳间隔发送递增序号。 +- [ ] 支持续签时只在内存替换 RTC Token。 +- [ ] 收到 `stop` 或终态后停止采集、退出 RTC 并调用停止接口。 +- [ ] 不根据 `engine_mode` 改变设备媒体行为。 + +### 16.4 录音与隐私 + +- [ ] 只有云端明确允许时才录制和上传对应轨道。 +- [ ] 上传前计算真实字节大小和 SHA-256。 +- [ ] 每个短期 URL 只 PUT 对应单一对象。 +- [ ] 只有校验为 VERIFIED 且到达 `local_delete_after` 后才删除本地密文。 +- [ ] 删除失败上报稳定错误码,不上传包含个人信息的自由文本堆栈。 + +## 17. 契约与联调依据 + +- 权威机器可读契约:`openapi/openapi.yaml` +- 设备接入实现说明:`docs/device-integration.md` +- 设备命令可靠下发:`docs/device-commands.md` +- 录音上传和访问:`docs/media-upload-and-access.md` +- 设备 API 兼容策略:`docs/device-api-compatibility.md` +- A/B 固件协议:`docs/device-firmware-ab-protocol.md` + +若本文档与当前 OpenAPI 发生字段差异,应以同一构建版本中的 `openapi/openapi.yaml` 为准,并同步修订本文档;不得根据固件版本、设备型号或 User-Agent 猜测接口字段。 diff --git a/internal/app/app.go b/internal/app/app.go index f77442a..eec01cf 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -173,7 +173,13 @@ func Run(ctx context.Context, serviceName string) error { return fmt.Errorf("create per-device secret resolver: %w", err) } } else { - deviceSecretResolver = deviceauth.StaticSecretResolver{"local-dev://simulated-terminal-1": []byte(cfg.DeviceLocalSecret)} + // The local environment provides one already-bound simulator and one + // unbound simulator so the family portal's one-time claim flow can be + // exercised without provisioning production device secrets. + deviceSecretResolver = deviceauth.StaticSecretResolver{ + "local-dev://simulated-terminal-1": []byte(cfg.DeviceLocalSecret), + "local-dev://unbound-terminal-2": []byte(cfg.DeviceLocalSecret), + } } deviceVerifier, err = deviceauth.NewVerifier( deviceIdentityRepository, diff --git a/internal/orchestration/provider_processing.go b/internal/orchestration/provider_processing.go index 2157a82..224d21c 100644 --- a/internal/orchestration/provider_processing.go +++ b/internal/orchestration/provider_processing.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "time" "data-terminal-cloud/internal/billing" @@ -167,6 +168,10 @@ func (p *StartProcessor) Handle(ctx context.Context, event outbox.Event) error { } return outbox.Retryable(err) } + slog.ErrorContext(ctx, "provider voice chat start rejected", + "provider", providerName, + "error", err, + ) return p.failRejectedStart(ctx, event.AggregateID, tenantID, "provider_start_rejected", "provider permanently rejected voice chat start") } } diff --git a/internal/voiceprovider/volcengine.go b/internal/voiceprovider/volcengine.go index 5ae3742..535c2b1 100644 --- a/internal/voiceprovider/volcengine.go +++ b/internal/voiceprovider/volcengine.go @@ -283,12 +283,19 @@ func (c *VolcengineClient) call(ctx context.Context, operation, action string, i if readErr != nil { return &Error{Operation: operation, Temporary: true, Cause: readErr} } + var result volcengineResponse + decodeErr := json.Unmarshal(responseBody, &result) if response.StatusCode < 200 || response.StatusCode >= 300 { + if decodeErr == nil && result.ResponseMetadata.Error != nil { + providerError := result.ResponseMetadata.Error + return &Error{Operation: operation, StatusCode: response.StatusCode, + Code: providerError.Code, Temporary: temporaryHTTPStatus(response.StatusCode) || temporaryProviderCode(providerError.Code), + Cause: errors.New(providerError.Message)} + } return &Error{Operation: operation, StatusCode: response.StatusCode, Temporary: temporaryHTTPStatus(response.StatusCode), Cause: errors.New(http.StatusText(response.StatusCode))} } - var result volcengineResponse - if err := json.Unmarshal(responseBody, &result); err != nil { + if decodeErr != nil { return &Error{Operation: operation, Temporary: true, Cause: errors.New("provider returned invalid JSON")} } if result.ResponseMetadata.Error != nil { diff --git a/internal/voiceprovider/volcengine_test.go b/internal/voiceprovider/volcengine_test.go index e024729..ffec09a 100644 --- a/internal/voiceprovider/volcengine_test.go +++ b/internal/voiceprovider/volcengine_test.go @@ -89,6 +89,23 @@ func TestVolcengineClientClassifiesRateLimitAndDuplicateStart(t *testing.T) { } } +func TestVolcengineClientPreservesProviderErrorOnNonSuccessHTTPStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(writer).Encode(map[string]any{"ResponseMetadata": map[string]any{"Error": map[string]any{ + "Code": "InvalidParameter.AppId", "Message": "application was not found", + }}}) + })) + defer server.Close() + client := newTestVolcengineClient(t, server.URL) + _, err := client.Start(context.Background(), StartRequest{ + TaskRef: "task-1", RoomID: "room-1", DeviceUserID: "device-1", AgentUserID: "agent-1", EngineMode: "s2s_hybrid", + }) + if err == nil || !strings.Contains(err.Error(), "InvalidParameter.AppId") || !strings.Contains(err.Error(), "application was not found") { + t.Fatalf("provider error details were lost: %v", err) + } +} + func TestVolcengineClientRejectsUneditedExampleTemplate(t *testing.T) { _, err := NewVolcengineClient(VolcengineOptions{ BaseURL: "https://rtc.volcengineapi.com", AccessKeyID: strings.Repeat("a", 32), SecretAccessKey: strings.Repeat("s", 40), diff --git a/web/package-lock.json b/web/package-lock.json index 01116f1..180e151 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,14 +1,16 @@ { - "name": "data-terminal-cloud-web", + "name": "elder-companion-customer-portal", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "data-terminal-cloud-web", + "name": "elder-companion-customer-portal", "version": "0.1.0", "dependencies": { + "@ant-design/icons": "^6.3.2", "@vitejs/plugin-react": "latest", + "antd": "^6.6.1", "oidc-client-ts": "^3.5.0", "openapi-typescript": "latest", "react": "latest", @@ -21,6 +23,99 @@ "@types/react-dom": "latest" } }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", + "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.1.2", + "@babel/runtime": "^7.23.2", + "@rc-component/util": "^1.4.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.3.2.tgz", + "integrity": "sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/icons-svg": "^4.5.0", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz", + "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz", + "integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "clsx": "^2.1.1", + "json2mq": "^0.2.0", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -44,6 +139,27 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.146.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", @@ -53,6 +169,727 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@rc-component/async-validator": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-6.0.0.tgz", + "integrity": "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/cascader": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.22.0.tgz", + "integrity": "sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.10.0", + "@rc-component/tree": "~1.4.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/checkbox": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/checkbox/-/checkbox-2.0.0.tgz", + "integrity": "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/collapse": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/collapse/-/collapse-1.2.0.tgz", + "integrity": "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-3.1.1.tgz", + "integrity": "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-2.0.2.tgz", + "integrity": "sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dialog": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/dialog/-/dialog-1.10.0.tgz", + "integrity": "sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.3.3", + "@rc-component/portal": "^2.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/drawer": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@rc-component/drawer/-/drawer-1.4.2.tgz", + "integrity": "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.1.3", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dropdown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.3.tgz", + "integrity": "sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/@rc-component/form": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.6.tgz", + "integrity": "sha512-1EXVsSKPZC6kGrIqxVck7kAWM6T65b6et/n5wIcyiaIa41VrDshD0nVLHoBDEIZr2ATQsOP6icb4XQkNFMLBAw==", + "license": "MIT", + "dependencies": { + "@rc-component/async-validator": "^6.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/image": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.10.0.tgz", + "integrity": "sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/portal": "^2.1.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/input": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/input/-/input-1.3.1.tgz", + "integrity": "sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==", + "license": "MIT", + "dependencies": { + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/input-number": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@rc-component/input-number/-/input-number-1.6.2.tgz", + "integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==", + "license": "MIT", + "dependencies": { + "@rc-component/mini-decimal": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/listy": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rc-component/listy/-/listy-1.2.3.tgz", + "integrity": "sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.3.1", + "@rc-component/virtual-list": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/mentions": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.11.0.tgz", + "integrity": "sha512-IC2qXuEBMFHxPIXEFfYWj6Sr7UiDZnOqJHCYQBbwPzopBJOPZIR6mV9U4QH1bYQRlKYlYnIsajWDMgVGgWQyWQ==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.3.1", + "@rc-component/menu": "~1.4.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/menu": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@rc-component/menu/-/menu-1.4.1.tgz", + "integrity": "sha512-3GsVRoQ4cnF/AoIQ4P+Z1haBfgfBPQfLT1RJY3Nu4DzOnheTslfCiGSPj7bv/cLj5sW5pHqN25dDXGP3JELAlQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz", + "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/motion": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@rc-component/motion/-/motion-1.3.3.tgz", + "integrity": "sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz", + "integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/notification": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@rc-component/notification/-/notification-2.0.7.tgz", + "integrity": "sha512-nqZzpf6BPdaj+3ILx7si79LLmqPKyUmQoXa+/9gg0SkH0v1DbD66oJgRMSBEVnd/zUT3D4gwxWIHUKebYf2ZXQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/overflow": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/overflow/-/overflow-1.0.1.tgz", + "integrity": "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/pagination": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.4.0.tgz", + "integrity": "sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/picker": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rc-component/picker/-/picker-1.12.0.tgz", + "integrity": "sha512-0FZGgDiDZFMm2hcfGv4jyjm7yWIj2MiwfeTzLL0vWkO+8cCSKdiM9XhWpkn1aAsoI2EwUy48Oz2cGfvL8ZKrfw==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/trigger": "^3.6.15", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/@rc-component/portal": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-2.2.1.tgz", + "integrity": "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/progress": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rc-component/progress/-/progress-1.0.3.tgz", + "integrity": "sha512-Q1nPLIvKr95el/q76yaBh5nv1hqCsWaHQb8U7vXEDMefEhHgM7n3CPoN+o4LHnTyANQHmMFgoS/naAT4Bhxuaw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-2.0.0.tgz", + "integrity": "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/rate": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/rate/-/rate-1.0.1.tgz", + "integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/resize-observer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/resize-observer/-/resize-observer-1.1.2.tgz", + "integrity": "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/segmented": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/segmented/-/segmented-1.3.0.tgz", + "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/select": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.10.1.tgz", + "integrity": "sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/slider": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/slider/-/slider-1.1.1.tgz", + "integrity": "sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/steps": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rc-component/steps/-/steps-1.2.2.tgz", + "integrity": "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/switch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rc-component/switch/-/switch-1.0.3.tgz", + "integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/table": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.11.1.tgz", + "integrity": "sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==", + "license": "MIT", + "dependencies": { + "@rc-component/context": "^2.0.1", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tabs": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.12.0.tgz", + "integrity": "sha512-XL7Kqy5fnUE2WTlO1/fCGrrfNlGFebdr7JseGkEIjzcVMAtIFQJ8sqCSOmxcXstjU6fonD/4rnhZHxj7sDTajQ==", + "license": "MIT", + "dependencies": { + "@rc-component/dropdown": "~1.0.0", + "@rc-component/menu": "~1.4.0", + "@rc-component/motion": "^1.1.3", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tooltip": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.5.2.tgz", + "integrity": "sha512-VLq4BclU3LgRe46g2hTr2bdsM8tFYs7dVwpuD88rPNNg4lCGe8mBSS4FiRzYzS+ffnZ46y4SI8KYKo9K7qBnhw==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.10.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-2.4.0.tgz", + "integrity": "sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg==", + "license": "MIT", + "dependencies": { + "@rc-component/portal": "^2.2.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.7.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tree": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.4.0.tgz", + "integrity": "sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/tree-select": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.16.1.tgz", + "integrity": "sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.10.0", + "@rc-component/tree": "~1.4.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/trigger": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.10.1.tgz", + "integrity": "sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.3.3", + "@rc-component/portal": "^2.2.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/upload": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.1.tgz", + "integrity": "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.12.0.tgz", + "integrity": "sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^19.2.7" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.5.1.tgz", + "integrity": "sha512-boqHxdtyWC88u8quYgEO49bcBy5fzRiOcnBge+N4nLzs2k8hUQ/yw7JE9dM6yCBE4jSm5YSHVCVMS+suBuJGKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^8.0.0", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list/node_modules/@babel/runtime": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.0.tgz", + "integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==", + "license": "MIT" + }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -409,6 +1246,70 @@ "node": ">=6" } }, + "node_modules/antd": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/antd/-/antd-6.6.1.tgz", + "integrity": "sha512-QHIHYoUk9N9nJy1T9fyxWKjY0qApdTEDd/6lzqYng8Uryv9FejNmbhKvYF7obGqB+TuLXQsPVF7fOVgyzM1KrQ==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/cssinjs": "^2.1.2", + "@ant-design/cssinjs-utils": "^2.1.2", + "@ant-design/fast-color": "^3.0.1", + "@ant-design/icons": "^6.3.2", + "@ant-design/react-slick": "~2.0.0", + "@babel/runtime": "^7.29.2", + "@rc-component/cascader": "~1.22.0", + "@rc-component/checkbox": "~2.0.0", + "@rc-component/collapse": "~1.2.0", + "@rc-component/color-picker": "~3.1.1", + "@rc-component/dialog": "~1.10.0", + "@rc-component/drawer": "~1.4.2", + "@rc-component/dropdown": "~1.0.3", + "@rc-component/form": "~1.8.6", + "@rc-component/image": "~1.10.0", + "@rc-component/input": "~1.3.1", + "@rc-component/input-number": "~1.6.2", + "@rc-component/listy": "~1.2.3", + "@rc-component/mentions": "~1.11.0", + "@rc-component/menu": "~1.4.1", + "@rc-component/motion": "^1.3.3", + "@rc-component/mutate-observer": "^2.0.1", + "@rc-component/notification": "~2.0.7", + "@rc-component/pagination": "~1.4.0", + "@rc-component/picker": "~1.12.0", + "@rc-component/progress": "~1.0.2", + "@rc-component/qrcode": "~2.0.0", + "@rc-component/rate": "~1.0.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/segmented": "~1.3.0", + "@rc-component/select": "~1.10.1", + "@rc-component/slider": "~1.1.1", + "@rc-component/steps": "~1.2.2", + "@rc-component/switch": "~1.0.3", + "@rc-component/table": "~1.11.1", + "@rc-component/tabs": "~1.12.0", + "@rc-component/tooltip": "~1.5.0", + "@rc-component/tour": "~2.4.0", + "@rc-component/tree": "~1.4.0", + "@rc-component/tree-select": "~1.16.1", + "@rc-component/trigger": "^3.10.1", + "@rc-component/upload": "~1.1.1", + "@rc-component/util": "^1.12.0", + "clsx": "^2.1.1", + "dayjs": "^1.11.11", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -436,17 +1337,37 @@ "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/colorette": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", "license": "MIT" }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", "license": "MIT" }, "node_modules/debug": { @@ -537,6 +1458,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, "node_modules/js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", @@ -580,6 +1507,15 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, "node_modules/jwt-decode": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", @@ -999,6 +1935,12 @@ "react": "^19.2.8" } }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1047,6 +1989,15 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1056,6 +2007,18 @@ "node": ">=0.10.0" } }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -1068,6 +2031,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/web/package.json b/web/package.json index 0b9e890..a699734 100644 --- a/web/package.json +++ b/web/package.json @@ -1,5 +1,5 @@ { - "name": "data-terminal-cloud-web", + "name": "elder-companion-customer-portal", "private": true, "version": "0.1.0", "type": "module", @@ -11,7 +11,9 @@ "generate:api": "openapi-typescript ../openapi/openapi.yaml -o src/api/schema.d.ts" }, "dependencies": { + "@ant-design/icons": "^6.3.2", "@vitejs/plugin-react": "latest", + "antd": "^6.6.1", "oidc-client-ts": "^3.5.0", "openapi-typescript": "latest", "react": "latest", diff --git a/web/src/components/StatusCard.tsx b/web/src/components/StatusCard.tsx index 4c0e1ed..4fa5f33 100644 --- a/web/src/components/StatusCard.tsx +++ b/web/src/components/StatusCard.tsx @@ -1,3 +1,5 @@ +import { Badge, Card } from "antd"; + type StatusCardProps = { label: string; value: string; @@ -6,14 +8,16 @@ type StatusCardProps = { }; export function StatusCard({ label, value, detail, tone }: StatusCardProps) { + const status = tone === "good" ? "success" : tone === "warning" ? "warning" : "default"; + return ( -
+
{label} -
{value}

{detail}

-
+ ); } diff --git a/web/src/main.tsx b/web/src/main.tsx index 3540d6a..3998e90 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,10 +1,52 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { PlatformOverview } from "./pages/PlatformOverview"; +import { ConfigProvider } from "antd"; +import zhCN from "antd/locale/zh_CN"; +import { CustomerPortal } from "./pages/CustomerPortal"; import "./styles.css"; createRoot(document.getElementById("root")!).render( - + + + ); diff --git a/web/src/pages/CustomerPortal.tsx b/web/src/pages/CustomerPortal.tsx new file mode 100644 index 0000000..0f89dfd --- /dev/null +++ b/web/src/pages/CustomerPortal.tsx @@ -0,0 +1,209 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Avatar, + Badge, + Breadcrumb, + Button, + Card, + Col, + Descriptions, + Dropdown, + Empty, + Input, + Layout, + Menu, + Row, + Space, + Spin, + Tag, + Tooltip, + Typography, + type MenuProps +} from "antd"; +import { + BellOutlined, + BookOutlined, + CaretDownOutlined, + CommentOutlined, + CreditCardOutlined, + DashboardOutlined, + HeartOutlined, + HomeOutlined, + LaptopOutlined, + LockOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + MessageOutlined, + SafetyCertificateOutlined, + SearchOutlined, + SettingOutlined, + TeamOutlined, + UserOutlined +} from "@ant-design/icons"; +import { useAuth } from "../auth/useAuth"; +import { SaaSWorkspace, type CustomerSection } from "./SaaSWorkspace"; + +type PortalSection = CustomerSection | "profile"; + +const sectionMeta: Record = { + home: { title: "家庭首页", description: "陪伴服务状态、长者近况与家庭待办一目了然。", breadcrumb: "首页" }, + members: { title: "家庭管理", description: "管理家庭空间、家人邀请、成员角色和所有权。", breadcrumb: "家庭管理" }, + subjects: { title: "长者管理", description: "维护长者档案、交流偏好、稳定事实和分项授权。", breadcrumb: "长者管理" }, + devices: { title: "设备管理", description: "绑定陪伴设备,查看在线状态、固件版本和音量配置。", breadcrumb: "设备管理" }, + conversations: { title: "通话记录", description: "按长者筛选通话,查看文字内容、媒体授权和单次费用。", breadcrumb: "通话记录" }, + stories: { title: "故事与记忆", description: "核对智能整理的故事,沉淀经过家庭确认的长期记忆。", breadcrumb: "故事与记忆" }, + sharing: { title: "资源分享", description: "面向指定家人分享故事、文字或录音,并随时撤销权限。", breadcrumb: "授权与隐私 / 资源分享" }, + privacy: { title: "隐私请求", description: "申请访问、导出或删除个人数据,并跟踪处理状态。", breadcrumb: "授权与隐私 / 隐私请求" }, + billing: { title: "费用与额度", description: "查看当前家庭可见通话的实际计量和费用说明。", breadcrumb: "费用与额度" }, + messages: { title: "消息中心", description: "查看设备、通话、故事和隐私请求的实时状态提醒。", breadcrumb: "消息中心" }, + profile: { title: "个人中心", description: "查看当前登录身份、客户组织和家庭角色。", breadcrumb: "个人中心" } +}; + +export function CustomerPortal() { + const auth = useAuth(); + const [collapsed, setCollapsed] = useState(false); + const [section, setSection] = useState(() => sectionFromURL()); + const [searchValue, setSearchValue] = useState(""); + const meta = sectionMeta[section]; + + useEffect(() => { + const onPopState = () => setSection(sectionFromURL()); + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); + + const menuItems = useMemo(() => [ + { key: "home", icon: , label: "首页" }, + { key: "members", icon: , label: "家庭管理" }, + { key: "subjects", icon: , label: "长者管理" }, + { + key: "privacy-group", + icon: , + label: "授权与隐私", + children: [ + { key: "sharing", icon: , label: "资源分享" }, + { key: "privacy", icon: , label: "隐私请求" } + ] + }, + { key: "devices", icon: , label: "设备管理" }, + { key: "conversations", icon: , label: "通话记录" }, + { key: "stories", icon: , label: "故事与记忆" }, + { key: "billing", icon: , label: "费用与额度" }, + { key: "messages", icon: , label: "消息中心" }, + { type: "divider" }, + { key: "profile", icon: , label: "个人中心" } + ], []); + + function navigate(next: PortalSection) { + setSection(next); + const url = new URL(window.location.href); + if (next === "home") url.searchParams.delete("section"); else url.searchParams.set("section", next); + window.history.pushState({}, "", url); + } + + function search() { + const value = searchValue.trim().toLowerCase(); + if (!value) return; + const matches: Array<[string[], PortalSection]> = [ + [["设备", "终端", "音量", "device"], "devices"], + [["通话", "语音", "录音", "conversation"], "conversations"], + [["故事", "回忆", "记忆", "story"], "stories"], + [["隐私", "导出", "删除", "授权", "privacy"], "privacy"], + [["分享", "share"], "sharing"], + [["费用", "额度", "账单", "cost"], "billing"], + [["消息", "通知", "提醒"], "messages"], + [["长者", "老人", "档案", "subject"], "subjects"], + [["家庭", "成员", "邀请", "member"], "members"] + ]; + const target = matches.find(([keywords]) => keywords.some((keyword) => value.includes(keyword)))?.[1]; + if (target) navigate(target); + } + + const accountMenu: MenuProps["items"] = [ + { key: "profile", icon: , label: "个人中心", onClick: () => navigate("profile") }, + { key: "logout", icon: , label: "退出登录", onClick: () => void auth.logout() } + ]; + + return + +
+
+
+ setSearchValue(event.target.value)} onSearch={search} prefix={} placeholder="搜索长者、设备、通话" allowClear className="customer-search" /> + + : } +
+
+ + + +
{collapsed ? null : 家庭服务}
+ navigate(key as PortalSection)} /> +
{collapsed ? null :
隐私受保护仅展示当前家庭数据
}
+ + + +
+ +
+

{meta.title}

{meta.description}

+ {auth.currentUser ? 家庭权限已校验 : null} +
+ + {auth.loading ? 正在读取登录状态… : null} + {!auth.loading && auth.error ? } /> : null} + {!auth.loading && !auth.currentUser && !auth.error ? void auth.login()} /> : null} + {auth.currentUser && auth.oidcUser && section !== "profile" ? : null} + {auth.currentUser && section === "profile" ? void auth.logout()} /> : null} +
+
+ + ; +} + +function CustomerLogin({ onLogin }: { onLogin: () => void }) { + return + + 登录家庭陪伴中心 + 登录后可查看自己家庭内的长者、设备、通话和故事。家庭角色不会自动获得录音播放或下载权限。 + +
OIDC 安全登录 家庭数据隔离
+
; +} + +function CustomerProfile({ currentUser, onLogout }: { currentUser: NonNullable["currentUser"]>; onLogout: () => void }) { + return
+ +
} />

{currentUser.identity.display_name || currentUser.identity.preferred_username}

{currentUser.identity.preferred_username}

+
+ {currentUser.accounts.length ? {currentUser.accounts.map((account) => 客户账号}> + +

家庭角色

{account.household_memberships.length ? account.household_memberships.map((membership) =>
{membership.household_name}{roleLabel(membership.role)}
{membership.capabilities.length} 项业务权限
) : }
+
)}
: } +
; +} + +function sectionFromURL(): PortalSection { + const value = new URL(window.location.href).searchParams.get("section"); + return value && value in sectionMeta ? value as PortalSection : "home"; +} + +function roleLabel(role: string) { + return ({ owner: "所有者", admin: "管理员", editor: "编辑者", viewer: "查看者" } as Record)[role] ?? role; +} diff --git a/web/src/pages/PlatformOverview.tsx b/web/src/pages/PlatformOverview.tsx index eafd679..74f6a9d 100644 --- a/web/src/pages/PlatformOverview.tsx +++ b/web/src/pages/PlatformOverview.tsx @@ -1,4 +1,33 @@ import { useCallback, useEffect, useState } from "react"; +import { + Alert, + Avatar, + Badge, + Breadcrumb, + Button, + Input, + Layout, + Menu, + Space, + Tag, + Tooltip +} from "antd"; +import { + AlertOutlined, + ApiOutlined, + BellOutlined, + CloudServerOutlined, + DashboardOutlined, + DollarOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + ReloadOutlined, + SearchOutlined, + SettingOutlined, + TeamOutlined, + ToolOutlined, + UserOutlined +} from "@ant-design/icons"; import { platformApi } from "../api/client"; import { StatusCard } from "../components/StatusCard"; import { useAuth } from "../auth/useAuth"; @@ -24,6 +53,7 @@ const workspaceKeys: WorkspaceKey[] = ["family", "config", "billing", "alerts", export function PlatformOverview() { const [state, setState] = useState({ loading: true }); const [activeWorkspace, setActiveWorkspace] = useState(() => workspaceFromURL()); + const [collapsed, setCollapsed] = useState(false); const auth = useAuth(); const refresh = useCallback(async (signal?: AbortSignal) => { @@ -95,148 +125,235 @@ export function PlatformOverview() { pushWorkspaceURL(workspace); } + const activeOption = workspaceOptions.find((option) => option.key === activeWorkspace) ?? workspaceOptions[0]; + const menuItems = [ + { + type: "group" as const, + label: "业务中心", + children: workspaceOptions.map((option) => ({ + key: option.key, + icon: workspaceIcon(option.key), + label: option.label, + title: option.detail + })) + } + ]; + return ( -
-
-
-

DATA TERMINAL · CLOUD PLATFORM

-

让每一次陪伴,都安全抵达

-

- 数据采集终端—云端系统研发工作台。当前版本已经接入家庭、长者、授权、设备与通话业务链路。 -

+ + +
+
- -
+
+ } placeholder="搜索设备、长者、通话" allowClear /> + + + + + )} +
+ - {state.error ? ( -
-
- 暂时无法连接云端服务 -

{state.error}。请确认API、PostgreSQL和Redis已经启动。

+ + +
+ + {!collapsed ? 数据终端 · 云端 : null}
- -
- ) : null} + selectWorkspace(key as WorkspaceKey)} + /> +
+ {!collapsed ? ( + <> + 平台版本 + {state.version ?? "dev"} + + ) : } +
+ -
- - - -
- -
-
-

IDENTITY & ACCESS

-

身份与家庭权限

-

OIDC只证明登录身份,客户组织与家庭权限全部由服务端关系推导。

-
- {auth.loading ? ( -
正在读取登录状态…
- ) : auth.currentUser ? ( -
-
+ +
+ +
- {auth.currentUser.identity.display_name || auth.currentUser.identity.preferred_username} - {auth.currentUser.identity.preferred_username} + + {workspaceIcon(activeWorkspace)} +

{activeOption?.label ?? "平台总览"}

+
+

+ {activeOption?.detail ?? "数据采集终端—云端系统研发工作台"}。统一管理家庭、长者、授权、设备与语音通话链路。 +

- -
- {auth.currentUser.accounts.map((account) => ( -
-

{account.tenant_name}

- {account.display_name} - {account.household_memberships.map((membership) => ( -
- {membership.household_name} - {roleLabel(membership.role)} · {membership.capabilities.length}项一般权限 + + + + {state.error ? ( + void refresh()}>重试} + /> + ) : null} + +
+ + + +
+ +
+
+

IDENTITY & ACCESS

+

身份与家庭权限

+

OIDC 只证明登录身份,客户组织与家庭权限全部由服务端关系推导。

+
+ {auth.loading ? ( +
正在读取登录状态…
+ ) : auth.currentUser ? ( +
+
+
+ {auth.currentUser.identity.display_name || auth.currentUser.identity.preferred_username} + {auth.currentUser.identity.preferred_username} +
+
- ))} -
- ))} -
- ) : ( -
- {auth.error ? "登录信息读取失败" : "尚未登录"} -

{auth.error ?? "进入本地Keycloak完成授权码与PKCE登录。研发账号见权限矩阵文档。"}

-
- {auth.error && auth.oidcUser ? ( - - ) : null} - -
-
- )} -
+ {auth.currentUser.accounts.map((account) => ( +
+

{account.tenant_name}

+ {account.display_name} + {account.household_memberships.map((membership) => ( +
+ {membership.household_name} + {roleLabel(membership.role)} · {membership.capabilities.length} 项一般权限 +
+ ))} +
+ ))} + + ) : ( +
+ {auth.error ? "登录信息读取失败" : "尚未登录"} +

{auth.error ?? "进入本地 Keycloak 完成授权码与 PKCE 登录。研发账号见权限矩阵文档。"}

+ + {auth.error && auth.oidcUser ? : null} + + +
+ )} + - {auth.currentUser && auth.oidcUser ? ( -
-
-
-

BUSINESS WORKSPACES

-

业务工作台

-
-

根据当前账号权限显示可用入口;切换入口时才加载对应业务数据。

-
- -
- {activeWorkspace === "family" ? : null} - {activeWorkspace === "config" && hasOperationsAccess ? : null} - {activeWorkspace === "billing" && hasOperationsAccess ? : null} - {activeWorkspace === "alerts" && hasOperationsAccess ? : null} - {activeWorkspace === "diagnostics" && hasOperationsAccess ? : null} -
-
- ) : null} + {auth.currentUser && auth.oidcUser ? ( +
+
+
+

{workspaceKicker(activeWorkspace)}

+

{activeOption?.label}

+
+

{activeOption?.detail};当前内容按账号权限加载。

+
+
+ {activeWorkspace === "family" ? : null} + {activeWorkspace === "config" && hasOperationsAccess ? : null} + {activeWorkspace === "billing" && hasOperationsAccess ? : null} + {activeWorkspace === "alerts" && hasOperationsAccess ? : null} + {activeWorkspace === "diagnostics" && hasOperationsAccess ? : null} +
+
+ ) : null} -
-
-

CURRENT MILESTONE

-

M8 · 联调、试点与上线准备

-
-
    -
  1. M0需求与架构基线
  2. -
  3. M1工程与环境
  4. -
  5. M2公共业务底座
  6. -
  7. M3端到端语音闭环
  8. -
  9. M4—M6SaaS与数据闭环
  10. -
  11. M7运营与生产加固
  12. -
  13. M8联调、试点与上线
  14. -
-
-
+
+
+

CURRENT MILESTONE

+

M8 · 联调、试点与上线准备

+
+
    +
  1. M0需求与架构基线
  2. +
  3. M1工程与环境
  4. +
  5. M2公共业务底座
  6. +
  7. M3端到端语音闭环
  8. +
  9. M4—M6SaaS与数据闭环
  10. +
  11. M7运营与生产加固
  12. +
  13. M8联调、试点与上线
  14. +
+
+ + + + ); } +function workspaceIcon(workspace: WorkspaceKey) { + return { + family: , + config: , + billing: , + alerts: , + diagnostics: + }[workspace]; +} + +function workspaceKicker(workspace: WorkspaceKey) { + return { + family: "FAMILY OPERATIONS", + config: "RUNTIME CONFIGURATION", + billing: "BILLING & QUOTA", + alerts: "ALERT MANAGEMENT", + diagnostics: "DIAGNOSTICS" + }[workspace]; +} + function roleLabel(role: string) { return ({ owner: "所有者", admin: "管理员", editor: "编辑者", viewer: "查看者" } as Record)[role] ?? role; } diff --git a/web/src/pages/SaaSWorkspace.tsx b/web/src/pages/SaaSWorkspace.tsx index 0ab4d59..d51eea7 100644 --- a/web/src/pages/SaaSWorkspace.tsx +++ b/web/src/pages/SaaSWorkspace.tsx @@ -41,7 +41,8 @@ import { type Story } from "../api/client"; -type Tab = "members" | "subjects" | "devices" | "firmware" | "conversations" | "stories" | "sharing" | "privacy"; +export type CustomerSection = "home" | "members" | "subjects" | "devices" | "conversations" | "stories" | "sharing" | "privacy" | "billing" | "messages"; +type Tab = CustomerSection | "firmware"; type LoadState = "idle" | "loading" | "ready" | "error"; type MediaLoadState = "idle" | "loading" | "ready" | "restricted" | "error"; type EventStreamState = "connecting" | "connected" | "reconnecting"; @@ -86,8 +87,15 @@ type FamilyControlDraft = | { kind: "sharing_grant_create"; subjectID: string; subjectName: string; granteeName: string; resourceLabel: string; input: SharingGrantInput; acknowledged: boolean; confirmation: string } | { kind: "sharing_revoke"; item: SharingGrant; acknowledged: boolean; confirmation: string }; -export function SaaSWorkspace({ accessToken, accounts }: { accessToken: string; accounts: CurrentUser["accounts"] }) { - const [tab, setTab] = useState("subjects"); +export function SaaSWorkspace({ accessToken, accounts, section = "home", onSectionChange, showNavigation = true, enableFirmware = false }: { + accessToken: string; + accounts: CurrentUser["accounts"]; + section?: CustomerSection; + onSectionChange?: (section: CustomerSection) => void; + showNavigation?: boolean; + enableFirmware?: boolean; +}) { + const [tab, setTab] = useState(section); const [state, setState] = useState("loading"); const [error, setError] = useState(); const [households, setHouseholds] = useState([]); @@ -179,10 +187,21 @@ export function SaaSWorkspace({ accessToken, accounts }: { accessToken: string; const [rollbackReleaseID, setRollbackReleaseID] = useState(""); const [eventStreamState, setEventStreamState] = useState("connecting"); const [lastEventAt, setLastEventAt] = useState(); + const [eventNotices, setEventNotices] = useState>([]); + const [billingUsages, setBillingUsages] = useState([]); + const [billingLoading, setBillingLoading] = useState(false); + const [billingError, setBillingError] = useState(); const eventRefreshTimer = useRef(undefined); const lastEventRefreshAt = useRef(0); const conversationDetailController = useRef(undefined); + useEffect(() => setTab(section), [section]); + + function selectSection(next: CustomerSection) { + setTab(next); + onSectionChange?.(next); + } + const scheduleEventRefresh = useCallback(() => { if (eventRefreshTimer.current !== undefined) return; const minimumIntervalMS = 2000; @@ -276,6 +295,18 @@ export function SaaSWorkspace({ accessToken, accounts }: { accessToken: string; window.sessionStorage.setItem(storageKey, id); if (data) { setLastEventAt(new Date().toISOString()); + try { + const item = JSON.parse(data) as { id?: number; type?: string; resource_type?: string; status?: string; occurred_at?: string }; + setEventNotices((current) => [{ + id: String(item.id ?? id), + type: item.type ?? "resource.updated", + resourceType: item.resource_type ?? "resource", + status: item.status ?? "UPDATED", + occurredAt: item.occurred_at ?? new Date().toISOString() + }, ...current.filter((notice) => notice.id !== String(item.id ?? id))].slice(0, 50)); + } catch { + // The event still triggers an authoritative REST refresh when a legacy payload cannot be decoded. + } scheduleEventRefresh(); } } @@ -401,6 +432,23 @@ export function SaaSWorkspace({ accessToken, accounts }: { accessToken: string; return () => controller.abort(); }, [accessToken, conversationEngineMode, conversationFrom, conversationStatus, conversationTo, refreshRevision, selectedSubjectID]); + useEffect(() => { + if (tab !== "billing" || conversations.length === 0) { + if (tab === "billing") setBillingUsages([]); + return; + } + const controller = new AbortController(); + setBillingLoading(true); + setBillingError(undefined); + void Promise.all(conversations.map((item) => platformApi.conversationUsage(item.id, accessToken, controller.signal))) + .then((responses) => setBillingUsages(responses.map((response) => response.data))) + .catch((cause) => { + if (!(cause instanceof DOMException && cause.name === "AbortError")) setBillingError(errorMessage(cause)); + }) + .finally(() => { if (!controller.signal.aborted) setBillingLoading(false); }); + return () => controller.abort(); + }, [accessToken, conversations, tab]); + useEffect(() => { if (devices.length === 0) { setPresence({}); setTwins({}); return; } const controller = new AbortController(); @@ -428,7 +476,7 @@ export function SaaSWorkspace({ accessToken, accounts }: { accessToken: string; const canReviewStory = selectedHousehold?.capabilities.includes("story.review") ?? false; const canReadPrivacy = selectedHousehold?.capabilities.includes("privacy.read") ?? false; const canManagePrivacy = selectedHousehold?.capabilities.includes("privacy.manage") ?? false; - const canManageFirmware = selectedHousehold?.capabilities.includes("firmware.manage") ?? false; + const canManageFirmware = enableFirmware && (selectedHousehold?.capabilities.includes("firmware.manage") ?? false); const canManageRecordingAccess = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin"; const canManageSharing = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin"; const policyVersion = accounts.find((account) => account.tenant_id === selectedHousehold?.tenant_id)?.policy_version ?? ""; @@ -551,7 +599,7 @@ export function SaaSWorkspace({ accessToken, accounts }: { accessToken: string; } async function openStoryConversation(story: Story) { - setTab("conversations"); + selectSection("conversations"); await openConversation(story.conversation_id); } @@ -1084,9 +1132,9 @@ export function SaaSWorkspace({ accessToken, accounts }: { accessToken: string;
-

FAMILY OPERATIONS

-

家庭业务工作台

-

家庭、长者、授权、设备和通话数据均按服务端成员关系隔离。

+

FAMILY COMPANION

+

{customerSectionTitle(tab)}

+

{customerSectionDescription(tab)}

{eventStreamState === "connected" ? "状态实时更新" : eventStreamState === "reconnecting" ? "状态重连中" : "状态连接中"} @@ -1100,12 +1148,12 @@ export function SaaSWorkspace({ accessToken, accounts }: { accessToken: string;
-
void acceptInvitation(event)}> + {tab === "members" ? void acceptInvitation(event)}> -
+ : null} -
+ {tab === "members" || households.length === 0 ?
{households.length === 0 ? "创建第一个家庭" : "新增家庭"}
void createHousehold(event)}>
建立家庭空间创建成功后,当前账号自动成为所有者,可继续新增长者档案和邀请成员。
@@ -1113,7 +1161,7 @@ export function SaaSWorkspace({ accessToken, accounts }: { accessToken: string;
-
+
: null} {state === "loading" ? : null} {state === "error" ? void loadRoot()} /> : null} @@ -1124,28 +1172,35 @@ export function SaaSWorkspace({ accessToken, accounts }: { accessToken: string; {state === "ready" && selectedHousehold ? ( <> -
+ {tab === "home" ?
- item.household_id === selectedHousehold.id).length} detail={`${Object.values(presence).filter((item) => item.online).length}台在线`} /> -
+ item.household_id === selectedHousehold.id).length} detail={`${devices.filter((item) => item.household_id === selectedHousehold.id && presence[item.id]?.online).length}台在线`} /> +
: null} - {selectedSubject ? setTab(action === "EDIT_PROFILE" || action === "MANAGE_CONSENTS" ? "subjects" : "devices")} /> : null} + {tab === "home" && selectedSubject ? selectSection(action === "EDIT_PROFILE" || action === "MANAGE_CONSENTS" ? "subjects" : "devices")} /> : null} - : null} {subjectError ?
{subjectError}
: null} + {tab === "home" ? item.household_id === selectedHousehold.id)} presence={presence} + conversations={conversations} stories={stories} privacyRequests={privacyRequests} + onOpen={(section) => selectSection(section)} /> : null} {tab === "members" ? : null} + {tab === "privacy" && !canReadPrivacy ? : null} + {tab === "sharing" && !canManageSharing ? : null} + {tab === "billing" ? selectSection("conversations")} /> : null} + {tab === "messages" ? item.household_id === selectedHousehold.id)} presence={presence} stories={stories} privacyRequests={privacyRequests} eventState={eventStreamState} /> : null} ) : null}
); } +function CustomerHomePanel({ household, subject, subjects, devices, presence, conversations, stories, privacyRequests, onOpen }: { + household: Household; + subject?: Subject; + subjects: Subject[]; + devices: ManagedDevice[]; + presence: Record; + conversations: ConversationSummary[]; + stories: Story[]; + privacyRequests: PrivacyRequest[]; + onOpen: (section: CustomerSection) => void; +}) { + const onlineDevices = devices.filter((item) => presence[item.id]?.online).length; + const activeConversations = conversations.filter((item) => ["PREPARING", "ROOM_ISSUED", "STARTING_AGENT", "ACTIVE", "STOPPING"].includes(item.status)).length; + const pendingStories = stories.filter((item) => item.status === "CANDIDATE" || item.status === "IN_REVIEW").length; + return
+
+

WELCOME HOME

{subject ? `守护${subject.preferred_name}的每一次陪伴` : `欢迎进入${household.name}`}

设备状态、通话进展、授权和家庭故事都在同一处更新,所有内容只对有权限的家庭成员开放。

+ +
+
+ + + + +
+
+

QUICK ACTIONS

常用入口

+ + + +
+
; +} + +function CustomerBillingPanel({ conversations, usages, loading, error, onOpenConversations }: { + conversations: ConversationSummary[]; + usages: ConversationUsage[]; + loading: boolean; + error?: string; + onOpenConversations: () => void; +}) { + const totals = new Map(); + const categories = new Map(); + usages.forEach((usage) => { + const currency = usage.currency || "CNY"; + totals.set(currency, (totals.get(currency) ?? 0) + usage.cost_micros); + usage.lines.forEach((line) => { + const key = `${line.usage_category}:${line.currency}`; + const current = categories.get(key) ?? { cost: 0, currency: line.currency }; + current.cost += line.cost_micros; + categories.set(key, current); + }); + }); + return
+

USAGE & COST

费用与额度

+

家庭端只汇总当前长者已加载通话的已核价费用;平台价格、供应商任务号和组织硬额度由运维端管理。

+ {loading ? : null} + {error ?
{error}
: null} + {!loading ?
+ {[...totals.entries()].map(([currency, cost]) =>
已加载通话费用{formatUsageMoney(cost, currency)}{usages.length} 次通话已有计量结果
)} + {totals.size === 0 ?
已加载通话费用{formatUsageMoney(0, "CNY")}当前没有已核价费用
: null} +
家庭可见范围{conversations.length}当前长者、当前筛选条件
+
: null} + {!loading && categories.size ?
{[...categories.entries()].map(([key, item]) =>
{usageCategoryLabel(key.split(":")[0])}{formatUsageMoney(item.cost, item.currency)}
)}
: null} +
额度说明

家庭端不会读取或修改平台级额度策略。创建通话时,后端仍会实时执行额度校验;如果额度不足,页面会显示服务端返回的明确原因。

+
; +} + +function CustomerMessagePanel({ notices, devices, presence, stories, privacyRequests, eventState }: { + notices: Array<{ id: string; type: string; resourceType: string; status: string; occurredAt: string }>; + devices: ManagedDevice[]; + presence: Record; + stories: Story[]; + privacyRequests: PrivacyRequest[]; + eventState: EventStreamState; +}) { + const offline = devices.filter((item) => presence[item.id] && !presence[item.id].online); + const pendingStories = stories.filter((item) => item.status === "CANDIDATE" || item.status === "IN_REVIEW"); + const activePrivacy = privacyRequests.filter((item) => !["COMPLETED", "REJECTED", "FAILED"].includes(item.status)); + const derived = [ + ...offline.map((item) => ({ id: `offline-${item.id}`, title: "设备当前离线", detail: `${item.subject_name} · ${item.model}`, tone: "warning" })), + ...pendingStories.map((item) => ({ id: `story-${item.id}`, title: "故事等待家庭确认", detail: item.title, tone: "info" })), + ...activePrivacy.map((item) => ({ id: `privacy-${item.id}`, title: "隐私请求处理中", detail: `${item.request_type} · ${item.status}`, tone: "info" })) + ]; + return
+

MESSAGE CENTER

消息中心

这里只展示状态提醒,不在通知中携带转写、录音、故事正文或隐私原因。

{eventState === "connected" ? "实时连接" : eventState === "reconnecting" ? "重新连接" : "正在连接"}
+ {derived.length ?
{derived.map((item) =>
)}
:

当前没有需要处理的家庭提醒。

} +

本次登录收到的实时事件

{notices.length ? notices.map((notice) =>
{eventTypeLabel(notice.type)}

{resourceTypeLabel(notice.resourceType)} · {notice.status}

) :

暂未收到新的状态事件。服务端事件到达后会自动出现在这里。

}
+
; +} + +function customerSectionTitle(section: Tab) { + return ({ home: "家庭首页", members: "家庭管理", subjects: "长者管理", devices: "设备管理", firmware: "固件发布", conversations: "通话记录", stories: "故事与记忆", sharing: "资源分享", privacy: "隐私请求", billing: "费用与额度", messages: "消息中心" } as Record)[section]; +} + +function customerSectionDescription(section: Tab) { + return ({ + home: "查看家庭陪伴服务的最新状态和待办事项。", + members: "管理家庭空间、成员邀请、角色和所有权。", + subjects: "维护长者档案、稳定事实和分项授权。", + devices: "绑定陪伴设备,查看在线状态并调整安全配置。", + firmware: "管理设备固件发布。", + conversations: "查看通话进展、文字内容、录音授权和单次费用。", + stories: "核对从通话中整理的故事和长期记忆。", + sharing: "按资源和权限范围向指定家庭成员分享内容。", + privacy: "提交访问、导出或删除申请并跟踪处理进度。", + billing: "汇总当前可见通话的实际计量费用。", + messages: "集中查看设备、通话、故事和隐私请求的状态提醒。" + } as Record)[section]; +} + +function eventTypeLabel(type: string) { + return ({ "device.presence.changed": "设备在线状态变化", "conversation.started": "通话已开始", "conversation.completed": "通话已完成", "conversation.failed": "通话启动失败", "story.created": "生成新的故事候选", "story.revised": "故事内容已更新", "media.upload.completed": "通话媒体上传完成", "export.ready": "隐私导出已就绪", "privacy_request.updated": "隐私请求状态变化", "device.command.updated": "设备指令状态变化" } as Record)[type] ?? "业务状态已更新"; +} + +function resourceTypeLabel(type: string) { + return ({ device: "设备", conversation: "通话", story: "故事", upload_batch: "媒体", export_task: "导出任务", privacy_request: "隐私请求", device_command: "设备指令" } as Record)[type] ?? "业务资源"; +} + function ConversationReadinessPanel({ readiness, subject, loading, onAction }: { readiness?: ConversationReadiness; subject: Subject; loading: boolean; onAction: (action: string) => void; }) { diff --git a/web/src/styles.css b/web/src/styles.css index 9f3e907..b18dcf0 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -891,3 +891,721 @@ form[aria-label="联调问题流转编辑"] label small { color: #7b898d; font-w .alert-config-form > div, .alert-config-form__wide { grid-column: auto; } .alert-config-form button { justify-self: stretch; } } + +/* Customer portal: a separate family-facing shell. */ +.customer-layout { + min-height: 100vh; + color: #1f2329; + background: #f5f6f8; +} + +.customer-layout button:hover { transform: none; } +.customer-header { + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: space-between; + height: 60px; + padding: 0 22px 0 14px; + border-bottom: 1px solid #e8eaee; + background: #fff; + box-shadow: 0 2px 8px rgba(31, 35, 41, 0.04); +} + +.customer-header__brand, +.customer-header__actions, +.customer-account-button, +.customer-sider__title, +.customer-sider__help, +.customer-profile__identity { + display: flex; + align-items: center; +} + +.customer-header__brand { gap: 10px; color: #646a73; white-space: nowrap; } +.customer-header__brand > strong { color: #1f2329; font-size: 18px; } +.customer-brand-mark { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 7px; + color: #fff; + background: #5b5ce2; + font-size: 17px; +} +.customer-brand-divider { width: 1px; height: 22px; margin: 0 5px; background: #e3e6eb; } +.customer-header__actions { gap: 10px; } +.customer-search { width: min(320px, 28vw); } +.customer-account-button { gap: 8px; height: 42px; padding: 0 8px; color: #3f444c; background: transparent; } + +.customer-layout__body { min-height: calc(100vh - 60px); } +.customer-sider { + position: sticky !important; + top: 60px; + align-self: flex-start; + height: calc(100vh - 60px); + overflow: auto; + border-right: 1px solid #e8eaee; + background: #fff !important; +} +.customer-sider__title { gap: 10px; height: 58px; padding: 0 24px; color: #30343b; font-weight: 700; } +.customer-sider .ant-menu { border-inline-end: 0 !important; padding: 0 10px; background: transparent; } +.customer-sider .ant-menu-item, +.customer-sider .ant-menu-submenu-title { margin-block: 3px; font-size: 14px; } +.customer-sider__help { + gap: 10px; + margin: 22px 14px; + padding: 12px; + border: 1px solid #e8eaee; + border-radius: 8px; + color: #5b5ce2; + background: #f8f8ff; +} +.customer-sider__help div { display: grid; gap: 2px; } +.customer-sider__help strong { color: #3f444c; font-size: 12px; } +.customer-sider__help span { color: #8b9199; font-size: 10px; } + +.customer-content { min-width: 0; background: #f5f6f8; } +.customer-page { + width: min(1440px, 100%); + margin: 0 auto; + padding: 22px 28px 48px; +} +.customer-page__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin: 14px 0 18px; +} +.customer-page__header h1 { max-width: none; color: #1f2329; font-family: inherit; font-size: 26px; line-height: 1.35; } +.customer-page__header p { margin: 6px 0 0; color: #646a73; line-height: 1.6; } +.customer-auth-state { display: flex; justify-content: center; } +.customer-auth-state .ant-card-body { display: flex; align-items: center; gap: 12px; color: #646a73; } + +.customer-page > .workspace { + margin-top: 0; + padding: 20px; + border: 1px solid #e3e6eb; + border-radius: 10px; + background: #fff; + box-shadow: none; +} +.customer-page .workspace__header { align-items: center; padding-bottom: 16px; border-bottom: 1px solid #eef0f3; } +.customer-page .workspace__header h2 { color: #1f2329; font-size: 18px; } +.customer-page .workspace__header > div:first-child > p:last-child { color: #7b818a; font-size: 13px; } +.customer-page .eyebrow { margin-bottom: 5px; color: #5b5ce2; font-size: 10px; letter-spacing: .13em; } +.customer-page .workspace__actions select { min-width: 160px; border-radius: 7px; } +.customer-page .workspace__summary { margin-top: 16px; } +.customer-page .metric { border: 1px solid #e8eaee; border-radius: 8px; background: #fafbfc; } +.customer-page .conversation-readiness, +.customer-page .conversation-readiness--ready, +.customer-page .conversation-readiness--blocked { border-radius: 8px; background: #fff; } + +.customer-welcome-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-top: 16px; + padding: 24px; + border: 1px solid #dedfff; + border-radius: 10px; + background: #f7f7ff; +} +.customer-welcome-card h3 { color: #22263d; font-size: 22px; } +.customer-welcome-card p:not(.eyebrow) { max-width: 650px; margin: 8px 0 0; color: #686e7b; line-height: 1.7; } +.customer-welcome-card > button { flex: none; border-radius: 7px; color: #fff; background: #5b5ce2; } +.customer-quick-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 14px; } +.customer-quick-grid button { + display: grid; + gap: 6px; + min-width: 0; + padding: 16px; + border: 1px solid #e6e8ec; + border-radius: 8px; + color: #646a73; + background: #fff; + text-align: left; +} +.customer-quick-grid button:hover { border-color: #b8b9f5; background: #fafaff; } +.customer-quick-grid strong { color: #1f2329; font-size: 24px; } +.customer-quick-grid small { overflow: hidden; color: #8b9199; text-overflow: ellipsis; white-space: nowrap; } +.customer-action-list { display: grid; grid-template-columns: 1.2fr repeat(3, 1fr); gap: 12px; margin-top: 14px; } +.customer-action-list > div, +.customer-action-list > button { padding: 16px; border: 1px solid #e6e8ec; border-radius: 8px; background: #fff; text-align: left; } +.customer-action-list > div { border: 0; padding-left: 0; } +.customer-action-list > button { display: grid; gap: 6px; color: #1f2329; } +.customer-action-list > button span { color: #8b9199; font-size: 12px; font-weight: 400; line-height: 1.5; } + +.customer-billing-panel, +.customer-message-panel { margin-top: 16px; } +.panel-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 14px; } +.panel-title h3 { color: #1f2329; font-size: 18px; } +.panel-title p:not(.eyebrow) { margin: 6px 0 0; color: #7b818a; } +.billing-total-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin: 14px 0; } +.billing-total-grid article { padding: 20px; border: 1px solid #e6e8ec; border-radius: 8px; background: #fafbfc; } +.billing-total-grid span, +.billing-total-grid small { display: block; color: #7b818a; } +.billing-total-grid strong { display: block; margin: 8px 0; color: #1f2329; font-size: 26px; } +.billing-category-list { border: 1px solid #e6e8ec; border-radius: 8px; overflow: hidden; } +.billing-category-list div { display: flex; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #eef0f3; } +.billing-category-list div:last-child { border-bottom: 0; } +.quota-note { margin-top: 14px; padding: 16px; border-left: 3px solid #5b5ce2; background: #f7f7ff; } +.quota-note p { margin: 5px 0 0; color: #646a73; line-height: 1.65; } + +.message-list, +.message-timeline { display: grid; gap: 8px; } +.message-item { display: flex; align-items: center; gap: 12px; padding: 13px 15px; border: 1px solid #e6e8ec; border-radius: 8px; background: #fff; } +.message-item > span { width: 8px; height: 8px; border-radius: 50%; background: #5b5ce2; } +.message-item--warning > span { background: #d98b12; } +.message-item strong { color: #30343b; } +.message-item p { margin: 3px 0 0; color: #8b9199; } +.message-timeline { margin-top: 18px; } +.message-timeline h4 { margin: 0 0 4px; } +.message-timeline article { display: grid; grid-template-columns: 150px 1fr; gap: 16px; padding: 12px 0; border-bottom: 1px solid #eef0f3; } +.message-timeline time, +.message-timeline p { color: #8b9199; } +.message-timeline p { margin: 3px 0 0; } + +.customer-login-card { max-width: 560px; margin: 70px auto 0; text-align: center; } +.customer-login-card .ant-card-body { padding: 42px; } +.customer-login-icon { display: grid; place-items: center; width: 52px; height: 52px; margin: 0 auto 18px; border-radius: 12px; color: #5b5ce2; background: #eeeeff; font-size: 23px; } +.customer-login-notes { display: flex; justify-content: center; gap: 22px; margin-top: 24px; color: #7b818a; font-size: 12px; } +.customer-profile { display: grid; gap: 16px; } +.customer-profile__identity { gap: 18px; } +.customer-profile__identity > div { flex: 1; } +.customer-profile__identity h2, +.customer-profile__identity p { margin: 0; } +.customer-profile__identity p { margin-top: 4px; color: #8b9199; } +.customer-membership-list { margin-top: 18px; } +.customer-membership-list > div { padding: 12px 0; border-top: 1px solid #eef0f3; } +.customer-membership-list > div > div { display: flex; justify-content: space-between; } +.customer-membership-list small { color: #8b9199; } + +@media (max-width: 1000px) { + .customer-search { display: none; } + .customer-quick-grid { grid-template-columns: repeat(2, 1fr); } + .customer-action-list { grid-template-columns: 1fr; } +} + +@media (max-width: 720px) { + .customer-header { padding-inline: 8px; } + .customer-header__brand > strong, + .customer-header__brand > span:not(.customer-brand-mark), + .customer-account-button > span, + .customer-account-button > .anticon-down { display: none; } + .customer-sider { position: fixed !important; z-index: 90; } + .customer-content { margin-left: 72px; } + .customer-page { padding: 16px 12px 32px; } + .customer-page__header { flex-direction: column; gap: 8px; } + .customer-page > .workspace { padding: 14px; } + .customer-page .workspace__header, + .customer-welcome-card { align-items: flex-start; flex-direction: column; } + .customer-page .workspace__actions { align-items: stretch; flex-direction: column; width: 100%; } + .customer-quick-grid, + .billing-total-grid { grid-template-columns: 1fr; } + .message-timeline article { grid-template-columns: 1fr; gap: 4px; } +} + +/* Ant Design enterprise console refresh */ +:root { + color: #1f2329; + background: #f5f6f8; + --console-primary: #5b5ce2; + --console-primary-soft: #ebe9ff; + --console-text: #1f2329; + --console-muted: #646a73; + --console-border: #e3e6eb; + --console-fill: #f7f8fa; + --console-white: #fff; +} + +html { background: #f5f6f8; } + +body { + color: var(--console-text); + background: #f5f6f8; +} + +button { + min-height: 34px; + border: 1px solid var(--console-primary); + border-radius: 8px; + padding: 0.42rem 0.9rem; + color: #fff; + background: var(--console-primary); + box-shadow: none; + font-size: 0.82rem; + transition: border-color 150ms ease, background 150ms ease, color 150ms ease; +} + +button:hover { + transform: none; + border-color: #7979eb; + background: #6c6de8; +} + +button:disabled { opacity: 0.55; } + +.button-secondary { + border-color: #d8dbe2; + color: #3d424a; + background: #fff; +} + +.app-layout { min-height: 100vh; } + +.app-header { + position: fixed; + inset: 0 0 auto; + z-index: 100; + display: flex; + height: 60px; + padding: 0 22px; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--console-border); + line-height: normal; + box-shadow: 0 1px 5px rgba(31, 35, 41, 0.04); +} + +.app-header__brand, +.app-header__tools { + display: flex; + align-items: center; + gap: 12px; +} + +.app-header__brand > strong { + color: #15181d; + font-size: 16px; + white-space: nowrap; +} + +.app-header__collapse.ant-btn { + width: 36px; + padding: 0; + color: #3d424a; +} + +.brand-mark, +.page-icon { + display: grid; + place-items: center; + color: #fff; + background: linear-gradient(135deg, #1677ff, #6c4cff); +} + +.brand-mark { + width: 32px; + height: 32px; + border-radius: 8px; + font-size: 17px; +} + +.header-search { width: min(300px, 24vw); } +.header-user { max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.app-layout__body { min-height: 100vh; padding-top: 60px; } + +.app-sider { + position: sticky !important; + top: 60px; + height: calc(100vh - 60px); + overflow: auto; + border-right: 1px solid var(--console-border); +} + +.app-sider .ant-layout-sider-children { + display: flex; + min-height: 100%; + flex-direction: column; +} + +.sider-product { + display: flex; + height: 58px; + padding: 0 20px; + align-items: center; + gap: 10px; + color: #252a31; + font-size: 14px; + font-weight: 600; + white-space: nowrap; +} + +.sider-product .anticon { color: var(--console-primary); font-size: 18px; } +.app-sider .ant-menu { flex: 1; border-inline-end: 0 !important; padding: 0 10px; } +.app-sider .ant-menu-item { height: 42px; margin-block: 4px; line-height: 42px; } +.app-sider .ant-menu-item-group-title { padding: 12px 14px 4px; color: #8f959e; font-size: 12px; } +.app-sider .ant-menu-item-selected { font-weight: 600; } + +.sider-meta { + display: grid; + gap: 3px; + margin: 12px; + padding: 12px; + border: 1px solid var(--console-border); + border-radius: 8px; + color: var(--console-muted); + background: #fff; + font-size: 11px; +} + +.sider-meta strong { color: #363b43; font-size: 12px; } + +.app-content { min-width: 0; background: #f5f6f8; } + +.page-container { + width: 100%; + max-width: 1560px; + margin: 0 auto; + padding: 22px 28px 40px; +} + +.page-breadcrumb { margin-bottom: 14px; color: #8f959e; font-size: 12px; } + +.hero { + align-items: center; + gap: 24px; + margin: 0 0 20px; + padding: 4px 0 2px; +} + +.hero h1 { + max-width: none; + color: #171a1f; + font-family: inherit; + font-size: 24px; + line-height: 1.4; +} + +.page-icon { + width: 36px; + height: 36px; + border-radius: 10px; + font-size: 18px; +} + +.hero__description { + max-width: 64rem; + margin: 8px 0 0 46px; + color: var(--console-muted); + font-size: 14px; + line-height: 1.65; +} + +.eyebrow { + margin: 0 0 7px; + color: var(--console-primary); + font-size: 11px; + letter-spacing: 0.12em; +} + +h1, h2, h3, h4 { color: var(--console-text); } +h2 { font-size: 20px; } +h3 { font-size: 16px; } + +.status-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + margin-top: 16px; +} + +.status-card { + min-height: 132px; + padding: 0; + border-color: var(--console-border); + border-radius: 10px; + background: #fff; + box-shadow: none; +} + +.status-card .ant-card-body { padding: 18px 20px; } +.status-card__topline { color: #7b818a; font-size: 13px; } +.status-card strong { margin-top: 19px; color: #1f2329; font-size: 22px; line-height: 1.2; } +.status-card p { overflow: hidden; margin: 8px 0 0; color: #7b818a; font-size: 12px; line-height: 1.5; text-overflow: ellipsis; white-space: nowrap; } + +.identity-panel, +.workspace-shell, +.milestone-panel, +.workspace, +.ops-workspace { + border: 1px solid var(--console-border); + border-radius: 10px; + background: #fff; + box-shadow: none; +} + +.identity-panel { + grid-template-columns: minmax(260px, 0.75fr) minmax(360px, 1.5fr); + gap: 34px; + margin-top: 14px; + padding: 22px 24px; +} + +.identity-panel__intro > p:last-child, +.identity-panel__state, +.identity-panel__login, +.identity-panel__login p { + color: var(--console-muted); +} + +.identity-panel__intro h2 { font-size: 19px; } +.identity-panel__login strong { color: var(--console-text); font-size: 16px; } +.identity-panel__account-head span { color: #8a9099; } + +.account-card { + padding: 12px 14px; + border: 1px solid var(--console-border); + border-radius: 8px; + background: var(--console-fill); +} + +.account-card > strong { color: var(--console-text); } +.membership { border-color: var(--console-border); color: #535961; } + +.workspace-shell { + margin-top: 14px; + padding: 0; + overflow: hidden; +} + +.workspace-shell__heading { + align-items: center; + padding: 18px 22px; + border-bottom: 1px solid var(--console-border); + background: #fff; +} + +.workspace-shell__heading h2 { font-size: 19px; } +.workspace-shell__heading > p { color: var(--console-muted); font-size: 12px; } +.workspace-shell__content { padding: 0; } +.workspace-shell__content > .workspace, +.workspace-shell__content > .ops-workspace { margin-top: 0; } + +.workspace, +.ops-workspace { + margin-top: 0; + padding: 22px; + border: 0; + border-radius: 0; +} + +.workspace__header, +.ops-workspace__head { align-items: center; } +.workspace__header > div:first-child > p:last-child, +.ops-workspace__head p { color: var(--console-muted); } + +.workspace__tabs { + gap: 4px; + margin: 20px 0 16px; + padding: 4px; + border: 1px solid var(--console-border); + border-radius: 8px; + background: #f7f8fa; +} + +.tab-button { + min-height: 32px; + border: 0; + border-radius: 6px; + padding: 6px 12px; + color: #5f656d; + background: transparent; +} + +.tab-button:hover { color: var(--console-primary); background: #f0efff; } +.tab-button.active { color: var(--console-primary); background: #fff; box-shadow: 0 1px 4px rgba(31, 35, 41, 0.08); } + +.workspace select, +.workspace input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), +.workspace textarea, +.ops-workspace select, +.ops-workspace input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), +.ops-workspace textarea { + min-height: 36px; + border: 1px solid #d8dbe2; + border-radius: 7px; + padding: 7px 10px; + color: #252a31; + background: #fff; + box-shadow: none; +} + +.workspace input:focus, +.workspace select:focus, +.workspace textarea:focus, +.ops-workspace input:focus, +.ops-workspace select:focus, +.ops-workspace textarea:focus { + outline: none; + border-color: #7778e6; + box-shadow: 0 0 0 2px rgba(91, 92, 226, 0.12); +} + +.workspace label, +.ops-workspace label, +.ops-config-form label, +.billing-form label, +.alert-config-form label, +.diagnostic-search label { + color: #5d636c; + font-size: 12px; + font-weight: 600; +} + +.tool-form, +.subject-create-form, +.profile-editor, +.conversation-readiness, +.panel-section, +.ops-config-form, +.ops-gate-card, +.ops-rollout-panel, +.billing-form, +.conversation-usage, +.diagnostic-search, +.alert-config-form, +.privacy-delete-confirmation, +.firmware-recovery-form, +.firmware-impact { + border-color: var(--console-border); + border-radius: 8px; + background: var(--console-fill); + box-shadow: none; +} + +.summary-card, +.device-card, +.diagnostic-card, +.billing-summary-grid article, +.alert-summary-grid article, +.ops-ledger, +.ops-version-group, +.billing-ledger, +.billing-policy-list, +.alert-ledger-grid > section, +.firmware-key-ledger, +.firmware-key-card, +.firmware-impact__metrics article, +.firmware-impact__devices article { + border-color: var(--console-border); + border-radius: 8px; + background: #fff; + box-shadow: none; +} + +.workspace__split, +.conversation-layout, +.story-layout, +.alert-queue-layout, +.billing-rate-table { + border-color: var(--console-border); + border-radius: 8px; +} + +.resource-list, +.conversation-list, +.story-sidebar, +.alert-queue, +.billing-rate-table > div, +.ops-ledger > div, +.ops-version-group > div:first-child, +.billing-ledger > div, +.billing-policy-list > div, +.alert-queue__toolbar, +.alert-ledger-grid section > div { + background: #f7f8fa; +} + +.resource-button, +.conversation-row { + border-radius: 7px; + color: #50565e; +} + +.resource-button:hover, +.conversation-row:hover { background: #eeeff2; } +.resource-button.active, +.conversation-row.active { color: var(--console-primary); background: #fff; box-shadow: 0 1px 4px rgba(31, 35, 41, 0.08); } + +.ops-billing, +.ops-alerts, +.ops-diagnostics { background: #fff; } + +.billing-summary-grid strong, +.alert-summary-grid strong, +.summary-card strong, +.diagnostic-card__head strong { color: var(--console-text); } + +.status-pill, +.event-stream-status, +.alert-severity, +.alert-detail__title b { + border-radius: 5px; +} + +.event-stream-status { color: #137a50; background: #e9f7f0; } +.event-stream-status--connecting, +.event-stream-status--reconnecting { color: #a86500; background: #fff5df; } + +.workspace__inline-error { border: 1px solid #ffd6d2; border-radius: 7px; color: #b42318; background: #fff2f0; } +.workspace__action-message { border: 1px solid #cdebdc; border-radius: 7px; color: #137a50; background: #edf9f3; } +.workspace__readonly-note, +.ops-config-form__note { border-radius: 7px; color: #646a73; background: #f2f3f5; } + +.alert-queue li.selected { box-shadow: inset 3px 0 var(--console-primary); background: #f4f3ff; } +.alert-timeline ol::before { background: #d8dbe2; } +.alert-timeline li::before { background: var(--console-primary); box-shadow: 0 0 0 1px #c8c9f6; } + +.milestone-panel { + grid-template-columns: 300px 1fr; + gap: 30px; + margin-top: 14px; + padding: 20px 24px; + color: var(--console-text); + background: #fff; +} + +.milestone-panel h2 { color: var(--console-text); font-size: 18px; } +.milestone-panel ol { gap: 10px; } +.milestone-panel li { color: #8b9199; font-size: 11px; } +.milestone-panel li span { margin-bottom: 7px; color: #a5abb3; font-size: 14px; font-weight: 700; } +.milestone-panel li.done, +.milestone-panel li.active { color: #4d535b; } +.milestone-panel li.done span { color: #1f9d68; } +.milestone-panel li.active span { color: var(--console-primary); } + +@media (max-width: 1080px) { + .header-search { display: none; } + .identity-panel { grid-template-columns: 1fr; gap: 18px; } + .milestone-panel { grid-template-columns: 1fr; } + .milestone-panel ol { grid-template-columns: repeat(4, 1fr); } +} + +@media (max-width: 760px) { + .app-header { padding: 0 10px; } + .app-header__brand > strong, + .app-header__brand .ant-tag, + .header-user, + .app-header__tools > .ant-badge { display: none; } + .app-sider { position: fixed !important; z-index: 80; } + .app-sider:not(.ant-layout-sider-collapsed) { box-shadow: 8px 0 24px rgba(31, 35, 41, 0.12); } + .app-content { margin-left: 72px; } + .page-container { padding: 16px 12px 28px; } + .hero { align-items: flex-start; gap: 14px; } + .hero__description { margin-left: 0; } + .status-grid { grid-template-columns: 1fr; } + .identity-panel { gap: 16px; padding: 18px; } + .workspace-shell__heading { align-items: flex-start; flex-direction: column; gap: 6px; } + .workspace-shell__heading > p { text-align: left; } + .workspace, + .ops-workspace { padding: 16px; } + .milestone-panel { padding: 18px; } + .milestone-panel ol { grid-template-columns: repeat(2, 1fr); } +}