Compare commits
108 Commits
dev
...
feat/local-agent
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c50c87a88 | |||
| 7e0393bf62 | |||
| a16229573b | |||
| 149c4dc89a | |||
| c66d178efe | |||
| e60679ed7b | |||
| 08c4d94187 | |||
| 39ab7d9700 | |||
| 348f1e0249 | |||
| 52988af196 | |||
| aba88193d8 | |||
| e9a8b9b4c6 | |||
| b8095688ba | |||
| 4153046e0e | |||
| a4b9897eaf | |||
| fe5c4215f7 | |||
| 92341be32c | |||
| ef2b815bd7 | |||
| 2a743a33f6 | |||
| 24351014e0 | |||
| 99ce07c1b0 | |||
| fb3685532c | |||
| 95f3781076 | |||
| a4669a0d82 | |||
| aec7ad949c | |||
| b6927247da | |||
| 6b31856551 | |||
| 9ae06229b8 | |||
| 08e6425fec | |||
| d6c1a787f4 | |||
| c5397438d4 | |||
| 8283bc542f | |||
| bb9c73a9b7 | |||
| b718bdbb21 | |||
| 5b871bc363 | |||
| 9458bf21f3 | |||
| 6fe0a58f1b | |||
| 302e1ebaff | |||
| 4ee4a91a51 | |||
| b526b21dee | |||
| 15f5a85612 | |||
| a28ae49b6a | |||
| 618184689e | |||
| 644b635d73 | |||
| f16f63284f | |||
| 9b153871eb | |||
| a42f30239f | |||
| ebbeed90e9 | |||
| da9c76f073 | |||
| 4500335da7 | |||
| ac38d5e663 | |||
| ce7cca657e | |||
| bd829cfecb | |||
| 68608c1592 | |||
| 83269e067a | |||
| 9e2ff6007f | |||
| 0f2afdaac1 | |||
| cbd0a96ce7 | |||
| a1c68ec6b2 | |||
| 04b5677605 | |||
| 883540bd7e | |||
| 07955ddf07 | |||
| a8a497b4ce | |||
| b1fea23a0c | |||
| a34d7045f1 | |||
| caa2602131 | |||
| f8f7359723 | |||
| 80d4aaf4ba | |||
| 466959d3c3 | |||
| 518cddbd1c | |||
| 7c8c723245 | |||
| b26fe21408 | |||
| 00ad6c8ac0 | |||
| e4ec74893f | |||
| 84463394d4 | |||
| bfd0d74c34 | |||
| c1420b5665 | |||
| 2c9ecc833e | |||
| 70b0419387 | |||
| 34500e66a7 | |||
| 79b110afd0 | |||
| ac4fd00e28 | |||
| c3151d1078 | |||
| d189dd967a | |||
| 0d2929931f | |||
| 197531ea33 | |||
| 6de1b3f6b2 | |||
| 373167b705 | |||
| de315450a4 | |||
| 55b8a697aa | |||
| 09ac03b758 | |||
| 2d14245086 | |||
| 84a32023ee | |||
| 7cacfdb5a3 | |||
| e350fa9595 | |||
| 05b66399f9 | |||
| da0d964054 | |||
| 2599936777 | |||
| 6b45adbbd2 | |||
| 2e02a12260 | |||
| 28a1543249 | |||
| 3a4e1d53a5 | |||
| 86e79d76ac | |||
| 1beef44625 | |||
| 5e5f6e9610 | |||
| ec9431bfa4 | |||
| 1c7e8e1ea9 | |||
| f0f9eccf6f |
@@ -23,6 +23,11 @@ POSTGRES_PASSWORD=
|
||||
# 图数据库密码(Neo4j,知识图谱)。
|
||||
NEO4J_PASSWORD=
|
||||
|
||||
# 全文(bleve)索引落盘目录。留空则用相对路径 .data/bleve —— 相对的是 mcp-go 的**启动目录**,
|
||||
# 从仓库根起和从 sundynix-mcp-go/ 起会写出两份互不相干的索引(本地排查全文召回时最容易被这个坑到)。
|
||||
# 本地开发建议显式写成绝对路径;容器部署已在 compose 里固定为 /data/bleve + 持久卷。
|
||||
BLEVE_PATH=
|
||||
|
||||
# ── 必填:管理员 ─────────────────────────────────────────────
|
||||
# 管理员用户 ID 白名单(逗号分隔)。生产模式下只有这些用户能进 /admin 控制台。
|
||||
# 首次部署:先留空起服务 → 注册第一个账号 → 从返回的 user id 填进来 → 重启 gateway。
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: deploy-132
|
||||
|
||||
# 只认 main(含 PR 合并入 main)。照 sundynix-site 的模式:
|
||||
# runner(128) 构建 4 个应用镜像 → docker save 打包 → scp 镜像包 + compose 到 132
|
||||
# → 132 docker load + docker compose up -d(用预构建镜像,不在 132 上 build)。
|
||||
# 于是 132 上只有 compose + .env(+ 临时镜像包),没有源码。
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REMOTE_DIR: /home/workspace/sundynix-agentix
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest # 须匹配 128 上已注册 runner 的标签;不符请改成你的标签
|
||||
steps:
|
||||
# 不用 actions/checkout(它要去 github.com 下载 action,内网 runner 连不上会 EOF);
|
||||
# 直接从内网 Gitea 用工作流自带 token 拉本仓库当前分支。init+fetch 不怕工作目录非空。
|
||||
- name: 检出代码(内网 Gitea)
|
||||
run: |
|
||||
git init -q .
|
||||
git remote add origin "https://token:${{ github.token }}@git.sundynix.cn/${{ github.repository }}.git"
|
||||
git fetch -q --depth 1 origin "${{ github.ref_name }}"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
- name: 构建 4 个应用镜像(在 runner 上)
|
||||
run: |
|
||||
# gateway/dispatcher/mcp-go 的构建上下文是仓库根(Go replace ../sundynix-shared,
|
||||
# 且 gateway 还要 COPY sundynix-admin/ 构建内嵌前端);mcp-py 上下文是自己的目录。
|
||||
docker build -f sundynix-gateway/Dockerfile -t sundynix/gateway:latest .
|
||||
docker build -f sundynix-dispatcher/Dockerfile -t sundynix/dispatcher:latest .
|
||||
docker build -f sundynix-mcp-go/Dockerfile -t sundynix/mcp-go:latest .
|
||||
docker build -f sundynix-mcp-py/Dockerfile -t sundynix/mcp-py:latest sundynix-mcp-py
|
||||
|
||||
- name: 导出镜像为压缩包
|
||||
run: |
|
||||
docker save \
|
||||
sundynix/gateway:latest sundynix/dispatcher:latest \
|
||||
sundynix/mcp-go:latest sundynix/mcp-py:latest \
|
||||
| gzip > images.tar.gz
|
||||
|
||||
- name: 安装 ssh 工具
|
||||
run: |
|
||||
if command -v apt-get >/dev/null; then
|
||||
apt-get update && apt-get install -y sshpass openssh-client
|
||||
elif command -v apk >/dev/null; then
|
||||
apk add --no-cache sshpass openssh-client
|
||||
fi
|
||||
|
||||
- name: 传镜像包与 compose 到 132
|
||||
env:
|
||||
SSHPASS: ${{ secrets.DEPLOY_PASSWORD }}
|
||||
run: |
|
||||
H="${{ secrets.DEPLOY_HOST }}"
|
||||
U="${{ secrets.DEPLOY_USER }}"
|
||||
# 只送两样:镜像包 + compose 文件,都落到 $REMOTE_DIR 根下(与 .env 同级,扁平布局)。
|
||||
# 132 上的 .env 由你手工预放在 $REMOTE_DIR/.env,绝不覆盖。
|
||||
sshpass -e ssh -o StrictHostKeyChecking=no "$U@$H" "mkdir -p $REMOTE_DIR"
|
||||
sshpass -e scp -o StrictHostKeyChecking=no images.tar.gz "$U@$H:$REMOTE_DIR/"
|
||||
sshpass -e scp -o StrictHostKeyChecking=no \
|
||||
deploy/132-app/docker-compose.yml "$U@$H:$REMOTE_DIR/docker-compose.yml"
|
||||
|
||||
- name: 132 加载镜像并起服务
|
||||
env:
|
||||
SSHPASS: ${{ secrets.DEPLOY_PASSWORD }}
|
||||
run: |
|
||||
H="${{ secrets.DEPLOY_HOST }}"
|
||||
U="${{ secrets.DEPLOY_USER }}"
|
||||
sshpass -e ssh -o StrictHostKeyChecking=no "$U@$H" "\
|
||||
cd $REMOTE_DIR && \
|
||||
gunzip -c images.tar.gz | docker load && \
|
||||
rm -f images.tar.gz && \
|
||||
docker compose up -d --no-build && \
|
||||
docker image prune -f"
|
||||
|
||||
- name: 健康检查(gateway :3000/healthz)
|
||||
env:
|
||||
SSHPASS: ${{ secrets.DEPLOY_PASSWORD }}
|
||||
run: |
|
||||
H="${{ secrets.DEPLOY_HOST }}"
|
||||
U="${{ secrets.DEPLOY_USER }}"
|
||||
sshpass -e ssh -o StrictHostKeyChecking=no "$U@$H" "\
|
||||
for i in \$(seq 1 20); do \
|
||||
curl -sf http://127.0.0.1:3000/healthz && echo ' ✅ 健康' && exit 0; \
|
||||
sleep 3; \
|
||||
done; \
|
||||
echo '❌ 健康检查失败'; \
|
||||
cd $REMOTE_DIR && docker compose logs --tail 50 gateway; exit 1"
|
||||
@@ -16,6 +16,8 @@ concurrency:
|
||||
jobs:
|
||||
go:
|
||||
name: Go · build + vet + test
|
||||
# 本 CI 只在 GitHub 跑;Gitea(git.sundynix.cn)只跑 .gitea/workflows/deploy.yml,不误跑这套。
|
||||
if: ${{ !contains(github.server_url, 'sundynix.cn') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -23,17 +25,71 @@ jobs:
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: "**/go.sum"
|
||||
- name: build + vet + test(4 模块;bus 用内嵌 NATS,无需外部服务)
|
||||
- name: build + vet + test -race(4 模块;bus 用内嵌 NATS,无需外部服务)
|
||||
run: |
|
||||
set -e
|
||||
for m in sundynix-shared sundynix-gateway sundynix-dispatcher sundynix-mcp-go; do
|
||||
echo "::group::$m"
|
||||
(cd "$m" && go build ./... && go vet ./... && go test ./...)
|
||||
# -race:数据竞争一票否决(4 模块本地已验证 race-clean)。ubuntu runner 自带 gcc,CGO 可用。
|
||||
(cd "$m" && go build ./... && go vet ./... && go test -race ./...)
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
# golangci-lint:只卡「新问题」——存量约 42 处(未检 Close/死代码)单独消化,不拿存量红门。
|
||||
# 仅在 PR 跑:dev→main 的 PR 是唯一合入口,据 base 算 diff;push 到 main 不重复跑。
|
||||
lint:
|
||||
name: Go · golangci-lint (new issues)
|
||||
if: ${{ github.event_name == 'pull_request' && !contains(github.server_url, 'sundynix.cn') }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
module: [sundynix-shared, sundynix-gateway, sundynix-dispatcher, sundynix-mcp-go]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # only-new-issues 要据 base 分支算 diff,需完整历史
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: "**/go.sum"
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
working-directory: ${{ matrix.module }}
|
||||
only-new-issues: true
|
||||
|
||||
# 安全扫描:govulncheck(依赖 CVE) advisory —— stdlib/nats CVE 要靠 toolchain/依赖升级,
|
||||
# 不拿它红门,只做可见性;gitleaks(密钥泄漏)在 PR 上扫 diff,硬拦提交密钥。
|
||||
security:
|
||||
name: Security · govulncheck + gitleaks
|
||||
if: ${{ !contains(github.server_url, 'sundynix.cn') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: "**/go.sum"
|
||||
- name: govulncheck(advisory:报告不阻断)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
for m in sundynix-shared sundynix-gateway sundynix-dispatcher sundynix-mcp-go; do
|
||||
echo "::group::govulncheck $m"
|
||||
(cd "$m" && govulncheck ./...) || true
|
||||
echo "::endgroup::"
|
||||
done
|
||||
- name: gitleaks(扫提交历史,命中即失败)
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
web:
|
||||
name: Frontend · tsc + vitest
|
||||
if: ${{ !contains(github.server_url, 'sundynix.cn') }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -59,6 +115,7 @@ jobs:
|
||||
# go:embed frontend/dist 要求先出前端产物,故先 npm build。
|
||||
desktop:
|
||||
name: Desktop · go build + test (macOS)
|
||||
if: ${{ !contains(github.server_url, 'sundynix.cn') }}
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -87,6 +144,7 @@ jobs:
|
||||
|
||||
py:
|
||||
name: mcp-py · sandbox guard
|
||||
if: ${{ !contains(github.server_url, 'sundynix.cn') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -17,6 +17,8 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
name: 构建 ${{ matrix.label }}
|
||||
# 只在 GitHub 跑;Gitea(git.sundynix.cn)不跑桌面端发布构建。
|
||||
if: ${{ !contains(github.server_url, 'sundynix.cn') }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# golangci-lint v2 配置。默认(standard)集:errcheck / govet / ineffassign / staticcheck / unused。
|
||||
# 引入策略:CI 用 only-new-issues —— 新代码必须干净,存量约 42 处(多为未检 Close/死代码)
|
||||
# 单独消化,不拿存量红整个门。本地全量跑:`golangci-lint run ./...`(在各模块目录)。
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
default: standard
|
||||
|
||||
exclusions:
|
||||
rules:
|
||||
# 测试里 defer x.Close() / 建数据的辅助调用不检返回值属常规,放过 errcheck。
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- errcheck
|
||||
+886
-212
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -79,7 +79,7 @@ RBAC 未做,暂以单管理员账号代理;概览口径必须是**系统级*
|
||||
其它=按序备用),无需新 admin UI;ModelConfig 加 Fallbacks 骑主配置下发(不改 bus 签名)
|
||||
- [x] 主调用失败/超时自动切备:`failoverModel`(model.ToolCallingChatModel)按序切,compose/ReAct/Chat
|
||||
全路径透明;调用方取消不切;备用 api_key 一并解密
|
||||
- [ ] 按任务类型/成本选模(可选,v1 跳过);熔断器联动(failover 在 model 层、熔断在编排层,互补未整合)
|
||||
- [ ] 按任务类型/成本选模(可选,v1 跳过);熔断器联动(failover 在 model 层、熔断在编排层)—— ✅ 已整合,见 T4.E「熔断器接回 failover」
|
||||
- 验收 ✅ live:active=死 ollama 主 + deepseek 备 → 任务连主拒连→自动切 deepseek→4s 完成。
|
||||
- v1 局限:Stream 仅建流同步报错时切(已回流 token 的中途失败不切)。
|
||||
|
||||
@@ -215,7 +215,6 @@ RBAC 未做,暂以单管理员账号代理;概览口径必须是**系统级*
|
||||
|
||||
### [x] T4.E 编排引擎边角 ✅(5/5 全清)
|
||||
- [x] Map 节点错误传播 + 汇总 ✅ —— writeSection 返回 (body,err) 仅真·LLM 失败计错(预算/无模型是主动降级不算);writeSections 汇总失败数、失败项 Body 带可见标记;mapNode 全失败→置 b.fatalErr(判 failed 非静默 done-空)、部分失败→trace+流式告警。report handleReport 同步提示部分失败。3 单测(全失败/部分精确计数/mapNode 置 fatalErr)。
|
||||
- [ ] 熔断器接回 failover(harness/circuitbreaker.go 与 llm/failover.go 未接合,可能长卡备用)| M
|
||||
- [x] coordinator 专家超时 ✅ —— specialistTool 加 timeout(默认 specialistTimeout=3min),InvokableRun 包 WithTimeout;超时作为"观察"跳过该专家(err=nil,lead 据其余综合,不中断协调)。2 单测(超时~50ms 跳过 / 正常不受影响)。
|
||||
- [x] Branch else 兜底 ✅ —— branchNode 识别 default/else 边,主选择(true/false)未命中时走它;trace 明确区分「走 default / 未匹配收口结束(END) / 正常选路」,杜绝静默落 END 被误当 bug。3 断言单测(default 命中/条件真走 true/无 default 仍空)。compose 层原有 len==0→END 收口保留。
|
||||
- [x] DSL 拓扑校验 ✅ —— gateway ParseAndAssemble 加 validateTopology:拦重复/空 id + 悬挂边(source/target 指向不存在节点);对空图/报告任务{topic}/非标准载荷宽松放过不误伤。单测(合法/重复id/空id/悬挂边×2/空端点/空图放过) + live(悬挂边→422,合法→202)。节点-工具映射校验需工具注册表,留后续。
|
||||
|
||||
+9
-3
@@ -9,7 +9,7 @@
|
||||
|
||||
- [x] **Phase A · 地基**:`llm.Pool` 换 Eino ChatModel 组件(commit d84b1ec,验收通过)
|
||||
- [x] **Phase B · 质变**:MCP 工具→`InvokableTool` + ReAct agent(模型自主调工具,验收 7/7 命中)
|
||||
- [x] **Phase C · 编排归一**:✅ 全图 `DSL→compose.Graph` 编译器(全节点 + branch + DAG 并行调度)+ callbacks→ExecEvent 归一,等价回归通过(EINO_COMPOSE 灰度开关,默认关;过渡期后 graph.go 退役)
|
||||
- [x] **Phase C · 编排归一**:✅ 全图 `DSL→compose.Graph` 编译器(全节点 + branch + DAG 并行调度)+ callbacks→ExecEvent 归一,等价回归通过;灰度期已过、graph.go 退役、compose 为唯一引擎(**例外**:报告 intent 仍走独立 `handleReport` 流水线,刻意未 compose 化,理由见 Phase C 改动清单)
|
||||
- [~] **Phase D · 状态化执行**:✅ 任务生命周期 FSM / ✅ HITL 人工审批中断(审批节点暂停→waiting→批准回 running / 拒绝→rejected,NATS 决定回传 + AckWait 续租,全栈含 Studio 审批节点 + 运行抽屉批准条)/ ⬜ 多智能体(按场景)
|
||||
|
||||
**组件化补完(A)**:检索 → `ragRetriever`(`components/retriever.Retriever`,`eino_components.go`);提示词 → `buildMessages` 改用 `prompt.FromMessages`+`MessagesPlaceholder`;工具 → `mcpTool`(`InvokableTool`,模型自主调用)。至此终态架构 8 层中 模型/工具/检索/提示词/编排/智能体(单)/可观测 均已 Eino 组件化;剩 人机交互(中断恢复,按场景)。
|
||||
@@ -150,7 +150,13 @@ github.com/cloudwego/eino-ext/... # ⚠️ 官方组件实现(open
|
||||
- `sundynix-dispatcher/internal/dsl/compile.go`(兑现文件头那句 TODO:"演进为 compose.NewGraph 的完整多节点编译")
|
||||
- 写 `DSL Flow → compose.Graph` 编译器:节点映射为 ChatModel / ToolNode / Retriever / `Lambda`;连线 → 边;branch 真假 → `compose` 分支;map → 并发分支。
|
||||
- `callbacks`:实现 handler 把 Eino 回调桥接到现有 `ExecEvent`(节点 start/end/error + 耗时),统一可观测,不重复造。
|
||||
- `report.go`:报告多步流水线改用 compose(章节并行 + 汇聚天然契合)。
|
||||
- ~~`report.go`:报告多步流水线改用 compose(章节并行 + 汇聚天然契合)。~~
|
||||
**实际未迁,刻意保留为独立路径(诚实修正)**:`orchestrator.go` 按 `intent==IntentReport`
|
||||
分流到 `handleReport`,用自研 `writeSections`(WaitGroup + 信号量并发 + 部分失败可见标记)做
|
||||
规划→分章并行→渲染,**不经 compose.Graph**。原因:① 报告是固定形状的专用流水线(规划-map-渲染),
|
||||
并行能力已由 `writeSections` 拿到,compose 化不带来用户可见收益;② 硬迁移会拿一个有单测背书、
|
||||
已 live 验证的稳定功能去冒回归风险。故报告是 compose 之外的第二条编排路径,通用 DSL 图仍全走 compose。
|
||||
真要归一(去掉第二条路径)另立任务评估,非本阶段承诺。
|
||||
- 检索节点 → `components/retriever`(包 mcp-go RAG);提示词 → `components/prompt.ChatTemplate`。
|
||||
- **对齐策略**:compose 版与 graph.go 版**并存**,用 `graph_test.go`/`integration_test.go` 做等价回归;全绿且灰度通过后,`graph.go` 才退役。
|
||||
|
||||
@@ -165,7 +171,7 @@ github.com/cloudwego/eino-ext/... # ⚠️ 官方组件实现(open
|
||||
主题:把"执行"从一次性 DAG 升级为**可持久化、可恢复的状态机**。三件事同一条线,一起做。
|
||||
|
||||
- **任务生命周期 FSM** ✅ 已完成:`submitted → running → done / failed / timeout` 显式状态机。
|
||||
- dispatcher 经新主题 `sundynix.tasks.status` 回写(`TaskStatusEvent`):进入执行→running、收尾→done/failed、整体超时上限 `taskExecTimeout=3min`→timeout;网关 `SubscribeTaskStatus` 落 PG(`Task.Status/Detail`)。
|
||||
- dispatcher 经新主题 `sundynix.status.task` 回写(`TaskStatusEvent`):进入执行→running、收尾→done/failed、整体超时上限 `taskExecTimeout=3min`→timeout;网关 `SubscribeTaskStatus` 落 PG(`Task.Status/Detail`)。
|
||||
- UI 轮询:`GET /api/v1/tasks/:id` 返回 `{status, detail}`。
|
||||
- 验收:submitted→running→done 实测流转、PG 持久化、3min 超时兜底——根治"卡运行中看不出来"。
|
||||
- 桌面端轮询接线仍可补(当前后端 + 端点已就绪)。
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# sundynix-agentix · JARVIS 大脑中枢设计文档
|
||||
|
||||
> 版本:2026-07-24
|
||||
> 定位:把 JARVIS 从「任务系统的语音皮肤」升级为「平台的常驻大脑中枢」——编排、报告、知识库、本地文件、App 界面都只是它手里的工具(Claw/OpenClaw 式全局控制感)。
|
||||
> 配套文档:`LOCAL_AGENT_DESIGN.md`(本地的手,双平面)、`VOICE_DESIGN.md`(耳朵和嘴)、`ARCHITECTURE_DESIGN.md`(总览)
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话结论
|
||||
|
||||
> **角色反转:把编排引擎从"执行主体"降级为 JARVIS 的一个工具(tool),把 JARVIS 从"任务的语音入口"升级为持有全部工具的常驻 ReAct 大脑。**
|
||||
>
|
||||
> 这是接线工程,不是重构工程——推理循环(react_agent.go)、语音通道、会话记忆、人格、工具协议、HITL 全部现成,缺的只有三条线:平台工具族、客户端动作通道、主动播报器。
|
||||
|
||||
## 1. 现状 vs 目标
|
||||
|
||||
**现状**:用户说话 → 每句话变一个 task → 编排引擎执行 → 念结果。JARVIS 从属于任务系统。
|
||||
**目标**:JARVIS = 常驻中枢 agent。你说「看看部署状态」,它自己决定是查任务、跑命令、还是调编排;任务跑完它主动开口告诉你。任务系统从属于它。
|
||||
|
||||
### 已就绪的器官(盘点)
|
||||
|
||||
| 中枢需要的 | 已有的 | 位置 |
|
||||
|---|---|---|
|
||||
| 推理循环 | ReAct 循环 + `list_tools` 动态工具发现(加工具零改循环) | `dispatcher/internal/eino/react_agent.go` |
|
||||
| 耳朵/嘴 | 语音 WS 全双工 + 连续对话(VAD 断句/自动重听) | `gateway/internal/handler/voice.go` |
|
||||
| 短期记忆 | sessionID → fetchHistory/appendHistory 闭环 | orchestrator.go |
|
||||
| 人格 | 每用户 UserJarvis(名字/人设/自带豆包) | `store/user_jarvis.go` |
|
||||
| 工具协议 | 注册表 + `agent_exposed` + inject 参数 | `mcp-go/internal/mcp/gateway.go` |
|
||||
| 提交关卡 | `preflightCore`/`launchCore`(无 gin,语音已复用) | `handler/task_handler.go` |
|
||||
| 安全闸 | HITL 审批 + 预算护栏 + 审计 | 全栈 |
|
||||
| 本地的手 | 双平面设计(档 A/B) | `LOCAL_AGENT_DESIGN.md` |
|
||||
|
||||
## 2. 三条缺的线
|
||||
|
||||
### 2.1 平台工具族 `platform_*`(让 JARVIS 操作平台本身)
|
||||
|
||||
新增第三个工具提供方:**gateway 自己**。理由:平台操作的权威(提交关卡、归属校验、计费)都在 gateway,工具就该长在权威所在地——而不是让 mcp-go 绕一圈回来调 gateway。
|
||||
|
||||
- 协议:`contract` 加 `SubjectToolsPlatform = "sundynix.tools.platform"`(+All/Queue/ToolSubjectPlatform),与 go/py 同族。
|
||||
- gateway 用 `bus.ServeTool` 起一个迷你注册表(照抄 mcp-go 的 toolDef 模式:唯一事实源,dispatch 与 list_tools 共用)。
|
||||
- dispatcher `agentTools` 多发现一路:`discoverTools(contract.ToolSubjectPlatform, …)`——**一行**。
|
||||
|
||||
**首批工具**(全部 inject `user_id`,严格归属校验):
|
||||
|
||||
| 工具 | 说明 |
|
||||
|---|---|
|
||||
| `platform_recent_tasks` | 最近任务列表(状态/主题/耗时),答"我刚才那些任务怎么样了" |
|
||||
| `platform_task_status(task_id)` | 单任务状态 + 输出摘要(TaskOwner 校验归属,别人的任务不可见) |
|
||||
| `platform_gen_report(topic, kb?)` | 提交报告任务——**必须走 preflightCore/launchCore 同一关卡**(预算/积分/落库),返回 task_id |
|
||||
|
||||
后续扩展(同一注册表加条目即可):`platform_run_orchestration(graph_id)`(待编排图有服务端存储后)、`platform_search_kb`、`platform_usage_today`。
|
||||
|
||||
### 2.2 客户端动作通道(让 JARVIS 控制界面与本机)
|
||||
|
||||
语音 WS 加一类下行消息:`ServerMsg{type:"action", action:"navigate", view:"runs", task_id:…}`。
|
||||
|
||||
- 客户端 VoiceClient 加 `onAction` 回调 → App 层 dispatcher 执行(切页面/打开面板/聚焦任务)。HUD 从显示器变驾驶舱。
|
||||
- **`local.*`(读文件/跑命令)走同一条通道**下发到 Wails Go host 执行——即 `LOCAL_AGENT_DESIGN.md` 档 A 的落点,两个设计在此汇合。
|
||||
- 安全:动作白名单(navigate 等 UI 动作低危先行);`local.*` 按该文档 §8 的门禁(cwd 沙箱/HITL/仅桌面端)。
|
||||
|
||||
### 2.3 主动播报器(中枢与工具的本质区别)
|
||||
|
||||
它会先开口:任务跑完/失败、部署红了 → JARVIS 主动说"先生,报告好了"。
|
||||
|
||||
- 零件全齐:JetStream 有任务终态事件(status 回写已持久化)、语音 WS 常连、TTS 现成。
|
||||
- 新增一个**值班推送器**:订阅当前语音会话用户的任务终态 → 判断值不值得开口(本会话触发的任务优先;免打扰规则)→ 文本 → 现有 TTS 通道播报 + 对话流插一条。
|
||||
- 与连续对话状态机合流:播报只在 ready/listening 空闲时插入,说话中排队,别抢麦。
|
||||
|
||||
## 3. 安全边界(权力越大越要收紧)
|
||||
|
||||
1. 每次工具调用过 ToolPolicy;破坏性操作(exec/写文件/会烧钱的编排提交)过 HITL。
|
||||
2. `platform_*` 一律 inject user_id + 服务端归属校验,模型不可指定别人的资源。
|
||||
3. `platform_gen_report` 等提交类工具走 preflightCore(预算/积分硬拦截),JARVIS 不能绕过计费花钱。
|
||||
4. `local.*` 只对有在线本地 runner 的桌面会话开放(见 LOCAL_AGENT_DESIGN §8)。
|
||||
5. 全部动作落审计/轨迹(mcpTool 已自动落 ExecEvent span)。
|
||||
|
||||
## 4. 落地阶段
|
||||
|
||||
| 阶段 | 内容 | 改动面 | 状态 |
|
||||
|---|---|---|---|
|
||||
| **P1 平台工具族** | contract 加 platform subject;gateway 迷你工具注册表(recent_tasks/task_status/gen_report);dispatcher 多发现一路 | shared 一处 / gateway 新文件 / dispatcher 一行 | **✅ 2026-07-24 live 验收全过**(真实数据/派报告走关卡/越权拒绝/积分硬拦截实测) |
|
||||
| **P2 动作通道** | ServerMsg action + 客户端 onAction + navigate 白名单(platform_open_view → NATS 语音事件 → WS) | protocol/voice.ts/App | **✅ 2026-07-24 live 验通**(“打开运行页”→界面真切) |
|
||||
| **P3 主动播报** | 任务终态 → announce 语音事件 → 对话流 + TTS(正朗读则排队不抢麦;prev 比对挡重投重播) | main 状态消费 + voice_event.go | **✅ 2026-07-24 live 验通**(报告跑完 JARVIS 主动开口) |
|
||||
| **P4 本地的手** | LOCAL_AGENT_DESIGN 档 A:`local_list_dir/read_file`(platform 注册表)→ NATS `local.exec.<uid>` → gateway runner WS → 桌面 Go host 沙箱执行;桌面设置「本地文件访问」显式开关;`cmd/localsim` 联调工具 | gateway 2 新文件 / desktop runner+设置 | **✅ 2026-07-24 live 验通**(真读文件+离线降级+沙箱逃逸单测全拦) |
|
||||
| **P4.2 能动的手** | `local_write_file` / `local_exec`:桌面端**原生确认框逐次审批**(默认拒绝/60s 超时即拒)+ 硬黑名单(删库/提权/管道下载执行/写系统路径/装开机项/摸凭据,点同意也不执行)+ 独立开关;工具自报 `timeout_sec` 突破 dispatcher 默认 3s(审批要等人) | desktop `localexec.go` / platform 注册表 / dispatcher 超时机制 | **✅ 2026-07-25 live 验通**(写脚本+执行链式两步,文件真落磁盘;黑名单 20 条单测全拦) |
|
||||
| **P4.3 调度** | `sundynix_schedule` 表 + leader 锁 ticker(30s 扫)+ `platform_schedule_create/list/cancel`;存自然语言指令,到点走语音同一条关卡执行并主动播报;先推进后提交防重复烧钱,停机错过的**不补跑** | store/schedule.go、handler/schedule_tick.go | **✅ 2026-07-25 live 验通**(到点自动触发→自主调 local_exec→真跑命令→出结果) |
|
||||
| **P5 常驻会话** | 每句一 task → 长驻 companion session(现靠 history 串联已够用,不急) | dispatcher | 暂缓 |
|
||||
|
||||
每步独立可用:P1 做完立刻能"嘴遥控平台"(问任务、派报告)。
|
||||
|
||||
## 5. P1 验收
|
||||
|
||||
- 语音问「我最近的任务都什么状态」→ JARVIS 调 `platform_recent_tasks` 报出真实列表。
|
||||
- 语音说「帮我生成一份 XX 报告」→ JARVIS 调 `platform_gen_report` → 回 task_id →「已开跑」;任务在运行页可见、计费正常扣。
|
||||
- 越权测试:构造 task_id 查他人任务 → 拒绝。
|
||||
@@ -0,0 +1,269 @@
|
||||
# sundynix-agentix · 本地 Agent 执行设计文档
|
||||
|
||||
> 版本:2026-07-23
|
||||
> 定位:在**保留中心化治理**的前提下,让 Agent 能像 Claude Code 那样**操作用户本机的文件、执行命令**——把"大脑/治理"留服务端,把"手/执行"下放到客户端。
|
||||
> 配套文档:`ARCHITECTURE_DESIGN.md`(架构总览)、`EINO_ADOPTION.md`(编排引擎)、`VOICE_DESIGN.md`(语音)、`SAAS_DESIGN.md`(多租户治理)、`DEPTH_ROADMAP.md`(路线图)
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话结论
|
||||
|
||||
> **中心化 ≠ 所有东西都在服务端执行。中心化 = 单一的控制/治理/观测平面。**
|
||||
>
|
||||
> 正确形态是**双平面**:**执行平面**下沉到客户端(拿 Claude Code 的手感),**控制平面**留服务端(守住计费/观测/记忆/策略)。这不是把已否决的"编排引擎下沉客户端"翻案——那否的是 DAG 工作流引擎;这里新增的是**另一个产品面**:一个 Claude-Code 式的本地操作 Agent。两者并存。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
### 1.1 要做什么
|
||||
|
||||
让用户在桌面端说/输入「把这个目录里的 CSV 都转成 JSON」「读一下这个日志找出报错」「跑一下测试」,Agent 就能**在用户自己的机器上**读写文件、执行命令,边干边把过程流式展示,且全程仍受服务端的预算/审批/审计管控。
|
||||
|
||||
```
|
||||
用户意图 → Agent 决策(LLM) → 本地工具执行(读/写/命令) → 观察 → 再决策 → … → 结果
|
||||
↑ 经服务端 LLM 代理(记忆/计费/护栏) ↑ 在本机瞬时执行(无额外过网)
|
||||
```
|
||||
|
||||
### 1.2 核心原则
|
||||
|
||||
1. **执行在本地,控制在中心**——工具在本机跑(手感),LLM 访问/记忆/计费/观测/策略全经服务端咽喉。
|
||||
2. **复用已有接口**——`ToolCaller`、`ToolCallingModel`、NATS req/reply、HITL 审批、prompt 注册表、预算护栏、secrets 托管,尽量不重造。
|
||||
3. **安全是成败手,不是可选项**——LLM 驱动在用户机器上跑命令是最危险的面,默认收紧:路径沙箱 + 命令白名单 + HITL 审批 + 仅桌面端可用。
|
||||
4. **可降级**——本地 runner 不在线时,明确报"本地执行器不可用",而非卡死;只读能力尽量保留。
|
||||
|
||||
### 1.3 不做什么(本期)
|
||||
|
||||
- ❌ 客户端本地跑大模型(推理一律经服务端 LLM 代理出去,这是中心化的支点)。
|
||||
- ❌ 让 Web 端 / 共享工作区操作"别人的机器"(`local.*` 工具仅对有在线本地 runner 的桌面会话开放)。
|
||||
- ❌ 客户端离线自治(无网即无推理,换取治理不失控)。
|
||||
- ❌ 把 DAG 工作流引擎搬到客户端(那是另一条产品线,维持服务端)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 与 Claude Code 的对比
|
||||
|
||||
### 2.1 先破除一个误解
|
||||
|
||||
Claude Code 的"手感"**不来自本地跑大模型**——它的 LLM 也在远端(Anthropic API),每一轮决策同样要过网。手感来自三点:
|
||||
|
||||
1. **紧凑的本地工具循环**:读→改→跑→看输出→再改,一秒好几轮,**内环不过网**。
|
||||
2. **全程流式**:思考、工具输出实时可见。
|
||||
3. **工作目录内的自主感**:锁定一个 cwd,在里面自由操作。
|
||||
|
||||
**所以"本地 vs 中心化"是假二选一。** 真正的问题是两个平面各放哪。而 Claude Code 自己就是混合的:**执行本地 + LLM 远端**。
|
||||
|
||||
### 2.2 逐项对比
|
||||
|
||||
| 维度 | Claude Code | sundynix-agentix(现状) | 本设计(双平面) |
|
||||
|---|---|---|---|
|
||||
| Agent 循环位置 | 本地 CLI 进程 | 服务端 dispatcher/Eino | **档 B:客户端 Go host** / 档 A:仍服务端 |
|
||||
| LLM 访问 | 直连 Anthropic(用户持 key) | dispatcher 直连 LLM 池 | **经 gateway LLM 代理**(key 服务端托管) |
|
||||
| 工具执行 | 本地、瞬时 | NATS→mcp-go(服务端) | **本地 runner,瞬时** |
|
||||
| 每工具是否过网 | 否(内环本地) | 是(server↔mcp) | **档 B 否 / 档 A 一跳(server↔client)** |
|
||||
| 多租户/隔离 | ❌ 无 | ✅ 已建 | ✅ 保留 |
|
||||
| 计费/计量 | ❌ 无 | ✅ 积分/成本/rollup | ✅ 经 LLM 代理 + 事件上抛 |
|
||||
| 集中可观测 | ❌ 本地日志 | ✅ OTel 全链路 | ✅ 客户端事件上抛服务端 |
|
||||
| Prompt/模型治理 | 本地配置 | ✅ 注册表 + 热切换 | ✅ 保留 |
|
||||
| 权限/审批 | 本地 permission 模式 | ✅ HITL 审批节点 | ✅ 策略下推 + 本地快审批 |
|
||||
| 工作目录沙箱 | ✅ cwd | 无(服务端无本地盘概念) | ✅ 新增本地 cwd 沙箱 |
|
||||
| 团队/共享知识 | ❌ | ✅ Space/KB 共享 | ✅ 保留 |
|
||||
| 离线可用 | 部分(无 LLM 则不可) | ❌ | ❌(刻意,换治理) |
|
||||
|
||||
### 2.3 结论:我们要成为的东西
|
||||
|
||||
> **"Claude Code + 治理层"**——把 Claude Code 的本地执行手感,装进一个多租户、可计费、可观测、可审批的受管平台里。这正是 Claude Code **刻意不做**的部分,也是我们已投入建设的护城河所在。
|
||||
|
||||
---
|
||||
|
||||
## 3. 现有架构支持度(为什么不用推倒重来)
|
||||
|
||||
读 `sundynix-dispatcher/internal/eino/react_agent.go` 可知,agent 循环有两个关键事实,决定了本方案**是"换实现 + 加代理",不是"重写"**:
|
||||
|
||||
1. **循环本体是 Eino 标准件 `react.NewAgent`**,可移植。它只吃两个注入:`ToolCallingModel`(LLM)+ 一组 `tool.BaseTool`(工具)。
|
||||
2. **这两样都已在干净接口后面**:
|
||||
- LLM:`o.agentPool(b).ToolCallingModel()` —— 池抽象,可换成"gateway LLM 代理客户端"。
|
||||
- 工具:`mcpTool.InvokableRun` → `m.caller.CallTool(ctx, subject, ToolCall)`,`caller` 是 **`ToolCaller` 接口**,`subject` 是**可替换路由函数**;工具还是 `list_tools` **动态发现**,加工具零改循环。
|
||||
|
||||
**最难的两块(LLM 访问、工具调用)已被提前解耦。** 这是本方案成本可控的根本原因。
|
||||
|
||||
---
|
||||
|
||||
## 3.5 客户端可行性:Go + Wails 是最佳形态
|
||||
|
||||
> 直接结论:**支持,而且 Go+Wails 比 Electron/Tauri 都强。** 因为客户端与服务端 agent 是**同一种语言(Go)**——档 B 不是"用另一门语言重写循环",而是**直接共享同一份 Eino 循环代码**。
|
||||
|
||||
### 3.5.1 同语言 = 消掉最贵的成本
|
||||
|
||||
| 客户端技术 | 档 B 要付的代价 |
|
||||
|---|---|
|
||||
| Electron(JS/TS) | 把 `react.NewAgent`/`mcpTool`/`ToolCaller` 全用 TS 重写 |
|
||||
| Tauri(Rust) | 用 Rust 重写 |
|
||||
| **Go + Wails(我们)** | **`import` 即用**:dispatcher 与 desktop 共用同一个 `cloudwego/eino`、同一套 `contract` 类型、同一份循环逻辑 |
|
||||
|
||||
这把 §7 四个解耦点里最贵的"重写"直接消掉,只剩"搬家"。
|
||||
|
||||
### 3.5.2 界线:agent 跑在 Go host,不在 WebView
|
||||
|
||||
**Wails app = Go host(原生进程,无沙箱)+ WKWebView(只渲染 UI,经绑定调 Go)。** agent 循环跑在 Go host——所以 **WebView 的沙箱限制与执行完全无关**。逐块看,每一块都在 Go host 里正常跑:
|
||||
|
||||
| 部件 | Wails Go host | 说明 |
|
||||
|---|---|---|
|
||||
| Eino `react.NewAgent` 循环 | ✅ | 纯 Go 库;host 是正常 Go 进程,`goroutine`/`context`/`StreamReader` 全正常 |
|
||||
| 本地工具 read/write/bash/grep | ✅ | `os.ReadFile`/`os.WriteFile`/`exec.CommandContext` 以用户 OS 权限跑——正是 Wails 甩纯 Web 应用之处 |
|
||||
| LLM 代理客户端 | ✅ | `net/http` 打 gateway;实现成 Eino `ToolCallingChatModel` 接口,对循环透明 |
|
||||
| 流式输出到 UI | ✅ | Go host 用 Wails 运行时事件(`EventsEmit`)推前端渲染;同时自持连接上抛服务端 |
|
||||
| 长连接/后台 goroutine | ✅ | host 生命周期独立于 webview,启动时起 goroutine 持 gateway 连接、注册 runner |
|
||||
|
||||
### 3.5.3 唯一真实的坑:模块依赖接线(非能力问题)
|
||||
|
||||
1. **desktop 是独立 module、不在 go.work 里**(见 `wails3-migration`、`go-workspace-genproto` 坑)。
|
||||
2. **Go `internal/` 包不能跨模块 import** → dispatcher 里那份循环**必须挪到非 internal 的共享位置**(`sundynix-shared/agent` 或新模块)才能被 desktop 引。即 §7 解耦点 #3。
|
||||
3. `sundynix-desktop/go.mod` 要 require `sundynix-shared`,构建走 `GOWORK=off`,靠 **go.mod 依赖(replace 指向本地 / Gitea module proxy)**解析,不能靠 go.work。**盯紧已踩过的 genproto/indirect-require 版本冲突。**
|
||||
4. 次要:Eino 传递依赖不少,加进 desktop 会让**二进制变大、构建变慢**——可接受,但心里有数。
|
||||
|
||||
> 一句话:**能力上零障碍,Go+Wails 天然支持;成本上因同语言,档 B 从"重写"降级成"把共享单元搬进 sundynix-shared + 接好 go.mod 依赖"。** 你趟过的 go.work/genproto 坑,恰好就是这次搬家要绕的雷。
|
||||
|
||||
---
|
||||
|
||||
## 4. 双平面架构
|
||||
|
||||
```
|
||||
┌────────────────────── 控制平面(服务端,中心化治理)──────────────────────┐
|
||||
│ gateway │
|
||||
│ ├─ LLM 代理端点 ← 咽喉:注入记忆/prompt、记用量/计费、护栏、OTel、key托管 │
|
||||
│ ├─ 策略下推 → ToolPolicy / 白名单 / 预算令牌桶(复用 config 下推范式)│
|
||||
│ ├─ HITL 策略定义 → 哪些操作必须审批(人工/组织级流程仍服务端 pause) │
|
||||
│ └─ 事件汇聚 ← 客户端上抛的 token/trace/usage,进 NATS/JetStream │
|
||||
│ dispatcher/Eino:DAG 工作流编排(另一产品面,维持不变) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
▲ LLM 调用(每轮决策) ▲ 事件上抛(异步)
|
||||
│ │
|
||||
┌────────────────────── 执行平面(客户端 Wails Go host)──────────────────────┐
|
||||
│ 本地 Agent 运行时(档 B:react 循环搬到这里) │
|
||||
│ ├─ ToolCallingModel = gateway LLM 代理客户端(实现 Eino ChatModel 接口) │
|
||||
│ ├─ 本地工具族 local.*:read/write/edit/bash/grep/glob(os / os/exec) │
|
||||
│ ├─ cwd 沙箱:所有操作锁在用户选的工作目录根下 │
|
||||
│ ├─ 本地 HITL:破坏性操作快速弹窗审批(策略由服务端下推) │
|
||||
│ └─ 策略缓存 + 预算令牌桶:内环本地判定,不阻塞网络 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**关键点:只有"LLM 调用"离开本机(Claude Code 也一样),工具执行全在本地。** 手感回来,治理不漏。
|
||||
|
||||
---
|
||||
|
||||
## 5. 两档演进路线
|
||||
|
||||
> 建议**先做档 A**,用真实用例量延迟;**真的卡手了再上档 B**。你的场景(助手操作几个文件/跑个命令)工具调用频率低,档 A 那点往返大概率无感;Claude-Code 内环之痛只在"读几十文件、反复跑测试"才暴露。
|
||||
|
||||
### 档 A:只下放工具执行,循环留服务端 —— **成本低(几天级)**
|
||||
|
||||
因为 `ToolCaller` 已是接口 + 可替换 subject,只需:
|
||||
|
||||
1. 新增 `local.*` 工具族,`CallTool` 的 subject 路由到 **NATS→gateway→客户端 runner**(而非 →mcp-go)。
|
||||
2. 客户端 Go host 跑一个"迷你 mcp"执行器,登录后注册到现有连接,收调用就 `os.ReadFile`/`exec.CommandContext`,结果原路回。
|
||||
3. gateway 做 subject 桥接:把发给某用户 runner 的 NATS 请求,转到该 runner 的连接,等 reply。
|
||||
|
||||
**循环、图、LLM、记忆、计费、追踪一行不动。** 就是给工具层多插一个执行目标。
|
||||
- ✅ 立刻拿到"操作本地"能力,治理全在。
|
||||
- ❌ 每工具过一次网(server↔client),无 Claude Code 紧凑手感。
|
||||
|
||||
### 档 B:把 agent 循环搬到客户端 —— **成本中等(解耦+搬家,非重构)**
|
||||
|
||||
拦路的不是架构,是循环今天**和服务端世界缠在一起**。需拆的**四个耦合点**(见 §7)。搬完后:
|
||||
|
||||
- ✅ 内环本地、瞬时,真正的 Claude Code 手感。
|
||||
- ✅ 控制平面(LLM 代理 + 策略下推 + 事件上抛)守住中心化。
|
||||
|
||||
---
|
||||
|
||||
## 6. 控制平面:三个支点
|
||||
|
||||
### 6.1 LLM 代理(咽喉,档 B 的地基)
|
||||
|
||||
- gateway 出一个 chat/completions 风格端点;客户端每次推理经它出去。
|
||||
- 挂载:记忆注入(复用现有 recall)、prompt 版本化(注册表)、用量计量(计费/积分)、护栏/忠实度、OTel span、**provider key 永不下发客户端**。
|
||||
- 客户端把该端点实现成一个 Eino `ToolCallingChatModel`,喂给 `react.NewAgent`——对循环透明。
|
||||
- 净新增,但可复用 `sundynix-dispatcher/internal/llm` 的 client 逻辑。
|
||||
|
||||
### 6.2 策略下推(治理集中,但别让内环等网络)
|
||||
|
||||
复用现有 `PublishConfigUpdated` / `RequestConfig` 下推范式:
|
||||
|
||||
- **ToolPolicy / 白名单**:会话开始下推客户端,**内环本地判定**,异步回报;服务端可 push 撤销/调整。
|
||||
- **预算令牌桶**:服务端发额度给客户端,本地花、异步续、花光**本地硬停**,不必每调用回问。
|
||||
- **HITL**:常见破坏性操作走**本地快速审批弹窗**(不阻塞、不回服务端 pause);"哪些必须审批"由服务端策略下推,每个决策**记回服务端审计**;只有"组织级人工批"流程才走现有服务端 pause→waiting。
|
||||
|
||||
### 6.3 事件上抛(观测/计费不断)
|
||||
|
||||
- 客户端把 trace/token/usage 事件经 NATS(或现有 WS)上抛,进 JetStream 持久(计费类一律持久 + 幂等,见 `nats-durability` 约定)。
|
||||
- 服务端观测面板照常看到"agent 读了哪个文件、跑了什么命令、花了多少 token"。
|
||||
|
||||
---
|
||||
|
||||
## 7. 档 B 的四个解耦点(真实工作量所在)
|
||||
|
||||
| # | 耦合点 | 现状 | 要做的解耦 | 性质 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 循环黏服务端专属件 | `o.sink.PublishToken`(NATS 出 token)、`execTracer`(span)、`harness.BudgetFrom`(预算)、`board`/`recordAgentOutput`(喂图下游) | 把循环抽成**吃接口的独立单元**:token sink / tracer / budget 改注入,客户端本地实现 + 事件上抛。redactor 已是纯函数可直接搬 | 机械解耦 |
|
||||
| 2 | LLM 代理净新增 | 仅 dispatcher 直连 LLM 池、持 key | gateway 出 chat 端点,客户端实现成 Eino ChatModel | 新增(可复用 llm client) |
|
||||
| 3 | 打包/模块边界 | 桌面端是**独立 module、不在 go.work**(见 `go-workspace-genproto`、`wails3-migration` 坑) | 把可复用 agent 单元挪进 `sundynix-shared`(或新模块),desktop 与 dispatcher 共享 import | 搬家(注意 go.work 依赖坑) |
|
||||
| 4 | 事件回传 | 服务端内部 NATS | 客户端 → 服务端事件通道(复用 WS 或 NATS)+ 幂等落库 | 新增(复用现有总线) |
|
||||
|
||||
**没有一条是"架构不支持"或"推倒重来"——全是解耦、搬家、加一个代理端点。**
|
||||
|
||||
---
|
||||
|
||||
## 8. 安全模型(最重要)
|
||||
|
||||
LLM 驱动在用户机器上跑命令 = 最高危面。默认全部收紧,宁可先严后松:
|
||||
|
||||
1. **路径沙箱**:用户显式选一个工作目录根;所有 `local.*` 只能在根下操作,越界直接拒(软链接/`..` 逃逸也拦)。
|
||||
2. **命令白名单/黑名单**:`rm -rf`、`curl|sh`、写系统路径、`sudo` 等默认拦;exec 默认需审批。
|
||||
3. **HITL 审批**:写文件/删除/执行命令默认走审批;可配"这次允许 / 本会话允许 / 加白名单"。
|
||||
4. **仅桌面端可用**:`local.*` 必须 gate 在"有在线本地 runner 的桌面会话";Web/共享工作区不给(否则语义即"让 A 操作 B 的机器",灾难)。写进 ToolPolicy。
|
||||
5. **归属校验**:本地 runner 只服务自己登录的用户,注册带 `user_id` + 设备指纹,服务端严格校验目标连接归属,绝不串。
|
||||
6. **审计**:每次本地文件写/命令执行,无论是否需审批,都落审计事件回服务端。
|
||||
7. **超时 + 资源限制**:命令有超时、输出大小上限,防失控进程/刷屏。
|
||||
|
||||
---
|
||||
|
||||
## 9. 可靠性 / 寻址
|
||||
|
||||
- **连接注册表**:服务端维护 `user_id/device → runner 连接`;任务提交时绑定"发起它的那台 runner"。
|
||||
- **多设备歧义**:一个用户开多台桌面端 → 任务必须绑定发起端,不能随便选。
|
||||
- **断线兜底**:客户端中途断线,工具调用超时 + 明确报"本地执行器不在线",任务优雅失败而非卡死。
|
||||
- **降级**:runner 不在线时,纯服务端能力(问答/检索/报告)不受影响。
|
||||
|
||||
---
|
||||
|
||||
## 10. 落地阶段(从现有往上加)
|
||||
|
||||
| 阶段 | 内容 | 依赖 | 产出 |
|
||||
|---|---|---|---|
|
||||
| **P0** | gateway **LLM 代理端点**:客户端推理经它,挂计量/追踪/记忆注入/护栏 | 复用 `internal/llm` | 中心化咽喉立住 |
|
||||
| **P1(档 A)** | `local.*` **只读**工具(list/read)+ 客户端 runner 注册 + subject 桥接 + cwd 沙箱 | `ToolCaller` 接口 | **✅ 2026-07-24 落地并 live 验通**(实现见 JARVIS_BRAIN_DESIGN P4:gateway `local_runner.go` + desktop `localrunner.go`;沙箱逃逸单测全拦;离线优雅降级) |
|
||||
| **P2(档 A)** | write/edit/bash + **本地 HITL** + ToolPolicy 下推 + 审计 | HITL 节点、config 下推 | 可操作本地,治理收紧 |
|
||||
| **P3(档 B)** | 按 §7 抽循环 → 搬 `sundynix-shared` → 客户端跑 react 循环 + 预算令牌桶 + 事件上抛 | 四个解耦点 | Claude Code 手感 |
|
||||
| **P4** | 桥接服务端图编排(Task 式子 agent)、会话 resume、CLAUDE.md 式本地项目记忆 | P3 | 两产品面打通 |
|
||||
|
||||
**决策门**:P1 跑通后,用真实用例量 server↔client 单次往返延迟。若无感 → 停在档 A 收益已足;若明显卡手(高频内环)→ 才付 P3 的钱。
|
||||
|
||||
---
|
||||
|
||||
## 11. 风险与取舍
|
||||
|
||||
- **产品定位决定 ROI**:档 B 只有当产品是"完整编码/操作 agent 在我仓库里连续折腾"才值;若是"助手偶尔干几件本地事",档 A 足矣。**先确认定位再决定要不要 P3。**
|
||||
- **模块边界坑**:desktop 不在 go.work,搬共享 agent 单元时小心 `genproto`/indirect require 冲突(见 `go-workspace-genproto`)。
|
||||
- **安全面扩大**:一旦能跑命令,任何提示注入/越权都可能落到用户机器。§8 的门禁是硬要求,不是可选。
|
||||
- **老引擎兼容**:客户端 Go 运行时无浏览器引擎版本问题;但若本地 agent 的 UI 复用现有前端,注意 WKWebView 版本坑(另见前端白屏排查)。
|
||||
|
||||
---
|
||||
|
||||
## 12. 待定问题(需产品确认)
|
||||
|
||||
1. 产品定位:受管的**团队/企业操作平台** 还是 **更强的个人本地助手**?(决定要不要档 B)
|
||||
2. 本地 runner 的连接:直连 NATS 还是经 gateway WS?(出网策略 + 部署拓扑约束)
|
||||
3. exec 的默认收紧程度:默认全审批,还是白名单内免审批?
|
||||
4. 是否需要"CLAUDE.md 式"本地项目记忆文件,与服务端记忆如何合并?
|
||||
+13
-8
@@ -43,18 +43,23 @@
|
||||
| **微信支付 Native**(P5.2) | 企业主体 + 商户号 | 二维码(code_url) | ✅ 已拍板 |
|
||||
| 支付宝 / Stripe | — | — | 不做,留 adapter 位 |
|
||||
|
||||
Adapter 接口(`internal/payment/channel.go`):
|
||||
Adapter 接口(`internal/payment/channel.go`)✅ **已落地**:
|
||||
```go
|
||||
type Channel interface {
|
||||
Name() string
|
||||
// CreatePay 依据订单生成支付凭据(二维码内容/跳转 URL/兑换码提示)
|
||||
CreatePay(ctx, o *PaymentOrder) (PayIntent, error)
|
||||
// VerifyCallback 验签并解析回调 → (orderID, channelTxnID, paidAmountFen, error)
|
||||
VerifyCallback(req *http.Request) (CallbackResult, error)
|
||||
// QueryOrder 主动查单(掉单补偿用)
|
||||
QueryOrder(ctx, orderID string) (OrderStatus, error)
|
||||
Name() string // 与 store.Channel*(wechat/alipay/stripe)一致,按订单渠道路由
|
||||
// CreatePay 依据订单生成支付凭据(微信 Native 出 code_url;其它渠道可出跳转 URL)
|
||||
CreatePay(ctx, orderID, description string, amountFen int64) (PayIntent, error)
|
||||
// QueryOrder 主动查单(前端轮询确认 + 掉单补偿共用)
|
||||
QueryOrder(ctx, orderID string) (PayResult, error)
|
||||
// VerifyCallback 验签并解析回调 → 统一 PayResult(验签失败必返 error)
|
||||
VerifyCallback(req *http.Request) (PayResult, error)
|
||||
}
|
||||
```
|
||||
实现说明(2026-07-18):入参用基本类型(orderID/amountFen)而非 `*store.PaymentOrder`,让
|
||||
`payment` 包不反依赖 `store`,渠道适配层保持纯粹。`Manager` 是渠道注册表(`Get(name)`/`Available()`/
|
||||
`Status(name)`/`ReloadWechat`),回调路由收敛为 `POST /billing/callback/:channel` 按名路由;
|
||||
查单/补偿据 `order.Channel` 路由。微信是当前唯一真渠道,支付宝/Stripe = 实现本接口 + 注册即接入。
|
||||
唯一未泛化的是回调 **ack 应答格式**(现为微信 `{code:SUCCESS}`,其它渠道接入时按渠道分应答)。
|
||||
|
||||
## 3b. 两层汇率,各管各的(用户 2026-07-17 强调:积分↔token 必须可动态调)
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# 基础设施机 128 的库密码。cp .env.example .env 后填写。
|
||||
# 这两个密码必须与 132 应用机 .env 里的 POSTGRES_PASSWORD / NEO4J_PASSWORD 一致。
|
||||
POSTGRES_PASSWORD=change-me-postgres
|
||||
NEO4J_PASSWORD=change-me-neo4j
|
||||
@@ -0,0 +1,134 @@
|
||||
# sundynix-agentix · 基础设施机 192.168.100.128
|
||||
#
|
||||
# cd deploy/128-infra && cp .env.example .env # 填库密码
|
||||
# docker compose up -d
|
||||
#
|
||||
# 说明:
|
||||
# - 对象存储 MinIO 已单独部署在 192.168.100.126,本机不含业务 MinIO;
|
||||
# 这里的 milvus-minio 仅供 Milvus 自己存段文件(内部网络,不对外)。
|
||||
# - 端口对局域网暴露(0.0.0.0),供应用机 192.168.100.132 连接。
|
||||
# ⚠️ Redis / Milvus / NATS 无认证,务必用防火墙只放行 132/126 网段,勿对公网开放。
|
||||
# - NATS 三节点集群(JetStream Raft):单机多容器,各自独立卷;对 132 暴露 4222/4223/4224。
|
||||
# 应用侧设 NATS_STREAM_REPLICAS=3,durable 流才有 quorum 容错。
|
||||
# - 全部卷持久化 + restart:unless-stopped,容器损坏/重启不丢数据。
|
||||
|
||||
services:
|
||||
# ---------- NATS 3 节点集群 ----------
|
||||
nats1:
|
||||
image: nats:2-alpine
|
||||
command: ["-c", "/etc/nats/nats-server.conf"]
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ../nats-cluster/nats1.conf:/etc/nats/nats-server.conf:ro
|
||||
- nats1_data:/data/jetstream
|
||||
ports: ["4222:4222", "8222:8222"] # 客户端 + 监控
|
||||
|
||||
nats2:
|
||||
image: nats:2-alpine
|
||||
command: ["-c", "/etc/nats/nats-server.conf"]
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ../nats-cluster/nats2.conf:/etc/nats/nats-server.conf:ro
|
||||
- nats2_data:/data/jetstream
|
||||
ports: ["4223:4222"]
|
||||
|
||||
nats3:
|
||||
image: nats:2-alpine
|
||||
command: ["-c", "/etc/nats/nats-server.conf"]
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ../nats-cluster/nats3.conf:/etc/nats/nats-server.conf:ro
|
||||
- nats3_data:/data/jetstream
|
||||
ports: ["4224:4222"]
|
||||
|
||||
# ---------- 关系库 / 缓存 ----------
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: sundynix
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?需在 .env 设置}
|
||||
POSTGRES_DB: sundynix
|
||||
volumes: ["pg_data:/var/lib/postgresql/data"]
|
||||
ports: ["5432:5432"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U sundynix"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
volumes: ["redis_data:/data"]
|
||||
ports: ["6379:6379"]
|
||||
|
||||
# ---------- 向量库 Milvus(自带 etcd + 自带内部 minio,不复用 126)----------
|
||||
milvus-etcd:
|
||||
image: quay.io/coreos/etcd:v3.5.14
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ETCD_AUTO_COMPACTION_MODE: revision
|
||||
ETCD_AUTO_COMPACTION_RETENTION: "1000"
|
||||
ETCD_QUOTA_BACKEND_BYTES: "4294967296"
|
||||
ETCD_SNAPSHOT_COUNT: "50000"
|
||||
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
|
||||
volumes: ["milvus_etcd:/etcd"]
|
||||
|
||||
milvus-minio:
|
||||
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MINIO_ACCESS_KEY: minioadmin # Milvus 段存储专用,内部网络不对外
|
||||
MINIO_SECRET_KEY: minioadmin
|
||||
command: minio server /minio_data
|
||||
volumes: ["milvus_minio_data:/minio_data"]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
|
||||
milvus:
|
||||
image: milvusdb/milvus:v2.4.13
|
||||
command: ["milvus", "run", "standalone"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ETCD_ENDPOINTS: milvus-etcd:2379
|
||||
MINIO_ADDRESS: milvus-minio:9000
|
||||
volumes: ["milvus_data:/var/lib/milvus"]
|
||||
ports: ["19530:19530"] # 供 132 的 mcp-go 连接
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
depends_on: [milvus-etcd, milvus-minio]
|
||||
|
||||
# ---------- 图谱库 ----------
|
||||
neo4j:
|
||||
image: neo4j:5-community
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:?需在 .env 设置}
|
||||
volumes: ["neo4j_data:/data"]
|
||||
ports: ["7687:7687", "7474:7474"] # bolt + 浏览器
|
||||
|
||||
# ---------- 链路追踪(可选,不需要可删)----------
|
||||
jaeger:
|
||||
image: jaegertracing/all-in-one:1.60
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
COLLECTOR_OTLP_ENABLED: "true"
|
||||
ports: ["16686:16686", "4318:4318"] # UI + OTLP/HTTP 接收
|
||||
|
||||
volumes:
|
||||
nats1_data:
|
||||
nats2_data:
|
||||
nats3_data:
|
||||
pg_data:
|
||||
redis_data:
|
||||
milvus_etcd:
|
||||
milvus_minio_data:
|
||||
milvus_data:
|
||||
neo4j_data:
|
||||
@@ -0,0 +1,26 @@
|
||||
# 应用机 132 的密钥与凭据。cp .env.example .env 后填写(.env 不进 git)。
|
||||
# 三个应用服务共享 SUNDYNIX_SECRET_KEY,必须完全一致(否则跨服务密文解不开)。
|
||||
|
||||
# —— 引导密钥(生成:openssl rand -base64 32)——
|
||||
SUNDYNIX_SECRET_KEY=change-me-secret-32
|
||||
JWT_SECRET=change-me-jwt-32
|
||||
|
||||
# —— 库密码(须与 128-infra/.env 一致)——
|
||||
POSTGRES_PASSWORD=change-me-postgres
|
||||
NEO4J_PASSWORD=change-me-neo4j
|
||||
|
||||
# —— 对象存储(126 上的 MinIO 凭据)——
|
||||
MINIO_ACCESS_KEY=change-me-minio-access
|
||||
MINIO_SECRET_KEY=change-me-minio-secret
|
||||
MINIO_BUCKET=sundynix-docs
|
||||
|
||||
# —— 管理员白名单:首次注册后拿 user.id 填这里,重启 gateway 生效(逗号分隔多个)——
|
||||
ADMIN_USER_IDS=
|
||||
|
||||
# —— CORS:admin 同源免;桌面端/web 从 3000 跨源直连需填实际 origin(frp 外网域名等),
|
||||
# 生产(APP_ENV=production)下缺省不放行任意源。多个逗号分隔。——
|
||||
CORS_ALLOW_ORIGIN=
|
||||
|
||||
# —— 可选调参 ——
|
||||
DISPATCHER_CONCURRENCY=8
|
||||
TASK_TOKEN_BUDGET=200000
|
||||
@@ -0,0 +1,109 @@
|
||||
# sundynix-agentix · 应用机 192.168.100.132
|
||||
#
|
||||
# cd deploy/132-app && cp .env.example .env # 填密钥/密码/MinIO 凭据
|
||||
# docker compose up -d --build # 在 132 本地构建并运行(Gitea 自动部署也走这条)
|
||||
#
|
||||
# 说明:
|
||||
# - 对外只暴露 gateway:3000(frp 映射)。gateway 已内嵌 admin 控制台(同源 serve UI+API),
|
||||
# 无需单独 admin nginx 容器。dispatcher/mcp-go/mcp-py 只走 NATS,无对外端口。
|
||||
# - 基础设施在 128(PG/NATS集群/Redis/Milvus/Neo4j),对象存储在 126(MinIO),env 全部指向它们。
|
||||
# - 构建上下文为仓库根(../..,因 Go 服务 replace ../sundynix-shared)。
|
||||
# - NATS_STREAM_REPLICAS=3:durable 流走 128 三节点集群,有 Raft quorum 容错。
|
||||
|
||||
services:
|
||||
gateway:
|
||||
build: { context: ../.., dockerfile: sundynix-gateway/Dockerfile }
|
||||
image: sundynix/gateway:${TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
APP_ENV: production
|
||||
GATEWAY_ADDR: ":8080"
|
||||
NATS_URL: nats://192.168.100.128:4222,nats://192.168.100.128:4223,nats://192.168.100.128:4224
|
||||
NATS_STREAM_REPLICAS: "3"
|
||||
POSTGRES_DSN: postgres://sundynix:${POSTGRES_PASSWORD:?}@192.168.100.128:5432/sundynix?sslmode=disable
|
||||
REDIS_ADDR: 192.168.100.128:6379
|
||||
MINIO_ENDPOINT: 192.168.100.126:9000
|
||||
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?}
|
||||
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?}
|
||||
MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-docs}
|
||||
SUNDYNIX_SECRET_KEY: ${SUNDYNIX_SECRET_KEY:?}
|
||||
JWT_SECRET: ${JWT_SECRET:?}
|
||||
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
||||
CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN:-*}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318
|
||||
# 微信域名校验文件目录(容器内路径);未设则该路由不注册
|
||||
WECHAT_VERIFY_DIR: /etc/sundynix/wechat-verify
|
||||
# 微信 access_token 中控(腾讯云静态 IP 换 token,gateway 只拉不换)。
|
||||
# 配了才从中控拉,避免本地动态出网 IP 触发 40164;不配则 gateway 直连微信换 token。
|
||||
# 值放宿主机 .env:WECHAT_TOKEN_URL=https://.../token.json、WECHAT_TOKEN_SECRET=<共享密钥>
|
||||
WECHAT_TOKEN_URL: ${WECHAT_TOKEN_URL:-}
|
||||
WECHAT_TOKEN_SECRET: ${WECHAT_TOKEN_SECRET:-}
|
||||
ports: ["3000:8080"] # frp 外网 → 132:3000 → 容器 8080
|
||||
# 让容器能访问宿主机端口(微信 token 中控经 frp stcp 落在宿主机 127.0.0.1:9099,
|
||||
# 容器里的 127.0.0.1 是容器自己,须用 host.docker.internal 指到宿主机)。
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
# 微信支付证书(商户私钥 + 微信支付公钥):宿主机 132 的目录只读挂进容器。
|
||||
# ⚠️ admin「系统配置 → 支付」里填的路径必须是**容器内路径**(/etc/sundynix/wechat-cert/...),
|
||||
# 不是宿主机路径——容器看不到宿主机的 /home/workspace/...。私钥不进镜像、不进 git。
|
||||
- /home/workspace/wechat-pay-cert:/etc/sundynix/wechat-cert:ro
|
||||
# 微信域名校验文件(MP_verify_xxx.txt):配置 JS安全域名/网页授权域名时微信要求
|
||||
# 能从域名根目录访问到。放宿主机、只读挂进来,不进镜像也不进 git。
|
||||
- /home/workspace/wechat-verify:/etc/sundynix/wechat-verify:ro
|
||||
|
||||
dispatcher:
|
||||
build: { context: ../.., dockerfile: sundynix-dispatcher/Dockerfile }
|
||||
image: sundynix/dispatcher:${TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
APP_ENV: production
|
||||
NATS_URL: nats://192.168.100.128:4222,nats://192.168.100.128:4223,nats://192.168.100.128:4224
|
||||
NATS_STREAM_REPLICAS: "3"
|
||||
SUNDYNIX_SECRET_KEY: ${SUNDYNIX_SECRET_KEY:?}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318
|
||||
DISPATCHER_CONCURRENCY: ${DISPATCHER_CONCURRENCY:-8}
|
||||
TASK_TOKEN_BUDGET: ${TASK_TOKEN_BUDGET:-200000}
|
||||
|
||||
mcp-go:
|
||||
build: { context: ../.., dockerfile: sundynix-mcp-go/Dockerfile }
|
||||
image: sundynix/mcp-go:${TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
APP_ENV: production
|
||||
NATS_URL: nats://192.168.100.128:4222,nats://192.168.100.128:4223,nats://192.168.100.128:4224
|
||||
NATS_STREAM_REPLICAS: "3"
|
||||
POSTGRES_DSN: postgres://sundynix:${POSTGRES_PASSWORD:?}@192.168.100.128:5432/sundynix?sslmode=disable
|
||||
REDIS_ADDR: 192.168.100.128:6379
|
||||
MILVUS_ADDR: 192.168.100.128:19530
|
||||
NEO4J_URI: neo4j://192.168.100.128:7687
|
||||
NEO4J_USER: neo4j
|
||||
NEO4J_PASS: ${NEO4J_PASSWORD:?}
|
||||
# 报告源/产物 + KB 正文对象存储(本会话新加 blob)——指向 126,勿漏,否则 fallback localhost 连不上。
|
||||
MINIO_ENDPOINT: 192.168.100.126:9000
|
||||
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?}
|
||||
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?}
|
||||
MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-docs}
|
||||
SUNDYNIX_SECRET_KEY: ${SUNDYNIX_SECRET_KEY:?}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318
|
||||
# 全文(bleve)索引落盘位置。不设则相对 CWD,写进容器可写层 → 每次发版重建容器就清零,
|
||||
# 而混合检索只是静默少一路召回、不报错,很难发现。必须配合下面的持久卷。
|
||||
BLEVE_PATH: /data/bleve
|
||||
volumes:
|
||||
- mcp-go-data:/data
|
||||
|
||||
mcp-py:
|
||||
build: { context: ../../sundynix-mcp-py, dockerfile: Dockerfile }
|
||||
image: sundynix/mcp-py:${TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NATS_URL: nats://192.168.100.128:4222,nats://192.168.100.128:4223,nats://192.168.100.128:4224
|
||||
# secure_sandbox(代码隔离执行)需访问宿主 Docker 拉一次性容器。安全敏感:等于把 132 宿主
|
||||
# Docker 暴露给该服务(可逃逸);不需要代码执行工具可注释掉这两行。mcp-py 无对外端口。
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
# 全文索引持久化:随容器重建保留(向量在 Milvus、图谱在 Neo4j,都在 128;
|
||||
# 只有 bleve 是跟着 mcp-go 走的本地索引,唯独它需要这个卷)。
|
||||
volumes:
|
||||
mcp-go-data:
|
||||
@@ -0,0 +1,21 @@
|
||||
# NATS 集群节点 1 / 3(JetStream Raft)。单机多节点:三容器同宿主(128),各自独立卷。
|
||||
# 三节点的 max_payload 必须一致;server_name 唯一;routes 指向另外两节点的 cluster 端口。
|
||||
server_name: nats1
|
||||
port: 4222
|
||||
http_port: 8222
|
||||
max_payload: 8MB
|
||||
|
||||
jetstream {
|
||||
store_dir: "/data/jetstream"
|
||||
max_memory_store: 1GB
|
||||
max_file_store: 10GB
|
||||
}
|
||||
|
||||
cluster {
|
||||
name: sundynix-cluster
|
||||
listen: 0.0.0.0:6222
|
||||
routes: [
|
||||
nats-route://nats2:6222
|
||||
nats-route://nats3:6222
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# NATS 集群节点 2 / 3(JetStream Raft)。max_payload 与其余节点一致;routes 指向 nats1/nats3。
|
||||
server_name: nats2
|
||||
port: 4222
|
||||
http_port: 8222
|
||||
max_payload: 8MB
|
||||
|
||||
jetstream {
|
||||
store_dir: "/data/jetstream"
|
||||
max_memory_store: 1GB
|
||||
max_file_store: 10GB
|
||||
}
|
||||
|
||||
cluster {
|
||||
name: sundynix-cluster
|
||||
listen: 0.0.0.0:6222
|
||||
routes: [
|
||||
nats-route://nats1:6222
|
||||
nats-route://nats3:6222
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# NATS 集群节点 3 / 3(JetStream Raft)。max_payload 与其余节点一致;routes 指向 nats1/nats2。
|
||||
server_name: nats3
|
||||
port: 4222
|
||||
http_port: 8222
|
||||
max_payload: 8MB
|
||||
|
||||
jetstream {
|
||||
store_dir: "/data/jetstream"
|
||||
max_memory_store: 1GB
|
||||
max_file_store: 10GB
|
||||
}
|
||||
|
||||
cluster {
|
||||
name: sundynix-cluster
|
||||
listen: 0.0.0.0:6222
|
||||
routes: [
|
||||
nats-route://nats1:6222
|
||||
nats-route://nats2:6222
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# 微信 access_token 中控(腾讯云静态 IP 换 token)
|
||||
|
||||
## 为什么
|
||||
|
||||
gateway 换 access_token 时微信看到的是**本地宽带出网 IP(动态会变)**,一变白名单
|
||||
就失效、二维码建不出来(40164)。微信官方推荐「中控服务器」:一台**固定 IP** 的机器
|
||||
统一换 token,业务机拉取使用。腾讯云那台(`162.14.122.200`,静态)当中控。
|
||||
|
||||
微信 IP 白名单**只限制换 token 这一步**(用 appsecret),拿 token 建二维码不查 IP。
|
||||
所以:腾讯云换 token → gateway 拉 token → gateway 本地建二维码。白名单只填腾讯云 IP。
|
||||
|
||||
## 实际采用方案(B:宝塔 nginx + IP + 密钥)
|
||||
|
||||
腾讯云未配域名,用 IP 直接 serve token.json,加密钥头保护。token 走公网明文传,
|
||||
但强随机密钥保护 + 90 分钟轮换,风险可控(单产品早期可接受;要更严可后续换 frp 隧道)。
|
||||
|
||||
### 腾讯云(宝塔)
|
||||
|
||||
1. **计划任务**(宝塔「计划任务」→ 添加,Shell 脚本,每 90 分钟):
|
||||
```bash
|
||||
#!/bin/bash
|
||||
APPID="wxc236cddde8e7f863"
|
||||
SECRET="你的公众号AppSecret"
|
||||
mkdir -p /home/workspace/wxtoken
|
||||
OUT="/home/workspace/wxtoken/token.json"
|
||||
resp=$(curl -s "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${APPID}&secret=${SECRET}")
|
||||
echo "$resp" | grep -q '"access_token"' && echo "$resp" > "$OUT.tmp" && mv "$OUT.tmp" "$OUT"
|
||||
```
|
||||
加完点一次「执行」,确认 `/home/workspace/wxtoken/token.json` 生成、内容含 access_token。
|
||||
|
||||
2. **建站点 + nginx**(宝塔「网站」→ 添加,域名填 IP `162.14.122.200`,纯静态)。
|
||||
站点「配置文件」的 `server { }` 里加(密钥换成 `openssl rand -hex 24` 生成的强随机串):
|
||||
```nginx
|
||||
location = /wxtoken/token.json {
|
||||
if ($http_authorization != "Bearer <强随机密钥>") { return 403; }
|
||||
default_type application/json;
|
||||
alias /home/workspace/wxtoken/token.json;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
```
|
||||
自测:带密钥能取、不带 403。
|
||||
|
||||
3. **微信 IP 白名单**:只填腾讯云 `162.14.122.200`(换 token 只发生在这台)。
|
||||
|
||||
### gateway(132 的 .env)
|
||||
```
|
||||
WECHAT_TOKEN_URL=http://162.14.122.200/wxtoken/token.json
|
||||
WECHAT_TOKEN_SECRET=<与 nginx 那串一字不差>
|
||||
```
|
||||
配了 gateway 就**只从中控拉、不自己换 token**;不配则维持直连。
|
||||
|
||||
## 切换 + 验证(关键,坐实 qrcode 不受 IP 限制)
|
||||
|
||||
1. 先让**本地 IP 和腾讯云 IP 都在白名单**,配好上面一切、部署,扫码确认能登录。
|
||||
2. 去公众平台把**本地 IP 删掉、只留腾讯云 `162.14.122.200`**,再扫码:
|
||||
- **能登录** → 换 token 走了中控、建二维码本地也没被拦,方案坐实、IP 隐患彻底消除。
|
||||
- **建二维码报 40164** → qrcode 也受 IP 限制,改用备选:腾讯云装 tinyproxy 正向代理,
|
||||
gateway 加 `WECHAT_HTTP_PROXY`(需再改一处 http 客户端)。
|
||||
|
||||
## 安全提醒
|
||||
|
||||
- `WECHAT_TOKEN_SECRET` 用强随机串(`openssl rand -hex 24`),别用品牌名等易猜词——
|
||||
泄露就能拉到 token 冒用公众号 2 小时。
|
||||
- token 走公网明文传是本方案的固有代价;要消除,后续可改 frp stcp 隧道(token 不出公网)。
|
||||
|
||||
## 排查
|
||||
|
||||
- gateway 日志 `[wxlogin] 建二维码失败: ... 40164` → 中控没生效或没配 env,仍在本地换 token
|
||||
- gateway 拉取 403 → `WECHAT_TOKEN_SECRET` 与 nginx 里的密钥不一致
|
||||
- token.json 过期/为空 → 宝塔计划任务没跑,或脚本手动执行失败
|
||||
+14
-8
@@ -5,7 +5,7 @@
|
||||
#
|
||||
# 设计:
|
||||
# - 应用经服务名互连(postgres:5432 等),不依赖 127.0.0.1;基建端口不对宿主暴露(仅内网)。
|
||||
# - 仅暴露 admin 控制台(:${HTTP_PORT}) 与 gateway(:${GATEWAY_PORT},供桌面端直连) + Jaeger UI(可选)。
|
||||
# - 仅暴露 gateway(:${GATEWAY_PORT},内嵌 admin 控制台 + API + 供桌面端直连) + Jaeger UI(可选)。
|
||||
# - 引导密钥从 .env 注入(SUNDYNIX_SECRET_KEY/JWT_SECRET/各库密码);模型 API key 仍在控制台配、加密存 PG。
|
||||
# - APP_ENV=production → 强制校验密钥、锁 CORS、启用管理员白名单。
|
||||
|
||||
@@ -64,11 +64,21 @@ services:
|
||||
NEO4J_URI: neo4j://neo4j:7687
|
||||
NEO4J_USER: neo4j
|
||||
NEO4J_PASS: ${NEO4J_PASSWORD:?}
|
||||
# 报告源/产物 + KB 正文对象存储(blob)——与 gateway 同一 MinIO,勿漏否则 fallback localhost。
|
||||
MINIO_ENDPOINT: minio:9000
|
||||
MINIO_ACCESS_KEY: minioadmin
|
||||
MINIO_SECRET_KEY: minioadmin
|
||||
MINIO_BUCKET: sundynix-docs
|
||||
SUNDYNIX_SECRET_KEY: *secret-key
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318
|
||||
# 全文(bleve)索引落盘位置。不设则相对 CWD,写进容器可写层 → 每次重建容器就清零,
|
||||
# 而混合检索只是静默少一路召回、不报错,很难发现。必须配合下面的持久卷。
|
||||
BLEVE_PATH: /data/bleve
|
||||
depends_on:
|
||||
nats: { condition: service_started }
|
||||
milvus: { condition: service_healthy } # mcp-go 须在 Milvus 后起,否则工具 no responders
|
||||
volumes:
|
||||
- mcp_go_data:/data
|
||||
|
||||
mcp-py:
|
||||
build: { context: sundynix-mcp-py, dockerfile: Dockerfile }
|
||||
@@ -83,13 +93,8 @@ services:
|
||||
depends_on:
|
||||
nats: { condition: service_started }
|
||||
|
||||
admin:
|
||||
build: { context: sundynix-admin, dockerfile: Dockerfile }
|
||||
image: sundynix/admin:${TAG:-latest}
|
||||
restart: unless-stopped
|
||||
ports: ["${HTTP_PORT:-80}:80"] # 运维控制台 + 反代网关 API(一个 URL 访问整套)
|
||||
depends_on:
|
||||
gateway: { condition: service_started }
|
||||
# admin 运维控制台已内嵌进 gateway(go:embed,见 sundynix-gateway/internal/webui),
|
||||
# 不再需要单独的 nginx 容器:访问 gateway:${GATEWAY_PORT} 即同时得到控制台 UI + API。
|
||||
|
||||
# ---------- 基础设施(端口不对宿主暴露,仅 compose 内网) ----------
|
||||
nats:
|
||||
@@ -181,3 +186,4 @@ volumes:
|
||||
minio_data:
|
||||
milvus_data:
|
||||
neo4j_data:
|
||||
mcp_go_data:
|
||||
|
||||
+208
-6
@@ -11,238 +11,416 @@ cloud.google.com/go/accessapproval v1.13.0 h1:kx3RQSS0VglTngQTPfywgVj+xFgre/vJou
|
||||
cloud.google.com/go/accessapproval v1.13.0/go.mod h1:7bmInw17bQX+ZPi7YmReC3xKymDrMmxXaUnaI6zQOqI=
|
||||
cloud.google.com/go/accesscontextmanager v1.14.0 h1:50ofyZiGo2yL3Wt1gZ0j0QnD9y3YrhhcwX0N08uS6KY=
|
||||
cloud.google.com/go/accesscontextmanager v1.14.0/go.mod h1:VO15iVnsM0FO9Dt8hSFPgkuHRZjq6LEYZq1szJ27U2k=
|
||||
cloud.google.com/go/accesscontextmanager v1.15.0 h1:0baVgug9IFV8y5cIoD4iN/m/QtJzk2k+j3MW+zJWCVw=
|
||||
cloud.google.com/go/accesscontextmanager v1.15.0/go.mod h1:YjW9urferk8i9ALwBF3bmdcogZeQYRn2yWwR8nkhsBc=
|
||||
cloud.google.com/go/aiplatform v1.125.0 h1:QUGv+XaHN9wcWdb0/J0NFIcaP/veQSvDcqg4GH6QiP4=
|
||||
cloud.google.com/go/aiplatform v1.125.0/go.mod h1:yWTZiCunYDnyxeWWD14tDo6+BMlvAUCC5VxuxhvbrVI=
|
||||
cloud.google.com/go/aiplatform v1.126.0 h1:5PxeWpQfkAyN8mtgtVp4H5nP+ntwPLFMjNREVdiolR0=
|
||||
cloud.google.com/go/aiplatform v1.126.0/go.mod h1:iR3za3evdprLe1XL2pLu0cYVCuTbc87QG0pgvcgiJlE=
|
||||
cloud.google.com/go/analytics v0.35.0 h1:GuwmzJHIaQRvtko6g4wW1ngQ7rpDvDLZ8M3iP3kiflU=
|
||||
cloud.google.com/go/analytics v0.35.0/go.mod h1:V9Qef2N0y8GDqQ9FTlmM2XpDEMYonZJRPSUNGZlPCcc=
|
||||
cloud.google.com/go/apigateway v1.12.0 h1:fpSMzMpRFOS3OAQBdiX3LIKNEGYe0ley2EI+lGaCK2s=
|
||||
cloud.google.com/go/apigateway v1.12.0/go.mod h1:f3Sk8Tdh1Ty5HR7kgbWB6Yu1M82LM+nIr5DTMZnLZWk=
|
||||
cloud.google.com/go/apigateway v1.13.0 h1:IENNmFQlMsl58RlvYHf6wxBNQFeP+lAnVNcyxtmByTE=
|
||||
cloud.google.com/go/apigateway v1.13.0/go.mod h1:pvEpOuuOIw2ev9VCcOyVkDXHHL4lvgMuqIe7XjJ8JoU=
|
||||
cloud.google.com/go/apigeeconnect v1.12.0 h1:YvZhi0QkHobBRMrN6ri9PUPnnhgEd4VpW0+yo1WI7r0=
|
||||
cloud.google.com/go/apigeeconnect v1.12.0/go.mod h1:mYJekCKZHc2ia5yZX5lwtexTn9CzsOfb6+sh/2hi42Q=
|
||||
cloud.google.com/go/apigeeregistry v1.0.0 h1:S0DHrbgpO8/b+YJY/Af2rEQ5d7dkiO1LB59UdvkS6Aw=
|
||||
cloud.google.com/go/apigeeregistry v1.0.0/go.mod h1:o+j6eA8hYhTWX5gEqMMBVDWY+/QQFrYe/YJBsO19pn0=
|
||||
cloud.google.com/go/apigeeregistry v1.1.0 h1:2zIogaYq7dF4E1dAGC4MUnasRuSmohsMl7wVuJ963/4=
|
||||
cloud.google.com/go/apigeeregistry v1.1.0/go.mod h1:4ZFhQlxMuyfDMz9ORDSV8FPZtf2yPQkKjigsFtrrE4Y=
|
||||
cloud.google.com/go/appengine v1.14.0 h1:dTww1xDqBpeR0BpLsiqfjyAnaK7S1vniJ5YR7L83Jh4=
|
||||
cloud.google.com/go/appengine v1.14.0/go.mod h1:JMjrVFg+YgfksZCWbtA3TgbKbPfZZtapB9cGL/5WVnM=
|
||||
cloud.google.com/go/appengine v1.15.0 h1:0MiBM2KGk1WAhh489WoELQ7IeW2qw6HTE6nZIlrOw5U=
|
||||
cloud.google.com/go/appengine v1.15.0/go.mod h1:/8gGZsOX5GDjOo4mAWk8IV59p2991dxTbEtKIlhDjzU=
|
||||
cloud.google.com/go/area120 v0.15.0 h1:9v6HeQsBpfRqoZtNeLI4V8SQ7Jcmt6s2gmgbjl0fVFc=
|
||||
cloud.google.com/go/area120 v0.15.0/go.mod h1:jD1fw9W4xxIZMY68g7PpbCPleoeGddFs5jPcdhfg3+Y=
|
||||
cloud.google.com/go/artifactregistry v1.25.0 h1:CWAoXkJBX02h68W7Z2ZNBqvH1wxET3s+fnKZcn2gM3c=
|
||||
cloud.google.com/go/artifactregistry v1.25.0/go.mod h1:aMmdtqKVmbuxCCb/NGDJYZHsK6AtqlcyvD05ACzs1n8=
|
||||
cloud.google.com/go/artifactregistry v1.26.0 h1:iq5kkdY2FJY8RkkNghUelb3EOcqyy7dVhfDGVk/Xw9g=
|
||||
cloud.google.com/go/artifactregistry v1.26.0/go.mod h1:c5FPi5GtDBP+OAr5kKhCBNQDT9ZgAyobXQjekx93VWs=
|
||||
cloud.google.com/go/asset v1.27.0 h1:Lj2lg/FB7VIBAkvUTVVx7Z9HRSPyVw7WN9butFJSONg=
|
||||
cloud.google.com/go/asset v1.27.0/go.mod h1:+HaDReZQAh/0syAf0uTMeUrMfXikr+KKyDtCdvf7j4M=
|
||||
cloud.google.com/go/asset v1.28.0 h1:M6YE1exBuZQhTi8wfIKrwjKK6b2ySfHKgrqXjPZA4EM=
|
||||
cloud.google.com/go/asset v1.28.0/go.mod h1:Pnvjhay8/FgodOH9uJC8OkfJfRtSnNIIU4WSxg5JfJw=
|
||||
cloud.google.com/go/assuredworkloads v1.18.0 h1:jk+W89a1UsdIzybt/UbRMRlJTXCqQaGuKCoFNz7nUb4=
|
||||
cloud.google.com/go/assuredworkloads v1.18.0/go.mod h1:zBnVYn0E+sDW/mhEmcg1R8+8tguXrtBgmfGY0q34kss=
|
||||
cloud.google.com/go/assuredworkloads v1.19.0 h1:XyNQ9SBX0eUkZYCZhC8dOY6ZFeSF6/OIjHn5dQs78Jo=
|
||||
cloud.google.com/go/assuredworkloads v1.19.0/go.mod h1:/UGGtFCMokM3sGJ4FxjfmLvvFpPa5I/Oz68mwk4Su+0=
|
||||
cloud.google.com/go/automl v1.20.0 h1:Gh9BlFogtzwSxaEfnx33XD8xJ+z0v33yLQd46/Ka7uM=
|
||||
cloud.google.com/go/automl v1.20.0/go.mod h1:OkHxjbVDblDafhwuP8yEkz1xcUJhgcbhbsieCW7GaiI=
|
||||
cloud.google.com/go/automl v1.21.0 h1:brKXr4i9AMVfgbCKOYSu4rnIoz1ILIgcIAy3nerzEGc=
|
||||
cloud.google.com/go/automl v1.21.0/go.mod h1:MNbhUevuECzM3jqSOM7hmOedOdRJkm8xbbXW44SU15U=
|
||||
cloud.google.com/go/baremetalsolution v1.9.0 h1:c56Ygy+4Lr8WtnLG4nV2VIzhhGVgsa6gSEPdp83/PYI=
|
||||
cloud.google.com/go/baremetalsolution v1.9.0/go.mod h1:o+stutiS8t+HmjNIG92Gkn8H9+5/q27d6lQp7e9GWdg=
|
||||
cloud.google.com/go/baremetalsolution v1.10.0 h1:6MaZilXzGZJ3mGqbSCdDezs9yrze6kcmBABsRUZ/aYI=
|
||||
cloud.google.com/go/baremetalsolution v1.10.0/go.mod h1:xhhT9VQiKPFd2fUs4oeDSRrxV0sb0PGeVmuZoUE2cBA=
|
||||
cloud.google.com/go/batch v1.19.0 h1:i4xCFKCvzfkSldUPYWL+DgBpKVTC3N8DSK6E9rFSbqQ=
|
||||
cloud.google.com/go/batch v1.19.0/go.mod h1:dpWfhLmLQZqsTBAFYjZA3pS04fCY5ttTenZcWmSeILw=
|
||||
cloud.google.com/go/batch v1.20.0 h1:nTL5x9HA1yVdPIdeqcbLjNnV9iYDYvMNmP6AnZ+qbHU=
|
||||
cloud.google.com/go/batch v1.20.0/go.mod h1:ABT/5QqsIDsONa+n/8C7XYPjwh/kjOEPXukcRTaMsCg=
|
||||
cloud.google.com/go/beyondcorp v1.7.0 h1:SHAZlC51z6ZO/OZZABnrI/Yk/z3GkhBREQC7qtgTo2I=
|
||||
cloud.google.com/go/beyondcorp v1.7.0/go.mod h1:vujdO0wfsBV2y1egrJxGtwKZr5P5V6bIHKWp1phWHBY=
|
||||
cloud.google.com/go/beyondcorp v1.8.0 h1:rsyle6zjxce2s57BpULw5u170xS+KSjFENSY7C0MZbs=
|
||||
cloud.google.com/go/beyondcorp v1.8.0/go.mod h1:aVxzwamO8H4GXWQHowBAmL0KYNfYpW4E6Do2wfP0RYs=
|
||||
cloud.google.com/go/bigquery v1.77.0 h1:L5AW3jhzEKpFVg4i0mVHxKpxogrqT7dczWBSr4m9MKU=
|
||||
cloud.google.com/go/bigquery v1.77.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM=
|
||||
cloud.google.com/go/bigquery v1.79.0 h1:+tW6oRuP/4dOgdl/p32on0+3OktVQtU+veOMxhjoOhE=
|
||||
cloud.google.com/go/bigquery v1.79.0/go.mod h1:QTt5tgZxqqvZs3dOZKpvriGqy+CdvY9LyetirFZRPOE=
|
||||
cloud.google.com/go/bigtable v1.47.0 h1:NGLgDSr/i79BTGCjxH/maPKxyvl5q8/SsBsyLK52kdI=
|
||||
cloud.google.com/go/bigtable v1.47.0/go.mod h1:GUM6PdkG3rrDse9kugqvX5+ktwo3ldfLtLi1VFn5Wj4=
|
||||
cloud.google.com/go/bigtable v1.50.0 h1:lihc3U/eVrlIjK55i93K8sol+pKtFozIJ9vooEuNd4I=
|
||||
cloud.google.com/go/bigtable v1.50.0/go.mod h1:RTannV5mvoJM8KscLTfRYMPo84u9/j+C3PSyYJGf5Ic=
|
||||
cloud.google.com/go/billing v1.26.0 h1:6RRjbRd6iZKZFb7/MgRvmXKq/Ism02ckkZLJazj4CQ0=
|
||||
cloud.google.com/go/billing v1.26.0/go.mod h1:axqDO1uHegh7u5qngkTfqN1djAeLGsWAFAblERgmgEk=
|
||||
cloud.google.com/go/binaryauthorization v1.15.0 h1:yzkO2Hv1HHDs3+98Twtae9a9a2bEkufu7zTc9tRCiMc=
|
||||
cloud.google.com/go/binaryauthorization v1.15.0/go.mod h1:+0CndCJPtcHuVCNok+qQskWvbP5Sp5m6eGL8Vpu5mss=
|
||||
cloud.google.com/go/binaryauthorization v1.16.0 h1:f1iNUaHWP9XPdh6grftTlIRuJSBytud4j51l1Jce94E=
|
||||
cloud.google.com/go/binaryauthorization v1.16.0/go.mod h1:E+iC5Avu4pdItdzGiSGHnh6TfQrl+KmPxDDg/T/VuHs=
|
||||
cloud.google.com/go/certificatemanager v1.14.0 h1:31fCXgMFDLSXh9HeF2M6hLE+dPF/1UFyIJXLmqpr41g=
|
||||
cloud.google.com/go/certificatemanager v1.14.0/go.mod h1:QOA8qRoM6/Ik03+srLnBykenGTy0fk78dnPcx5ZWOW8=
|
||||
cloud.google.com/go/certificatemanager v1.15.0 h1:PwljEZlI3lZgoFUG0pnIBNOwrQUaAP+gS0ZahTwUZBA=
|
||||
cloud.google.com/go/certificatemanager v1.15.0/go.mod h1:8dfGG2/TbUpCNqsCF/TIMOGV0OVvU6nhkZWTU4MmCXU=
|
||||
cloud.google.com/go/channel v1.26.0 h1:lvEuQo7hmVsgedO9aLaIBvXRVg5EoK3jskKdYJl+Vyg=
|
||||
cloud.google.com/go/channel v1.26.0/go.mod h1:04T5Wjq+mHlvEUNzExydnBW1vO64q3Q2Wsblp/dpBxY=
|
||||
cloud.google.com/go/channel v1.27.0 h1:fqDZDzVw61c4iIUe7rQ+cBYlBdSZ3yqnzw5WrZTPjqs=
|
||||
cloud.google.com/go/channel v1.27.0/go.mod h1:9ekufBLXuQ6j1oyqtDSIp29qWU5EwCi8WUi9qkLn3MA=
|
||||
cloud.google.com/go/cloudbuild v1.30.0 h1:iOvtaQAcMmdLJaseR6qV76RgFHAAwZlwbpHwWMTqIdo=
|
||||
cloud.google.com/go/cloudbuild v1.30.0/go.mod h1:rg52xEmndQQPiC9NV/8sCaVtKxHMU9D9MeU+oE9VGKA=
|
||||
cloud.google.com/go/cloudbuild v1.32.0 h1:tlF+KSIJJ0kaKkxACpk9htZ1euDpyOqPuO1aHR4j3oI=
|
||||
cloud.google.com/go/cloudbuild v1.32.0/go.mod h1:mYgcM8CMaPmAnO7GxSQ9ADAxVRwS+1b7s6WVkt29OXY=
|
||||
cloud.google.com/go/clouddms v1.13.0 h1:/oIzRKf/FgUYqSBwSnwrrtJPkSQ2EMzY8UHQwhGXoJk=
|
||||
cloud.google.com/go/clouddms v1.13.0/go.mod h1:aMgrOZ+/EKF/PL+h1sDbS+7fAIYV5rTwD+G/apCeHQk=
|
||||
cloud.google.com/go/clouddms v1.14.0 h1:oaktwVyUeKTuzjpde3xbpmc9bAQPQq6WJWOBr7K6YEA=
|
||||
cloud.google.com/go/clouddms v1.14.0/go.mod h1:qSwET2Q27cJ4wCDsPsbkagXqQqkWfOy+gU3RjMsT/c8=
|
||||
cloud.google.com/go/cloudtasks v1.18.0 h1:KzT7hfix/9/xAf20tNPIxwX59XGpRF0Lun2t8LHOj9E=
|
||||
cloud.google.com/go/cloudtasks v1.18.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE=
|
||||
cloud.google.com/go/compute v1.62.0 h1:tJ7lKJ8YEVa6vZX03Jc8o1YePbjKDOQhDw1BscMZ1bs=
|
||||
cloud.google.com/go/compute v1.62.0/go.mod h1:Xm6PbsLgBpAg4va77ljbBdpMjzuU+uPp5Ze2dnZq7lw=
|
||||
cloud.google.com/go/compute v1.64.0 h1:7MmuzeAxlG5MOG5PQD2NLtyYR6bWjkvGljRu7pByoRU=
|
||||
cloud.google.com/go/compute v1.64.0/go.mod h1:eHhcRZ6vf70fQCS3VEsiWSh+nQ+tLvSMb7mwLQskgN0=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/contactcenterinsights v1.22.0 h1:VzNZG5RxHhRWlhmPg3GHUmPDqsZXbHq1GJ9yw6ISJbY=
|
||||
cloud.google.com/go/contactcenterinsights v1.22.0/go.mod h1:2Crd36H59Lwkt4gWrLgmnbnF59IIZIa3XYt1gtNqJkQ=
|
||||
cloud.google.com/go/contactcenterinsights v1.23.0 h1:EtrRf8T7ajR+dR8nseDxwKy3o1VaWP1GFAu7lgMmT9A=
|
||||
cloud.google.com/go/contactcenterinsights v1.23.0/go.mod h1:uB/kygbfYH/gWEq3NEgq3QRI7/MvpjFyX81ajcW5YAI=
|
||||
cloud.google.com/go/container v1.51.0 h1:KdeWHqwlOvABdhkhSTSb4QFySMOuXnc3UUoQMmq9lic=
|
||||
cloud.google.com/go/container v1.51.0/go.mod h1:EvqoT2eXfxLweXXUlhAMGR0sOAB00XPzEjoL01esSDs=
|
||||
cloud.google.com/go/container v1.53.0 h1:3zUySblDfZI9c+5gUWVOacKrdsQwiEerK4HM8eS/Ud8=
|
||||
cloud.google.com/go/container v1.53.0/go.mod h1:SBOylKhlKYCBFs/8kz2yqRdUW5ctVNHs82JKOTjrB9s=
|
||||
cloud.google.com/go/containeranalysis v0.19.0 h1:89ZhFvJHWzX9/jUJy/IEVbtSa2hv4+NtsvwWMEsUYnY=
|
||||
cloud.google.com/go/containeranalysis v0.19.0/go.mod h1:Zq0XHzUIa0oTa7H6aSR8HWqeJnoRI9syUcYJzfozjZQ=
|
||||
cloud.google.com/go/datacatalog v1.32.0 h1:fyYn8ODkGil5y3zTIqgIhOfzTu1ACaU2o+C750CO6Ac=
|
||||
cloud.google.com/go/datacatalog v1.32.0/go.mod h1:DE272tynQUwheJeQAyVfV+nO8yrdkuDyOgH2LtOrkWM=
|
||||
cloud.google.com/go/datacatalog v1.33.0 h1:8V80PpoAGdOOr2QhBrp4wZ66MDCbATdAB/fmVmo5rlU=
|
||||
cloud.google.com/go/datacatalog v1.33.0/go.mod h1:/EMN04S73fZcPdtNg86VYLDrhi2HheMehQtMCS86Klk=
|
||||
cloud.google.com/go/dataflow v0.16.0 h1:BchGCAl9QIZ/pyTGokv5V3daxBTrXsOmrU4ARXoTNd4=
|
||||
cloud.google.com/go/dataflow v0.16.0/go.mod h1:BWhSrIGmsMfuYj3J+nJ2Tw7tplRR6r28kvRiqCD3WlQ=
|
||||
cloud.google.com/go/dataform v1.0.0 h1:EExrLoU1kh8wYxjeRW/LUIlC4yk4QW5ikoZMbI0mgtE=
|
||||
cloud.google.com/go/dataform v1.0.0/go.mod h1:i1a0zkS751kvrY1IIPpUQZ77H5doxx7cs0AP3hnXTMk=
|
||||
cloud.google.com/go/dataform v1.2.0 h1:eAIsWhr6AbUIX+wOdfctO6hAr9UoQnTR2qQW739XH1I=
|
||||
cloud.google.com/go/dataform v1.2.0/go.mod h1:Lhkjd6L04/nBqsEo7S9Tx7D+Vm0pDDDZuKczewAJuX0=
|
||||
cloud.google.com/go/datafusion v1.13.0 h1:rpmpw3F9clEDTk1uCAMjPwJblRGjlW1tQEMEiTC/tR8=
|
||||
cloud.google.com/go/datafusion v1.13.0/go.mod h1:MQdANs3I/4gitzY+mTBx27rrQyMiUg8uc2Z4TPLWWfc=
|
||||
cloud.google.com/go/datafusion v1.14.0 h1:56rjOW8xFBnTOLgTEWdU09KhVC2/X+y/ukzux7zYqBE=
|
||||
cloud.google.com/go/datafusion v1.14.0/go.mod h1:2z+uDUKkLPacNNos5lW1Jf1IRDoFyeE+glJ4hmxF2Uc=
|
||||
cloud.google.com/go/datalabeling v0.14.0 h1:hlmO3GBCfiU23UovEKnJoKDKzr+7Du5x1UxXm+4U5AY=
|
||||
cloud.google.com/go/datalabeling v0.14.0/go.mod h1:DYjvP4RhQ0332YgO22APYlBjCebb+SCaS0e2KApDq/Q=
|
||||
cloud.google.com/go/datalabeling v0.15.0 h1:9TL2kwlO/ODD1fne3uvgTLa53SttwrQi92Bn7KWnKhg=
|
||||
cloud.google.com/go/datalabeling v0.15.0/go.mod h1:H8WSRKD9XYCDXDlZE3bPgvV7UYI0F05e+ufKev2AFc8=
|
||||
cloud.google.com/go/dataplex v1.35.0 h1:EKEhiy/SGYwCH2DZ2r8JEFq1Hx+x+fjJZXRDY3rgPEk=
|
||||
cloud.google.com/go/dataplex v1.35.0/go.mod h1:B7AFwXU1u3sp7FVQ3IFYnQguGTycJS2mF1voE0lLe1o=
|
||||
cloud.google.com/go/dataplex v1.36.0 h1:r4O6RHEkwHr41ItsTUym0ZgH1iDf48R+W8fI7KoMNl8=
|
||||
cloud.google.com/go/dataplex v1.36.0/go.mod h1:ftgNMXBt+wJ4wPVNvYJ3UY3VTZtKS/i/uFEQppaEbKk=
|
||||
cloud.google.com/go/dataproc/v2 v2.23.0 h1:7PR3Aa+NO+AESWUv1dt6aFHfXizIx/zo6N2sdQuEWI0=
|
||||
cloud.google.com/go/dataproc/v2 v2.23.0/go.mod h1:dOzSynzBm7TBf9nIxmJxKAQt5EpdNPcNJomYfbpPhm4=
|
||||
cloud.google.com/go/dataproc/v2 v2.25.0 h1:IF1wxkXvcgoTP92NVqX6QNDf2SdKNZFPa2IWeSKP310=
|
||||
cloud.google.com/go/dataproc/v2 v2.25.0/go.mod h1:hkiM6kzc8CwLGoquMN1oghyhuI1fE0girmChH4h9W7w=
|
||||
cloud.google.com/go/dataqna v0.13.0 h1:VR1y/NuN+RvPkmfmlowbmk60BLINR4MgZAusVFIGQjU=
|
||||
cloud.google.com/go/dataqna v0.13.0/go.mod h1:XiVVFTOEJLBSvm3ILbyjXngGQYpjb/66MSksqz/56fs=
|
||||
cloud.google.com/go/datastore v1.24.0 h1:auNUPJTT9gFcHNj2iKOEeE23nrjf7dE7VA6TO3jw8h0=
|
||||
cloud.google.com/go/datastore v1.24.0/go.mod h1:cEkLhU6Ti/gauQ7DFrUrG8bQjiMIxi++b5ePiThi5So=
|
||||
cloud.google.com/go/datastore v1.25.0 h1:zUjMnCLCcRZVDSdQIXsbnNCl1SVRNw5Jm0J77gPaPKs=
|
||||
cloud.google.com/go/datastore v1.25.0/go.mod h1:jvJVNe+S2nHVIndV1H/B4s9K3MLsTMqOKlxSrzHTxB4=
|
||||
cloud.google.com/go/datastream v1.20.0 h1:/Xv8hdolIN5SpMgxiiuDc8AncfEuXU9TWRhvQkngCq8=
|
||||
cloud.google.com/go/datastream v1.20.0/go.mod h1:uoWTtfP20W8MXuV2DPcl5zqnVsxQ9QEmmBHX858oYTQ=
|
||||
cloud.google.com/go/datastream v1.21.0 h1:hrWGASBj85xs6wpnx9ifdSBYiRLhKDgQrA1HjeNYBhc=
|
||||
cloud.google.com/go/datastream v1.21.0/go.mod h1:z9AlkQGdXqkeyO5HE+D6sYbOkLJYB4BCZpXFPX/1Vpo=
|
||||
cloud.google.com/go/deploy v1.32.0 h1:vA9yH8EEXOsq1caJpvkJl9wJYA9VU8xuU45V7iC9XHk=
|
||||
cloud.google.com/go/deploy v1.32.0/go.mod h1:lUG7maG/NkoTXmQ8G1mtcVymnbizfDJh6ER7vljVa/U=
|
||||
cloud.google.com/go/deploy v1.33.0 h1:2PdZ8kbIztLVRMrIN/wHbalZeZZikQlxBptBFK+eEtM=
|
||||
cloud.google.com/go/deploy v1.33.0/go.mod h1:QdF3plD8D5gV2RmkTXBB6cHrq490WlpFr1SChdOJO2Y=
|
||||
cloud.google.com/go/dialogflow v1.82.0 h1:PKC7h47s036UsW4YxTV2aRCTOChEzMzioczRdlKSApk=
|
||||
cloud.google.com/go/dialogflow v1.82.0/go.mod h1:UtuiGOq9gAlTz9u4Vt+q1syMrx9ANQzTk+lC3WDdSOw=
|
||||
cloud.google.com/go/dialogflow v1.84.0 h1:BymJ6nPotDcnphK2/D0VHMAfakjJlIV5GqxLHQg/Tos=
|
||||
cloud.google.com/go/dialogflow v1.84.0/go.mod h1:OU8Lj1aw5Vr2hl9ifW+vsKnc2b4iJH+41U7nZ4whg3U=
|
||||
cloud.google.com/go/dlp v1.34.0 h1:SI7KSLOtAfhEAw3af8NxpKHGHbJ9BkoM7805TMaU6m4=
|
||||
cloud.google.com/go/dlp v1.34.0/go.mod h1:+haQd/n0QTv5BK7wZnCk2qctd5sfKL50jjh9E6N0d/Q=
|
||||
cloud.google.com/go/dlp v1.36.0 h1:2KOeg+++hpyln8WvbVZGTdgtXxWzpiKQ3re+3dPj5ts=
|
||||
cloud.google.com/go/dlp v1.36.0/go.mod h1:UW92dBhxvqkSKLct+Ril7Y9B4CanS5VLuDwlTGVA9VQ=
|
||||
cloud.google.com/go/documentai v1.48.0 h1:qodgYZJgA89EWNJeeWXWBe/5kq9C5cQhthJweI6Z6CE=
|
||||
cloud.google.com/go/documentai v1.48.0/go.mod h1:mGjfbNf0cqCHKgxMZZV7frbfoF9T2hKkU1h88QyOy3c=
|
||||
cloud.google.com/go/documentai v1.49.0 h1:WLZOaWvK4NH7xMLxANsDgIdXugeqmVyYRCbVN1m87DQ=
|
||||
cloud.google.com/go/documentai v1.49.0/go.mod h1:VyQA+SxPnCPlVLSJ5UcFx+LQm8JCzK7uUXdkOaAHvG8=
|
||||
cloud.google.com/go/domains v0.15.0 h1:X5RjcYzpsVkuTMZ3OfuSDIv9pBtMlDEk7XXunlfB518=
|
||||
cloud.google.com/go/domains v0.15.0/go.mod h1:BjoSVNc+LVwoHMnE2fxTQNzGLSWWb6f3a8VAN6+VjVk=
|
||||
cloud.google.com/go/domains v0.16.0 h1:h+lcYPxyEulj5SYuH3+OwOsLZb4DTW11QX6nlxxXQ1I=
|
||||
cloud.google.com/go/domains v0.16.0/go.mod h1:O5AhaEyUAgZC2X4M10nSu3dQt2cJLtbjhtrNrdeSPF8=
|
||||
cloud.google.com/go/edgecontainer v1.9.0 h1:9S7YGenFNDVMoh5tulCbSniETQ+XxgjDDie/sEhdkw8=
|
||||
cloud.google.com/go/edgecontainer v1.9.0/go.mod h1:mZmgXuMGTGI6RUUTXsOZa+F2rFF21v0JPnuX7LQEqBE=
|
||||
cloud.google.com/go/edgecontainer v1.10.0 h1:4WOjcIZRCB4ynxH9Zs1tteIcm2LD58VUCLLmIwLYUAc=
|
||||
cloud.google.com/go/edgecontainer v1.10.0/go.mod h1:g4xb11IzVWa9peXNTlnNguKP8uJVvMK4zZeDlGS2Wus=
|
||||
cloud.google.com/go/errorreporting v0.9.0 h1:LlE2SVIbz0k+OSeNTksk34inr3Fy62JMhHUvNaS8f7c=
|
||||
cloud.google.com/go/errorreporting v0.9.0/go.mod h1:V7ojx7z76JITDZNGyDNkIIa9nNEkQzF6Yj+VHl2YF84=
|
||||
cloud.google.com/go/essentialcontacts v1.12.0 h1:+AtGn+hYLdr62sMqP0ZGMRzp9A4t+MSWe8eZoD/ho+M=
|
||||
cloud.google.com/go/essentialcontacts v1.12.0/go.mod h1:W8fTL17jP6vmsPHQaCT5rOjWGohEssuqDUroxnjST0A=
|
||||
cloud.google.com/go/eventarc v1.23.0 h1:/EUAdoBWSlqQRbpQYTV2Msmg4esw3Mum3tEU7zkhLi4=
|
||||
cloud.google.com/go/eventarc v1.23.0/go.mod h1:tIJL0hoWtZXVa5MjcAep/4xB+AXz4AbqQV14ogX5VwU=
|
||||
cloud.google.com/go/eventarc v1.25.0 h1:+XAJNEmxJIPZLMtzLYdUZN61Kmqx62hWLry4AsWaTwo=
|
||||
cloud.google.com/go/eventarc v1.25.0/go.mod h1:ncY2NKHKiX+sUjIfxVozrivvmJQ4HWo2znxms7AxlP8=
|
||||
cloud.google.com/go/filestore v1.15.0 h1:ZYFAnP4elMogIQAXFwPx4nKpcvY0dJOZV+zl2l50MGQ=
|
||||
cloud.google.com/go/filestore v1.15.0/go.mod h1:oD+PvCWu4HqfEdNv65yk2XaLIiP7h4AuAH9Ua5YBRTM=
|
||||
cloud.google.com/go/filestore v1.16.0 h1:2GXy5jvq2oUX6HnCpx3GjzSqlcMz8K9gkHaF9HPcYNE=
|
||||
cloud.google.com/go/filestore v1.16.0/go.mod h1:szr35omqptDEuXgBbJ8PdVdYM3lf/Md96kNufWr1tVs=
|
||||
cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E=
|
||||
cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU=
|
||||
cloud.google.com/go/firestore v1.24.0 h1:x0Z3hrgjYgo2wI9whuBRQcNc2hYwzZDQy/7pkUXbXcs=
|
||||
cloud.google.com/go/firestore v1.24.0/go.mod h1:5aojyjN4olKUnBZDCRWwM+NsdrrCX3t1qfyERZGOonM=
|
||||
cloud.google.com/go/functions v1.24.0 h1:0nb8LMMABq/oChZg+ovRD5bsc/dNm5ti/aoHRZ9MoUs=
|
||||
cloud.google.com/go/functions v1.24.0/go.mod h1:t40GeqBAQNuqKlHCxmV/pxhyYJnImLcvRa3GBv4tAy0=
|
||||
cloud.google.com/go/functions v1.25.0 h1:ndUtLkam3XF9b0t2zVACH9D/EgFBISbVuQFqRz/X58k=
|
||||
cloud.google.com/go/functions v1.25.0/go.mod h1:b/tqakoKeAkj9RspEjqswWf5299Lkz9C/742QUD3OEk=
|
||||
cloud.google.com/go/gkebackup v1.13.0 h1:QyeJc4XPqV0hzoAcAQIi8YMveT8eRI21oOK16qKItJo=
|
||||
cloud.google.com/go/gkebackup v1.13.0/go.mod h1:D2MDbHW4V/uKCmS9TnT8hNKX2tPkE/pWp9nSm0TQ9hY=
|
||||
cloud.google.com/go/gkebackup v1.14.0 h1:li3BtGRis1QYrkLo8+Iq2wf5WbP9v3sz9VoUw8WqgaA=
|
||||
cloud.google.com/go/gkebackup v1.14.0/go.mod h1:kaD4l/s0ONcb3L9iHC8PzG1XkC5ggPwA/KAl6yAyQGs=
|
||||
cloud.google.com/go/gkeconnect v1.0.0 h1:IDuAD/w0Xph8k/31vdt6pBkDdRx55lVXz7gW8ABOfCs=
|
||||
cloud.google.com/go/gkeconnect v1.0.0/go.mod h1:5iWSBQzMIRLwUHUWVhxxcNK45ZPE8ntyBgE0MkavlqQ=
|
||||
cloud.google.com/go/gkehub v0.21.0 h1:Fvx6c94yYToZBlsYF7tBIt+LW1u6uY6WYK/h3Z6IZYI=
|
||||
cloud.google.com/go/gkehub v0.21.0/go.mod h1:xKePlMrI8LpKErzKMWdH/yQv+GDV60ypCNfTTdT+BN0=
|
||||
cloud.google.com/go/gkehub v0.22.0 h1:gHKPoWQuWpd9dXLoC58dxwBvSYTNc5/fdYYcs3JUn1s=
|
||||
cloud.google.com/go/gkehub v0.22.0/go.mod h1:WiXX1w9ZHwKZVUDwL//YQfjfWS7yE0I/ym3smZn9iwE=
|
||||
cloud.google.com/go/gkemulticloud v1.11.0 h1:MTqEPjiNVY9bcliSfQR23HHaTPlfFinDh+4ARB5Gn14=
|
||||
cloud.google.com/go/gkemulticloud v1.11.0/go.mod h1:OtfHtgqOgDrXfcdFw8eUkCUI154Q51vvdqZYZV4c4qM=
|
||||
cloud.google.com/go/gkemulticloud v1.12.0 h1:3LPj8ro7bPdZ0BlQJwby25G32rRezGLRdhNM6U3AxGs=
|
||||
cloud.google.com/go/gkemulticloud v1.12.0/go.mod h1:vLNCxGah7pPIoNSX4Yx+hb8klqA0lzzXTWBSut9KzRo=
|
||||
cloud.google.com/go/gsuiteaddons v1.12.0 h1:kz9DyBk84wZCda2Rdha1MOIf7/9x6R+N+LuH9B3zYFs=
|
||||
cloud.google.com/go/gsuiteaddons v1.12.0/go.mod h1:rm/XT7wmwOFGn7jmWtVV65QmZCakzTbHLSojIC4Hskg=
|
||||
cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
|
||||
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
|
||||
cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk=
|
||||
cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY=
|
||||
cloud.google.com/go/iap v1.17.0 h1:BDAVJy+juq7cMRumIx9toc4pt1K7zXoZdAI3lDD6D3g=
|
||||
cloud.google.com/go/iap v1.17.0/go.mod h1:b+r+yjrss2WmAEzNrQQjlEdD5E9B8c47mOF7XnqT+z0=
|
||||
cloud.google.com/go/ids v1.10.0 h1:uk4kW7UYUtIzlQigKreGKXq4HzbXrspjJ5SzUfPV6qg=
|
||||
cloud.google.com/go/ids v1.10.0/go.mod h1:uCSFrXfCnRUKBl5PdE/ZqBNp1+vKSKPWpdYGa61WjpQ=
|
||||
cloud.google.com/go/ids v1.11.0 h1:394LkKIavhv/+oQVMtbXe9WQqCeY9wSFcHktqC9ZkLE=
|
||||
cloud.google.com/go/ids v1.11.0/go.mod h1:+drdvU0pQ4x5uYiWCv364VOeIpTN/PETBrdR51D4Tjk=
|
||||
cloud.google.com/go/iot v1.13.0 h1:pyt1EuMFpbV/2BlmYlXr+HBnUNSPKlP9dVK/dpFTu1U=
|
||||
cloud.google.com/go/iot v1.13.0/go.mod h1:62W4n2fe/Ct66NWJEfCB5suZ3XsL5Atx+MxFjScr+9s=
|
||||
cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE=
|
||||
cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U=
|
||||
cloud.google.com/go/kms v1.32.0 h1:s+rEluaaZKhLVjrIWG7uNBsnWbiitElzNzFGyp6+nIg=
|
||||
cloud.google.com/go/kms v1.32.0/go.mod h1:CSGvW6GnMQbY+1nOHcIzhMtHSbExXlOmCKjWtYVjcpA=
|
||||
cloud.google.com/go/language v1.18.0 h1:q58bL7rmxvw6Q6VHt+wjFsAE1Tj/JkuAEIR4+84rx9U=
|
||||
cloud.google.com/go/language v1.18.0/go.mod h1:xSeiVB4UiA9wYmFy2GWjf1Mb1K3uR1Yi/80qoqTxH04=
|
||||
cloud.google.com/go/lifesciences v0.15.0 h1:sLkI7iAWGPkptWD5f6P9UX6JKiCp5gc4uoa07F8WykI=
|
||||
cloud.google.com/go/lifesciences v0.15.0/go.mod h1:FwS+QkqPdVWl4SmKUCFozFvsTVWTLH13HCKcwR/MR9U=
|
||||
cloud.google.com/go/lifesciences v0.16.0 h1:T+7N3oVHK8a7BhX6HJLrBW8xEbMV9iY8Mb+aOl7r6+A=
|
||||
cloud.google.com/go/lifesciences v0.16.0/go.mod h1:axEwGa3A63+vCXIis+0Zkseu8KecqtNoSn7x0zyjJfM=
|
||||
cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k=
|
||||
cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
|
||||
cloud.google.com/go/logging v1.19.0 h1:NCqhdVUg3wQ8Cobdf16FDSuTGi3+6+hdSBHrY5TsR6Q=
|
||||
cloud.google.com/go/logging v1.19.0/go.mod h1:i40NZCHC9Gqvod4yE+yQfDWwlgwW/SrshkkGibCHxcA=
|
||||
cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY=
|
||||
cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
|
||||
cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM=
|
||||
cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0=
|
||||
cloud.google.com/go/managedidentities v1.12.0 h1:tGderKWJBrOee9BtGul26gA6425tdbZXkbK0ZSkbAE4=
|
||||
cloud.google.com/go/managedidentities v1.12.0/go.mod h1:rm72jf/v//0NG73VQNZM1JlV2E95uhJymmSXlgi6hMA=
|
||||
cloud.google.com/go/managedidentities v1.13.0 h1:ZzWkg3LSrIi9OdzCK3bxutexvw1dDDg9+tOMitbkOXw=
|
||||
cloud.google.com/go/managedidentities v1.13.0/go.mod h1:lUYH5r6QEJTHqjgga0WFeiieqJ0iRwEuQSk20O41Vj0=
|
||||
cloud.google.com/go/maps v1.36.0 h1:MkS6PUuiVmn7YsDv1SEzffTmZ4ucgaiBpZzsz1cE5mk=
|
||||
cloud.google.com/go/maps v1.36.0/go.mod h1:Ly0sd/0G1MgKuWpGc2vCBjNZ+fc8iRHzcBWJqrw7Xao=
|
||||
cloud.google.com/go/maps v1.37.0 h1:YoTRWohpNKeKxdQynorwHH69NZuUM06divZtqGPYjUA=
|
||||
cloud.google.com/go/maps v1.37.0/go.mod h1:oalKFBmf2eHmdr3OvfEiiBlOakNlVitYYEPcM3TTUB4=
|
||||
cloud.google.com/go/mediatranslation v0.13.0 h1:qqMRAK0mhc9M+lM6pb791oh3DYUbwLcIy58jOyXtadE=
|
||||
cloud.google.com/go/mediatranslation v0.13.0/go.mod h1:kjZrowuigFr+Bf1HM1TCtp1a3E3kfG1ovPK5VEuaNAQ=
|
||||
cloud.google.com/go/memcache v1.16.0 h1:J6Iq97D6rlDMTJRnTjP3tttRrop8bZDCEKpbsmu0K1c=
|
||||
cloud.google.com/go/memcache v1.16.0/go.mod h1:y/rXhJiieCF742K958dY29fSfM+Y3wh2thRmWspU2Dg=
|
||||
cloud.google.com/go/memcache v1.17.0 h1:l/co1jsmVLkNyl8uMqj1u1HIwd3AhCvGdHbEAIPb6jc=
|
||||
cloud.google.com/go/memcache v1.17.0/go.mod h1:QQpFWgJvrFaQ6DgmitHejdbkLg8SJfHg5BzltKEWSt0=
|
||||
cloud.google.com/go/metastore v1.19.0 h1:oAFi3AkO9YZHoDYXo3cbLXlBS4PUUxe5/9kR+q4ta1g=
|
||||
cloud.google.com/go/metastore v1.19.0/go.mod h1:JGTjGdQ627m2ptDo86XsIKqzzZCk+GG41VEFD7ENsqs=
|
||||
cloud.google.com/go/metastore v1.20.0 h1:kz0XBkP531MB1IR9jTY6/DB33UFP5Df1vFpPNgW1iPk=
|
||||
cloud.google.com/go/metastore v1.20.0/go.mod h1:/bhZoizjM5iOrqWJeAFDw7c16C783wEftqofnJgKKYI=
|
||||
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
|
||||
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
|
||||
cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY=
|
||||
cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM=
|
||||
cloud.google.com/go/networkconnectivity v1.26.0 h1:cnPha9p2FFBbxVQA0D5fRBQsROq6tVFmsDZfrGEObtY=
|
||||
cloud.google.com/go/networkconnectivity v1.26.0/go.mod h1:Uhzfk7NbiY6RNqV9XFvPWRji58+MkTYsTRfQ3EPtrGg=
|
||||
cloud.google.com/go/networkconnectivity v1.27.0 h1:ieYJjbUn2L9ZSzEB9+qKgtoD3/l1/qAnId/Z6KE4ZBo=
|
||||
cloud.google.com/go/networkconnectivity v1.27.0/go.mod h1:pCnczH2W/cnLSlnsnN+VzBoXlM81ZoUGuuacFBGThyw=
|
||||
cloud.google.com/go/networkmanagement v1.28.0 h1:x4U4osf+1qmq7/FRIfjM781mJSeXhmjoDWrbhB4f3Mo=
|
||||
cloud.google.com/go/networkmanagement v1.28.0/go.mod h1:2YogSU3sD7LvtmWntUAuGARbFQmy3A0En3LrJr69jkU=
|
||||
cloud.google.com/go/networkmanagement v1.30.0 h1:LqYm91vSQk3NM6fHjthseRh4xrDvhXIWQBsQY/Ksus8=
|
||||
cloud.google.com/go/networkmanagement v1.30.0/go.mod h1:3SBf5T7jyGzw5jqJWE7TUDRhIl2E029jggbeoFEgt5E=
|
||||
cloud.google.com/go/networksecurity v0.17.0 h1:6rH2+St9F9n6r49bZ0u7X0Bp7tmWkE0ZguYCWU69xKc=
|
||||
cloud.google.com/go/networksecurity v0.17.0/go.mod h1:NlMistWENBCFt1v748gUn4v9Rk5AVCTDTFs1VSE3JUg=
|
||||
cloud.google.com/go/networksecurity v0.19.0 h1:PzJnbVd0sS2+xDR9hnk31NAyqFt9qB8ohLn7XIsKIBA=
|
||||
cloud.google.com/go/networksecurity v0.19.0/go.mod h1:VWDFX+stDgzZYDsCX1Wy/JO9Tlw7g/V1UHbiORVgqq0=
|
||||
cloud.google.com/go/notebooks v1.17.0 h1:fiezRHPH/H4HatBxbzEQljlmDV8MBv2ffWs1Z6TyHhw=
|
||||
cloud.google.com/go/notebooks v1.17.0/go.mod h1:NScGIhfQCqLRIlVaUVbm595F6dhqiTl5XS1KaKgitKM=
|
||||
cloud.google.com/go/notebooks v1.18.0 h1:BgxdQcoRSSjiyszWNOTu3fmXG/6Ks4W2sHgjx0BAke0=
|
||||
cloud.google.com/go/notebooks v1.18.0/go.mod h1:fXU6A3TJ2YobFy6fxOr4tKZZ8QgTjdJAqDIykOB85Gk=
|
||||
cloud.google.com/go/optimization v1.11.0 h1:lh0CcgHOGEAilUn4xS4/gIsSZA4AmTqEhGXdpz6Z+N0=
|
||||
cloud.google.com/go/optimization v1.11.0/go.mod h1:qCWskZMcynh0GBsUrCP6oPwwnUhbwg5UcXvVM9hzOD8=
|
||||
cloud.google.com/go/optimization v1.12.0 h1:Cd4DgujNhEMcjfaVLGqbjfDwVDnmS1Bc93cx9TE8ZYw=
|
||||
cloud.google.com/go/optimization v1.12.0/go.mod h1:28gzCUmeCLcT4vctGEo71QF4b60TYkKQo5y8Gs2KPq8=
|
||||
cloud.google.com/go/orchestration v1.16.0 h1:aVakYx6wLQV8I8ZDydplEKzQ2+hTJ3Qh/lU5/mwijQA=
|
||||
cloud.google.com/go/orchestration v1.16.0/go.mod h1:H7MFVP8Z/dtml39nf43sWYPL/2o7J4tdSZAlJrBuqnQ=
|
||||
cloud.google.com/go/orchestration v1.17.0 h1:dqf2HzqUi4CaaDiNfv6jVoZjz9lF0HWjyIEgccxPHMk=
|
||||
cloud.google.com/go/orchestration v1.17.0/go.mod h1:Lf/Czqh4Jfy3IFpvDkKWjfjkYFI+tj6nAjq5ihivrq4=
|
||||
cloud.google.com/go/orgpolicy v1.20.0 h1:kpVcE/OsC5aAzHCsAiuQSg3+s6ILzgPTuPZyS7n7ejA=
|
||||
cloud.google.com/go/orgpolicy v1.20.0/go.mod h1:9LHqEGx5P5dhansdKTNIEXpM+QbebAIOs66+HUID4aQ=
|
||||
cloud.google.com/go/osconfig v1.21.0 h1:jpq0DNmjS4FkTbNILFdp03uZUQt8D2izpUtgtmSDieQ=
|
||||
cloud.google.com/go/osconfig v1.21.0/go.mod h1:BofnHqjjvu6lZQv/hqo2+rLCUiY4O6A9UYwwvVrSBjk=
|
||||
cloud.google.com/go/osconfig v1.22.0 h1:r5lzneR9GNixJ96lZ6oIfJK9Cd5sKpnzZJe1LcGZf4w=
|
||||
cloud.google.com/go/osconfig v1.22.0/go.mod h1:bUL0FaSR2ahPcFRRYnd6a0LyUzsQYIdUpBq8Tmxg8fE=
|
||||
cloud.google.com/go/oslogin v1.18.0 h1:OkccORMcY2XEHwN5+AP0XH/SCUNBl39GGjaQuSCVcIw=
|
||||
cloud.google.com/go/oslogin v1.18.0/go.mod h1:3Oa36T3781Mv+yCSVYlfasi7auHjfPFqvNOd1q92umc=
|
||||
cloud.google.com/go/phishingprotection v0.13.0 h1:6WJF1z3Ie8fZAiCRpeSGB81h6s86XWS2SBC9iQUZxX8=
|
||||
cloud.google.com/go/phishingprotection v0.13.0/go.mod h1:2gyYqwNjePPEocXDkDve3EuJPaRqN/E7fp28K3arR0k=
|
||||
cloud.google.com/go/policytroubleshooter v1.15.0 h1:nHNbD/2XYM5krsBN9C1W+qSFOlOcbbjct51LXCM1qig=
|
||||
cloud.google.com/go/policytroubleshooter v1.15.0/go.mod h1:yNuROjN6h+2/TE2JOvBBJMjYIjC6j0UYHq8f2kVHlA4=
|
||||
cloud.google.com/go/policytroubleshooter v1.16.0 h1:V3CcqzY6FhY2c2idRGZv0Ef4mFinLnXnfDFtAhxyTfM=
|
||||
cloud.google.com/go/policytroubleshooter v1.16.0/go.mod h1:FZg3IW3exF6wc9eO/iBYijsGqiiCzc9mjZhsxgATXYA=
|
||||
cloud.google.com/go/privatecatalog v0.15.0 h1:sQSFvJIXKM9RYFK3fPIBDSykC5I2TOmCZMXz9Q3DFsY=
|
||||
cloud.google.com/go/privatecatalog v0.15.0/go.mod h1:av2b5Rv+oG5ORxUqGlCAYO9s4pXjgc6q2qO9nkTcqT8=
|
||||
cloud.google.com/go/privatecatalog v0.16.0 h1:SBhcVNVhgEGT1edcfSFY/KSiy2xSQTSQUpY12f10rmw=
|
||||
cloud.google.com/go/privatecatalog v0.16.0/go.mod h1:Dq1bSHRRaDqFr7Rb7UntXVjh1reeY6YdzYicL0EPTrM=
|
||||
cloud.google.com/go/pubsub v1.50.2 h1:54Up97HnThdP4H8jjWJSSQ/mnYG2EKon7ZSNETRq0tM=
|
||||
cloud.google.com/go/pubsub v1.50.2/go.mod h1:jyCWeZdGFqd4mitSsBERnJcpqaHBsxQoPkNvjj4sp0w=
|
||||
cloud.google.com/go/pubsub v1.51.0 h1:XOaCejsqX7EEtUdQz+WPag66wWsUUGliyCOfGPKfo90=
|
||||
cloud.google.com/go/pubsub v1.51.0/go.mod h1:NERXf11sd82UV3VnflcUj8POIyQUXT/QwrKlxD8di/I=
|
||||
cloud.google.com/go/pubsub/v2 v2.5.1 h1:+TwXJr78P9RrMV3S8lKHIhJo2E99jI7ta65e+ujJjts=
|
||||
cloud.google.com/go/pubsub/v2 v2.5.1/go.mod h1:Pd+qeabMX+576vQJhTN7TelE4k6kJh15dLU/ptOQ/UA=
|
||||
cloud.google.com/go/pubsub/v2 v2.6.0 h1:8pjR0id+GTB+krKx5G6AGJoYrHog58w2Q89PCOrfM64=
|
||||
cloud.google.com/go/pubsub/v2 v2.6.0/go.mod h1:4anqvV/w8Pcgu2tO0qr2XgsF3GXHowzryfQ5gOnVmWY=
|
||||
cloud.google.com/go/pubsublite v1.8.2 h1:jLQozsEVr+c6tOU13vDugtnaBSUy/PD5zK6mhm+uF1Y=
|
||||
cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI=
|
||||
cloud.google.com/go/pubsublite v1.10.0 h1:kqNk9e0gt0/Eu6lChjDlgu4B8k/399FZFpAU4LNq4Co=
|
||||
cloud.google.com/go/pubsublite v1.10.0/go.mod h1:o9NVNBY4m8LubZqRCJtBdxpjP8DAsYizsxC6Z1vI7Dk=
|
||||
cloud.google.com/go/recaptchaenterprise/v2 v2.26.0 h1:9qDHoQtUIZ8FDN5uFAfnNz8jyyXdpAJf6rMN4ZMmMcU=
|
||||
cloud.google.com/go/recaptchaenterprise/v2 v2.26.0/go.mod h1:+ntF70/j7qBa6G/pwmYA0mkBcDeTCXV6WDqUL7GObfs=
|
||||
cloud.google.com/go/recommendationengine v0.14.0 h1:kQ+PcZcQBv+FMlZRTp29UYvl3VD5/jsU0MsNgOFTw3I=
|
||||
cloud.google.com/go/recommendationengine v0.14.0/go.mod h1:UP9cN46tDpZ/N57eDYIWeIRHjMOchtiIyjWjV0Dvr3k=
|
||||
cloud.google.com/go/recommendationengine v0.15.0 h1:G0tmQXp67YDaduvde7AX+cIdTtDCo2PBMeqv8SBOpnw=
|
||||
cloud.google.com/go/recommendationengine v0.15.0/go.mod h1:Yx45rCF3A5fLSeXxSkXOCTXSBDBogrQnR7kUTJHwYxw=
|
||||
cloud.google.com/go/recommender v1.19.0 h1:fJ6oO/7Ta/yzfRHcuJUln9iCeo6FDb5yIi2L7eLldzs=
|
||||
cloud.google.com/go/recommender v1.19.0/go.mod h1:LRh+1HJjLx2kDE3S65AIlG/lvwA0llEFWYPD/QtgoaU=
|
||||
cloud.google.com/go/redis v1.23.0 h1:y/NCxLQR46TQufJNjgINfWsRjCxkgClU37mMf/D1EE4=
|
||||
cloud.google.com/go/redis v1.23.0/go.mod h1:EUlUT24BAL6LsE1f/N9Bg3LhRCfH+LzwLGbst3KuZRw=
|
||||
cloud.google.com/go/redis v1.24.0 h1:lV6xFUF7t3fm2ZRFjB4IVNkznG9EVwetYVJYwzmW8pc=
|
||||
cloud.google.com/go/redis v1.24.0/go.mod h1:ebtw9WLFKswecHO2ifNykuteNJNwoPqMCHz4UI11kF4=
|
||||
cloud.google.com/go/resourcemanager v1.15.0 h1:OwcTLrKaly0SMPoYHssPG4FBzRF0tyimeySOFD/YPJ0=
|
||||
cloud.google.com/go/resourcemanager v1.15.0/go.mod h1:ve0VNxPoDU6XxDuEMCjkineb0YzXQXx3mOWwnNckGDE=
|
||||
cloud.google.com/go/resourcemanager v1.16.0 h1:aDf2RuuQ1Z+kbHHDzHyJNMHPGRFK+cMYfZIhWRaeRX0=
|
||||
cloud.google.com/go/resourcemanager v1.16.0/go.mod h1:Hn4HPkLRnTuiUhFEFJg736Brt7BwlS84xYU06sc3STc=
|
||||
cloud.google.com/go/resourcesettings v1.8.3 h1:13HOFU7v4cEvIHXSAQbinF4wp2Baybbq7q9FMctg1Ek=
|
||||
cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw=
|
||||
cloud.google.com/go/retail v1.31.0 h1:nJnfVzX+GOIe+PwDNSYG080ydirDzoD53z+c3y7ZzpU=
|
||||
cloud.google.com/go/retail v1.31.0/go.mod h1:sfq/cT+gfSLuURf/mdVAw5n0pav3hxSP1rT8RfL7Qxk=
|
||||
cloud.google.com/go/retail v1.32.0 h1:u27GLYlnNsQrqzcvSlpZK0L6Ig8k3PEuXGkUdNFCOn4=
|
||||
cloud.google.com/go/retail v1.32.0/go.mod h1:t9w9mBarD59BnFHTST2LoiCP5608ZlEfniHLgA6OoH0=
|
||||
cloud.google.com/go/run v1.21.0 h1:gQJUy0//XNXXpiZs42KlbLPhbycxbpS2QymGRFlPXv4=
|
||||
cloud.google.com/go/run v1.21.0/go.mod h1:Z5wHbyFirI8XU48EPs5XJf/qmVm1SXZEhuS8EvZOuQU=
|
||||
cloud.google.com/go/run v1.22.0 h1:U56fxJWdrT+yjo4S/Vrtw5m69NdNL11Cyv9jX2JOi1s=
|
||||
cloud.google.com/go/run v1.22.0/go.mod h1:Wo0aTNrqfftGmbxPPraeOxSUDUZ2c7IVNg2dk8Qm1Bs=
|
||||
cloud.google.com/go/scheduler v1.16.0 h1:EPdChptxnvCasdMixuu58247qhKMO1iAlrVaQhvuRyE=
|
||||
cloud.google.com/go/scheduler v1.16.0/go.mod h1:0hsZg0MZJADyke1lutI0FHAYJR8Dtm8oIivXkmpACkA=
|
||||
cloud.google.com/go/secretmanager v1.20.0 h1:GjE3NoyFXo7ipRPy26PMmg4oRX1Ra8fswH45r16rWV0=
|
||||
cloud.google.com/go/secretmanager v1.20.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU=
|
||||
cloud.google.com/go/security v1.25.0 h1:U7Op1u7GSbmqEBtQEqxAKG7jMpMFNJ9mTFf+E0HYTJY=
|
||||
cloud.google.com/go/security v1.25.0/go.mod h1:xKPO7XBfUtgjfzPJeznEhI0gp/ZRJt/ZbWtuMYMeUDk=
|
||||
cloud.google.com/go/security v1.26.0 h1:xp/htSPHpyqo225Ju6PjNS9jxfcOEQMxnF229Oze62g=
|
||||
cloud.google.com/go/security v1.26.0/go.mod h1:nd0i5OHXtJduMt0n6UnEojy7fiTfnfj/PSDeD7LAD+c=
|
||||
cloud.google.com/go/securitycenter v1.44.0 h1:/jinB3GeXuNkWfrzK1EdWR+kD4J0z0YGyEe52+gPIoM=
|
||||
cloud.google.com/go/securitycenter v1.44.0/go.mod h1:7BMMbSTAddVfiE+HrC8tKS6SuRkyK7FRPlkpAZBRV3U=
|
||||
cloud.google.com/go/securitycenter v1.45.0 h1:k5uzLtTSxnh4fwG+8nIcuwDjEE3J+6xe2wcZ72cys7s=
|
||||
cloud.google.com/go/securitycenter v1.45.0/go.mod h1:7mAlzsCsKlEVmciAFORl431laDGpoKGFkSQndAzFs30=
|
||||
cloud.google.com/go/servicedirectory v1.17.0 h1:yrohWkwM8t5JfEFCmmlyksKnpMpvxM8XbRpwg+yIo64=
|
||||
cloud.google.com/go/servicedirectory v1.17.0/go.mod h1:CtgjXS1idj3s9Q6tB68021Rzk8Q6decV6+ldXC1BoBk=
|
||||
cloud.google.com/go/shell v1.12.0 h1:eDwvv8ya1BCHCwHCzEIYp/9maLhGCco0LIjeGT4evBA=
|
||||
cloud.google.com/go/shell v1.12.0/go.mod h1:TivWrVriy6xQ0wBjNJJridJgODZz8zXUEW2u48kynzY=
|
||||
cloud.google.com/go/shell v1.13.0 h1:vJ/g4BXCwBRMmUxHoNjdbE0DRh5Aysw/+vrZ/aPl0yc=
|
||||
cloud.google.com/go/shell v1.13.0/go.mod h1:9WWf3xHQUElP5fL/lB9IJ/MMMnN2W/T86cBp+pXFFWo=
|
||||
cloud.google.com/go/spanner v1.91.0 h1:XwXfcZ0kc1NT9Uu2IsThFiWtYptB+WgLn/KZEZcyzRg=
|
||||
cloud.google.com/go/spanner v1.91.0/go.mod h1:8NB5a7qgwIhGD19Ly+vkpKffPL78vIG9RcrgsuREha0=
|
||||
cloud.google.com/go/spanner v1.92.0 h1:cfeMNmtFjz+OYzQVCIuGBw4Cik4CbF2ptXMuRQcUar0=
|
||||
cloud.google.com/go/spanner v1.92.0/go.mod h1:rCDPfWXNX0h+t484r+crCEaaMKbJfoWkHRDKU3H3+oY=
|
||||
cloud.google.com/go/speech v1.35.0 h1:jxWycO5+PfhBWxqnuJNDjNMi85zRK2Jcb4CVhOz6JcA=
|
||||
cloud.google.com/go/speech v1.35.0/go.mod h1:shnf33sZbGnQQZyek1fdLOR5rRKV6D3jsNqpqyijvj8=
|
||||
cloud.google.com/go/speech v1.36.0 h1:ZcmRKcY02vkF3o/Eaa9yI3i1baNFTF4ctyDZVRfnNS0=
|
||||
cloud.google.com/go/speech v1.36.0/go.mod h1:tiSA8MiX49o1ngq5Ww2JFTvfjKxtAuBKY/UIH6coCPg=
|
||||
cloud.google.com/go/storagetransfer v1.18.0 h1:Y8kA7TiPPjiQH7Xsuf2KlBAJd7Jcn5J8aR5ABO81p/g=
|
||||
cloud.google.com/go/storagetransfer v1.18.0/go.mod h1:AbGutEym/KNasoiDpSj/CYbigp5yhgosSgwlhGvQNs4=
|
||||
cloud.google.com/go/storagetransfer v1.19.0 h1:jh+3SegMUulEhuJbkADCv8lmfRC8/pCpxja4oex2Ns8=
|
||||
cloud.google.com/go/storagetransfer v1.19.0/go.mod h1:sy4ImXynHkm9CKmbILtmzLN36PHh7JOhUTpqXf5SvMs=
|
||||
cloud.google.com/go/talent v1.13.0 h1:/nZYKG20ZHfZDr7ikRuDnssxk8fuaDxGR+KH3iB4gak=
|
||||
cloud.google.com/go/talent v1.13.0/go.mod h1:GSwli9V25WQdzeuJDJWH9TlQmA8lPFn7yKsxowdxW9Y=
|
||||
cloud.google.com/go/talent v1.14.0 h1:dUNSwgBUFlDEMHJpmntEvDeLkPQAoEzBrvDbNfS/UeQ=
|
||||
cloud.google.com/go/talent v1.14.0/go.mod h1:jieYQngp1YqRtqV2t92w3LTrjuLV05kMM4BZMUUneaw=
|
||||
cloud.google.com/go/texttospeech v1.21.0 h1:u1Zvij2JgV3Vci3M2YrotjqnmW4px0uhoVoW8Vv6IP0=
|
||||
cloud.google.com/go/texttospeech v1.21.0/go.mod h1:p/UVJILAo/S5vsJaWZVdDRzNzA7wXIA+hTACvpMeOBk=
|
||||
cloud.google.com/go/texttospeech v1.22.0 h1:xGqQv2LB4nttaBNMepCm19EQ0Sk0YdjzPvklDU+LYsI=
|
||||
cloud.google.com/go/texttospeech v1.22.0/go.mod h1:bAksATiWPKaw8r8wVgANa4GkVdsyFE4y9ulRzKyuJec=
|
||||
cloud.google.com/go/tpu v1.13.0 h1:OAtRW+A/+bTLsPS5/trnK7Cz1GceMa2ZlLQzP/ZbSTg=
|
||||
cloud.google.com/go/tpu v1.13.0/go.mod h1:F5gT5BL22Dhsr05JLHdMjAjj+wcTn3Xtuu4jvq9yFug=
|
||||
cloud.google.com/go/tpu v1.14.0 h1:u2f8Lhezy6piu0BB3+4pTLw9xyOeU5n8PCgWjbjCitk=
|
||||
cloud.google.com/go/tpu v1.14.0/go.mod h1:1pggTTG5npfxea6vYjyl60Fg09VgbM7efBgVjnFZjpo=
|
||||
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
|
||||
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
|
||||
cloud.google.com/go/translate v1.17.0 h1:6ecjspRHAOHU+x+e4HOK/2o+bzw7KHwu//eyLVf4TuM=
|
||||
cloud.google.com/go/translate v1.17.0/go.mod h1:3mErnHTQBu9yeLiL35K0HBBuaM6Vk2fD/vyWFz790VU=
|
||||
cloud.google.com/go/translate v1.18.0 h1:2dxWfGMG7y5vobxD2pPyngnfPPIk4MYdNB9QK5sEbuc=
|
||||
cloud.google.com/go/translate v1.18.0/go.mod h1:aRVIE+P+7fngk8HwwFAgis5QA7wphGpKrFpdNoWtGCM=
|
||||
cloud.google.com/go/video v1.32.0 h1:9Us/tkhNRg3WY9wIrVC3Jcs1P0nXKE4XnS1zYJ3xTTY=
|
||||
cloud.google.com/go/video v1.32.0/go.mod h1:KxDL728ZzH+FJwtEb9XkiLTETW5bI37hTWbJiRYeXkk=
|
||||
cloud.google.com/go/video v1.33.0 h1:zPA41kNscRrJeRo/xD73TjNCFAnRDnP9ViNPuWARVeg=
|
||||
cloud.google.com/go/video v1.33.0/go.mod h1:hEx8TNpQT6kdjMVsywePvT8BCb63Ee3F/R0GRa9wnzo=
|
||||
cloud.google.com/go/videointelligence v1.16.0 h1:WSvC2OI6Su3ulwz0aS7qOVQHO7ZtohUyI6GMqvETY/o=
|
||||
cloud.google.com/go/videointelligence v1.16.0/go.mod h1:mmX1JpIWzwozaigrdRNjikZc3aFLNHFKh+OFwAdfiW4=
|
||||
cloud.google.com/go/videointelligence v1.17.0 h1:cd+s4jLMS59xiYLT6eQ6PVKP8R0PskgifXDa7YwMUUU=
|
||||
cloud.google.com/go/videointelligence v1.17.0/go.mod h1:Phxz7AQpvXoOvz+KrrOZEJRo4CDgYXMDVDqhCtdF1jc=
|
||||
cloud.google.com/go/vision/v2 v2.14.0 h1:l4CjEOm9veghGSutx79p+WG6vI6/5DPjRsAasmi9zX4=
|
||||
cloud.google.com/go/vision/v2 v2.14.0/go.mod h1:ODlLCajJOq4t8thoi1uVvbnfIfix73HsYWhZuIveagQ=
|
||||
cloud.google.com/go/vision/v2 v2.15.0 h1:aTR1vj4++WtS9HD6YdGuoaYygMTJ873WaoV9sYjlQCc=
|
||||
cloud.google.com/go/vision/v2 v2.15.0/go.mod h1:DUdjdFkXqPvEoPC4WDYFvYCn0LlAZ4vVz29A0bXvW90=
|
||||
cloud.google.com/go/vmmigration v1.15.0 h1:F2uqT8+JXvSywV381YoQ4To3RnJETRtPkvcbdWXGmgM=
|
||||
cloud.google.com/go/vmmigration v1.15.0/go.mod h1:MP6mQ21ru1usBeCbl805Ioz0Fy+yf3qK2kUkhZ69QQY=
|
||||
cloud.google.com/go/vmmigration v1.16.0 h1:OPGMxx73owRHMve5B4L6W+0KQUwlunyE9kfbYzFHqug=
|
||||
cloud.google.com/go/vmmigration v1.16.0/go.mod h1:ILrSjXnHMpdamkkAU8fjMKKMsH27B6FLC5kv/6TkLy0=
|
||||
cloud.google.com/go/vmwareengine v1.8.0 h1:TmHKgTRH+mjq2VaaxrNcXqWyeleX7YaJPvrfWFCn0eE=
|
||||
cloud.google.com/go/vmwareengine v1.8.0/go.mod h1:e66l90IZhm1yQfYZv+YCWjSNSklQZCRmuEvKL8n3Ua0=
|
||||
cloud.google.com/go/vmwareengine v1.9.0 h1:vdSutihjzH5iYEFlN+1x+gDm4xzj6Md2yMzXADKI5cQ=
|
||||
cloud.google.com/go/vmwareengine v1.9.0/go.mod h1:zXXuUaIpvDhsV6sR+JdQfcQ4V5+pDarrp7FW7nOdS2I=
|
||||
cloud.google.com/go/vpcaccess v1.13.0 h1:aU7IKE/IAUgOzXCOgPsku4nV2DwmRpJHn6+QMf5Ub70=
|
||||
cloud.google.com/go/vpcaccess v1.13.0/go.mod h1:4Uus6E/9FYUtIrwBE1wJ1RosKwb02H6kEd9puJ02TL8=
|
||||
cloud.google.com/go/vpcaccess v1.14.0 h1:fFa1i67AjC/3MuG5GPlvx36sIMeffpsIs3kI1C7S3Jk=
|
||||
cloud.google.com/go/vpcaccess v1.14.0/go.mod h1:MxbVgr+2fpIFIEIdSmgnb8ykNWRPVtslpmWijp7an68=
|
||||
cloud.google.com/go/webrisk v1.16.0 h1:OKkOJ81+YjGnrfN3oBNdpycZqKFNE4w52fSGo32rgNw=
|
||||
cloud.google.com/go/webrisk v1.16.0/go.mod h1:VIQw8smiaMOlget/xOk6niTkNJTiQc5skEmCuAksxJc=
|
||||
cloud.google.com/go/webrisk v1.17.0 h1:t2WMlBo3xXxH6k5kTSkyIYUl24AHgmBI76F/5OqAPRY=
|
||||
cloud.google.com/go/webrisk v1.17.0/go.mod h1:ypwCZ+G/SXyUZ+x3ppxn1hu+6tDifGNd/OpwPtCdJHI=
|
||||
cloud.google.com/go/websecurityscanner v1.12.0 h1:iV+hAXeEo8kKtFWDZWPp9Z0fsziZnuOt4nCCYp68RP0=
|
||||
cloud.google.com/go/websecurityscanner v1.12.0/go.mod h1:cZSc9HqoFdccL1mqZtPIInOd4R8PBGwI20wdnrz6AO8=
|
||||
cloud.google.com/go/workflows v1.19.0 h1:O5LlH7x1QovbDssany0TBe+hcSOcK5gPgIeaoByy0ZU=
|
||||
cloud.google.com/go/workflows v1.19.0/go.mod h1:TWsrDGgsJy7xAJ07byzHhKKehEWItJG3BivEHVhGH5g=
|
||||
cloud.google.com/go/workflows v1.20.0 h1:qROpn1zRDdeIjUP9w9O3MHXJEg8v/pzVMicAcw9vLvQ=
|
||||
cloud.google.com/go/workflows v1.20.0/go.mod h1:TC9yx7VpjGdBBeKM8FG2EMtms5Q9nyTqI+2uV9bDNs4=
|
||||
codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY=
|
||||
codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ=
|
||||
codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI=
|
||||
@@ -276,6 +454,8 @@ github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao
|
||||
github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
|
||||
github.com/Joker/hpp v1.0.0 h1:65+iuJYdRXv/XyN62C1uEmmOx3432rNG/rKlX6V7Kkc=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
@@ -305,6 +485,7 @@ github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqr
|
||||
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
|
||||
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
|
||||
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6 h1:G1bPvciwNyF7IUmKXNt9Ak3m6u9DE1rF+RmtIkBpVdA=
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI=
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM=
|
||||
@@ -553,6 +734,8 @@ github.com/kataras/iris/v12 v12.1.8 h1:O3gJasjm7ZxpxwTH8tApZsvf274scSGQAUpNe47c3
|
||||
github.com/kataras/neffos v0.0.14 h1:pdJaTvUG3NQfeMbbVCI8JT2T5goPldyyfUB2PJfh1Bs=
|
||||
github.com/kataras/pio v0.0.2 h1:6NAi+uPJ/Zuid6mrAKlgpbI11/zK/lV4B2rxWaJN98Y=
|
||||
github.com/kataras/sitemap v0.0.5 h1:4HCONX5RLgVy6G4RkYOV3vKNcma9p236LdGOipJsaFE=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw=
|
||||
github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
|
||||
github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=
|
||||
@@ -659,6 +842,7 @@ github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtz
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rollbar/rollbar-go v1.0.2 h1:uA3+z0jq6ka9WUUt9VX/xuiQZXZyWRoeKvkhVvLO9Jc=
|
||||
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
|
||||
github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g=
|
||||
@@ -785,11 +969,9 @@ go.etcd.io/gofail v0.1.0 h1:XItAMIhOojXFQMgrxjnd2EIIHun/d5qL0Pf7FzVTkFg=
|
||||
go.etcd.io/gofail v0.1.0/go.mod h1:VZBCXYGZhHAinaBiiqYvuDynvahNsAyLFwB3kEHKz1M=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.44.0 h1:1IFH4oFKK8KupzIelCl3u+bkxpGRps1oWRjQI2+TTWs=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.44.0/go.mod h1:JqWFXsc7VDaqIyubFhEd2cPHqsrzqP0Lvn783SUwyro=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
@@ -797,8 +979,6 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHS
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
|
||||
go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
@@ -821,6 +1001,8 @@ golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
@@ -874,6 +1056,8 @@ golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
|
||||
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY=
|
||||
@@ -894,6 +1078,24 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
|
||||
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
|
||||
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
|
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
|
||||
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
|
||||
mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4=
|
||||
mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=
|
||||
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI=
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>sundynix-agentix · 运维控制台</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>sundynix agentix · 事件驱动的 AI Agent 工作台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
# 运维控制台静态托管 + 反代网关 API(一个 URL 同时给前端与 /api)。
|
||||
|
||||
# WebSocket 升级:nginx 默认会吃掉 Upgrade/Connection 头,被代理的 WS 永远握手不成功
|
||||
# (现象:HTTP 接口全好,只有 WS 报 ws error)。语音会话 /api/v1/voice/stream 与
|
||||
# 本地执行器 /api/v1/local/runner 都走 WS,必须显式转发。
|
||||
# 用 map 而非写死 "upgrade":普通请求带上 Connection: upgrade 会干扰 keepalive。
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -17,6 +27,9 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# WebSocket(语音会话 / 本地执行器):不转发这两个头则握手必失败。
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
# SSE(token 流 / 执行轨迹):关闭缓冲 + 长超时,否则流式会被攒住/断开。
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
Generated
+357
-3
@@ -8,14 +8,19 @@
|
||||
"name": "sundynix-admin",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.25.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.0"
|
||||
"react-router-dom": "^7.1.0",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
@@ -1958,6 +1963,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
@@ -2089,9 +2114,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -2278,6 +2301,15 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@@ -2357,6 +2389,44 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"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/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
@@ -2460,6 +2530,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
@@ -2494,6 +2573,12 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
@@ -2516,6 +2601,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
@@ -2668,6 +2759,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -2717,6 +2821,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -2805,6 +2918,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
@@ -3210,6 +3332,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -3220,6 +3354,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz",
|
||||
"integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@@ -3374,6 +3517,42 @@
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
@@ -3387,6 +3566,15 @@
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
@@ -3441,6 +3629,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
@@ -3630,6 +3827,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -3765,6 +3979,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -3775,6 +3998,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -3947,6 +4176,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
@@ -3984,6 +4219,32 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
@@ -4040,6 +4301,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
||||
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
@@ -4274,6 +4545,13 @@
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -5086,6 +5364,12 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
@@ -5103,6 +5387,35 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
@@ -5120,12 +5433,53 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,19 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.25.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.0"
|
||||
"react-router-dom": "^7.1.0",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 308 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
+38
-12
@@ -1,11 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { AppShell } from "./shell/AppShell";
|
||||
import { Login } from "./Login";
|
||||
import { me, clearToken, type AuthUser } from "./api";
|
||||
|
||||
// 用 HashRouter:纯静态托管/桌面内嵌都能深链,无需服务端路由配置。
|
||||
export default function App() {
|
||||
// 官网落地页(融进本工程,src/site/),挂根 /。
|
||||
import { SiteLayout } from "./site/components/layout/site-layout";
|
||||
import HomePage from "./site/pages/home";
|
||||
import DownloadPage from "./site/pages/download";
|
||||
import PricingPage from "./site/pages/pricing";
|
||||
import NotFoundPage from "./site/pages/not-found";
|
||||
|
||||
// 后台鉴权门:只在访问 /admin 时跑 me()(官网公开、不打鉴权)。
|
||||
// 未登录 → Login;登录后 → AppShell(其内部再渲染 /admin/* 子路由)。
|
||||
function AdminGate() {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -26,14 +34,32 @@ export default function App() {
|
||||
return <Login onAuthed={setUser} />;
|
||||
}
|
||||
return (
|
||||
<HashRouter>
|
||||
<AppShell
|
||||
user={user}
|
||||
onLogout={() => {
|
||||
clearToken();
|
||||
setUser(null);
|
||||
}}
|
||||
/>
|
||||
</HashRouter>
|
||||
<AppShell
|
||||
user={user}
|
||||
onLogout={() => {
|
||||
clearToken();
|
||||
setUser(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 用 BrowserRouter(路径式):/ = 官网、/admin = 运维后台。
|
||||
// gateway 的 NoRoute 对非 /api、非文件路径回退 index.html,故深链/刷新都能工作。
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* 后台:/admin 前缀,最具体,优先匹配 */}
|
||||
<Route path="/admin/*" element={<AdminGate />} />
|
||||
{/* 官网:根 / + /download,公开;未知路径落官网 404(保留 header/footer) */}
|
||||
<Route element={<SiteLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="pricing" element={<PricingPage />} />
|
||||
<Route path="download" element={<DownloadPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
+357
-6
@@ -71,7 +71,7 @@ export async function me(): Promise<AuthUser | null> {
|
||||
}
|
||||
|
||||
// ---- 模型配置(id 为雪花字符串)----
|
||||
export type Kind = "chat" | "embedding";
|
||||
export type Kind = "chat" | "voice" | "embedding";
|
||||
|
||||
export interface Model {
|
||||
id: string;
|
||||
@@ -227,6 +227,77 @@ export async function listRedeemCodes(): Promise<RedeemCodeRow[]> {
|
||||
}
|
||||
|
||||
// ---- 微信支付配置(DB 存储、热生效;APIv3 密钥密文入库、不回显)----
|
||||
// ---- 微信扫码登录配置 ----
|
||||
export interface WechatMPConfig {
|
||||
appid: string;
|
||||
token: string; // 消息推送签名校验用(与公众平台服务器配置一致)
|
||||
app_secret: string; // 单管理员后台:明文回显
|
||||
welcome: string; // 关注后自动回复的欢迎语(空=用默认)
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface WechatUserRow {
|
||||
id: string;
|
||||
name: string;
|
||||
wechat_openid: string;
|
||||
created_at: string;
|
||||
tenant_name: string;
|
||||
balance_micro: number;
|
||||
}
|
||||
|
||||
export async function adminWechatUsers(): Promise<WechatUserRow[]> {
|
||||
const res = guard(await fetch(`${ADMIN}/wechat-users`, { headers: authHeaders() }));
|
||||
const d = (await res.json().catch(() => ({}))) as { users?: WechatUserRow[]; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `wechat users failed: ${res.status}`);
|
||||
return d.users ?? [];
|
||||
}
|
||||
|
||||
export async function getWechatMP(): Promise<WechatMPConfig> {
|
||||
const res = guard(await fetch(`${ADMIN}/wechat-mp`, { headers: authHeaders() }));
|
||||
const d = (await res.json().catch(() => ({}))) as Partial<WechatMPConfig> & { error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `wechat-mp failed: ${res.status}`);
|
||||
return { appid: d.appid ?? "", token: d.token ?? "", app_secret: d.app_secret ?? "", welcome: d.welcome ?? "", enabled: !!d.enabled };
|
||||
}
|
||||
|
||||
// app_secret 传空串 = 沿用已保存的。
|
||||
export async function saveWechatMP(body: { appid: string; app_secret: string; token: string; welcome: string }): Promise<{ enabled: boolean }> {
|
||||
const res = guard(await fetch(`${ADMIN}/wechat-mp`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(body) }));
|
||||
const d = (await res.json().catch(() => ({}))) as { enabled?: boolean; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
|
||||
return { enabled: !!d.enabled };
|
||||
}
|
||||
|
||||
// —— 语音(火山豆包)配置 · 新版 API Key 鉴权 ——
|
||||
export interface VoiceConfig {
|
||||
api_key: string; // 明文回显(单管理员后台)
|
||||
asr_resource_id: string;
|
||||
tts_resource_id: string;
|
||||
tts_voice_type: string;
|
||||
asr_enabled: boolean;
|
||||
tts_enabled: boolean;
|
||||
}
|
||||
|
||||
export async function getVoiceConfig(): Promise<VoiceConfig> {
|
||||
const res = guard(await fetch(`${ADMIN}/voice`, { headers: authHeaders() }));
|
||||
const d = (await res.json().catch(() => ({}))) as Partial<VoiceConfig> & { error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `voice config failed: ${res.status}`);
|
||||
return {
|
||||
api_key: d.api_key ?? "", asr_resource_id: d.asr_resource_id ?? "",
|
||||
tts_resource_id: d.tts_resource_id ?? "", tts_voice_type: d.tts_voice_type ?? "",
|
||||
asr_enabled: !!d.asr_enabled, tts_enabled: !!d.tts_enabled,
|
||||
};
|
||||
}
|
||||
|
||||
// api_key 传空串 = 沿用已保存的。
|
||||
export async function saveVoiceConfig(body: {
|
||||
api_key: string; asr_resource_id: string; tts_resource_id: string; tts_voice_type: string;
|
||||
}): Promise<{ asr_enabled: boolean; tts_enabled: boolean }> {
|
||||
const res = guard(await fetch(`${ADMIN}/voice`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(body) }));
|
||||
const d = (await res.json().catch(() => ({}))) as { asr_enabled?: boolean; tts_enabled?: boolean; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
|
||||
return { asr_enabled: !!d.asr_enabled, tts_enabled: !!d.tts_enabled };
|
||||
}
|
||||
|
||||
export interface WechatPayConfig {
|
||||
mchid: string;
|
||||
cert_serial: string;
|
||||
@@ -235,7 +306,7 @@ export interface WechatPayConfig {
|
||||
public_key_id: string; // PUB_KEY_ID_ 开头
|
||||
appid: string;
|
||||
notify_url: string;
|
||||
has_apiv3_key: boolean;
|
||||
apiv3_key: string; // 单管理员后台:明文回显
|
||||
}
|
||||
|
||||
export async function getWechatPay(): Promise<{ config: WechatPayConfig; enabled: boolean; reason: string }> {
|
||||
@@ -301,6 +372,184 @@ export async function adminReconcile(): Promise<{ diffs: ReconcileDiff[]; ok: bo
|
||||
return { diffs: d.diffs ?? [], ok: !!d.ok };
|
||||
}
|
||||
|
||||
// ---- 支付联调:走「用户面」的真实下单/查单接口 ----
|
||||
// 刻意不做 admin 专用旁路:验的就是用户真实走的那条路(下单 → 渠道 → 回调/查单 → 入账),
|
||||
// 造条测试专线的话验过了也不代表线上通。因此这两个打的是 /api/v1/billing/*(非 /admin/*),
|
||||
// 租户由服务端按当前登录用户解析,无需带租户头。
|
||||
export interface TestOrder {
|
||||
order_id: string;
|
||||
code_url: string;
|
||||
amount_fen: number;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export async function createPayOrder(packId: string): Promise<TestOrder> {
|
||||
const res = guard(
|
||||
await fetch(`${GATEWAY}/api/v1/billing/orders`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(true),
|
||||
body: JSON.stringify({ pack_id: packId }),
|
||||
}),
|
||||
);
|
||||
const d = (await res.json().catch(() => ({}))) as Partial<TestOrder> & { error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `下单失败: ${res.status}`);
|
||||
return d as TestOrder;
|
||||
}
|
||||
|
||||
// warn:已付但金额与订单不符 —— 服务端不入账、挂起人工核对,订单会一直停在 pending。
|
||||
export async function payOrderStatus(orderId: string): Promise<{ order: PayOrder; warn?: string }> {
|
||||
const res = guard(await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: authHeaders() }));
|
||||
const d = (await res.json().catch(() => ({}))) as { order?: PayOrder; warn?: string; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `查询失败: ${res.status}`);
|
||||
return { order: d.order as PayOrder, warn: d.warn };
|
||||
}
|
||||
|
||||
// ---- 订阅(手动购买制:买一个周期,期内每 N 天发一次积分,到期即失效)----
|
||||
export interface SubPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
price_fen: number;
|
||||
duration_days: number; // 一个周期多少天
|
||||
refill_credits_micro: number; // 每次发放的积分
|
||||
refill_interval_days: number; // 每几天发一次
|
||||
active: boolean;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
export interface SubRow {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
tenant_name: string;
|
||||
plan_name: string;
|
||||
status: string; // active / expired
|
||||
started_at: string;
|
||||
expires_at: string;
|
||||
refill_seq: number; // 已发放次数
|
||||
}
|
||||
|
||||
export async function adminSubPlans(): Promise<SubPlan[]> {
|
||||
const res = guard(await fetch(`${ADMIN}/sub-plans`, { headers: authHeaders() }));
|
||||
const d = (await res.json().catch(() => ({}))) as { plans?: SubPlan[]; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `sub plans failed: ${res.status}`);
|
||||
return d.plans ?? [];
|
||||
}
|
||||
|
||||
// refill_credits 以「积分」为单位(面向人),服务端转 micro。
|
||||
export async function saveSubPlan(p: {
|
||||
id?: string;
|
||||
name: string;
|
||||
price_fen: number;
|
||||
duration_days: number;
|
||||
refill_credits: number;
|
||||
refill_interval_days: number;
|
||||
active: boolean;
|
||||
sort: number;
|
||||
}): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/sub-plans`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(p) }));
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function adminSubscriptions(): Promise<SubRow[]> {
|
||||
const res = guard(await fetch(`${ADMIN}/subscriptions`, { headers: authHeaders() }));
|
||||
const d = (await res.json().catch(() => ({}))) as { subscriptions?: SubRow[]; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `subscriptions failed: ${res.status}`);
|
||||
return d.subscriptions ?? [];
|
||||
}
|
||||
|
||||
// 人工退款:仅对已入账(paid)单——置 refunded + 记 adjust 负分录 + 回退余额(幂等)。
|
||||
// status="noop" 表示该单本就无需退(已退/未支付)。真渠道钱的原路退回需 admin 另在商户后台操作。
|
||||
export async function adminRefundOrder(id: string, memo: string): Promise<{ status: string; detail?: string }> {
|
||||
const res = guard(await fetch(`${ADMIN}/orders/${id}/refund`, {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ memo }),
|
||||
}));
|
||||
const d = (await res.json().catch(() => ({}))) as { status?: string; detail?: string; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `refund failed: ${res.status}`);
|
||||
return { status: d.status ?? "", detail: d.detail };
|
||||
}
|
||||
|
||||
// ---- 全平台任务/运行观测(管理端,来自 sundynix_task 跨租户)----
|
||||
export interface AdminTask {
|
||||
task_id: string;
|
||||
tenant_id: string;
|
||||
tenant_name: string;
|
||||
owner: string;
|
||||
owner_email: string;
|
||||
status: string;
|
||||
detail: string;
|
||||
topic: string;
|
||||
at: string; // RFC3339
|
||||
eval_level: string;
|
||||
eval_overall: number;
|
||||
}
|
||||
|
||||
export interface AdminEval {
|
||||
overall: number;
|
||||
rule: number;
|
||||
llm: number;
|
||||
faithful: number;
|
||||
level: string;
|
||||
flags: string; // JSON 数组字符串
|
||||
reason: string;
|
||||
sources: number;
|
||||
corrected: boolean;
|
||||
}
|
||||
|
||||
export interface AdminTaskDetail extends AdminTask {
|
||||
graph: string;
|
||||
output: string;
|
||||
trace: string;
|
||||
eval: AdminEval | null;
|
||||
}
|
||||
|
||||
// 任务下钻。走 /admin 而非用户面的 /tasks/:id/replay —— 后者受租户插件过滤,
|
||||
// 管理员看别的租户的任务会静默拿到空输出/空轨迹(不报错),排查时极具误导性。
|
||||
export async function adminTaskDetail(id: string): Promise<{ task: AdminTaskDetail; exec: unknown[] }> {
|
||||
const res = guard(await fetch(`${ADMIN}/tasks/${encodeURIComponent(id)}`, { headers: authHeaders() }));
|
||||
const d = (await res.json().catch(() => ({}))) as { task?: AdminTaskDetail; exec?: unknown[]; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `task detail failed: ${res.status}`);
|
||||
return { task: d.task as AdminTaskDetail, exec: d.exec ?? [] };
|
||||
}
|
||||
|
||||
// adminTasks 全平台任务流 + 状态计数。status 空=全部;tenant 空=全租户;含 HITL 待审批(status=waiting)。
|
||||
export async function adminTasks(
|
||||
status = "",
|
||||
tenant = "",
|
||||
limit = 50,
|
||||
): Promise<{ tasks: AdminTask[]; counts: Record<string, number> }> {
|
||||
const q = new URLSearchParams();
|
||||
if (status) q.set("status", status);
|
||||
if (tenant) q.set("tenant", tenant);
|
||||
q.set("limit", String(limit));
|
||||
const res = guard(await fetch(`${ADMIN}/tasks?${q.toString()}`, { headers: authHeaders() }));
|
||||
const d = (await res.json().catch(() => ({}))) as { tasks?: AdminTask[]; counts?: Record<string, number>; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `tasks failed: ${res.status}`);
|
||||
return { tasks: d.tasks ?? [], counts: d.counts ?? {} };
|
||||
}
|
||||
|
||||
// ---- 全平台空间(Space)观测(管理端,跨租户 sundynix_space)----
|
||||
export interface AdminSpace {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
tenant_name: string;
|
||||
name: string;
|
||||
kind: string; // personal / project / tenant
|
||||
creator: string;
|
||||
creator_email: string;
|
||||
archived: boolean;
|
||||
members: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function adminSpaces(limit = 200): Promise<AdminSpace[]> {
|
||||
const res = guard(await fetch(`${ADMIN}/spaces?limit=${limit}`, { headers: authHeaders() }));
|
||||
const d = (await res.json().catch(() => ({}))) as { spaces?: AdminSpace[]; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `spaces failed: ${res.status}`);
|
||||
return d.spaces ?? [];
|
||||
}
|
||||
|
||||
// ---- 自动评测观测(真数据,来自 sundynix_eval)----
|
||||
export interface EvalDay {
|
||||
day: string; // YYYYMMDD
|
||||
@@ -369,6 +618,8 @@ export async function guardrailEvents(limit = 100): Promise<GuardrailEvent[]> {
|
||||
export interface DatasourceKB {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 检索作用域键前半段:三库里的库名是 "<space_id>/<name>",检索试验台按此定位。 */
|
||||
space_id: string;
|
||||
kind: string;
|
||||
tenant_id: string;
|
||||
tenant_name: string;
|
||||
@@ -377,6 +628,45 @@ export interface DatasourceKB {
|
||||
total_words: number;
|
||||
}
|
||||
|
||||
/** 检索命中:text=命中片段,score=该路的相似度/融合分(不同 mode 分数体系不同,勿跨路比较)。 */
|
||||
export interface KbHit {
|
||||
text: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/** 检索模式:单路用于逐路定位是哪一路没召回;hybrid=纯 RRF 融合(不 rerank);""=生产链路(混合+rerank)。 */
|
||||
export type SearchMode = "" | "vector" | "fulltext" | "graph" | "hybrid";
|
||||
|
||||
/** RouteDiag 一路召回的诊断。三种"空"必须能分辨:没配置(disabled)、报错(error)、确实没匹配(empty)。 */
|
||||
export interface RouteDiag {
|
||||
name: "vector" | "fulltext" | "graph";
|
||||
status: "ok" | "empty" | "disabled" | "error";
|
||||
hits: number;
|
||||
ms: number;
|
||||
error?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// adminKbSearch 检索试验台:按完整作用域键跨租户检索任意知识库。
|
||||
// kb 传 `${space_id}/${name}`(来自 adminDatasources)。返回命中 + 每一路的诊断。
|
||||
export async function adminKbSearch(
|
||||
kb: string,
|
||||
q: string,
|
||||
topK = 5,
|
||||
mode: SearchMode = "",
|
||||
): Promise<{ hits: KbHit[]; routes: RouteDiag[] }> {
|
||||
const res = guard(
|
||||
await fetch(`${ADMIN}/kb/search`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(true),
|
||||
body: JSON.stringify({ kb, q, topK, mode }),
|
||||
}),
|
||||
);
|
||||
const d = (await res.json().catch(() => ({}))) as { hits?: KbHit[]; routes?: RouteDiag[]; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `search failed: ${res.status}`);
|
||||
return { hits: d.hits ?? [], routes: d.routes ?? [] };
|
||||
}
|
||||
|
||||
export async function adminDatasources(): Promise<{ counts: { users: number; kbs: number; docs: number }; datasources: DatasourceKB[] }> {
|
||||
const res = guard(await fetch(`${ADMIN}/datasources`, { headers: authHeaders() }));
|
||||
const d = (await res.json().catch(() => ({}))) as { counts?: { users: number; kbs: number; docs: number }; datasources?: DatasourceKB[]; error?: string };
|
||||
@@ -411,11 +701,27 @@ export interface ToolGroup {
|
||||
up: boolean;
|
||||
tools: ToolInfo[] | null;
|
||||
}
|
||||
export interface NatsStreamHealth {
|
||||
name: string;
|
||||
leader: string;
|
||||
replicas_healthy: number;
|
||||
replicas_total: number;
|
||||
messages: number;
|
||||
}
|
||||
export interface NatsClusterStatus {
|
||||
connected: boolean;
|
||||
connected_to: string;
|
||||
known_servers: number;
|
||||
rtt_ms: number;
|
||||
streams: NatsStreamHealth[] | null;
|
||||
degraded: number;
|
||||
}
|
||||
export interface SystemStatus {
|
||||
checked_at: string;
|
||||
infra: StatusItem[];
|
||||
services: StatusItem[];
|
||||
tools: ToolGroup[];
|
||||
nats?: NatsClusterStatus;
|
||||
}
|
||||
|
||||
export async function getStatus(): Promise<SystemStatus> {
|
||||
@@ -524,8 +830,20 @@ export interface GuardrailEventItem {
|
||||
at: string;
|
||||
}
|
||||
|
||||
export async function listAudit(limit = 50, offset = 0): Promise<AuditEntry[]> {
|
||||
const res = guard(await fetch(`${ADMIN}/audit?limit=${limit}&offset=${offset}`, { headers: authHeaders() }));
|
||||
// 筛选走服务端(全库匹配)。此前是取回一页再在前端过滤,翻页外的记录搜不到——
|
||||
// 对审计来说,“搜不到”会被当成“没发生过”,是会误导结论的。
|
||||
export interface AuditQuery {
|
||||
action?: string; // HTTP 方法,精确
|
||||
path?: string; // 路径前缀
|
||||
q?: string; // actor / ip / detail / path 模糊
|
||||
}
|
||||
|
||||
export async function listAudit(limit = 50, offset = 0, f: AuditQuery = {}): Promise<AuditEntry[]> {
|
||||
const p = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||
if (f.action) p.set("action", f.action);
|
||||
if (f.path) p.set("path", f.path);
|
||||
if (f.q?.trim()) p.set("q", f.q.trim());
|
||||
const res = guard(await fetch(`${ADMIN}/audit?${p}`, { headers: authHeaders() }));
|
||||
if (!res.ok) throw new Error(`audit failed: ${res.status}`);
|
||||
return ((await res.json()) as { logs?: AuditEntry[] }).logs ?? [];
|
||||
}
|
||||
@@ -674,8 +992,11 @@ export async function listTenants(): Promise<TenantRow[]> {
|
||||
return ((await res.json()) as { tenants?: TenantRow[] }).tenants ?? [];
|
||||
}
|
||||
|
||||
export async function createTenant(name: string, slug: string, ownerEmail?: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ name, slug, owner_email: ownerEmail ?? "" }) }));
|
||||
export async function createTenant(name: string, slug: string, ownerEmail?: string, plan?: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants`, {
|
||||
method: "POST", headers: authHeaders(true),
|
||||
body: JSON.stringify({ name, slug, owner_email: ownerEmail ?? "", plan: plan ?? "free" }),
|
||||
}));
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string; warn?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `create tenant failed: ${res.status}`);
|
||||
if (d.warn) throw new Error(d.warn); // 租户已建但指定 owner 失败 → 当提示
|
||||
@@ -718,3 +1039,33 @@ export async function setSharedBilling(tenantId: string, on: boolean): Promise<v
|
||||
throw new Error(d.error ?? `set shared billing failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// —— KB 存储迁移(管理员一次性运维操作:owner/kb 作用域 → space_id/kb 作用域)——
|
||||
export interface MigrateKbResult {
|
||||
total: number; // 扫描文档总数
|
||||
enqueued: number; // 成功入队重灌的文档数
|
||||
skipped: number; // 已是新作用域跳过数
|
||||
}
|
||||
export async function migrateKbStorage(): Promise<MigrateKbResult> {
|
||||
const res = guard(await fetch(`${ADMIN}/migrate-kb-storage`, { method: "POST", headers: authHeaders(true) }));
|
||||
const d = (await res.json().catch(() => ({}))) as Partial<MigrateKbResult> & { error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `migrate kb failed: ${res.status}`);
|
||||
return { total: d.total ?? 0, enqueued: d.enqueued ?? 0, skipped: d.skipped ?? 0 };
|
||||
}
|
||||
|
||||
export async function setTenantPlan(tenantId: string, plan: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/plan`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ plan }) }));
|
||||
if (!res.ok) {
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(d.error ?? `set tenant plan failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setTenantStatus(tenantId: string, status: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/status`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ status }) }));
|
||||
if (!res.ok) {
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(d.error ?? `set tenant status failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { grantCredits, type TenantRow } from "../api";
|
||||
|
||||
// GrantCreditsModal:替换 window.prompt 的完整充值弹窗。
|
||||
// 支持正数充值 / 负数人工校正;带操作原因 + 备注 + 风险提示 + 汇率说明。
|
||||
|
||||
const MICRO = 1_000_000;
|
||||
const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
|
||||
const REASONS = [
|
||||
"线下回款核销",
|
||||
"补偿发放(服务故障)",
|
||||
"测试赠送",
|
||||
"人工校正(超扣补偿)",
|
||||
"合同预付款",
|
||||
"其它(见备注)",
|
||||
];
|
||||
|
||||
interface Props {
|
||||
tenant: TenantRow | null;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function GrantCreditsModal({ tenant, onClose, onDone }: Props) {
|
||||
const [amount, setAmount] = useState("100");
|
||||
const [reason, setReason] = useState(REASONS[0]);
|
||||
const [memo, setMemo] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const amountRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 每次打开聚焦数量输入框
|
||||
useEffect(() => {
|
||||
if (tenant) {
|
||||
setAmount("100");
|
||||
setReason(REASONS[0]);
|
||||
setMemo("");
|
||||
setErr("");
|
||||
setTimeout(() => amountRef.current?.select(), 50);
|
||||
}
|
||||
}, [tenant]);
|
||||
|
||||
if (!tenant) return null;
|
||||
|
||||
const n = Number(amount);
|
||||
const isValid = !isNaN(n) && n !== 0;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!isValid) {
|
||||
setErr("积分数量不能为 0");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setErr("");
|
||||
try {
|
||||
const finalMemo = reason === "其它(见备注)" ? memo : reason + (memo ? `:${memo}` : "");
|
||||
await grantCredits(tenant.id, n, finalMemo);
|
||||
onDone();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">手动发放积分</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-400">操作将写入账本分录,可在对账页追溯</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-xl leading-none text-gray-400 hover:text-gray-600">×</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
{/* 租户信息 */}
|
||||
<div className="flex items-center gap-3 rounded-xl border border-violet-100 bg-violet-50 p-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-violet-100 text-sm font-bold text-violet-700">
|
||||
{tenant.name.charAt(0)}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-800">{tenant.name}</div>
|
||||
<div className="font-mono text-xs text-gray-400">
|
||||
当前余额:<span className="font-medium text-violet-600">{credits(tenant.credit_balance_micro)}</span> 积分
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 积分数量 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-600">积分数量</label>
|
||||
<input
|
||||
ref={amountRef}
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => { setAmount(e.target.value); setErr(""); }}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm font-mono focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
|
||||
placeholder="正数=充值,负数=扣减/校正"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">正数=充值/发放,负数=扣减/校正。当前汇率:1 积分 = 1000 token</p>
|
||||
</div>
|
||||
|
||||
{/* 操作原因 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-600">操作原因</label>
|
||||
<select
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-700 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
|
||||
>
|
||||
{REASONS.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 备注 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-600">
|
||||
备注(可选){reason === "其它(见备注)" && <span className="text-rose-500"> *必填</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
placeholder="如:2026-07-18 线下转账 ¥500 核销"
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 风险提示 */}
|
||||
<div className="rounded-lg border border-amber-100 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
⚠️ 此操作会立即更新租户余额并写入账本,无法撤销。如需扣减请填写负数。
|
||||
</div>
|
||||
|
||||
{err && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{err}</div>}
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
|
||||
<span className="text-xs text-gray-400">操作完成后自动记入审计日志</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onClose} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-500 hover:bg-gray-50">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={saving || !isValid}
|
||||
className="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-40"
|
||||
>
|
||||
{saving ? "发放中…" : "确认发放"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,10 +37,18 @@ export function ModelManager({
|
||||
|
||||
const set = (k: keyof ModelInput, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const editing = !!form.id;
|
||||
|
||||
// onEdit 把某行载入表单就地编辑(api_key 留空=沿用现有密钥,后端按 id 更新而非新增)。
|
||||
const onEdit = (m: Model) => {
|
||||
setForm({ id: m.id, kind, provider: m.provider, base_url: m.base_url, api_key: "", model: m.model });
|
||||
setMsg("");
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
try {
|
||||
await saveModel({ ...form, kind });
|
||||
setMsg("✓ 已保存");
|
||||
setMsg(editing ? "✓ 已更新并热更新" : "✓ 已保存");
|
||||
setForm(empty);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
@@ -108,7 +116,21 @@ export function ModelManager({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteModel(m.id).then(refresh)}
|
||||
onClick={() => onEdit(m)}
|
||||
className="rounded border px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// 删除不可撤销,且删掉在用模型会直接打断线上推理——所以要二次确认,
|
||||
// 且把"这条正在用"讲明白(此前是一点就删,无任何提示)。
|
||||
const warn = m.active
|
||||
? `「${m.model}」正在使用中,删除后该 ${m.kind === "embedding" ? "向量" : "对话"}能力立即不可用。`
|
||||
: `将删除「${m.model}」(${m.provider})。`;
|
||||
if (!window.confirm(`${warn}\n此操作不可撤销,确定继续?`)) return;
|
||||
void deleteModel(m.id).then(refresh);
|
||||
}}
|
||||
className="rounded border px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-50"
|
||||
>
|
||||
删除
|
||||
@@ -123,7 +145,9 @@ export function ModelManager({
|
||||
</section>
|
||||
|
||||
<section className="max-w-xl">
|
||||
<h2 className="mb-3 text-sm font-semibold text-gray-700">登记(开发期:第三方在线 API,OpenAI 兼容)</h2>
|
||||
<h2 className="mb-3 text-sm font-semibold text-gray-700">
|
||||
{editing ? `编辑「${form.model}」(改完保存即热更新)` : "登记(开发期:第三方在线 API,OpenAI 兼容)"}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="text-xs text-gray-500">
|
||||
Provider
|
||||
@@ -162,12 +186,14 @@ export function ModelManager({
|
||||
className="mt-1 w-full rounded border px-2 py-1 text-sm font-mono"
|
||||
value={form.api_key}
|
||||
onChange={(e) => set("api_key", e.target.value)}
|
||||
placeholder="sk-…"
|
||||
placeholder={editing ? "留空=沿用现有密钥" : "sk-…"}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button onClick={onSave} className="rounded bg-violet-600 px-3 py-1 text-sm text-white">保存</button>
|
||||
<button onClick={onSave} className="rounded bg-violet-600 px-3 py-1 text-sm text-white">
|
||||
{editing ? "更新" : "保存"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={testing || !form.base_url}
|
||||
@@ -175,6 +201,14 @@ export function ModelManager({
|
||||
>
|
||||
{testing ? "测试中…" : "测试连接"}
|
||||
</button>
|
||||
{editing && (
|
||||
<button
|
||||
onClick={() => { setForm(empty); setMsg(""); }}
|
||||
className="rounded border px-3 py-1 text-sm text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
{msg && <span className="text-xs text-gray-600">{msg}</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { adminOrders, adminReconcile, type PayOrder, type OrderStats, type ReconcileDiff } from "../api";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { adminOrders, adminReconcile, adminRefundOrder, type PayOrder, type OrderStats, type ReconcileDiff } from "../api";
|
||||
|
||||
|
||||
// 充值订单流 + 日终对账(P5.3 观测)。全平台充值单的落地视角:
|
||||
// - 状态计数卡片(pending/paid/expired + 累计到账额)
|
||||
@@ -27,6 +28,13 @@ export function OrderStream() {
|
||||
// 对账结果:null=未跑;[]=零差异;有元素=有差异
|
||||
const [diffs, setDiffs] = useState<ReconcileDiff[] | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [refunding, setRefunding] = useState(""); // 正在退款的订单 id
|
||||
// 退款 Modal
|
||||
const [refundTarget, setRefundTarget] = useState<PayOrder | null>(null);
|
||||
const [refundMemo, setRefundMemo] = useState("");
|
||||
const [refundErr, setRefundErr] = useState("");
|
||||
const refundMemoRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
|
||||
const load = useCallback(() => {
|
||||
adminOrders(filter)
|
||||
@@ -51,8 +59,94 @@ export function OrderStream() {
|
||||
}
|
||||
};
|
||||
|
||||
const openRefund = (o: PayOrder) => {
|
||||
setRefundTarget(o);
|
||||
setRefundMemo("");
|
||||
setRefundErr("");
|
||||
setTimeout(() => refundMemoRef.current?.focus(), 50);
|
||||
};
|
||||
|
||||
const doRefund = async () => {
|
||||
if (!refundTarget) return;
|
||||
if (!refundMemo.trim()) { setRefundErr("退款原因不能为空"); return; }
|
||||
setRefunding(refundTarget.id);
|
||||
setRefundErr("");
|
||||
try {
|
||||
const r = await adminRefundOrder(refundTarget.id, refundMemo.trim());
|
||||
if (r.status === "noop") setErr(r.detail ?? "该订单无需退款");
|
||||
setRefundTarget(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
setRefundErr((e as Error).message);
|
||||
} finally {
|
||||
setRefunding("");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 退款 Modal */}
|
||||
{refundTarget && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && setRefundTarget(null)}
|
||||
>
|
||||
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">人工退款</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-400">冲销订单积分并调整余额</p>
|
||||
</div>
|
||||
<button onClick={() => setRefundTarget(null)} className="text-xl leading-none text-gray-400 hover:text-gray-600">×</button>
|
||||
</div>
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
{/* 订单摘要 */}
|
||||
<div className="space-y-2 rounded-xl border border-gray-100 bg-gray-50 p-3 text-sm">
|
||||
<div className="flex justify-between text-xs uppercase tracking-wide text-gray-400">
|
||||
<span>订单信息</span>
|
||||
<span className="font-mono">{refundTarget.id.slice(-12)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between"><span className="text-gray-600">租户</span><span className="font-medium">{refundTarget.tenant_name || refundTarget.tenant_id}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-600">充値积分</span><span className="font-medium text-gray-800">{credits(refundTarget.credits_micro)} 积分</span></div>
|
||||
{refundTarget.amount_fen > 0 && <div className="flex justify-between"><span className="text-gray-600">充値金额</span><span className="font-medium text-emerald-600">{yuan(refundTarget.amount_fen)}</span></div>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-600">退款原因(必填)</label>
|
||||
<textarea
|
||||
ref={refundMemoRef}
|
||||
rows={3}
|
||||
value={refundMemo}
|
||||
onChange={(e) => { setRefundMemo(e.target.value); setRefundErr(""); }}
|
||||
placeholder="请说明退款原因,此内容将写入审计日志…"
|
||||
className="w-full resize-none rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs text-rose-700 space-y-1">
|
||||
<div className="font-semibold">⚠️ 操作风险提示</div>
|
||||
<div>· 本操作将冲销 <strong>{credits(refundTarget.credits_micro)} 积分</strong>,若已消费则余额可能变负</div>
|
||||
<div>· 微信真实资金的原路退回需另在商户后台操作,此处仅冲销本地积分</div>
|
||||
<div>· 操作不可撤销</div>
|
||||
</div>
|
||||
{refundErr && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{refundErr}</div>}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
|
||||
<span className="text-xs text-gray-400">操作将记入审计日志</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setRefundTarget(null)} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-500 hover:bg-gray-50">取消</button>
|
||||
<button
|
||||
onClick={() => void doRefund()}
|
||||
disabled={!!refunding || !refundMemo.trim()}
|
||||
className="rounded-lg bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-40"
|
||||
>
|
||||
{refunding ? "退款中…" : "确认退款"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<h3 className="text-sm font-semibold text-gray-700">充值订单与对账</h3>
|
||||
<span className="text-[11px] text-gray-400">全平台充值单落地视角 + 账本一致性核对</span>
|
||||
@@ -117,7 +211,8 @@ export function OrderStream() {
|
||||
<th className="py-2 pr-3 font-medium">渠道</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">积分</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">金额</th>
|
||||
<th className="py-2 font-medium">状态</th>
|
||||
<th className="py-2 pr-3 font-medium">状态</th>
|
||||
<th className="py-2 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -128,16 +223,26 @@ export function OrderStream() {
|
||||
<td className="py-1.5 pr-3 text-xs text-gray-500">{CHANNEL_LABEL[o.channel] ?? o.channel}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums text-gray-800">{credits(o.credits_micro)}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums text-gray-500">{o.amount_fen > 0 ? yuan(o.amount_fen) : "—"}</td>
|
||||
<td className="py-1.5">
|
||||
<td className="py-1.5 pr-3">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${STATUS_BADGE[o.status] ?? "bg-gray-100 text-gray-500"}`}>
|
||||
{STATUS_LABEL[o.status] ?? o.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{o.status === "paid" ? (
|
||||
<button onClick={() => openRefund(o)} disabled={refunding === o.id}
|
||||
className="rounded border border-rose-200 px-2 py-0.5 text-[11px] text-rose-600 hover:bg-rose-50 disabled:opacity-40">
|
||||
{refunding === o.id ? "退款中…" : "退款"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[11px] text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{orders.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-6 text-center text-xs text-gray-400">暂无充值订单</td>
|
||||
<td colSpan={7} className="py-6 text-center text-xs text-gray-400">暂无充值订单</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { createPayOrder, payOrderStatus, type CreditPack, type PayOrder } from "../api";
|
||||
|
||||
// 支付联调:在管理端就地下一笔**真单**并盯到终态,用来验证「渠道配置 → 下单 → 扫码付款
|
||||
// → 回调/查单 → 入账」整条链路在当前环境真的通。
|
||||
//
|
||||
// 刻意走用户面的 /api/v1/billing/* 而非 admin 旁路:造条测试专线的话,验过了也不能
|
||||
// 说明线上用户那条路通。代价是这会真扣钱 —— 所以 UI 上必须讲清楚,并给出退款去处。
|
||||
//
|
||||
// 轮询策略与 sundynix-web 的支付弹窗一致:递归 setTimeout(不让慢请求摞起来)、
|
||||
// 切后台暂停(扫完码要去微信 App)、失败指数退避、倒计时以服务端 expires_at 为准。
|
||||
const POLL_BASE_MS = 2500;
|
||||
const POLL_MAX_MS = 15000;
|
||||
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
|
||||
type Phase = "creating" | "waiting" | "paid" | "expired";
|
||||
|
||||
export function PayProbeDialog({ pack, onClose }: { pack: CreditPack; onClose: (paid: boolean) => void }) {
|
||||
const [qr, setQr] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [warn, setWarn] = useState("");
|
||||
const [phase, setPhase] = useState<Phase>("creating");
|
||||
const [order, setOrder] = useState<PayOrder | null>(null);
|
||||
const [left, setLeft] = useState(0);
|
||||
const [polls, setPolls] = useState(0); // 轮询次数:链路是否在动,一眼可见
|
||||
const orderRef = useRef("");
|
||||
const expiresRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
let timer: number | null = null;
|
||||
let failures = 0;
|
||||
|
||||
const stop = () => {
|
||||
if (timer) window.clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
const schedule = (ms: number) => {
|
||||
stop();
|
||||
timer = window.setTimeout(() => void tick(), ms);
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
if (!alive) return;
|
||||
if (document.hidden) return schedule(POLL_BASE_MS);
|
||||
try {
|
||||
const { order: o, warn: w } = await payOrderStatus(orderRef.current);
|
||||
if (!alive) return;
|
||||
failures = 0;
|
||||
setPolls((n) => n + 1);
|
||||
setOrder(o);
|
||||
setWarn(w ?? "");
|
||||
if (o.status === "paid") {
|
||||
setPhase("paid");
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
if (o.status === "expired" || o.status === "failed") {
|
||||
setPhase("expired");
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
schedule(POLL_BASE_MS);
|
||||
} catch (e) {
|
||||
if (!alive) return;
|
||||
failures += 1;
|
||||
setErr((e as Error).message);
|
||||
schedule(Math.min(POLL_BASE_MS * 2 ** failures, POLL_MAX_MS));
|
||||
}
|
||||
};
|
||||
|
||||
const onVisible = () => {
|
||||
if (!document.hidden && alive && orderRef.current) schedule(0);
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const o = await createPayOrder(pack.id);
|
||||
if (!alive) return;
|
||||
orderRef.current = o.order_id;
|
||||
expiresRef.current = new Date(o.expires_at).getTime();
|
||||
setLeft(Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000)));
|
||||
setQr(await QRCode.toDataURL(o.code_url, { width: 220, margin: 1 }));
|
||||
setPhase("waiting");
|
||||
schedule(POLL_BASE_MS);
|
||||
} catch (e) {
|
||||
if (alive) setErr((e as Error).message);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pack.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== "waiting") return;
|
||||
const t = window.setInterval(() => {
|
||||
const s = Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000));
|
||||
setLeft(s);
|
||||
if (s === 0) setPhase("expired");
|
||||
}, 1000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [phase]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/40 p-4">
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-5 shadow-xl">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h4 className="text-sm font-semibold text-gray-800">支付联调 · {pack.name}</h4>
|
||||
<button onClick={() => onClose(phase === "paid")} className="text-xs text-gray-400 hover:text-gray-600">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 rounded-md bg-amber-50 px-2.5 py-1.5 text-[11px] leading-relaxed text-amber-700">
|
||||
⚠️ 这是<b>真实订单,会真实扣款</b>(走用户实际那条链路,才验得准)。测完可在「支付 · 订单与对账」里对该单发起退款。
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-col items-center gap-3">
|
||||
{err && !qr ? (
|
||||
<p className="py-10 text-center text-xs text-rose-500">{err}</p>
|
||||
) : phase === "creating" ? (
|
||||
<p className="py-16 text-xs text-gray-400">正在向渠道下单…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative rounded-lg border border-gray-100 bg-white p-2">
|
||||
<img src={qr} alt="微信支付二维码" width={220} height={220} />
|
||||
{(phase === "expired" || phase === "paid") && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-white/85 text-sm font-medium text-gray-700">
|
||||
{phase === "paid" ? "✅ 已入账" : "二维码已失效"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold tabular-nums text-gray-800">¥{(pack.price_fen / 100).toFixed(2)}</div>
|
||||
{phase === "waiting" && <div className="mt-0.5 text-[11px] tabular-nums text-gray-400">二维码 {mmss(left)} 后失效</div>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{warn && (
|
||||
<p className="w-full rounded-md bg-amber-50 px-3 py-2 text-center text-[11px] leading-relaxed text-amber-700">{warn}</p>
|
||||
)}
|
||||
|
||||
{/* 链路证据:这些字段就是判断「哪一环通了」的依据,比一句“成功”有用 */}
|
||||
{orderRef.current && (
|
||||
<dl className="w-full space-y-1 rounded-lg bg-gray-50 p-3 text-[11px]">
|
||||
<Row k="订单号" v={<span className="font-mono">{orderRef.current}</span>} />
|
||||
<Row
|
||||
k="订单状态"
|
||||
v={
|
||||
<span
|
||||
className={
|
||||
phase === "paid" ? "font-medium text-emerald-600" : phase === "expired" ? "text-gray-400" : "text-amber-600"
|
||||
}
|
||||
>
|
||||
{order?.status ?? "pending"}
|
||||
{phase === "waiting" && " · 等待扫码支付"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Row k="轮询次数" v={<span className="tabular-nums">{polls}</span>} />
|
||||
{order?.credits_micro != null && phase === "paid" && (
|
||||
<Row k="到账积分" v={<span className="tabular-nums text-emerald-600">{(order.credits_micro / 1e6).toLocaleString()}</span>} />
|
||||
)}
|
||||
{err && <Row k="最近错误" v={<span className="text-rose-500">{err}</span>} />}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{phase === "paid" && (
|
||||
<p className="text-center text-[11px] leading-relaxed text-emerald-600">
|
||||
整条链路已验通:下单 → 渠道 → 回调/查单 → 幂等入账 → 积分到账。
|
||||
</p>
|
||||
)}
|
||||
{phase === "waiting" && (
|
||||
<p className="text-center text-[11px] leading-relaxed text-gray-400">
|
||||
用微信扫码支付。关掉本窗口也不影响入账——掉单补偿定时器每分钟兜底一次。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ k, v }: { k: string; v: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="shrink-0 text-gray-400">{k}</dt>
|
||||
<dd className="truncate text-gray-700">{v}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useState } from "react";
|
||||
import { adminKbSearch, type DatasourceKB, type KbHit, type RouteDiag, type SearchMode } from "../api";
|
||||
|
||||
// 检索试验台:对同一个 query 同时跑「生产链路」与「三路 + RRF 融合」,并排看各自召回。
|
||||
// 用途:线上召回不准时定位是哪一环 —— 向量路空=embedding/切块问题;全文路空=分词/索引问题;
|
||||
// 图谱路空=Neo4j 未起或未抽三元组;融合有而生产为空=rerank 把结果滤掉了。
|
||||
//
|
||||
// ⚠️ 分数不可跨路比较:向量是余弦相似度(0~1),RRF 是 1/(k+rank) 的融合分(通常 <0.05)。
|
||||
|
||||
const ROUTES: Array<{ mode: SearchMode; label: string; hint: string }> = [
|
||||
{ mode: "vector", label: "向量", hint: "Milvus · 语义相似" },
|
||||
{ mode: "fulltext", label: "全文", hint: "Bleve · 关键词倒排" },
|
||||
{ mode: "graph", label: "图谱", hint: "Neo4j · 实体关系" },
|
||||
{ mode: "hybrid", label: "RRF 融合", hint: "三路融合 · 不含 rerank" },
|
||||
];
|
||||
|
||||
type Results = Partial<Record<string, { hits: KbHit[]; routes?: RouteDiag[]; err?: string }>>;
|
||||
|
||||
// 每一路的诊断徽章。三种"空"必须分得清 —— 没配置 / 报错 / 确实没匹配,
|
||||
// 以前它们在界面上长得一模一样(都是 0 条),排查时只能靠猜。
|
||||
const STATUS_TONE: Record<string, { label: string; cls: string }> = {
|
||||
ok: { label: "正常", cls: "bg-emerald-50 text-emerald-600" },
|
||||
empty: { label: "无匹配", cls: "bg-gray-100 text-gray-500" },
|
||||
disabled: { label: "未启用", cls: "bg-amber-50 text-amber-700" },
|
||||
error: { label: "报错", cls: "bg-rose-50 text-rose-600" },
|
||||
};
|
||||
const ROUTE_CN: Record<string, string> = { vector: "向量", fulltext: "全文", graph: "图谱" };
|
||||
|
||||
export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) {
|
||||
const withDocs = kbs.filter((k) => k.doc_count > 0);
|
||||
const [kbKey, setKbKey] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [topK, setTopK] = useState(5);
|
||||
const [res, setRes] = useState<Results>({});
|
||||
const [running, setRunning] = useState(false);
|
||||
const [ran, setRan] = useState(false);
|
||||
|
||||
const run = async () => {
|
||||
if (!kbKey || !q.trim()) return;
|
||||
setRunning(true);
|
||||
setRan(true);
|
||||
// 生产链路 + 四路诊断并行跑,各自独立成败(一路挂不影响其它路展示)。
|
||||
const modes: SearchMode[] = ["", ...ROUTES.map((r) => r.mode)];
|
||||
const settled = await Promise.all(
|
||||
modes.map(async (m) => {
|
||||
try {
|
||||
return [m, await adminKbSearch(kbKey, q.trim(), topK, m)] as const;
|
||||
} catch (e) {
|
||||
return [m, { hits: [], err: (e as Error).message }] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
setRes(Object.fromEntries(settled));
|
||||
setRunning(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold text-gray-700">检索试验台</h4>
|
||||
<span className="text-[11px] text-gray-400">同一 query 跑通所有检索路,定位召回问题出在哪一环</span>
|
||||
</div>
|
||||
|
||||
{/* 查询条件 */}
|
||||
<div className="mt-4 flex flex-wrap items-end gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] text-gray-400">知识库</span>
|
||||
<select
|
||||
value={kbKey}
|
||||
onChange={(e) => setKbKey(e.target.value)}
|
||||
className="w-64 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
|
||||
>
|
||||
<option value="">选择一个知识库…</option>
|
||||
{withDocs.map((k) => (
|
||||
<option key={k.id} value={`${k.space_id}/${k.name}`}>
|
||||
{k.name} · {k.tenant_name || k.tenant_id} ({k.doc_count} 篇)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-1 flex-col gap-1" style={{ minWidth: 220 }}>
|
||||
<span className="text-[11px] text-gray-400">查询</span>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && void run()}
|
||||
placeholder="输入一个真实用户会问的问题…"
|
||||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] text-gray-400">topK</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={topK}
|
||||
onChange={(e) => setTopK(Math.max(1, Math.min(20, Number(e.target.value) || 5)))}
|
||||
className="w-20 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => void run()}
|
||||
disabled={running || !kbKey || !q.trim()}
|
||||
className="rounded-lg bg-violet-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-violet-700 disabled:opacity-40"
|
||||
>
|
||||
{running ? "检索中…" : "跑一遍"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{withDocs.length === 0 && (
|
||||
<p className="mt-3 text-xs text-amber-600">还没有含文档的知识库,先去桌面端建库并入库再来试。</p>
|
||||
)}
|
||||
|
||||
{ran && (
|
||||
<div className="mt-5 space-y-4">
|
||||
{/* 各路健康状况:先回答"哪一路能用",再看"召回了什么" */}
|
||||
{(() => {
|
||||
const routes = res[""]?.routes ?? res["hybrid"]?.routes ?? [];
|
||||
if (!routes.length) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-gray-50 px-3 py-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400">各路状态</span>
|
||||
{routes.map((r) => {
|
||||
const tone = STATUS_TONE[r.status] ?? STATUS_TONE.empty;
|
||||
return (
|
||||
<span
|
||||
key={r.name}
|
||||
title={r.error || r.note || ""}
|
||||
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] ${tone.cls}`}
|
||||
>
|
||||
{ROUTE_CN[r.name] ?? r.name} · {tone.label}
|
||||
<span className="tabular-nums opacity-60">{r.hits}命中/{r.ms}ms</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* 非正常路的原因写全,别让人去翻日志 */}
|
||||
{(res[""]?.routes ?? res["hybrid"]?.routes ?? [])
|
||||
.filter((r) => r.status === "disabled" || r.status === "error")
|
||||
.map((r) => (
|
||||
<p key={r.name} className={`text-[11px] ${r.status === "error" ? "text-rose-600" : "text-amber-700"}`}>
|
||||
{ROUTE_CN[r.name] ?? r.name}路{r.status === "error" ? "报错" : "未启用"}:{r.error || r.note}
|
||||
</p>
|
||||
))}
|
||||
{/* 生产链路:用户实际拿到的结果 */}
|
||||
<RouteCard
|
||||
label="生产链路"
|
||||
hint="混合检索 + rerank —— Agent 实际拿到的就是这个"
|
||||
data={res[""]}
|
||||
highlight
|
||||
/>
|
||||
|
||||
{/* 诊断四路 */}
|
||||
<div>
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-300">
|
||||
分路诊断(分数体系不同,勿跨路比大小)
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{ROUTES.map((r) => {
|
||||
// 单路那次请求里,取它自己那一路的诊断(RRF 融合没有单独一路,故为空)
|
||||
const diag = res[r.mode]?.routes?.find((d) => d.name === r.mode);
|
||||
return <RouteCard key={r.mode} label={r.label} hint={r.hint} data={res[r.mode]} diag={diag} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteCard({
|
||||
label,
|
||||
hint,
|
||||
data,
|
||||
diag,
|
||||
highlight,
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
data?: { hits: KbHit[]; routes?: RouteDiag[]; err?: string };
|
||||
diag?: RouteDiag;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
const hits = data?.hits ?? [];
|
||||
return (
|
||||
<div className={`rounded-lg border p-3 ${highlight ? "border-violet-200 bg-violet-50/40" : "border-gray-100 bg-gray-50/40"}`}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-gray-700">{label}</span>
|
||||
<span className={`text-[11px] tabular-nums ${hits.length ? "text-emerald-600" : "text-gray-400"}`}>
|
||||
{hits.length} 命中
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-gray-400">{hint}</div>
|
||||
|
||||
{data?.err ? (
|
||||
<p className="mt-2 text-[11px] text-rose-500">{data.err}</p>
|
||||
) : hits.length === 0 ? (
|
||||
// 空的原因比"空"本身重要:没配置 / 报错 / 确实没匹配,处理方式完全不同
|
||||
<p className={`mt-2 text-[11px] leading-relaxed ${diag?.status === "error" ? "text-rose-500" : diag?.status === "disabled" ? "text-amber-600" : "text-gray-400"}`}>
|
||||
{diag?.status === "disabled"
|
||||
? `未启用:${diag.note || "该路未配置"}`
|
||||
: diag?.status === "error"
|
||||
? `报错:${diag.error}`
|
||||
: diag?.note || "这一路没召回(索引里确实没有匹配内容)"}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="mt-2 space-y-1.5">
|
||||
{hits.map((h, i) => (
|
||||
<li key={i} className="text-[11px] leading-relaxed">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="shrink-0 text-gray-300">#{i + 1}</span>
|
||||
<span className="shrink-0 tabular-nums text-violet-600">{h.score.toFixed(4)}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-3 text-gray-600" title={h.text}>
|
||||
{h.text}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminTaskDetail, type AdminTask, type AdminTaskDetail } from "../api";
|
||||
|
||||
// 任务下钻:点列表一行看这一次运行到底发生了什么 —— 轨迹、最终输出、评测明细。
|
||||
//
|
||||
// 数据全部取自 sundynix_task 收尾时落库的列,不依赖 Redis 流(那个只有 10min TTL,
|
||||
// 历史任务早没了)。所以这里看到的是"复盘"而非"实时",正在跑的任务轨迹会是空的——
|
||||
// 这一点在 UI 上要讲清楚,否则会被当成"轨迹丢了"。
|
||||
//
|
||||
// 不含审批操作:审批是客户端用户的行为(桌面端 ApprovalBar),管理端只做观测。
|
||||
|
||||
// 字段名以库里真实轨迹为准(不是猜的):seq/ts/node/kind/phase/label/detail。
|
||||
// ts 是毫秒时间戳;kind ∈ system|model|tool|agent|plan|section|render|approval;
|
||||
// phase ∈ info|start|end|await|error。
|
||||
type ExecEvent = {
|
||||
seq?: number;
|
||||
ts?: number;
|
||||
node?: string;
|
||||
kind?: string;
|
||||
phase?: string;
|
||||
label?: string;
|
||||
detail?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
const KIND_TONE: Record<string, string> = {
|
||||
system: "bg-gray-100 text-gray-600",
|
||||
model: "bg-violet-50 text-violet-600",
|
||||
tool: "bg-sky-50 text-sky-600",
|
||||
agent: "bg-indigo-50 text-indigo-600",
|
||||
plan: "bg-amber-50 text-amber-700",
|
||||
section: "bg-emerald-50 text-emerald-600",
|
||||
render: "bg-teal-50 text-teal-600",
|
||||
approval: "bg-orange-50 text-orange-600",
|
||||
};
|
||||
|
||||
// 只有 error/await 值得跳出来:前者是失败点,后者是卡在人工审批。
|
||||
const PHASE_TONE: Record<string, string> = {
|
||||
error: "text-rose-600 font-medium",
|
||||
await: "text-orange-600",
|
||||
};
|
||||
|
||||
const hhmmss = (ms?: number) => (ms ? new Date(ms).toLocaleTimeString("zh-CN", { hour12: false }) : "");
|
||||
|
||||
export function TaskDetailDrawer({ task, onClose }: { task: AdminTask; onClose: () => void }) {
|
||||
const [d, setD] = useState<AdminTaskDetail | null>(null);
|
||||
const [exec, setExec] = useState<ExecEvent[]>([]);
|
||||
const [err, setErr] = useState("");
|
||||
const [tab, setTab] = useState<"trace" | "output" | "eval" | "graph">("trace");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
adminTaskDetail(task.task_id)
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
setD(r.task);
|
||||
setExec(r.exec as ExecEvent[]);
|
||||
})
|
||||
.catch((e) => alive && setErr((e as Error).message));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [task.task_id]);
|
||||
|
||||
// flags 是库里存的 JSON 字符串,实测可能是 "null"(不是 "[]")——JSON.parse 出来是 null,
|
||||
// 不校验就会在 flags.length 上抛 TypeError 把整个控制台白屏掉。必须确认真是数组。
|
||||
const flags: string[] = (() => {
|
||||
try {
|
||||
const v: unknown = d?.eval?.flags ? JSON.parse(d.eval.flags) : null;
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-gray-900/30" onClick={onClose}>
|
||||
<div
|
||||
className="flex h-full w-full max-w-2xl flex-col bg-white shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="border-b border-gray-100 px-5 py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-sm font-semibold text-gray-800">{task.topic || "(无主题)"}</h3>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-gray-400">{task.task_id}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="shrink-0 text-xs text-gray-400 hover:text-gray-600">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-1 text-[11px]">
|
||||
<Row k="租户" v={task.tenant_name || task.tenant_id || "—"} />
|
||||
<Row k="提交人" v={task.owner_email || task.owner || "—"} />
|
||||
<Row k="状态" v={task.status} />
|
||||
<Row k="提交时间" v={new Date(task.at).toLocaleString("zh-CN")} />
|
||||
{task.detail && <Row k="失败原因" v={task.detail} span />}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* 页签 */}
|
||||
<div className="flex gap-1 border-b border-gray-100 px-5 pt-3">
|
||||
{(
|
||||
[
|
||||
["trace", `执行轨迹${exec.length ? ` (${exec.length})` : ""}`],
|
||||
["output", "最终输出"],
|
||||
["eval", "评测"],
|
||||
["graph", "DSL"],
|
||||
] as const
|
||||
).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setTab(k)}
|
||||
className={`rounded-t px-3 py-1.5 text-xs ${
|
||||
tab === k ? "border-b-2 border-violet-500 font-medium text-violet-600" : "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="flex-1 overflow-auto px-5 py-4">
|
||||
{err && <p className="text-xs text-rose-500">{err}</p>}
|
||||
{!d && !err && <p className="text-xs text-gray-400">加载中…</p>}
|
||||
|
||||
{d && tab === "trace" && (
|
||||
exec.length === 0 ? (
|
||||
<Empty
|
||||
text={
|
||||
task.status === "running" || task.status === "submitted"
|
||||
? "任务还在跑,轨迹要等收尾才落库(这里是复盘视图,不是实时流)"
|
||||
: "这次运行没有留下轨迹(早于轨迹落库功能上线,或收尾时落库失败——后者会在 gateway 日志里有 [task] 告警)"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ol className="space-y-2">
|
||||
{exec.map((e, i) => (
|
||||
<li
|
||||
key={e.seq ?? i}
|
||||
className={`rounded-lg border p-2.5 text-[11px] ${
|
||||
e.phase === "error" ? "border-rose-200 bg-rose-50/50" : "border-gray-100 bg-gray-50/60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="shrink-0 tabular-nums text-gray-300">#{e.seq ?? i + 1}</span>
|
||||
{e.label && <span className={`font-medium text-gray-700 ${PHASE_TONE[e.phase ?? ""] ?? ""}`}>{e.label}</span>}
|
||||
{e.kind && (
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_TONE[e.kind] ?? "bg-gray-100 text-gray-500"}`}>
|
||||
{e.kind}
|
||||
</span>
|
||||
)}
|
||||
{e.phase && e.phase !== "info" && (
|
||||
<span className={`text-[10px] ${PHASE_TONE[e.phase] ?? "text-gray-400"}`}>{e.phase}</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 tabular-nums text-gray-300">{hhmmss(e.ts)}</span>
|
||||
</div>
|
||||
{e.node && <div className="mt-0.5 font-mono text-[10px] text-gray-400">{e.node}</div>}
|
||||
{e.detail && <p className="mt-1 whitespace-pre-wrap break-words text-gray-600">{e.detail}</p>}
|
||||
{/* 兜底:字段形状变了也别把线索吞掉(上一版就是猜错字段名,白渲染了一列空行) */}
|
||||
{!e.label && !e.detail && (
|
||||
<pre className="mt-1 overflow-x-auto text-[10px] text-gray-500">{JSON.stringify(e)}</pre>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
)}
|
||||
|
||||
{d && tab === "output" && (
|
||||
d.output ? (
|
||||
<pre className="whitespace-pre-wrap break-words text-[11px] leading-relaxed text-gray-700">{d.output}</pre>
|
||||
) : (
|
||||
<Empty text="没有最终输出(任务未完成或失败)" />
|
||||
)
|
||||
)}
|
||||
|
||||
{d && tab === "eval" && (
|
||||
d.eval ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<Score label="综合" v={d.eval.overall} />
|
||||
<Score label="规则" v={d.eval.rule} />
|
||||
<Score label="质量" v={d.eval.llm} />
|
||||
<Score label="忠实度" v={d.eval.faithful} hint={d.eval.sources === 0 ? "无检索来源,未评" : undefined} />
|
||||
</div>
|
||||
<dl className="space-y-1 rounded-lg bg-gray-50 p-3 text-[11px]">
|
||||
<Row k="分级" v={d.eval.level} />
|
||||
<Row k="检索来源数" v={String(d.eval.sources)} />
|
||||
<Row k="是否纠偏重生成" v={d.eval.corrected ? "是(低分自动纠偏后采纳)" : "否"} />
|
||||
</dl>
|
||||
{flags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{flags.map((f) => (
|
||||
<span key={f} className="rounded bg-amber-50 px-2 py-0.5 text-[10px] text-amber-700">
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{d.eval.reason && (
|
||||
<p className="whitespace-pre-wrap rounded-lg border border-gray-100 p-3 text-[11px] leading-relaxed text-gray-600">
|
||||
{d.eval.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Empty text="尚无评测结果(任务未完成,或评测仍在进行)" />
|
||||
)
|
||||
)}
|
||||
|
||||
{d && tab === "graph" && (
|
||||
d.graph ? (
|
||||
<pre className="overflow-x-auto text-[10px] leading-relaxed text-gray-600">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(d.graph), null, 2);
|
||||
} catch {
|
||||
return d.graph; // 解析不了就原样显示,别把排查线索吞掉
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
) : (
|
||||
<Empty text="没有记录提交时的 DSL" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ k, v, span }: { k: string; v: string; span?: boolean }) {
|
||||
return (
|
||||
<div className={`flex items-baseline gap-2 ${span ? "col-span-2" : ""}`}>
|
||||
<dt className="shrink-0 text-gray-400">{k}</dt>
|
||||
<dd className="min-w-0 break-words text-gray-700">{v}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Score({ label, v, hint }: { label: string; v: number; hint?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-100 p-2 text-center" title={hint}>
|
||||
<div className="text-[10px] text-gray-400">{label}</div>
|
||||
<div className="mt-0.5 text-sm font-semibold tabular-nums text-gray-800">{(v * 100).toFixed(0)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ text }: { text: string }) {
|
||||
return <p className="py-10 text-center text-[11px] leading-relaxed text-gray-400">{text}</p>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { PayProbeDialog } from "./PayProbeDialog";
|
||||
import { adminPacks, savePack, genRedeemCodes, listRedeemCodes, getWechatPay, saveWechatPay, type CreditPack, type RedeemCodeRow, type WechatPayConfig } from "../api";
|
||||
|
||||
// 充值渠道(P5.1,设计见 PAYMENT_DESIGN.md):
|
||||
@@ -26,10 +27,9 @@ export function TopupChannels() {
|
||||
}
|
||||
|
||||
// ---- 微信支付配置:DB 存储、保存即热生效(不重启 gateway)。
|
||||
// APIv3 密钥只写不回显(密文入库,与模型 API Key 同一把密钥加密);
|
||||
// 商户证书私钥文件放服务器磁盘,这里只填路径。
|
||||
// APIv3 密钥密文入库,但单管理员后台明文回显方便核对;证书私钥文件放服务器磁盘,此处只填路径。
|
||||
function WechatConfigBlock() {
|
||||
const empty: WechatPayConfig = { mchid: "", cert_serial: "", private_key_path: "", public_key_path: "", public_key_id: "", appid: "", notify_url: "", has_apiv3_key: false };
|
||||
const empty: WechatPayConfig = { mchid: "", cert_serial: "", private_key_path: "", public_key_path: "", public_key_id: "", appid: "", notify_url: "", apiv3_key: "" };
|
||||
const [cfg, setCfg] = useState<WechatPayConfig>(empty);
|
||||
const [apiv3, setApiv3] = useState(""); // 留空=沿用已存
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
@@ -41,6 +41,7 @@ function WechatConfigBlock() {
|
||||
getWechatPay()
|
||||
.then((r) => {
|
||||
setCfg(r.config);
|
||||
setApiv3(r.config.apiv3_key);
|
||||
setEnabled(r.enabled);
|
||||
setReason(r.reason);
|
||||
})
|
||||
@@ -64,8 +65,6 @@ function WechatConfigBlock() {
|
||||
});
|
||||
setEnabled(r.enabled);
|
||||
setReason(r.reason);
|
||||
setApiv3("");
|
||||
if (apiv3) setCfg((c) => ({ ...c, has_apiv3_key: true }));
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
@@ -107,12 +106,11 @@ function WechatConfigBlock() {
|
||||
{field("公钥 ID(PUB_KEY_ID_ 开头)", "public_key_id", "PUB_KEY_ID_01…")}
|
||||
{field("微信支付公钥文件路径(服务器磁盘)", "public_key_path", "/etc/sundynix/wechat/pub_key.pem", "md:col-span-2")}
|
||||
<label className="text-xs text-gray-500">
|
||||
APIv3 密钥{cfg.has_apiv3_key && <span className="ml-1 text-emerald-600">已保存</span>}
|
||||
APIv3 密钥
|
||||
<input
|
||||
type="password"
|
||||
value={apiv3}
|
||||
onChange={(e) => setApiv3(e.target.value)}
|
||||
placeholder={cfg.has_apiv3_key ? "留空则沿用已保存的" : "32 字节"}
|
||||
placeholder="32 字节"
|
||||
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
@@ -250,6 +248,7 @@ function RedeemBlock() {
|
||||
// ---- 积分包:钱→积分定价(微信支付上线后用户按包扫码) ----
|
||||
function PacksBlock() {
|
||||
const [rows, setRows] = useState<CreditPack[]>([]);
|
||||
const [probe, setProbe] = useState<CreditPack | null>(null); // 正在联调的积分包
|
||||
const [name, setName] = useState("");
|
||||
const [creditsIn, setCreditsIn] = useState("");
|
||||
const [yuan, setYuan] = useState("");
|
||||
@@ -326,7 +325,8 @@ function PacksBlock() {
|
||||
<th className="py-2 pr-3 font-medium">名称</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">积分</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">售价</th>
|
||||
<th className="py-2 text-right font-medium">状态</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">状态</th>
|
||||
<th className="py-2 text-right font-medium">联调</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -335,22 +335,39 @@ function PacksBlock() {
|
||||
<td className="py-2 pr-3 text-gray-800">{p.name}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">{credits(p.credits_micro)}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">¥{(p.price_fen / 100).toFixed(2)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<td className="py-2 pr-3 text-right">
|
||||
<button onClick={() => void toggle(p)}
|
||||
className={`rounded border px-2 py-0.5 text-[11px] ${p.active ? "border-emerald-200 text-emerald-600 hover:bg-emerald-50" : "border-gray-200 text-gray-400 hover:bg-gray-50"}`}>
|
||||
{p.active ? "在售 · 点击下架" : "已下架 · 点击上架"}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{/* 就地下真单验链路。下架的包下单会被服务端拒,按钮也就没意义 */}
|
||||
<button onClick={() => setProbe(p)} disabled={!p.active} title={p.active ? "生成付款码,验证支付链路" : "已下架的包不可下单"}
|
||||
className="rounded border border-violet-200 px-2 py-0.5 text-[11px] text-violet-600 hover:bg-violet-50 disabled:cursor-not-allowed disabled:border-gray-200 disabled:text-gray-300 disabled:hover:bg-transparent">
|
||||
生成付款码
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-6 text-center text-xs text-gray-400">还没有积分包——微信支付上线前配好即可</td>
|
||||
<td colSpan={5} className="py-6 text-center text-xs text-gray-400">还没有积分包——微信支付上线前配好即可</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{probe && (
|
||||
<PayProbeDialog
|
||||
pack={probe}
|
||||
onClose={() => {
|
||||
setProbe(null);
|
||||
void load(); // 联调单也是真单,回来刷一次列表
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+34
-34
@@ -1,11 +1,10 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { adminUsage, grantCredits, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
||||
import { BillingRules } from "../components/BillingRules";
|
||||
import { TopupChannels } from "../components/TopupChannels";
|
||||
import { OrderStream } from "../components/OrderStream";
|
||||
import { adminUsage, listTenants, type TenantRow, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
||||
import { GrantCreditsModal } from "./GrantCreditsModal";
|
||||
|
||||
// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。
|
||||
// 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage,系统级跨租户)即其结果。
|
||||
|
||||
// 仪表盘的「计费与用量」区块:全平台(或单租户)的积分消耗/Token/成本 + 趋势 + 租户排行。
|
||||
// 自包含取数与筛选,直接塞进仪表盘即可,不需要父级传参。
|
||||
// 金额/积分均为微单位(×10⁻⁶),展示时 ÷1e6。
|
||||
|
||||
const MICRO = 1_000_000;
|
||||
@@ -38,7 +37,7 @@ function fillDays(trend: UsageDay[], days: number): UsageDay[] {
|
||||
}
|
||||
const mmdd = (ymdStr: string) => `${ymdStr.slice(4, 6)}-${ymdStr.slice(6, 8)}`;
|
||||
|
||||
export function UsagePage() {
|
||||
export function UsageSection() {
|
||||
const [days, setDays] = useState(30);
|
||||
const [tenant, setTenant] = useState(""); // "" = 全平台
|
||||
const [report, setReport] = useState<UsageReport | null>(null);
|
||||
@@ -46,6 +45,10 @@ export function UsagePage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
// 发放积分 Modal
|
||||
const [grantTenant, setGrantTenant] = useState<TenantRow | null>(null);
|
||||
const [allTenants, setAllTenants] = useState<TenantRow[]>([]);
|
||||
|
||||
|
||||
const load = async () => {
|
||||
setRefreshing(true);
|
||||
@@ -63,30 +66,34 @@ export function UsagePage() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 预载租户列表用于发放积分 Modal
|
||||
listTenants().then(setAllTenants).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [days, tenant]);
|
||||
|
||||
|
||||
const series = useMemo(() => (report ? fillDays(report.trend, days) : []), [report, days]);
|
||||
|
||||
const [granting, setGranting] = useState("");
|
||||
const doGrant = async (tenantId: string, name: string) => {
|
||||
const s = window.prompt(`给「${name}」充值积分(正=充值,负=扣减/校正)`, "100");
|
||||
if (s === null) return;
|
||||
const n = Number(s);
|
||||
if (!n) return;
|
||||
setGranting(tenantId);
|
||||
try {
|
||||
await grantCredits(tenantId, n, "admin 手动充值");
|
||||
await load();
|
||||
} catch (e) {
|
||||
window.alert((e as Error).message);
|
||||
} finally {
|
||||
setGranting("");
|
||||
const openGrant = (r: UsageTenantSum) => {
|
||||
const found = allTenants.find((t) => t.id === r.tenant_id);
|
||||
if (found) {
|
||||
setGrantTenant(found);
|
||||
} else {
|
||||
// 如果全载列表还没到,直接用 UsageTenantSum 构造一个临时对象
|
||||
setGrantTenant({
|
||||
id: r.tenant_id, name: r.name, slug: "", plan: "free",
|
||||
status: "active", credit_balance_micro: r.balance_micro,
|
||||
shared_billing: false, members: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400">加载用量数据中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-500">用量加载失败:{err}</div>;
|
||||
if (!report) return null;
|
||||
@@ -96,18 +103,12 @@ export function UsagePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 配置端:计费规则(改规则即对后续任务生效) */}
|
||||
<BillingRules onSaved={() => void load()} />
|
||||
|
||||
{/* 配置端:充值渠道(兑换码生成/台账 + 积分包定价 + 微信配置,P5.1/P5.2) */}
|
||||
<TopupChannels />
|
||||
|
||||
{/* 观测端:充值订单流 + 对账(P5.3) */}
|
||||
<OrderStream />
|
||||
{/* 发放积分 Modal */}
|
||||
<GrantCreditsModal tenant={grantTenant} onClose={() => setGrantTenant(null)} onDone={() => void load()} />
|
||||
|
||||
{/* 观测端:用量结果 */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<h3 className="text-sm font-semibold text-gray-700">用量观测</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-700">计费与用量</h3>
|
||||
<span className="text-[11px] text-gray-400">按规则折算后的实际消耗</span>
|
||||
</div>
|
||||
|
||||
@@ -214,11 +215,10 @@ export function UsagePage() {
|
||||
<td className={`py-2 pr-3 text-right tabular-nums ${r.balance_micro >= 0 ? "text-gray-800" : "text-rose-500"}`}>{credits(r.balance_micro)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => void doGrant(r.tenant_id, r.name || r.tenant_id)}
|
||||
disabled={granting === r.tenant_id}
|
||||
className="rounded border border-violet-200 px-2.5 py-1 text-xs text-violet-600 hover:bg-violet-50 disabled:opacity-40"
|
||||
onClick={() => openGrant(r)}
|
||||
className="rounded border border-violet-200 px-2.5 py-1 text-xs text-violet-600 hover:bg-violet-50"
|
||||
>
|
||||
{granting === r.tenant_id ? "…" : "充值"}
|
||||
充值
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -8,3 +8,123 @@ body,
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 全局隐藏滚动条(保留滚动功能)——官网与控制台所有页面、以及页内任意滚动区
|
||||
(侧栏、表格 overflow-auto、Modal 等)统一生效。 */
|
||||
* {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* 旧 Edge/IE */
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none; /* Chrome / Safari */
|
||||
}
|
||||
|
||||
/* ══ 官网落地页设计 token(跟随 logo:青→蓝→紫)══
|
||||
仅 src/site/ 的落地页组件用这些;admin 控制台用默认 gray/violet 调色板,互不影响。
|
||||
bg-ground/text-ink 等只作用在 SiteLayout 的容器上(见 site-layout.tsx),不改 body。 */
|
||||
:root {
|
||||
--ground: #f5f7f9;
|
||||
--surface: #ffffff;
|
||||
--ink: #151c24;
|
||||
--ink-2: #526069;
|
||||
--ink-3: #8595a0;
|
||||
--hairline: #e0e7ec;
|
||||
--hairline-strong: #c6d2da;
|
||||
--accent: #0284c7;
|
||||
--accent-ink: #075985;
|
||||
--accent-soft: #e0f2fe;
|
||||
--code-bg: #ebf1f5;
|
||||
--term-bg: #0e1620;
|
||||
--term-ink: #c9e4f5;
|
||||
--brand-g1: #0891b2;
|
||||
--brand-g2: #2563eb;
|
||||
--brand-g3: #9333ea;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--ground: #0b1117;
|
||||
--surface: #111925;
|
||||
--ink: #e6edf3;
|
||||
--ink-2: #93a6b4;
|
||||
--ink-3: #62737f;
|
||||
--hairline: #1f2c38;
|
||||
--hairline-strong: #32434f;
|
||||
--accent: #38bdf8;
|
||||
--accent-ink: #7dd3fc;
|
||||
--accent-soft: #0b3049;
|
||||
--code-bg: #14202b;
|
||||
--term-bg: #0a121b;
|
||||
--term-ink: #bfe3f7;
|
||||
--brand-g1: #22d3ee;
|
||||
--brand-g2: #60a5fa;
|
||||
--brand-g3: #c084fc;
|
||||
}
|
||||
|
||||
/* v3 对 var() 色不支持 /opacity 修饰,用 color-mix 还原 bg-ground/85(sticky 头透底 + 模糊)。 */
|
||||
.bg-ground-85 {
|
||||
background-color: color-mix(in srgb, var(--ground) 85%, transparent);
|
||||
}
|
||||
|
||||
/* 品牌渐变文字(logo 同源:青→蓝→紫),落地页克制使用 */
|
||||
.text-brand-gradient {
|
||||
background: linear-gradient(92deg, var(--brand-g1), var(--brand-g2) 55%, var(--brand-g3));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* 终端光标 */
|
||||
@keyframes caret-blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.caret-blink {
|
||||
animation: caret-blink 1.1s steps(1) infinite;
|
||||
}
|
||||
|
||||
/* 滚动进场(配合 Reveal 组件) */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
transition:
|
||||
opacity 0.6s ease,
|
||||
transform 0.6s ease;
|
||||
}
|
||||
.reveal-in {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 终端事件流:逐行浮现 */
|
||||
@keyframes term-line-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.term-line {
|
||||
opacity: 0;
|
||||
animation: term-line-in 0.45s ease forwards;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.caret-blink,
|
||||
.term-line {
|
||||
animation: none;
|
||||
}
|
||||
.term-line {
|
||||
opacity: 1;
|
||||
}
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,64 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { listAudit, listGuardrailEvents, type AuditEntry, type GuardrailEventItem } from "../api";
|
||||
import { listAudit, type AuditEntry } from "../api";
|
||||
|
||||
// 操作方法配色。
|
||||
// 操作方法配色
|
||||
const ACTION_STYLE: Record<string, string> = {
|
||||
POST: "bg-emerald-50 text-emerald-600",
|
||||
PUT: "bg-amber-50 text-amber-600",
|
||||
DELETE: "bg-rose-50 text-rose-600",
|
||||
PATCH: "bg-violet-50 text-violet-600",
|
||||
POST: "bg-emerald-50 text-emerald-600 border border-emerald-100",
|
||||
PUT: "bg-amber-50 text-amber-600 border border-amber-100",
|
||||
DELETE: "bg-rose-50 text-rose-600 border border-rose-100",
|
||||
PATCH: "bg-violet-50 text-violet-600 border border-violet-100",
|
||||
GET: "bg-gray-50 text-gray-600 border border-gray-100",
|
||||
};
|
||||
const statusTone = (s: number) => (s < 300 ? "text-emerald-600" : s < 500 ? "text-amber-600" : "text-rose-500");
|
||||
|
||||
// uid 太长,展示时截断(保留首尾,hover 看全)。
|
||||
const statusTone = (s: number) => (s < 300 ? "text-emerald-600" : s < 500 ? "text-amber-600" : "text-rose-500");
|
||||
const shortId = (id: string) => (id && id.length > 10 ? `${id.slice(0, 4)}…${id.slice(-4)}` : id || "系统");
|
||||
const fmt = (at: string) => new Date(at).toLocaleString("zh-CN", { hour12: false });
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
const ROUTE_OPTS = [
|
||||
{ value: "", label: "所有路由" },
|
||||
{ value: "/api/v1/admin", label: "管理员接口 (/admin)" },
|
||||
{ value: "/api/v1/prompts", label: "提示词接口 (/prompts)" },
|
||||
{ value: "/api/v1/auth", label: "鉴权接口 (/auth)" },
|
||||
{ value: "/api/v1/spaces", label: "空间接口 (/spaces)" },
|
||||
];
|
||||
|
||||
export function AuditPage() {
|
||||
const [audit, setAudit] = useState<AuditEntry[]>([]);
|
||||
const [events, setEvents] = useState<GuardrailEventItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setRefreshing(true);
|
||||
// 筛选器状态
|
||||
const [methodFilter, setMethodFilter] = useState<string>(""); // 空 = 全部
|
||||
const [routeFilter, setRouteFilter] = useState<string>("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// 分页状态
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const load = async (pageNum = page, showRef = false) => {
|
||||
if (showRef) setRefreshing(true);
|
||||
else setLoading(true);
|
||||
|
||||
try {
|
||||
const [a, e] = await Promise.all([listAudit(80), listGuardrailEvents(80)]);
|
||||
setAudit(a);
|
||||
setEvents(e);
|
||||
const offset = (pageNum - 1) * LIMIT;
|
||||
// 从后端载入比 LIMIT 稍微多一条,以此判断是否有下一页
|
||||
const data = await listAudit(LIMIT + 1, offset, {
|
||||
action: methodFilter,
|
||||
path: routeFilter,
|
||||
q: searchQuery,
|
||||
});
|
||||
if (data.length > LIMIT) {
|
||||
setAudit(data.slice(0, LIMIT));
|
||||
setHasMore(true);
|
||||
} else {
|
||||
setAudit(data);
|
||||
setHasMore(false);
|
||||
}
|
||||
setUpdatedAt(new Date());
|
||||
setErr("");
|
||||
} catch (er) {
|
||||
@@ -39,36 +70,47 @@ export function AuditPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const t = setInterval(() => void load(), 30000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
void load(page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400">加载审计记录中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-500">加载失败:{err}</div>;
|
||||
// 筛选条件变化 → 回第 1 页重新向服务端查询(防抖 300ms,免得每敲一个字打一次库)。
|
||||
// 已在第 1 页时 setPage 不会触发上面的 effect,所以这里直接 load。
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
if (page !== 1) setPage(1);
|
||||
else void load(1);
|
||||
}, 300);
|
||||
return () => window.clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [methodFilter, routeFilter, searchQuery]);
|
||||
|
||||
const blocked = events.filter((e) => e.kind === "blocked").length;
|
||||
const suspect = events.filter((e) => e.kind === "suspect").length;
|
||||
// 筛选已由服务端完成(全库匹配 + 分页),前端不再二次过滤:
|
||||
// 否则会在"服务端已筛过的一页"上再筛一次,翻页计数与实际结果对不上。
|
||||
const filteredAudit = audit;
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (page > 1) setPage(page - 1);
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (hasMore) setPage(page + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 顶栏 */}
|
||||
<div className="flex flex-wrap items-center gap-4 rounded-2xl border border-gray-200/70 bg-white p-5">
|
||||
<div className="mr-auto">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">审计 & 安全事件</h3>
|
||||
<p className="text-xs text-gray-400">敏感操作留痕 + 输入护栏命中 · 只增不改,供运维溯源</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-gray-150 bg-white p-5 shadow-sm">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">敏感操作审计</h3>
|
||||
<p className="text-xs text-gray-400">敏感操作全链路留痕 · 只增不改,供合规审计与故障溯源</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-100">
|
||||
<Stat value={audit.length} label="操作留痕" />
|
||||
<Stat value={blocked} label="护栏拦截" bad={blocked > 0} />
|
||||
<Stat value={suspect} label="灰区放行" warn={suspect > 0} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
{updatedAt && <span>更新于 {updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}</span>}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
{updatedAt && <span>最近更新:{updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}</span>}
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
onClick={() => void load(page, true)}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1 rounded border border-gray-200 px-2.5 py-1 text-gray-500 hover:bg-gray-50 disabled:opacity-40"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
@@ -78,54 +120,134 @@ export function AuditPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 安全事件(护栏命中) */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-5">
|
||||
<div className="mb-3 flex items-baseline gap-2.5">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">安全事件</h3>
|
||||
<span className="text-xs text-gray-400">输入护栏拦截 / 灰区放行({events.length})</span>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<div className="rounded-xl bg-gray-50/70 py-8 text-center text-xs text-gray-400">暂无护栏命中 🎉</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="flex items-center gap-3 py-2.5 text-xs">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${e.kind === "blocked" ? "bg-rose-50 text-rose-600" : "bg-amber-50 text-amber-600"}`}>
|
||||
{e.kind === "blocked" ? "拦截" : "灰区"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-gray-700" title={e.reason}>{e.reason || "(无原因)"}</span>
|
||||
<code className="hidden shrink-0 font-mono text-[10px] text-gray-400 sm:inline">{e.method} {e.path}</code>
|
||||
<span className="shrink-0 font-mono text-[10px] text-gray-300" title={e.actor}>{shortId(e.actor)}</span>
|
||||
<span className="hidden shrink-0 text-[10px] text-gray-300 md:inline">{e.ip}</span>
|
||||
<span className="shrink-0 text-[10px] text-gray-300">{fmt(e.at)}</span>
|
||||
</div>
|
||||
{/* 筛选工具栏 */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{/* HTTP Method Button Group */}
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-gray-150 bg-gray-50 p-1 text-xs">
|
||||
{["", "GET", "POST", "PUT", "DELETE"].map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => { setMethodFilter(m); setPage(1); }}
|
||||
className={`rounded px-3 py-1 font-semibold transition-all ${
|
||||
methodFilter === m
|
||||
? "bg-violet-600 text-white shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{m || "全部"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 操作审计 */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-5">
|
||||
<div className="mb-3 flex items-baseline gap-2.5">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">操作审计</h3>
|
||||
<span className="text-xs text-gray-400">改配置 / 改密钥 / 激活提示词 / 审批 等变更操作({audit.length})</span>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Route prefix select */}
|
||||
<select
|
||||
value={routeFilter}
|
||||
onChange={(e) => { setRouteFilter(e.target.value); setPage(1); }}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-700 focus:outline-none focus:border-violet-400 shadow-sm"
|
||||
>
|
||||
{ROUTE_OPTS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Search Input */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索操作者 ID / IP / 详情…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => { setSearchQuery(e.target.value); setPage(1); }}
|
||||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs focus:outline-none focus:border-violet-400 w-56 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{audit.length === 0 ? (
|
||||
<div className="rounded-xl bg-gray-50/70 py-8 text-center text-xs text-gray-400">暂无操作留痕</div>
|
||||
</div>
|
||||
|
||||
{/* 审计日志列表 */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-baseline justify-between border-b pb-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">操作日志明细</h3>
|
||||
<span className="text-xs text-gray-400">当前页展示 {filteredAudit.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-xs text-gray-400 animate-pulse">正在载入审计记录…</div>
|
||||
) : err ? (
|
||||
<div className="py-12 text-center text-xs text-rose-500">加载失败:{err}</div>
|
||||
) : filteredAudit.length === 0 ? (
|
||||
<div className="py-12 text-center text-xs text-gray-400">没有匹配的审计记录</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{audit.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-3 py-2.5 text-xs">
|
||||
<span className={`w-14 shrink-0 rounded px-1.5 py-0.5 text-center text-[10px] font-semibold ${ACTION_STYLE[a.action] ?? "bg-gray-100 text-gray-500"}`}>
|
||||
{a.action}
|
||||
</span>
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-[11px] text-gray-600" title={a.path}>{a.path}</code>
|
||||
<span className={`shrink-0 font-mono text-[10px] ${statusTone(a.status)}`}>{a.status}</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-gray-300" title={a.actor}>{shortId(a.actor)}</span>
|
||||
<span className="hidden shrink-0 text-[10px] text-gray-300 md:inline">{a.ip}</span>
|
||||
<span className="shrink-0 text-[10px] text-gray-300">{fmt(a.at)}</span>
|
||||
<div className="space-y-1.5">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium w-16">方法</th>
|
||||
<th className="py-2 pr-3 font-medium">路由路径</th>
|
||||
<th className="py-2 pr-3 font-medium w-16">状态</th>
|
||||
<th className="py-2 pr-3 font-medium">详情</th>
|
||||
<th className="py-2 pr-3 font-medium w-24">操作人</th>
|
||||
<th className="py-2 pr-3 font-medium w-28">客户端 IP</th>
|
||||
<th className="py-2 font-medium w-36">触发时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAudit.map((a) => (
|
||||
<tr key={a.id} className="border-b border-gray-50 last:border-0 hover:bg-gray-50/40 text-xs">
|
||||
<td className="py-2.5 pr-3">
|
||||
<span className={`inline-block w-14 rounded px-1.5 py-0.5 text-center text-[10px] font-bold ${ACTION_STYLE[a.action] ?? "bg-gray-100 text-gray-500"}`}>
|
||||
{a.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 max-w-[12rem] truncate font-mono text-[11px] text-gray-600" title={a.path}>
|
||||
{a.path}
|
||||
</td>
|
||||
<td className={`py-2.5 pr-3 font-mono font-semibold ${statusTone(a.status)}`}>
|
||||
{a.status}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-500 text-[11px] max-w-[20rem] truncate" title={a.detail}>
|
||||
{a.detail || "—"}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 font-mono text-gray-400" title={a.actor}>
|
||||
{shortId(a.actor)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-400 font-mono">
|
||||
{a.ip}
|
||||
</td>
|
||||
<td className="py-2.5 text-gray-400">
|
||||
{fmt(a.at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 pt-4 mt-2">
|
||||
<span className="text-xs text-gray-400">
|
||||
当前第 <span className="font-semibold text-gray-700">{page}</span> 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={page === 1}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasMore}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -133,11 +255,3 @@ export function AuditPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ value, label, bad, warn }: { value: number; label: string; bad?: boolean; warn?: boolean }) {
|
||||
return (
|
||||
<div className="px-4 first:pl-0">
|
||||
<div className={`text-xl font-semibold tracking-tight ${bad ? "text-rose-500" : warn ? "text-amber-600" : "text-gray-900"}`}>{value}</div>
|
||||
<div className="text-[10px] text-gray-400">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { UsageSection } from "../components/UsageSection";
|
||||
import { adminOverview, getStatus, type AdminOverview, type SystemStatus } from "../api";
|
||||
|
||||
// 管理端「概览」= 系统控制塔:统筹全系统的吞吐 / 配置态 / 健康,而非某个账号的个人工作台。
|
||||
@@ -150,6 +151,75 @@ export function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* C. 全局任务吞吐 */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm lg:col-span-2">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700">全平台任务吞吐</h4>
|
||||
<p className="text-[11px] text-gray-400">近 7 天调度中心处理的任务总数(所有租户/用户)</p>
|
||||
</div>
|
||||
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700">真实数据</span>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="h-48 w-full overflow-visible">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<line x1={pad} y1={H - pad} x2={W - pad} y2={H - pad} stroke="#f1f5f9" />
|
||||
{areaPath && <path d={areaPath} fill="url(#g)" />}
|
||||
{dPath && <path d={dPath} fill="none" stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
{pts.map((p, i) => (
|
||||
<g key={i}>
|
||||
<circle cx={p.x} cy={p.y} r="4" fill="#fff" stroke="#7c3aed" strokeWidth="2" />
|
||||
<title>{`${trend[i].key}: ${trend[i].count} 任务`}</title>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
<div className="mt-2 flex justify-between px-2 text-[10px] text-gray-400">
|
||||
{trend.map((d) => (
|
||||
<span key={d.key}>{d.key}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700">任务终态分布</h4>
|
||||
<p className="text-[11px] text-gray-400">近 7 天全局终态占比</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{ov.status_count.length === 0 && <div className="text-xs text-gray-400">暂无任务</div>}
|
||||
{ov.status_count
|
||||
.slice()
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map((d) => {
|
||||
const st = statusStyle(d.key);
|
||||
const pct = (d.count / totalStatus) * 100;
|
||||
return (
|
||||
<div key={d.key}>
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5 text-gray-600">
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: st.color }} />
|
||||
{st.label}
|
||||
</span>
|
||||
<span className="font-semibold text-gray-400">{d.count} · {pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
|
||||
<div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: st.color }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 计费与用量(原独立页并入:积分消耗/Token/成本 + 趋势 + 各租户排行 + 充值) */}
|
||||
<UsageSection />
|
||||
|
||||
{/* B. 控制面配置态(管理端独有) */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
@@ -224,90 +294,6 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* C. 全局任务吞吐 */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm lg:col-span-2">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700">全平台任务吞吐</h4>
|
||||
<p className="text-[11px] text-gray-400">近 7 天调度中心处理的任务总数(所有租户/用户)</p>
|
||||
</div>
|
||||
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700">真实数据</span>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="h-48 w-full overflow-visible">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<line x1={pad} y1={H - pad} x2={W - pad} y2={H - pad} stroke="#f1f5f9" />
|
||||
{areaPath && <path d={areaPath} fill="url(#g)" />}
|
||||
{dPath && <path d={dPath} fill="none" stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
{pts.map((p, i) => (
|
||||
<g key={i}>
|
||||
<circle cx={p.x} cy={p.y} r="4" fill="#fff" stroke="#7c3aed" strokeWidth="2" />
|
||||
<title>{`${trend[i].key}: ${trend[i].count} 任务`}</title>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
<div className="mt-2 flex justify-between px-2 text-[10px] text-gray-400">
|
||||
{trend.map((d) => (
|
||||
<span key={d.key}>{d.key}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700">任务终态分布</h4>
|
||||
<p className="text-[11px] text-gray-400">近 7 天全局终态占比</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{ov.status_count.length === 0 && <div className="text-xs text-gray-400">暂无任务</div>}
|
||||
{ov.status_count
|
||||
.slice()
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map((d) => {
|
||||
const st = statusStyle(d.key);
|
||||
const pct = (d.count / totalStatus) * 100;
|
||||
return (
|
||||
<div key={d.key}>
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5 text-gray-600">
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: st.color }} />
|
||||
{st.label}
|
||||
</span>
|
||||
<span className="font-semibold text-gray-400">{d.count} · {pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
|
||||
<div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: st.color }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* D. 系统健康拓扑 */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700">系统健康拓扑</h4>
|
||||
<p className="text-[11px] text-gray-400">基建 + 应用服务 + MCP 工具注册(NATS 实时探活)</p>
|
||||
</div>
|
||||
<span className="rounded bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700">实时</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<HealthGroup title="基建" items={status.infra.map((i) => ({ name: i.name, up: i.up, detail: i.detail }))} />
|
||||
<HealthGroup title="应用服务" items={status.services.map((s) => ({ name: s.name, up: s.up, detail: s.detail }))} />
|
||||
<HealthGroup
|
||||
title="MCP 工具组"
|
||||
items={status.tools.map((t) => ({ name: t.server, up: t.up, detail: `${t.tools?.length ?? 0} 个工具` }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -321,23 +307,6 @@ function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function HealthGroup({ title, items }: { title: string; items: { name: string; up: boolean; detail?: string }[] }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 mt-2 text-[10px] font-semibold uppercase tracking-wider text-gray-300">{title}</div>
|
||||
{items.map((it) => (
|
||||
<div key={it.name} className="flex items-center justify-between border-b border-gray-50 py-1.5 text-xs">
|
||||
<span className="flex items-center gap-1.5 text-gray-600">
|
||||
<span className={`h-2 w-2 rounded-full ${it.up ? "bg-emerald-500" : "bg-rose-500"}`} />
|
||||
{it.name}
|
||||
</span>
|
||||
<span className={`text-[10px] ${it.up ? "text-gray-400" : "text-rose-500"}`}>{it.up ? it.detail || "在线" : "离线"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Tone = "violet" | "emerald" | "cyan" | "amber" | "rose";
|
||||
const TONE: Record<Tone, string> = {
|
||||
violet: "bg-violet-50 text-violet-600",
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ModelManager } from "../components/ModelManager";
|
||||
import { adminDatasources, type DatasourceKB } from "../api";
|
||||
import { Link } from "react-router-dom";
|
||||
import { RetrievalBench } from "../components/RetrievalBench";
|
||||
import { adminDatasources, listModels, migrateKbStorage, type DatasourceKB, type MigrateKbResult } from "../api";
|
||||
|
||||
// 数据源 & RAG:真数据(全平台知识库清单,来自 sundynix_kb/sundynix_doc)。
|
||||
// 此前该页 GraphRAG 拓扑图与「向量/全文/图谱权重滑块」全 mock——而且权重概念本身是虚构的:
|
||||
// mcp-go 的 RRF 融合是各路等权的倒排互惠融合(rrfK=60 平滑常数),没有"每路占几成"的权重。
|
||||
// 故删假滑块+假拓扑,换成真数据源清单 + 诚实的检索管线说明。
|
||||
|
||||
const KIND_LABEL: Record<string, string> = { general: "通用", folder: "文件夹", project: "项目", case: "案例" };
|
||||
const fmtWords = (n: number) => (n >= 1e4 ? `${(n / 1e4).toFixed(1)}万` : `${n}`);
|
||||
|
||||
@@ -15,6 +12,17 @@ export function DatasourcesPage() {
|
||||
const [rows, setRows] = useState<DatasourceKB[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
// 运行时的 active embedding 模型展示
|
||||
const [activeEmbedding, setActiveEmbedding] = useState<string>("加载中…");
|
||||
|
||||
// 运维工具折叠状态
|
||||
const [opsOpen, setOpsOpen] = useState(false);
|
||||
|
||||
// 迁移 Modal 状态
|
||||
const [migrationModal, setMigrationModal] = useState<"none" | "confirm" | "running" | "done">("none");
|
||||
const [migrationResult, setMigrationResult] = useState<MigrateKbResult | null>(null);
|
||||
const [migrationErr, setMigrationErr] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
adminDatasources()
|
||||
@@ -25,19 +33,199 @@ export function DatasourcesPage() {
|
||||
})
|
||||
.catch((e) => setErr((e as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
// 获取激活的 embedding 模型
|
||||
listModels("embedding")
|
||||
.then((models) => {
|
||||
const active = models.find((m) => m.active);
|
||||
if (active) {
|
||||
setActiveEmbedding(`${active.model} (${active.provider})`);
|
||||
} else {
|
||||
setActiveEmbedding("未配置/未激活 (将回退代码默认)");
|
||||
}
|
||||
})
|
||||
.catch(() => setActiveEmbedding("获取失败"));
|
||||
}, []);
|
||||
|
||||
const startMigration = async () => {
|
||||
setMigrationModal("running");
|
||||
setMigrationErr("");
|
||||
try {
|
||||
const res = await migrateKbStorage();
|
||||
setMigrationResult(res);
|
||||
setMigrationModal("done");
|
||||
} catch (e) {
|
||||
setMigrationErr((e as Error).message);
|
||||
setMigrationModal("confirm"); // 退回到确认态展示错误
|
||||
}
|
||||
};
|
||||
|
||||
const totalWords = rows.reduce((a, r) => a + r.total_words, 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Embedding 模型配置(真实组件) */}
|
||||
<ModelManager
|
||||
kind="embedding"
|
||||
title="Embedding 模型(embedding → mcp-go RAG)"
|
||||
baseUrlHint="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
modelHint="text-embedding-v3"
|
||||
/>
|
||||
{/* 顶部激活模型只读展示 & 引导 */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700">当前激活向量化模型 (Embedding)</h4>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
<code className="text-xs font-mono font-medium text-gray-800">{activeEmbedding}</code>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to="/admin/models"
|
||||
className="flex items-center gap-1 text-xs font-medium text-violet-600 hover:text-violet-700 hover:underline"
|
||||
>
|
||||
前往「模型配置」修改 →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 运维工具 (RAG 迁移) */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<button
|
||||
onClick={() => setOpsOpen(!opsOpen)}
|
||||
className="flex w-full items-center justify-between font-semibold text-gray-700 focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-gray-700">运维工具</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{opsOpen ? "收起 ▲" : "展开 ▼"}</span>
|
||||
</button>
|
||||
|
||||
{opsOpen && (
|
||||
<div className="mt-4 border-t border-gray-100 pt-4 space-y-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 text-xs bg-amber-50/50 border border-amber-100 p-4 rounded-lg">
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold text-amber-800 flex items-center gap-1.5">
|
||||
<span>知识库存储目录路径迁移</span>
|
||||
<span className="bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded text-[9px] font-bold">建议在低峰期操作</span>
|
||||
</div>
|
||||
<p className="text-gray-500 leading-relaxed max-w-xl">
|
||||
将平台全量知识库底层的存储目录规范,由早期版本的 <code className="bg-gray-100 px-1 rounded">owner/kb_id/doc</code> 升级为新版本统一的 <code className="bg-gray-100 px-1 rounded">space_id/kb_id/doc</code> 规范。此操作将遍历所有文件,并在数据库与对象存储中更新映射关系,迁移期间可能产生轻微检索延迟。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMigrationModal("confirm")}
|
||||
className="self-start md:self-center shrink-0 bg-amber-600 hover:bg-amber-700 text-white text-xs font-semibold py-2 px-4 rounded-lg shadow-sm transition"
|
||||
>
|
||||
开始迁移
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 迁移 Modal */}
|
||||
{migrationModal !== "none" && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
{migrationModal === "confirm" && "确认开始存储迁移?"}
|
||||
{migrationModal === "running" && "正在迁移中…"}
|
||||
{migrationModal === "done" && "迁移完成 🎉"}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-400">一键升级平台 KB 作用域至 space_id</p>
|
||||
</div>
|
||||
{migrationModal !== "running" && (
|
||||
<button
|
||||
onClick={() => setMigrationModal("none")}
|
||||
className="text-xl leading-none text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 py-5 text-sm">
|
||||
{migrationModal === "confirm" && (
|
||||
<>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
您将触发全平台的数据目录热迁移。这会将旧版未挂载空间的独立知识库映射,统一调整至 Space 空间映射下。
|
||||
</p>
|
||||
<div className="rounded-lg border border-amber-100 bg-amber-50 p-3 text-xs text-amber-700 space-y-1">
|
||||
<div className="font-semibold">⚠️ 运维安全警示:</div>
|
||||
<div>· 这是一个高危数据库与存储热变更操作</div>
|
||||
<div>· 建议执行前对 <code className="font-mono bg-amber-100/50 px-1">sundynix_doc</code> 表进行备份</div>
|
||||
<div>· 确认执行后不可中途停止</div>
|
||||
</div>
|
||||
{migrationErr && (
|
||||
<div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">
|
||||
操作失败:{migrationErr}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{migrationModal === "running" && (
|
||||
<div className="flex flex-col items-center justify-center py-6 space-y-3">
|
||||
<svg className="animate-spin h-8 w-8 text-amber-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-xs text-gray-500 font-medium animate-pulse">正在扫描文档并重新入队,请勿关闭窗口…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{migrationModal === "done" && migrationResult && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
|
||||
数据升级指令已成功执行,各文档已重新入队执行 RRF 索引重灌。
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100 border rounded-xl overflow-hidden bg-gray-50/50">
|
||||
<div className="flex justify-between p-3 text-xs">
|
||||
<span className="text-gray-500">已扫描文档总数</span>
|
||||
<span className="font-semibold text-gray-800 font-mono">{migrationResult.total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between p-3 text-xs">
|
||||
<span className="text-gray-500">入队重灌文档数</span>
|
||||
<span className="font-semibold text-amber-600 font-mono">{migrationResult.enqueued}</span>
|
||||
</div>
|
||||
<div className="flex justify-between p-3 text-xs">
|
||||
<span className="text-gray-500">跳过(已是新作用域)</span>
|
||||
<span className="font-semibold text-gray-600 font-mono">{migrationResult.skipped}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-gray-100 px-6 py-4">
|
||||
{migrationModal === "confirm" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setMigrationModal("none")}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-xs text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void startMigration()}
|
||||
className="rounded-lg bg-amber-600 hover:bg-amber-700 px-4 py-2 text-xs font-semibold text-white shadow-sm transition"
|
||||
>
|
||||
确认迁移
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{migrationModal === "done" && (
|
||||
<button
|
||||
onClick={() => setMigrationModal("none")}
|
||||
className="rounded-lg bg-violet-600 hover:bg-violet-700 px-4 py-2 text-xs font-semibold text-white shadow-sm transition"
|
||||
>
|
||||
我知道了
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 检索管线说明(诚实:三路 + RRF 等权融合,非可调权重) */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
@@ -53,6 +241,9 @@ export function DatasourcesPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 检索试验台:紧跟管线说明 —— 讲完原理就能当场验证,不用只看文字 */}
|
||||
<RetrievalBench kbs={rows} />
|
||||
|
||||
{/* 平台数据源计数 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Stat label="知识库" value={String(counts.kbs)} tone="violet" />
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { guardrailEvents, type GuardrailEvent } from "../api";
|
||||
import { listGuardrailEvents, type GuardrailEventItem } from "../api";
|
||||
|
||||
// 输入护栏观测:真数据(来自 guardrail_event,middleware.Guardrail 命中即落库)。
|
||||
// 命中事件流(blocked 硬拦 / suspect 灰区放行→Tier2 LLM 裁决) + 计数 + 原因分布。
|
||||
//
|
||||
// 诚实边界:护栏规则(Tier1 正则/敏感词 + Tier2 LLM 分类器)目前是中间件里的代码常量,
|
||||
// 尚不支持运行时编辑(改规则需改代码重部署)。故本页只做观测,不摆"能改却不生效"的假配置面。
|
||||
// 若要规则运行时可配,需另建配置存储 + 中间件读库(参考 prompt 控制面的热下发)。
|
||||
|
||||
const KIND_BADGE: Record<string, string> = { blocked: "bg-rose-50 text-rose-600", suspect: "bg-amber-50 text-amber-600" };
|
||||
const KIND_BADGE: Record<string, string> = { blocked: "bg-rose-50 text-rose-600 border border-rose-100", suspect: "bg-amber-50 text-amber-600 border border-amber-100" };
|
||||
const KIND_LABEL: Record<string, string> = { blocked: "硬拦截", suspect: "灰区放行" };
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
// 解析 signals(JSON 数组字符串)为可读标签。
|
||||
function signalLabels(raw: string): string[] {
|
||||
if (!raw) return [];
|
||||
@@ -23,26 +21,40 @@ function signalLabels(raw: string): string[] {
|
||||
}
|
||||
|
||||
export function GuardrailsPage() {
|
||||
const [events, setEvents] = useState<GuardrailEvent[]>([]);
|
||||
const [events, setEvents] = useState<GuardrailEventItem[]>([]);
|
||||
const [filter, setFilter] = useState<"" | "blocked" | "suspect">("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// 分页状态
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
guardrailEvents(100)
|
||||
const offset = (page - 1) * LIMIT;
|
||||
listGuardrailEvents(LIMIT + 1, offset)
|
||||
.then((r) => {
|
||||
setEvents(r);
|
||||
if (r.length > LIMIT) {
|
||||
setEvents(r.slice(0, LIMIT));
|
||||
setHasMore(true);
|
||||
} else {
|
||||
setEvents(r);
|
||||
setHasMore(false);
|
||||
}
|
||||
setErr("");
|
||||
})
|
||||
.catch((e) => setErr((e as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(load, []);
|
||||
|
||||
useEffect(load, [page]);
|
||||
|
||||
const blocked = events.filter((e) => e.kind === "blocked").length;
|
||||
const suspect = events.filter((e) => e.kind === "suspect").length;
|
||||
// 原因/信号 Top(真实命中分布)。
|
||||
|
||||
// 原因/信号 Top(基于当前拉取列表的命中分布)。
|
||||
const topReasons = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const e of events) {
|
||||
@@ -52,34 +64,58 @@ export function GuardrailsPage() {
|
||||
return [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
|
||||
}, [events]);
|
||||
|
||||
const shown = filter ? events.filter((e) => e.kind === filter) : events;
|
||||
const shown = useMemo(() => {
|
||||
return events.filter((e) => {
|
||||
// 1. 类型过滤
|
||||
if (filter && e.kind !== filter) return false;
|
||||
// 2. 搜索过滤 (Actor 或 IP)
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchActor = e.actor?.toLowerCase().includes(q) ?? false;
|
||||
const matchIp = e.ip?.toLowerCase().includes(q) ?? false;
|
||||
const matchReason = e.reason?.toLowerCase().includes(q) ?? false;
|
||||
if (!matchActor && !matchIp && !matchReason) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [events, filter, searchQuery]);
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400">加载护栏事件中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-500">护栏事件加载失败:{err}</div>;
|
||||
const handlePrevPage = () => {
|
||||
if (page > 1) setPage(page - 1);
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (hasMore) setPage(page + 1);
|
||||
};
|
||||
|
||||
if (loading && page === 1) return <div className="text-sm text-gray-400 p-5">加载护栏事件中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-500 p-5">护栏事件加载失败:{err}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-800">输入护栏</h2>
|
||||
<h2 className="text-base font-semibold text-gray-800">安全护栏观测</h2>
|
||||
<p className="text-xs text-gray-400">命中事件流 · 硬拦截与灰区放行 · 原因分布(全平台真实命中)</p>
|
||||
</div>
|
||||
<button onClick={load} className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50">刷新</button>
|
||||
<button onClick={load} className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50 shadow-sm">
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 计数 */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||
<Stat label="硬拦截" value={String(blocked)} tone="rose" sub="Tier1 命中即拒(近 100 条内)" />
|
||||
<Stat label="灰区放行" value={String(suspect)} tone="amber" sub="打标 → Tier2 LLM 执行前裁决" />
|
||||
<Stat label="命中总数" value={String(events.length)} tone="violet" sub="近 100 条护栏事件" />
|
||||
<Stat label="当前页硬拦截" value={String(blocked)} tone="rose" sub="Tier1 命中即拒" />
|
||||
<Stat label="当前页灰区放行" value={String(suspect)} tone="amber" sub="打标 → Tier2 LLM 裁决" />
|
||||
<Stat label="当前页命中总数" value={String(events.length)} tone="violet" sub={`分页数: ${LIMIT}`} />
|
||||
</div>
|
||||
|
||||
{/* 规则说明(诚实:规则在代码里,非运行时可配) */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">护栏规则</h4>
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">护栏机制说明</h4>
|
||||
<div className="space-y-1.5 text-xs leading-relaxed text-gray-500">
|
||||
<p><span className="font-medium text-gray-700">Tier1(中间件,同步硬拦)</span>:注入正则 + 敏感词库命中即 <span className="text-rose-600">blocked</span> 拒绝请求。</p>
|
||||
<p><span className="font-medium text-gray-700">Tier2(灰区升级)</span>:疑似输入打标 <span className="text-amber-600">suspect</span> 放行,由 Dispatcher 执行前调 LLM 分类器裁决。</p>
|
||||
<p><span className="font-medium text-gray-700">Tier1(中间件,同步硬拦)</span>:注入正则 + 敏感词库命中即 <span className="text-rose-600 font-medium">blocked</span> 拒绝请求。</p>
|
||||
<p><span className="font-medium text-gray-700">Tier2(灰区升级)</span>:疑似输入打标 <span className="text-amber-600 font-medium">suspect</span> 放行,由 Dispatcher 执行前调 LLM 分类器裁决。</p>
|
||||
<p className="text-gray-400">规则当前定义在网关中间件代码中,尚不支持运行时编辑(改规则需改代码重部署)。运行时可配需另建配置存储 + 中间件读库,参考「提示词」控制面的热下发。</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,57 +139,100 @@ export function GuardrailsPage() {
|
||||
)}
|
||||
|
||||
{/* 事件流 */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700">命中事件流</h4>
|
||||
<div className="flex overflow-hidden rounded-lg border border-gray-200 text-xs">
|
||||
{([["", "全部"], ["blocked", "硬拦截"], ["suspect", "灰区"]] as const).map(([v, label]) => (
|
||||
<button key={v} onClick={() => setFilter(v)}
|
||||
className={`px-3 py-1.5 ${filter === v ? "bg-violet-600 text-white" : "bg-white text-gray-500 hover:bg-gray-50"}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* IP / Actor Search */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索操作人 ID / IP / 原因…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs focus:outline-none focus:border-violet-400 w-52 shadow-sm"
|
||||
/>
|
||||
|
||||
{/* Type buttons */}
|
||||
<div className="flex overflow-hidden rounded-lg border border-gray-200 text-xs">
|
||||
{([["", "全部"], ["blocked", "硬拦截"], ["suspect", "灰区"]] as const).map(([v, label]) => (
|
||||
<button key={v} onClick={() => { setFilter(v); setPage(1); }}
|
||||
className={`px-3 py-1.5 ${filter === v ? "bg-violet-600 text-white" : "bg-white text-gray-500 hover:bg-gray-50"}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-gray-400">暂无护栏命中 —— 没有可疑输入</div>
|
||||
<div className="py-8 text-center text-xs text-gray-400">暂无护栏命中 —— 没有匹配的可疑输入</div>
|
||||
) : (
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">时间</th>
|
||||
<th className="py-2 pr-3 font-medium">类型</th>
|
||||
<th className="py-2 pr-3 font-medium">原因 / 命中信号</th>
|
||||
<th className="py-2 pr-3 font-medium">路径</th>
|
||||
<th className="py-2 font-medium">来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((e) => (
|
||||
<tr key={e.id} className="border-b border-gray-50 last:border-0">
|
||||
<td className="py-2 pr-3 text-xs text-gray-500">{new Date(e.at).toLocaleString("zh-CN")}</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_BADGE[e.kind] ?? "bg-gray-100 text-gray-500"}`}>{KIND_LABEL[e.kind] ?? e.kind}</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
{e.kind === "blocked" ? (
|
||||
<span className="text-xs text-gray-700">{e.reason || "—"}</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{signalLabels(e.signals).map((sig, i) => (
|
||||
<span key={i} className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700">{sig}</span>
|
||||
))}
|
||||
{signalLabels(e.signals).length === 0 && <span className="text-xs text-gray-400">—</span>}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 font-mono text-[11px] text-gray-500">{e.method} {e.path}</td>
|
||||
<td className="py-2 text-[11px] text-gray-400">{e.actor || e.ip || "匿名"}</td>
|
||||
<div className="space-y-4">
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">时间</th>
|
||||
<th className="py-2 pr-3 font-medium">类型</th>
|
||||
<th className="py-2 pr-3 font-medium">原因 / 命中信号</th>
|
||||
<th className="py-2 pr-3 font-medium">路径</th>
|
||||
<th className="py-2 font-medium">来源 (Actor/IP)</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((e) => (
|
||||
<tr key={e.id} className="border-b border-gray-50 last:border-0 hover:bg-gray-50/40 text-xs">
|
||||
<td className="py-2.5 pr-3 text-gray-500">{new Date(e.at).toLocaleString("zh-CN")}</td>
|
||||
<td className="py-2.5 pr-3">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-bold ${KIND_BADGE[e.kind] ?? "bg-gray-100 text-gray-500"}`}>
|
||||
{KIND_LABEL[e.kind] ?? e.kind}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 max-w-[20rem] truncate" title={e.reason}>
|
||||
{e.kind === "blocked" ? (
|
||||
<span className="text-gray-700">{e.reason || "—"}</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{signalLabels(e.signals).map((sig, i) => (
|
||||
<span key={i} className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700">{sig}</span>
|
||||
))}
|
||||
{signalLabels(e.signals).length === 0 && <span className="text-gray-400">—</span>}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 font-mono text-[11px] text-gray-500">{e.method} {e.path}</td>
|
||||
<td className="py-2.5 text-[11px] text-gray-400" title={`Actor: ${e.actor}, IP: ${e.ip}`}>
|
||||
{e.actor ? `${e.actor.slice(0, 8)}…` : ""} {e.ip ? `(${e.ip})` : "匿名"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 pt-4 mt-2">
|
||||
<span className="text-xs text-gray-400">
|
||||
当前第 <span className="font-semibold text-gray-700">{page}</span> 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={page === 1}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasMore}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -171,3 +250,4 @@ function Stat({ label, value, sub, tone }: { label: string; value: string; sub?:
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getWechatMP, saveWechatMP, GATEWAY, type WechatMPConfig } from "../api";
|
||||
|
||||
// 运维 · 登录设置:微信公众号扫码登录(带参二维码 + 关注/扫码事件)。
|
||||
// AppSecret 明文回显(单管理员后台,方便核对/复制;密文仍加密入库)。
|
||||
export function LoginConfigPage() {
|
||||
const [cfg, setCfg] = useState<WechatMPConfig | null>(null);
|
||||
const [appid, setAppid] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [secret, setSecret] = useState(""); // 留空=沿用已存
|
||||
const [welcome, setWelcome] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [ok, setOk] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getWechatMP()
|
||||
.then((c) => {
|
||||
setCfg(c);
|
||||
setAppid(c.appid);
|
||||
setToken(c.token);
|
||||
setSecret(c.app_secret);
|
||||
setWelcome(c.welcome);
|
||||
})
|
||||
.catch((e) => setErr((e as Error).message));
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
setOk(false);
|
||||
try {
|
||||
const r = await saveWechatMP({ appid: appid.trim(), app_secret: secret, token: token.trim(), welcome: welcome.trim() });
|
||||
setCfg((c) => (c ? { ...c, appid: appid.trim(), token: token.trim(), app_secret: secret, welcome: welcome.trim(), enabled: r.enabled } : c));
|
||||
setOk(true);
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 回调 URL 用当前后端地址推断(生产 GATEWAY 为空串=同源,显示成本域名)
|
||||
const origin = GATEWAY || (typeof window !== "undefined" ? window.location.origin : "");
|
||||
const callbackURL = origin.replace(/:\d+$/, "") + "/wx/mp/callback";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="border-b border-gray-200 pb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800">登录设置 · 微信扫码</h3>
|
||||
<p className="text-xs text-gray-400">公众号扫码登录(登录即引导关注)。AppSecret 加密入库、后台明文可见,随时可换</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold text-gray-700">公众号配置</h4>
|
||||
{cfg?.enabled ? (
|
||||
<span className="rounded bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-600">已启用</span>
|
||||
) : (
|
||||
<span className="rounded bg-gray-100 px-2 py-0.5 text-[10px] text-gray-500">未启用(三项齐全才生效)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label className="text-xs text-gray-500">
|
||||
AppID
|
||||
<input value={appid} onChange={(e) => setAppid(e.target.value)} placeholder="wx..."
|
||||
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||
</label>
|
||||
<label className="text-xs text-gray-500">
|
||||
AppSecret
|
||||
<input value={secret} onChange={(e) => setSecret(e.target.value)}
|
||||
placeholder="公众号开发密钥"
|
||||
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||
</label>
|
||||
<label className="text-xs text-gray-500 md:col-span-2">
|
||||
Token(消息推送令牌,须与公众平台「服务器配置」里填的完全一致)
|
||||
<input value={token} onChange={(e) => setToken(e.target.value)} placeholder="自定义一串字母数字"
|
||||
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||
</label>
|
||||
<label className="text-xs text-gray-500 md:col-span-2">
|
||||
关注欢迎语(用户关注后自动回复。留空则用默认,建议写上下载链接)
|
||||
<textarea value={welcome} onChange={(e) => setWelcome(e.target.value)} rows={3}
|
||||
placeholder={"欢迎关注!\n下载客户端登录即可开始体验。\n下载地址:https://…"}
|
||||
className="mt-1 block w-full resize-y rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
|
||||
{ok && <p className="mt-2 text-xs text-emerald-600">已保存</p>}
|
||||
|
||||
<div className="mt-3">
|
||||
<button onClick={() => void save()} disabled={busy}
|
||||
className="rounded-lg bg-violet-600 px-3.5 py-1.5 text-sm text-white hover:bg-violet-700 disabled:opacity-40">
|
||||
{busy ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 配置清单:把「必须在公众平台做什么」写在眼前,省得来回翻文档 */}
|
||||
<div className="rounded-xl border border-amber-100 bg-amber-50/40 p-5 text-xs leading-relaxed text-gray-600">
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">还需在微信公众平台「服务器配置」里配置</h4>
|
||||
<ol className="list-decimal space-y-1.5 pl-4">
|
||||
<li>服务器地址(URL) 填 <code className="break-all rounded bg-white px-1">{callbackURL}</code></li>
|
||||
<li>令牌(Token) 填与上面<b>完全一致</b>的那串</li>
|
||||
<li>消息加解密方式选 <b>明文模式</b>(本服务按明文处理,选其它会验签失败)</li>
|
||||
<li>「IP 白名单」加上服务器出网的公网 IP(否则拉取 access_token 报 40164,二维码建不出来)</li>
|
||||
<li>保存服务器配置时微信会即时回调本服务验证——需先部署好本页配置再去点提交</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ModelsPage } from "./ModelsPage";
|
||||
import { BillingRules } from "../components/BillingRules";
|
||||
|
||||
// 模型配置:模型登记/激活/连通性测试 + 它的计费规则(token→积分汇率、硬拦截、按模型定价与权重)。
|
||||
// 配了模型就顺手定它的价,两块放一起。
|
||||
// 支付相关(渠道配置 / 订单对账)已拆到「运维 → 支付」下的两个子页,不在这里。
|
||||
// 提示词有版本历史 + diff + 激活的完整工作流,保留独立页。
|
||||
export function ModelConfigPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-gray-200 pb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800">模型配置</h3>
|
||||
<p className="text-xs text-gray-400">对话 / 向量模型登记与激活 · 计费规则与按模型定价</p>
|
||||
</div>
|
||||
|
||||
<ModelsPage />
|
||||
<BillingRules />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,69 @@
|
||||
import { useState } from "react";
|
||||
import { ModelManager } from "../components/ModelManager";
|
||||
import type { Kind } from "../api";
|
||||
|
||||
// 模型配置页:工作主力(chat) / JARVIS 语音(voice) / 向量化(embedding) 三 Tab 统一管理。
|
||||
// 工作与语音分开:编排/报告用强模型(chat),JARVIS 语音对话用快模型(voice, 低时延)——各配各激活。
|
||||
const TABS: { key: Kind; label: string }[] = [
|
||||
{ key: "chat", label: "工作主力模型" },
|
||||
{ key: "voice", label: "JARVIS 语音模型" },
|
||||
{ key: "embedding", label: "向量化模型" },
|
||||
];
|
||||
|
||||
// 对话模型(chat)配置页 → Dispatcher 经 NATS 热更新。
|
||||
export function ModelsPage() {
|
||||
const [tab, setTab] = useState<Kind>("chat");
|
||||
|
||||
return (
|
||||
<ModelManager
|
||||
kind="chat"
|
||||
title="对话模型(chat → Dispatcher)"
|
||||
baseUrlHint="https://api.deepseek.com"
|
||||
modelHint="deepseek-chat"
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
{/* 头部标题与 Tab 切换 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-gray-150 pb-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-800">模型管理</h3>
|
||||
<p className="text-xs text-gray-400">工作主力模型跑编排/报告(要强)· JARVIS 语音模型跑实时对话(要快)· 向量化模型跑 RAG</p>
|
||||
</div>
|
||||
|
||||
<div className="flex rounded-lg border border-gray-200 bg-gray-50/50 p-1">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
|
||||
tab === t.key ? "bg-white text-violet-700 shadow-sm" : "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型管理组件 */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
{tab === "chat" && (
|
||||
<ModelManager
|
||||
kind="chat"
|
||||
title="工作主力模型 (chat → Dispatcher 编排/报告)"
|
||||
baseUrlHint="https://api.deepseek.com"
|
||||
modelHint="deepseek-v4-pro"
|
||||
/>
|
||||
)}
|
||||
{tab === "voice" && (
|
||||
<ModelManager
|
||||
kind="voice"
|
||||
title="JARVIS 语音模型 (voice → 语音对话,选快模型抢首字时延)"
|
||||
baseUrlHint="https://api.deepseek.com"
|
||||
modelHint="deepseek-v4-flash"
|
||||
/>
|
||||
)}
|
||||
{tab === "embedding" && (
|
||||
<ModelManager
|
||||
kind="embedding"
|
||||
title="向量化模型 (embedding → mcp-go RAG)"
|
||||
baseUrlHint="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
modelHint="text-embedding-v3"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { TopupChannels } from "../components/TopupChannels";
|
||||
|
||||
// 支付 · 配置:充值渠道(积分包定价、兑换码生成/台账)+ 微信支付参数。
|
||||
// 与「支付 · 订单与对账」拆开:这里只管「怎么收钱」,那边看「收到了什么」。
|
||||
export function PaymentConfigPage() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="border-b border-gray-200 pb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800">支付 · 配置</h3>
|
||||
<p className="text-xs text-gray-400">积分包定价与上下架 · 兑换码生成与台账 · 微信支付商户参数</p>
|
||||
</div>
|
||||
<TopupChannels />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { OrderStream } from "../components/OrderStream";
|
||||
|
||||
// 支付 · 订单与对账:全平台充值订单流 + 状态计数 + 一键对账 + 人工退款。
|
||||
// 配置面在「支付 · 配置」,这里只做流水观测与处置。
|
||||
export function PaymentOrdersPage() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="border-b border-gray-200 pb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800">支付 · 订单与对账</h3>
|
||||
<p className="text-xs text-gray-400">充值订单流水 · 账本一致性对账 · 人工退款</p>
|
||||
</div>
|
||||
<OrderStream />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { adminSpaces, type AdminSpace } from "../api";
|
||||
|
||||
// 全平台空间(Space)观测:跨租户看所有空间(类型/租户/建者/成员数/归档态)。
|
||||
// Space 是租户与成员之间的中间容器(Tenant > Space > 成员),数据来自 sundynix_space。
|
||||
|
||||
const KIND: Record<string, { label: string; badge: string }> = {
|
||||
personal: { label: "个人", badge: "bg-gray-100 text-gray-500" },
|
||||
project: { label: "项目", badge: "bg-cyan-50 text-cyan-600" },
|
||||
tenant: { label: "全员", badge: "bg-violet-50 text-violet-600" },
|
||||
};
|
||||
const kind = (k: string) => KIND[k] ?? { label: k, badge: "bg-gray-100 text-gray-500" };
|
||||
|
||||
export function SpacesPage() {
|
||||
const [spaces, setSpaces] = useState<AdminSpace[]>([]);
|
||||
const [err, setErr] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hideArchived, setHideArchived] = useState(true);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
adminSpaces(300)
|
||||
.then((s) => {
|
||||
setSpaces(s);
|
||||
setErr("");
|
||||
})
|
||||
.catch((e) => setErr((e as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
useEffect(load, [load]);
|
||||
|
||||
const shown = useMemo(() => (hideArchived ? spaces.filter((s) => !s.archived) : spaces), [spaces, hideArchived]);
|
||||
const stats = useMemo(() => {
|
||||
const byKind: Record<string, number> = {};
|
||||
let archived = 0;
|
||||
for (const s of spaces) {
|
||||
byKind[s.kind] = (byKind[s.kind] ?? 0) + 1;
|
||||
if (s.archived) archived++;
|
||||
}
|
||||
return { total: spaces.length, byKind, archived };
|
||||
}, [spaces]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<h3 className="text-sm font-semibold text-gray-700">全平台空间(Space)</h3>
|
||||
<span className="text-[11px] text-gray-400">租户与成员之间的中间容器:Agent / 知识库按空间共享</span>
|
||||
</div>
|
||||
|
||||
{/* 计数卡片 */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<Stat label="空间总数" value={stats.total} tone="text-gray-800" />
|
||||
<Stat label="项目空间" value={stats.byKind.project ?? 0} tone="text-cyan-600" />
|
||||
<Stat label="全员空间" value={stats.byKind.tenant ?? 0} tone="text-violet-600" />
|
||||
<Stat label="已归档" value={stats.archived} tone="text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">{loading ? "加载中…" : `共 ${shown.length} 个空间`}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<input type="checkbox" checked={hideArchived} onChange={(e) => setHideArchived(e.target.checked)} />
|
||||
隐藏已归档
|
||||
</label>
|
||||
<button onClick={load} className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50">
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{err && <p className="mb-2 text-xs text-rose-500">{err}</p>}
|
||||
|
||||
<div className="max-h-[32rem] overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">空间</th>
|
||||
<th className="py-2 pr-3 font-medium">类型</th>
|
||||
<th className="py-2 pr-3 font-medium">租户</th>
|
||||
<th className="py-2 pr-3 font-medium">建者</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">成员</th>
|
||||
<th className="py-2 pr-3 font-medium">创建时间</th>
|
||||
<th className="py-2 font-medium">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((s) => (
|
||||
<tr key={s.id} className="border-b border-gray-50 last:border-0">
|
||||
<td className="py-2 pr-3 text-gray-800">{s.name}</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${kind(s.kind).badge}`}>{kind(s.kind).label}</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-gray-600">{s.tenant_name || <span className="text-gray-300">{s.tenant_id || "—"}</span>}</td>
|
||||
<td className="py-2 pr-3 text-xs text-gray-500">{s.creator_email || s.creator || "—"}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums text-gray-700">{s.members}</td>
|
||||
<td className="py-2 pr-3 text-xs text-gray-500 whitespace-nowrap">{new Date(s.created_at).toLocaleDateString("zh-CN")}</td>
|
||||
<td className="py-2 text-xs">
|
||||
{s.archived ? <span className="text-gray-400">已归档</span> : <span className="text-emerald-600">活跃</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{shown.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-xs text-gray-400">暂无空间</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: number; tone: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||
<div className="text-xs text-gray-400">{label}</div>
|
||||
<div className={`mt-1 text-2xl font-semibold tabular-nums ${tone}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,24 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
|
||||
import { getStatus, type StatusItem, type SystemStatus, type ToolInfo } from "../api";
|
||||
|
||||
const REFRESH_SEC = 5;
|
||||
|
||||
const SERVICE_META: Record<string, { role: string; icon: IconName }> = {
|
||||
gateway: { role: "HTTP 接入层 · 鉴权 / 限流 / SSE", icon: "gateway" },
|
||||
dispatcher: { role: "编排执行 · Eino 图引擎", icon: "cpu" },
|
||||
"mcp-go": { role: "Go I/O 工具 · RAG / 记忆 / 报告", icon: "tool" },
|
||||
"mcp-py": { role: "Python 算法工具 · 沙箱 / 解析", icon: "box" },
|
||||
const SERVICE_META: Record<string, { role: string; icon: IconName; colorClass: string }> = {
|
||||
gateway: { role: "HTTP 接入层 · 鉴权 / 限流 / SSE", icon: "gateway", colorClass: "bg-violet-50 text-violet-600" },
|
||||
dispatcher: { role: "编排执行 · Eino 图引擎", icon: "cpu", colorClass: "bg-amber-50 text-amber-600" },
|
||||
"mcp-go": { role: "Go I/O 工具 · RAG / 记忆 / 报告", icon: "tool", colorClass: "bg-cyan-50 text-cyan-600" },
|
||||
"mcp-py": { role: "Python 算法工具 · 沙箱 / 解析", icon: "box", colorClass: "bg-rose-50 text-rose-600" },
|
||||
};
|
||||
|
||||
const INFRA_META: Record<string, { role: string; port: string; icon: IconName }> = {
|
||||
postgres: { role: "关系库", port: "5432", icon: "db" },
|
||||
redis: { role: "缓存 / 限流", port: "6379", icon: "db" },
|
||||
nats: { role: "消息总线 · JetStream", port: "4222", icon: "bus" },
|
||||
milvus: { role: "向量库", port: "19530", icon: "db" },
|
||||
neo4j: { role: "图数据库", port: "7687", icon: "bus" },
|
||||
postgres: { role: "关系主库", port: "5432", icon: "db" },
|
||||
redis: { role: "限流 / 缓存", port: "6379", icon: "db" },
|
||||
nats: { role: "JetStream 总线", port: "4222", icon: "bus" },
|
||||
milvus: { role: "向量检索库", port: "19530", icon: "db" },
|
||||
neo4j: { role: "图关系库", port: "7687", icon: "bus" },
|
||||
minio: { role: "对象存储 (OSS)", port: "9000", icon: "db" },
|
||||
// 全文索引不是独立服务,是 mcp-go 进程内的本地 bleve —— 没有端口,落在容器卷上。
|
||||
全文索引: { role: "全文检索 (bleve)", port: "本地卷", icon: "db" },
|
||||
};
|
||||
|
||||
function toolCategory(t: string): string {
|
||||
@@ -26,23 +30,22 @@ function toolCategory(t: string): string {
|
||||
if (["run_code", "secure_sandbox", "parse_document"].includes(t)) return "算法 / 沙箱";
|
||||
return "系统";
|
||||
}
|
||||
const CAT_ORDER = ["知识库 / 检索", "记忆", "报告", "会话历史", "外部接入", "算法 / 沙箱", "系统"];
|
||||
// 能力域配色(工具小卡片的圆点 + 角标)。
|
||||
const CAT_STYLE: Record<string, { dot: string; chip: string }> = {
|
||||
"知识库 / 检索": { dot: "bg-violet-500", chip: "bg-violet-50 text-violet-600" },
|
||||
记忆: { dot: "bg-cyan-500", chip: "bg-cyan-50 text-cyan-600" },
|
||||
报告: { dot: "bg-amber-500", chip: "bg-amber-50 text-amber-600" },
|
||||
会话历史: { dot: "bg-emerald-500", chip: "bg-emerald-50 text-emerald-600" },
|
||||
外部接入: { dot: "bg-rose-500", chip: "bg-rose-50 text-rose-600" },
|
||||
"算法 / 沙箱": { dot: "bg-indigo-500", chip: "bg-indigo-50 text-indigo-600" },
|
||||
系统: { dot: "bg-gray-400", chip: "bg-gray-100 text-gray-500" },
|
||||
};
|
||||
const latTone = (ms?: number) => (ms == null ? "text-gray-400" : ms < 50 ? "text-emerald-600" : ms < 200 ? "text-amber-600" : "text-rose-500");
|
||||
|
||||
// 数据流动光点 + 节点辉光的关键帧(注入一次)。
|
||||
const CAT_ORDER = ["知识库 / 检索", "记忆", "报告", "会话历史", "外部接入", "算法 / 沙箱", "系统"];
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
"知识库 / 检索": "border-violet-200 hover:border-violet-400 text-violet-700 bg-violet-50/30 hover:bg-violet-50/50",
|
||||
"记忆": "border-cyan-200 hover:border-cyan-400 text-cyan-700 bg-cyan-50/30 hover:bg-cyan-50/50",
|
||||
"报告": "border-amber-200 hover:border-amber-400 text-amber-700 bg-amber-50/30 hover:bg-amber-50/50",
|
||||
"会话历史": "border-emerald-200 hover:border-emerald-400 text-emerald-700 bg-emerald-50/30 hover:bg-emerald-50/50",
|
||||
"外部接入": "border-rose-200 hover:border-rose-400 text-rose-700 bg-rose-50/30 hover:bg-rose-50/50",
|
||||
"算法 / 沙箱": "border-indigo-200 hover:border-indigo-400 text-indigo-700 bg-indigo-50/30 hover:bg-indigo-50/50",
|
||||
"系统": "border-blue-200 hover:border-blue-400 text-blue-700 bg-blue-50/30 hover:bg-blue-50/50",
|
||||
};
|
||||
|
||||
const FLOW_CSS = `
|
||||
@keyframes sdxFlow { 0%{left:-6%;opacity:0} 12%{opacity:1} 88%{opacity:1} 100%{left:106%;opacity:0} }
|
||||
@keyframes sdxGlow { 0%,100%{opacity:.55} 50%{opacity:1} }
|
||||
@keyframes sdxFlow { 0%{left:-8%;opacity:0} 15%{opacity:1} 85%{opacity:1} 100%{left:108%;opacity:0} }
|
||||
@keyframes sdxGlow { 0%,100%{transform: scale(1); opacity: 0.8;} 50%{transform: scale(1.15); opacity: 1;} }
|
||||
`;
|
||||
|
||||
export function StatusPage() {
|
||||
@@ -52,13 +55,20 @@ export function StatusPage() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [auto, setAuto] = useState(true);
|
||||
const [countdown, setCountdown] = useState(REFRESH_SEC);
|
||||
|
||||
// MCP 工具浏览器状态
|
||||
const [activeServer, setActiveServer] = useState<string>("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState("All");
|
||||
|
||||
const autoRef = useRef(auto);
|
||||
autoRef.current = auto;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await getStatus());
|
||||
const res = await getStatus();
|
||||
setData(res);
|
||||
setErr("");
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
@@ -87,11 +97,53 @@ export function StatusPage() {
|
||||
return () => window.clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// 当 data 加载后,默认选中第一个 server
|
||||
useEffect(() => {
|
||||
if (data?.tools?.length && !activeServer) {
|
||||
const active = data.tools.find((t) => t.up);
|
||||
if (active) {
|
||||
setActiveServer(active.server);
|
||||
} else {
|
||||
setActiveServer(data.tools[0].server);
|
||||
}
|
||||
}
|
||||
}, [data, activeServer]);
|
||||
|
||||
const svc = (n: string) => data?.services.find((s) => s.name === n);
|
||||
const infra = (n: string) => data?.infra.find((s) => s.name === n);
|
||||
|
||||
if (loading && !data) return <div className="text-sm text-gray-400">加载中…</div>;
|
||||
if (!data) return <div className="text-sm text-rose-500">拉取失败:{err}</div>;
|
||||
// 获取当前选中的 server 及其下的工具
|
||||
const currentServerGroup = useMemo(() => {
|
||||
if (!data?.tools) return null;
|
||||
return data.tools.find((g) => g.server === activeServer) ?? data.tools[0] ?? null;
|
||||
}, [data, activeServer]);
|
||||
|
||||
const serverTools = useMemo(() => currentServerGroup?.tools ?? [], [currentServerGroup]);
|
||||
|
||||
// 当前选中的 server 下所有工具的分类以及数量计数
|
||||
const serverCategories = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const t of serverTools) {
|
||||
const cat = toolCategory(t.name);
|
||||
counts[cat] = (counts[cat] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [serverTools]);
|
||||
|
||||
const filteredTools = useMemo(() => {
|
||||
return serverTools.filter((t) => {
|
||||
const cat = toolCategory(t.name);
|
||||
if (selectedCategory !== "All" && cat !== selectedCategory) return false;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return t.name.toLowerCase().includes(q) || t.cn.toLowerCase().includes(q) || t.desc.toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [serverTools, selectedCategory, searchQuery]);
|
||||
|
||||
if (loading && !data) return <div className="text-sm text-gray-400 p-6">加载中…</div>;
|
||||
if (!data) return <div className="text-sm text-rose-500 p-6">拉取失败:{err}</div>;
|
||||
|
||||
const servicesUp = data.services.filter((s) => s.up).length;
|
||||
const infraUp = data.infra.filter((s) => s.up).length;
|
||||
@@ -102,60 +154,92 @@ export function StatusPage() {
|
||||
const avgLat = lats.length ? Math.round(lats.reduce((a, b) => a + b, 0) / lats.length) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 概览:状态 + 数字层次 + 控制(中性底,绿色只作状态点) */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<span className={`relative flex h-2.5 w-2.5 ${allUp ? "" : ""}`}>
|
||||
<span className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 ${allUp ? "bg-emerald-400" : "bg-amber-400"}`} />
|
||||
<span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${allUp ? "bg-emerald-500" : "bg-amber-500"}`} />
|
||||
</span>
|
||||
<div className="mr-auto">
|
||||
<div className="text-lg font-semibold tracking-tight text-gray-900">{allUp ? "系统运行正常" : `${downCount} 项异常`}</div>
|
||||
<div className="text-xs text-gray-400">{allUp ? "所有应用服务与基建均已就绪" : "部分组件未就绪,见下方明细"}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
onClick={() => setAuto((a) => !a)}
|
||||
className={`rounded-full px-3 py-1.5 font-medium transition ${auto ? "bg-emerald-50 text-emerald-600" : "bg-gray-100 text-gray-400"}`}
|
||||
>
|
||||
{auto ? `自动刷新 · ${countdown}s` : "已暂停"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
className="flex items-center gap-1.5 rounded-full bg-gray-900 px-3.5 py-1.5 font-medium text-white transition hover:bg-gray-700"
|
||||
>
|
||||
<Icon name="refresh" className={`h-3.5 w-3.5 ${busy ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<style>{FLOW_CSS}</style>
|
||||
|
||||
{/* 头部控制栏 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-gray-150 pb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-gray-800 flex items-center gap-2.5">
|
||||
服务监控控制台
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold ${
|
||||
allUp ? "bg-emerald-50 text-emerald-700 border border-emerald-200" : "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
}`}>
|
||||
<span className={`h-2 w-2 rounded-full ${allUp ? "bg-emerald-500" : "bg-amber-500"} breath-dot`} style={{ animation: "subtle-breath 2.5s ease-in-out infinite" }} />
|
||||
{allUp ? "运行正常" : `${downCount} 项异常`}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-gray-400 mt-1">系统全量核心微服务探针与基础架构数据库实时监控</p>
|
||||
</div>
|
||||
|
||||
{/* 数字层次:大号数字 + 小标签,竖线分隔,不再用边框盒子 */}
|
||||
<div className="mt-6 grid grid-cols-2 divide-x divide-gray-100 sm:grid-cols-5">
|
||||
<BigStat value={`${servicesUp}/${data.services.length}`} label="应用服务" bad={servicesUp !== data.services.length} />
|
||||
<BigStat value={`${infraUp}/${data.infra.length}`} label="基建环境" bad={infraUp !== data.infra.length} />
|
||||
<BigStat value={toolCount} label="注册工具" />
|
||||
<BigStat value={avgLat != null ? `${avgLat}ms` : "—"} label="平均探针延迟" />
|
||||
<BigStat value={new Date(data.checked_at).toLocaleTimeString("zh-CN", { hour12: false })} label="最后检查" muted />
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setAuto((a) => !a)}
|
||||
className={`flex items-center gap-2 text-xs px-3.5 py-2 rounded-xl border border-gray-200 bg-white shadow-sm transition ${
|
||||
auto ? "text-emerald-700 border-emerald-200 bg-emerald-50/10 font-bold" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${auto ? "bg-emerald-500 animate-ping" : "bg-gray-400"}`} />
|
||||
{auto ? `自动刷新 · ${countdown}s` : "已暂停自动刷新"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-violet-600 hover:bg-violet-700 text-xs font-semibold text-white transition shadow-sm hover:shadow-violet-200 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="refresh" className={`h-3.5 w-3.5 ${busy ? "animate-spin" : ""}`} />
|
||||
立即刷新
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 请求链路:实时数据管道(深色科技底 + 节点辉光 + 流动光点) */}
|
||||
<section
|
||||
className="relative overflow-hidden rounded-2xl border border-slate-800 bg-slate-950 p-6"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 1px 1px, rgba(148,163,184,0.10) 1px, transparent 0), radial-gradient(60% 120% at 50% 0%, rgba(124,58,237,0.18), transparent 70%)",
|
||||
backgroundSize: "22px 22px, 100% 100%",
|
||||
}}
|
||||
>
|
||||
<style>{FLOW_CSS}</style>
|
||||
<div className="flex items-baseline gap-2.5">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-slate-100">请求链路</h3>
|
||||
<span className="text-xs text-slate-500">桌面端 / 管理端 → 网关 → 总线 → 调度 → 工具层 · 实时</span>
|
||||
{/* KPI Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<BigStatCard
|
||||
label="应用服务"
|
||||
value={`${servicesUp}/${data.services.length}`}
|
||||
subText={servicesUp === data.services.length ? "100% 存活在线" : `${data.services.length - servicesUp} 个节点离线`}
|
||||
barPercent={(servicesUp / data.services.length) * 100}
|
||||
barColor={servicesUp === data.services.length ? "bg-emerald-500" : "bg-amber-500"}
|
||||
/>
|
||||
<BigStatCard
|
||||
label="基建依赖"
|
||||
value={`${infraUp}/${data.infra.length}`}
|
||||
subText={infraUp === data.infra.length ? "关系/缓存/总线均就绪" : `${data.infra.length - infraUp} 个组件受损`}
|
||||
barPercent={(infraUp / data.infra.length) * 100}
|
||||
barColor={infraUp === data.infra.length ? "bg-emerald-500" : "bg-amber-500"}
|
||||
/>
|
||||
<BigStatCard
|
||||
label="MCP注册工具"
|
||||
value={`${toolCount} 个`}
|
||||
subText="大模型挂载能力集合"
|
||||
/>
|
||||
<BigStatCard
|
||||
label="平均探针延迟"
|
||||
value={avgLat != null ? `${avgLat} ms` : "—"}
|
||||
subText={avgLat != null && avgLat < 20 ? "通信链路极其通畅" : "存在部分网络阻塞"}
|
||||
valueColor="text-cyan-600"
|
||||
/>
|
||||
<BigStatCard
|
||||
label="最后检查"
|
||||
value={new Date(data.checked_at).toLocaleTimeString("zh-CN", { hour12: false })}
|
||||
subText="轮询频率每 5 秒"
|
||||
valueColor="text-gray-600 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Topology Pipeline (请求链路) */}
|
||||
<section className="rounded-2xl border border-gray-200 bg-gradient-to-br from-white to-slate-50/50 p-6">
|
||||
<div className="border-b border-slate-100 pb-3 mb-6">
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 01.553-.894L9 2l6 3 5.447-2.724A1 1 0 0121 3.118v10.764a1 1 0 01-.553.894L15 18l-6 2z" />
|
||||
</svg>
|
||||
实时网络通信链路拓扑
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center overflow-x-auto pb-1">
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4 md:gap-2 px-2">
|
||||
<FlowNode icon="monitor" label="客户端" sub="桌面端 / 管理端" up />
|
||||
<Link up={svc("gateway")?.up} />
|
||||
<FlowNode icon="gateway" label="网关" sub=":8080" up={svc("gateway")?.up} latency={svc("gateway")?.latency_ms} />
|
||||
@@ -170,70 +254,307 @@ export function StatusPage() {
|
||||
sub="mcp-go · mcp-py"
|
||||
up={Boolean(svc("mcp-go")?.up && svc("mcp-py")?.up)}
|
||||
partial={Boolean((svc("mcp-go")?.up || svc("mcp-py")?.up) && !(svc("mcp-go")?.up && svc("mcp-py")?.up))}
|
||||
latency={Math.max(svc("mcp-go")?.latency_ms ?? 0, svc("mcp-py")?.latency_ms ?? 0) || undefined}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 应用服务 ‖ 基建环境 */}
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-5">
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-6 lg:col-span-3">
|
||||
<SectionHead title="应用服务" hint="进程存活 · 探针耗时" />
|
||||
<div className="mt-4 grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-gray-100 bg-gray-100 sm:grid-cols-2">
|
||||
{data.services.map((s) => (
|
||||
<ServiceRow key={s.name} item={s} />
|
||||
))}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
||||
{/* 应用服务 (左) */}
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5 lg:col-span-3">
|
||||
<div className="border-b border-slate-100 pb-3.5 mb-4">
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-violet-600"></span>
|
||||
微应用服务组件
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">业务核心接入层与后端逻辑</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{data.services.map((s) => {
|
||||
const meta = SERVICE_META[s.name];
|
||||
return (
|
||||
<div key={s.name} className="border border-slate-200 bg-slate-50/30 rounded-2xl p-4 flex flex-col justify-between hover:border-violet-300 transition duration-300">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<span className="text-xs font-bold text-slate-800 block">{s.name}</span>
|
||||
<span className="text-[10px] text-slate-400 mt-1 block leading-relaxed">{meta?.role || s.detail}</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full border text-[9px] font-bold ${
|
||||
s.up ? "bg-emerald-50 text-emerald-700 border-emerald-100" : "bg-rose-50 text-rose-700 border-rose-100"
|
||||
}`}>
|
||||
{s.up ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-slate-100 flex items-center justify-between">
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-semibold">
|
||||
{s.name === "mcp-go" || s.name === "mcp-py" ? "已注册工具数" : "网络延迟"}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-slate-700 font-mono">
|
||||
{/* 取实际上报的注册工具数。曾写死 23/4,加减工具后这里一直骗人,
|
||||
而且服务离线时也照样显示,看不出工具其实一个都没注册上。 */}
|
||||
{s.name === "mcp-go" || s.name === "mcp-py"
|
||||
? `${data.tools.find((g) => g.server === s.name)?.tools?.length ?? 0} 个`
|
||||
: `${s.latency_ms ?? 0} ms`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-6 lg:col-span-2">
|
||||
<SectionHead title="基建环境" hint="5 个依赖" />
|
||||
<div className="mt-4 divide-y divide-gray-100">
|
||||
{data.infra.map((s) => (
|
||||
<InfraRow key={s.name} item={s} />
|
||||
))}
|
||||
{/* 基建依赖 (右) */}
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5 lg:col-span-2">
|
||||
<div className="border-b border-slate-100 pb-3.5 mb-4">
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-cyan-500"></span>
|
||||
平台基础架构依赖
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">数据库、中间件及第三方存储服务</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3.5">
|
||||
{data.infra.map((item) => {
|
||||
const meta = INFRA_META[item.name];
|
||||
return (
|
||||
<div key={item.name} className="flex items-center justify-between text-xs pb-3 border-b border-slate-100 last:border-0 last:pb-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="font-bold text-slate-800">{item.name}</span>
|
||||
<span className="text-[10px] text-slate-400">{meta?.role || "后端组件"}</span>
|
||||
{/* detail 是降级原因/后果的唯一载体(如"内存兜底·重启即清零"),必须露出来 */}
|
||||
{item.detail && (
|
||||
<span className={`truncate text-[10px] ${item.up ? "text-slate-400" : "text-amber-600"}`} title={item.detail}>
|
||||
· {item.detail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{item.up && item.latency_ms != null && item.latency_ms > 0 && (
|
||||
<span className="font-mono text-[10px] text-slate-400 tabular-nums">{item.latency_ms}ms</span>
|
||||
)}
|
||||
<code className="text-[10px] text-slate-400 font-mono bg-slate-100 px-1.5 py-0.5 rounded">
|
||||
:{meta?.port || "—"}
|
||||
</code>
|
||||
<span className={`font-bold ${item.up ? "text-emerald-600" : "text-rose-500"}`}>
|
||||
{item.up ? "就绪" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* MCP 工具注册 */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-6">
|
||||
<SectionHead title="MCP 工具注册" hint="各 MCP 服务在线时上报、按能力域分组" />
|
||||
<div className="mt-4 space-y-5">
|
||||
{data.tools.map((g) => (
|
||||
<ToolServer key={g.server} server={g.server} up={g.up} tools={g.tools ?? []} />
|
||||
{/* NATS 集群 · JetStream Raft 副本健康 */}
|
||||
{data.nats && (
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5">
|
||||
<div className="border-b border-slate-100 pb-3.5 mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-indigo-500"></span>
|
||||
NATS 消息骨干 · 集群
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">JetStream Raft 副本健康——计费/状态/评测等持久流不丢的保证</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-5 text-xs">
|
||||
{[
|
||||
{ k: "节点", v: String(data.nats.known_servers) },
|
||||
{ k: "RTT", v: `${data.nats.rtt_ms}ms` },
|
||||
{ k: "连接", v: data.nats.connected_to || "—" },
|
||||
].map((m) => (
|
||||
<div key={m.k} className="text-right">
|
||||
<div className="text-[9px] uppercase text-slate-400 tracking-wider font-semibold">{m.k}</div>
|
||||
<div className="font-bold text-slate-700 font-mono truncate max-w-[140px]" title={m.v}>{m.v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-[10px] uppercase tracking-wide text-slate-400">
|
||||
<th className="pb-2 font-medium">持久流</th>
|
||||
<th className="pb-2 font-medium">Leader</th>
|
||||
<th className="pb-2 font-medium text-right">副本健康</th>
|
||||
<th className="pb-2 font-medium text-right">消息数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data.nats.streams ?? []).map((s) => {
|
||||
const ok = s.replicas_healthy >= s.replicas_total;
|
||||
return (
|
||||
<tr key={s.name} className="border-t border-slate-50">
|
||||
<td className="py-2 font-mono text-slate-700">{s.name}</td>
|
||||
<td className="py-2 font-mono text-[10px] text-slate-500 truncate max-w-[160px]" title={s.leader}>{s.leader || "—"}</td>
|
||||
<td className="py-2 text-right">
|
||||
<span className={`font-bold font-mono ${ok ? "text-emerald-600" : "text-amber-600"}`}>
|
||||
{s.replicas_healthy}/{s.replicas_total}
|
||||
</span>
|
||||
{!ok && <span className="ml-1 text-[9px] text-amber-600">降级</span>}
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono text-slate-500 tabular-nums">{s.messages.toLocaleString()}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{(data.nats.streams ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-4 text-center text-[10px] text-slate-400">暂无流信息</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* MCP 工具注册箱 */}
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-6 space-y-4">
|
||||
<div className="border-b border-slate-100 pb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<svg className="h-4 w-4 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
MCP 资源注册工具箱
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">查看上报给大模型分发器的运行时 MCP 能力函数</p>
|
||||
</div>
|
||||
|
||||
{/* Tab & Search */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Tab Buttons */}
|
||||
<div className="flex rounded-xl border border-slate-200 p-0.5 bg-slate-100/50 text-xs">
|
||||
{data.tools.map((g) => (
|
||||
<button
|
||||
key={g.server}
|
||||
onClick={() => {
|
||||
setActiveServer(g.server);
|
||||
setSelectedCategory("All");
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg font-bold transition-all duration-300 ${
|
||||
activeServer === g.server
|
||||
? "text-violet-700 bg-white shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{g.server} ({g.tools?.length ?? 0})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索函数别名 / 物理名称 / 作用..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-violet-200 focus:border-violet-400 w-56 shadow-sm transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag Pills Filter */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-slate-400 border-b border-slate-100 pb-3.5">
|
||||
<span className="mr-2 font-bold text-slate-500 uppercase tracking-wider text-[10px]">能力标签筛选:</span>
|
||||
<button
|
||||
onClick={() => setSelectedCategory("All")}
|
||||
className={`px-3 py-1 rounded-full font-semibold transition ${
|
||||
selectedCategory === "All"
|
||||
? "bg-violet-600 text-white shadow-sm shadow-violet-100"
|
||||
: "bg-slate-100 hover:bg-slate-200 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{Object.keys(serverCategories).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setSelectedCategory(c)}
|
||||
className={`px-3 py-1 rounded-full font-semibold transition ${
|
||||
selectedCategory === c
|
||||
? "bg-violet-600 text-white shadow-sm shadow-violet-100"
|
||||
: "bg-slate-100 hover:bg-slate-200 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{c} ({serverCategories[c]})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tools Grid */}
|
||||
{filteredTools.length === 0 ? (
|
||||
<div className="py-12 text-center text-xs text-slate-400">没有找到任何匹配的函数工具 🔍</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredTools.map((t) => {
|
||||
const cat = toolCategory(t.name);
|
||||
const colorClass = CATEGORY_COLORS[cat] || CATEGORY_COLORS["系统"];
|
||||
return (
|
||||
<div key={t.name} className={`p-4 rounded-2xl flex flex-col justify-between transition-all duration-300 hover:-translate-y-0.5 border ${colorClass}`}>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current"></span>
|
||||
{t.cn}
|
||||
</span>
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded bg-white border border-slate-200 uppercase font-mono">
|
||||
{cat}
|
||||
</span>
|
||||
</div>
|
||||
<code className="text-[10px] text-slate-400 font-mono block">{t.name}</code>
|
||||
<p className="text-[11px] text-slate-500 leading-relaxed mt-2 line-clamp-2" title={t.desc}>
|
||||
{t.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 子组件 ----
|
||||
// ---- 子子组件 ----
|
||||
|
||||
function SectionHead({ title, hint }: { title: string; hint: string }) {
|
||||
function BigStatCard({
|
||||
label,
|
||||
value,
|
||||
subText,
|
||||
barPercent,
|
||||
barColor,
|
||||
valueColor = "text-slate-800",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
subText: string;
|
||||
barPercent?: number;
|
||||
barColor?: string;
|
||||
valueColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2.5">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">{title}</h3>
|
||||
<span className="text-xs text-gray-400">{hint}</span>
|
||||
<div className="premium-card p-5 bg-white border border-gray-200 rounded-2xl shadow-sm hover:shadow-md transition">
|
||||
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">{label}</span>
|
||||
<div className="flex items-baseline gap-1 mt-2">
|
||||
<span className={`text-2xl font-bold ${valueColor}`}>{value}</span>
|
||||
</div>
|
||||
{barPercent != null ? (
|
||||
<div className="w-full bg-slate-100 h-1.5 rounded-full mt-3 overflow-hidden">
|
||||
<div className={`h-full rounded-full ${barColor || "bg-emerald-500"}`} style={{ width: `${barPercent}%` }} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-400 block mt-3 font-medium">{subText}</span>
|
||||
)}
|
||||
{barPercent != null && (
|
||||
<span className="text-[10px] text-slate-400 block mt-1.5 font-medium">{subText}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BigStat({ value, label, bad, muted }: { value: string | number; label: string; bad?: boolean; muted?: boolean }) {
|
||||
return (
|
||||
<div className="px-4 first:pl-0">
|
||||
<div className={`text-2xl font-semibold tracking-tight ${bad ? "text-rose-500" : muted ? "text-gray-500" : "text-gray-900"}`}>{value}</div>
|
||||
<div className="mt-0.5 text-[11px] text-gray-400">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ up, partial }: { up?: boolean; partial?: boolean }) {
|
||||
const c = up ? "bg-emerald-500" : partial ? "bg-amber-500" : "bg-rose-500";
|
||||
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${c}`} />;
|
||||
}
|
||||
|
||||
function FlowNode({
|
||||
icon,
|
||||
label,
|
||||
@@ -250,159 +571,72 @@ function FlowNode({
|
||||
latency?: number;
|
||||
}) {
|
||||
const down = !up && !partial;
|
||||
const accent = down ? "text-rose-400" : partial ? "text-amber-300" : "text-emerald-300";
|
||||
const ring = down ? "ring-rose-500/40" : partial ? "ring-amber-400/40" : "ring-emerald-400/30";
|
||||
const glow = down
|
||||
? "shadow-[0_0_18px_-4px_rgba(244,63,94,0.5)]"
|
||||
: partial
|
||||
? "shadow-[0_0_18px_-4px_rgba(245,158,11,0.45)]"
|
||||
: "shadow-[0_0_22px_-6px_rgba(16,185,129,0.55)]";
|
||||
|
||||
// 决定 icon 背景和文字颜色配色
|
||||
const iconColorClass = useMemo(() => {
|
||||
if (icon === "monitor") return "bg-slate-100 text-slate-600";
|
||||
if (icon === "gateway") return "bg-violet-50 text-violet-600";
|
||||
if (icon === "bus") return "bg-cyan-50 text-cyan-600";
|
||||
if (icon === "cpu") return "bg-amber-50 text-amber-600";
|
||||
return "bg-rose-50 text-rose-600";
|
||||
}, [icon]);
|
||||
|
||||
return (
|
||||
<div className={`relative flex min-w-[116px] flex-1 flex-col items-center rounded-xl bg-white/[0.04] px-3 py-3.5 text-center ring-1 ${ring} ${glow} backdrop-blur-sm`}>
|
||||
<span className="absolute right-2.5 top-2.5 flex h-2 w-2">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full rounded-full ${down ? "bg-rose-500" : partial ? "bg-amber-400" : "bg-emerald-400"}`}
|
||||
style={{ animation: "sdxGlow 1.6s ease-in-out infinite" }}
|
||||
/>
|
||||
<span className={`relative inline-flex h-2 w-2 rounded-full ${down ? "bg-rose-500" : partial ? "bg-amber-400" : "bg-emerald-400"}`} />
|
||||
</span>
|
||||
<Icon name={icon} className={`mb-1.5 h-5 w-5 ${accent}`} />
|
||||
<div className="text-xs font-semibold text-slate-100">{label}</div>
|
||||
<div className="text-[10px] text-slate-400">{sub}</div>
|
||||
{latency != null && <div className={`mt-1 font-mono text-[10px] ${down ? "text-rose-300" : "text-emerald-300/90"}`}>{latency}ms</div>}
|
||||
<div className="flex items-center gap-3 p-4 rounded-2xl bg-white border border-slate-200 shadow-sm w-44 hover:shadow-md transition duration-300">
|
||||
<div className={`h-9 w-9 rounded-xl flex items-center justify-center ${iconColorClass}`}>
|
||||
<Icon name={icon} className="h-4.5 w-4.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-800">{label}</div>
|
||||
<div className="text-[9px] text-slate-400 font-mono">
|
||||
{sub}
|
||||
{latency != null && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className={down ? "text-rose-500 font-bold" : "text-emerald-600 font-bold"}>
|
||||
{latency}ms
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Link:连接线 + 沿线流动的数据光点(up 才流动;down 显示红色静态虚线)。
|
||||
function Link({ up }: { up?: boolean }) {
|
||||
if (!up) {
|
||||
return (
|
||||
<div className="relative mx-1 h-px min-w-[28px] flex-1 self-center bg-gradient-to-r from-rose-500/30 via-rose-500/40 to-rose-500/30" />
|
||||
<div className="h-8 w-px md:h-px md:w-full bg-gradient-to-b md:bg-gradient-to-r from-rose-500/20 to-rose-500/20 relative" />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="relative mx-1 h-5 min-w-[28px] flex-1 self-center">
|
||||
{/* 基线 */}
|
||||
<div className="absolute left-0 right-0 top-1/2 h-px -translate-y-1/2 bg-gradient-to-r from-emerald-400/20 via-cyan-400/30 to-emerald-400/20" />
|
||||
{/* 流动光点(多个 + 错峰延迟 → 数据流动感) */}
|
||||
{[0, 0.53, 1.06].map((d, i) => (
|
||||
<div className="h-8 w-px md:h-px md:w-full bg-slate-200 relative flex items-center justify-center">
|
||||
{/* 流动光点 */}
|
||||
{[0, 0.8, 1.6].map((delay, idx) => (
|
||||
<span
|
||||
key={i}
|
||||
className="absolute top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full bg-cyan-300 shadow-[0_0_8px_2px_rgba(34,211,238,0.7)]"
|
||||
style={{ animation: `sdxFlow 1.6s linear ${d}s infinite` }}
|
||||
key={idx}
|
||||
className="absolute top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full bg-violet-500 shadow-[0_0_8px_rgba(139,92,246,0.6)]"
|
||||
style={{ animation: `sdxFlow 2.4s linear ${delay}s infinite` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceRow({ item }: { item: StatusItem }) {
|
||||
const meta = SERVICE_META[item.name];
|
||||
return (
|
||||
<div className="bg-white p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${item.up ? "bg-gray-50 text-gray-500" : "bg-rose-50 text-rose-500"}`}>
|
||||
<Icon name={meta?.icon ?? "tool"} className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900">{item.name}</span>
|
||||
<Dot up={item.up} />
|
||||
<span className={`text-[11px] ${item.up ? "text-emerald-600" : "text-rose-500"}`}>{item.up ? "运行中" : "离线"}</span>
|
||||
{item.up && item.latency_ms != null && <span className={`ml-auto font-mono text-[11px] ${latTone(item.latency_ms)}`}>{item.latency_ms}ms</span>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-gray-400" title={meta?.role}>{meta?.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-2 truncate text-[11px] ${item.up ? "text-gray-500" : "text-rose-500"}`} title={item.detail}>{item.detail || (item.up ? "在线" : "无响应")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfraRow({ item }: { item: StatusItem }) {
|
||||
const meta = INFRA_META[item.name];
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
|
||||
<Icon name={meta?.icon ?? "db"} className={`h-4 w-4 shrink-0 ${item.up ? "text-gray-300" : "text-rose-400"}`} />
|
||||
<span className="text-sm font-medium text-gray-800">{item.name}</span>
|
||||
<span className="text-[11px] text-gray-400">{meta?.role}</span>
|
||||
<code className="ml-auto font-mono text-[10px] text-gray-300">:{meta?.port}</code>
|
||||
<span className="flex w-12 items-center justify-end gap-1.5">
|
||||
<Dot up={item.up} />
|
||||
<span className={`text-[11px] ${item.up ? "text-emerald-600" : "text-rose-500"}`}>{item.up ? "就绪" : "离线"}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolServer({ server, up, tools }: { server: string; up: boolean; tools: ToolInfo[] }) {
|
||||
const groups: Record<string, ToolInfo[]> = {};
|
||||
for (const t of tools) (groups[toolCategory(t.name)] ??= []).push(t);
|
||||
const cats = Object.keys(groups).sort((a, b) => CAT_ORDER.indexOf(a) - CAT_ORDER.indexOf(b));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Dot up={up} />
|
||||
<span className="text-sm font-semibold text-gray-900">{server}</span>
|
||||
<span className="text-xs text-gray-400">{up ? `${tools.length} 个工具` : "无响应(未启动?)"}</span>
|
||||
{up &&
|
||||
cats.map((c) => (
|
||||
<span key={c} className="rounded-md bg-gray-50 px-2 py-0.5 text-[10px] text-gray-400">
|
||||
{c} <span className="font-semibold text-gray-600">{groups[c].length}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{up && cats.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{cats.map((c) => {
|
||||
const cs = CAT_STYLE[c] ?? CAT_STYLE["系统"];
|
||||
return (
|
||||
<div key={c}>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${cs.dot}`} />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400">{c}</span>
|
||||
<span className="text-[10px] text-gray-300">{groups[c].length}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{groups[c].map((t) => (
|
||||
<div
|
||||
key={t.name}
|
||||
className="group rounded-xl border border-gray-200/70 bg-white p-3 transition hover:-translate-y-0.5 hover:border-violet-200 hover:shadow-[0_4px_16px_-6px_rgba(124,58,237,0.25)]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${cs.dot}`} />
|
||||
<span className="text-xs font-semibold text-gray-800">{t.cn}</span>
|
||||
<code className="ml-auto truncate font-mono text-[10px] text-gray-400">{t.name}</code>
|
||||
</div>
|
||||
<div className="mt-1.5 line-clamp-2 text-[10px] leading-relaxed text-gray-400" title={t.desc}>
|
||||
{t.desc}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 内联图标 ----
|
||||
type IconName = "refresh" | "monitor" | "gateway" | "bus" | "cpu" | "tool" | "box" | "db";
|
||||
const PATHS: Record<IconName, string> = {
|
||||
refresh: "M21 12a9 9 0 1 1-3-6.7L21 8 M21 3v5h-5",
|
||||
monitor: "M3 4h18v12H3z M8 20h8 M12 16v4",
|
||||
gateway: "M4 4h16v6H4z M4 14h16v6H4z M8 7h.01 M8 17h.01",
|
||||
bus: "M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M6 15a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M18 16a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M8.6 13.5l6.8 4 M15.4 6.5l-6.8 4",
|
||||
cpu: "M6 6h12v12H6z M9 9h6v6H9z M9 1v3 M15 1v3 M9 20v3 M15 20v3 M1 9h3 M1 15h3 M20 9h3 M20 15h3",
|
||||
tool: "M14.7 6.3a4 4 0 0 1-5.4 5.4L4 17v3h3l5.3-5.3a4 4 0 0 0 5.4-5.4l-2.7 2.7-2-2 2.7-2.7z",
|
||||
gateway: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10",
|
||||
bus: "M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4",
|
||||
cpu: "M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4",
|
||||
tool: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z",
|
||||
box: "M21 8 12 3 3 8v8l9 5 9-5z M3 8l9 5 9-5 M12 13v8",
|
||||
db: "M12 3c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3z M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6 M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6",
|
||||
};
|
||||
|
||||
function Icon({ name, className }: { name: IconName; className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -410,3 +644,4 @@ function Icon({ name, className }: { name: IconName; className?: string }) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminSubPlans, saveSubPlan, adminSubscriptions, type SubPlan, type SubRow } from "../api";
|
||||
|
||||
// 支付 · 订阅:套餐配置(价格/时长/发放节奏)+ 全平台订阅观测。
|
||||
//
|
||||
// 语义要在界面上讲清楚,否则配错了很难发现:
|
||||
// - 手动购买制,**不自动续费**(微信 Native 无代扣能力),到期即失效;
|
||||
// - 期内每 N 天发一次积分,发放是**累加**(不清零用户已有积分);
|
||||
// - 因此一个周期实际发放次数 = floor(时长 / 间隔),配的时候直接算给人看。
|
||||
const MICRO = 1_000_000;
|
||||
const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
const yuan = (fen: number) => (fen / 100).toFixed(2);
|
||||
|
||||
export function SubscriptionPage() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="border-b border-gray-200 pb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800">支付 · 订阅</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
订阅套餐定价与发放节奏 · 全平台订阅状态。手动购买制,到期即失效,不自动续费
|
||||
</p>
|
||||
</div>
|
||||
<PlansBlock />
|
||||
<SubsBlock />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlansBlock() {
|
||||
const [rows, setRows] = useState<SubPlan[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [price, setPrice] = useState("");
|
||||
const [duration, setDuration] = useState("30");
|
||||
const [refill, setRefill] = useState("");
|
||||
const [interval, setIntervalDays] = useState("7");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const load = () => adminSubPlans().then(setRows).catch((e) => setErr((e as Error).message));
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const add = async () => {
|
||||
const p = Number(price), d = Number(duration), r = Number(refill), i = Number(interval);
|
||||
if (!name.trim() || !p || !d || !r || !i || busy) return;
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
try {
|
||||
await saveSubPlan({
|
||||
name: name.trim(), price_fen: Math.round(p * 100), duration_days: d,
|
||||
refill_credits: r, refill_interval_days: i, active: true, sort: 0,
|
||||
});
|
||||
setName(""); setPrice(""); setRefill("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = async (p: SubPlan) => {
|
||||
try {
|
||||
await saveSubPlan({
|
||||
id: p.id, name: p.name, price_fen: p.price_fen, duration_days: p.duration_days,
|
||||
refill_credits: p.refill_credits_micro / MICRO, refill_interval_days: p.refill_interval_days,
|
||||
active: !p.active, sort: p.sort,
|
||||
});
|
||||
await load();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 一个周期发几次:让人在配的时候就看见后果,而不是上线后才发现只发了一次。
|
||||
const times = (p: { duration_days: number; refill_interval_days: number }) =>
|
||||
p.refill_interval_days > 0 ? Math.floor(p.duration_days / p.refill_interval_days) : 0;
|
||||
|
||||
const previewTimes = times({ duration_days: Number(duration) || 0, refill_interval_days: Number(interval) || 0 });
|
||||
const previewTotal = previewTimes * (Number(refill) || 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-gray-700">订阅套餐</h4>
|
||||
<span className="text-[11px] text-gray-400">积分按周期发放且累加,不清零用户已有积分</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="text-xs text-gray-500">
|
||||
名称
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:专业版月付"
|
||||
className="mt-1 block w-36 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||
</label>
|
||||
<label className="text-xs text-gray-500">
|
||||
售价(¥)
|
||||
<input value={price} onChange={(e) => setPrice(e.target.value)} inputMode="decimal" placeholder="99"
|
||||
className="mt-1 block w-20 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||
</label>
|
||||
<label className="text-xs text-gray-500">
|
||||
时长(天)
|
||||
<input value={duration} onChange={(e) => setDuration(e.target.value)} inputMode="numeric"
|
||||
className="mt-1 block w-20 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||
</label>
|
||||
<label className="text-xs text-gray-500">
|
||||
每次发放(积分)
|
||||
<input value={refill} onChange={(e) => setRefill(e.target.value)} inputMode="numeric" placeholder="1000"
|
||||
className="mt-1 block w-28 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||
</label>
|
||||
<label className="text-xs text-gray-500">
|
||||
每几天发
|
||||
<input value={interval} onChange={(e) => setIntervalDays(e.target.value)} inputMode="numeric"
|
||||
className="mt-1 block w-20 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||
</label>
|
||||
<button onClick={() => void add()} disabled={busy}
|
||||
className="rounded-lg bg-violet-600 px-3.5 py-1.5 text-sm text-white hover:bg-violet-700 disabled:opacity-40">
|
||||
{busy ? "…" : "新增"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 配置后果预览:省得上线后才发现节奏配错 */}
|
||||
{previewTimes > 0 && (
|
||||
<p className="mt-2 text-[11px] text-gray-500">
|
||||
一个周期发 <b className="text-violet-600">{previewTimes}</b> 次,合计{" "}
|
||||
<b className="text-violet-600">{previewTotal.toLocaleString("zh-CN")}</b> 积分
|
||||
{Number(duration) % Number(interval) !== 0 && (
|
||||
<span className="text-amber-600">({Number(duration)} 不能被 {Number(interval)} 整除,最后一次发放后到期前会有空档)</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
|
||||
|
||||
<div className="mt-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">名称</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">售价</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">时长</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">发放节奏</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">周期总积分</th>
|
||||
<th className="py-2 text-right font-medium">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((p) => (
|
||||
<tr key={p.id} className="border-b border-gray-50 last:border-0">
|
||||
<td className="py-2 pr-3 text-gray-800">{p.name}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">¥{yuan(p.price_fen)}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{p.duration_days} 天</td>
|
||||
<td className="py-2 pr-3 text-right text-[11px] tabular-nums text-gray-600">
|
||||
每 {p.refill_interval_days} 天 · {credits(p.refill_credits_micro)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">
|
||||
{credits(p.refill_credits_micro * times(p))}
|
||||
<span className="ml-1 text-[10px] text-gray-400">({times(p)} 次)</span>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button onClick={() => void toggle(p)}
|
||||
className={`rounded border px-2 py-0.5 text-[11px] ${p.active ? "border-emerald-200 text-emerald-600 hover:bg-emerald-50" : "border-gray-200 text-gray-400 hover:bg-gray-50"}`}>
|
||||
{p.active ? "在售 · 点击下架" : "已下架 · 点击上架"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-6 text-center text-xs text-gray-400">还没有订阅套餐</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubsBlock() {
|
||||
const [rows, setRows] = useState<SubRow[]>([]);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
adminSubscriptions().then(setRows).catch((e) => setErr((e as Error).message));
|
||||
}, []);
|
||||
|
||||
const now = Date.now();
|
||||
const daysLeft = (iso: string) => Math.ceil((new Date(iso).getTime() - now) / 86400000);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-gray-700">全平台订阅</h4>
|
||||
<span className="text-[11px] text-gray-400">到期即失效,不自动续费;剩余天数少的排在前面</span>
|
||||
</div>
|
||||
{err && <p className="mb-2 text-xs text-rose-500">{err}</p>}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">租户</th>
|
||||
<th className="py-2 pr-3 font-medium">套餐</th>
|
||||
<th className="py-2 pr-3 font-medium">状态</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">已发放</th>
|
||||
<th className="py-2 text-right font-medium">到期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s) => {
|
||||
const left = daysLeft(s.expires_at);
|
||||
return (
|
||||
<tr key={s.id} className="border-b border-gray-50 last:border-0">
|
||||
<td className="py-2 pr-3 text-gray-800">{s.tenant_name || s.tenant_id}</td>
|
||||
<td className="py-2 pr-3 text-gray-600">{s.plan_name || "—"}</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${s.status === "active" ? "bg-emerald-50 text-emerald-600" : "bg-gray-100 text-gray-500"}`}>
|
||||
{s.status === "active" ? "生效中" : "已过期"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums text-gray-600">{s.refill_seq} 次</td>
|
||||
<td className="py-2 text-right text-[11px] tabular-nums">
|
||||
<span className="text-gray-500">{new Date(s.expires_at).toLocaleDateString("zh-CN")}</span>
|
||||
{s.status === "active" && (
|
||||
// 快到期的要显眼:到期即失效,没有自动续费兜底
|
||||
<span className={`ml-1.5 ${left <= 3 ? "font-medium text-rose-600" : left <= 7 ? "text-amber-600" : "text-gray-400"}`}>
|
||||
剩 {left} 天
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-6 text-center text-xs text-gray-400">还没有人订阅</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { TaskDetailDrawer } from "../components/TaskDetailDrawer";
|
||||
import { adminTasks, listTenants, type AdminTask, type TenantRow } from "../api";
|
||||
|
||||
// 全平台任务/运行观测:跨租户看所有任务(状态分布 + 列表 + 提交人/租户/评测)。
|
||||
// 含 HITL 待审批(筛 waiting)。数据来自 sundynix_task,后端 GET /admin/tasks。
|
||||
|
||||
const STATUS: Record<string, { label: string; badge: string; dot: string }> = {
|
||||
submitted: { label: "已提交", badge: "bg-gray-100 text-gray-500", dot: "bg-gray-400" },
|
||||
running: { label: "运行中", badge: "bg-cyan-50 text-cyan-600", dot: "bg-cyan-500" },
|
||||
done: { label: "完成", badge: "bg-emerald-50 text-emerald-600", dot: "bg-emerald-500" },
|
||||
failed: { label: "失败", badge: "bg-rose-50 text-rose-500", dot: "bg-rose-500" },
|
||||
timeout: { label: "超时", badge: "bg-amber-50 text-amber-600", dot: "bg-amber-500" },
|
||||
waiting: { label: "待审批", badge: "bg-yellow-50 text-yellow-700", dot: "bg-yellow-500" },
|
||||
rejected: { label: "已拒绝", badge: "bg-violet-50 text-violet-600", dot: "bg-violet-500" },
|
||||
};
|
||||
const st = (s: string) => STATUS[s] ?? { label: s, badge: "bg-gray-100 text-gray-500", dot: "bg-gray-400" };
|
||||
|
||||
// 筛选标签顺序(waiting 提前,运维最关心待审批 + 失败)。
|
||||
const FILTERS = ["", "waiting", "running", "failed", "timeout", "done", "rejected"] as const;
|
||||
|
||||
const EVAL_LEVEL: Record<string, string> = {
|
||||
excellent: "text-emerald-600",
|
||||
good: "text-emerald-500",
|
||||
fair: "text-amber-600",
|
||||
poor: "text-rose-500",
|
||||
};
|
||||
|
||||
export function TasksPage() {
|
||||
const [tasks, setTasks] = useState<AdminTask[]>([]);
|
||||
const [counts, setCounts] = useState<Record<string, number>>({});
|
||||
const [filter, setFilter] = useState("");
|
||||
const [tenantFilter, setTenantFilter] = useState("");
|
||||
const [tenants, setTenants] = useState<TenantRow[]>([]);
|
||||
const [drill, setDrill] = useState<AdminTask | null>(null); // 正在下钻的任务
|
||||
const [copiedId, setCopiedId] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 载入租户列表用于筛选
|
||||
useEffect(() => {
|
||||
listTenants()
|
||||
.then(setTenants)
|
||||
.catch((e) => console.error("Failed to load tenants:", e));
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
adminTasks(filter, tenantFilter, 100)
|
||||
.then((r) => {
|
||||
setTasks(r.tasks);
|
||||
setCounts(r.counts);
|
||||
setErr("");
|
||||
})
|
||||
.catch((e) => setErr((e as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [filter, tenantFilter]);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
const handleCopy = (id: string) => {
|
||||
navigator.clipboard.writeText(id)
|
||||
.then(() => {
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(""), 1500);
|
||||
})
|
||||
.catch((err) => console.error("Failed to copy:", err));
|
||||
};
|
||||
|
||||
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<h3 className="text-sm font-semibold text-gray-700">全平台任务 / 运行观测</h3>
|
||||
<span className="text-[11px] text-gray-400">跨租户查看所有任务,含 HITL 待审批(筛「待审批」)</span>
|
||||
</div>
|
||||
|
||||
{/* 状态计数卡片 */}
|
||||
<div className="grid grid-cols-3 gap-3 lg:grid-cols-7">
|
||||
<Stat label="全部" value={total} active={filter === ""} onClick={() => setFilter("")} />
|
||||
{FILTERS.filter((f) => f).map((f) => (
|
||||
<Stat key={f} label={st(f).label} value={counts[f] ?? 0} tone={f} active={filter === f} onClick={() => setFilter(f)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-400">
|
||||
{loading ? "加载中…" : `共 ${tasks.length} 条${filter ? `(${st(filter).label})` : ""}`}
|
||||
</span>
|
||||
{/* 租户过滤下拉 */}
|
||||
<select
|
||||
value={tenantFilter}
|
||||
onChange={(e) => setTenantFilter(e.target.value)}
|
||||
className="rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 focus:outline-none focus:border-violet-400"
|
||||
>
|
||||
<option value="">全部租户</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name || t.slug || t.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button onClick={load} className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50">
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
{err && <p className="mb-2 text-xs text-rose-500">{err}</p>}
|
||||
|
||||
<div className="max-h-[32rem] overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">时间</th>
|
||||
<th className="py-2 pr-3 font-medium">任务 / 主题</th>
|
||||
<th className="py-2 pr-3 font-medium">租户</th>
|
||||
<th className="py-2 pr-3 font-medium">提交人</th>
|
||||
<th className="py-2 pr-3 font-medium">状态</th>
|
||||
<th className="py-2 pr-3 font-medium">评测</th>
|
||||
<th className="py-2 font-medium">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((t) => (
|
||||
<tr
|
||||
key={t.task_id}
|
||||
onClick={() => setDrill(t)}
|
||||
className="cursor-pointer border-b border-gray-50 align-top last:border-0 hover:bg-violet-50/40"
|
||||
title="点击查看执行轨迹 / 输出 / 评测"
|
||||
>
|
||||
<td className="py-2 pr-3 text-xs text-gray-500 whitespace-nowrap">{new Date(t.at).toLocaleString("zh-CN")}</td>
|
||||
<td className="py-2 pr-3">
|
||||
<div className="text-gray-800 flex items-center gap-1.5">
|
||||
<span>{t.topic || <code className="text-[11px] text-gray-500">{t.task_id.slice(0, 8)}…</code>}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // 复制不应顺带打开下钻
|
||||
handleCopy(t.task_id);
|
||||
}}
|
||||
className="text-[10px] text-gray-400 hover:text-violet-600 transition"
|
||||
title="复制任务 ID"
|
||||
>
|
||||
{copiedId === t.task_id ? "✓ 已复制" : "📋"}
|
||||
</button>
|
||||
</div>
|
||||
{t.topic && (
|
||||
<div className="text-[10px] text-gray-300 font-mono flex items-center gap-1">
|
||||
<span>{t.task_id}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-gray-600">{t.tenant_name || <span className="text-gray-300">{t.tenant_id || "—"}</span>}</td>
|
||||
<td className="py-2 pr-3 text-xs text-gray-500">{t.owner_email || t.owner || "—"}</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] ${st(t.status).badge}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st(t.status).dot}`} />
|
||||
{st(t.status).label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-xs">
|
||||
{t.eval_level ? (
|
||||
<span className={EVAL_LEVEL[t.eval_level] ?? "text-gray-500"}>
|
||||
{t.eval_level} · {(t.eval_overall * 100).toFixed(0)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 max-w-[18rem] text-xs text-gray-500">
|
||||
<span className="line-clamp-2" title={t.detail}>{t.detail || "—"}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{tasks.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-xs text-gray-400">暂无任务</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drill && <TaskDetailDrawer task={drill} onClose={() => setDrill(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone?: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const dot = tone ? st(tone).dot : "bg-gray-400";
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`rounded-xl border p-3 text-left transition ${
|
||||
active ? "border-violet-300 bg-violet-50" : "border-gray-100 bg-white hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-gray-400">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${dot}`} />
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums text-gray-800">{value}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { listTenants, createTenant, listMembers, addMember, setMemberRole, removeMember, setSharedBilling, type TenantRow, type Member } from "../api";
|
||||
import {
|
||||
listTenants,
|
||||
createTenant,
|
||||
listMembers,
|
||||
addMember,
|
||||
setMemberRole,
|
||||
removeMember,
|
||||
setSharedBilling,
|
||||
setTenantPlan,
|
||||
setTenantStatus,
|
||||
type TenantRow,
|
||||
type Member
|
||||
} from "../api";
|
||||
import { GrantCreditsModal } from "../components/GrantCreditsModal";
|
||||
|
||||
// 管理端「租户 & 用户」= 多成员租户管理(平台运维口径):租户目录 + 每租户成员增改删。
|
||||
// 全真数据:GET/POST /admin/tenants、/admin/tenants/:id/members。
|
||||
@@ -8,6 +21,9 @@ const ROLES = ["owner", "admin", "member", "viewer", "billing_admin"];
|
||||
const ROLE_CN: Record<string, string> = { owner: "所有者", admin: "管理员", member: "成员", viewer: "只读", billing_admin: "计费管理" };
|
||||
const credits = (m: number) => (m / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
|
||||
const PLANS = ["free", "pro", "enterprise"];
|
||||
const PLAN_CN: Record<string, string> = { free: "免费版", pro: "专业版", enterprise: "企业版" };
|
||||
|
||||
export function TenantsPage() {
|
||||
const [tenants, setTenants] = useState<TenantRow[]>([]);
|
||||
const [selId, setSelId] = useState<string>("");
|
||||
@@ -19,6 +35,7 @@ export function TenantsPage() {
|
||||
const [tName, setTName] = useState("");
|
||||
const [tSlug, setTSlug] = useState("");
|
||||
const [tOwner, setTOwner] = useState("");
|
||||
const [tPlan, setTPlan] = useState("free");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// 加成员表单
|
||||
@@ -27,6 +44,11 @@ export function TenantsPage() {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [mErr, setMErr] = useState("");
|
||||
|
||||
// Modals state
|
||||
const [editPlanTenant, setEditPlanTenant] = useState<TenantRow | null>(null);
|
||||
const [grantTenant, setGrantTenant] = useState<TenantRow | null>(null);
|
||||
const [statusChanging, setStatusChanging] = useState(false);
|
||||
|
||||
const selected = useMemo(() => tenants.find((t) => t.id === selId) ?? null, [tenants, selId]);
|
||||
|
||||
const loadTenants = async (keepSel = true) => {
|
||||
@@ -67,10 +89,11 @@ export function TenantsPage() {
|
||||
if (!tName.trim() || !tSlug.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await createTenant(tName.trim(), tSlug.trim(), tOwner.trim() || undefined);
|
||||
await createTenant(tName.trim(), tSlug.trim(), tOwner.trim() || undefined, tPlan);
|
||||
setTName("");
|
||||
setTSlug("");
|
||||
setTOwner("");
|
||||
setTPlan("free");
|
||||
await loadTenants();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
@@ -119,11 +142,89 @@ export function TenantsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onPlanChange = async (plan: string) => {
|
||||
if (!editPlanTenant) return;
|
||||
try {
|
||||
await setTenantPlan(editPlanTenant.id, plan);
|
||||
setEditPlanTenant(null);
|
||||
await loadTenants();
|
||||
} catch (e) {
|
||||
setErr(`修改 Plan 失败: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onStatusToggle = async () => {
|
||||
if (!selected) return;
|
||||
const nextStatus = selected.status === "suspended" ? "active" : "suspended";
|
||||
const actionText = nextStatus === "suspended" ? "暂停" : "恢复";
|
||||
if (!window.confirm(`确定要 ${actionText} 租户「${selected.name}」吗?`)) return;
|
||||
|
||||
setStatusChanging(true);
|
||||
try {
|
||||
await setTenantStatus(selected.id, nextStatus);
|
||||
await loadTenants();
|
||||
} catch (e) {
|
||||
setMErr(`${actionText}租户失败: ${(e as Error).message}`);
|
||||
} finally {
|
||||
setStatusChanging(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400">加载租户中…</div>;
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{err && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{err}</div>}
|
||||
|
||||
{/* 充值 Modal */}
|
||||
<GrantCreditsModal
|
||||
tenant={grantTenant}
|
||||
onClose={() => setGrantTenant(null)}
|
||||
onDone={() => void loadTenants()}
|
||||
/>
|
||||
|
||||
{/* 修改方案 Modal */}
|
||||
{editPlanTenant && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && setEditPlanTenant(null)}
|
||||
>
|
||||
<div className="relative mx-4 w-full max-w-sm overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">更改方案等级</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-400">调整租户 {editPlanTenant.name} 的等级</p>
|
||||
</div>
|
||||
<button onClick={() => setEditPlanTenant(null)} className="text-xl leading-none text-gray-400 hover:text-gray-600">×</button>
|
||||
</div>
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div className="space-y-2">
|
||||
{PLANS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => void onPlanChange(p)}
|
||||
className={`w-full text-left p-3 rounded-lg border flex items-center justify-between transition-all ${
|
||||
editPlanTenant.plan === p
|
||||
? "border-violet-500 bg-violet-50/50 font-semibold text-violet-700"
|
||||
: "border-gray-100 hover:bg-gray-50 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span>{PLAN_CN[p]}</span>
|
||||
<span className="text-[11px] uppercase text-gray-400">{p}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-6 py-4 flex justify-end">
|
||||
<button onClick={() => setEditPlanTenant(null)} className="rounded-lg border border-gray-200 bg-white px-4 py-2 text-xs text-gray-500 hover:bg-gray-50">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* 左:租户目录 + 建租户 */}
|
||||
<div className="space-y-6">
|
||||
@@ -140,8 +241,22 @@ export function TenantsPage() {
|
||||
className={`w-full rounded-lg border p-3 text-left transition-all ${selId === t.id ? "border-violet-500 bg-violet-50/40 shadow-sm" : "border-gray-100 hover:bg-gray-50/60"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-gray-800">{t.name}</span>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[9px] font-medium ${t.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}>{t.plan}</span>
|
||||
<span className="text-xs font-bold text-gray-800 flex items-center gap-1.5">
|
||||
{t.name}
|
||||
{t.status === "suspended" && (
|
||||
<span className="rounded bg-rose-100 text-rose-700 px-1 py-0.2 text-[8px] font-bold">已暂停</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditPlanTenant(t);
|
||||
}}
|
||||
title="点击修改 Plan"
|
||||
className={`rounded px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:opacity-85 ${t.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}
|
||||
>
|
||||
{t.plan} ✎
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-[10px] text-gray-400">
|
||||
<span>{t.members} 成员 · <span className="font-mono">{t.slug}</span></span>
|
||||
@@ -158,6 +273,20 @@ export function TenantsPage() {
|
||||
<input className="w-full rounded border px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none" placeholder="租户名称(如:华泰投行部)" value={tName} onChange={(e) => setTName(e.target.value)} />
|
||||
<input className="w-full rounded border px-2 py-1.5 text-xs font-mono focus:border-violet-500 focus:outline-none" placeholder="唯一标识 slug(如 ht-ib)" value={tSlug} onChange={(e) => setTSlug(e.target.value)} />
|
||||
<input className="w-full rounded border px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none" placeholder="owner 邮箱(可选,须已注册)" value={tOwner} onChange={(e) => setTOwner(e.target.value)} />
|
||||
|
||||
<label className="block text-xs text-gray-500 space-y-1">
|
||||
<span>方案等级</span>
|
||||
<select
|
||||
value={tPlan}
|
||||
onChange={(e) => setTPlan(e.target.value)}
|
||||
className="w-full rounded border bg-white px-2 py-1.5 text-xs text-gray-700 focus:border-violet-500 focus:outline-none"
|
||||
>
|
||||
{PLANS.map((p) => (
|
||||
<option key={p} value={p}>{PLAN_CN[p]} ({p})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button onClick={() => void onCreate()} disabled={creating || !tName.trim() || !tSlug.trim()} className="w-full rounded bg-violet-600 py-1.5 text-xs font-semibold text-white hover:bg-violet-700 disabled:opacity-40">
|
||||
{creating ? "创建中…" : "创建租户"}
|
||||
</button>
|
||||
@@ -174,18 +303,49 @@ export function TenantsPage() {
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between border-b pb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700">{selected.name} · 成员</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold text-gray-700">{selected.name} · 成员</h4>
|
||||
<span
|
||||
onClick={() => setEditPlanTenant(selected)}
|
||||
className={`rounded px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:opacity-85 ${selected.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}
|
||||
>
|
||||
{selected.plan} ✎
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400">成员共享该租户的积分池与用量口径 · slug {selected.slug}</p>
|
||||
</div>
|
||||
<span className="tabular-nums text-xs text-gray-500">{credits(selected.credit_balance_micro)} 积分</span>
|
||||
|
||||
<div className="flex items-center gap-2 text-right">
|
||||
<span className="tabular-nums text-xs text-gray-500">{credits(selected.credit_balance_micro)} 积分</span>
|
||||
<button
|
||||
onClick={() => setGrantTenant(selected)}
|
||||
className="rounded border border-violet-200 px-2 py-0.5 text-[10px] text-violet-600 hover:bg-violet-50"
|
||||
>
|
||||
充值/校正
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 共享计费开关 */}
|
||||
<label className="mb-3 flex cursor-pointer items-center gap-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-xs text-gray-600">
|
||||
<input type="checkbox" checked={selected.shared_billing} onChange={(e) => void onToggleShared(e.target.checked)} className="h-4 w-4 accent-violet-600" />
|
||||
<span className="font-medium text-gray-700">共享计费</span>
|
||||
<span className="text-gray-400">开:成员消耗计本租户积分池;关:成员各计个人池(owner 恒计本租户)</span>
|
||||
</label>
|
||||
{/* 状态控制 & 共享计费开关 */}
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3 p-3 rounded-lg border border-gray-100 bg-gray-50/50">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-gray-600">
|
||||
<input type="checkbox" checked={selected.shared_billing} onChange={(e) => void onToggleShared(e.target.checked)} className="h-4 w-4 accent-violet-600" />
|
||||
<span className="font-medium text-gray-700">共享计费</span>
|
||||
<span className="text-gray-400">开:成员消耗本租户池;关:成员消耗个人池</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => void onStatusToggle()}
|
||||
disabled={statusChanging}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-semibold shadow-sm transition-all ${
|
||||
selected.status === "suspended"
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100"
|
||||
: "border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100"
|
||||
}`}
|
||||
>
|
||||
{selected.status === "suspended" ? "恢复租户账号" : "暂停租户账号"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 加成员 */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-violet-100 bg-violet-50/30 p-3">
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getVoiceConfig, saveVoiceConfig } from "../api";
|
||||
|
||||
// 运维 · 语音设置:火山引擎豆包语音(流式 ASR 耳朵 + 双向流式 TTS 嘴)。
|
||||
// AccessToken 明文回显(单管理员后台,方便核对;密文仍加密入库)。设计见 VOICE_DESIGN.md。
|
||||
export function VoiceConfigPage() {
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [asrRes, setAsrRes] = useState("");
|
||||
const [ttsRes, setTtsRes] = useState("");
|
||||
const [voice, setVoice] = useState("");
|
||||
const [asrOn, setAsrOn] = useState(false);
|
||||
const [ttsOn, setTtsOn] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [ok, setOk] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getVoiceConfig()
|
||||
.then((c) => {
|
||||
setApiKey(c.api_key);
|
||||
setAsrRes(c.asr_resource_id);
|
||||
setTtsRes(c.tts_resource_id);
|
||||
setVoice(c.tts_voice_type);
|
||||
setAsrOn(c.asr_enabled);
|
||||
setTtsOn(c.tts_enabled);
|
||||
})
|
||||
.catch((e) => setErr((e as Error).message));
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
setOk(false);
|
||||
try {
|
||||
const r = await saveVoiceConfig({
|
||||
api_key: apiKey,
|
||||
asr_resource_id: asrRes.trim(),
|
||||
tts_resource_id: ttsRes.trim(),
|
||||
tts_voice_type: voice.trim(),
|
||||
});
|
||||
setAsrOn(r.asr_enabled);
|
||||
setTtsOn(r.tts_enabled);
|
||||
setOk(true);
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const field = "mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none";
|
||||
const badge = (on: boolean, label: string) =>
|
||||
on ? (
|
||||
<span className="rounded bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-600">{label} 已就绪</span>
|
||||
) : (
|
||||
<span className="rounded bg-gray-100 px-2 py-0.5 text-[10px] text-gray-500">{label} 未配齐</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="border-b border-gray-200 pb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800">语音设置 · 火山豆包</h3>
|
||||
<p className="text-xs text-gray-400">流式语音识别(耳朵) + 双向流式语音合成(嘴)。AccessToken 加密入库、后台明文可见</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold text-gray-700">火山引擎配置</h4>
|
||||
{badge(asrOn, "ASR")}
|
||||
{badge(ttsOn, "TTS")}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label className="text-xs text-gray-500 md:col-span-2">
|
||||
API Key(新版鉴权,Authorization: Bearer <APIKey>)
|
||||
<input value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="火山控制台生成的 API Key" className={field} />
|
||||
</label>
|
||||
<label className="text-xs text-gray-500">
|
||||
ASR Resource-Id
|
||||
<input value={asrRes} onChange={(e) => setAsrRes(e.target.value)} placeholder="流式语音识别的 X-Api-Resource-Id" className={field} />
|
||||
</label>
|
||||
<label className="text-xs text-gray-500">
|
||||
TTS Resource-Id
|
||||
<input value={ttsRes} onChange={(e) => setTtsRes(e.target.value)} placeholder="双向流式 TTS 的 X-Api-Resource-Id" className={field} />
|
||||
</label>
|
||||
<label className="text-xs text-gray-500 md:col-span-2">
|
||||
音色 voice_type
|
||||
<input value={voice} onChange={(e) => setVoice(e.target.value)} placeholder="从标准音色里挑一个(如 zh_male_…)" className={field} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
|
||||
{ok && <p className="mt-2 text-xs text-emerald-600">已保存</p>}
|
||||
|
||||
<div className="mt-3">
|
||||
<button onClick={() => void save()} disabled={busy} className="rounded-lg bg-violet-600 px-3.5 py-1.5 text-sm text-white hover:bg-violet-700 disabled:opacity-40">
|
||||
{busy ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-amber-100 bg-amber-50/40 p-5 text-xs leading-relaxed text-gray-600">
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">去哪拿这些参数(新版 API Key 鉴权)</h4>
|
||||
<ol className="list-decimal space-y-1.5 pl-4">
|
||||
<li>火山控制台生成 <b>API Key</b>(新版鉴权,一个 Key 走 Authorization 头,不再用旧版 appid+token)</li>
|
||||
<li><b>ASR Resource-Id</b>:在「流式语音识别 2.0」文档的鉴权小节(X-Api-Resource-Id 那一栏)</li>
|
||||
<li><b>TTS Resource-Id</b>:在「双向流式语音合成」文档的鉴权小节</li>
|
||||
<li><b>音色</b>:控制台「音色管理」里挑一个(JARVIS 建议沉稳男声),填其 voice_type</li>
|
||||
<li>端点/音频格式(PCM 16k 单声道)由后端固定,不用填</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminWechatUsers, type WechatUserRow } from "../api";
|
||||
|
||||
// 平台 · 微信用户:所有微信扫码登录的用户,集中查看。
|
||||
// 每个用户仍是独立租户/独立积分(各买各的),这里只做统一观测。
|
||||
const MICRO = 1_000_000;
|
||||
const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
|
||||
export function WechatUsersPage() {
|
||||
const [rows, setRows] = useState<WechatUserRow[]>([]);
|
||||
const [err, setErr] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copied, setCopied] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
adminWechatUsers()
|
||||
.then(setRows)
|
||||
.catch((e) => setErr((e as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const copy = (s: string) => {
|
||||
void navigator.clipboard?.writeText(s);
|
||||
setCopied(s);
|
||||
setTimeout(() => setCopied(""), 1200);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="border-b border-gray-200 pb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800">微信用户</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
扫码登录的用户 · 共 {rows.length} 人 · 每人独立租户与积分(各买各的)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
{err && <p className="mb-2 text-xs text-rose-500">{err}</p>}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">昵称</th>
|
||||
<th className="py-2 pr-3 font-medium">OpenID</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">积分余额</th>
|
||||
<th className="py-2 font-medium">加入时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((u) => (
|
||||
<tr key={u.id} className="border-b border-gray-50 last:border-0">
|
||||
<td className="py-2 pr-3 text-gray-800">{u.name || "—"}</td>
|
||||
<td className="py-2 pr-3">
|
||||
{/* openid 长且要复制,点一下复制全串 */}
|
||||
<button
|
||||
onClick={() => copy(u.wechat_openid)}
|
||||
title="点击复制"
|
||||
className="font-mono text-[11px] text-gray-500 hover:text-violet-600"
|
||||
>
|
||||
{copied === u.wechat_openid ? "已复制 ✓" : `${u.wechat_openid.slice(0, 10)}…${u.wechat_openid.slice(-6)}`}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums text-gray-700">{credits(u.balance_micro)}</td>
|
||||
<td className="py-2 text-xs text-gray-500">{new Date(u.created_at).toLocaleString("zh-CN")}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-8 text-center text-xs text-gray-400">还没有微信用户</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { routes, navGroups, defaultPath } from "./routes";
|
||||
import { routes, navGroups, defaultPath, type NavNode, type RouteDef } from "./routes";
|
||||
|
||||
// 把嵌套导航拍平成路由数组(顶层项 + 二级子项),顺序即渲染顺序。
|
||||
function flatten(nodes: NavNode[]): RouteDef[] {
|
||||
return nodes.flatMap((n) => (n.kind === "item" ? [n.route] : n.items));
|
||||
}
|
||||
|
||||
// routes 是控制台导航的单一事实源 —— 派生分组必须保序、不丢项、不串组。
|
||||
describe("navGroups", () => {
|
||||
it("按注册顺序归并分组,每条路由都落到对应组", () => {
|
||||
const groups = navGroups();
|
||||
const flat = groups.flatMap((g) => g.items);
|
||||
const flat = navGroups().flatMap((g) => flatten(g.nodes));
|
||||
// 不丢项、不重复
|
||||
expect(flat).toHaveLength(routes.length);
|
||||
expect(new Set(flat.map((r) => r.path)).size).toBe(routes.length);
|
||||
@@ -17,10 +21,31 @@ describe("navGroups", () => {
|
||||
expect(navGroups().map((g) => g.group)).toEqual(seen);
|
||||
});
|
||||
|
||||
it("同组路由保持注册时的相对顺序", () => {
|
||||
it("同组路由保持注册时的相对顺序(含二级子项)", () => {
|
||||
for (const g of navGroups()) {
|
||||
const expected = routes.filter((r) => r.group === g.group).map((r) => r.path);
|
||||
expect(g.items.map((r) => r.path)).toEqual(expected);
|
||||
expect(flatten(g.nodes).map((r) => r.path)).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("带 parent 的路由聚成一个二级菜单,不散落成顶层项", () => {
|
||||
for (const g of navGroups()) {
|
||||
for (const n of g.nodes) {
|
||||
if (n.kind === "item") {
|
||||
expect(n.route.parent).toBeUndefined(); // 顶层项不该带 parent
|
||||
} else {
|
||||
expect(n.items.length).toBeGreaterThan(0);
|
||||
// 同一个二级菜单下的子项,parent 必须都等于该菜单名
|
||||
for (const r of n.items) expect(r.parent).toBe(n.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("同 parent 的多条路由只生成一个二级菜单节点", () => {
|
||||
for (const g of navGroups()) {
|
||||
const labels = g.nodes.filter((n) => n.kind === "parent").map((n) => (n as Extract<NavNode, { kind: "parent" }>).label);
|
||||
expect(new Set(labels).size).toBe(labels.length);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+115
-42
@@ -1,3 +1,7 @@
|
||||
import { SubscriptionPage } from "./pages/SubscriptionPage";
|
||||
import { LoginConfigPage } from "./pages/LoginConfigPage";
|
||||
import { VoiceConfigPage } from "./pages/VoiceConfigPage";
|
||||
import { WechatUsersPage } from "./pages/WechatUsersPage";
|
||||
import { lazy, type ReactNode } from "react";
|
||||
|
||||
// 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。
|
||||
@@ -5,90 +9,152 @@ import { lazy, type ReactNode } from "react";
|
||||
|
||||
|
||||
const DashboardPage = lazy(() => import("./pages/DashboardPage").then((m) => ({ default: m.DashboardPage })));
|
||||
const UsagePage = lazy(() => import("./pages/UsagePage").then((m) => ({ default: m.UsagePage })));
|
||||
const ModelsPage = lazy(() => import("./pages/ModelsPage").then((m) => ({ default: m.ModelsPage })));
|
||||
const ModelConfigPage = lazy(() => import("./pages/ModelConfigPage").then((m) => ({ default: m.ModelConfigPage })));
|
||||
const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage })));
|
||||
const StatusPage = lazy(() => import("./pages/StatusPage").then((m) => ({ default: m.StatusPage })));
|
||||
const TasksPage = lazy(() => import("./pages/TasksPage").then((m) => ({ default: m.TasksPage })));
|
||||
const EvalsPage = lazy(() => import("./pages/EvalsPage").then((m) => ({ default: m.EvalsPage })));
|
||||
const TenantsPage = lazy(() => import("./pages/TenantsPage").then((m) => ({ default: m.TenantsPage })));
|
||||
const GuardrailsPage = lazy(() => import("./pages/GuardrailsPage").then((m) => ({ default: m.GuardrailsPage })));
|
||||
const PromptsPage = lazy(() => import("./pages/PromptsPage").then((m) => ({ default: m.PromptsPage })));
|
||||
const AuditPage = lazy(() => import("./pages/AuditPage").then((m) => ({ default: m.AuditPage })));
|
||||
const PaymentConfigPage = lazy(() => import("./pages/PaymentConfigPage").then((m) => ({ default: m.PaymentConfigPage })));
|
||||
const PaymentOrdersPage = lazy(() => import("./pages/PaymentOrdersPage").then((m) => ({ default: m.PaymentOrdersPage })));
|
||||
const SpacesPage = lazy(() => import("./pages/SpacesPage").then((m) => ({ default: m.SpacesPage })));
|
||||
|
||||
export interface RouteDef {
|
||||
path: string;
|
||||
label: string;
|
||||
group: string;
|
||||
/** 二级菜单名。同 group 内 parent 相同的若干条会聚成一个可展开子菜单(如 运维 > 支付 > 配置/订单与对账)。 */
|
||||
parent?: string;
|
||||
ready?: boolean;
|
||||
element: ReactNode;
|
||||
}
|
||||
|
||||
/** 侧栏一个节点:要么是单条路由,要么是带子项的可展开二级菜单。 */
|
||||
export type NavNode =
|
||||
| { kind: "item"; route: RouteDef }
|
||||
| { kind: "parent"; label: string; items: RouteDef[] };
|
||||
|
||||
export const routes: RouteDef[] = [
|
||||
{
|
||||
path: "/dashboard",
|
||||
label: "概览",
|
||||
path: "dashboard",
|
||||
label: "仪表盘",
|
||||
group: "分析",
|
||||
ready: true,
|
||||
element: <DashboardPage />,
|
||||
},
|
||||
{
|
||||
path: "/usage",
|
||||
label: "计费 & 用量",
|
||||
group: "分析",
|
||||
ready: true,
|
||||
element: <UsagePage />,
|
||||
},
|
||||
{
|
||||
path: "/models",
|
||||
label: "模型",
|
||||
group: "配置",
|
||||
ready: true,
|
||||
element: <ModelsPage />,
|
||||
},
|
||||
{
|
||||
path: "/datasources",
|
||||
label: "数据源 & RAG",
|
||||
group: "配置",
|
||||
ready: true,
|
||||
element: <DatasourcesPage />,
|
||||
},
|
||||
{
|
||||
path: "/prompts",
|
||||
label: "提示词",
|
||||
group: "配置",
|
||||
ready: true,
|
||||
element: <PromptsPage />,
|
||||
},
|
||||
{
|
||||
path: "/status",
|
||||
path: "status",
|
||||
label: "服务状态",
|
||||
group: "运维",
|
||||
ready: true,
|
||||
element: <StatusPage />,
|
||||
},
|
||||
{
|
||||
path: "/evals",
|
||||
path: "tasks",
|
||||
label: "任务观测",
|
||||
group: "运维",
|
||||
ready: true,
|
||||
element: <TasksPage />,
|
||||
},
|
||||
{
|
||||
path: "evals",
|
||||
label: "自动评测",
|
||||
group: "运维",
|
||||
ready: true,
|
||||
element: <EvalsPage />,
|
||||
},
|
||||
{
|
||||
path: "/audit",
|
||||
path: "audit",
|
||||
label: "审计 & 安全",
|
||||
group: "运维",
|
||||
ready: true,
|
||||
element: <AuditPage />,
|
||||
},
|
||||
{
|
||||
path: "/tenants",
|
||||
path: "models",
|
||||
label: "模型配置",
|
||||
group: "运维",
|
||||
ready: true,
|
||||
element: <ModelConfigPage />,
|
||||
},
|
||||
{
|
||||
path: "login-config",
|
||||
label: "登录设置",
|
||||
group: "运维",
|
||||
ready: true,
|
||||
element: <LoginConfigPage />,
|
||||
},
|
||||
{
|
||||
path: "voice-config",
|
||||
label: "语音设置",
|
||||
group: "运维",
|
||||
ready: true,
|
||||
element: <VoiceConfigPage />,
|
||||
},
|
||||
{
|
||||
path: "datasources",
|
||||
label: "数据源 & RAG",
|
||||
group: "运维",
|
||||
ready: true,
|
||||
element: <DatasourcesPage />,
|
||||
},
|
||||
{
|
||||
path: "prompts",
|
||||
label: "提示词",
|
||||
group: "运维",
|
||||
ready: true,
|
||||
element: <PromptsPage />,
|
||||
},
|
||||
{
|
||||
path: "payment/config",
|
||||
label: "配置",
|
||||
group: "运维",
|
||||
parent: "支付",
|
||||
ready: true,
|
||||
element: <PaymentConfigPage />,
|
||||
},
|
||||
{
|
||||
path: "payment/subscription",
|
||||
label: "订阅",
|
||||
group: "运维",
|
||||
parent: "支付",
|
||||
ready: true,
|
||||
element: <SubscriptionPage />,
|
||||
},
|
||||
{
|
||||
path: "payment/orders",
|
||||
label: "订单与对账",
|
||||
group: "运维",
|
||||
parent: "支付",
|
||||
ready: true,
|
||||
element: <PaymentOrdersPage />,
|
||||
},
|
||||
{
|
||||
path: "tenants",
|
||||
label: "租户 & 用户",
|
||||
group: "平台",
|
||||
ready: true,
|
||||
element: <TenantsPage />,
|
||||
},
|
||||
{
|
||||
path: "/guardrails",
|
||||
path: "wechat-users",
|
||||
label: "微信用户",
|
||||
group: "平台",
|
||||
ready: true,
|
||||
element: <WechatUsersPage />,
|
||||
},
|
||||
{
|
||||
path: "spaces",
|
||||
label: "空间",
|
||||
group: "平台",
|
||||
ready: true,
|
||||
element: <SpacesPage />,
|
||||
},
|
||||
{
|
||||
path: "guardrails",
|
||||
label: "安全护栏",
|
||||
group: "平台",
|
||||
ready: true,
|
||||
@@ -96,18 +162,25 @@ export const routes: RouteDef[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultPath = "/dashboard";
|
||||
export const defaultPath = "dashboard";
|
||||
|
||||
// 派生分组导航(保持注册顺序)。
|
||||
export function navGroups(): Array<{ group: string; items: RouteDef[] }> {
|
||||
const out: Array<{ group: string; items: RouteDef[] }> = [];
|
||||
export function navGroups(): Array<{ group: string; nodes: NavNode[] }> {
|
||||
const out: Array<{ group: string; nodes: NavNode[] }> = [];
|
||||
for (const r of routes) {
|
||||
let g = out.find((x) => x.group === r.group);
|
||||
if (!g) {
|
||||
g = { group: r.group, items: [] };
|
||||
g = { group: r.group, nodes: [] };
|
||||
out.push(g);
|
||||
}
|
||||
g.items.push(r);
|
||||
if (!r.parent) {
|
||||
g.nodes.push({ kind: "item", route: r });
|
||||
continue;
|
||||
}
|
||||
// 同 parent 的归到同一个可展开节点(首次出现决定它在组内的位置)。
|
||||
const exist = g.nodes.find((n): n is Extract<NavNode, { kind: "parent" }> => n.kind === "parent" && n.label === r.parent);
|
||||
if (exist) exist.items.push(r);
|
||||
else g.nodes.push({ kind: "parent", label: r.parent, items: [r] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,65 +1,229 @@
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import React, { Suspense, useEffect, useState } from "react";
|
||||
import { NavLink, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { routes, navGroups, defaultPath } from "../routes";
|
||||
import { gatewayOnline, type AuthUser } from "../api";
|
||||
import { routes, navGroups, defaultPath, type RouteDef } from "../routes";
|
||||
import { getStatus, type AuthUser } from "../api";
|
||||
|
||||
const ADMIN_BASE = "/admin";
|
||||
|
||||
// 路线路径到 SVG 图标的映射
|
||||
const ICON_MAP: Record<string, React.ReactNode> = {
|
||||
dashboard: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v10a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
),
|
||||
models: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
datasources: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
),
|
||||
prompts: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
),
|
||||
status: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2z" />
|
||||
</svg>
|
||||
),
|
||||
tasks: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
),
|
||||
evals: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
audit: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
),
|
||||
"login-config": (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
),
|
||||
"voice-config": (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
),
|
||||
"wechat-users": (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
),
|
||||
tenants: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
spaces: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
guardrails: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
),
|
||||
"payment/config": (
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
"payment/orders": (
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 14l2 2 4-4M7 21h10a2 2 0 002-2V7l-4-4H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
"payment/subscription": (
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// 二级菜单(parent)自己的图标,按 parent 名索引。
|
||||
const PARENT_ICON: Record<string, React.ReactNode> = {
|
||||
支付: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// 控制台外壳:导航与内容均由路由注册表派生(动态路由)。
|
||||
export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
|
||||
const [online, setOnline] = useState(false);
|
||||
// 健康状态对象
|
||||
const [health, setHealth] = useState({
|
||||
gateway: false,
|
||||
dispatcher: false,
|
||||
postgres: false,
|
||||
redis: false,
|
||||
nats: false,
|
||||
});
|
||||
|
||||
const loc = useLocation();
|
||||
const current = routes.find((r) => r.path === loc.pathname);
|
||||
const current = routes.find((r) => `${ADMIN_BASE}/${r.path}` === loc.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
const ping = () => gatewayOnline().then(setOnline);
|
||||
const ping = () => {
|
||||
getStatus()
|
||||
.then((res) => {
|
||||
const pgUp = res.infra?.find((x) => x.name === "postgres")?.up ?? false;
|
||||
const redisUp = res.infra?.find((x) => x.name === "redis")?.up ?? false;
|
||||
const natsUp = res.infra?.find((x) => x.name === "nats")?.up ?? false;
|
||||
const dispatcherUp = res.services?.find((x) => x.name === "dispatcher")?.up ?? false;
|
||||
|
||||
setHealth({
|
||||
gateway: true, // 能获取到 status 意味着 Gateway 必然在线
|
||||
dispatcher: dispatcherUp,
|
||||
postgres: pgUp,
|
||||
redis: redisUp,
|
||||
nats: natsUp,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// 获取失败,全部下线
|
||||
setHealth({
|
||||
gateway: false,
|
||||
dispatcher: false,
|
||||
postgres: false,
|
||||
redis: false,
|
||||
nats: false,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
ping();
|
||||
const id = setInterval(ping, 4000);
|
||||
const id = setInterval(ping, 5000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen text-gray-900">
|
||||
<aside className="flex w-56 shrink-0 flex-col border-r bg-gray-50">
|
||||
<div className="border-b p-4">
|
||||
<div className="text-sm font-bold text-gray-800">sundynix-agentix</div>
|
||||
<div className="text-[11px] text-gray-400">运维控制台</div>
|
||||
<div className="flex h-screen w-screen text-gray-900 bg-gray-50/20">
|
||||
<aside className="flex w-56 shrink-0 flex-col border-r border-gray-150 bg-gray-55/40">
|
||||
<div className="border-b border-gray-100 p-5">
|
||||
<div className="text-sm font-bold text-gray-800 tracking-tight">sundynix-agentix</div>
|
||||
<div className="text-[10px] text-gray-400 font-medium">运维管理控制台</div>
|
||||
</div>
|
||||
<nav className="flex flex-col gap-1 p-2">
|
||||
|
||||
<nav className="flex-1 flex flex-col gap-1.5 p-3 overflow-y-auto">
|
||||
{navGroups().map((g) => (
|
||||
<div key={g.group}>
|
||||
<div className="mt-2 px-3 text-[9px] font-semibold tracking-wider text-gray-300">{g.group}</div>
|
||||
{g.items.map((r) => (
|
||||
<NavLink
|
||||
key={r.path}
|
||||
to={r.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center justify-between rounded px-3 py-2 text-sm ${
|
||||
isActive ? "bg-violet-50 font-medium text-violet-700" : "text-gray-600 hover:bg-gray-100"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{r.label}
|
||||
{!r.ready && <span className="text-[9px] text-gray-300">规划</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
<div key={g.group} className="space-y-1">
|
||||
<div className="px-3 py-1 text-[9px] font-bold uppercase tracking-wider text-gray-400">{g.group}</div>
|
||||
{g.nodes.map((n) =>
|
||||
n.kind === "item" ? (
|
||||
<NavItem key={n.route.path} route={n.route} />
|
||||
) : (
|
||||
<NavParent key={n.label} label={n.label} items={n.items} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-auto border-t p-4 text-[11px] text-gray-500">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="truncate text-gray-600" title={user.email}>{user.name || user.email}</span>
|
||||
<button onClick={onLogout} className="text-gray-400 hover:text-rose-600">登出</button>
|
||||
|
||||
<div className="border-t border-gray-100 p-4 text-xs text-gray-500 bg-white/50 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="truncate text-gray-700 font-semibold" title={user.email}>{user.name || user.email}</span>
|
||||
<button onClick={onLogout} className="text-gray-400 hover:text-rose-600 font-semibold transition">登出</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${online ? "bg-emerald-500" : "bg-rose-500"}`} />
|
||||
Gateway {online ? "在线" : "离线"}
|
||||
|
||||
{/* 健康度指示栏 */}
|
||||
<div className="pt-2 border-t border-gray-100/50">
|
||||
<div className="mb-1 text-[10px] font-bold text-gray-400 uppercase tracking-wide">系统服务健康度</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Gateway dot */}
|
||||
<span
|
||||
title={`Gateway: ${health.gateway ? "在线" : "离线"}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.gateway ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
|
||||
/>
|
||||
{/* Dispatcher dot */}
|
||||
<span
|
||||
title={`Dispatcher: ${health.dispatcher ? "在线" : "离线"}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.dispatcher ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
|
||||
/>
|
||||
{/* Postgres dot */}
|
||||
<span
|
||||
title={`Postgres: ${health.postgres ? "在线" : "离线"}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.postgres ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
|
||||
/>
|
||||
{/* Redis dot */}
|
||||
<span
|
||||
title={`Redis: ${health.redis ? "在线" : "离线"}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.redis ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
|
||||
/>
|
||||
{/* NATS dot */}
|
||||
<span
|
||||
title={`NATS: ${health.nats ? "在线" : "离线"}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.nats ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-400 font-medium">
|
||||
{health.gateway && health.dispatcher && health.postgres && health.redis && health.nats ? "正常" : "部分受损"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<h1 className="mb-4 text-lg font-semibold text-gray-800">{current?.label ?? ""}</h1>
|
||||
<Suspense fallback={<div className="text-sm text-gray-400">加载中…</div>}>
|
||||
<main className="flex-1 overflow-auto p-6 bg-white/70">
|
||||
<h1 className="mb-4 text-base font-bold text-gray-800 tracking-tight">{current?.label ?? ""}</h1>
|
||||
<Suspense fallback={<div className="text-xs text-gray-400 animate-pulse">页面加载中…</div>}>
|
||||
<Routes>
|
||||
{routes.map((r) => (
|
||||
<Route key={r.path} path={r.path} element={r.element} />
|
||||
@@ -71,3 +235,66 @@ export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => v
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 单条导航项(顶层或二级子项共用)。
|
||||
function NavItem({ route: r }: { route: RouteDef }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={`${ADMIN_BASE}/${r.path}`}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 rounded-lg px-3 py-2 text-xs font-semibold transition-all ${
|
||||
isActive
|
||||
? "bg-violet-600 text-white shadow-sm shadow-violet-100"
|
||||
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="shrink-0">{ICON_MAP[r.path]}</span>
|
||||
<span className="flex-1 truncate">{r.label}</span>
|
||||
{!r.ready && <span className="rounded bg-gray-100 px-1 text-[8px] text-gray-400">规划</span>}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
// 可展开的二级菜单(如 运维 > 支付 > 配置 / 订单与对账)。
|
||||
// 子项命中时自动展开;父项本身不可导航,只负责收起/展开。
|
||||
function NavParent({ label, items }: { label: string; items: RouteDef[] }) {
|
||||
const loc = useLocation();
|
||||
const hasActive = items.some((r) => loc.pathname === `${ADMIN_BASE}/${r.path}`);
|
||||
const [open, setOpen] = useState(hasActive);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasActive) setOpen(true);
|
||||
}, [hasActive]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-xs font-semibold transition-all ${
|
||||
hasActive ? "text-violet-700" : "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0">{PARENT_ICON[label]}</span>
|
||||
<span className="flex-1 truncate text-left">{label}</span>
|
||||
<svg
|
||||
className={`h-3 w-3 shrink-0 text-gray-400 transition-transform ${open ? "rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ml-4 mt-1 space-y-1 border-l border-gray-200 pl-2">
|
||||
{items.map((r) => (
|
||||
<NavItem key={r.path} route={r} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { DOWNLOADS } from '@/content/site'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function DownloadGrid() {
|
||||
return (
|
||||
<div className="mx-auto grid max-w-[780px] grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{DOWNLOADS.map((d) => (
|
||||
<div
|
||||
key={d.os}
|
||||
className="rounded-xl border border-hairline bg-surface p-6 text-center transition-colors hover:border-accent"
|
||||
>
|
||||
<div className="mb-1 text-[15.5px] font-semibold">{d.os}</div>
|
||||
<div className="mb-4 font-mono text-[11.5px] text-ink-3">
|
||||
{d.meta}
|
||||
</div>
|
||||
<a
|
||||
href={d.href}
|
||||
className={cn(
|
||||
'inline-block rounded-[7px] px-5 py-1.5 text-[13.5px] font-medium transition-opacity hover:opacity-90',
|
||||
d.highlight
|
||||
? 'bg-accent text-ground'
|
||||
: 'border border-hairline-strong text-accent-ink',
|
||||
)}
|
||||
>
|
||||
{d.action}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { SITE } from '@/content/site'
|
||||
import { LogoMark } from '@/components/logo'
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
title: 'PRODUCT',
|
||||
links: [
|
||||
{ label: '功能', href: '/#features' },
|
||||
{ label: '架构', href: '/#architecture' },
|
||||
{ label: '下载', href: '/download', internal: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'RESOURCES',
|
||||
links: [
|
||||
{ label: '下载桌面版', href: '/download', internal: true },
|
||||
{ label: '源码仓库', href: SITE.repo },
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-hairline">
|
||||
<div className="mx-auto grid max-w-6xl gap-10 px-6 py-12 md:grid-cols-[1.5fr_1fr_1fr] lg:px-10">
|
||||
<div>
|
||||
<p className="flex items-center gap-2.5 font-mono text-[15px] font-semibold">
|
||||
<LogoMark size={30} />
|
||||
sundynix <em className="not-italic text-accent">agentix</em>
|
||||
</p>
|
||||
<p className="mt-2 text-[14px] text-ink-2">{SITE.tagline}</p>
|
||||
<p className="mt-4 font-mono text-[11.5px] tracking-[0.08em] text-ink-3">
|
||||
{SITE.version} · MACOS / WINDOWS / SELF-HOSTED
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{COLUMNS.map((col) => (
|
||||
<nav key={col.title} aria-label={col.title}>
|
||||
<p className="mb-3.5 font-mono text-[11px] tracking-[0.16em] text-ink-3">
|
||||
{col.title}
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
{col.links.map((link) => (
|
||||
<li key={link.label}>
|
||||
{'internal' in link && link.internal ? (
|
||||
<Link
|
||||
to={link.href}
|
||||
className="text-[13.5px] text-ink-2 transition-colors hover:text-accent-ink"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
) : (
|
||||
<a
|
||||
href={link.href}
|
||||
target={link.href.startsWith('http') ? '_blank' : undefined}
|
||||
rel={link.href.startsWith('http') ? 'noreferrer' : undefined}
|
||||
className="text-[13.5px] text-ink-2 transition-colors hover:text-accent-ink"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-hairline">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-6 gap-y-2 px-6 py-5 font-mono text-[12px] text-ink-3 lg:px-10">
|
||||
<span>© 2026 SUNDYNIX AGENTIX</span>
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="transition-colors hover:text-accent-ink"
|
||||
>
|
||||
{SITE.icp}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, NavLink } from 'react-router-dom'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
import { LogoMark } from '@/components/logo'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// 官网默认浅色,不搬深浅切换(ThemeProvider 未挂载)。
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: '产品', to: '/', end: true },
|
||||
{ label: '功能', to: '/#features' },
|
||||
{ label: '架构', to: '/#architecture' },
|
||||
{ label: '定价', to: '/pricing' },
|
||||
]
|
||||
|
||||
export function SiteHeader() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const links = NAV_ITEMS.map((item) =>
|
||||
item.to.includes('#') ? (
|
||||
<a
|
||||
key={item.to}
|
||||
href={item.to}
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-sm text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
) : (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={() => setOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'text-sm transition-colors hover:text-ink',
|
||||
isActive ? 'text-ink' : 'text-ink-2',
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<header className="bg-ground-85 sticky top-0 z-40 border-b border-hairline backdrop-blur">
|
||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-6 lg:px-10">
|
||||
<Link to="/" className="flex items-center gap-2.5 font-mono text-[15px] font-semibold">
|
||||
<LogoMark size={26} />
|
||||
sundynix <em className="not-italic text-accent">agentix</em>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-7 md:flex">
|
||||
{links}
|
||||
<Link
|
||||
to="/download"
|
||||
className="rounded-[7px] bg-accent px-4 py-1.5 text-[13px] font-medium text-ground transition-opacity hover:opacity-90"
|
||||
>
|
||||
下载桌面版
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={open ? '关闭菜单' : '打开菜单'}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex size-8 items-center justify-center text-ink-2"
|
||||
>
|
||||
{open ? <X className="size-5" /> : <Menu className="size-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<nav className="flex flex-col gap-4 border-t border-hairline px-6 py-5 md:hidden">
|
||||
{links}
|
||||
<Link
|
||||
to="/download"
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-fit rounded-[7px] bg-accent px-4 py-1.5 text-[13px] font-medium text-ground"
|
||||
>
|
||||
下载桌面版
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
import { SiteHeader } from '@/components/layout/site-header'
|
||||
import { SiteFooter } from '@/components/layout/site-footer'
|
||||
|
||||
export function SiteLayout() {
|
||||
const { pathname } = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0)
|
||||
}, [pathname])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-ground font-sans text-ink antialiased">
|
||||
<SiteHeader />
|
||||
<main className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
interface LogoMarkProps {
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 品牌 Logo:霓虹数据流 S(用户原版位图裁切,源图在 design-assets/) */
|
||||
export function LogoMark({ size = 28, className }: LogoMarkProps) {
|
||||
return (
|
||||
<img
|
||||
src="/brand/logo-mark.webp"
|
||||
width={size}
|
||||
height={size}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
style={{ borderRadius: '22%' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
import { createOrder, orderStatus } from '../lib/site-api'
|
||||
|
||||
// 官网购买支付弹窗:下单 → 微信 code_url 画二维码 → 轮询单态。
|
||||
// 复用后端支付链路(下单/回调/查单/掉单补偿全共用),与桌面端/联调台同一套。
|
||||
const POLL_MS = 2500
|
||||
|
||||
type Phase = 'creating' | 'waiting' | 'paid' | 'expired'
|
||||
|
||||
export function PayDialog({
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
item: { kind: 'sub' | 'pack'; id: string; name: string; priceFen: number }
|
||||
onClose: (paid: boolean) => void
|
||||
}) {
|
||||
const [qr, setQr] = useState('')
|
||||
const [phase, setPhase] = useState<Phase>('creating')
|
||||
const [err, setErr] = useState('')
|
||||
const [warn, setWarn] = useState('')
|
||||
const orderRef = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
let timer: number | null = null
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const o = await createOrder(item.kind === 'sub' ? { planId: item.id } : { packId: item.id })
|
||||
if (!alive) return
|
||||
orderRef.current = o.order_id
|
||||
setQr(await QRCode.toDataURL(o.code_url, { width: 220, margin: 1 }))
|
||||
setPhase('waiting')
|
||||
const tick = async () => {
|
||||
if (!alive) return
|
||||
try {
|
||||
const s = await orderStatus(orderRef.current)
|
||||
if (!alive) return
|
||||
setWarn(s.warn ?? '')
|
||||
if (s.status === 'paid') {
|
||||
setPhase('paid')
|
||||
window.setTimeout(() => onClose(true), 900)
|
||||
return
|
||||
}
|
||||
if (s.status === 'expired' || s.status === 'failed') {
|
||||
setPhase('expired')
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
/* 单次失败忽略 */
|
||||
}
|
||||
timer = window.setTimeout(() => void tick(), POLL_MS)
|
||||
}
|
||||
timer = window.setTimeout(() => void tick(), POLL_MS)
|
||||
} catch (e) {
|
||||
if (alive) setErr((e as Error).message)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
if (timer) window.clearTimeout(timer)
|
||||
}
|
||||
}, [item, onClose])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => onClose(phase === 'paid')}>
|
||||
<div className="w-full max-w-sm rounded-2xl border border-hairline bg-surface p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-base font-semibold text-ink">微信支付 · {item.name}</h3>
|
||||
<button onClick={() => onClose(phase === 'paid')} className="text-sm text-ink-3 hover:text-ink">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col items-center gap-3">
|
||||
{err ? (
|
||||
<p className="py-10 text-center text-sm text-red-500">{err}</p>
|
||||
) : phase === 'creating' ? (
|
||||
<p className="py-16 text-sm text-ink-3">正在生成支付二维码…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative rounded-xl bg-white p-3">
|
||||
<img src={qr} alt="微信支付二维码" width={220} height={220} />
|
||||
{(phase === 'paid' || phase === 'expired') && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-white/85 text-sm font-medium text-ink">
|
||||
{phase === 'paid' ? '✅ 支付成功' : '二维码已失效'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold tabular-nums text-ink">¥{(item.priceFen / 100).toFixed(2)}</div>
|
||||
{phase === 'waiting' && <div className="mt-0.5 text-xs text-ink-3">微信扫一扫完成支付</div>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{warn && <p className="w-full rounded-lg bg-amber-50 px-3 py-2 text-center text-xs text-amber-700">{warn}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface RevealProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/** 进场延迟(毫秒),用于同屏元素错峰 */
|
||||
delay?: number
|
||||
}
|
||||
|
||||
/** 滚动进场:进入视口后淡入上移一次;prefers-reduced-motion 时直接显示 */
|
||||
export function Reveal({ children, className, delay = 0 }: RevealProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setVisible(true)
|
||||
return
|
||||
}
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true)
|
||||
io.disconnect()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15, rootMargin: '0px 0px -40px 0px' },
|
||||
)
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={delay ? { transitionDelay: `${delay}ms` } : undefined}
|
||||
className={cn('reveal', visible && 'reveal-in', className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<span className="mb-2.5 block font-mono text-[11px] tracking-[0.18em] text-accent">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { TERMINAL_DEMO } from '@/content/site'
|
||||
|
||||
/** Hero 事件流终端窗 — 双主题共用深色 */
|
||||
export function TerminalDemo() {
|
||||
return (
|
||||
<div className="mx-auto mt-13 max-w-[720px] overflow-hidden rounded-t-xl border border-b-0 border-hairline-strong bg-term text-left">
|
||||
<div className="flex items-center gap-2 border-b border-white/[0.07] px-4 py-2.5">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span key={i} className="size-2.5 rounded-full bg-white/[0.18]" />
|
||||
))}
|
||||
<span className="ml-2.5 font-mono text-[11.5px] tracking-[0.1em] text-white/40">
|
||||
{TERMINAL_DEMO.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto px-5 pb-6 pt-5 font-mono text-[13.5px] leading-[2.05] text-term-ink">
|
||||
<div className="term-line">
|
||||
<span className="font-semibold text-[#22d3ee]">› </span>
|
||||
{TERMINAL_DEMO.prompt}
|
||||
</div>
|
||||
<div className="term-line text-white/35" style={{ animationDelay: '0.35s' }}>
|
||||
──────────────────────────────────
|
||||
</div>
|
||||
{TERMINAL_DEMO.events.map((ev, i) => (
|
||||
<div
|
||||
key={ev.tag}
|
||||
className="term-line whitespace-nowrap"
|
||||
style={{ animationDelay: `${0.7 + i * 0.45}s` }}
|
||||
>
|
||||
<span className={ev.ok ? 'text-[#a6d96a]' : 'text-[#60a5fa]'}>
|
||||
[{ev.tag}]
|
||||
</span>{' '}
|
||||
{ev.text}
|
||||
{i === TERMINAL_DEMO.events.length - 1 && (
|
||||
<span className="caret-blink ml-1 inline-block h-[15px] w-2 translate-y-0.5 bg-[#22d3ee]" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { wxTicket, wxPoll, type SiteUser } from '../lib/site-api'
|
||||
|
||||
// 官网微信扫码登录弹窗:建票拿微信二维码图 → 展示 → 轮询登录态。
|
||||
// 扫码后弹公众号关注页,关注即登录(带参二维码 + 关注/扫码事件)。
|
||||
export function WechatLoginDialog({ onClose, onLoggedIn }: { onClose: () => void; onLoggedIn: (u: SiteUser) => void }) {
|
||||
const [qr, setQr] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [expired, setExpired] = useState(false)
|
||||
const [nonce, setNonce] = useState(0)
|
||||
const ticketRef = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
let timer: number | null = null
|
||||
setErr('')
|
||||
setExpired(false)
|
||||
setQr('')
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const t = await wxTicket()
|
||||
if (!alive) return
|
||||
ticketRef.current = t.ticket
|
||||
setQr(t.qr_image)
|
||||
const deadline = Date.now() + t.expires_in * 1000
|
||||
const tick = async () => {
|
||||
if (!alive) return
|
||||
if (Date.now() > deadline) {
|
||||
setExpired(true)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await wxPoll(ticketRef.current)
|
||||
if (!alive) return
|
||||
if (r.status === 'authorized' && r.user) {
|
||||
onLoggedIn(r.user)
|
||||
return
|
||||
}
|
||||
if (r.status === 'expired') {
|
||||
setExpired(true)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
/* 单次失败忽略,继续轮询 */
|
||||
}
|
||||
timer = window.setTimeout(() => void tick(), 2000)
|
||||
}
|
||||
timer = window.setTimeout(() => void tick(), 2000)
|
||||
} catch (e) {
|
||||
if (alive) setErr((e as Error).message)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
if (timer) window.clearTimeout(timer)
|
||||
}
|
||||
}, [nonce, onLoggedIn])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={onClose}>
|
||||
<div className="w-full max-w-sm rounded-2xl border border-hairline bg-surface p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-base font-semibold text-ink">微信扫码登录</h3>
|
||||
<button onClick={onClose} className="text-sm text-ink-3 hover:text-ink">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col items-center gap-3">
|
||||
{err ? (
|
||||
<p className="py-10 text-center text-sm text-red-500">{err}</p>
|
||||
) : qr ? (
|
||||
<div className="relative rounded-xl bg-white p-3">
|
||||
<img src={qr} alt="微信登录二维码" width={220} height={220} />
|
||||
{expired && (
|
||||
<button
|
||||
onClick={() => setNonce((n) => n + 1)}
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-1 rounded-xl bg-black/70 text-sm text-white"
|
||||
>
|
||||
二维码已过期
|
||||
<span className="rounded bg-white/20 px-2 py-1 text-xs">点击刷新</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-16 text-sm text-ink-3">正在生成二维码…</p>
|
||||
)}
|
||||
<p className="text-center text-xs text-ink-3">微信扫一扫,关注公众号即可登录</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/** 站点文案与数据,改这里即可,不用动组件 */
|
||||
|
||||
export const SITE = {
|
||||
/** 静态回退版本号;线上以 /api/releases/latest 为准 */
|
||||
version: 'v0.1.2',
|
||||
tagline: '事件驱动的 AI Agent 工作台',
|
||||
repo: 'https://git.sundynix.cn/sundynix/sundynix-agentix',
|
||||
icp: '滇ICP备2025056308号-1',
|
||||
}
|
||||
|
||||
export const TERMINAL_DEMO = {
|
||||
title: 'SUNDYNIX.STREAMS.RT-7F2A',
|
||||
prompt: '帮我调研「2026 国产大模型推理成本」,出一份带图表的报告',
|
||||
events: [
|
||||
{
|
||||
tag: 'coordinator',
|
||||
text: '已拆解为 3 个子任务,派发 researcher × 2 · writer × 1',
|
||||
},
|
||||
{
|
||||
tag: 'knowledge',
|
||||
text: '三路混合检索 · vector + fulltext + graph · RRF 融合',
|
||||
},
|
||||
{ tag: 'harness', text: '输出守卫通过 · 预算 ¥2.00 剩余 ¥1.37' },
|
||||
{
|
||||
tag: 'report',
|
||||
text: '大纲 5 章已确认,并行撰写中 → 推理成本分析.docx',
|
||||
ok: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const FEATURES = [
|
||||
{
|
||||
label: 'STUDIO',
|
||||
title: '可视化编排画布',
|
||||
desc: 'React Flow 画布拖出工作流,导出 JSON DSL 直接执行;分支路由、map 并行扇出、⌘K 命令面板。',
|
||||
},
|
||||
{
|
||||
label: 'MULTI-AGENT',
|
||||
title: '多智能体协作',
|
||||
desc: 'Orchestrator–Worker 模式:主脑撰写任务简报,并行派发专家 Agent,团队视图实时看它们「上班」。',
|
||||
},
|
||||
{
|
||||
label: 'KNOWLEDGE',
|
||||
title: '三路混合检索知识库',
|
||||
desc: '向量 + 全文 + 知识图谱三路召回,RRF 融合重排;双链笔记、反向链接与关系图谱,检索过程可调试。',
|
||||
},
|
||||
{
|
||||
label: 'REPORT',
|
||||
title: '真 · Word 报告生成',
|
||||
desc: '选题 → 大纲规划 → 分章并行研究撰写,产出原生 .docx(零依赖 OOXML 渲染),另有 PDF / Markdown。',
|
||||
},
|
||||
{
|
||||
label: 'MEMORY',
|
||||
title: '长期记忆',
|
||||
desc: '异步批量固化(ADD/UPDATE/DELETE),近因 × 重要性衰减打分,记忆面板随时可查可改。',
|
||||
},
|
||||
{
|
||||
label: 'HARNESS',
|
||||
title: '可靠性套件',
|
||||
desc: '提示注入拦截、流式密钥脱敏、熔断器、LLM 评审自动纠偏、成本预算上限——生产级守卫全内置。',
|
||||
},
|
||||
{
|
||||
label: 'SANDBOX',
|
||||
title: '代码解释器沙箱',
|
||||
desc: 'AST 静态审查 + Docker 隔离:无网络、非 root、只读根文件系统,让 Agent 放心跑代码。',
|
||||
},
|
||||
{
|
||||
label: 'STREAMING',
|
||||
title: '一切皆流式',
|
||||
desc: 'NATS 零拷贝骨干网直推 SSE/WS,节点级执行轨迹实时可见,Prometheus + OTel 全链路观测。',
|
||||
},
|
||||
]
|
||||
|
||||
export const ARCH_LAYERS = [
|
||||
{
|
||||
id: 'L1 · CLIENT',
|
||||
name: '桌面工作台',
|
||||
detail: '· Web / 管理控制台',
|
||||
tech: 'wails3 · react19',
|
||||
},
|
||||
{
|
||||
id: 'L2 · GATEWAY',
|
||||
name: 'API 网关',
|
||||
detail: '· 鉴权 / DSL 解析 / 计费 / 守卫',
|
||||
tech: 'gin · postgres · redis',
|
||||
},
|
||||
{
|
||||
id: 'L4 · DISPATCHER',
|
||||
name: '图执行引擎',
|
||||
detail: '· Agent Loop / LLM Pool',
|
||||
tech: 'eino · openai-compat',
|
||||
},
|
||||
{
|
||||
id: 'L5 · TOOLS',
|
||||
name: 'MCP 工具层',
|
||||
detail: '· 检索 / 解析 / 沙箱',
|
||||
tech: 'milvus · bleve · neo4j · py3.11',
|
||||
},
|
||||
]
|
||||
|
||||
export const QUICKSTART_STEPS = [
|
||||
{
|
||||
title: '下载或克隆。',
|
||||
desc: '桌面版开箱即用;自部署走 docker compose。',
|
||||
},
|
||||
{
|
||||
title: '接入模型。',
|
||||
desc: '任何 OpenAI 兼容端点:DeepSeek、百炼……填 Key 即可。',
|
||||
},
|
||||
{
|
||||
title: '喂知识,派任务。',
|
||||
desc: '拖入文档建库,画布编排或直接对话,看团队开工。',
|
||||
},
|
||||
]
|
||||
|
||||
export const DOWNLOADS = [
|
||||
{
|
||||
os: 'macOS',
|
||||
meta: 'universal · .app · v0.1.2',
|
||||
action: '下载 .dmg',
|
||||
href: `${SITE.repo}/releases`,
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
os: 'Windows',
|
||||
meta: 'x64 · .exe · v0.1.2',
|
||||
action: '下载 .exe',
|
||||
href: `${SITE.repo}/releases`,
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
os: '自托管',
|
||||
meta: 'docker compose · 全平台',
|
||||
action: '部署文档',
|
||||
href: SITE.repo,
|
||||
highlight: false,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const BASE = 'sundynix agentix'
|
||||
const DEFAULT = `${BASE} — 事件驱动的 AI Agent 工作台`
|
||||
|
||||
/** 设置页面标题;不传参恢复默认 */
|
||||
export function usePageTitle(title?: string) {
|
||||
useEffect(() => {
|
||||
document.title = title ? `${title} — ${BASE}` : DEFAULT
|
||||
return () => {
|
||||
document.title = DEFAULT
|
||||
}
|
||||
}, [title])
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SITE } from '@/content/site'
|
||||
|
||||
export interface ReleaseInfo {
|
||||
version: string
|
||||
page_url: string
|
||||
macos_url?: string
|
||||
windows_url?: string
|
||||
source: 'gitea' | 'fallback'
|
||||
}
|
||||
|
||||
// 官网融进 admin 后不接 posts/releases 后端(营销落地页 only):直接用静态版本号,
|
||||
// 去掉原来那次必然失败的 fetch('/api/releases/latest')。
|
||||
const STATIC: ReleaseInfo = {
|
||||
version: SITE.version,
|
||||
page_url: `${SITE.repo}/releases`,
|
||||
source: 'fallback',
|
||||
}
|
||||
|
||||
/** 最新版本信息(静态,来自 content/site.ts)。 */
|
||||
export function useRelease(): ReleaseInfo {
|
||||
return STATIC
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// 官网侧的用户 API:微信扫码登录 + 购买(下单/查单)。
|
||||
//
|
||||
// 与运维后台的 api.ts 分开:普通用户在官网登录拿到的是「用户 JWT」,
|
||||
// 存独立 key(sdx_site_token),不与管理员令牌(sdx_admin_token)混用。
|
||||
// 主产品(跑 Agent/知识库/报告)在桌面端,官网只做「登录 + 购买」。
|
||||
|
||||
import { GATEWAY } from "../../api";
|
||||
|
||||
const SITE_TOKEN = "sdx_site_token";
|
||||
|
||||
export function siteToken(): string {
|
||||
try {
|
||||
return localStorage.getItem(SITE_TOKEN) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
function setSiteToken(t: string) {
|
||||
try {
|
||||
localStorage.setItem(SITE_TOKEN, t);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
export function clearSiteToken() {
|
||||
try {
|
||||
localStorage.removeItem(SITE_TOKEN);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders(json = false): Record<string, string> {
|
||||
const t = siteToken();
|
||||
const h: Record<string, string> = t ? { Authorization: `Bearer ${t}` } : {};
|
||||
if (json) h["Content-Type"] = "application/json";
|
||||
return h;
|
||||
}
|
||||
|
||||
export interface SiteUser {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// me:有 token 时确认登录态;无效则清掉。
|
||||
export async function siteMe(): Promise<SiteUser | null> {
|
||||
if (!siteToken()) return null;
|
||||
const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: authHeaders() });
|
||||
if (!res.ok) {
|
||||
clearSiteToken();
|
||||
return null;
|
||||
}
|
||||
return ((await res.json()) as { user?: SiteUser }).user ?? null;
|
||||
}
|
||||
|
||||
// ---- 微信扫码登录 ----
|
||||
export async function wxTicket(): Promise<{ ticket: string; qr_image: string; expires_in: number }> {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/wx/mp/ticket`, { method: "POST" });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "创建登录二维码失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 轮询:pending | authorized(带 token+user) | expired | consumed。
|
||||
export async function wxPoll(ticket: string): Promise<{ status: string; user?: SiteUser }> {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/wx/mp/poll?t=${encodeURIComponent(ticket)}`);
|
||||
if (!res.ok) return { status: "expired" };
|
||||
const d = (await res.json()) as { status?: string; token?: string; user?: SiteUser };
|
||||
if (d.token && d.user) {
|
||||
setSiteToken(d.token);
|
||||
return { status: "authorized", user: d.user };
|
||||
}
|
||||
return { status: d.status ?? "pending" };
|
||||
}
|
||||
|
||||
// ---- 购买(复用支付链路:下单 → 微信 code_url 二维码 → 轮询单态)----
|
||||
export interface PayOrder {
|
||||
order_id: string;
|
||||
code_url: string;
|
||||
amount_fen: number;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
// target 二选一:买订阅传 planId,买积分包传 packId。
|
||||
export async function createOrder(target: { planId: string } | { packId: string }): Promise<PayOrder> {
|
||||
const body = "planId" in target ? { plan_id: target.planId } : { pack_id: target.packId };
|
||||
const res = await fetch(`${GATEWAY}/api/v1/billing/orders`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(true),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const d = (await res.json().catch(() => ({}))) as Partial<PayOrder> & { error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? "下单失败");
|
||||
return d as PayOrder;
|
||||
}
|
||||
|
||||
// warn:已付但金额不符(服务端挂起人工核对,订单停在 pending)。
|
||||
export async function orderStatus(orderId: string): Promise<{ status: string; warn?: string }> {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: authHeaders() });
|
||||
if (!res.ok) return { status: "unknown" };
|
||||
const d = (await res.json()) as { order?: { status?: string }; warn?: string };
|
||||
return { status: d.order?.status ?? "pending", warn: d.warn };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { SectionLabel } from '@/components/section-label'
|
||||
import { DownloadGrid } from '@/components/download-grid'
|
||||
import { usePageTitle } from '@/hooks/use-page-title'
|
||||
|
||||
export default function DownloadPage() {
|
||||
usePageTitle('下载')
|
||||
return (
|
||||
<section className="px-6 py-16 lg:px-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mx-auto mb-9 max-w-[560px] text-center">
|
||||
<SectionLabel>DOWNLOAD</SectionLabel>
|
||||
<h1 className="text-balance text-[28px] font-[650] tracking-[-0.02em]">
|
||||
下载 sundynix agentix
|
||||
</h1>
|
||||
<p className="mt-2.5 text-[15px] text-ink-2">
|
||||
macOS 与 Windows 桌面版,或用 docker compose 私有化部署。
|
||||
</p>
|
||||
</div>
|
||||
<DownloadGrid />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Fragment } from 'react'
|
||||
import { ARCH_LAYERS } from '@/content/site'
|
||||
import { SectionLabel } from '@/components/section-label'
|
||||
import { Reveal } from '@/components/reveal'
|
||||
|
||||
export function Architecture() {
|
||||
return (
|
||||
<section
|
||||
id="architecture"
|
||||
className="border-t border-hairline px-6 py-16 lg:px-10"
|
||||
>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Reveal>
|
||||
<div className="mx-auto mb-9 max-w-[560px] text-center">
|
||||
<SectionLabel>ARCHITECTURE</SectionLabel>
|
||||
<h2 className="text-balance text-[26px] font-[650] tracking-[-0.02em]">
|
||||
五层架构,一条零拷贝总线
|
||||
</h2>
|
||||
<p className="mt-2.5 text-[15px] text-ink-2">
|
||||
Monolith First,为微服务演进预留缝隙(Morph B)。
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={120} className="mx-auto max-w-[760px]">
|
||||
{ARCH_LAYERS.map((layer, i) => (
|
||||
<Fragment key={layer.id}>
|
||||
{/* NATS 总线插在 L2 和 L4 之间 */}
|
||||
{i === 2 && (
|
||||
<div className="mb-2.5 flex items-center justify-center gap-3.5 rounded-[10px] border border-dashed border-accent bg-accent-soft px-5 py-3 font-mono text-xs tracking-[0.1em] text-accent-ink">
|
||||
⇅ NATS JETSTREAM — 零拷贝消息骨干网 · sundynix.* ⇅
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2.5 grid grid-cols-1 items-center gap-1 rounded-[10px] border border-hairline bg-surface px-5 py-3.5 sm:grid-cols-[120px_1fr_auto] sm:gap-5">
|
||||
<span className="font-mono text-[11px] tracking-[0.14em] text-ink-3">
|
||||
{layer.id}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{layer.name}{' '}
|
||||
<span className="text-[13px] font-normal text-ink-2">
|
||||
{layer.detail}
|
||||
</span>
|
||||
</span>
|
||||
<span className="font-mono text-[11.5px] text-accent-ink">
|
||||
{layer.tech}
|
||||
</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SectionLabel } from '@/components/section-label'
|
||||
import { DownloadGrid } from '@/components/download-grid'
|
||||
import { Reveal } from '@/components/reveal'
|
||||
|
||||
export function DownloadCta() {
|
||||
return (
|
||||
<section className="border-t border-hairline px-6 py-16 lg:px-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Reveal>
|
||||
<div className="mx-auto mb-9 max-w-[560px] text-center">
|
||||
<SectionLabel>DOWNLOAD</SectionLabel>
|
||||
<h2 className="text-balance text-[26px] font-[650] tracking-[-0.02em]">
|
||||
把 AI 团队装进桌面
|
||||
</h2>
|
||||
<p className="mt-2.5 text-[15px] text-ink-2">
|
||||
内置应用内更新,发布流水线自动出包。
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={120}>
|
||||
<DownloadGrid />
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { FEATURES } from '@/content/site'
|
||||
import { SectionLabel } from '@/components/section-label'
|
||||
import { Reveal } from '@/components/reveal'
|
||||
|
||||
export function Features() {
|
||||
return (
|
||||
<section id="features" className="border-t border-hairline px-6 py-16 lg:px-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Reveal>
|
||||
<div className="mx-auto mb-9 max-w-[560px] text-center">
|
||||
<SectionLabel>CAPABILITIES</SectionLabel>
|
||||
<h2 className="text-balance text-[26px] font-[650] tracking-[-0.02em]">
|
||||
一个工作台,一支 AI 团队
|
||||
</h2>
|
||||
<p className="mt-2.5 text-[15px] text-ink-2">
|
||||
从编排到交付,每个环节都是流式的、可观测的、有守卫的。
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={120}>
|
||||
<div className="grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-hairline bg-hairline sm:grid-cols-2 lg:grid-cols-4">
|
||||
{FEATURES.map((f) => (
|
||||
<div
|
||||
key={f.label}
|
||||
className="group bg-surface p-6 transition-colors hover:bg-ground"
|
||||
>
|
||||
<span className="mb-3 block font-mono text-[10.5px] tracking-[0.16em] text-accent-ink transition-colors group-hover:text-accent">
|
||||
{f.label}
|
||||
</span>
|
||||
<h3 className="mb-1.5 text-base font-semibold">{f.title}</h3>
|
||||
<p className="text-[13.5px] leading-[1.7] text-ink-2">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Download } from 'lucide-react'
|
||||
import { SITE } from '@/content/site'
|
||||
import { TerminalDemo } from '@/components/terminal-demo'
|
||||
import { Reveal } from '@/components/reveal'
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="px-6 pt-21 text-center lg:px-10">
|
||||
<Reveal>
|
||||
<span className="mb-6.5 inline-flex items-center gap-2 rounded-full border border-hairline-strong px-3.5 py-1 font-mono text-xs tracking-[0.08em] text-accent-ink">
|
||||
<span className="size-[7px] rounded-full bg-accent" />
|
||||
{SITE.version} 已发布 · macOS / Windows
|
||||
</span>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={80}>
|
||||
<h1 className="mx-auto max-w-[17em] text-balance text-4xl font-[650] leading-[1.18] tracking-[-0.028em] md:text-[52px] lg:text-[58px]">
|
||||
事件驱动的
|
||||
<br />
|
||||
<span className="text-brand-gradient">AI Agent 工作台</span>
|
||||
<span className="text-ink-3">。</span>
|
||||
</h1>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={160}>
|
||||
<p className="mx-auto mt-5.5 max-w-[37em] text-[17px] text-ink-2">
|
||||
在画布上编排智能体,让多 Agent
|
||||
团队替你研究、检索、写出真正的 Word
|
||||
报告。桌面开箱即用,也可一条命令私有化部署。
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={240}>
|
||||
<div className="mt-9 flex flex-wrap justify-center gap-3">
|
||||
<Link
|
||||
to="/download"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-accent px-6 py-2.5 text-[14.5px] font-medium text-ground transition-opacity hover:opacity-90"
|
||||
>
|
||||
下载桌面版 <Download className="size-4" />
|
||||
</Link>
|
||||
<a
|
||||
href={SITE.repo}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center rounded-lg border border-hairline-strong px-6 py-2.5 font-mono text-[14px] transition-colors hover:border-accent"
|
||||
>
|
||||
$ make demo
|
||||
</a>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={320}>
|
||||
<TerminalDemo />
|
||||
</Reveal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Hero } from '@/pages/home/hero'
|
||||
import { Features } from '@/pages/home/features'
|
||||
import { Architecture } from '@/pages/home/architecture'
|
||||
import { Quickstart } from '@/pages/home/quickstart'
|
||||
import { DownloadCta } from '@/pages/home/download-cta'
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<Features />
|
||||
<Architecture />
|
||||
<Quickstart />
|
||||
<DownloadCta />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { QUICKSTART_STEPS } from '@/content/site'
|
||||
import { SectionLabel } from '@/components/section-label'
|
||||
import { Reveal } from '@/components/reveal'
|
||||
|
||||
export function Quickstart() {
|
||||
return (
|
||||
<section className="border-t border-hairline px-6 py-16 lg:px-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Reveal>
|
||||
<div className="mb-9 max-w-[560px]">
|
||||
<SectionLabel>QUICKSTART</SectionLabel>
|
||||
<h2 className="text-[26px] font-[650] tracking-[-0.02em]">
|
||||
五分钟跑起来
|
||||
</h2>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal
|
||||
delay={120}
|
||||
className="grid grid-cols-1 items-start gap-6 md:grid-cols-2"
|
||||
>
|
||||
<pre className="overflow-x-auto rounded-[10px] bg-term px-5.5 py-5 font-mono text-[13.5px] leading-8 text-term-ink">
|
||||
<code>
|
||||
<span className="text-white/35"># 内嵌 NATS,零配置体验</span>
|
||||
{'\n'}
|
||||
<span className="text-[#22d3ee]">$</span> git clone
|
||||
https://git.sundynix.cn/sundynix/sundynix-agentix{'\n'}
|
||||
<span className="text-[#22d3ee]">$</span> make demo{'\n'}
|
||||
{'\n'}
|
||||
<span className="text-white/35"># 或一条命令私有化部署</span>
|
||||
{'\n'}
|
||||
<span className="text-[#22d3ee]">$</span> docker compose up -d
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
<ul>
|
||||
{QUICKSTART_STEPS.map((step, i) => (
|
||||
<li
|
||||
key={step.title}
|
||||
className="flex gap-4 border-b border-hairline py-3 text-[14.5px] text-ink-2"
|
||||
>
|
||||
<span className="pt-[3px] font-mono text-xs tracking-[0.08em] text-accent">
|
||||
0{i + 1}
|
||||
</span>
|
||||
<span>
|
||||
<b className="font-semibold text-ink">{step.title}</b>
|
||||
{step.desc}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { usePageTitle } from '@/hooks/use-page-title'
|
||||
|
||||
export default function NotFoundPage() {
|
||||
usePageTitle('页面不存在')
|
||||
return (
|
||||
<section className="flex flex-col items-center px-6 py-28 text-center">
|
||||
<p className="font-mono text-[13px] tracking-[0.18em] text-accent">
|
||||
404 · NOT FOUND
|
||||
</p>
|
||||
<h1 className="mt-4 text-[28px] font-[650] tracking-[-0.02em]">
|
||||
这条事件流不存在
|
||||
</h1>
|
||||
<p className="mt-2 text-[15px] text-ink-2">
|
||||
地址可能已变更,回首页重新出发。
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="mt-8 rounded-lg bg-accent px-6 py-2.5 text-[14.5px] font-medium text-ground transition-opacity hover:opacity-90"
|
||||
>
|
||||
回首页
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { GATEWAY } from '../../api'
|
||||
import { siteMe, type SiteUser } from '../lib/site-api'
|
||||
import { WechatLoginDialog } from '../components/wechat-login-dialog'
|
||||
import { PayDialog } from '../components/pay-dialog'
|
||||
|
||||
// 官网定价页。数据来自后台配置的「订阅套餐 / 积分包」,**不写死在前端**——
|
||||
// 运营改价、上下架、调发放节奏都在管理端完成,这里跟着变,不用发版。
|
||||
//
|
||||
// 公开可见(/api/v1/pricing 不挂鉴权):让人在登录前就看得到价格,
|
||||
// 这是购买转化的前提,也是官网存在的意义。
|
||||
|
||||
const MICRO = 1_000_000
|
||||
const credits = (m: number) => (m / MICRO).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
|
||||
const yuan = (fen: number) => (fen / 100).toLocaleString('zh-CN', { minimumFractionDigits: fen % 100 === 0 ? 0 : 2 })
|
||||
|
||||
interface SubPlan {
|
||||
id: string
|
||||
name: string
|
||||
price_fen: number
|
||||
duration_days: number
|
||||
refill_credits_micro: number
|
||||
refill_interval_days: number
|
||||
}
|
||||
|
||||
interface Pack {
|
||||
id: string
|
||||
name: string
|
||||
credits_micro: number
|
||||
price_fen: number
|
||||
}
|
||||
|
||||
// 一个待购项:点「购买」后,未登录先扫码登录,再弹微信支付。全程在官网完成。
|
||||
type BuyItem = { kind: 'sub' | 'pack'; id: string; name: string; priceFen: number }
|
||||
|
||||
export default function PricingPage() {
|
||||
const [plans, setPlans] = useState<SubPlan[]>([])
|
||||
const [packs, setPacks] = useState<Pack[]>([])
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [user, setUser] = useState<SiteUser | null>(null)
|
||||
const [buying, setBuying] = useState<BuyItem | null>(null) // 已登录,正在支付
|
||||
const [pendingBuy, setPendingBuy] = useState<BuyItem | null>(null) // 待登录后继续购买
|
||||
|
||||
useEffect(() => {
|
||||
void siteMe().then(setUser) // 有历史登录态则免扫码
|
||||
}, [])
|
||||
|
||||
// 点购买:未登录先记下意图并弹登录,登录后自动继续;已登录直接支付。
|
||||
const onBuy = (item: BuyItem) => {
|
||||
if (user) setBuying(item)
|
||||
else setPendingBuy(item)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// GATEWAY 生产构建为空串(同源相对),开发时指向 :8080——写死相对路径会打到 vite
|
||||
fetch(`${GATEWAY}/api/v1/pricing`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
||||
.then((d: { plans?: SubPlan[]; packs?: Pack[] }) => {
|
||||
setPlans(d.plans ?? [])
|
||||
setPacks(d.packs ?? [])
|
||||
setState('ready')
|
||||
})
|
||||
.catch(() => setState('error'))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-16 md:py-24">
|
||||
<header className="max-w-2xl">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-accent">PRICING</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-ink md:text-4xl">按用量计费,先付后用</h1>
|
||||
<p className="mt-4 text-sm leading-relaxed text-ink-2">
|
||||
所有消耗以「积分」结算。订阅按周期自动发放积分,也可以单次购买积分包。
|
||||
没有隐藏费用,用不完的积分不清零。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{state === 'loading' && <p className="mt-12 text-sm text-ink-3">正在获取价格…</p>}
|
||||
{state === 'error' && (
|
||||
<p className="mt-12 text-sm text-ink-3">
|
||||
价格暂时取不到,请稍后再试。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state === 'ready' && (
|
||||
<>
|
||||
{plans.length > 0 && (
|
||||
<section className="mt-12">
|
||||
<h2 className="text-lg font-semibold text-ink">订阅</h2>
|
||||
<p className="mt-1 text-sm text-ink-2">
|
||||
购买一个周期,期内按节奏发放积分。到期即结束,不会自动续费、不会自动扣款。
|
||||
</p>
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{plans.map((p) => {
|
||||
const times = p.refill_interval_days > 0 ? Math.floor(p.duration_days / p.refill_interval_days) : 0
|
||||
return (
|
||||
<article
|
||||
key={p.id}
|
||||
className="flex flex-col rounded-2xl border border-hairline bg-surface p-6 transition-colors hover:border-hairline-strong"
|
||||
>
|
||||
<h3 className="text-base font-semibold text-ink">{p.name}</h3>
|
||||
<div className="mt-3 flex items-baseline gap-1">
|
||||
<span className="text-3xl font-semibold tracking-tight text-ink">¥{yuan(p.price_fen)}</span>
|
||||
<span className="text-sm text-ink-3">/ {p.duration_days} 天</span>
|
||||
</div>
|
||||
<ul className="mt-5 space-y-2 text-sm text-ink-2">
|
||||
<li>
|
||||
每 {p.refill_interval_days} 天发放{' '}
|
||||
<b className="text-ink">{credits(p.refill_credits_micro)}</b> 积分
|
||||
</li>
|
||||
<li>
|
||||
周期内共 {times} 次,合计{' '}
|
||||
<b className="text-ink">{credits(p.refill_credits_micro * times)}</b> 积分
|
||||
</li>
|
||||
<li className="text-ink-3">积分累加,用不完不清零</li>
|
||||
</ul>
|
||||
<button
|
||||
onClick={() => onBuy({ kind: 'sub', id: p.id, name: p.name, priceFen: p.price_fen })}
|
||||
className="mt-6 inline-flex items-center justify-center rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-90"
|
||||
>
|
||||
购买
|
||||
</button>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{packs.length > 0 && (
|
||||
<section className="mt-14">
|
||||
<h2 className="text-lg font-semibold text-ink">积分包</h2>
|
||||
<p className="mt-1 text-sm text-ink-2">一次性购买,用完再买,适合用量不规律的场景。</p>
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{packs.map((p) => (
|
||||
<article key={p.id} className="rounded-2xl border border-hairline bg-surface p-5">
|
||||
<h3 className="text-sm font-semibold text-ink">{p.name}</h3>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-ink">¥{yuan(p.price_fen)}</div>
|
||||
<p className="mt-1 text-sm text-ink-2">{credits(p.credits_micro)} 积分</p>
|
||||
<button
|
||||
onClick={() => onBuy({ kind: 'pack', id: p.id, name: p.name, priceFen: p.price_fen })}
|
||||
className="mt-4 inline-flex text-sm font-medium text-accent underline underline-offset-4"
|
||||
>
|
||||
购买 →
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{plans.length === 0 && packs.length === 0 && (
|
||||
<p className="mt-12 text-sm text-ink-3">价格方案即将上线。</p>
|
||||
)}
|
||||
|
||||
<p className="mt-14 text-xs leading-relaxed text-ink-3">
|
||||
支付由微信支付提供。购买需要先登录;企业采购与私有化部署请通过页脚联系方式与我们联系。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 未登录点购买 → 扫码登录 → 登录后自动继续之前那笔购买 */}
|
||||
{pendingBuy && (
|
||||
<WechatLoginDialog
|
||||
onClose={() => setPendingBuy(null)}
|
||||
onLoggedIn={(u) => {
|
||||
setUser(u)
|
||||
const item = pendingBuy
|
||||
setPendingBuy(null)
|
||||
setBuying(item)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{buying && <PayDialog item={buying} onClose={() => setBuying(null)} />}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,38 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: { extend: {} },
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
// 官网落地页设计 token(跟随 logo:青→蓝→紫)。值引用 CSS 变量(见 src/index.css 的
|
||||
// :root/.dark),admin 自身页面用默认 gray/violet 调色板、不碰这些 token,故不冲突。
|
||||
colors: {
|
||||
ground: "var(--ground)",
|
||||
surface: "var(--surface)",
|
||||
ink: "var(--ink)",
|
||||
"ink-2": "var(--ink-2)",
|
||||
"ink-3": "var(--ink-3)",
|
||||
hairline: "var(--hairline)",
|
||||
"hairline-strong": "var(--hairline-strong)",
|
||||
accent: "var(--accent)",
|
||||
"accent-ink": "var(--accent-ink)",
|
||||
"accent-soft": "var(--accent-soft)",
|
||||
code: "var(--code-bg)",
|
||||
term: "var(--term-bg)",
|
||||
"term-ink": "var(--term-ink)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"-apple-system",
|
||||
"PingFang SC",
|
||||
"Hiragino Sans GB",
|
||||
"Microsoft YaHei",
|
||||
"Noto Sans SC",
|
||||
"sans-serif",
|
||||
],
|
||||
mono: ["ui-monospace", "SF Mono", "JetBrains Mono", "Menlo", "Consolas", "monospace"],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/site/*"] }
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// 官网落地页融进 admin,代码在 src/site/ 下且沿用 @ 别名导入(@/content/site 等)。
|
||||
resolve: { alias: { "@": path.resolve(__dirname, "./src/site") } },
|
||||
server: { port: 5174 },
|
||||
// 单元/组件测试:纯逻辑 + 关键控制面(jsdom 环境)。运行:npm test
|
||||
test: {
|
||||
|
||||
@@ -15,9 +15,11 @@ import (
|
||||
)
|
||||
|
||||
// App 经 Wails v3 的 Service 绑定暴露给前端,承载只有桌面端能做的原生能力:
|
||||
// 文件读写、系统"另存为"框、用系统默认应用打开、原生通知。
|
||||
// 文件读写、系统"另存为"框、用系统默认应用打开、原生通知、本地执行 runner(JARVIS 的手)。
|
||||
// v3 中不再需要注入 ctx——对话框经 application.Get().Dialog 获取。
|
||||
type App struct{}
|
||||
type App struct {
|
||||
runner LocalRunner // 本地执行器(localrunner.go):用户显式开启才连接
|
||||
}
|
||||
|
||||
// Ping 供前端探活 Go 桥是否就绪。
|
||||
func (a *App) Ping() string { return "sundynix-desktop ok" }
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>JARVIS 语音助手需要麦克风来听你说话。</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
<string>0.1.1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0.0</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>JARVIS 语音助手需要麦克风来听你说话。</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
/**
|
||||
* App 经 Wails v3 的 Service 绑定暴露给前端,承载只有桌面端能做的原生能力:
|
||||
* 文件读写、系统"另存为"框、用系统默认应用打开、原生通知。
|
||||
* 文件读写、系统"另存为"框、用系统默认应用打开、原生通知、本地执行 runner(JARVIS 的手)。
|
||||
* v3 中不再需要注入 ctx——对话框经 application.Get().Dialog 获取。
|
||||
* @module
|
||||
*/
|
||||
@@ -12,6 +12,27 @@
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
/**
|
||||
* DefaultLocalDirs 返回建议授权的常用目录(桌面/下载/文档),供设置界面一键填入。
|
||||
*/
|
||||
export function DefaultLocalDirs(): $CancellablePromise<string> {
|
||||
return $Call.ByID(2376992125);
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalExecEnabled 供前端显示开关状态。
|
||||
*/
|
||||
export function LocalExecEnabled(): $CancellablePromise<boolean> {
|
||||
return $Call.ByID(2160533474);
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalRunnerStatus 返回 "offline" / "connecting" / "online:<workdir>"(前端状态显示)。
|
||||
*/
|
||||
export function LocalRunnerStatus(): $CancellablePromise<string> {
|
||||
return $Call.ByID(1389428436);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify 弹一条系统通知(best-effort:macOS 用 osascript,其它平台暂静默)。
|
||||
*/
|
||||
@@ -42,3 +63,26 @@ export function PrintReportPage(filename: string, html: string): $CancellablePro
|
||||
export function SaveReportAs(url: string, filename: string): $CancellablePromise<string> {
|
||||
return $Call.ByID(1437856486, url, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetLocalExecEnabled 单独开/关"允许 JARVIS 执行命令与写文件"(前端设置项调用)。
|
||||
* 关闭时同时清掉会话批准记忆——关了就是彻底关,不留后门。
|
||||
*/
|
||||
export function SetLocalExecEnabled(on: boolean): $CancellablePromise<void> {
|
||||
return $Call.ByID(1135777740, on);
|
||||
}
|
||||
|
||||
/**
|
||||
* StartLocalRunner 开启本地访问:dirs 是换行分隔的授权目录列表(沙箱白名单)。
|
||||
* 幂等:重复调用先停旧连接。断线自动重连(5s 退避)直到 StopLocalRunner。
|
||||
*/
|
||||
export function StartLocalRunner(gatewayURL: string, token: string, dirs: string): $CancellablePromise<void> {
|
||||
return $Call.ByID(2808780368, gatewayURL, token, dirs);
|
||||
}
|
||||
|
||||
/**
|
||||
* StopLocalRunner 关闭本地文件访问(幂等)。
|
||||
*/
|
||||
export function StopLocalRunner(): $CancellablePromise<void> {
|
||||
return $Call.ByID(476635130);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TopBar } from "./shell/TopBar";
|
||||
import { LeftNav, type ViewKey } from "./shell/LeftNav";
|
||||
import { ApprovalBar } from "./shell/ApprovalBar";
|
||||
import { SpaceMembers } from "./shell/SpaceMembers";
|
||||
import { InviteMembers } from "./shell/InviteMembers";
|
||||
import { StudioView } from "./studio/StudioView";
|
||||
import { MemoryView } from "./views/MemoryView";
|
||||
import { KbView } from "./views/KbView";
|
||||
@@ -15,6 +16,7 @@ import { Home } from "./views/Home";
|
||||
import { CommandPalette, type Command } from "./components/CommandPalette";
|
||||
import { UpdateBanner } from "./components/UpdateBanner";
|
||||
import { Login } from "./views/Login";
|
||||
import { VoiceDock } from "./shell/VoiceDock";
|
||||
import { submitTask, generateReport, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, myTenants, switchTenant, spaceCurrent, mySpaces, switchSpace, createSpace, type Identity, type AuthUser, type TenantCtx, type MyTenant, type SpaceCtx, type MySpace } from "./lib/api";
|
||||
import type { TaskDsl } from "./lib/dsl";
|
||||
import { emptyRun, type RunState } from "./lib/run";
|
||||
@@ -46,6 +48,7 @@ export default function App() {
|
||||
const [run, setRun] = useState<RunState>(emptyRun);
|
||||
const [cmdOpen, setCmdOpen] = useState(false);
|
||||
const [membersOpen, setMembersOpen] = useState(false);
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const closeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const execCloseRef = useRef<(() => void) | null>(null);
|
||||
@@ -281,6 +284,22 @@ export default function App() {
|
||||
[identity, attachRun],
|
||||
);
|
||||
|
||||
// 语音任务:后端已在 ASR 转写后自行提交(走 SubmitTask 同一关卡),前端只需挂回它的运行流。
|
||||
// 与 onRun 的区别:不再 submit,直接 attach(task 已在后端跑)——跳「运行·观测」看轨迹/输出。
|
||||
const onVoiceTask = useCallback(
|
||||
(taskId: string) => {
|
||||
closeRef.current?.();
|
||||
execCloseRef.current?.();
|
||||
stopPoll();
|
||||
const t0 = Date.now();
|
||||
setRun({ phase: "streaming", taskId, output: "", events: [{ t: 0, label: `语音任务 ${taskId}` }], exec: [] });
|
||||
setFocusRun(null);
|
||||
setView("runs");
|
||||
attachRun(taskId, t0, "语音任务已跑完");
|
||||
},
|
||||
[attachRun],
|
||||
);
|
||||
|
||||
// 恢复在途待审任务:登录后若存在 waiting 任务且当前无 live run,挂回它 → 全局审批条重现,
|
||||
// 用户刷新页面/重开 app 也能继续批准(HITL 持久化中断后审批可跨重启、可等数小时)。
|
||||
const restoredRef = useRef(false);
|
||||
@@ -326,7 +345,7 @@ export default function App() {
|
||||
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
||||
/>
|
||||
<UpdateBanner />
|
||||
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} onManageMembers={() => setMembersOpen(true)} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
||||
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} onManageMembers={() => setMembersOpen(true)} onInviteMembers={() => setInviteOpen(true)} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
||||
<ApprovalBar run={run} />
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<LeftNav active={view} onSelect={setView} />
|
||||
@@ -357,6 +376,15 @@ export default function App() {
|
||||
canManage={space?.role === "owner" || space?.role === "admin"}
|
||||
selfUserId={user?.id ?? ""}
|
||||
/>
|
||||
<InviteMembers open={inviteOpen} onClose={() => setInviteOpen(false)} tenantName={tenant?.tenant?.name ?? ""} />
|
||||
{/* JARVIS 界面动作(P2):服务端白名单过后,这里再按已知 ViewKey 核一次才执行(双保险)。 */}
|
||||
<VoiceDock
|
||||
onTask={onVoiceTask}
|
||||
onNavigate={(view, taskId) => {
|
||||
const allowed: ViewKey[] = ["home", "studio", "kb", "report", "runs", "memory", "usage"];
|
||||
if (allowed.includes(view as ViewKey)) goto(view as ViewKey, taskId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,42 @@
|
||||
// Gateway HTTP/SSE 客户端:提交 DSL 任务、订阅 Token 流、登记偏好记忆。
|
||||
import type { TaskDsl } from "./dsl";
|
||||
|
||||
// 开发期直连 Gateway;Wails 打包后可改为本地后端地址或经 Go 绑定。
|
||||
export const GATEWAY: string =
|
||||
(import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080";
|
||||
// 服务器地址:**运行时可配**(登录页可填),优先级 localStorage > 构建期 VITE_GATEWAY > 本地默认。
|
||||
// 做成运行时而非只在构建期写死,是因为同一个 App 要能在「本地全栈」和「线上服务器」之间随时切,
|
||||
// 每换一次环境就重编一次客户端不现实。改完需重载窗口(模块级常量,40+ 处调用点靠它读同一份值)。
|
||||
const GATEWAY_KEY = "sdx_gateway";
|
||||
|
||||
function initialGateway(): string {
|
||||
try {
|
||||
const saved = localStorage.getItem(GATEWAY_KEY);
|
||||
if (saved) return saved.replace(/\/+$/, ""); // 去掉尾斜杠,否则拼出 //api/v1 这种路径
|
||||
} catch {
|
||||
/* 隐私模式忽略 */
|
||||
}
|
||||
return (import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080";
|
||||
}
|
||||
|
||||
export const GATEWAY: string = initialGateway();
|
||||
|
||||
// setGatewayURL 保存服务器地址(空串=清除,回落构建期默认)。调用方保存后应重载窗口使其生效。
|
||||
export function setGatewayURL(url: string): void {
|
||||
try {
|
||||
const v = url.trim().replace(/\/+$/, "");
|
||||
if (v) localStorage.setItem(GATEWAY_KEY, v);
|
||||
else localStorage.removeItem(GATEWAY_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// customGatewayURL 返回用户自定义的地址(没设过则空串),供设置界面回显。
|
||||
export function customGatewayURL(): string {
|
||||
try {
|
||||
return localStorage.getItem(GATEWAY_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export interface Identity {
|
||||
userId: string;
|
||||
@@ -150,6 +183,31 @@ export interface MyTenant {
|
||||
members: number;
|
||||
}
|
||||
|
||||
// ---- 订阅(只读)----
|
||||
// 桌面端**不做**购买流程:购买入口在 Web 面(一个功能一个入口)。这里只读状态,
|
||||
// 因为"还剩几天到期"必须在用户干活的地方看得见——到期即失效且没有任何续费通知。
|
||||
export interface MySubscription {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
status: string;
|
||||
expires_at: string;
|
||||
refill_seq: number;
|
||||
}
|
||||
|
||||
export interface MySubPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
refill_credits_micro: number;
|
||||
refill_interval_days: number;
|
||||
}
|
||||
|
||||
export async function mySubscription(): Promise<{ subscription: MySubscription | null; plan: MySubPlan | null }> {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/billing/subscription`, { headers: bearer() });
|
||||
if (!res.ok) return { subscription: null, plan: null };
|
||||
const d = (await res.json()) as { subscription?: MySubscription | null; plan?: MySubPlan | null };
|
||||
return { subscription: d.subscription ?? null, plan: d.plan ?? null };
|
||||
}
|
||||
|
||||
export async function myTenants(): Promise<{ tenants: MyTenant[]; active: string }> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/tenants`, { headers: bearer() }));
|
||||
if (!res.ok) return { tenants: [], active: "" };
|
||||
@@ -246,6 +304,47 @@ export const setSpaceMemberRole = (spaceId: string, userId: string, role: string
|
||||
export const removeSpaceMember = (spaceId: string, userId: string) =>
|
||||
spaceMemberWrite("DELETE", `/api/v1/spaces/${spaceId}/members/${userId}`);
|
||||
|
||||
// ---- 租户成员「二维码邀请」(可复用团队码;扫码关注即入组)----
|
||||
export interface TenantInvite {
|
||||
id: string;
|
||||
role: string; // member/viewer/admin
|
||||
qr_image: string; // 微信二维码图 URL
|
||||
expires_at: string;
|
||||
max_uses: number; // 0=不限
|
||||
used_count: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function listInvites(): Promise<TenantInvite[]> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/invites`, { headers: bearer() }));
|
||||
if (!res.ok) {
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(d.error ?? `invites failed: ${res.status}`);
|
||||
}
|
||||
const d = (await res.json()) as { invites?: TenantInvite[] };
|
||||
return d.invites ?? [];
|
||||
}
|
||||
|
||||
export async function createInvite(role: string, expiresDays: number, maxUses: number): Promise<TenantInvite> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/invites`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...bearer() },
|
||||
body: JSON.stringify({ role, expires_days: expiresDays, max_uses: maxUses }),
|
||||
}));
|
||||
const d = (await res.json().catch(() => ({}))) as { invite?: TenantInvite; error?: string };
|
||||
if (!res.ok || !d.invite) throw new Error(d.error ?? `create invite failed: ${res.status}`);
|
||||
return d.invite;
|
||||
}
|
||||
|
||||
export async function revokeInvite(id: string): Promise<void> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/invites/${id}`, { method: "DELETE", headers: bearer() }));
|
||||
if (!res.ok) {
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(d.error ?? `revoke failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)----
|
||||
export interface UsageDay {
|
||||
day: string; // YYYYMMDD
|
||||
@@ -309,15 +408,23 @@ export async function approveTask(taskId: string, approved: boolean, opts?: { no
|
||||
if (!res.ok) throw new Error(`approve failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
// tokenQuery 给 EventSource / 下载链接这类**带不了 Authorization 头**的 URL 附上 JWT,
|
||||
// 后端 AuthFromHeaderOrQuery 从 ?token= 取它做鉴权 + 归属校验(公开 by-id 端点不再裸奔)。
|
||||
function tokenQuery(url: string): string {
|
||||
const t = getToken();
|
||||
if (!t) return url;
|
||||
return url + (url.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(t);
|
||||
}
|
||||
|
||||
// streamTokens: 订阅 SSE /api/v1/tasks/:id/stream,逐 token 回调,done 收尾。
|
||||
// 返回关闭函数。注意 EventSource 无法带请求头,但流按 task_id 寻址,无需身份头。
|
||||
// 返回关闭函数。EventSource 无法带请求头,故 JWT 走 ?token=(tokenQuery)。
|
||||
export function streamTokens(
|
||||
taskId: string,
|
||||
onToken: (t: string) => void,
|
||||
onDone: () => void,
|
||||
onError?: (e: unknown) => void,
|
||||
): () => void {
|
||||
const es = new EventSource(`${GATEWAY}/api/v1/tasks/${taskId}/stream`);
|
||||
const es = new EventSource(tokenQuery(`${GATEWAY}/api/v1/tasks/${taskId}/stream`));
|
||||
es.addEventListener("token", (e) => onToken((e as MessageEvent).data));
|
||||
es.addEventListener("done", () => {
|
||||
es.close();
|
||||
@@ -349,7 +456,7 @@ export function streamExec(
|
||||
onDone: () => void,
|
||||
onError?: (e: unknown) => void,
|
||||
): () => void {
|
||||
const es = new EventSource(`${GATEWAY}/api/v1/tasks/${taskId}/exec`);
|
||||
const es = new EventSource(tokenQuery(`${GATEWAY}/api/v1/tasks/${taskId}/exec`));
|
||||
es.addEventListener("exec", (e) => onEvent(JSON.parse((e as MessageEvent).data) as ExecEvent));
|
||||
es.addEventListener("done", () => {
|
||||
es.close();
|
||||
@@ -528,7 +635,7 @@ export function streamIngest(
|
||||
onDone: () => void,
|
||||
onError?: () => void,
|
||||
): () => void {
|
||||
const es = new EventSource(`${GATEWAY}/api/v1/kb/ingest/${jobId}/stream`);
|
||||
const es = new EventSource(tokenQuery(`${GATEWAY}/api/v1/kb/ingest/${jobId}/stream`));
|
||||
es.addEventListener("progress", (e) => onEvent(JSON.parse((e as MessageEvent).data) as IngestEvent));
|
||||
es.addEventListener("done", () => {
|
||||
es.close();
|
||||
@@ -589,7 +696,7 @@ export async function generateReport(id: Identity, topic: string, kb?: string):
|
||||
|
||||
// reportDownloadUrl: 渲染好的 Word(.docx) 下载地址(兼容旧入口)。
|
||||
export function reportDownloadUrl(taskId: string): string {
|
||||
return `${GATEWAY}/api/v1/reports/${taskId}/download`;
|
||||
return tokenQuery(`${GATEWAY}/api/v1/reports/${taskId}/download`);
|
||||
}
|
||||
|
||||
// reportExportUrl: 按需导出报告地址(format=docx|md;后端现渲染)。PDF 由前端打印预览生成。
|
||||
@@ -600,7 +707,7 @@ export function reportFilename(topic: string, id: string, ext: string): string {
|
||||
}
|
||||
|
||||
export function reportExportUrl(taskId: string, format: "docx" | "md"): string {
|
||||
return `${GATEWAY}/api/v1/reports/${taskId}/export?format=${format}`;
|
||||
return tokenQuery(`${GATEWAY}/api/v1/reports/${taskId}/export?format=${format}`);
|
||||
}
|
||||
|
||||
// setMemory: PUT /api/v1/memory,登记一条用户偏好(→ mcp-go memory_upsert)。
|
||||
@@ -715,3 +822,38 @@ export async function runReplay(taskId: string): Promise<{ output: string; exec:
|
||||
const d = (await res.json()) as { output?: string; exec?: ExecEvent[] };
|
||||
return { output: d.output ?? "", exec: d.exec ?? [] };
|
||||
}
|
||||
|
||||
// ---- 每用户 JARVIS 设置(名字 / 人设 / 自带豆包配置)----
|
||||
export interface JarvisConfig {
|
||||
name: string;
|
||||
persona: string;
|
||||
asr_resource_id: string;
|
||||
tts_resource_id: string;
|
||||
tts_voice_type: string;
|
||||
api_key: string; // 读取时为脱敏值;保存留空/掩码=沿用已存
|
||||
has_own_voice: boolean;
|
||||
}
|
||||
|
||||
export async function getMyJarvis(): Promise<JarvisConfig> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/jarvis`, { headers: bearer() }));
|
||||
if (!res.ok) throw new Error("读取 JARVIS 设置失败");
|
||||
return res.json() as Promise<JarvisConfig>;
|
||||
}
|
||||
|
||||
export async function saveMyJarvis(cfg: {
|
||||
name: string;
|
||||
persona: string;
|
||||
api_key?: string;
|
||||
asr_resource_id?: string;
|
||||
tts_resource_id?: string;
|
||||
tts_voice_type?: string;
|
||||
}): Promise<void> {
|
||||
const res = guard401(
|
||||
await fetch(`${GATEWAY}/api/v1/me/jarvis`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", ...bearer() },
|
||||
body: JSON.stringify(cfg),
|
||||
}),
|
||||
);
|
||||
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error || "保存失败");
|
||||
}
|
||||
|
||||
@@ -27,6 +27,44 @@ export function notify(title: string, body: string): void {
|
||||
if (inWails()) void App.Notify(title, body);
|
||||
}
|
||||
|
||||
// ---- 本地执行 runner(JARVIS「本地的手」,仅桌面壳可用;浏览器一律不可用)----
|
||||
|
||||
export function localRunnerAvailable(): boolean {
|
||||
return inWails();
|
||||
}
|
||||
|
||||
// startLocalRunner:以 dirs(换行分隔的多个目录)为沙箱白名单开启本地访问。
|
||||
export async function startLocalRunner(gatewayURL: string, token: string, dirs: string): Promise<void> {
|
||||
if (!inWails()) throw new Error("本地访问仅桌面端可用");
|
||||
await App.StartLocalRunner(gatewayURL, token, dirs);
|
||||
}
|
||||
|
||||
// defaultLocalDirs:桌面/下载/文档的绝对路径(换行分隔),供一键填入。
|
||||
export async function defaultLocalDirs(): Promise<string> {
|
||||
if (!inWails()) return "";
|
||||
return App.DefaultLocalDirs();
|
||||
}
|
||||
|
||||
export function stopLocalRunner(): void {
|
||||
if (inWails()) void App.StopLocalRunner();
|
||||
}
|
||||
|
||||
// localRunnerStatus:"offline" / "connecting" / "online:<workdir>";浏览器恒 offline。
|
||||
export async function localRunnerStatus(): Promise<string> {
|
||||
if (!inWails()) return "offline";
|
||||
return App.LocalRunnerStatus();
|
||||
}
|
||||
|
||||
// 「允许执行命令与写文件」是独立于只读访问的第二道开关:开了也仍要逐次原生确认框批准。
|
||||
export function setLocalExecEnabled(on: boolean): void {
|
||||
if (inWails()) void App.SetLocalExecEnabled(on);
|
||||
}
|
||||
|
||||
export async function localExecEnabled(): Promise<boolean> {
|
||||
if (!inWails()) return false;
|
||||
return App.LocalExecEnabled();
|
||||
}
|
||||
|
||||
// printReportHtml:把已渲染的报告 HTML 在打印视图里出 PDF("打印→存为 PDF")。
|
||||
// 走前端打印是为了让中文(CJK)零字体依赖即可正确排版——后端 PDF 需内嵌 CJK 字体,较重。
|
||||
// 桌面壳内 WKWebView 会把 window.open 拦成 null(实机验过),改走原生桥:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nextAfterDrain, shouldSendMic, type VoiceState } from "./voice";
|
||||
|
||||
// 连续对话的纯决策函数:状态门控 / 播完去向 / 点按语义。
|
||||
// VoiceClient 本体依赖 WebSocket/AudioContext(jsdom 难直测),决策逻辑抽纯函数在这测。
|
||||
|
||||
describe("shouldSendMic 上行音频门控", () => {
|
||||
it("只在聆听态上行", () => {
|
||||
expect(shouldSendMic("listening")).toBe(true);
|
||||
});
|
||||
|
||||
it.each<VoiceState>(["idle", "connecting", "ready", "thinking", "speaking"])(
|
||||
"%s 态不上行(防回声喂 ASR + 省计费)",
|
||||
(s) => {
|
||||
expect(shouldSendMic(s)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("nextAfterDrain 播完去向", () => {
|
||||
it("对话模式回聆听(自动重听)", () => {
|
||||
expect(nextAfterDrain(true)).toBe("listening");
|
||||
});
|
||||
|
||||
it("非对话模式回 ready 待命", () => {
|
||||
expect(nextAfterDrain(false)).toBe("ready");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
// 语音会话客户端(JARVIS):一条 WebSocket 承载上行麦克风音频 + 下行转写 + 下行 TTS 音频。
|
||||
// 协议与网关 internal/voice/protocol.go 对齐:
|
||||
// - 二进制帧:上行=麦克风 PCM 16k;下行=TTS PCM 24k
|
||||
// - 文本帧(JSON):控制/事件(ClientMsg / ServerMsg)
|
||||
//
|
||||
// 麦克风采集用 ScriptProcessorNode(WKWebView 通吃,无需单独 worklet 文件),48k→16k 降采样、
|
||||
// Float32→Int16。下行播放用 AudioBufferSourceNode 排队调度,做无缝连续朗读。
|
||||
|
||||
import { GATEWAY, getToken } from "./api";
|
||||
|
||||
export type VoiceState = "idle" | "connecting" | "ready" | "listening" | "thinking" | "speaking";
|
||||
|
||||
export interface VoiceCallbacks {
|
||||
onState?: (s: VoiceState) => void;
|
||||
onTranscript?: (text: string, final: boolean) => void;
|
||||
onTask?: (taskId: string) => void;
|
||||
onReply?: (deltaText: string) => void; // Agent 回答增量文本(打字机,逐 token)
|
||||
onTurnStart?: () => void; // 新一轮开始(含自动重听)——UI 清上一轮转写/回答
|
||||
onAction?: (action: string, view: string, taskId: string) => void; // JARVIS 界面动作(navigate 等,白名单执行)
|
||||
onAnnounce?: (text: string) => void; // 主动播报文本(随后照常来 speaking/音频/tts_end)
|
||||
onError?: (msg: string) => void;
|
||||
}
|
||||
|
||||
// ---- 连续对话的纯决策函数(抽出便于单测;VoiceClient/VoiceDock 共用)----
|
||||
|
||||
// shouldSendMic:当前状态是否应上行麦克风音频。对话模式麦克风常开,
|
||||
// 但只在聆听时发——thinking/speaking 不发(防扬声器回声喂给 ASR + 省 ASR 时长计费)。
|
||||
export function shouldSendMic(state: VoiceState): boolean {
|
||||
return state === "listening";
|
||||
}
|
||||
|
||||
// nextAfterDrain:TTS 排队音频播完后的去向。对话模式回聆听继续多轮,否则回 ready 待命。
|
||||
export function nextAfterDrain(conversation: boolean): VoiceState {
|
||||
return conversation ? "listening" : "ready";
|
||||
}
|
||||
|
||||
// 健壮性定时器时长(仅对话模式生效)
|
||||
const IDLE_EXIT_MS = 30_000; // 聆听空转:30s 无任何转写 → 自动退出对话(防 ASR 长连接白烧计费)
|
||||
const THINK_WATCHDOG_MS = 90_000; // 思考看门狗:final 后 90s 没等到朗读 → 回聆听继续对话
|
||||
|
||||
const UP_SAMPLE_RATE = 16000; // 上行 ASR 采样率(与网关 voice.AudioSampleRate 一致)
|
||||
const DOWN_SAMPLE_RATE = 24000; // 下行 TTS 采样率(与网关 voice.TTSSampleRate 一致)
|
||||
|
||||
function wsURL(): string {
|
||||
const base = GATEWAY.replace(/^http/, "ws");
|
||||
const t = getToken();
|
||||
const q = t ? `?token=${encodeURIComponent(t)}` : "";
|
||||
return `${base}/api/v1/voice/stream${q}`;
|
||||
}
|
||||
|
||||
// float32 → int16 PCM(小端)。
|
||||
function floatToPCM16(f32: Float32Array): ArrayBuffer {
|
||||
const out = new DataView(new ArrayBuffer(f32.length * 2));
|
||||
for (let i = 0; i < f32.length; i++) {
|
||||
let s = Math.max(-1, Math.min(1, f32[i]));
|
||||
out.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
}
|
||||
return out.buffer;
|
||||
}
|
||||
|
||||
// 线性降采样到 16k(浏览器 AudioContext 常跑 48k,火山 ASR 要 16k)。
|
||||
function downsampleTo16k(input: Float32Array, inRate: number): Float32Array {
|
||||
if (inRate === UP_SAMPLE_RATE) return input;
|
||||
const ratio = inRate / UP_SAMPLE_RATE;
|
||||
const outLen = Math.floor(input.length / ratio);
|
||||
const out = new Float32Array(outLen);
|
||||
for (let i = 0; i < outLen; i++) out[i] = input[Math.floor(i * ratio)];
|
||||
return out;
|
||||
}
|
||||
|
||||
export class VoiceClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private cb: VoiceCallbacks;
|
||||
private state: VoiceState = "idle";
|
||||
|
||||
// 上行采集
|
||||
private micCtx: AudioContext | null = null;
|
||||
private micStream: MediaStream | null = null;
|
||||
private micNode: ScriptProcessorNode | null = null;
|
||||
private micAnalyser: AnalyserNode | null = null;
|
||||
|
||||
// 下行播放
|
||||
private playCtx: AudioContext | null = null;
|
||||
private nextStart = 0; // 下一段音频的调度起点(连续朗读用)
|
||||
private sources: AudioBufferSourceNode[] = [];
|
||||
private playAnalyser: AnalyserNode | null = null;
|
||||
private lvlBuf = new Uint8Array(512); // 复用的时域采样缓冲(level() 每帧读,别每帧新建)
|
||||
private drainTimer: number | null = null; // tts_end 后等排队音频放完的定时器
|
||||
|
||||
// 对话模式(免按键连续多轮):麦克风常开、VAD 自动断句提交、答完自动重听
|
||||
private conversation = false;
|
||||
private idleTimer: number | null = null; // 聆听空转定时器
|
||||
private idleDeadline = 0; // 空转退出时刻(epoch ms),UI 读它画最后 10s 倒计时环
|
||||
private thinkTimer: number | null = null; // 思考看门狗定时器
|
||||
|
||||
constructor(cb: VoiceCallbacks) {
|
||||
this.cb = cb;
|
||||
}
|
||||
|
||||
private setState(s: VoiceState) {
|
||||
this.state = s;
|
||||
this.cb.onState?.(s);
|
||||
}
|
||||
|
||||
getState(): VoiceState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
// connect 建立 WS,等待服务端 ready。
|
||||
async connect(): Promise<void> {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
||||
this.setState("connecting");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(wsURL());
|
||||
ws.binaryType = "arraybuffer";
|
||||
this.ws = ws;
|
||||
ws.onmessage = (e) => this.onMessage(e);
|
||||
ws.onerror = () => {
|
||||
this.cb.onError?.("语音连接出错");
|
||||
reject(new Error("ws error"));
|
||||
};
|
||||
ws.onclose = () => {
|
||||
this.conversation = false; // 连接断了对话就断,别留定时器空转
|
||||
this.clearIdleTimer();
|
||||
this.clearThinkTimer();
|
||||
this.stopMic();
|
||||
if (this.state !== "idle") this.setState("idle");
|
||||
};
|
||||
ws.onopen = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
private onMessage(e: MessageEvent) {
|
||||
if (typeof e.data === "string") {
|
||||
let m: { type: string; text?: string; final?: boolean; task_id?: string; msg?: string; action?: string; view?: string };
|
||||
try {
|
||||
m = JSON.parse(e.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
switch (m.type) {
|
||||
case "ready":
|
||||
this.setState("ready");
|
||||
break;
|
||||
case "transcript":
|
||||
this.cb.onTranscript?.(m.text ?? "", !!m.final);
|
||||
if (m.final) {
|
||||
this.clearIdleTimer();
|
||||
this.setState("thinking");
|
||||
this.armThinkTimer(); // 看门狗:后端不回/任务失败/无 TTS 时别永远卡在"思考中"
|
||||
} else if (this.conversation && this.state === "listening") {
|
||||
this.armIdleTimer(); // 有声音活动 → 空转计时重来
|
||||
}
|
||||
break;
|
||||
case "task":
|
||||
if (m.task_id) this.cb.onTask?.(m.task_id);
|
||||
break;
|
||||
case "reply":
|
||||
if (m.text) this.cb.onReply?.(m.text); // 打字机:回答增量文本
|
||||
break;
|
||||
case "speaking":
|
||||
this.clearThinkTimer();
|
||||
this.setState("speaking");
|
||||
break;
|
||||
case "tts_end":
|
||||
// 别 resetPlayback!服务端"音频发完" ≠ 客户端"播完":音频按 nextStart 预约到未来时刻播,
|
||||
// 而火山合成远快于真实语速,收到 tts_end 时大半音频还排在队列里没播。stop 掉就只剩前几个字。
|
||||
// 让排队音频自然放完,最后一段结束再回 ready/聆听。
|
||||
this.clearThinkTimer();
|
||||
if (this.state === "speaking") {
|
||||
// 对话模式:此刻就补发 start——服务端重建 ASR 的握手(几百毫秒)与排队音频播放重叠,
|
||||
// 音频一播完立刻能听;上行音频门控在 shouldSendMic,回聆听态前不会发。
|
||||
if (this.conversation) this.send({ type: "start" });
|
||||
this.drainThenReady();
|
||||
} else if (this.state === "thinking") {
|
||||
// 没进朗读就结束(TTS 启动失败/无音频):别干等看门狗,立刻进下一轮/回待命。
|
||||
if (this.conversation) {
|
||||
this.send({ type: "start" });
|
||||
this.cb.onTurnStart?.();
|
||||
this.setState("listening");
|
||||
this.armIdleTimer();
|
||||
} else {
|
||||
this.setState("ready");
|
||||
}
|
||||
}
|
||||
// 其余态(已打断回聆听等):忽略——这是上一轮 speak 收尾的迟到 tts_end,
|
||||
// 再发 start 会把用户已开口的新一轮重置掉。
|
||||
break;
|
||||
case "action":
|
||||
// JARVIS 界面动作(P2 动作通道):交给 UI 层白名单执行,客户端不盲信。
|
||||
this.cb.onAction?.(m.action ?? "", m.view ?? "", m.task_id ?? "");
|
||||
break;
|
||||
case "announce":
|
||||
// 主动播报(P3):文本进对话流;音频随后照常走 speaking/二进制帧/tts_end,
|
||||
// 现有状态机零新分支(speaking 会关上行门控,播完 drain 回聆听)。
|
||||
if (m.text) this.cb.onAnnounce?.(m.text);
|
||||
break;
|
||||
case "error":
|
||||
this.cb.onError?.(m.msg ?? "语音出错");
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 二进制帧 = 下行 TTS 音频(PCM 24k int16)
|
||||
this.enqueueAudio(e.data as ArrayBuffer);
|
||||
}
|
||||
|
||||
// ---- 上行:麦克风 ----
|
||||
|
||||
// startListening 开一轮:连接(若需)→ 若正在朗读先打断 → 采麦克风 → 发 start。
|
||||
async startListening(graph?: string): Promise<void> {
|
||||
await this.connect();
|
||||
// 关键:在**用户手势**(点麦克风)里就把播放 AudioContext 建好并 resume——否则它在 WS 回调里
|
||||
// 惰性创建会处于 suspended 态,TTS 音频静默播不出(Chrome/WKWebView 的自动播放策略)。
|
||||
this.ensurePlayCtx();
|
||||
if (this.state === "speaking") this.bargeIn();
|
||||
this.send({ type: "start", graph });
|
||||
await this.startMic();
|
||||
this.setState("listening");
|
||||
}
|
||||
|
||||
// ensurePlayCtx 建/复用下行播放 AudioContext,并在 suspended 时 resume(自动播放策略要求手势内唤醒)。
|
||||
private ensurePlayCtx(): AudioContext {
|
||||
if (!this.playCtx) this.playCtx = new AudioContext();
|
||||
if (!this.playAnalyser) {
|
||||
// 下行电平探针:所有 TTS 音频经它再到扬声器(驱动 HUD 的"说话"频谱)。
|
||||
const an = this.playCtx.createAnalyser();
|
||||
an.fftSize = 512;
|
||||
an.connect(this.playCtx.destination);
|
||||
this.playAnalyser = an;
|
||||
}
|
||||
if (this.playCtx.state === "suspended") void this.playCtx.resume();
|
||||
return this.playCtx;
|
||||
}
|
||||
|
||||
// level 返回当前"活跃"音频电平 0..1:说话读下行 TTS,其余读上行麦克风;无探针则 0。
|
||||
// 供 JARVIS HUD 每帧读取,让可视化随真实声音起伏。
|
||||
level(): number {
|
||||
const a = this.state === "speaking" ? this.playAnalyser : this.micAnalyser;
|
||||
if (!a) return 0;
|
||||
a.getByteTimeDomainData(this.lvlBuf);
|
||||
let s = 0;
|
||||
for (let i = 0; i < this.lvlBuf.length; i++) {
|
||||
const v = (this.lvlBuf[i] - 128) / 128;
|
||||
s += v * v;
|
||||
}
|
||||
return Math.min(1, Math.sqrt(s / this.lvlBuf.length) * 3.2);
|
||||
}
|
||||
|
||||
// stopListening 结束本轮说话:停麦克风 + 发 end(服务端拿最终转写→提交任务)。
|
||||
stopListening(): void {
|
||||
this.stopMic();
|
||||
this.send({ type: "end" });
|
||||
if (this.state === "listening") this.setState("thinking");
|
||||
}
|
||||
|
||||
// stopPTT 结束本轮说话但保留麦克风上下文:用于按住空格(PTT)松开发送场景。
|
||||
// 不销毁 micNode/micStream,下次重按 startListening 时 startMic 直接早返回,
|
||||
// 避免 getUserMedia / AudioContext 重建延迟,实现快速重按。
|
||||
stopPTT(): void {
|
||||
this.send({ type: "end" });
|
||||
if (this.state === "listening") this.setState("thinking");
|
||||
}
|
||||
|
||||
// bargeIn 打断当前朗读(用户又开口)。
|
||||
bargeIn(): void {
|
||||
this.send({ type: "barge_in" });
|
||||
this.resetPlayback();
|
||||
}
|
||||
|
||||
// ---- 对话模式:免按键连续多轮 ----
|
||||
|
||||
inConversation(): boolean {
|
||||
return this.conversation;
|
||||
}
|
||||
|
||||
// startConversation 进入连续对话:麦克风常开,之后 VAD 自动断句提交、答完自动重听,
|
||||
// 直到 stopConversation(点按退出 / 空转超时)。
|
||||
async startConversation(graph?: string): Promise<void> {
|
||||
await this.connect();
|
||||
this.ensurePlayCtx(); // 用户手势内建好播放上下文(自动播放策略)
|
||||
if (this.state === "speaking") this.bargeIn();
|
||||
this.send({ type: "start", graph });
|
||||
await this.startMic(); // 失败(无麦/无权限)直接抛给 UI,不进对话模式
|
||||
this.conversation = true;
|
||||
this.cb.onTurnStart?.();
|
||||
this.setState("listening");
|
||||
this.armIdleTimer();
|
||||
}
|
||||
|
||||
// stopConversation 退出连续对话:关麦、清定时器、回待命。幂等。
|
||||
stopConversation(): void {
|
||||
this.conversation = false;
|
||||
this.clearIdleTimer();
|
||||
this.clearThinkTimer();
|
||||
this.stopMic();
|
||||
if (this.state !== "idle") this.setState("ready");
|
||||
}
|
||||
|
||||
// interruptAndListen 对话模式里打断朗读并立即回聆听(点按打断,留在对话)。
|
||||
interruptAndListen(): void {
|
||||
this.bargeIn(); // 服务端掐 TTS + 本地清排队音频/drain 定时器
|
||||
this.clearThinkTimer();
|
||||
this.send({ type: "start" }); // 服务端重置轮 + 重建 ASR
|
||||
this.cb.onTurnStart?.();
|
||||
this.setState("listening");
|
||||
this.armIdleTimer();
|
||||
}
|
||||
|
||||
// ---- 健壮性定时器(仅对话模式)----
|
||||
|
||||
private armIdleTimer(): void {
|
||||
this.clearIdleTimer();
|
||||
this.idleDeadline = Date.now() + IDLE_EXIT_MS;
|
||||
this.idleTimer = window.setTimeout(() => {
|
||||
this.idleTimer = null;
|
||||
this.idleDeadline = 0;
|
||||
if (this.conversation && this.state === "listening") {
|
||||
this.stopConversation();
|
||||
this.cb.onError?.("长时间没听到声音,已退出连续对话");
|
||||
}
|
||||
}, IDLE_EXIT_MS);
|
||||
}
|
||||
|
||||
private clearIdleTimer(): void {
|
||||
this.idleDeadline = 0;
|
||||
if (this.idleTimer !== null) {
|
||||
window.clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// idleRemainingMs 距空转自动退出还剩多少毫秒;不在「对话中聆听」则返回 null。
|
||||
// UI 用它画最后 10s 的倒计时环(开口即 armIdleTimer 重置,环自然消失)。
|
||||
idleRemainingMs(): number | null {
|
||||
if (!this.conversation || this.state !== "listening" || this.idleDeadline === 0) return null;
|
||||
return Math.max(0, this.idleDeadline - Date.now());
|
||||
}
|
||||
|
||||
private armThinkTimer(): void {
|
||||
this.clearThinkTimer();
|
||||
this.thinkTimer = window.setTimeout(() => {
|
||||
this.thinkTimer = null;
|
||||
if (this.state !== "thinking") return;
|
||||
if (this.conversation) {
|
||||
this.cb.onError?.("等回答超时,继续聆听");
|
||||
this.send({ type: "start" });
|
||||
this.cb.onTurnStart?.();
|
||||
this.setState("listening");
|
||||
this.armIdleTimer();
|
||||
} else {
|
||||
// PTT:回待命并说清可能原因,别让用户对着"思考中"干等。
|
||||
this.cb.onError?.("等了很久没有回答——可能后端任务没跑起来(dispatcher/模型未就绪)。去运行页看看这条任务的状态。");
|
||||
this.setState("ready");
|
||||
}
|
||||
}, THINK_WATCHDOG_MS);
|
||||
}
|
||||
|
||||
private clearThinkTimer(): void {
|
||||
if (this.thinkTimer !== null) {
|
||||
window.clearTimeout(this.thinkTimer);
|
||||
this.thinkTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async startMic(): Promise<void> {
|
||||
if (this.micNode) return;
|
||||
// WKWebView(Wails 原生壳)等非安全上下文里 navigator.mediaDevices 可能未暴露——
|
||||
// 直接调用会抛 "undefined is not an object" 崩掉。先探测,给出可读提示而非崩溃。
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error("此窗口暂不支持麦克风(原生壳需授权/安全上下文)。用浏览器打开 localhost:5173 可直接说话。");
|
||||
}
|
||||
// 先看有没有麦克风设备:Mac Studio/Mini 等无内置麦克风的机器上,getUserMedia 会以
|
||||
// OverconstrainedError "Invalid constraint" 报错(误导),提前给准确提示。
|
||||
let devs: MediaDeviceInfo[] = [];
|
||||
try {
|
||||
devs = await navigator.mediaDevices.enumerateDevices();
|
||||
} catch {
|
||||
/* 拿不到设备列表就跳过预检,直接试 getUserMedia */
|
||||
}
|
||||
if (devs.length > 0 && !devs.some((d) => d.kind === "audioinput")) {
|
||||
throw new Error("没检测到麦克风设备。这台机器(如 Mac Studio/Mini)可能无内置麦克风——插个麦克风(带麦耳机 / USB 麦 / AirPods)再试。");
|
||||
}
|
||||
|
||||
// 依次尝试多种约束形式:WKWebView(原生壳)对约束挑剔,不同实现接受的形式不同。
|
||||
// 全失败则把**真实错误名**抛出来(区分是"约束不认"Overconstrained 还是"权限被拒"NotAllowed)。
|
||||
const tries: MediaStreamConstraints[] = [
|
||||
{ audio: true },
|
||||
{ audio: {} },
|
||||
{ audio: { echoCancellation: true, noiseSuppression: true } },
|
||||
];
|
||||
let stream: MediaStream | null = null;
|
||||
let lastErr: unknown;
|
||||
for (const c of tries) {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia(c);
|
||||
break;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
}
|
||||
if (!stream) {
|
||||
const err = lastErr as { name?: string; message?: string };
|
||||
const detail = `${err?.name ?? "Error"}: ${err?.message ?? String(lastErr)}`;
|
||||
if (err?.name === "NotAllowedError" || err?.name === "SecurityError") {
|
||||
throw new Error("麦克风权限被拒。请到 系统设置 → 隐私与安全性 → 麦克风 里允许本应用。");
|
||||
}
|
||||
throw new Error("麦克风打开失败:" + detail);
|
||||
}
|
||||
this.micStream = stream;
|
||||
const ctx = new AudioContext();
|
||||
this.micCtx = ctx;
|
||||
if (ctx.state === "suspended") await ctx.resume(); // 防采集上下文挂起(无回调=不上行音频)
|
||||
const src = ctx.createMediaStreamSource(stream);
|
||||
const an = ctx.createAnalyser(); // 上行电平探针(驱动 JARVIS HUD 的"聆听"反应)
|
||||
an.fftSize = 512;
|
||||
src.connect(an);
|
||||
this.micAnalyser = an;
|
||||
const node = ctx.createScriptProcessor(4096, 1, 1);
|
||||
node.onaudioprocess = (ev) => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
// 对话模式麦克风常开,但只在聆听态上行——thinking/speaking 不发(防回声 + 省 ASR 计费)。
|
||||
if (!shouldSendMic(this.state)) return;
|
||||
const f32 = ev.inputBuffer.getChannelData(0);
|
||||
const ds = downsampleTo16k(f32, ctx.sampleRate);
|
||||
this.ws.send(floatToPCM16(ds));
|
||||
};
|
||||
src.connect(node);
|
||||
node.connect(ctx.destination); // ScriptProcessor 需接上目的地才触发回调(下游静音)
|
||||
this.micNode = node;
|
||||
}
|
||||
|
||||
private stopMic(): void {
|
||||
this.micNode?.disconnect();
|
||||
this.micNode = null;
|
||||
this.micAnalyser = null;
|
||||
this.micStream?.getTracks().forEach((t) => t.stop());
|
||||
this.micStream = null;
|
||||
this.micCtx?.close().catch(() => {});
|
||||
this.micCtx = null;
|
||||
}
|
||||
|
||||
// ---- 下行:TTS 音频播放(排队调度,无缝连续)----
|
||||
|
||||
private enqueueAudio(buf: ArrayBuffer): void {
|
||||
if (buf.byteLength === 0) return;
|
||||
const ctx = this.ensurePlayCtx(); // 建/复用并 resume(防 WS 回调里上下文仍 suspended → 静默)
|
||||
|
||||
const i16 = new Int16Array(buf);
|
||||
const f32 = new Float32Array(i16.length);
|
||||
for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 0x8000;
|
||||
const audioBuf = ctx.createBuffer(1, f32.length, DOWN_SAMPLE_RATE);
|
||||
audioBuf.getChannelData(0).set(f32);
|
||||
|
||||
const node = ctx.createBufferSource();
|
||||
node.buffer = audioBuf;
|
||||
node.connect(this.playAnalyser ?? ctx.destination); // 经探针再到扬声器(读得到电平)
|
||||
const now = ctx.currentTime;
|
||||
if (this.nextStart < now) this.nextStart = now;
|
||||
node.start(this.nextStart);
|
||||
this.nextStart += audioBuf.duration;
|
||||
node.onended = () => {
|
||||
this.sources = this.sources.filter((s) => s !== node);
|
||||
};
|
||||
this.sources.push(node);
|
||||
}
|
||||
|
||||
private resetPlayback(): void {
|
||||
if (this.drainTimer !== null) {
|
||||
window.clearTimeout(this.drainTimer);
|
||||
this.drainTimer = null;
|
||||
}
|
||||
this.sources.forEach((s) => {
|
||||
try {
|
||||
s.stop();
|
||||
} catch {
|
||||
/* 已停忽略 */
|
||||
}
|
||||
});
|
||||
this.sources = [];
|
||||
this.nextStart = 0;
|
||||
}
|
||||
|
||||
// drainThenReady 处理 tts_end:不打断,等排队音频按调度自然放完,再去下一站——
|
||||
// 对话模式回聆听(自动重听,新一轮),否则回 ready 待命。
|
||||
// speaking 态要维持到真正播完(HUD 频谱靠它读 playAnalyser);播完把队列清干净。
|
||||
private drainThenReady(): void {
|
||||
if (this.drainTimer !== null) window.clearTimeout(this.drainTimer);
|
||||
const ctx = this.playCtx;
|
||||
const remainMs = ctx ? Math.max(0, (this.nextStart - ctx.currentTime) * 1000) : 0;
|
||||
this.drainTimer = window.setTimeout(() => {
|
||||
this.drainTimer = null;
|
||||
this.sources = [];
|
||||
this.nextStart = 0;
|
||||
if (this.state !== "speaking") return; // 期间被打断/新一轮改了态就不覆盖
|
||||
const next = nextAfterDrain(this.conversation);
|
||||
if (next === "listening") {
|
||||
this.cb.onTurnStart?.(); // 新一轮:UI 清上一轮转写/回答
|
||||
this.setState("listening"); // start 已在 tts_end 时提前发,ASR 握手与播放重叠
|
||||
this.armIdleTimer();
|
||||
} else {
|
||||
this.setState("ready");
|
||||
}
|
||||
}, remainMs + 120); // +120ms 余量,等末尾 source 真正 onended
|
||||
}
|
||||
|
||||
private send(m: { type: string; graph?: string }): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(m));
|
||||
}
|
||||
|
||||
// close 彻底关闭会话(发 bye、停采集/播放、断 WS)。
|
||||
close(): void {
|
||||
this.conversation = false;
|
||||
this.clearIdleTimer();
|
||||
this.clearThinkTimer();
|
||||
this.send({ type: "bye" });
|
||||
this.stopMic();
|
||||
this.resetPlayback();
|
||||
this.playCtx?.close().catch(() => {});
|
||||
this.playCtx = null;
|
||||
this.playAnalyser = null;
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
this.setState("idle");
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,14 @@ import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
import { applyInitialTheme } from "./lib/theme";
|
||||
import { ErrorBoundary } from "./ui/ErrorBoundary";
|
||||
|
||||
applyInitialTheme(); // 先于渲染设好主题类,避免首屏闪烁
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { QrCode, Trash2, Loader2 } from "lucide-react";
|
||||
import { Dialog, Button, Select, Badge, useToast } from "../ui";
|
||||
import { listInvites, createInvite, revokeInvite, type TenantInvite } from "../lib/api";
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = { admin: "管理员", member: "成员", viewer: "只读" };
|
||||
|
||||
// InviteMembers 租户成员「二维码邀请」弹窗:owner/admin 生成可复用团队码,
|
||||
// 成员用微信扫码关注即自动入组(后端 WxMPEvent inv_ 分支)。三道闸:有效期 + 人数 + 撤销。
|
||||
// 作用于当前活跃租户(后端取 ctx 租户,前端不传 id)。
|
||||
export function InviteMembers({ open, onClose, tenantName }: { open: boolean; onClose: () => void; tenantName: string }) {
|
||||
const toast = useToast();
|
||||
const [rows, setRows] = useState<TenantInvite[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [role, setRole] = useState("member");
|
||||
const [days, setDays] = useState(7);
|
||||
const [maxUses, setMaxUses] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fresh, setFresh] = useState<TenantInvite | null>(null); // 刚生成的,置顶大图展示
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setRows(await listInvites());
|
||||
} catch (e) {
|
||||
toast.push("error", (e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFresh(null);
|
||||
void refresh();
|
||||
}
|
||||
}, [open, refresh]);
|
||||
|
||||
const generate = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const inv = await createInvite(role, days, maxUses);
|
||||
setFresh(inv);
|
||||
toast.push("success", "已生成邀请二维码");
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast.push("error", (e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (inv: TenantInvite) => {
|
||||
if (!window.confirm("撤销这张邀请码?已加入的成员不受影响,但此码将无法再扫码加入。")) return;
|
||||
try {
|
||||
await revokeInvite(inv.id);
|
||||
if (fresh?.id === inv.id) setFresh(null);
|
||||
toast.push("success", "已撤销");
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast.push("error", (e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const usesLabel = (inv: TenantInvite) => (inv.max_uses > 0 ? `${inv.used_count}/${inv.max_uses} 人` : `${inv.used_count} 人(不限)`);
|
||||
const expLabel = (iso: string) => new Date(iso).toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }) + " 到期";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} title={`邀请成员 · ${tenantName}`}>
|
||||
{/* 生成表单 */}
|
||||
<div className="mb-3 flex items-end gap-2">
|
||||
<label className="text-[11px] text-slate-500">
|
||||
角色
|
||||
<Select className="mt-1 h-8 w-20 text-xs" value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<option value="member">成员</option>
|
||||
<option value="viewer">只读</option>
|
||||
<option value="admin">管理员</option>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="text-[11px] text-slate-500">
|
||||
有效期
|
||||
<Select className="mt-1 h-8 w-20 text-xs" value={days} onChange={(e) => setDays(Number(e.target.value))}>
|
||||
<option value={1}>1 天</option>
|
||||
<option value={7}>7 天</option>
|
||||
<option value={30}>30 天</option>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="text-[11px] text-slate-500">
|
||||
人数上限
|
||||
<Select className="mt-1 h-8 w-24 text-xs" value={maxUses} onChange={(e) => setMaxUses(Number(e.target.value))}>
|
||||
<option value={0}>不限</option>
|
||||
<option value={1}>1 人</option>
|
||||
<option value={5}>5 人</option>
|
||||
<option value={20}>20 人</option>
|
||||
</Select>
|
||||
</label>
|
||||
<Button variant="primary" size="sm" icon={busy ? Loader2 : QrCode} onClick={generate} disabled={busy}>
|
||||
生成
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 刚生成的码:大图展示,便于当场扫 */}
|
||||
{fresh && (
|
||||
<div className="mb-3 flex flex-col items-center rounded-lg border border-brand/40 bg-ink-800/60 p-3">
|
||||
{fresh.qr_image ? (
|
||||
<img src={fresh.qr_image} alt="邀请二维码" className="h-44 w-44 rounded bg-white p-1" />
|
||||
) : (
|
||||
<div className="flex h-44 w-44 items-center justify-center text-xs text-slate-500">二维码加载中…</div>
|
||||
)}
|
||||
<p className="mt-2 text-center text-[11px] text-slate-400">
|
||||
让对方用<b className="text-slate-200">微信扫一扫</b>关注公众号即自动加入
|
||||
<br />
|
||||
身份:{ROLE_LABEL[fresh.role] ?? fresh.role} · {usesLabel(fresh)} · {expLabel(fresh.expires_at)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 有效邀请码列表 */}
|
||||
<div className="mb-1 text-[11px] text-slate-500">有效邀请码</div>
|
||||
<ul className="max-h-56 space-y-1 overflow-auto">
|
||||
{loading && <li className="px-1 py-2 text-[11px] text-slate-500">加载中…</li>}
|
||||
{!loading && rows.length === 0 && <li className="px-1 py-2 text-[11px] text-slate-600">还没有邀请码,生成一张发给团队。</li>}
|
||||
{rows.map((inv) => (
|
||||
<li key={inv.id} className="flex items-center gap-2 rounded-md border border-line bg-ink-800/60 px-2.5 py-2">
|
||||
<button onClick={() => setFresh(inv)} title="查看二维码" className="shrink-0 text-slate-500 transition hover:text-brand-400">
|
||||
<QrCode className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-200">
|
||||
<Badge tone={inv.role === "admin" ? "accent" : inv.role === "viewer" ? "warn" : "neutral"}>{ROLE_LABEL[inv.role] ?? inv.role}</Badge>
|
||||
<span className="text-[11px] text-slate-400">{usesLabel(inv)}</span>
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-slate-500">{expLabel(inv.expires_at)}</div>
|
||||
</div>
|
||||
<button onClick={() => revoke(inv)} className="text-slate-600 transition hover:text-danger" title="撤销">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user