Compare commits

..
61 Commits
Author SHA1 Message Date
skycurtain 29019fc5d5 refactor(cli): naku 与 pack:release 合并为 pack CLI——release/build 子命令,--yes 非交互 2026-10-29 15:36:06 +08:00
skycurtain 8d4a5c4f94 docs: README 润色收尾——定位中性化、补适用场景与 AI 协作物料说明 2026-10-29 00:45:34 +08:00
skycurtain ddb1b56459 chore: MIT 许可证落地——LICENSE 全文、package.json license 字段与 README 边界声明 2026-10-28 23:14:41 +08:00
skycurtain fdc5942a72 feat(web): 管理端日志来源徽章支付弹窗与支付记录筛选——订阅 ID / 交易号可筛,弹窗可跳转核对补录 2026-10-18 12:22:02 +08:00
skycurtain 199972a2dc feat: 调用日志补模型筛选与汇总条——可按模型收窄查询,并在当前筛选下直接读出请求次数与消费用量 2026-10-14 08:01:58 +08:00
skycurtain b0e9ea750b feat: 账本域 billing_domain 与成员支付可见性收紧
三表增加账本域标签,成员支付参数改为同域透明、跨域隔离、未标域默认藏;管理面登记/筛选/行内编辑与文档中心同步对齐。
2026-10-13 20:42:26 +08:00
skycurtain 843ab4433a fix(web): 复制令牌兼容 HTTP 非 Secure Context 2026-10-13 12:42:11 +08:00
skycurtain 75a567611b fix(deploy): COPY --chmod=755 根治单文件二进制丢失可执行位
Windows 打包与跨机传输会使 naku-* 丢掉 +x,容器内 exec 以 126
(Permission denied)退出,migrate 失败后 server/engine 不会启动。
镜像内用 COPY --chmod=755 固定可执行位,并同步部署方案说明。
2026-10-13 11:03:35 +08:00
skycurtain c0051cff03 feat(deploy): Caddy 对外端口支持 HTTP_PORT / HTTPS_PORT 配置 2026-10-13 10:39:12 +08:00
skycurtain 2dbd625e87 docs: 对齐实现状态——进程拆分、部署与支付拆表相关稿定稿 2026-10-13 01:11:13 +08:00
skycurtain 905d4ce77e feat(deploy): 产物包部署链路与 naku 打包 CLI
Docker Compose 五服务 + Debian slim 单文件运行时;migrate 幂等门闩;
naku pack/build(citty + clack);部署方案文档入档。
2026-10-13 00:56:43 +08:00
skycurtain 86782c63ad chore(deploy): 移除 Docker 配置与文档中的部署方案,待从代码重议 2026-10-12 19:02:38 +08:00
skycurtain 9966f03347 feat(web): 时间选择器增加「今天」快捷项
北京日界左闭右开;相对窗口可跨日自动重算。
2026-10-12 15:00:29 +08:00
skycurtain d0bab545b1 chore: 服务启动不再自动迁移数据库
schema 变更改为显式 db:push / 手动应用后再启动。
2026-10-12 13:57:25 +08:00
skycurtain f4f5ed0b0f feat(member): 档案修改与空壳删除
可改 name/remark;key_count=0 时硬删,名下有 key 仅可停用。PATCH 扩档案字段并落审计。
2026-10-12 13:57:19 +08:00
skycurtain e293a8e5a9 docs: R16 成员档案修改与空壳删除裁决入档
成员操作面扩为可改 name/remark、key_count=0 时硬删空壳;软删除仍是账本实体默认语义。
2026-10-11 22:41:43 +08:00
skycurtain 34a7823e1a feat(web): 根路径与未知路径默认进入成员端
管理端入口形态待定,当前经地址栏 /admin 进入;交互设计访问拓扑同步。
2026-10-10 16:47:01 +08:00
skycurtain ea4efbe1d7 feat(deploy): 一条命令链部署——双单文件 + Caddy + DATABASE_URL 单源
多阶段镜像编出 server/engine 单文件与前端静态;compose 起 pg/server/engine/caddy;
启动时自动迁移;仅用 DATABASE_URL,postgres 建库参数由 entrypoint 拆解。
2026-10-10 16:46:54 +08:00
skycurtain 5f76bfc9d6 docs: 清除版本叙事并按开源文档体系整理
去掉初版/新版与分支指称,重写 README/AGENTS,删子包 README,对齐文档交叉引用与已废止测试脚本。
2026-10-10 07:56:07 +08:00
skycurtain 8816c32025 chore: vite 固定 5175,脚本统一 --cwd,清理未用导入 2026-10-10 01:36:57 +08:00
skycurtain 4be0853ade feat(web): 表格工具行统一刷新钮
纯图标 + tooltip,筛选下一行右对齐;覆盖管理端与成员端列表;清理 overview 未用 ref。
2026-10-09 23:42:24 +08:00
skycurtain 4a7a3e76ee fix(web): 成员侧边栏品牌与管理端对齐 2026-10-09 23:20:41 +08:00
skycurtain 502f985147 feat(web): 成员端仅总览轮询,相对时间窗定格 2026-10-09 23:20:05 +08:00
skycurtain e3e0978b95 fix(web): 订阅表付款列头改为「付款金额 / 时间」 2026-10-09 22:43:18 +08:00
skycurtain c32548e598 fix(web): 支付表列与表单语义校准
充值金额/实付金额文案与 dataIndex;期间两行;备注列收窄;
选用弹层 booting 不闪登录;订阅 paid_at 不默认当前时间。
2026-10-09 22:41:04 +08:00
skycurtain 00c08b37ff docs: payments 拆表设计定稿与契约/交互对齐
新增建模评估与 subscriptions/topups 表设计;删除 payments 单表篇;
契约、支付交互、需求清单与文档地图同步。
2026-10-09 22:03:43 +08:00
skycurtain c03fdc3d67 feat(web): 支付记录双 Tab 与平台选用弹层
订阅/充值分形态表单(秒级时间、金额非负、额度换算预览);
选用弹层已登记灰显、每次打开重拉、关弹窗清密码。
2026-10-09 22:02:36 +08:00
skycurtain 10d8f4c915 feat(server): 订阅/充值 CRUD 与平台选用分页
admin-subscriptions/topups 取代 payments;member overview 分两集;
platform/topups 单页代拉;上游 token 失效回 422,避免误踢管理端会话。
2026-10-09 22:02:20 +08:00
skycurtain c7cc6f6dfd feat(db): payments 拆为 subscriptions 与 topups
订阅权益与充值入账分表;topups 对齐上游充值单(无 quota_granted),
paid_at=complete_time;quota_derived 查询层派生。
2026-10-09 22:02:10 +08:00
skycurtain d4d740222e fix(web): 平台订阅选用弹窗居中并限高内滚 2026-10-09 20:50:15 +08:00
skycurtain 0269cf3386 feat(web): 成员端同构三页与日志详情,管理端轻量筛选
成员视图改管理端壳(总览/调用日志/支付参数),行内详情弹窗含原始数据;
成员日志含错误行;共享时间范围。管理端成员/支付筛选;支付仅留类型筛选。
2026-10-09 17:04:59 +08:00
skycurtain 665fa088f5 feat(web): 管理端菜单顺序与总览操作列查调用 2026-10-09 13:42:14 +08:00
skycurtain 31d963e956 fix(core): 明细用量美元保留 6 位小数 2026-10-09 13:42:13 +08:00
skycurtain 29f5f4f84c fix(web): 成员视图模型列与支付结构,范围 watch 勿误重置 2026-10-09 02:28:21 +08:00
skycurtain 431b27ea58 docs: 调用日志交互定稿,导航与契约界面落点对齐 2026-10-09 01:42:27 +08:00
skycurtain 008eb14adc feat(web): 管理面调用日志——筛选明细与 key/成员跳转链路 2026-10-09 01:41:45 +08:00
skycurtain 2e91c593ab feat(web): 成员视图——聚合、支付参数、key 列表与日志徽章展开 2026-10-08 23:57:54 +08:00
skycurtain b2f65a1d55 chore: 纳入 antdv 协作 skills,忽略本机 Agent 接线目录 2026-10-08 23:42:48 +08:00
skycurtain 932e74ba3b fix: 取消密码长度规则,仅保留必填 2026-10-08 23:27:43 +08:00
skycurtain 4d31c20d35 feat(web): 审计表体内滚动、时间范围筛选与分页档位 2026-10-08 23:27:42 +08:00
skycurtain 66a9a93914 feat(web): 审计日志表与账号设置改密 2026-10-08 23:02:16 +08:00
skycurtain 1b57b75be0 fix(web): 未登记订阅提示改为单条 Alert + Tag 编号换行 2026-10-08 22:49:37 +08:00
skycurtain ed2ff0f950 feat: 支付登记从平台选择订阅——登录拉取预填,密码不落库 2026-10-08 22:32:33 +08:00
skycurtain 29e0317155 fix(server): 数据截止仅统计 active key,停用 key 不拉慢全池水位 2026-10-08 15:32:07 +08:00
skycurtain b33fc64a28 feat(web): 请求时间改为 ISO8601+08:00 偏移,响应仍为 Unix 秒 2026-10-08 14:12:07 +08:00
skycurtain 8b54629159 feat(web): 支付记录——同构登记表单、额度双输入与核对提示 2026-10-08 00:47:05 +08:00
skycurtain 9aed3cbe8d fix(web): Dropdown trigger 传数组以消除 prop 警告 2026-10-08 00:47:04 +08:00
skycurtain b808be324d fix(web): 成员名称列 dataIndex;改 key 归属后刷新成员缓存 2026-10-08 00:29:45 +08:00
skycurtain b16ddb1acb feat(web): 成员管理——清单表、开户令牌明文、重发与启停 2026-10-08 00:22:27 +08:00
skycurtain adbb6adb93 docs: 分支名同步为 v1,校准 README 当前状态 2026-10-07 23:48:17 +08:00
skycurtain 0adae25a8c fix(web): Key 停用确认拆为标题与说明两行 2026-10-07 23:48:17 +08:00
skycurtain 311643065c feat(web): Key 管理——清单表、行内归属启停、登记四态与停滞显形 2026-10-07 23:05:33 +08:00
skycurtain 2446d3bf2c feat(web): 总览时间范围随日历推进,聚合与引擎状态周期刷新 2026-10-07 21:17:22 +08:00
skycurtain 3fb75ec699 fix(web): 健康带数据截止文案区分失败 key 与链路停滞 2026-10-07 21:17:12 +08:00
skycurtain bd5a9bf99f feat(web): 全局中文 locale;管理面固定外壳、内容区独立滚动 2026-10-07 20:56:55 +08:00
skycurtain 7ff87888a3 fix(server): 总览 by_key 返回 member_name,归属按成员表解析 2026-10-07 20:56:47 +08:00
skycurtain 293141ea0a feat(web): 全员总览——时间范围、健康带、来源分解、成员表与 key 明细 2026-10-07 20:56:38 +08:00
skycurtain d6d7d9411f chore(web): Vite 开发代理 /api 到母体后端 2026-10-07 20:56:32 +08:00
skycurtain 716ff96b07 feat(web): 用户下拉菜单改为账号设置/退出,修复会话用户名空显示 2026-10-07 19:54:51 +08:00
skycurtain 129ce7e380 feat(web): 布局壳与 NAKU 品牌——侧栏图标菜单、两行品牌区、favicon 2026-10-07 17:06:53 +08:00
skycurtain fbb989c3fb feat(web): 前端逻辑骨架——http/router/stores/queries 与薄壳路由 2026-10-07 14:36:06 +08:00
137 changed files with 15318 additions and 1129 deletions
@@ -0,0 +1,343 @@
---
name: antdv-next-cli-create-pr
description: Create pull requests for antdv-next/cli using the repository's PR conventions. Use this skill when the user asks to create/open a PR, draft PR title/body, summarize branch changes for a PR, or otherwise prepare PR content. Judge by intent rather than fixed phrases; short colloquial requests still count if they are about creating a PR rather than discussing PR concepts.
---
# Antdv Next CLI PR 创建规范
## 目标
一、基于当前分支相对基线分支的全部改动生成 PR,不只看最后一个 commit。
二、优先使用仓库自带 PR 模板;当前没有模板时,使用本 skill 约定的 `Summary` / `Testing` 结构。
三、PR 正文默认使用英文,用户明确要求中文时可使用中文;PR 标题始终使用英文,并遵循本文档约定的命名格式。
四、真正执行 `gh pr create` 之前,必须先把 `base`、`title`、`body` 给用户确认,确认后才能创建 PR。
## 基本规则
### 一、按意图触发,不按短语触发
只要能判断用户是在请求创建 PR,或为创建 PR 做准备,就应使用本 skill。
不要把触发限制成固定说法。即使用户表达很短、很口语,或要求不完整,只要不是在单纯讨论 PR 概念,也应进入本 skill 的工作流。
### 二、正文结构以仓库约定为准
当前仓库没有 `.github/PULL_REQUEST_TEMPLATE*`。默认使用:
- `## Summary`
- `## Testing`
如果仓库之后新增 PR 模板,必须先读取并使用模板,不要自行改 section 名称或删除主结构。
### 三、正文默认英文,但标题固定英文
现有 PR 正文以英文为主,因此默认使用英文正文。用户明确要求中文时可以使用中文正文。
无论正文使用哪种语言:
- `PR title` 都必须是英文
- `PR title` 必须符合 Conventional Commits 格式
- 标题 subject 必须以小写字符开头,以通过 `.github/workflows/validate-pr-title.yml`
### 四、先分析分支,再写 PR
创建 PR 前,必须先看:
- 当前分支名
- 基线分支
- 当前分支相对基线分支的 commit 列表
- `base...HEAD` 的完整 diff
不要只根据工作区未提交内容写 PR,也不要只根据最近一个 commit 写 PR。
### 五、先给草稿,后创建 PR
无论用户是否说“直接帮我创建 PR”,都要先完成以下步骤:
1. 生成 `base`、`title`、`body` 草稿
2. 明确告诉用户:这是准备提交的 PR 内容
3. 让用户确认是否继续创建,或先修改
4. 只有用户明确确认后,才能真正执行 `gh pr create`
若用户中途要求修改标题、类型、测试说明、目标分支等,应先更新草稿,再次确认。
### 六、标题和正文要分工明确
- PR 标题:用英文一句话概括本分支最主要的变动
- PR 正文:说明主要结果、关键方案、关联 issue 和实际验证结果
正文不是逐文件流水账。要归纳“为什么改”和“改完后对开发者/用户有什么影响”。
### 七、信息不足时不要硬写
若以下内容缺失且无法从分支改动中可靠推断:
- 基线分支
- 关联 issue
- 变动性质
- 测试或验证方式
可以先给出草稿,并把无法确认的地方保留为待补充项;若用户要求直接创建 PR,也必须先说明缺失项并等待确认。
## 执行步骤
### 1. 检查仓库和 PR 环境
建议先确认:
```bash
git status --short
git branch --show-current
git remote -v
gh auth status
```
若 `gh` 不可用、未登录、当前不在 git 仓库、或当前分支不适合提 PR,应先说明问题,不要继续伪造结果。
### 2. 确定基线分支
不要未经分析就直接使用 `main`。按以下顺序判断:
1. 用户明确指定了 `base branch` -> 直接使用
2. 若当前分支存在可用的“来源线索”,优先根据真实 Git 信息推断:
- `git branch -vv` 查看 tracking / upstream
- `git reflog show <current-branch>` 查看是否能看出“从哪条分支 checkout 出来”
- 必要时结合 `git merge-base HEAD <candidate-branch>` 比较分叉点
3. 若能较可靠判断“当前分支是从某条分支切出来的”,优先使用该分支作为 `base`
4. 若无法可靠推断,再退回远端默认分支或仓库默认分支
建议查看:
```bash
git branch --show-current
git branch -vv
git reflog show --date=local $(git branch --show-current)
git remote show origin
```
注意:
- tracking / upstream 只能作为线索,不等于绝对正确的“父分支”
- `reflog` 若已清理,可能无法得到结果
- 若推断结果不够确定,要在草稿中明确标注为“推断值”
#### 新功能分支说明
本仓库的新功能 PR 通常也提交到 `main`。只有用户明确要求 stacked PR,或 Git 历史能可靠证明当前分支基于另一功能分支时,才使用非 `main` 的 base;不要仅因类型为 `feat` 擅自改 base。
### 3. 收集本分支全部改动
至少查看:
```bash
git log --oneline <base>..HEAD
git diff --stat <base>...HEAD
git diff <base>...HEAD
```
必要时再看:
```bash
git diff --name-only <base>...HEAD
```
归纳时要覆盖该分支会进入 PR 的全部提交,而不是只写最后一次改动。
### 4. 确定正文结构和语言
先检查是否存在 `.github/PULL_REQUEST_TEMPLATE*`:
- 存在:读取并使用仓库模板
- 不存在:使用 `Summary` / `Testing` 结构
正文默认使用英文;用户明确要求中文时再使用中文。
### 5. 判断 PR 类型
必须根据整条分支的“主结果”判断,不要被单个文件或 commit 干扰。
优先判断:
1. 新增 CLI、Agent、MCP 或集成方可用能力 -> `feat`
2. 修复错误行为 -> `fix`
3. 只修改文档或 skill -> `docs`
4. 主要修改构建、打包配置 -> `build`
5. 主要修改 workflow / CI -> `ci`
6. 主要修改测试 -> `test`
7. 无外部行为变化的代码整理 -> `refactor`
8. 性能优化 -> `perf`
9. 依赖或内部维护 -> `chore`
10. 版本发布 -> `release`
例如:
- 新增 MCP tool -> `feat(mcp)`
- 修复 changelog 版本解析 -> `fix(changelog)`
- 更新 agent skill 说明 -> `docs(skills)`
- 调整 tsdown 打包配置 -> `build(config)`
- 修改 PR 校验 workflow -> `ci`
### 6. 归纳 PR 的核心信息
至少整理出:
- 主要结果:本分支解决什么问题或新增什么能力
- Related Issues:有可靠关联时填写 issue 链接或 `Closes #xxxx` / `Fixes #xxxx`
- Summary:归纳用户影响、关键方案和重要约束
- Testing:只列出本次实际执行并通过的命令
- 兼容性:说明是否有 CLI 参数、输出格式、配置、MCP API 或打包产物变化
### 7. 处理 Testing
根据改动范围选择验证:
- 所有 PR:`pnpm lint`
- 修改 `src/**` 或 `test/**`:`pnpm build` 和 `pnpm test`
- 涉及 CLI 执行、MCP、setup、打包或跨模块集成:考虑 `pnpm test:all`
- 通用补充检查:`git diff --check`
只写实际执行且通过的命令。未执行时写 `Not run (reason)`;失败时如实说明失败命令,不要写成通过。
### 8. 生成 PR 标题
标题要求:
- 按下方“写法要求 -> 标题”生成
- 覆盖整条分支的主要目标
- 不要照搬单个 commit message
- `type` 要与第 5 步判断一致
### 9. 按约定产出 PR 正文草稿
没有仓库模板时使用:
```markdown
## Summary
- <主要结果或用户影响>
- <关键方案或重要约束>
## Testing
- `<实际通过的命令>`
```
填写时遵守:
- Summary 通常使用 2 至 4 个要点
- Testing 只列实际结果
- 有可靠关联 issue 时加入 `Closes #xxxx`、`Fixes #xxxx` 或链接
- 涉及命令输出或交互变化时,提醒补充必要的终端输出、截图或示例
- 有破坏性变化、迁移要求或公开行为变化时明确写出
- 信息尚未确认时显式标注,不要假装确定
### 10. 先给用户确认
输出时至少包含:
- `Base branch`
- `PR title`
- `PR body`
- 实际执行的测试及结果
- 需要用户补充或确认的点
明确询问用户是否:
- 直接创建 PR
- 先修改后再创建
没有明确确认前,不得执行 `gh pr create`。
### 11. 创建 PR
只有在用户明确确认后,才执行。
执行前再次检查:
```bash
git status --short
git branch -vv
git remote -v
gh repo view --json nameWithOwner,defaultBranchRef
```
要求:
1. 确认当前分支的 tracking remote 和远端分支正确
2. 确认 PR 的目标仓库是 `antdv-next/cli`,不要依赖 `gh` 默认推断
3. 若 tracking remote 缺失、指向不明确、或不是预期 fork,先向用户确认,不要默认推送
4. 只有在推送目标 remote 明确无误时,才推送当前分支
5. 使用已确认过的标题和正文执行 `gh pr create`
6. 只有用户明确要求 Draft PR 时才使用 `--draft`
7. 相同 head/base 已存在 PR 时,返回现有 PR,不要重复创建
若需要推送,优先使用明确的远端与分支名:
```bash
git push -u <remote> HEAD
```
正文较长时写入安全的临时文件,并使用:
```bash
gh pr create \
--repo antdv-next/cli \
--base <base> \
--head <owner>:<branch> \
--title "<title>" \
--body-file <body-file>
```
同仓库分支可以只传 `<branch>`;fork 分支使用 `<owner>:<branch>`。创建成功后返回 PR 链接。
## 写法要求
### 标题
- 必须是英文
- 默认先判断 `type`,再决定是否需要 `scope`
- 优先使用 `type: subject` 或 `type(scope): subject`
- 优先写结果,不写过程
- 避免 `update`, `fix issues`, `misc changes` 这类空话
- 若分支包含多类小改动,提炼一个更高层概括
常用 `type` 参考:
- `feat`:新增能力
- `fix`:修复问题
- `docs`:文档或 skill
- `refactor`:重构
- `test`:测试改动
- `build`:构建或打包
- `ci`:CI 或 workflow
- `chore`:杂项维护
- `perf`:性能优化
- `release`:版本发布
`scope` 使用规则:
- 改动集中在单个功能边界时再加,如 `refactor(changelog): ...`
- 常见 scope 包括 `cli`、`mcp`、`changelog`、`version`、`bug`、`setup`、`skills`、`package`
- 若没有明显聚焦对象,就不要硬加 `scope`
- 不要把目录名机械塞进 `scope`
### Summary
- 先写本分支解决的问题或新增的能力
- 再写关键方案和重要约束
- 若涉及 CLI 参数、输出格式、配置、MCP API 或打包产物变化,点明外部可感知差异
- 不要写逐文件流水账
### Testing
- 只列本次实际执行并通过的命令
- 未运行时写 `Not run (reason)`
- 失败时如实说明,不要伪造通过结果
- 根据改动范围参考 `pnpm lint`、`pnpm build`、`pnpm test`、`pnpm test:all` 和 `git diff --check`
## 参考
更多类型判断、基线分支建议、确认话术与标题示例见 `references/template-notes-and-examples.md`。
@@ -0,0 +1,174 @@
## PR 类型建议
优先选择最贴近整条分支主结果的一个 type:
- 新增 CLI、Agent、MCP 或集成能力:`feat`
- 修复错误行为:`fix`
- 只修改文档或 skill:`docs`
- 主要修改测试:`test`
- 无外部行为变化的整理:`refactor`
- 性能优化:`perf`
- 构建或打包配置:`build`
- GitHub Actions / CI:`ci`
- 依赖或内部维护:`chore`
- 版本发布:`release`
## 类型判断补充说明
不要因为 diff 里包含逻辑代码就直接判成 `fix`,先看最终结果。
优先判断:
1. `feat` 只用于对外新增能力,不是“代码变多了”
2. `fix` 用于修复真实错误行为
3. 文档、skill、构建和 CI 改动优先使用各自 type
4. 以用户、CLI 调用方或维护者最终感知到的结果为准
示例:
- 新增 MCP 查询工具 -> `feat(mcp): ...`
- 修复版本 fallback 逻辑 -> `fix(version): ...`
- 更新 agent skill -> `docs(skills): ...`
- 调整 tsdown 配置 -> `build(config): ...`
- 修改 PR title workflow -> `ci: ...`
## Related Issues 写法
有明确 issue 时:
- `Closes #123`
- `Fixes #123`
- `Refs #123`
没有可靠关联时可以不写,或写 `None`。不要编造 issue 编号。
## Summary 写法
推荐使用 2 至 4 个要点,回答:
1. 原先有什么问题,或为什么需要新能力
2. 这次改变了什么
3. 是否影响 CLI 参数、输出格式、配置、MCP API 或包内容
4. 是否有破坏性变化或迁移要求
英文示例:
```markdown
## Summary
- keep changelog version resolution inside the diff utility
- simplify the command layer to pass resolved versions
- preserve existing CLI output with no public API changes
```
中文示例:
```markdown
## Summary
- 将 changelog 版本解析收敛到 diff 工具内部
- 简化命令层,只传递已解析版本
- 保持现有 CLI 输出,不涉及公开 API 变化
```
## Testing 写法
Testing 只记录实际执行并通过的命令,不要根据代码改动猜测结果。
常见验证:
- 所有 PR:`pnpm lint`
- 修改 `src/**` 或 `test/**`:`pnpm build`、`pnpm test`
- CLI、MCP、setup、打包或跨模块集成:考虑 `pnpm test:all`
- 通用检查:`git diff --check`
示例:
```markdown
## Testing
- `pnpm lint`
- `pnpm build`
- `pnpm test`
- `git diff --check`
```
未执行时:
```markdown
## Testing
- Not run (documentation-only change)
```
若测试失败,要写明失败命令和原因,不能写成通过。
## 基线分支判断建议
目标是尽量推断“当前分支实际从哪里切出来”,而不是拍脑袋默认 `main`。
建议顺序:
1. 用户明确指定了 `base branch` -> 直接使用
2. 查看当前分支是否能从 `reflog` 看出 checkout 来源
3. 查看 `git branch -vv` 的 tracking / upstream 作为辅助线索
4. 必要时结合 `merge-base` 比较候选分支
5. 若仍无法确定,再退回远端默认分支或仓库默认分支
建议命令:
```bash
git branch --show-current
git branch -vv
git reflog show --date=local $(git branch --show-current)
git remote show origin
git merge-base HEAD <candidate-branch>
```
注意:
- upstream 不是绝对父分支,只是候选线索
- `reflog` 最接近真实答案,但不一定一直存在
- 不确定时要明确告诉用户“这是推断值”
## 创建 PR 前确认话术建议
在真正执行 `git push` 或 `gh pr create` 前,先给用户确认版草稿:
```markdown
我先整理了一版待提交的 PR 草稿,请你确认:
- Target repository: `antdv-next/cli`
- Base branch: `main`
- Head branch: `refactor/changelog-version-loading`
- PR title: `refactor(changelog): move version loading into diff utility`
- Testing: `pnpm lint`, `pnpm test`
如果没问题,我再继续推送并创建 PR;如果你想改 title、base、正文或 Draft 状态,我先更新草稿。
```
## PR 标题示例
`antdv-next/cli` 的 PR 标题使用英文,并遵循:
- `<type>: <subject>`
- `<type>(<scope>): <subject>`
subject 必须以小写字符开头,以通过 `.github/workflows/validate-pr-title.yml`。
推荐示例:
- `feat(mcp): add component changelog query tool`
- `fix(version): resolve missing patch versions with fallback`
- `docs(skills): clarify CLI usage for agents`
- `refactor(changelog): move metadata loading into diff utility`
- `build(config): optimize tsdown bundle output`
- `ci: validate pull request title format`
- `chore(package): include skills in published files`
不要这样写:
- `修复版本解析问题`
- `Update version resolver`
- `fix issues`
- `some improvements`
+247
View File
@@ -0,0 +1,247 @@
---
name: antdv
description: >
Use when the user's task involves antdv-next — writing antdv-next components,
debugging antdv-next issues, querying antdv-next props/events/slot/tokens/demos, migrating between
antdv-next versions, or analyzing antdv-next usage in a project. Triggers on antdv-next-related
code, imports from 'antdv-next', or explicit antdv-next questions.
allowed-tools:
- Bash(antdv *)
- Bash(antdv bug*)
- Bash(antdv bug-cli*)
- Bash(npm install -g @antdv-next/cli*)
- Bash(which antdv)
---
# Ant Design CLI
You can visit `@antdv-next/cli` - a local cli tool that contains metadata about the current, latest version of antdv-next. Use it to query component knowledge, analyze projects, and guide migrations. All data is offline and does not require a network.
## Setup
Before first use, check if the CLI is installed. If not, install it automatically:
```bash
which antdv || npm install -g @antdv-next/cli
```
After running any command, if the output contains an "Update available" notice, run `antdv upgrade` to update before continuing.
**Always use `--format json` for structured output you can parse programmatically.**
## Scenarios
### 1. Writing antd component code
Before writing any antd component code, look up its API first — don't rely on memory.
```bash
# Check what props are available
antdv info Button --format json
# Get a working demo as starting point
antdv demo Button basic --format json
# Check semantic classNames/styles for custom styling
antdv semantic Button --format json
# Check component-level design tokens for theming
antdv token Button --format json
# Get the overall design language (design.md): colors, typography, spacing, radius + principles
antdv design.md --format json
```
**Workflow:** `antdv info` → understand props/event/slot → `antdv demo` → grab a working example → write code.
### 2. Looking up full documentation
When you need comprehensive component docs (not just props):
```bash
antdv doc Table --format json # full markdown docs for Table
antdv doc Table --lang zh # Chinese docs
```
### 3. Debugging antd issues
When code isn't working as expected or the user reports an antdv bug:
```bash
# Collect full environment snapshot (system, deps, browsers, build tools)
antdv env --format json
# Check if the prop exists for the user's antd version
antdv info Select --version 5.12.0 --format json
# Check if the prop is deprecated
antdv lint ./src/components/MyForm.tsx --format json
# Diagnose project-level configuration issues
antdv doctor --format json
```
**Workflow:** `antdv env` → capture full environment → `antdv doctor` → check configuration → `antdv info --ver X` → verify API against the user's exact version → `antdv lint` → find deprecated or incorrect usage.
### 4. Analyzing project antd usage
When the user wants to understand how antd is used in their project:
```bash
# Scan component usage statistics
antdv usage ./src --format json
# Filter to a specific component
antdv usage ./src --filter Form --format json
# Lint for best practice violations
antdv lint ./src --format json
# Check only specific rule categories
antdv lint ./src --only deprecated --format json
antdv lint ./src --only a11y --format json
antdv lint ./src --only performance --format json
```
### 5. Comparing component APIs between versions
When the user asks what component APIs changed between versions:
```bash
# Compare props, events, slots, and methods for all components
antdv changelog 1.2.2 1.5.2 --format json
# Limit the comparison to one component
antdv changelog 1.2.2 1.5.2 Select --format json
```
### 6. Exploring available components
When the user is choosing which component to use:
```bash
# List all components with categories
antdv list --format json
# List components for a specific antd version
antdv list --version 5.0.0 --format json
```
### 7. Collecting environment info
When you need to understand the project's antdv-next setup, or prepare info for a bug report:
```bash
# Full environment snapshot (text — paste into GitHub Issues)
antdv env
# Structured JSON for programmatic use
anvtd env --format json
# Scan a specific project directory
antdv env ./my-project --format json
```
Collects: OS, Node, package managers (npm/pnpm/yarn/bun/utoo), npm registry, browsers, core deps (antdv-next/vue/dayjs), all `@antdv-next/*` packages, and build tools (vite/typescript/etc.).
### 8. Reporting antd bugs
When the user asks you to report an antdv bug:
```bash
# Step 0: Collect environment info for reference (optional — antd bug already embeds basic env)
# Use the output to cross-check versions or attach extra details to the bug report
antdv env --format json
# Step 1: Preview for user review
antdv bug --title "DatePicker crashes when selecting date" \
--reproduction "https://codesandbox.io/s/xxx" \
--steps "1. Open DatePicker 2. Click a date" \
--expected "Date is selected" \
--actual "Component crashes with error" \
--format json
# Step 2: Show to user, ask for confirmation
# Step 3: Submit after user confirms
antdv bug --title "DatePicker crashes when selecting date" \
--reproduction "https://codesandbox.io/s/xxx" \
--steps "1. Open DatePicker 2. Click a date" \
--expected "Date is selected" \
--actual "Component crashes with error" \
--submit
```
### 9. Reporting CLI issues
When the user asks you to report a CLI bug, or explicitly requests help filing one:
```bash
# Preview the bug report for the user
antdv bug-cli --title "antdv info Button returns wrong props for v1.5.1" \
--description "When querying Button info for version 1.5.1, the output includes props that don't exist in that version" \
--steps "1. Run: antdv info Button --ver 1.5.1 --format json" \
--expected "Props matching antdv-next 1.5.0 Button API" \
--actual "Props include 'classes' which was added in 1.5.1" \
--format json
```
Show the report to the user and confirm before submitting:
```bash
antdv bug-cli --title "antdv info Button returns wrong props for v1.5.1" \
--description "..." \
--steps "..." \
--expected "..." \
--actual "..." \
--submit
```
**Opt-out:** If the environment variable `ANTDV_NO_AUTO_REPORT=1` is set, skip all bug-reporting suggestions entirely — do not suggest `antdv bug` or `antdv bug-cli` unless the user directly asks.
### 10. Upgrading the CLI
When the user wants to update `@antdv-next/cli` to the latest version, or when an "Update available" notice appears:
```bash
# Upgrade to the latest version (auto-detects package manager)
antdv upgrade
```
The command detects which package manager installed the CLI (npm, yarn, pnpm, bun) and runs the appropriate upgrade command. If detection fails, it suggests the manual command.
### 11. Using as MCP server
If working in an IDE that supports MCP (Claude Desktop, Cursor, etc.), the CLI can also run as an MCP server, exposing all knowledge-query tools directly:
```json
{
"mcpServers": {
"antdv": {
"command": "antdv",
"args": ["mcp", "--version", "1.5.0"]
}
}
}
```
This provides 8 tools (`antdv_list`, `antdv_info`, `antdv_doc`, `antdv_demo`, `antdv_token`, `antdv_design_md`, `antdv_semantic`) and 2 prompts (`antdv-expert`, `antdv-page-generator`) via MCP protocol.
## Global Flags
| Flag | Purpose |
|---------------------|----------------------------------------------------------------------------|
| `--format <format>` | Output format: `json`, `text`, or `markdown` (agents should prefer `json`) |
| `--ver` | Target a specific antd version (e.g. `1.5.0`) |
| `--detail` | Include extra fields (description, since, deprecated, FAQ) |
| `-V, --version` | Print CLI version and exit |
## Key Rules
1. **Always query before writing** — Don't guess antd APIs from memory. Run `antdv info` first.
2. **Match the user's version** — Knowledge queries (`list/info/doc/demo/token/semantic/changelog`) support antdv-next v1.x.
3. **Use `--format json`** — Every command supports it. Parse the JSON output rather than regex-matching text output.
4. **Check before suggesting migration** — Run `antdv changelog <v1> <v2>` and `antdv migrate` before advising on version upgrades.
5. **Lint after changes** — After writing or modifying antd code, run `antdv lint` on the changed files to catch deprecated or problematic usage.
6. **Report antdv-next bugs** — When the user asks to report an antd bug, use `antdv bug`. Always preview first, get user confirmation, then submit.
7. **Report CLI issues** — When the user asks about a CLI problem, use `antdv bug-cli` to help them file a report. Always preview first, get user confirmation, then submit.
+19
View File
@@ -0,0 +1,19 @@
# 构建上下文只保留 release/ 与 Caddyfile(Dockerfile 仅 COPY 这两处)
.git
.env
.env.*
!.env.example
node_modules
**/node_modules
apps
packages
scripts
docs
*.log
api*.json
session-archive
coverage
*.tsbuildinfo
.claude
.agents
agent
+17 -3
View File
@@ -1,10 +1,15 @@
# ── new-api-key-usage · 本地环境变量示例 ──────────────────────────
# ── new-api-key-usage · 环境变量示例 ──────────────────────────
# 复制为 .env 后填写(.env 已被 .gitignore,勿提交)
# 开发:任意可用的 PostgreSQL,DATABASE_URL 指向即可
# 部署:放在服务器约定目录(不进产物包);chmod 600;与库同等备份
# 本服务端口(避开 3000/3001 等常见占用)
# 本服务端口(避开 3000/3001 等常见占用;compose 内 server 固定 18456)
PORT=18456
# PostgreSQL 连接串(开发 compose 默认如下)
# PostgreSQL 连接串——数据库配置的唯一来源
# 开发:指向你本机/已有的库(主机 127.0.0.1 或 localhost)
# 部署:同一份即可;compose 会把主机改写为容器服务名 postgres
# 密码含 @ : / 等字符时请 URL 编码
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/new_api_key_usage
# 已登记 sk-key 的加密密钥:≥32 字节随机数,base64url。
@@ -19,11 +24,20 @@ NEWAPI_BASE=http://127.0.0.1:8080
# 会话 cookie 是否带 Secure(默认 true)。
# 本地 http://localhost 或 127.0.0.1 调试:现代浏览器可保留 true;
# 局域网 IP / 自定义域名 / 出现「登录后立刻 401」时改为 false。
# HTTPS + 域名部署务必 true。
COOKIE_SECURE=false
# Caddy 对外端口(宿主机被占用时改这里,例如 28080 / 28443)
# 仅影响宿主机映射;容器内仍是 80/443
HTTP_PORT=80
HTTPS_PORT=443
# 业务日志级别:debug | info | warning | error | fatal(访问日志另由 hono/logger 输出)
LOG_LEVEL=info
# 同步引擎周期(毫秒,≥1000;默认 300000 = 5 分钟)
# SYNC_INTERVAL_MS=300000
# 空库双通道:可选。用户名与密码同时存在时,首次 setup-state 静默落库;
# 只填一个视为未配置。此后改 env 不改密;改密走管理面表单。
# ADMIN_INIT_USERNAME=
+7
View File
@@ -17,9 +17,16 @@
# 依赖与构建产物
node_modules/
dist/
bin/
build/
.turbo/
release/
.bun-cache/
# 测试与工具
coverage/
*.tsbuildinfo
# Agent 本地接线(skills 本体只保留 .agents/skills)
.claude/
agent/
+15 -13
View File
@@ -5,17 +5,17 @@
## 协作约定(长期有效)
- **Git**:仅当项目作者明确说"提交 / commit"时才执行提交;`git push` 永远由项目作者手工执行,AI 绝不推送
- **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/references/`(平台可用接口参考——实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**(本分支暂无此目录,需要时再建)
- **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/references/`(平台可用接口参考——实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**(需要时再建)
- **代码文件命名**:所有**目录名与文件名**一律 kebab-case(如 `time-range-picker.vue` · `admin-session.ts`);**组件在模板中引用时用大驼峰**(`<TimeRangePicker />`)——文件名 kebab、模板标签 PascalCase
- **文档状态**:头部表格标「状态」(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);设计文档定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
- **部署**:交付物必须保证「一条命令链」可部署;**双形态并行已裁决**——完整前后端(运营方服务器)先行,Cloudflare Workers 抽取版(作者自部署)后续(见 `docs/design/需求清单.md` R13)
- **不引入版本叙事**:不使用 v1/v2/v3 之类版本编号,不以「MVP」等标签或指称显式描述任何历史阶段;文档内容必须**自立**——不得引用、描述任何当前分支之外、可能被删除的分支内容
- **本地不入库也不 ignore 的物料**(AI 提交时务必避开):`api*.json`(上游实测快照)、`session-archive/`(作者主动备份的会话记录,如 `v1-设计-*.md`)——不加入 git、不写 `.gitignore`,靠提交时人工/执行者目视把关;`v1-设计-1.md` 为历史对话原档,允许保留旧术语,不作现行依据
- **不引入版本叙事**:不使用 v1/v2/v3 之类版本编号,不以「MVP」等标签或指称显式描述任何历史阶段;文档内容必须**自立**——不得引用、描述任何当前仓库之外、可能被删除的分支或目录内容
- **本地不入库也不 ignore 的物料**(AI 提交时务必避开):`api*.json`(上游实测快照)、`session-archive/`(作者主动备份的会话记录)——不加入 git、不写 `.gitignore`,靠提交时人工/执行者目视把关
## 项目速览(速记)
- **`design`(本分支)= 设计文档中心 + 实现主线**:实现代码与文档同分支演进(代码落点已定案),monorepo 结构(`apps/server` · `apps/web` · `packages/core` · `packages/db`)见 `docs/design/技术选型.md` §4
- 数据模型:四张基线表(`api_keys` · `usage_logs` · `payments` · `qpu_history`)+ 三张访问主体表(`members` · `users` · `sessions`)设计见本目录,**七篇已定稿(2026-10-01,经作者逐篇审阅)**;同步架构(轮询 + `request_id` 去重 + 攒库)的动因由上游物理约束决定,架构结论不变
- 需求总集与待裁决项(平台身份体系、费用分摊与账单、充值折算、多订阅、数据资产保护)见 `docs/design/需求清单.md`;交付形态已裁决(双形态并行,完整形态先行)
- **本仓库 = 设计文档中心 + 实现主线**:实现代码与文档同仓演进,monorepo 结构(`apps/server` · `apps/engine` · `apps/web` · `packages/core` · `packages/db`)见 `docs/design/技术选型.md` §4
- 数据模型:基线表(`api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history`)+ 三张访问主体表(`members` · `users` · `sessions`)——支付登记已拆为 `subscriptions` + `topups`(见 `docs/design/payments建模评估.md`);同步架构(轮询 + `request_id` 去重 + 攒库)的动因由上游物理约束决定
- 需求总集与裁决档案见 `docs/design/需求清单.md`;交付形态已裁决(双形态并行,完整形态先行)
- 上游约束速记:只有 sk-key、无时间过滤接口 → 轮询攒库是唯一路径(README「架构的根本动因」)
## 命令
@@ -25,15 +25,17 @@ Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun`
| 命令 | 用途 |
|------|------|
| `bun install` | 安装 / 链接 workspace 依赖(在仓库根) |
| `bun test` | 全部测试(core 契约 + server HTTP + **集成**);集成需 `DATABASE_URL`(compose 中的 PG),无库时自动跳过 |
| `bun --env-file=.env scripts/seed-from-samples.ts` | 从根目录 `api*.json` 实测快照灌开发库(幂等);验收接口口径用 |
| `bun run db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
| `bun run db:push` | 推送到本地库(开发期;**服务启动不自动迁**) |
| `bun run db:studio` | Drizzle Studio 看库 |
| `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) |
| `bun run dev:server` | 开发跑母体后端(hot reload) |
| `bun run dev:server` | 开发跑后端(hot reload) |
| `bun run dev:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) |
| `bun run dev:web` | 开发跑前端 Vite |
| `docker compose up -d` | 起开发用 PostgreSQL |
| `bun run --filter @new-api-key-usage/db db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
| `bun run --filter @new-api-key-usage/db db:push` | 推送到本地库(开发期) |
| `bun run build:web` | 前端类型检查 + 产物构建 |
| `bun run build:server` | 编译后端单文件 → `bin/naku-server`(当前平台) |
| `bun run build:engine` | 编译同步引擎单文件 → `bin/naku-engine`(当前平台) |
| `bun run pack` | 打包/构建 CLI(citty + clack):`pack release` 交互打部署包 → `release/`;`pack build` 本地构建 → `bin/`;支持 `--yes` / `--target` / `--only`;`pack release --yes` 非交互打部署包(目标默认 linux-x64) |
环境变量见 `.env.example`(复制为 `.env` 后填写)。
+17
View File
@@ -0,0 +1,17 @@
# 站点地址按部署环境改:
# 本地/内网 HTTP → :80
# 公网 HTTPS → your.domain.example(Caddy 自动签证书)
:80 {
root * /srv
# 接口反代到 server 容器
handle /api/* {
reverse_proxy server:18456
}
# SPA:非 /api 的路径回退 index.html
handle {
try_files {path} /index.html
file_server
}
}
+21
View File
@@ -0,0 +1,21 @@
# 语法:docker/dockerfile:1
# 只装配 release/ 产物,构建期不编译业务代码
# ---------- 应用运行时 ----------
# 用 Debian slim:与 Bun --compile(glibc)同源,兼容面更稳;
# CA 与 libstdc++ 显式安装,不依赖 slim 是否自带
FROM debian:bookworm-slim AS app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libstdc++6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# --chmod:Windows 打包 / 跨机传输常丢可执行位,缺 x 时容器内 exec 以 126 退出
COPY --chmod=755 release/naku-server release/naku-engine release/naku-migrate /app/
COPY release/drizzle /app/drizzle
ENV MIGRATIONS_FOLDER=/app/drizzle
CMD ["/app/naku-server"]
# ---------- 网关(Caddy + 前端静态) ----------
FROM caddy:2-alpine AS proxy
COPY Caddyfile /etc/caddy/Caddyfile
COPY release/dist /srv
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 skycurtain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+139 -59
View File
@@ -1,92 +1,172 @@
# new-api-key-usage
> 针对 NewAPI 平台的 key 用量统计工具
> NewAPI 网关下的 key 用量统计工具(产品名 **NAKU**)
定时用每个 sk-key 轮询网关日志接口,按 `request_id` 去重,攒全量日志到数据库;任意时间范围的用量查询,即一条本地聚合查询。
| 项目 | 内容 |
|------|------|
| 当前状态 | **实现推进中**(`design` 分支 = 文档中心 + 实现主线)——设计文档全部定稿(含同步引擎);后端全量落地(core / db / server / engine + Scalar 在线文档),同步引擎已人工验收;**前端仅有 antdv-next 能力 spike,真实页面待开工** |
| 技术栈 | 全栈 TypeScript(选型定稿见 [`docs/design/技术选型.md`](docs/design/技术选型.md)):母体 Bun + Hono + Drizzle + PostgreSQL · 前端 Vue 3 + antdv-next |
| 当前状态 | **实现推进中**——设计文档主体定稿;后端 + 同步引擎 + 前端(管理面与成员视图)已落地;部署链已定(见 [`docs/design/部署方案.md`](docs/design/部署方案.md));Cloudflare Workers 抽取版待做 |
| 技术栈 | 全栈 TypeScript(选型见 [`docs/design/技术选型.md`](docs/design/技术选型.md)):Bun + Hono + Drizzle + PostgreSQL · 前端 Vue 3 + antdv-next |
## 项目沿革
## 这是什么
| 阶段 | 形态 | 结局 |
|------|------|------|
| 初版方案 | Cloudflare Workers | 弃——当时运营方愿意提供服务器资源 |
| 新版设计(当前) | **本分支,设计已收官、实现推进中** | 捡回 Cloudflare Workers(**serverless 后端**:Cron 定时轮询 + 云端数据库攒库,非初版原案复刻)作为后续衍生形态;母体完整前后端先行,CFW 延后至功能全集实现后 |
面向 [NewAPI](https://github.com/QuantumNous/new-api) 实例的**独立计费辅助平台**:订阅持有人创建多个 sk- API key 分发给多名使用者,平台把「谁用了多少」的事实记录清楚——用量、来源标注、支付参数、多维统计;**不算「谁该付谁多少钱」**(分摊是人和人之间的事)。
**双形态并行的直接动因**:服务器部署形态下,每次迭代都需实例运营方在服务器上手工升级,测试阶段迭代频繁而运营方无暇跟随;若部署到作者自控的 Cloudflare 账号(Workers + Pages),迭代部署权回到作者手里。
**适用场景**:同一份订阅额度由多人共享的小团队 / 成本分担群体。
## 文档地图
- 名字直述用途:NewAPI 网关下按 key 查询用量(key usage);产品展示名 NAKU
- 仓库名 / 包名统一小写 kebab-case
| 文档 | 什么时候读 | 状态 |
|------|-----------|------|
| [`docs/design/需求清单.md`](docs/design/需求清单.md) | **需求权威索引与裁决档案**——R1–R15 全部定案(含裁撤与延后),R 编号体系为后续设计文档的引用锚点 | 定稿(2026-10-01) |
| [`docs/design/技术选型.md`](docs/design/技术选型.md) | 母体技术栈裁决(CFW 延后至功能全集实现后,仅零成本 R14 预留):全栈 TS;Bun + Hono + Drizzle/pg;前端 Vue 3 + antdv-next;分层落地与回退;antdv-next 核验通过(§7.1) | 定稿(2026-10-02) |
| [`docs/design/api_keys表字段设计.md`](docs/design/api_keys表字段设计.md) | 数据模型第一张表:key 登记、生命周期与成员归属 | 定稿(2026-10-01) |
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 数据模型第二张表:日志明细、来源提列、去重与写入策略 | 同上 |
| [`docs/design/payments表字段设计.md`](docs/design/payments表字段设计.md) | 数据模型第三张表:支付记录(订阅/充值同构,币种无关,只存事实) | 同上 |
| [`docs/design/qpu_history表字段设计.md`](docs/design/qpu_history表字段设计.md) | 数据模型第四张表:换算参数时间线(自动采样、只读) | 同上 |
| [`docs/design/members表字段设计.md`](docs/design/members表字段设计.md) | 数据模型新增表:成员实体——key 归属、成员视图身份与按成员统计的锚点 | 定稿(2026-10-01) |
| [`docs/design/users表字段设计.md`](docs/design/users表字段设计.md) | 数据模型新增表:管理员(单行)——登录体系的身份落点 | 同上 |
| [`docs/design/sessions表字段设计.md`](docs/design/sessions表字段设计.md) | 数据模型新增表:管理面会话——cookie 令牌的服务端对应物 | 同上 |
| [`docs/design/交互设计-总览与共性.md`](docs/design/交互设计-总览与共性.md) | 交互设计总纲:访问拓扑、导航、跨页组件、状态与数值呈现约定 | 定稿(2026-10-01) |
| [`docs/design/交互设计-登录与会话.md`](docs/design/交互设计-登录与会话.md) | 管理面登录页、引导页、改密、成员登录壳 | 同上 |
| [`docs/design/交互设计-全员总览.md`](docs/design/交互设计-全员总览.md) | 管理面首页:健康带 + 按成员 / key 汇总 | 同上 |
| [`docs/design/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 同上 |
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 订阅/充值同构表单、核对提示 | 同上 |
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、令牌签发/重发(明文仅一次)、停用 | 同上 |
| [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 同上 |
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员单页世界——R12 携带形态定案(徽章 + 展开行) | 同上 |
| [`docs/design/API契约-公共约定.md`](docs/design/API契约-公共约定.md) | API 契约总纲:路由隔离与鉴权两档、错误信封、时间 / 数值 / 分页 / 命名(8 条拟定约定经审通过)——后两篇的引用锚点 | 定稿(2026-10-02) |
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 16 端点:引导与会话三态、引擎状态、总览聚合、key 登记四态、下钻明细、支付、成员(令牌明文仅一次)、审计;5 项裁定(经审通过)+ 4 项遗留(key 详情已裁决:实现期定) | 定稿(2026-10-02) |
| [`docs/design/API契约-成员视图.md`](docs/design/API契约-成员视图.md) | 成员视图 3 端点:令牌验证与页头、单页聚合(含支付参数卡判定)、日志明细(DTO 与管理面同构);4 项裁定(经审通过)+ 1 项遗留 | 定稿(2026-10-02) |
| [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) | 独立 `apps/engine` 进程:手写 `setTimeout` 轮次、失败只记录、`engine_status` 快照供 `/api/admin/engine`;周期可配、**不为 CFW 预留** | 已实现(定稿 2026-10-05) |
| [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md) | 计费平台在 NewAPI 中可用的接口参考:鉴权档位、请求与返回结构、字段语义、实测行为、源码出处(**唯一上游参考**) | 事实依据 |
## 背景与约束
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(我们定的方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策)
- 生命周期:设计文档定稿后不搬家、只改状态(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);被取代时删除(git 历史即考古)
- 典型场景:NewAPI(Go + GORM 开源 LLM 网关)实例提供订阅服务,多名使用者通过分发 API key 共享订阅额度——**非商业,纯成本分担**
- 平台持有方通常只是实例的**普通用户**:只有 sk-key,**没有实例管理员账号**
- 一切数据获取只能走 key 级接口(账号级接口用 sk-key 访问会 401)——这是整个数据链路的设计前提
- 部署形态为**双形态并行**:完整前后端跑在自有或托管服务器上(见 [`docs/design/部署方案.md`](docs/design/部署方案.md));Cloudflare Workers 抽取版部署在自己掌控的账号里(见 [`docs/design/需求清单.md`](docs/design/需求清单.md) R13)
## 这是什么项目
**双形态并行的动因**:服务器部署形态下,每次迭代都要经服务器管理员手工升级;Workers 形态把迭代部署权收回自己手里。完整前后端先行,抽取版延后至功能全集实现后。
- 名字直述用途:NewAPI 网关下按 key 查询用量(key usage)
- 全场景统一小写 kebab-case:仓库名 / 包名 / 文档标题一致,无大写变体
## 核心能力
## 背景故事
- 本工具源于一个真实场景:[NewAPI](https://github.com/QuantumNous/new-api)(Go + GORM 的开源 LLM 网关)实例提供订阅服务,多个使用者通过分发 API key 共享同一份订阅额度
- 项目作者是该实例的**普通用户**:购买了订阅额度,创建多个 sk- API key 分发给多名使用者分摊使用成本——**非商业,纯成本分担**
- 关键身份约束:项目作者**只有 sk-key,没有实例的管理员账号**。因此:
- 一切数据获取只能走 key 级接口(账号级接口用 sk-key 访问会 401)
- 部署形态已裁决为**双形态并行**:完整前后端部署在实例运营方提供的服务器上(作者不接触服务器)+ Cloudflare Workers 抽取版部署在作者自控账号(见 [`docs/design/需求清单.md`](docs/design/需求清单.md) R13)
## 核心需求(基线摘要;完整范围见需求清单)
| # | 需求 | 说明 |
| # | 能力 | 说明 |
|---|------|------|
| 1 | key 用量查询 | 按时间范围查模型请求日志与用量;作者的全员视角经管理面总览实现——「凭 sk-key 的开放查询」与「多 key 合并查询」机制已裁撤(见需求清单裁决记录) |
| 2 | key 登记 | 管理通道手动登记:登记后入库并加入轮询;查看面凭已登记的 key 查询(纯只读) |
| 1 | key 用量查询 | 按时间范围查模型请求日志与用量;管理端全员视角,成员端各自视角 |
| 2 | key 登记 | 管理通道手动登记:入库并加入轮询;生命周期为手动启停(引擎只读 `status`) |
| 3 | 支付事实登记 | 订阅权益与充值入账分表登记;`billing_domain` 标账本域,成员同域可见 |
| 4 | 多维统计 | 按成员 / key / 计费来源 / 模型的任意时段聚合 |
基线刻意收缩的部分(费用分摊计算、计费周期管理、多组支持、登录页、admin 角色、会话记忆)与新增域(数据资产保护等)的完整需求盘点见 [`docs/design/需求清单.md`](docs/design/需求清单.md)。
完整需求范围(含刻意不做的部分)见 [`docs/design/需求清单.md`](docs/design/需求清单.md)。
## 架构的根本动因:为什么必须攒数据
这不是优化选项,是上游接口的物理约束决定的**唯一路径**(不随部署形态改变):
1. sk-key 可用的接口(`/api/usage/token`、`/api/log/token` 等)返回的都是**累计值**或**最近 1000 条**,没有任何接口支持时间范围过滤
2. 真正适合算“当月用量”的接口(`/api/log/self/stat`,支持时间范围 + quota 汇总)需要账号登录态,sk-key 访问 401——这是“按时间段统计用量”无法纯客户端实现的**核心障碍**
2. 真正适合算「当月用量」的接口(`/api/log/self/stat`,支持时间范围 + quota 汇总)需要账号登录态,sk-key 访问 401——这是「按时间段统计用量」无法纯客户端实现的**核心障碍**
3. `/api/log/token` 硬上限 1000 条、不支持任何参数(分页 / limit / 时间过滤全被忽略)
→ 结论:轮询同步 + `request_id` 去重入库 + 数据库。**部署形态可以变(运营方服务器 / Cloudflare Workers),攒库架构不变。**上游接口完整实证(含源码出处)见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md)。
→ 结论:轮询同步 + `request_id` 去重入库 + 数据库。**部署形态可以变,攒库架构不变。** 上游接口完整实证(含源码出处)见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md)。
## 技术栈与结构
全栈 TypeScript,monorepo(Bun workspaces):
```text
apps/
server/ # 后端:Hono + Drizzle/pg + Scalar 在线文档
engine/ # 同步引擎:独立进程,轮询去重入库
web/ # 前端:Vue 3 + antdv-next + Vite
packages/
core/ # 业务核心:去重、折算、加密、上游客户端、DTO
db/ # 数据访问层:Drizzle schema + 仓储
```
数据模型:`api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history` + `members` · `users` · `sessions`。
## 快速开始
前置:Bun、一个可用的 PostgreSQL(本机已有或别处均可)。
```bash
bun install
cp .env.example .env # DATABASE_URL 指向你已有的 PostgreSQL
bun run db:push # 开发期推送 schema(启动不自动迁)
bun run dev:server # 后端(默认 18456)
bun run dev:engine # 同步引擎(另开终端)
bun run dev:web # 前端 Vite(5175)
```
在线 API 文档:`http://127.0.0.1:18456/api/docs`。
更多命令与协作约定见 [`AGENTS.md`](AGENTS.md)。
## 部署
方案详文见 [`docs/design/部署方案.md`](docs/design/部署方案.md)。形态一句话:**本地打产物包 → 覆盖服务器 `release/` → 一条 compose 命令 → 看容器**。
### 本地打包
```bash
bun run pack release
# 交互选:目标平台 · 打包内容 · 是否 tar.gz
# 产出 release/:dist · naku-server · naku-engine · naku-migrate · drizzle/
# 非交互(CI / 一键):
bun run pack release --yes
# 或 bun run pack release --yes --target=bun-linux-arm64 --only=dist,naku-server
```
### 服务器
首次安装:把 `docker-compose.yml` · `Dockerfile` · `.dockerignore` · `Caddyfile` · `entrypoint-*.sh` 放到约定目录(如 `/opt/naku/`),再放 `release/`,填写 `.env`(`chmod 600`)。日常更新只覆盖 `release/`。
```bash
cd /opt/naku
docker compose up -d --build
docker compose ps # migrate 应为 exited(0),其余 running/healthy
```
- 页面:`http://<服务器>/`(若改了 `HTTP_PORT` 则加端口);接口文档:`http://<服务器>/api/docs`
- 迁移:由 `migrate` 一次性服务在启动时幂等执行(journal),重复执行不会多灌
- 端口:默认 80/443;宿主机被占用时在 `.env` 设 `HTTP_PORT` / `HTTPS_PORT`,再执行固定命令
- HTTPS:`Caddyfile` 站点行改成域名,`.env` 设 `COOKIE_SECURE=true`,再执行固定命令
### 注意
1. `.env` 不进产物包、不进 git;含 `SK_KEY_ENCRYPTION_SECRET`,须与数据库同等备份
2. `COOKIE_SECURE` 必须与访问方式匹配:HTTP + `true` 会「登录后立刻 401」
3. `DATABASE_URL` 密码含 `@` `:` `/` 等字符时做 URL 编码
## 文档地图
| 文档 | 什么时候读 | 状态 |
|------|-----------|------|
| [`docs/design/需求清单.md`](docs/design/需求清单.md) | **需求权威索引与裁决档案**——R1–R16 定案;含支付拆表与成员档案/空壳删除裁决 | 定稿(2026-10-10) |
| [`docs/design/技术选型.md`](docs/design/技术选型.md) | 技术栈裁决:全栈 TS;Bun + Hono + Drizzle/pg;前端 Vue 3 + antdv-next;分层落地与回退 | 定稿(2026-10-02) |
| [`docs/design/部署方案.md`](docs/design/部署方案.md) | 母体交付与更新:产物包、服务器目录、容器编排、迁移时机、安全约定 | 已实现(定稿 2026-10-12) |
| [`docs/design/api_keys表字段设计.md`](docs/design/api_keys表字段设计.md) | key 登记、生命周期与成员归属 | 定稿(2026-10-01) |
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 日志明细、来源提列、去重与写入策略 | 同上 |
| [`docs/design/payments建模评估.md`](docs/design/payments建模评估.md) | 支付建模复盘:订阅权益 vs 充值入账两分;拆表已采纳 | 定稿(2026-10-09) |
| [`docs/design/subscriptions表字段设计.md`](docs/design/subscriptions表字段设计.md) | 订阅权益:期间 + 额度预算 + 可选付款事实;`subscription_id` 直连日志 | 定稿(2026-10-12) |
| [`docs/design/topups表字段设计.md`](docs/design/topups表字段设计.md) | 充值入账事件:`paid_at` 主轴;不挂订阅 | 定稿(2026-10-12) |
| [`docs/design/qpu_history表字段设计.md`](docs/design/qpu_history表字段设计.md) | 换算参数时间线(自动采样、只读) | 定稿(2026-10-01) |
| [`docs/design/members表字段设计.md`](docs/design/members表字段设计.md) | 成员实体——key 归属、身份与统计锚点;档案可改、空壳可删 | 定稿(2026-10-10) |
| [`docs/design/users表字段设计.md`](docs/design/users表字段设计.md) | 管理员(单行)——登录体系的身份落点 | 同上 |
| [`docs/design/sessions表字段设计.md`](docs/design/sessions表字段设计.md) | 管理面会话——cookie 令牌的服务端对应物 | 同上 |
| [`docs/design/交互设计-总览与共性.md`](docs/design/交互设计-总览与共性.md) | 交互总纲:访问拓扑、导航、跨页组件、状态与数值呈现约定 | 定稿(2026-10-01) |
| [`docs/design/交互设计-登录与会话.md`](docs/design/交互设计-登录与会话.md) | 登录页、引导页、改密、成员登录壳 | 同上 |
| [`docs/design/交互设计-全员总览.md`](docs/design/交互设计-全员总览.md) | 管理面首页:健康带 + 按成员 / key 汇总 | 同上 |
| [`docs/design/交互设计-调用日志.md`](docs/design/交互设计-调用日志.md) | 调用日志:筛选、汇总条、来源徽章支付弹窗、明细表与跳转链 | 定稿(2026-10-08;模型筛选与汇总条、徽章弹窗修订) |
| [`docs/design/实现方案-日志模型筛选与汇总条.md`](docs/design/实现方案-日志模型筛选与汇总条.md) | 日志页模型筛选 + 条数/消费用量汇总条:API、数据层、前端改造与验收 | 已实现(2026-10-13) |
| [`docs/design/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 定稿(2026-10-01) |
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 支付记录(两 Tab):订阅权益 / 充值入账登记、核对提示、ID/交易号筛选 | 定稿(2026-10-12;补筛选) |
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、档案修改、令牌签发/重发、启停、空壳删除 | 定稿(2026-10-10) |
| [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 定稿(2026-10-10) |
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员视图三页(总览 / 调用日志 / 支付参数);订阅徽章展开 + 调用详情 | 定稿(2026-10-09) |
| [`docs/design/API契约-公共约定.md`](docs/design/API契约-公共约定.md) | API 总纲:路由隔离与鉴权两档、错误信封、时间 / 数值 / 分页 / 命名 | 定稿(2026-10-10) |
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 18 端点 | 定稿(2026-10-12) |
| [`docs/design/API契约-成员视图.md`](docs/design/API契约-成员视图.md) | 成员视图 3 端点:`/me` · `/overview` · `/logs` | 定稿(2026-10-12) |
| [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) | 独立 `apps/engine`:轮次、失败只记录、`engine_status` 快照 | 已实现(定稿 2026-10-05) |
| [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md) | 上游可用接口参考:鉴权档位、字段语义、实测行为、源码出处(**唯一上游参考**) | 事实依据 |
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策)
- 生命周期:状态流转(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
- AI 协作物料:[`AGENTS.md`](AGENTS.md)(协作约定)与 `.agents/skills/`(技能包)随仓库分发——参与开发的 Agent 从这里读取项目约定,人工贡献者亦可参考
## 关键事实清单(实测 / 源码确认,设计时不得违反)
> 出处均见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md),此处仅索引。
1. **`QuotaPerUnit = 500000`**:quota ÷ 500000 = 额度美元——**它不是实付金额**:折算到实付人民币还需乘「购买汇率」(由运营方定价、可变,历史上调整过一次)
1. **`QuotaPerUnit = 500000`**:quota ÷ 500000 = 额度美元——**它不是实付金额**;折算到实付金额还需乘「购买汇率」(由运营方定价、可变)
2. **去重必须用 `request_id`**:`/api/log/token` 返回的 `id` 是每次从 1 重编的展示序号,绝不能用于去重
3. **`created_at` 是 UTC Unix 秒**(三字段交叉实测),北京时间输入需转换
4. **1000 条硬上限**:窗口只保留最近 1000 条——实测当前站点的日志速率下约等于 3 天;5 分钟级同步余量充分,但同步停摆超过约 3 天会永久丢数据
5. **`TokenAuthReadOnly`**:用量/日志接口用宽松鉴权,key 过期 / 额度耗尽仍可查询(用完也能对账);**显式禁用会被 401 拒绝**
3. **`created_at` 是 UTC Unix 秒**,本地时间输入需转换
4. **1000 条硬上限**:窗口只保留最近 1000 条——当前日志速率下约等于 3 天;同步停摆超过窗口会永久丢数据
5. **`TokenAuthReadOnly`**:用量/日志接口用宽松鉴权,key 过期 / 额度耗尽仍可查询;**显式禁用会被 401 拒绝**
6. **`DisplayTokenStatEnabled` 已开启**:billing 接口返回 token 级额度
## License
[MIT](LICENSE) © 2026 skycurtain
NAKU 是 NewAPI 的**独立客户端**,仅通过 HTTP API 交互,不包含其源码;与上游许可证相互独立。上游 NewAPI 为 AGPL-3.0,本项目不受其传染,亦不衍生自它。
+1
View File
@@ -8,6 +8,7 @@ setupLogging(env.LOG_LEVEL);
const log = appLogger("engine");
const db = createDb(env.DATABASE_URL);
const repos = createRepos(db);
const loop = createSyncLoop({
-13
View File
@@ -1,13 +0,0 @@
# apps/server
母体后端(Hono + Bun)。
```sh
bun install
bun run dev
```
默认端口 **18456**(可用 `PORT` 覆盖):
- 在线文档:http://127.0.0.1:18456/api/docs
- OpenAPI:http://127.0.0.1:18456/api/openapi.json
+16 -2
View File
@@ -3,6 +3,8 @@ import "./zod-openapi-setup.ts";
import {
changePasswordBody,
createMemberBody,
createSubscriptionBody,
createTopupBody,
engineStatus,
errorEnvelope,
keyItem,
@@ -14,7 +16,8 @@ import {
memberWithToken,
patchKeyBody,
patchMemberBody,
paymentItem,
subscriptionItem,
topupItem,
auditLogItem,
registerKeyBody,
setupBody,
@@ -50,12 +53,18 @@ export const memberOverviewSchema = nameSchema(
);
export const logItemSchema = nameSchema(logItem, "LogItem");
export const logPageSchema = nameSchema(logPage, "LogPage");
export const paymentItemSchema = nameSchema(paymentItem, "PaymentItem");
export const subscriptionItemSchema = nameSchema(
subscriptionItem,
"SubscriptionItem",
);
export const topupItemSchema = nameSchema(topupItem, "TopupItem");
export const auditLogItemSchema = nameSchema(auditLogItem, "AuditLogItem");
export {
changePasswordBody as changePasswordBodySchema,
createMemberBody as createMemberBodySchema,
createSubscriptionBody as createSubscriptionBodySchema,
createTopupBody as createTopupBodySchema,
loginBody as loginBodySchema,
patchKeyBody as patchKeyBodySchema,
patchMemberBody as patchMemberBodySchema,
@@ -84,3 +93,8 @@ export const err422 = {
description: "校验失败",
content: { "application/json": { schema: errorSchema } },
} as const;
export const err502 = {
description: "上游不可达",
content: { "application/json": { schema: errorSchema } },
} as const;
+7 -3
View File
@@ -4,11 +4,13 @@ import { ERR, appLogger } from "@new-api-key-usage/core";
import { createRepos, type Db } from "@new-api-key-usage/db";
import { jsonError, type AppVars } from "./middleware.ts";
import { adminSessionRoutes } from "./routes/admin-session.ts";
import { adminPlatformRoutes } from "./routes/admin-platform.ts";
import { adminEngineRoutes } from "./routes/admin-engine.ts";
import { adminKeyRoutes } from "./routes/admin-keys.ts";
import { adminMemberRoutes } from "./routes/admin-members.ts";
import { adminUsageRoutes } from "./routes/admin-usage.ts";
import { adminPaymentRoutes } from "./routes/admin-payments.ts";
import { adminSubscriptionRoutes } from "./routes/admin-subscriptions.ts";
import { adminTopupRoutes } from "./routes/admin-topups.ts";
import { adminAuditRoutes } from "./routes/admin-audit.ts";
import { memberViewRoutes } from "./routes/member-view.ts";
@@ -97,7 +99,7 @@ export function createApp(opts: CreateAppOptions) {
{ name: "引导与会话", description: "三态判定 / 登录 / 改密" },
{ name: "引擎", description: "同步引擎状态" },
{ name: "Key 管理", description: "登记 / 启停 / 归属" },
{ name: "成员管理", description: "开户 / 令牌 / 启停" },
{ name: "成员管理", description: "开户 / 档案 / 令牌 / 启停 / 空壳删除" },
{ name: "成员视图", description: "令牌自助查询" },
],
});
@@ -116,8 +118,10 @@ export function createApp(opts: CreateAppOptions) {
admin.route("/", adminKeyRoutes());
admin.route("/", adminMemberRoutes());
admin.route("/", adminUsageRoutes());
admin.route("/", adminPaymentRoutes());
admin.route("/", adminSubscriptionRoutes());
admin.route("/", adminTopupRoutes());
admin.route("/", adminAuditRoutes());
admin.route("/", adminPlatformRoutes());
app.route("/api/admin", admin);
app.route("/api/member", memberViewRoutes());
+7 -1
View File
@@ -21,7 +21,7 @@ export function adminAuditRoutes() {
method: "get",
path: "/audit-logs",
summary: "审计日志",
description: "分页、时间倒序;可按 domain 过滤(key/payment/member/account)",
description: "分页、时间倒序;可按 domain 与 [from,to) 过滤",
tags: ["审计"],
security: [{ adminSession: [] }],
request: {
@@ -31,6 +31,8 @@ export function adminAuditRoutes() {
domain: z
.enum(["key", "payment", "member", "account"])
.optional(),
from: z.string().optional(),
to: z.string().optional(),
}),
},
responses: {
@@ -49,8 +51,12 @@ export function adminAuditRoutes() {
200,
Math.max(1, Number(q.page_size ?? 50) || 50),
);
const from = q.from ? new Date(q.from) : undefined;
const to = q.to ? new Date(q.to) : undefined;
const { rows, total } = await c.get("repos").audit.listPage({
domain: q.domain,
from: from && !Number.isNaN(from.getTime()) ? from : undefined,
to: to && !Number.isNaN(to.getTime()) ? to : undefined,
page,
page_size,
});
+24 -1
View File
@@ -30,6 +30,7 @@ function toKeyItem(row: ApiKeyRow & { member_name?: string | null }): KeyItem {
status: row.status as "active" | "disabled",
member_id: row.member_id,
member_name: row.member_name ?? null,
billing_domain: row.billing_domain,
registered_at: toUnixSec(row.registered_at)!,
log_synced_at: toUnixSec(row.log_synced_at),
usage_synced_at: toUnixSec(row.usage_synced_at),
@@ -59,6 +60,7 @@ export function adminKeyRoutes() {
query: z.object({
member_id: z.string().optional(),
status: z.enum(["active", "disabled"]).optional(),
billing_domain: z.string().optional(),
recent_failed: z.enum(["true", "false"]).optional(),
}),
},
@@ -77,12 +79,15 @@ export function adminKeyRoutes() {
const filter: {
member_id?: number | "unassigned";
status?: string;
billing_domain?: string | "none";
} = {};
if (q.member_id === "unassigned") filter.member_id = "unassigned";
else if (q.member_id) filter.member_id = Number(q.member_id);
if (q.status === "active" || q.status === "disabled") {
filter.status = q.status;
}
if (q.billing_domain === "none") filter.billing_domain = "none";
else if (q.billing_domain) filter.billing_domain = q.billing_domain;
let rows = await c.get("repos").keys.listAll(filter);
if (q.recent_failed === "true") {
const snap = await c.get("repos").engine.getSnapshot();
@@ -144,6 +149,10 @@ export function adminKeyRoutes() {
}
member_id = n;
}
let billing_domain: string | null = null;
if (typeof body?.billing_domain === "string" && body.billing_domain.trim()) {
billing_domain = body.billing_domain.trim();
}
if (!sk_key || !sk_key.startsWith("sk-")) {
return jsonError(
c,
@@ -208,6 +217,7 @@ export function adminKeyRoutes() {
key_hash,
key_ciphertext,
member_id,
billing_domain,
token_name: usage.name ?? "",
});
// 首次登记即写入上游额度字段(表设计 §4:首轮日志同步异步补)
@@ -241,7 +251,7 @@ export function adminKeyRoutes() {
createRoute({
method: "patch",
path: "/keys/{id}",
summary: "启停 / 归属变更",
summary: "启停 / 归属 / 账本域变更",
description: "部分更新;审计按实际变更字段各落一行",
tags: ["Key 管理"],
security: [{ adminSession: [] }],
@@ -301,6 +311,19 @@ export function adminKeyRoutes() {
});
}
}
if (body && "billing_domain" in body) {
let nextDomain: string | null = null;
if (typeof body.billing_domain === "string" && body.billing_domain.trim()) {
nextDomain = body.billing_domain.trim();
}
if (nextDomain !== before.billing_domain) {
patch.billing_domain = nextDomain;
actions.push({
action: "key.reassign",
summary: `key #${before.id} · 账本域变更`,
});
}
}
if (Object.keys(patch).length === 0) {
return jsonError(
c,
+106 -9
View File
@@ -135,7 +135,8 @@ export function adminMemberRoutes() {
createRoute({
method: "patch",
path: "/members/{id}",
summary: "停用 / 启用",
summary: "档案修改 / 停用 / 启用",
description: "部分更新 name/remark/status;审计按实际变更字段各落一行",
tags: ["成员管理"],
security: [{ adminSession: [] }],
request: {
@@ -152,27 +153,123 @@ export function adminMemberRoutes() {
},
401: err401,
404: err404,
409: err409,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = await c.req.json().catch(() => null);
const status = body?.status;
if (status !== "active" && status !== "disabled") {
return jsonError(c, 422, ERR.validation_failed({ status: "非法状态" }));
}
const repos = c.get("repos");
const before = await repos.members.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
const row = await repos.members.updateStatus(id, status);
const profile: { name?: string; remark?: string } = {};
const actions: Array<{ action: string; summary: string }> = [];
if (typeof body?.name === "string") {
const name = body.name.trim();
if (!name || name.length > 64) {
return jsonError(
c,
422,
ERR.validation_failed({ name: "名称无效" }),
);
}
if (name !== before.name) {
const taken = await repos.members.findByName(name);
if (taken && taken.id !== id) {
return jsonError(c, 409, ERR.member_name_taken());
}
profile.name = name;
actions.push({
action: "member.update",
summary: `成员 ${before.name} · 改名为 ${name}`,
});
}
}
if (typeof body?.remark === "string") {
if (body.remark !== before.remark) {
profile.remark = body.remark;
actions.push({
action: "member.update",
summary: `成员 ${before.name} · 修改备注`,
});
}
}
let nextStatus: "active" | "disabled" | null = null;
if (body?.status === "active" || body?.status === "disabled") {
if (body.status !== before.status) {
nextStatus = body.status;
actions.push({
action: body.status === "active" ? "member.enable" : "member.disable",
summary: `成员 ${before.name} · ${body.status === "active" ? "启用" : "停用"}`,
});
}
}
if (Object.keys(profile).length === 0 && !nextStatus) {
return jsonError(
c,
422,
ERR.validation_failed({ body: "没有需要更新的字段" }),
);
}
let row = before;
if (Object.keys(profile).length > 0) {
row = await repos.members.updateProfile(id, profile);
}
if (nextStatus) {
row = await repos.members.updateStatus(id, nextStatus);
}
for (const a of actions) {
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: status === "active" ? "member.enable" : "member.disable",
summary: `成员 ${row.name} · ${status === "active" ? "启用" : "停用"}`,
action: a.action,
summary: a.summary,
});
return c.json(toMemberItem(row, 0), 200);
}
return c.json(toMemberItem(row, await repos.members.countKeys(id)), 200);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/members/{id}",
summary: "空壳删除",
description: "仅 key_count = 0 可硬删;名下有 key 返回 409 member_has_keys",
tags: ["成员管理"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
},
responses: {
204: { description: "已删除" },
401: err401,
404: err404,
409: err409,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const repos = c.get("repos");
const before = await repos.members.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
const keyCount = await repos.members.countKeys(id);
if (keyCount > 0) {
return jsonError(c, 409, ERR.member_has_keys(keyCount));
}
await repos.members.deleteById(id);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: "member.delete",
summary: `删除成员 ${before.name}`,
});
return c.body(null, 204);
},
);
-287
View File
@@ -1,287 +0,0 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
qpuAt,
quotaToUsdStr,
resolveQpuTimeline,
toUnixSec,
type PaymentItem,
} from "@new-api-key-usage/core";
import type { PaymentRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import { err401, err404, err422, paymentItemSchema } from "../api-schemas.ts";
import { z } from "zod";
const createBody = z.object({
kind: z.enum(["subscription", "topup"]),
paid_at: z.string(),
paid_amount: z.number(),
remark: z.string().optional().default(""),
period_start: z.string().nullable().optional(),
period_end: z.string().nullable().optional(),
subscription_id: z.number().int().positive().nullable().optional(),
quota_granted: z.number().int().nullable().optional(),
});
function toPaymentItem(row: PaymentRow, quota_usd: string): PaymentItem {
return {
id: row.id,
kind: row.kind as PaymentItem["kind"],
paid_at: toUnixSec(row.paid_at)!,
paid_amount: row.paid_amount,
remark: row.remark,
period_start: toUnixSec(row.period_start),
period_end: toUnixSec(row.period_end),
subscription_id: row.subscription_id,
quota_granted: row.quota_granted,
quota_usd,
};
}
function validatePaymentBody(
body: Record<string, unknown>,
):
| { fields: Record<string, string> }
| {
values: {
kind: string;
paid_at: Date;
paid_amount: number;
remark: string;
period_start: Date | null;
period_end: Date | null;
subscription_id: number | null;
quota_granted: number | null;
};
} {
const fields: Record<string, string> = {};
const kind = body?.kind;
if (kind !== "subscription" && kind !== "topup") {
fields.kind = "请选择订阅或充值";
return { fields };
}
if (!body?.paid_at) fields.paid_at = "请填写支付时间";
if (typeof body?.paid_amount !== "number") {
fields.paid_amount = "请填写支付金额";
}
if (body?.quota_granted == null) fields.quota_granted = "请填写本笔额度";
if (kind === "subscription") {
if (!body?.period_start) fields.period_start = "订阅必须填写期间起点";
if (body?.subscription_id == null) {
fields.subscription_id = "订阅必须填写订阅编号";
}
}
if (Object.keys(fields).length > 0) return { fields };
return {
values: {
kind: kind as string,
paid_at: new Date(String(body.paid_at)),
paid_amount: Number(body.paid_amount),
remark: typeof body.remark === "string" ? body.remark : "",
period_start:
kind === "subscription" && body.period_start
? new Date(String(body.period_start))
: null,
period_end:
body.period_end != null && body.period_end !== ""
? new Date(String(body.period_end))
: null,
subscription_id:
kind === "subscription" && body.subscription_id != null
? Number(body.subscription_id)
: null,
quota_granted: Number(body.quota_granted),
},
};
}
export function adminPaymentRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/payments",
summary: "支付记录清单",
description: "全量直返、支付时间倒序;quota_usd 按 paid_at 时刻 QPU 换算",
tags: ["支付记录"],
security: [{ adminSession: [] }],
responses: {
200: {
description: "payment 数组",
content: {
"application/json": { schema: z.array(paymentItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const rows = await repos.payments.listAll();
const items = rows.map((row) => {
const qpu = qpuAt(row.paid_at, timeline);
return toPaymentItem(
row,
row.quota_granted != null
? quotaToUsdStr(row.quota_granted, qpu)
: "0.00",
);
});
return c.json(items, 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/payments",
summary: "登记支付",
description: "订阅/充值同构;kind 决定期间与订阅编号是否必填",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: createBody } },
required: true,
},
},
responses: {
201: {
description: "payment",
content: { "application/json": { schema: paymentItemSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const body = (await c.req.json().catch(() => ({}))) as Record<
string,
unknown
>;
const parsed = validatePaymentBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
const row = await repos.payments.insert(parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "payment.create",
summary: `登记支付 #${row.id} ${row.kind}`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const qpu = qpuAt(row.paid_at, timeline);
return c.json(
toPaymentItem(
row,
row.quota_granted != null
? quotaToUsdStr(row.quota_granted, qpu)
: "0.00",
),
201,
);
},
);
r.openapi(
createRoute({
method: "put",
path: "/payments/{id}",
summary: "编辑支付",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: { "application/json": { schema: createBody } },
required: true,
},
},
responses: {
200: {
description: "payment",
content: { "application/json": { schema: paymentItemSchema } },
},
401: err401,
404: err404,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = (await c.req.json().catch(() => ({}))) as Record<
string,
unknown
>;
const parsed = validatePaymentBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
if (!(await repos.payments.findById(id))) {
return jsonError(c, 404, ERR.not_found());
}
const row = await repos.payments.update(id, parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "payment.update",
summary: `编辑支付 #${row.id}`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const qpu = qpuAt(row.paid_at, timeline);
return c.json(
toPaymentItem(
row,
row.quota_granted != null
? quotaToUsdStr(row.quota_granted, qpu)
: "0.00",
),
200,
);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/payments/{id}",
summary: "删除支付",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
},
responses: {
204: { description: "No Content" },
401: err401,
404: err404,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const repos = c.get("repos");
const before = await repos.payments.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
await repos.payments.remove(id);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "payment.delete",
summary: `删除支付 #${id}`,
});
return c.body(null, 204);
},
);
return r;
}
+212
View File
@@ -0,0 +1,212 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import { z } from "zod";
import {
ERR,
fetchPlatformSubscriptions,
fetchPlatformTopups,
platformLogin,
} from "@new-api-key-usage/core";
import { jsonError, requireAdminSession, type AppVars } from "../middleware.ts";
import { err401, err422, err502 } from "../api-schemas.ts";
const platformSubsSchema = z.object({
subscriptions: z.array(
z.object({
id: z.number().int(),
plan_id: z.number().int(),
start_time: z.number().int(),
end_time: z.number().int(),
status: z.string(),
amount_total: z.number(),
amount_used: z.number(),
}),
),
});
const loginBody = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
const listBody = z.object({
access_token: z.string().min(1),
});
/**
* 登记辅助:控制台账号登录 + 拉订阅。密码不落库;access_token 由前端短暂持有。
*/
export function adminPlatformRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "post",
path: "/platform/login",
summary: "登录 NewAPI 控制台(登记辅助)",
tags: ["支付"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: loginBody } },
required: true,
},
},
responses: {
200: {
description: "access_token",
content: {
"application/json": {
schema: z.object({ access_token: z.string() }),
},
},
},
401: err401,
422: err422,
502: err502,
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const username = String(body?.username ?? "");
const password = String(body?.password ?? "");
if (!username || !password) {
return jsonError(
c,
422,
ERR.validation_failed({
username: username ? "" : "请填写用户名",
password: password ? "" : "请填写密码",
}),
);
}
const login = await platformLogin(c.get("newapiBase"), username, password);
if (!login.ok) {
if (login.kind === "rejected") return jsonError(c, 422, ERR.invalid_credentials());
if (login.kind === "unreachable") return jsonError(c, 502, ERR.upstream_unreachable());
return jsonError(
c,
422,
ERR.validation_failed({
password: "登录未成功——可能需验证码/加密登录,或账号密码不正确",
}),
);
}
return c.json({ access_token: login.data.access_token }, 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/platform/subscriptions",
summary: "拉取订阅列表(登记辅助)",
tags: ["支付"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: listBody } },
required: true,
},
},
responses: {
200: {
description: "subscriptions",
content: { "application/json": { schema: platformSubsSchema } },
},
401: err401,
422: err422,
502: err502,
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const token = String(body?.access_token ?? "");
if (!token) {
return jsonError(
c,
422,
ERR.validation_failed({ access_token: "缺少 access_token" }),
);
}
const subs = await fetchPlatformSubscriptions(c.get("newapiBase"), token);
if (!subs.ok) {
if (subs.kind === "rejected") {
// 上游 token 失效 ≠ 管理端会话过期:绝不能回 401(前端会误登出)
return jsonError(c, 422, ERR.platform_token_invalid());
}
return jsonError(c, 502, ERR.upstream_unreachable());
}
return c.json({ subscriptions: subs.data }, 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/platform/topups",
summary: "分页拉取充值单(登记辅助)",
tags: ["支付"],
security: [{ adminSession: [] }],
request: {
body: {
content: {
"application/json": {
schema: listBody.extend({
page: z.number().int().positive().optional(),
page_size: z.number().int().positive().max(50).optional(),
}),
},
},
required: true,
},
},
responses: {
200: {
description: "topups page",
content: {
"application/json": {
schema: z.object({
items: z.array(z.looseObject({ id: z.number().int() })),
total: z.number().int(),
page: z.number().int(),
page_size: z.number().int(),
}),
},
},
},
401: err401,
422: err422,
502: err502,
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const token = String(body?.access_token ?? "");
if (!token) {
return jsonError(
c,
422,
ERR.validation_failed({ access_token: "缺少 access_token" }),
);
}
const page = Number(body?.page ?? 1) || 1;
const pageSize = Math.min(50, Number(body?.page_size ?? 10) || 10);
const tops = await fetchPlatformTopups(
c.get("newapiBase"),
token,
page,
pageSize,
);
if (!tops.ok) {
if (tops.kind === "rejected") {
return jsonError(c, 422, ERR.platform_token_invalid());
}
return jsonError(c, 502, ERR.upstream_unreachable());
}
return c.json(tops.data, 200);
},
);
return r;
}
+11 -3
View File
@@ -51,6 +51,14 @@ const DUMMY_PASSWORD_HASH =
export function adminSessionRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
// 只保护需要会话的端点:GET/DELETE /session、PUT /password
// POST /session 是登录、setup* 是引导——不能套鉴权
r.use("/session", async (c, next) => {
if (c.req.method === "POST") return next();
return requireAdminSession(c, next);
});
r.use("/password", requireAdminSession);
r.openapi(
createRoute({
method: "get",
@@ -121,7 +129,7 @@ export function adminSessionRoutes() {
const password = typeof body?.password === "string" ? body.password : "";
const fields: Record<string, string> = {};
if (!username) fields.username = "请填写用户名";
if (password.length < 8) fields.password = "密码至少 8 位";
if (!password) fields.password = "请填写密码";
if (Object.keys(fields).length > 0) {
return jsonError(c, 422, ERR.validation_failed(fields));
}
@@ -266,11 +274,11 @@ export function adminSessionRoutes() {
const body = await c.req.json().catch(() => null);
const old_password = String(body?.old_password ?? "");
const new_password = String(body?.new_password ?? "");
if (new_password.length < 8) {
if (!new_password) {
return jsonError(
c,
422,
ERR.validation_failed({ new_password: "密码至少 8 位" }),
ERR.validation_failed({ new_password: "请填写新密码" }),
);
}
const repos = c.get("repos");
@@ -0,0 +1,300 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
errorBody,
qpuAt,
quotaToUsdStr,
resolveQpuTimeline,
toUnixSec,
type SubscriptionItem,
} from "@new-api-key-usage/core";
import type { SubscriptionRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import {
createSubscriptionBodySchema,
err401,
err404,
err422,
errorSchema,
subscriptionItemSchema,
} from "../api-schemas.ts";
import { z } from "zod";
function toSubscriptionItem(
row: SubscriptionRow,
quota_usd: string,
): SubscriptionItem {
return {
id: row.id,
subscription_id: row.subscription_id,
period_start: toUnixSec(row.period_start)!,
period_end: toUnixSec(row.period_end),
quota_granted: row.quota_granted,
quota_usd,
paid_amount: row.paid_amount,
paid_at: toUnixSec(row.paid_at),
billing_domain: row.billing_domain,
remark: row.remark,
created_at: toUnixSec(row.created_at)!,
};
}
function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
| { fields: Record<string, string> }
| {
values: {
subscription_id: number;
period_start: Date;
period_end: Date | null;
quota_granted: number;
paid_amount: number | null;
paid_at: Date | null;
billing_domain: string | null;
remark: string;
};
} {
const fields: Record<string, string> = {};
if (body.subscription_id == null || body.subscription_id <= 0) {
fields.subscription_id = "请填写订阅编号";
}
if (!body.period_start) fields.period_start = "请填写期间起点";
if (body.quota_granted == null || body.quota_granted <= 0) {
fields.quota_granted = "请填写套餐总额度";
}
if (Object.keys(fields).length > 0) return { fields };
return {
values: {
subscription_id: body.subscription_id,
period_start: new Date(body.period_start),
period_end: body.period_end
? new Date(body.period_end)
: null,
quota_granted: body.quota_granted,
paid_amount: body.paid_amount ?? null,
paid_at: body.paid_at ? new Date(body.paid_at) : null,
billing_domain:
typeof body.billing_domain === "string" && body.billing_domain.trim()
? body.billing_domain.trim()
: null,
remark: body.remark ?? "",
},
};
}
export function adminSubscriptionRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/subscriptions",
summary: "订阅权益清单",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
query: z.object({
billing_domain: z.string().optional(),
}),
},
responses: {
200: {
description: "subscription 数组",
content: {
"application/json": { schema: z.array(subscriptionItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const q = c.req.query();
const filter =
q.billing_domain === "none"
? { billing_domain: "none" as const }
: q.billing_domain
? { billing_domain: q.billing_domain }
: undefined;
const rows = await repos.subscriptions.listAll(filter);
return c.json(
rows.map((row) =>
toSubscriptionItem(
row,
quotaToUsdStr(
row.quota_granted,
qpuAt(row.paid_at ?? row.created_at, timeline),
),
),
),
200,
);
},
);
r.openapi(
createRoute({
method: "post",
path: "/subscriptions",
summary: "登记订阅权益",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
body: {
content: {
"application/json": { schema: createSubscriptionBodySchema },
},
},
},
responses: {
201: {
description: "subscription",
content: {
"application/json": { schema: subscriptionItemSchema },
},
},
401: err401,
409: {
description: "重复登记",
content: { "application/json": { schema: errorSchema } },
},
422: err422,
},
}),
async (c) => {
const body = c.req.valid("json");
const parsed = parseSubBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
const exists = await repos.subscriptions.findBySubscriptionId(
parsed.values.subscription_id,
);
if (exists) {
return jsonError(
c,
409,
errorBody(
"duplicate",
`订阅 #${parsed.values.subscription_id} 已有登记`,
),
);
}
const row = await repos.subscriptions.insert(parsed.values);
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "subscription.create",
summary: `订阅 #${row.subscription_id} · 登记`,
});
return c.json(
toSubscriptionItem(
row,
quotaToUsdStr(
row.quota_granted,
qpuAt(row.paid_at ?? row.created_at, timeline),
),
),
201,
);
},
);
r.openapi(
createRoute({
method: "put",
path: "/subscriptions/{id}",
summary: "编辑订阅权益",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: {
"application/json": { schema: createSubscriptionBodySchema },
},
},
},
responses: {
200: {
description: "subscription",
content: {
"application/json": { schema: subscriptionItemSchema },
},
},
401: err401,
404: err404,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = c.req.valid("json");
const parsed = parseSubBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
if (!(await repos.subscriptions.findById(id))) {
return jsonError(c, 404, ERR.not_found());
}
const row = await repos.subscriptions.update(id, parsed.values);
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "subscription.update",
summary: `订阅 #${row.subscription_id} · 编辑`,
});
return c.json(
toSubscriptionItem(
row,
quotaToUsdStr(
row.quota_granted,
qpuAt(row.paid_at ?? row.created_at, timeline),
),
),
200,
);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/subscriptions/{id}",
summary: "删除订阅权益",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: { params: z.object({ id: z.string() }) },
responses: {
204: { description: "deleted" },
401: err401,
404: err404,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const repos = c.get("repos");
const before = await repos.subscriptions.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
await repos.subscriptions.remove(id);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "subscription.delete",
summary: `订阅 #${before.subscription_id} · 删除`,
});
return c.body(null, 204);
},
);
return r;
}
+256
View File
@@ -0,0 +1,256 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
qpuAt,
quotaToUsdStr,
resolveQpuTimeline,
toUnixSec,
type TopupItem,
} from "@new-api-key-usage/core";
import type { TopupRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import {
createTopupBodySchema,
err401,
err404,
err422,
topupItemSchema,
} from "../api-schemas.ts";
import { z } from "zod";
function toTopupItem(row: TopupRow, qpu: number): TopupItem {
const quota_derived = Math.round(row.amount * qpu);
return {
id: row.id,
upstream_id: row.upstream_id,
amount: row.amount,
money: row.money,
trade_no: row.trade_no,
payment_method: row.payment_method,
payment_provider: row.payment_provider,
status: row.status,
paid_at: toUnixSec(row.paid_at)!,
quota_derived,
quota_usd: quotaToUsdStr(quota_derived, qpu),
billing_domain: row.billing_domain,
remark: row.remark,
created_at: toUnixSec(row.created_at)!,
};
}
function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
| { fields: Record<string, string> }
| {
values: {
upstream_id: number | null;
amount: number;
money: number;
trade_no: string | null;
payment_method: string | null;
payment_provider: string | null;
status: string | null;
paid_at: Date;
billing_domain: string | null;
remark: string;
};
} {
const fields: Record<string, string> = {};
if (!body.paid_at) fields.paid_at = "请填写入账时间";
if (typeof body.amount !== "number") fields.amount = "请填写计费单位金额";
if (typeof body.money !== "number") fields.money = "请填写实付金额";
if (Object.keys(fields).length > 0) return { fields };
return {
values: {
upstream_id: body.upstream_id ?? null,
amount: body.amount,
money: body.money,
trade_no: body.trade_no ?? null,
payment_method: body.payment_method ?? null,
payment_provider: body.payment_provider ?? null,
status: body.status ?? "success",
paid_at: new Date(body.paid_at),
billing_domain:
typeof body.billing_domain === "string" && body.billing_domain.trim()
? body.billing_domain.trim()
: null,
remark: body.remark ?? "",
},
};
}
export function adminTopupRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/topups",
summary: "充值入账清单",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
query: z.object({
billing_domain: z.string().optional(),
}),
},
responses: {
200: {
description: "topup 数组",
content: {
"application/json": { schema: z.array(topupItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const q = c.req.query();
const filter =
q.billing_domain === "none"
? { billing_domain: "none" as const }
: q.billing_domain
? { billing_domain: q.billing_domain }
: undefined;
const rows = await repos.topups.listAll(filter);
return c.json(
rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))),
200,
);
},
);
r.openapi(
createRoute({
method: "post",
path: "/topups",
summary: "登记充值",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
body: {
content: {
"application/json": { schema: createTopupBodySchema },
},
required: true,
},
},
responses: {
201: {
description: "topup",
content: { "application/json": { schema: topupItemSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const body = (await c.req.json().catch(() => ({}))) as z.infer<
typeof createTopupBodySchema
>;
const parsed = parseTopupBody(body as z.infer<typeof createTopupBodySchema>);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
const row = await repos.topups.insert(parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "topup.create",
summary: `充值 #${row.id} · 登记`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
return c.json(toTopupItem(row, qpuAt(row.paid_at, timeline)), 201);
},
);
r.openapi(
createRoute({
method: "put",
path: "/topups/{id}",
summary: "编辑充值",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: {
"application/json": { schema: createTopupBodySchema },
},
required: true,
},
},
responses: {
200: {
description: "topup",
content: { "application/json": { schema: topupItemSchema } },
},
401: err401,
404: err404,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = (await c.req.json().catch(() => ({}))) as z.infer<
typeof createTopupBodySchema
>;
const parsed = parseTopupBody(body as z.infer<typeof createTopupBodySchema>);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
if (!(await repos.topups.findById(id))) {
return jsonError(c, 404, ERR.not_found());
}
const row = await repos.topups.update(id, parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "topup.update",
summary: `充值 #${row.id} · 编辑`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
return c.json(toTopupItem(row, qpuAt(row.paid_at, timeline)), 200);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/topups/{id}",
summary: "删除充值",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: { params: z.object({ id: z.string() }) },
responses: {
204: { description: "deleted" },
401: err401,
404: err404,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const repos = c.get("repos");
const before = await repos.topups.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
await repos.topups.remove(id);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "topup.delete",
summary: `充值 #${before.id} · 删除`,
});
return c.body(null, 204);
},
);
return r;
}
+81 -12
View File
@@ -4,6 +4,7 @@ import {
resolveQpuTimeline,
sumUsage,
toUnixSec,
usageRowPrecise,
type LogItem,
} from "@new-api-key-usage/core";
import {
@@ -49,6 +50,7 @@ const overviewSchema = z.object({
key_id: z.number().int(),
token_name: z.string(),
member_id: z.number().int().nullable(),
member_name: z.string().nullable(),
status: z.string(),
data_cutoff: z.number().int().nullable(),
usage: usageTotals,
@@ -184,11 +186,15 @@ export function adminUsageRoutes() {
}
>();
const keyMeta = await repos.keysExtra.listIdsWithCutoff();
const memberNameById = new Map(
(await repos.members.listAll()).map((m) => [m.id, m.name]),
);
for (const k of keyMeta) {
keyMap.set(k.id, {
token_name: k.token_name,
member_id: k.member_id,
member_name: null,
member_name:
k.member_id == null ? null : (memberNameById.get(k.member_id) ?? null),
status: k.status,
log_synced_at: k.log_synced_at,
rows: [],
@@ -207,6 +213,7 @@ export function adminUsageRoutes() {
key_id,
token_name: v.token_name,
member_id: v.member_id,
member_name: v.member_name,
status: v.status,
data_cutoff: toUnixSec(v.log_synced_at),
usage: sumUsage(v.rows, timeline),
@@ -234,6 +241,7 @@ export function adminUsageRoutes() {
};
b.key_count += 1;
if (
v.status === "active" &&
v.log_synced_at &&
(b.cutoff === null || v.log_synced_at < b.cutoff)
) {
@@ -271,7 +279,7 @@ export function adminUsageRoutes() {
path: "/logs",
summary: "日志明细(下钻)",
description:
"分页;from/to 必填;默认全类型(可 type 筛);token_name 取当前名",
"分页;from/to 必填;默认全类型(可 type / model_name 筛);token_name 取当前名;附带 summary 与 models",
tags: ["用量"],
security: [{ adminSession: [] }],
request: {
@@ -282,12 +290,13 @@ export function adminUsageRoutes() {
member_id: z.string().optional(),
billing_source: z.string().optional(),
subscription_id: z.string().optional(),
model_name: z.string().optional(),
type: z.string().optional(),
}),
},
responses: {
200: {
description: "items/total",
description: "items/total/summary/models",
content: { "application/json": { schema: logPageSchema } },
},
401: err401,
@@ -304,23 +313,42 @@ export function adminUsageRoutes() {
const page_size = Math.min(200, Math.max(1, Number(q.page_size ?? 50) || 50));
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const { rows, total } = await repos.logs.listPage({
const filters = {
from: range.from,
to: range.to,
page,
page_size,
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
member_id: q.member_id ? Number(q.member_id) : undefined,
billing_source: q.billing_source || undefined,
member_id:
q.member_id === "unassigned"
? ("unassigned" as const)
: q.member_id
? Number(q.member_id)
: undefined,
billing_source:
q.billing_source === "none" ? "none" : q.billing_source || undefined,
subscription_id: q.subscription_id
? Number(q.subscription_id)
: undefined,
type: q.type != null && q.type !== "" ? Number(q.type) : undefined,
model_name: q.model_name || undefined,
type:
q.type === "error"
? ("error" as const)
: q.type != null && q.type !== ""
? Number(q.type)
: undefined,
};
const { rows, total } = await repos.logs.listPage({
...filters,
page,
page_size,
});
const sum = await repos.logs.summarize(filters);
const usage = sumUsage(sum.usage_rows, timeline);
const models = await repos.logs.listModels(filters);
const items: LogItem[] = rows.map((row) => {
const { quota_usd } = sumUsage(
[{ quota: row.quota, created_at: row.created_at }],
const { quota_usd } = usageRowPrecise(
row.quota,
row.created_at,
timeline,
);
return {
@@ -339,10 +367,51 @@ export function adminUsageRoutes() {
is_stream: row.is_stream,
billing_source: row.billing_source as LogItem["billing_source"],
subscription_id: row.subscription_id,
raw: {
id: row.id,
api_key_id: row.api_key_id,
dedupe_key: row.dedupe_key,
billing_source: row.billing_source,
subscription_id: row.subscription_id,
user_id: row.user_id,
created_at: toUnixSec(row.created_at),
type: row.type,
content: row.content,
username: row.username,
token_name: row.token_name,
model_name: row.model_name,
quota: row.quota,
prompt_tokens: row.prompt_tokens,
completion_tokens: row.completion_tokens,
use_time: row.use_time,
is_stream: row.is_stream,
channel: row.channel,
channel_name: row.channel_name,
token_id: row.token_id,
group_name: row.group_name,
ip: row.ip,
request_id: row.request_id,
upstream_request_id: row.upstream_request_id,
other: row.other,
},
};
});
return c.json({ items, total }, 200);
return c.json(
{
items,
total,
summary: {
hit_count: sum.hit_count,
usage_count: sum.usage_count,
error_count: sum.error_count,
quota: usage.quota,
quota_usd: usage.quota_usd,
},
models,
},
200,
);
},
);
+135 -50
View File
@@ -3,9 +3,9 @@ import {
ERR,
resolveQpuTimeline,
sumUsage,
usageRowPrecise,
toUnixSec,
type LogItem,
type PaymentItem,
} from "@new-api-key-usage/core";
import {
jsonError,
@@ -18,7 +18,6 @@ import {
logPageSchema,
memberMeSchema,
memberOverviewSchema,
paymentItemSchema,
} from "../api-schemas.ts";
import { z } from "zod";
@@ -72,7 +71,7 @@ export function memberViewRoutes() {
path: "/overview",
summary: "单页聚合",
description:
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(完全公开透明)",
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(同账本域内可见)",
tags: ["成员视图"],
security: [{ memberToken: [] }],
request: {
@@ -160,42 +159,66 @@ export function memberViewRoutes() {
usage: sumUsage(v.rows, timeline),
}));
// 「该时段涉及」支付:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to)
const paymentRows = await repos.payments.listAll();
// 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 我域内进行中;
// 充值 = 我域内且 paid_at ∈ [from,to)。未标域默认藏(仅「涉及我日志」的订阅例外)。
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
const myDomains = new Set<string>();
for (const k of allKeys) {
if (k.billing_domain) myDomains.add(k.billing_domain);
}
const subRows = await repos.subscriptions.listAll();
const usedSubIds = new Set(
rows.map((x) => x.subscription_id).filter((v): v is number => v != null),
);
const payments: PaymentItem[] = [];
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
for (const p of paymentRows) {
const inRange = p.paid_at >= range.from && p.paid_at < range.to;
const subscriptionsOut = [];
for (const s of subRows) {
const ongoing =
p.kind === "subscription" &&
(p.period_end == null ||
p.period_end > new Date() ||
(p.period_start != null &&
p.period_start <= range.to &&
(p.period_end ?? new Date()) >= range.from));
const involved =
p.kind === "topup"
? inRange
: usedSubIds.has(p.subscription_id ?? -1) || ongoing;
if (!involved) continue;
const qpu = qpuAt(p.paid_at, timeline);
payments.push({
id: p.id,
kind: p.kind as PaymentItem["kind"],
paid_at: toUnixSec(p.paid_at)!,
paid_amount: p.paid_amount,
remark: p.remark,
period_start: toUnixSec(p.period_start),
period_end: toUnixSec(p.period_end),
subscription_id: p.subscription_id,
quota_granted: p.quota_granted,
quota_usd:
p.quota_granted != null
? quotaToUsdStr(p.quota_granted, qpu)
: "0.00",
s.period_end == null ||
s.period_end > new Date() ||
(s.period_start <= range.to &&
(s.period_end ?? new Date()) >= range.from);
const involved = usedSubIds.has(s.subscription_id);
const inMyDomain =
s.billing_domain != null && myDomains.has(s.billing_domain);
// 涉及我日志的始终可见(事实直连);其余仅我域内进行中可见
if (!involved && !(ongoing && inMyDomain)) continue;
const qpu = qpuAt(s.paid_at ?? s.created_at, timeline);
subscriptionsOut.push({
id: s.id,
subscription_id: s.subscription_id,
period_start: toUnixSec(s.period_start)!,
period_end: toUnixSec(s.period_end),
quota_granted: s.quota_granted,
quota_usd: quotaToUsdStr(s.quota_granted, qpu),
paid_amount: s.paid_amount,
paid_at: toUnixSec(s.paid_at),
billing_domain: s.billing_domain,
remark: s.remark,
created_at: toUnixSec(s.created_at)!,
});
}
const topupRows = await repos.topups.listAll();
const topupsOut = [];
for (const t of topupRows) {
if (t.billing_domain == null || !myDomains.has(t.billing_domain)) continue;
if (!(t.paid_at >= range.from && t.paid_at < range.to)) continue;
const qpu = qpuAt(t.paid_at, timeline);
const quota_derived = Math.round(t.amount * qpu);
topupsOut.push({
id: t.id,
upstream_id: t.upstream_id,
amount: t.amount,
money: t.money,
trade_no: t.trade_no,
payment_method: t.payment_method,
payment_provider: t.payment_provider,
status: t.status,
paid_at: toUnixSec(t.paid_at)!,
billing_domain: t.billing_domain,
quota_derived,
quota_usd: quotaToUsdStr(quota_derived, qpu),
remark: t.remark,
created_at: toUnixSec(t.created_at)!,
});
}
@@ -205,14 +228,8 @@ export function memberViewRoutes() {
usage,
by_source,
keys,
payments: payments.map((p) => ({
...p,
kind: p.kind as string,
period_start: p.period_start ?? null,
period_end: p.period_end ?? null,
subscription_id: p.subscription_id ?? null,
quota_granted: p.quota_granted ?? null,
})),
subscriptions: subscriptionsOut,
topups: topupsOut,
},
200,
);
@@ -224,7 +241,8 @@ export function memberViewRoutes() {
method: "get",
path: "/logs",
summary: "日志明细(名下 key)",
description: "分页;恒 type=2;DTO 与管理面同构",
description:
"分页;默认全类型(可 type / model_name 筛);DTO 与管理面同构;附带 summary 与 models",
tags: ["成员视图"],
security: [{ memberToken: [] }],
request: {
@@ -233,11 +251,16 @@ export function memberViewRoutes() {
to: z.string(),
page: z.string().optional(),
page_size: z.string().optional(),
api_key_id: z.string().optional(),
billing_source: z.string().optional(),
subscription_id: z.string().optional(),
model_name: z.string().optional(),
type: z.string().optional(),
}),
},
responses: {
200: {
description: "items/total",
description: "items/total/summary/models",
content: { "application/json": { schema: logPageSchema } },
},
401: err401,
@@ -257,16 +280,37 @@ export function memberViewRoutes() {
);
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const { rows, total } = await repos.logs.listMemberPage({
member_id: c.get("memberId")!,
const member_id = c.get("memberId")!;
const filters = {
member_id,
from: range.from,
to: range.to,
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
billing_source:
q.billing_source === "none" ? "none" : q.billing_source || undefined,
subscription_id: q.subscription_id
? Number(q.subscription_id)
: undefined,
model_name: q.model_name || undefined,
type:
q.type === "error"
? ("error" as const)
: q.type != null && q.type !== ""
? Number(q.type)
: undefined,
};
const { rows, total } = await repos.logs.listMemberPage({
...filters,
page,
page_size,
});
const sum = await repos.logs.summarizeMember(filters);
const usage = sumUsage(sum.usage_rows, timeline);
const models = await repos.logs.listMemberModels(filters);
const items: LogItem[] = rows.map((row) => {
const { quota_usd } = sumUsage(
[{ quota: row.quota, created_at: row.created_at }],
const { quota_usd } = usageRowPrecise(
row.quota,
row.created_at,
timeline,
);
return {
@@ -285,9 +329,50 @@ export function memberViewRoutes() {
is_stream: row.is_stream,
billing_source: row.billing_source as LogItem["billing_source"],
subscription_id: row.subscription_id,
raw: {
id: row.id,
api_key_id: row.api_key_id,
dedupe_key: row.dedupe_key,
billing_source: row.billing_source,
subscription_id: row.subscription_id,
user_id: row.user_id,
created_at: toUnixSec(row.created_at),
type: row.type,
content: row.content,
username: row.username,
token_name: row.token_name,
model_name: row.model_name,
quota: row.quota,
prompt_tokens: row.prompt_tokens,
completion_tokens: row.completion_tokens,
use_time: row.use_time,
is_stream: row.is_stream,
channel: row.channel,
channel_name: row.channel_name,
token_id: row.token_id,
group_name: row.group_name,
ip: row.ip,
request_id: row.request_id,
upstream_request_id: row.upstream_request_id,
other: row.other,
},
};
});
return c.json({ items, total }, 200);
return c.json(
{
items,
total,
summary: {
hit_count: sum.hit_count,
usage_count: sum.usage_count,
error_count: sum.error_count,
quota: usage.quota,
quota_usd: usage.quota_usd,
},
models,
},
200,
);
},
);
-54
View File
@@ -1,54 +0,0 @@
# apps/web
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
bun install
```
### Compile and Hot-Reload for Development
```sh
bun dev
```
### Type-Check, Compile and Minify for Production
```sh
bun run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
bun test:unit
```
### Lint with [ESLint](https://eslint.org/)
```sh
bun lint
```
+4 -1
View File
@@ -3,7 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>antdv-next §3.7 能力 spike</title>
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>NAKU · NewAPI 用量统计</title>
</head>
<body>
<div id="app"></div>
+6 -1
View File
@@ -16,8 +16,13 @@
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@antdv-next/icons": "^1.1.2",
"@new-api-key-usage/core": "workspace:*",
"antdv-next": "^1.6.0",
"@tanstack/vue-query": "^5.104.1",
"@vueuse/core": "15.0.0",
"antdv-next": "1.6.0",
"axios": "^1.20.0",
"dayjs": "1.11.23",
"pinia": "^4.0.3",
"vue": "^3.5.42",
"vue-router": "^5.3.1"
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 636 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#1677FF"/>
<text x="16" y="21.5" text-anchor="middle" dominant-baseline="middle"
font-family="Segoe UI, system-ui, sans-serif" font-size="18"
font-weight="600" fill="#FFFFFF">N</text>
</svg>

After

Width:  |  Height:  |  Size: 314 B

-168
View File
@@ -1,168 +0,0 @@
<script setup lang="ts">
/**
* antdv-next §3.7 能力 spike——逐项覆盖《交互设计-总览与共性》清单。
* 任一项不覆盖即触发回退(Element Plus / ant-design-vue)并回报作者。
*/
import { h, ref } from "vue";
const collapsed = ref(false);
const drawerOpen = ref(false);
const modalOpen = ref(false);
const confirmLoading = ref(false);
const range = ref<[string, string] | null>(null);
const skKey = ref("");
const quotaMode = ref<"usd" | "quota">("usd");
const page = ref(1);
const dataSource = [
{
key: "1",
token_name: "小张-01",
billing_source: "subscription",
status: "active",
quota: 250000,
quota_usd: "0.50",
},
{
key: "2",
token_name: "小李-02",
billing_source: "wallet",
status: "disabled",
quota: 100000,
quota_usd: "0.20",
},
];
const columns = [
{ title: "名称", dataIndex: "token_name", key: "token_name" },
{
title: "来源",
key: "billing_source",
customRender: ({ record }: { record: (typeof dataSource)[0] }) =>
h(
"a-tag",
{ color: record.billing_source === "subscription" ? "blue" : "gold" },
() => (record.billing_source === "subscription" ? "订阅" : "充值"),
),
},
{
title: "状态",
key: "status",
customRender: ({ record }: { record: (typeof dataSource)[0] }) =>
h(
"a-badge",
{
status: record.status === "active" ? "success" : "default",
text: record.status === "active" ? "启用" : "停用",
},
),
},
{ title: "quota", dataIndex: "quota", key: "quota" },
{ title: "额度美元", dataIndex: "quota_usd", key: "quota_usd" },
];
function onCopy() {
// toast:复制反馈
(window as unknown as { antd?: { message?: { success: (m: string) => void } } })
.antd?.message?.success?.("已复制");
}
function onRangeChange(vals: [string, string] | null) {
range.value = vals;
}
</script>
<template>
<a-layout style="min-height: 100vh">
<a-layout-sider v-model:collapsed="collapsed" collapsible>
<div style="color: #fff; padding: 16px; font-weight: 600">
new-api-key-usage
</div>
<a-menu theme="dark" mode="inline" default-selected-keys="['spike']">
<a-menu-item key="spike">能力 Spike</a-menu-item>
<a-menu-item key="overview" disabled>全员总览</a-menu-item>
<a-menu-item key="keys" disabled>Key 管理</a-menu-item>
</a-menu>
</a-layout-sider>
<a-layout>
<a-layout-content style="margin: 16px; background: #fff; padding: 16px">
<a-alert
type="info"
show-icon
closable
message="antdv-next §3.7 能力 spike"
description="可折叠侧边栏 / 抽屉 · 密集表格与展开行 · 对话框与二次确认 · message / Alert · DatePicker 范围 · 大号粘贴 · 徽章 / Segmented / Skeleton / Pagination"
style="margin-bottom: 16px"
/>
<a-space direction="vertical" style="width: 100%" :size="16">
<a-space wrap>
<a-button type="primary" @click="drawerOpen = true">打开抽屉</a-button>
<a-button @click="modalOpen = true">打开对话框</a-button>
<a-button @click="onCopy">message.toast</a-button>
<a-popconfirm title="确认停用?" @confirm="onCopy">
<a-button danger>二次确认</a-button>
</a-popconfirm>
</a-space>
<a-space wrap>
<a-range-picker
style="width: 320px"
@change="(_: unknown, vals: [string, string]) => onRangeChange(vals)"
/>
<a-segmented
v-model:value="quotaMode"
:options="[
{ label: '美元', value: 'usd' },
{ label: 'quota', value: 'quota' },
]"
/>
</a-space>
<a-textarea
v-model:value="skKey"
:rows="4"
placeholder="大号粘贴输入框:sk-key 登记对话框用"
/>
<a-table
:columns="columns"
:data-source="dataSource"
:pagination="false"
size="small"
:scroll="{ x: 800 }"
:expand-column-width="48"
>
<template #expandedRowRender="{ record }">
<div style="padding: 8px">
来源徽章展开行:{{ record.billing_source }} ·
<a-tag color="green">订阅 #3</a-tag>
</div>
</template>
</a-table>
<a-pagination v-model:current="page" :total="50" show-size-changer />
<a-skeleton active :paragraph="{ rows: 2 }" />
</a-space>
<a-drawer
v-model:open="drawerOpen"
title="可折叠侧边栏 / 抽屉"
placement="right"
:width="320"
>
<p>抽屉能力:筛选面板 / 详情。</p>
</a-drawer>
<a-modal
v-model:open="modalOpen"
title="对话框"
:confirm-loading="confirmLoading"
@ok="modalOpen = false"
>
<p>对话框 + 二次确认能力。</p>
</a-modal>
</a-layout-content>
</a-layout>
</a-layout>
</template>
+15
View File
@@ -0,0 +1,15 @@
import type { AuditLogItem, Paginated } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export async function fetchAuditLogs(params: {
page: number;
page_size: number;
domain?: string;
from?: string;
to?: string;
}): Promise<Paginated<AuditLogItem>> {
const { data } = await adminHttp.get<Paginated<AuditLogItem>>("/audit-logs", {
params,
});
return data;
}
+12
View File
@@ -0,0 +1,12 @@
import type { EngineStatus, QpuLatest } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export async function fetchEngine(): Promise<EngineStatus> {
const { data } = await adminHttp.get<EngineStatus>("/engine");
return data;
}
export async function fetchQpuLatest(): Promise<QpuLatest> {
const { data } = await adminHttp.get<QpuLatest>("/qpu/latest");
return data;
}
+35
View File
@@ -0,0 +1,35 @@
import type { KeyItem } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type KeyListFilters = {
member_id?: number | "unassigned";
status?: "active" | "disabled";
billing_domain?: string | "none";
recent_failed?: boolean;
};
export async function fetchKeys(filters: KeyListFilters = {}): Promise<KeyItem[]> {
const { data } = await adminHttp.get<KeyItem[]>("/keys", { params: filters });
return data;
}
export async function registerKey(body: {
sk_key: string;
member_id?: number | null;
billing_domain?: string | null;
}): Promise<KeyItem> {
const { data } = await adminHttp.post<KeyItem>("/keys", body);
return data;
}
export async function patchKey(
id: number,
body: {
status?: "active" | "disabled";
member_id?: number | null;
billing_domain?: string | null;
},
): Promise<KeyItem> {
const { data } = await adminHttp.patch<KeyItem>(`/keys/${id}`, body);
return data;
}
+22
View File
@@ -0,0 +1,22 @@
import type { LogPage } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type AdminLogFilters = {
from: string;
to: string;
page: number;
page_size: number;
api_key_id?: number;
member_id?: number | "unassigned";
billing_source?: string;
subscription_id?: number;
model_name?: string;
type?: number | "error";
};
export async function fetchLogs(params: AdminLogFilters): Promise<LogPage> {
const { data } = await adminHttp.get<LogPage>("/logs", {
params,
});
return data;
}
+44
View File
@@ -0,0 +1,44 @@
import type { MemberItem } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type MemberWithToken = {
member: MemberItem;
token: string;
};
export async function fetchMembers(): Promise<MemberItem[]> {
const { data } = await adminHttp.get<MemberItem[]>("/members");
return data;
}
/** 开户:响应含令牌明文(仅此一次) */
export async function createMember(body: {
name: string;
remark?: string;
}): Promise<MemberWithToken> {
const { data } = await adminHttp.post<MemberWithToken>("/members", body);
return data;
}
export async function patchMember(
id: number,
body: {
name?: string;
remark?: string;
status?: "active" | "disabled";
},
): Promise<MemberItem> {
const { data } = await adminHttp.patch<MemberItem>(`/members/${id}`, body);
return data;
}
/** 空壳删除:仅 key_count = 0 */
export async function deleteMember(id: number): Promise<void> {
await adminHttp.delete(`/members/${id}`);
}
/** 重发:旧令牌即刻失效,明文仅此一次 */
export async function issueMemberToken(id: number): Promise<{ token: string }> {
const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`);
return data;
}
+39
View File
@@ -0,0 +1,39 @@
import type { UsageTotals, UnixSec } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type AdminOverview = {
data_cutoff: UnixSec | null;
unassigned_subscription: UsageTotals;
by_source: Array<{
billing_source: string;
count: number;
quota: number;
quota_usd: string;
}>;
by_member: Array<{
member_id: number | null;
member_name: string | null;
key_count: number;
data_cutoff: UnixSec | null;
usage: UsageTotals;
}>;
by_key: Array<{
key_id: number;
token_name: string;
member_id: number | null;
member_name: string | null;
status: string;
data_cutoff: UnixSec | null;
usage: UsageTotals;
}>;
};
export async function fetchOverview(range: {
from: string;
to: string;
}): Promise<AdminOverview> {
const { data } = await adminHttp.get<AdminOverview>("/overview", {
params: range,
});
return data;
}
+70
View File
@@ -0,0 +1,70 @@
import { adminHttp } from "../http.ts";
export type PlatformSubscription = {
id: number;
plan_id: number;
start_time: number;
end_time: number;
status: string;
amount_total: number;
amount_used: number;
};
export type PlatformTopup = {
id: number;
amount: number;
money: number;
trade_no?: string;
payment_method?: string;
payment_provider?: string;
create_time?: number;
complete_time?: number;
status?: string;
};
export type PlatformLoginResult = { access_token: string };
/** 登录控制台(密码不落库);token 由调用方短暂保存 */
export async function loginPlatform(body: {
username: string;
password: string;
}): Promise<PlatformLoginResult> {
const { data } = await adminHttp.post<PlatformLoginResult>(
"/platform/login",
body,
);
return data;
}
export async function listPlatformSubscriptions(
accessToken: string,
): Promise<PlatformSubscription[]> {
const { data } = await adminHttp.post<{ subscriptions: PlatformSubscription[] }>(
"/platform/subscriptions",
{ access_token: accessToken },
);
return data.subscriptions;
}
export async function listPlatformTopups(
accessToken: string,
page = 1,
pageSize = 10,
): Promise<{
items: PlatformTopup[];
total: number;
page: number;
page_size: number;
}> {
const { data } = await adminHttp.post<{
items: PlatformTopup[];
total: number;
page: number;
page_size: number;
}>("/platform/topups", {
access_token: accessToken,
page,
page_size: pageSize,
});
return data;
}
+41
View File
@@ -0,0 +1,41 @@
import { adminHttp } from "../http.ts";
export type SetupState = { initialized: boolean };
export type UsernameBody = { username: string };
export async function fetchSetupState(): Promise<SetupState> {
const { data } = await adminHttp.get<SetupState>("/setup-state");
return data;
}
export async function setup(body: {
username: string;
password: string;
}): Promise<UsernameBody> {
const { data } = await adminHttp.post<UsernameBody>("/setup", body);
return data;
}
export async function login(body: {
username: string;
password: string;
}): Promise<UsernameBody> {
const { data } = await adminHttp.post<UsernameBody>("/session", body);
return data;
}
export async function fetchSession(): Promise<UsernameBody> {
const { data } = await adminHttp.get<UsernameBody>("/session");
return data;
}
export async function logout(): Promise<void> {
await adminHttp.delete("/session");
}
export async function changePassword(body: {
old_password: string;
new_password: string;
}): Promise<void> {
await adminHttp.put("/password", body);
}
+44
View File
@@ -0,0 +1,44 @@
import type { SubscriptionItem } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type SubscriptionBody = {
subscription_id: number;
period_start: string;
period_end?: string | null;
quota_granted: number;
paid_amount?: number | null;
paid_at?: string | null;
billing_domain?: string | null;
remark?: string;
};
export async function fetchSubscriptions(filter?: {
billing_domain?: string | "none";
}): Promise<SubscriptionItem[]> {
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions", {
params: filter,
});
return data;
}
export async function createSubscription(
body: SubscriptionBody,
): Promise<SubscriptionItem> {
const { data } = await adminHttp.post<SubscriptionItem>("/subscriptions", body);
return data;
}
export async function updateSubscription(
id: number,
body: SubscriptionBody,
): Promise<SubscriptionItem> {
const { data } = await adminHttp.put<SubscriptionItem>(
`/subscriptions/${id}`,
body,
);
return data;
}
export async function deleteSubscription(id: number): Promise<void> {
await adminHttp.delete(`/subscriptions/${id}`);
}
+41
View File
@@ -0,0 +1,41 @@
import type { TopupItem } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type TopupBody = {
upstream_id?: number | null;
amount: number;
money: number;
trade_no?: string | null;
payment_method?: string | null;
payment_provider?: string | null;
status?: string | null;
paid_at: string;
billing_domain?: string | null;
remark?: string;
};
export async function fetchTopups(filter?: {
billing_domain?: string | "none";
}): Promise<TopupItem[]> {
const { data } = await adminHttp.get<TopupItem[]>("/topups", {
params: filter,
});
return data;
}
export async function createTopup(body: TopupBody): Promise<TopupItem> {
const { data } = await adminHttp.post<TopupItem>("/topups", body);
return data;
}
export async function updateTopup(
id: number,
body: TopupBody,
): Promise<TopupItem> {
const { data } = await adminHttp.put<TopupItem>(`/topups/${id}`, body);
return data;
}
export async function deleteTopup(id: number): Promise<void> {
await adminHttp.delete(`/topups/${id}`);
}
+24
View File
@@ -0,0 +1,24 @@
/**
* 401 分流出口:由 main.ts 接到 router,避免 http ↔ router 循环依赖。
* 管理面任意 401 → /admin/login;成员视图任意 401 → /member/login(契约公共约定 §2)。
*/
export type AuthWorld = "admin" | "member";
type AuthRedirectHandler = (world: AuthWorld, reason: "expired" | "unauthorized") => void;
const fallback: AuthRedirectHandler = (world) => {
window.location.href = world === "admin" ? "/admin/login" : "/member/login";
};
let handler: AuthRedirectHandler = fallback;
export function setAuthRedirect(fn: AuthRedirectHandler): void {
handler = fn;
}
export function redirectAuth(
world: AuthWorld,
reason: "expired" | "unauthorized" = "unauthorized",
): void {
handler(world, reason);
}
+108
View File
@@ -0,0 +1,108 @@
import axios, {
AxiosError,
type AxiosInstance,
type InternalAxiosRequestConfig,
} from "axios";
import type { ErrorEnvelope } from "@new-api-key-usage/core";
import { redirectAuth, type AuthWorld } from "./auth-redirect.ts";
const MEMBER_TOKEN_KEY = "member_token";
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly fields: Record<string, string> | undefined;
readonly keyId: number | undefined;
constructor(
status: number,
code: string,
message: string,
extra?: { fields?: Record<string, string>; key_id?: number },
) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
this.fields = extra?.fields;
this.keyId = extra?.key_id;
}
}
function parseErrorEnvelope(data: unknown): ErrorEnvelope | null {
if (!data || typeof data !== "object") return null;
const env = data as Partial<ErrorEnvelope>;
if (!env.error || typeof env.error !== "object") return null;
const { code, message } = env.error;
if (typeof code !== "string" || typeof message !== "string") return null;
return env as ErrorEnvelope;
}
function toApiError(error: AxiosError, world: AuthWorld): ApiError {
const envelope = parseErrorEnvelope(error.response?.data);
if (envelope) {
return new ApiError(
error.response?.status ?? 0,
envelope.error.code,
envelope.error.message,
{
fields: envelope.error.fields,
key_id: envelope.error.key_id,
},
);
}
const status = error.response?.status ?? 0;
if (status === 401) {
return new ApiError(401, "unauthorized", "凭证无效或已过期");
}
if (status === 0 || !error.response) {
return new ApiError(0, "network", "网络异常,请稍后重试");
}
// 中间人非 JSON 错误体(代理 502 页等)回退通用文案
return new ApiError(status, "internal", "服务暂时不可用,请稍后重试");
}
function attachInterceptors(client: AxiosInstance, world: AuthWorld): void {
if (world === "member") {
client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = sessionStorage.getItem(MEMBER_TOKEN_KEY);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
}
client.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
const apiError = toApiError(error, world);
// 401 仅表示「本世界凭证失效」;上游平台 token 失效是 422 platform_token_invalid
if (
apiError.status === 401 &&
apiError.code !== "platform_token_invalid"
) {
redirectAuth(world, "expired");
}
return Promise.reject(apiError);
},
);
}
function createClient(world: AuthWorld, baseURL: string): AxiosInstance {
const client = axios.create({
baseURL,
timeout: 30_000,
// 管理面会话 cookie 需跨端口(Vite 5173 → API 18456)携带
withCredentials: true,
headers: { Accept: "application/json" },
});
attachInterceptors(client, world);
return client;
}
/** 管理面:`/api/admin/*`,会话 cookie */
export const adminHttp = createClient("admin", "/api/admin");
/** 成员视图:`/api/member/*`,Bearer 令牌 */
export const memberHttp = createClient("member", "/api/member");
+66
View File
@@ -0,0 +1,66 @@
import type {
LogPage,
SubscriptionItem,
TopupItem,
UnixSec,
UsageTotals,
} from "@new-api-key-usage/core";
import { memberHttp } from "../http.ts";
export type MemberMe = {
id: number;
name: string;
};
export type MemberOverview = {
member: { id: number | null; name: string | null };
usage: UsageTotals;
by_source: Array<{
billing_source: string;
count: number;
quota: number;
quota_usd: string;
}>;
keys: Array<{
key_id: number;
token_name: string;
log_synced_at: UnixSec | null;
usage: UsageTotals;
}>;
subscriptions: SubscriptionItem[];
topups: TopupItem[];
};
export async function fetchMe(): Promise<MemberMe> {
const { data } = await memberHttp.get<MemberMe>("/me");
return data;
}
export async function fetchOverview(range: {
from: string;
to: string;
}): Promise<MemberOverview> {
const { data } = await memberHttp.get<MemberOverview>("/overview", {
params: range,
});
return data;
}
export type MemberLogQuery = {
from: string;
to: string;
page: number;
page_size: number;
api_key_id?: number;
billing_source?: string;
subscription_id?: number;
model_name?: string;
type?: number | "error";
};
export async function fetchLogs(query: MemberLogQuery): Promise<LogPage> {
const { data } = await memberHttp.get<LogPage>("/logs", {
params: query,
});
return data;
}
+70
View File
@@ -0,0 +1,70 @@
/**
* vue-query key 工厂——失效与缓存的唯一命名源。
* 约定:`[面, 端点, …参数]`,参数保持可 JSON 序列化。
*/
export type KeyFilters = {
member_id?: number | "unassigned";
status?: "active" | "disabled";
recent_failed?: boolean;
};
export type PageQuery = {
page: number;
page_size: number;
};
export type TimeRange = {
from: string;
to: string;
};
export const qk = {
admin: {
setupState: () => ["admin", "setup-state"] as const,
session: () => ["admin", "session"] as const,
engine: () => ["admin", "engine"] as const,
overview: (range: TimeRange) =>
["admin", "overview", range.from, range.to] as const,
keys: (filters: KeyFilters) => ["admin", "keys", filters] as const,
logs: (range: TimeRange & PageQuery & Record<string, unknown>) =>
[
"admin",
"logs",
range.from,
range.to,
range.page,
range.page_size,
range.api_key_id ?? "",
range.member_id ?? "",
range.model_name ?? "",
range.billing_source ?? "",
range.subscription_id ?? "",
range.type ?? "",
] as const,
subscriptions: () => ["admin", "subscriptions"] as const,
topups: () => ["admin", "topups"] as const,
qpuLatest: () => ["admin", "qpu-latest"] as const,
members: () => ["admin", "members"] as const,
auditLogs: (q: PageQuery & { domain?: string; from?: string; to?: string }) =>
["admin", "audit-logs", q.page, q.page_size, q.domain ?? "", q.from ?? "", q.to ?? ""] as const,
},
member: {
me: () => ["member", "me"] as const,
overview: (range: TimeRange) =>
["member", "overview", range.from, range.to] as const,
logs: (range: TimeRange & PageQuery & Record<string, unknown>) =>
[
"member",
"logs",
range.from,
range.to,
range.page,
range.page_size,
range.api_key_id ?? "",
range.model_name ?? "",
range.billing_source ?? "",
range.subscription_id ?? "",
range.type ?? "",
] as const,
},
};
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
// 路由出口 + 全局中文(DatePicker 等默认 en-US,须用 ConfigProvider)
import zhCN from "antdv-next/locale/zh_CN";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
dayjs.locale("zh-cn");
</script>
<template>
<a-config-provider :locale="zhCN">
<router-view />
</a-config-provider>
</template>
@@ -0,0 +1,186 @@
<script setup lang="ts">
/**
* 健康带(交互-全员总览 §1.1):引擎轮次 · 全池数据截止 · 未关联订阅
* 文案偏完整说明,不追求极简电报体。
*/
import { computed } from "vue";
import type { EngineStatus } from "@new-api-key-usage/core";
import type { AdminOverview } from "@/api/admin/overview.ts";
import { formatMinute, hoursSince } from "@/utils/time.ts";
const props = defineProps<{
engine: EngineStatus | undefined;
overview: AdminOverview | undefined;
loading?: boolean;
}>();
const emit = defineEmits<{
(e: "go-failed-keys"): void;
}>();
const lastRound = computed(() => props.engine?.last_round ?? null);
const failedCount = computed(() => lastRound.value?.failed ?? 0);
const cutoff = computed(() => props.overview?.data_cutoff ?? null);
const cutoffHours = computed(() => hoursSince(cutoff.value));
const cutoffStale = computed(
() => cutoffHours.value != null && cutoffHours.value > 2,
);
const unassigned = computed(
() => props.overview?.unassigned_subscription ?? null,
);
const unassignedBad = computed(
() => (unassigned.value?.count ?? 0) > 0 || (unassigned.value?.quota ?? 0) > 0,
);
</script>
<template>
<div class="health-band">
<a-skeleton v-if="loading" active :paragraph="{ rows: 1 }" :title="false" />
<template v-else>
<div class="health-band__item">
<div class="health-band__title">同步引擎</div>
<div class="health-band__body">
<template v-if="lastRound">
最近一次同步轮次共处理
<strong>{{ lastRound.keys_total }}</strong>
枚 key,其中成功
<strong>{{ lastRound.succeeded }}</strong>
枚。
<template v-if="failedCount > 0">
有
<a-button
type="link"
size="small"
class="health-band__fail-btn"
@click="emit('go-failed-keys')"
>
{{ failedCount }} 枚拉取失败
</a-button>
,可到 Key 管理筛选「最近轮次失败」查看是哪几枚。
</template>
<template v-else>没有拉取失败。</template>
</template>
<span v-else class="health-band__muted">
引擎尚未完成过同步轮次,或引擎进程未启动——此时用量数据可能还不完整。
</span>
</div>
</div>
<div class="health-band__item">
<div class="health-band__title">数据截止</div>
<div class="health-band__body">
<a-tooltip
title="取全部 key 的 log_synced_at 中最慢的那一个;某枚 key 同步失败会一直不写该字段,从而拖住全池截止"
>
<span>全池同步截止于 <strong>{{ formatMinute(cutoff) }}</strong>(北京时间)。</span>
</a-tooltip>
<template v-if="cutoffStale">
<span v-if="failedCount > 0" class="health-band__warn">
截止时间落后超过 2 小时——通常是
<a-button
type="link"
size="small"
class="health-band__fail-btn"
@click="emit('go-failed-keys')"
>
同步失败的 key
</a-button>
一直未成功、拖住了全池最慢值;请到 Key 管理处理(必要时停用)。
</span>
<span v-else class="health-band__warn">
截止时间落后超过 2 小时,而最近轮次没有失败 key——同步链路可能停滞,请检查引擎日志。
</span>
</template>
<span v-else-if="cutoff != null" class="health-band__ok">
目前在正常滞后范围内。
</span>
</div>
</div>
<div class="health-band__item">
<div class="health-band__title">未关联订阅</div>
<div class="health-band__body">
<a-tooltip
title="统计的是「来源为订阅、但日志上没有订阅编号」的用量;正常情况下应始终为 0"
>
<template v-if="unassignedBad">
<span class="health-band__danger">
本时段发现
{{ unassigned?.count }} 条 / ${{ unassigned?.quota_usd }}
订阅来源用量未关联到任何订阅编号
</span>
(说明支付记录缺登记,或上游计费行为发生了变化),需要人工核对。
</template>
<template v-else>
本时段没有出现「订阅来源却关联不到订阅编号」的用量,属正常状态。
</template>
</a-tooltip>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.health-band {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 20px;
margin-bottom: 24px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fafafa;
font-size: 13px;
line-height: 22px;
}
.health-band__item {
display: grid;
grid-template-columns: 96px 1fr;
gap: 16px;
align-items: start;
}
.health-band__title {
color: rgba(0, 0, 0, 0.45);
padding-top: 1px;
}
.health-band__body {
color: rgba(0, 0, 0, 0.88);
min-width: 0;
}
.health-band__body strong {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.health-band__muted {
color: rgba(0, 0, 0, 0.45);
}
.health-band__ok {
color: rgba(0, 0, 0, 0.45);
margin-left: 4px;
}
.health-band__warn {
color: #d48806;
margin-left: 4px;
}
.health-band__danger {
color: #cf1322;
font-weight: 600;
}
.health-band__fail-btn {
padding: 0 4px;
height: auto;
line-height: inherit;
color: #cf1322;
font-weight: 600;
}
</style>
@@ -0,0 +1,126 @@
<script setup lang="ts">
/**
* 令牌明文对话框:仅签发/重发后出现一次(交互-成员管理 §3)
*/
import { ref, watch } from "vue";
import { message } from "antdv-next";
import { CopyOutlined, WarningOutlined } from "@antdv-next/icons";
import { useClipboard } from "@vueuse/core";
const props = defineProps<{
open: boolean;
token: string;
memberName?: string;
}>();
const emit = defineEmits<{
(e: "update:open", v: boolean): void;
}>();
const confirmClose = ref(false);
watch(
() => props.open,
(v) => {
if (v) {
confirmClose.value = false;
}
},
);
/** legacy: true —— HTTP 等非 Secure Context 无 Clipboard API 时退回 execCommand */
const { copy } = useClipboard({ legacy: true });
async function onCopy() {
try {
await copy(props.token);
message.success("已复制");
} catch {
message.error("复制失败,请手动选中复制");
}
}
function tryClose() {
if (!confirmClose.value) {
confirmClose.value = true;
return;
}
emit("update:open", false);
}
</script>
<template>
<a-modal
:open="open"
:title="memberName ? `访问令牌 · ${memberName}` : '访问令牌'"
:mask-closable="false"
:closable="false"
:keyboard="false"
:footer="null"
>
<a-alert
type="warning"
show-icon
style="margin-bottom: 16px"
message="关闭后将无法再次查看——请立即复制并通过你信任的渠道发给成员"
/>
<div class="token-plain">
<code class="token-plain__text">{{ token }}</code>
<a-button @click="onCopy">
<CopyOutlined />
复制
</a-button>
</div>
<div class="token-plain__footer">
<template v-if="!confirmClose">
<a-button @click="tryClose">关闭</a-button>
</template>
<template v-else>
<span class="token-plain__confirm">
<WarningOutlined style="color: #faad14" />
确认已保存并发送?
</span>
<a-space>
<a-button @click="confirmClose = false">再看看</a-button>
<a-button type="primary" @click="tryClose">我已保存并发送</a-button>
</a-space>
</template>
</div>
</a-modal>
</template>
<style scoped>
.token-plain {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fafafa;
}
.token-plain__text {
flex: 1;
word-break: break-all;
font-size: 13px;
line-height: 22px;
color: rgba(0, 0, 0, 0.88);
user-select: all;
}
.token-plain__footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
min-height: 32px;
}
.token-plain__confirm {
margin-right: auto;
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
</style>
@@ -0,0 +1,486 @@
<script setup lang="ts">
/**
* 支付登记弹窗:订阅 / 充值分形态(拆表后)
* 时间控件统一精确到秒(show-time)
*/
import type { Dayjs } from "dayjs";
import dayjs from "dayjs";
import { computed, reactive, ref, watch } from "vue";
import { message } from "antdv-next";
import type { SubscriptionItem, TopupItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import PlatformSubscriptionPicker from "@/components/admin/platform-subscription-picker.vue";
import PlatformTopupPicker from "@/components/admin/platform-topup-picker.vue";
import type {
PlatformSubscription,
PlatformTopup,
} from "@/api/admin/platform.ts";
import {
useBillingDomainOptions,
useCreateSubscription,
useCreateTopup,
useQpuLatest,
useSubscriptions,
useTopups,
useUpdateSubscription,
useUpdateTopup,
} from "@/queries/admin.ts";
import { unixToBeijingWall, wallAsBeijingIso } from "@/utils/time.ts";
const TIME_FMT = "YYYY-MM-DD HH:mm:ss";
const props = defineProps<{
open: boolean;
tab: "subs" | "topups";
editingSub?: SubscriptionItem | null;
editingTop?: TopupItem | null;
}>();
const emit = defineEmits<{ (e: "update:open", v: boolean): void }>();
const createSub = useCreateSubscription();
const updateSub = useUpdateSubscription();
const createTop = useCreateTopup();
const updateTop = useUpdateTopup();
const subsQuery = useSubscriptions();
const topupsQuery = useTopups();
const qpuQuery = useQpuLatest();
const qpu = computed(() => qpuQuery.data.value?.qpu ?? null);
/** 额度双输入旁的换算预览(不入库,仅展示) */
const quotaPreview = computed(() => {
if (isSub.value) {
if (subForm.quotaMode === "quota") {
const q = subForm.quota_granted;
if (q == null || !qpu.value) return null;
return `≈ $${(q / qpu.value).toFixed(2)}(按当前换算率)`;
}
const u = subForm.quota_usd;
if (u == null || !qpu.value) return null;
return `≈ ${Math.round(u * qpu.value)} quota`;
}
const a = topForm.amount;
if (a == null || !qpu.value) return null;
return `≈ ${Math.round(a * qpu.value)} quota(按当前换算率)`;
});
const pickerOpen = ref(false);
const topPickerOpen = ref(false);
const error = ref("");
const isSub = ref(props.tab === "subs");
const registeredSubIds = computed(
() => (subsQuery.data.value ?? []).map((s) => s.subscription_id),
);
const registeredTopIds = computed(
() =>
(topupsQuery.data.value ?? [])
.map((t) => t.upstream_id)
.filter((v): v is number => v != null),
);
const domainOptions = useBillingDomainOptions();
const subForm = reactive({
subscription_id: null as number | null,
period_start: null as Dayjs | null,
period_end: null as Dayjs | null,
quota_granted: null as number | null,
quotaMode: "quota" as "quota" | "usd",
quota_usd: null as number | null,
paid_amount: null as number | null,
paid_at: null as Dayjs | null,
billing_domain: "",
remark: "",
});
const topForm = reactive({
upstream_id: null as number | null,
amount: null as number | null,
money: null as number | null,
trade_no: "",
payment_method: "",
payment_provider: "",
paid_at: null as Dayjs | null,
billing_domain: "",
remark: "",
});
function resetSub() {
Object.assign(subForm, {
subscription_id: null,
period_start: null,
period_end: null,
quota_granted: null,
quotaMode: "quota",
quota_usd: null,
paid_amount: null,
paid_at: null,
billing_domain: "",
remark: "",
});
}
function resetTop() {
Object.assign(topForm, {
upstream_id: null,
amount: null,
money: null,
trade_no: "",
payment_method: "",
payment_provider: "",
paid_at: null,
billing_domain: "",
remark: "",
});
}
watch(
() => props.open,
(v) => {
if (!v) return;
error.value = "";
isSub.value = props.tab === "subs";
if (props.editingSub) {
isSub.value = true;
const s = props.editingSub;
subForm.subscription_id = s.subscription_id;
subForm.period_start = unixToBeijingWall(s.period_start);
subForm.period_end =
s.period_end != null ? unixToBeijingWall(s.period_end) : null;
subForm.quota_granted = s.quota_granted;
subForm.quotaMode = "quota";
subForm.quota_usd = null;
subForm.paid_amount = s.paid_amount;
subForm.paid_at =
s.paid_at != null ? unixToBeijingWall(s.paid_at) : null;
subForm.billing_domain = s.billing_domain ?? "";
subForm.remark = s.remark;
} else {
// 订阅的 paid_at 是「套餐付款时刻」,可空——不默认填今天
resetSub();
}
if (props.editingTop) {
isSub.value = false;
const t = props.editingTop;
topForm.upstream_id = t.upstream_id;
topForm.amount = t.amount;
topForm.money = t.money;
topForm.trade_no = t.trade_no ?? "";
topForm.payment_method = t.payment_method ?? "";
topForm.payment_provider = t.payment_provider ?? "";
topForm.paid_at = unixToBeijingWall(t.paid_at);
topForm.billing_domain = t.billing_domain ?? "";
topForm.remark = t.remark;
} else {
resetTop();
topForm.paid_at = dayjs();
}
},
);
function onPickSub(s: PlatformSubscription) {
subForm.subscription_id = s.id;
subForm.period_start = unixToBeijingWall(s.start_time);
subForm.period_end =
s.end_time != null ? unixToBeijingWall(s.end_time) : null;
subForm.quota_granted = s.amount_total;
subForm.quotaMode = "quota";
}
function onPickTop(t: PlatformTopup & { paid_at_iso: string }) {
topForm.upstream_id = t.id;
topForm.amount = t.amount;
topForm.money = t.money;
topForm.trade_no = t.trade_no ?? "";
topForm.payment_method = t.payment_method ?? "";
topForm.payment_provider = t.payment_provider ?? "";
const sec = t.complete_time ?? t.create_time;
topForm.paid_at =
sec != null ? unixToBeijingWall(sec) : unixToBeijingWall(Date.now() / 1000);
}
function iso(v: Dayjs | null): string | null {
// 北京墙钟进控件 → wallAsBeijingIso 无损回写(与旧登记表单一致)
return v ? wallAsBeijingIso(v) : null;
}
async function onSubmit() {
error.value = "";
try {
if (isSub.value) {
if (!subForm.period_start) {
error.value = "请填写期间起点";
return;
}
if (subForm.subscription_id == null) {
error.value = "请填写订阅编号";
return;
}
const qpu = qpuQuery.data.value?.qpu ?? 500000;
const quota =
subForm.quotaMode === "quota"
? subForm.quota_granted
: subForm.quota_usd != null
? Math.round(subForm.quota_usd * qpu)
: null;
if (quota == null) {
error.value = "请填写套餐总额度";
return;
}
if (subForm.paid_amount != null && subForm.paid_amount < 0) {
error.value = "付款金额不能为负数";
return;
}
const dup = (subsQuery.data.value ?? []).find(
(p) =>
p.subscription_id === subForm.subscription_id &&
p.id !== props.editingSub?.id,
);
if (dup) {
error.value = `订阅 #${subForm.subscription_id} 已有登记——请编辑原记录`;
return;
}
const body = {
subscription_id: subForm.subscription_id,
period_start: iso(subForm.period_start)!,
period_end: iso(subForm.period_end),
quota_granted: quota,
paid_amount: subForm.paid_amount,
paid_at: iso(subForm.paid_at),
billing_domain: subForm.billing_domain.trim() || null,
remark: subForm.remark,
};
if (props.editingSub) {
await updateSub.mutateAsync({ id: props.editingSub.id, body });
} else {
await createSub.mutateAsync(body);
}
} else {
if (topForm.amount == null || topForm.amount <= 0) {
error.value = "请填写充值金额 amount(须为正)";
return;
}
if (topForm.money == null || topForm.money < 0) {
error.value = "实付金额不能为负数";
return;
}
if (!topForm.paid_at) {
error.value = "请填写入账时间";
return;
}
const body = {
upstream_id: topForm.upstream_id,
amount: topForm.amount,
money: topForm.money,
trade_no: topForm.trade_no || null,
payment_method: topForm.payment_method || null,
payment_provider: topForm.payment_provider || null,
status: props.editingTop?.status ?? "success",
paid_at: iso(topForm.paid_at)!,
billing_domain: topForm.billing_domain.trim() || null,
remark: topForm.remark,
};
if (props.editingTop) {
await updateTop.mutateAsync({ id: props.editingTop.id, body });
} else {
await createTop.mutateAsync(body);
}
}
message.success(props.editingSub || props.editingTop ? "已保存" : "已登记");
emit("update:open", false);
} catch (e) {
const err = e as ApiError;
if (err.fields) {
error.value = Object.values(err.fields)[0] || "请检查表单";
} else {
error.value = err.message || "保存失败";
}
}
}
</script>
<template>
<a-modal
:open="open"
:title="isSub ? (editingSub ? '编辑订阅' : '登记订阅') : editingTop ? '编辑充值' : '登记充值'"
:confirm-loading="createSub.isPending.value || createTop.isPending.value || updateSub.isPending.value || updateTop.isPending.value"
ok-text="保存"
cancel-text="取消"
width="560"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
@ok="onSubmit"
@update:open="emit('update:open', $event)"
>
<a-radio-group
v-if="!editingSub && !editingTop"
v-model:value="isSub"
style="margin-bottom: 16px"
>
<a-radio :value="true">订阅</a-radio>
<a-radio :value="false">充值</a-radio>
</a-radio-group>
<a-form v-if="isSub" layout="vertical">
<a-form-item label="订阅 ID" required>
<a-space>
<a-input-number
v-model:value="subForm.subscription_id"
:min="1"
:precision="0"
style="width: 160px"
/>
<a-button @click="pickerOpen = true">从平台选用</a-button>
</a-space>
</a-form-item>
<a-form-item label="期间起止" required>
<a-date-picker
v-model:value="subForm.period_start"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
style="width: 200px"
/>
<span style="margin: 0 8px">~</span>
<a-date-picker
v-model:value="subForm.period_end"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
placeholder="止(空=进行中)"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="套餐总额度" required>
<a-radio-group v-model:value="subForm.quotaMode" style="margin-right: 8px">
<a-radio-button value="quota">quota</a-radio-button>
<a-radio-button value="usd">美元</a-radio-button>
</a-radio-group>
<a-input-number
v-if="subForm.quotaMode === 'quota'"
v-model:value="subForm.quota_granted"
:min="0"
:precision="0"
style="width: 180px"
/>
<a-input-number
v-else
v-model:value="subForm.quota_usd"
:min="0"
:precision="2"
style="width: 180px"
/>
<div v-if="quotaPreview" class="form-note">
{{ quotaPreview }} · 落库为原始 quota
</div>
</a-form-item>
<a-form-item label="付款金额 / 付款时间(可空)">
<a-input-number
v-model:value="subForm.paid_amount"
:min="0"
:precision="2"
style="width: 140px"
placeholder="金额"
/>
<a-date-picker
v-model:value="subForm.paid_at"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
placeholder="钱出手时刻(可空)"
style="width: 200px; margin-left: 8px"
/>
<div class="form-note">
社交层付款事实:金额数值、币种由你们约定;未知可留空。登记时刻由系统另记
</div>
</a-form-item>
<a-form-item label="账本域">
<a-auto-complete
v-model:value="subForm.billing_domain"
:options="domainOptions"
allow-clear
/>
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="subForm.remark" />
</a-form-item>
</a-form>
<a-form v-else layout="vertical">
<a-form-item label="充值金额 amount" required>
<a-space>
<a-input-number
v-model:value="topForm.amount"
:min="0"
:precision="6"
style="width: 180px"
/>
<a-button @click="topPickerOpen = true">从平台选用</a-button>
</a-space>
<div class="form-note">
平台计费侧充值额(不标币种);与「实付」之间汇率由管理员约定。换得 quota ≈ amount × QPU
<template v-if="quotaPreview"> · {{ quotaPreview }}</template>
</div>
</a-form-item>
<a-form-item label="实付金额 money" required>
<a-input-number
v-model:value="topForm.money"
:min="0"
:precision="2"
style="width: 180px"
/>
<div class="form-note">实际支付金额(不预设币种)</div>
</a-form-item>
<a-form-item label="入账时间 paid_at" required>
<a-date-picker
v-model:value="topForm.paid_at"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
style="width: 220px"
/>
</a-form-item>
<a-form-item label="交易号 / 渠道">
<a-input v-model:value="topForm.trade_no" placeholder="trade_no" style="width: 220px" />
<a-input
v-model:value="topForm.payment_method"
placeholder="method"
style="width: 120px; margin-left: 8px"
/>
<a-input
v-model:value="topForm.payment_provider"
placeholder="provider"
style="width: 120px; margin-left: 8px"
/>
</a-form-item>
<a-form-item label="账本域">
<a-auto-complete
v-model:value="topForm.billing_domain"
:options="domainOptions"
allow-clear
/>
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="topForm.remark" />
</a-form-item>
</a-form>
<a-alert v-if="error" type="error" show-icon :message="error" />
<PlatformSubscriptionPicker
v-model:open="pickerOpen"
:registered-ids="registeredSubIds"
@select="onPickSub"
/>
<PlatformTopupPicker
v-model:open="topPickerOpen"
:registered-ids="registeredTopIds"
@select="onPickTop"
/>
</a-modal>
</template>
<style scoped>
.form-note {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
</style>
@@ -0,0 +1,264 @@
<script setup lang="ts">
/**
* 订阅选择弹层:登录(仅一次/标签页)+ 列表选用,与登记表单解耦
*/
import { ref, watch } from "vue";
import {
listPlatformSubscriptions,
loginPlatform,
type PlatformSubscription,
} from "@/api/admin/platform.ts";
import type { ApiError } from "@/api/http.ts";
import { formatDateTime } from "@/utils/time.ts";
const props = defineProps<{
open: boolean;
/** 已登记的 subscription_id——灰显不可选用 */
registeredIds?: number[];
}>();
const emit = defineEmits<{
(e: "update:open", v: boolean): void;
(e: "select", sub: PlatformSubscription): void;
}>();
const TOKEN_KEY = "naku_platform_token";
const username = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
/** 打开后检查登录并拉取中——避免闪现登录表单 */
const booting = ref(false);
const list = ref<PlatformSubscription[]>([]);
function readToken(): string {
try {
return sessionStorage.getItem(TOKEN_KEY) ?? "";
} catch {
return "";
}
}
function writeToken(token: string) {
try {
if (token) sessionStorage.setItem(TOKEN_KEY, token);
else sessionStorage.removeItem(TOKEN_KEY);
} catch {
/* ignore */
}
}
watch(
() => props.open,
(v) => {
if (v) {
// 有 token:先显示「检查中」,拉完再进列表/登录,不闪登录表单
if (readToken()) {
booting.value = true;
void load(true).finally(() => {
booting.value = false;
});
}
} else {
list.value = [];
error.value = "";
password.value = "";
booting.value = false;
}
},
);
async function load(skipLoginCheck = false) {
if (loading.value) return;
if (!skipLoginCheck && (!username.value.trim() || !password.value)) return;
error.value = "";
loading.value = true;
try {
let token = readToken();
if (!token) {
const login = await loginPlatform({
username: username.value.trim(),
password: password.value,
});
token = login.access_token;
writeToken(token);
}
password.value = "";
list.value = await listPlatformSubscriptions(token);
if (list.value.length === 0) {
error.value = "账号下没有订阅记录";
}
} catch (e) {
const err = e as ApiError;
if (err.status === 401 || err.code === "platform_token_invalid") {
writeToken("");
error.value = "平台登录已失效——请重新输入密码";
} else {
error.value = err.message || "拉取失败";
}
list.value = [];
password.value = "";
} finally {
loading.value = false;
}
}
function isRegistered(s: PlatformSubscription): boolean {
return (props.registeredIds ?? []).includes(s.id);
}
function onUse(s: PlatformSubscription) {
if (isRegistered(s)) return;
emit("select", s);
emit("update:open", false);
}
</script>
<template>
<a-modal
:open="open"
title="从平台选择订阅"
:footer="null"
:width="520"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
destroy-on-hidden
@update:open="emit('update:open', $event)"
>
<div v-if="booting || (loading && list.length === 0)" class="plat-pick__boot">
<a-spin />
<span class="plat-pick__boot-text">正在检查登录并拉取…</span>
</div>
<template v-else-if="list.length === 0">
<p class="plat-pick__hint">
使用 NewAPI 控制台账号登录后拉取订阅,仅用于预填登记;密码不保存。
</p>
<a-form layout="vertical" @finish="load">
<a-form-item label="账号">
<a-input
v-model:value="username"
autocomplete="username"
@press-enter="load"
/>
</a-form-item>
<a-form-item label="密码">
<a-input-password
v-model:value="password"
autocomplete="current-password"
@press-enter="load"
/>
</a-form-item>
<a-button
type="primary"
html-type="submit"
block
:loading="loading"
:disabled="!username.trim() || !password"
>
登录并拉取
</a-button>
</a-form>
<a-alert
v-if="error"
type="warning"
show-icon
:message="error"
style="margin-top: 12px"
/>
</template>
<template v-else>
<div class="plat-pick__list">
<div
v-for="s in list"
:key="s.id"
class="plat-pick__item"
:class="{ 'is-registered': isRegistered(s) }"
@click="onUse(s)"
>
<div class="plat-pick__head">
<strong>订阅 #{{ s.id }}</strong>
<span class="plat-pick__status">
{{ isRegistered(s) ? "已登记" : s.status }}
</span>
</div>
<div class="plat-pick__meta">
{{ formatDateTime(s.start_time) }} ~
{{ s.end_time ? formatDateTime(s.end_time) : "进行中" }}
· 额度 {{ s.amount_total }}
</div>
</div>
</div>
</template>
</a-modal>
</template>
<style scoped>
.plat-pick__boot {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 0;
}
.plat-pick__boot-text {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.plat-pick__hint {
margin: 0 0 16px;
font-size: 12px;
line-height: 20px;
color: rgba(0, 0, 0, 0.45);
}
.plat-pick__list {
max-height: 360px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.plat-pick__item {
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.plat-pick__item:hover {
background: #fafafa;
}
.plat-pick__item.is-registered {
opacity: 0.45;
cursor: not-allowed;
}
.plat-pick__item.is-registered:hover {
background: transparent;
}
.plat-pick__item:last-child {
border-bottom: none;
}
.plat-pick__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.plat-pick__status {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.plat-pick__meta {
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,312 @@
<script setup lang="ts">
/**
* 充值选用弹层:登录 + 传统分页;已登记按 upstream_id 灰显
*/
import { computed, ref, watch } from "vue";
import {
listPlatformTopups,
loginPlatform,
type PlatformTopup,
} from "@/api/admin/platform.ts";
import type { ApiError } from "@/api/http.ts";
import { formatDateTime } from "@/utils/time.ts";
const props = defineProps<{
open: boolean;
registeredIds?: number[];
}>();
const emit = defineEmits<{
(e: "update:open", v: boolean): void;
(e: "select", topup: PlatformTopup & { paid_at_iso: string }): void;
}>();
const TOKEN_KEY = "naku_platform_token";
const PAGE_SIZE = 10;
const username = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
/** 打开后检查登录并拉取中——避免闪现登录表单 */
const booting = ref(false);
const items = ref<PlatformTopup[]>([]);
const total = ref(0);
const page = ref(1);
const totalPages = computed(() =>
Math.max(1, Math.ceil(total.value / PAGE_SIZE)),
);
function readToken(): string {
try {
return sessionStorage.getItem(TOKEN_KEY) ?? "";
} catch {
return "";
}
}
function writeToken(token: string) {
try {
if (token) sessionStorage.setItem(TOKEN_KEY, token);
else sessionStorage.removeItem(TOKEN_KEY);
} catch {
/* ignore */
}
}
async function ensureToken(): Promise<string> {
let token = readToken();
if (token) return token;
const login = await loginPlatform({
username: username.value.trim(),
password: password.value,
});
token = login.access_token;
writeToken(token);
password.value = "";
return token;
}
async function loadPage(p: number, opts?: { allowTokenOnly?: boolean }) {
if (loading.value) return;
if (!opts?.allowTokenOnly && !readToken() && (!username.value.trim() || !password.value)) {
return;
}
error.value = "";
loading.value = true;
try {
const token = await ensureToken();
password.value = "";
const data = await listPlatformTopups(token, p, PAGE_SIZE);
items.value = data.items;
total.value = data.total;
page.value = data.page || p;
if (items.value.length === 0 && p === 1) {
error.value = "账号下没有充值记录";
}
} catch (e) {
const err = e as ApiError;
if (err.status === 401 || err.code === "platform_token_invalid") {
writeToken("");
error.value = "平台登录已失效——请重新输入密码";
} else {
error.value = err.message || "拉取失败";
}
items.value = [];
password.value = "";
} finally {
loading.value = false;
}
}
async function onLogin() {
if (!username.value.trim() || !password.value || loading.value) return;
await loadPage(1);
}
function isRegistered(t: PlatformTopup): boolean {
return (props.registeredIds ?? []).includes(t.id);
}
function onUse(t: PlatformTopup) {
if (isRegistered(t)) return;
const sec = t.complete_time ?? t.create_time ?? Math.floor(Date.now() / 1000);
emit("select", {
...t,
paid_at_iso: new Date(sec * 1000).toISOString(),
});
emit("update:open", false);
}
watch(
() => props.open,
(v) => {
if (v) {
if (readToken()) {
booting.value = true;
void loadPage(1, { allowTokenOnly: true }).finally(() => {
booting.value = false;
});
}
} else {
items.value = [];
total.value = 0;
page.value = 1;
error.value = "";
password.value = "";
booting.value = false;
}
},
);
</script>
<template>
<a-modal
:open="open"
title="从平台选择充值"
:footer="null"
:width="560"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
destroy-on-hidden
@update:open="emit('update:open', $event)"
>
<div
v-if="booting || (loading && items.length === 0)"
class="top-pick__boot"
>
<a-spin />
<span class="top-pick__boot-text">正在检查登录并拉取…</span>
</div>
<template v-else-if="items.length === 0 && !loading">
<p class="top-pick__hint">
登录 NewAPI 控制台拉取充值单(传统分页);密码不保存。
</p>
<a-form layout="vertical" @finish="onLogin">
<a-form-item label="账号">
<a-input v-model:value="username" autocomplete="username" />
</a-form-item>
<a-form-item label="密码">
<a-input-password
v-model:value="password"
autocomplete="current-password"
/>
</a-form-item>
<a-button
type="primary"
html-type="submit"
block
:loading="loading"
:disabled="!username.trim() || !password"
>
登录并拉取
</a-button>
</a-form>
<a-alert
v-if="error"
type="warning"
show-icon
:message="error"
style="margin-top: 12px"
/>
</template>
<template v-else>
<div class="top-pick__list">
<div
v-for="t in items"
:key="t.id"
class="top-pick__item"
:class="{ 'is-registered': isRegistered(t) }"
@click="onUse(t)"
>
<div class="top-pick__head">
<strong>
{{ formatDateTime(t.complete_time ?? t.create_time) }}
</strong>
<span class="top-pick__status">
{{ isRegistered(t) ? "已登记" : t.status || "" }}
</span>
</div>
<div class="top-pick__meta">
amount {{ t.amount }} · money {{ t.money }}
<template v-if="t.trade_no"> · {{ t.trade_no }}</template>
</div>
</div>
</div>
<div class="top-pick__pager">
<a-pagination
:current="page"
:page-size="PAGE_SIZE"
:total="total"
size="small"
:show-total="(t: number) => `共 ${t} 条`"
@change="(p: number) => loadPage(p)"
/>
</div>
<a-alert
v-if="error"
type="warning"
show-icon
:message="error"
style="margin-top: 8px"
/>
</template>
</a-modal>
</template>
<style scoped>
.top-pick__boot {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 0;
}
.top-pick__boot-text {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.top-pick__hint {
margin: 0 0 16px;
font-size: 12px;
line-height: 20px;
color: rgba(0, 0, 0, 0.45);
}
.top-pick__list {
max-height: 360px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.top-pick__item {
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.top-pick__item:hover {
background: #fafafa;
}
.top-pick__item.is-registered {
opacity: 0.45;
cursor: not-allowed;
}
.top-pick__item.is-registered:hover {
background: transparent;
}
.top-pick__item:last-child {
border-bottom: none;
}
.top-pick__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.top-pick__status {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.top-pick__meta {
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
font-variant-numeric: tabular-nums;
}
.top-pick__pager {
display: flex;
justify-content: center;
margin-top: 12px;
}
</style>
@@ -0,0 +1,228 @@
<script setup lang="ts">
/**
* 登记 Key:sk-key 明文在 web 的唯一露面处(交互-key管理 §2)
*/
import { computed, reactive, ref, watch } from "vue";
import type { MemberItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import { useCreateMember, useRegisterKey, useBillingDomainOptions } from "@/queries/admin.ts";
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
const props = defineProps<{
open: boolean;
members: MemberItem[];
}>();
const emit = defineEmits<{
(e: "update:open", v: boolean): void;
(e: "registered", keyId: number): void;
}>();
const register = useRegisterKey();
const createMember = useCreateMember();
const domainOptions = useBillingDomainOptions();
const form = reactive({
sk_key: "",
member_id: null as number | "unassigned" | null,
billing_domain: "",
});
const resultMsg = ref<{ type: "success" | "error" | "warning"; text: string } | null>(
null,
);
const showNewMember = ref(false);
const newMemberName = ref("");
const newMemberRemark = ref("");
const tokenOpen = ref(false);
const plainToken = ref("");
const tokenMemberName = ref("");
watch(
() => props.open,
(v) => {
if (!v) {
form.sk_key = "";
form.member_id = null;
form.billing_domain = "";
resultMsg.value = null;
showNewMember.value = false;
newMemberName.value = "";
newMemberRemark.value = "";
}
},
);
const memberOptions = computed(() => [
{ value: "unassigned", label: "未归属" },
...props.members.map((m) => ({ value: m.id, label: m.name })),
]);
async function onSubmit() {
resultMsg.value = null;
if (!form.sk_key.trim()) {
resultMsg.value = { type: "warning", text: "请粘贴 sk-key" };
return;
}
try {
const key = await register.mutateAsync({
sk_key: form.sk_key.trim(),
member_id:
form.member_id === "unassigned" || form.member_id == null
? null
: form.member_id,
billing_domain: form.billing_domain.trim() || null,
});
resultMsg.value = {
type: "success",
text: "已登记,首轮同步进行中(约 5 分钟内完成)",
};
emit("registered", key.id);
} catch (e) {
const err = e as ApiError;
if (err.status === 409) {
resultMsg.value = {
type: "warning",
text: "该 key 已在清单中",
};
if (err.keyId != null) emit("registered", err.keyId);
} else if (err.status === 422) {
// 上游拒绝 vs 本地表单校验(如 sk- 前缀)文案不同
const fieldMsg = err.fields?.sk_key;
resultMsg.value = {
type: "error",
text:
fieldMsg ??
(err.code === "validation_failed"
? err.message || "请检查填写内容"
: "上游拒绝了这枚 key——可能不存在或已被禁用"),
};
} else if (err.status === 502 || err.status === 0) {
resultMsg.value = {
type: "error",
text: "上游站点暂时不可达,请稍后重试",
};
} else {
resultMsg.value = {
type: "error",
text: err.message || "登记失败,请稍后重试",
};
}
}
}
async function onCreateMember() {
const name = newMemberName.value.trim();
if (!name) return;
try {
const res = await createMember.mutateAsync({
name,
remark: newMemberRemark.value.trim(),
});
form.member_id = res.member.id;
showNewMember.value = false;
newMemberName.value = "";
newMemberRemark.value = "";
// POST /members 必回明文令牌——立刻展示,避免遗失
plainToken.value = res.token;
tokenMemberName.value = res.member.name;
tokenOpen.value = true;
resultMsg.value = {
type: "success",
text: `已新建成员「${res.member.name}」并选为归属,请保存下方令牌`,
};
} catch (e) {
const err = e as ApiError;
resultMsg.value = { type: "error", text: err.message || "新建成员失败" };
}
}
</script>
<template>
<a-modal
:open="open"
title="登记 Key"
:confirm-loading="register.isPending.value"
ok-text="登记"
cancel-text="取消"
@update:open="emit('update:open', $event)"
@ok="onSubmit"
>
<a-form layout="vertical">
<a-form-item label="sk-key" required>
<a-textarea
v-model:value="form.sk_key"
:rows="4"
placeholder="粘贴你在 NewAPI 创建的 sk-…"
/>
<div class="register-key__note">
粘贴后立即加密存储,界面不再回显明文
</div>
</a-form-item>
<a-form-item label="归属成员">
<a-select
v-model:value="form.member_id"
:options="memberOptions"
placeholder="未归属"
allow-clear
/>
<a-button
type="link"
size="small"
style="padding-left: 0"
@click="showNewMember = !showNewMember"
>
+ 新建成员
</a-button>
</a-form-item>
<a-form-item label="账本域">
<a-auto-complete
v-model:value="form.billing_domain"
:options="domainOptions"
allow-clear
/>
</a-form-item>
<a-form-item v-if="showNewMember" label="新成员名称">
<a-input
v-model:value="newMemberName"
placeholder="成员名称"
@press-enter="onCreateMember"
/>
<a-input
v-model:value="newMemberRemark"
placeholder="备注(可选)"
style="margin-top: 8px"
/>
<a-button
type="primary"
size="small"
style="margin-top: 8px"
:loading="createMember.isPending.value"
@click="onCreateMember"
>
创建并选用
</a-button>
</a-form-item>
<a-alert
v-if="resultMsg"
:type="resultMsg.type"
show-icon
:message="resultMsg.text"
/>
</a-form>
<MemberTokenModal
v-model:open="tokenOpen"
:token="plainToken"
:member-name="tokenMemberName"
@update:open="(v: boolean) => { if (!v) { plainToken = ''; tokenMemberName = ''; } }"
/>
</a-modal>
</template>
<style scoped>
.register-key__note {
margin-top: 4px;
font-size: 12px;
line-height: 20px;
color: rgba(0, 0, 0, 0.45);
}
</style>
@@ -0,0 +1,74 @@
<script setup lang="ts">
/**
* 调用日志汇总条(交互设计-调用日志 §2.5):
* 单行低高度——请求次数 + 消费用量;用量恒 type=2 口径由上层保证。
*/
import QuotaText from "./quota-text.vue";
defineProps<{
hitCount: number;
usageCount: number;
errorCount: number;
quota: number;
quotaUsd: string;
loading?: boolean;
}>();
</script>
<template>
<div class="logs-summary-bar" :class="{ 'is-loading': loading }">
<div class="logs-summary-bar__seg">
<span class="logs-summary-bar__label">请求次数</span>
<span class="logs-summary-bar__value">
<template v-if="loading">—</template>
<template v-else>
{{ hitCount }} 条<template v-if="errorCount > 0">
(消费 {{ usageCount }} / 错误 {{ errorCount }})</template>
</template>
</span>
</div>
<div class="logs-summary-bar__seg">
<span class="logs-summary-bar__label">消费用量</span>
<span class="logs-summary-bar__value">
<QuotaText v-if="!loading" :quota="quota" :quota-usd="quotaUsd" />
<template v-else>—</template>
</span>
</div>
</div>
</template>
<style scoped>
.logs-summary-bar {
display: flex;
align-items: center;
gap: 24px;
min-height: 36px;
padding: 0 12px;
margin-bottom: 12px;
border: 1px solid #e8ecf4;
border-radius: 6px;
background: #f7f9fc;
}
.logs-summary-bar.is-loading {
opacity: 0.55;
}
.logs-summary-bar__seg {
display: inline-flex;
align-items: baseline;
gap: 8px;
white-space: nowrap;
}
.logs-summary-bar__label {
font-size: 12px;
color: #8a8f99;
}
.logs-summary-bar__value {
font-size: 13px;
font-weight: 500;
color: #1f2329;
}
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
/**
* 页面标题区——对齐 design.md 排版:
* 标题 headline-sm(20/600/28)· 说明 body-sm 三级字(rgba 0.45)
*/
defineProps<{
title: string;
subtitle?: string;
}>();
</script>
<template>
<header class="page-header">
<div class="page-header__text">
<h1 class="page-header__title">{{ title }}</h1>
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.actions" class="page-header__actions">
<slot name="actions" />
</div>
</header>
</template>
<style scoped>
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.page-header__title {
margin: 0;
font-size: 20px;
font-weight: 600;
line-height: 28px;
color: rgba(0, 0, 0, 0.88);
}
.page-header__subtitle {
margin: 4px 0 0;
font-size: 12px;
line-height: 20px;
color: rgba(0, 0, 0, 0.45);
}
.page-header__actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
/* 与标题块上缘对齐时,控件 32px 高与 label 竖向居中 */
min-height: 32px;
}
</style>
@@ -0,0 +1,40 @@
<script setup lang="ts">
/**
* 额度双单位:原始 quota + 美元(契约公共约定 §5;前端只呈现不换算)
*/
import { computed } from "vue";
import { formatInt } from "@/utils/format.ts";
const props = defineProps<{
quota: number | null | undefined;
quotaUsd: string | null | undefined;
}>();
const quotaText = computed(() => formatInt(props.quota));
</script>
<template>
<span class="quota-text">
<span class="quota-text__num">{{ quotaText }}</span>
<span class="quota-text__usd">(${{ quotaUsd ?? "—" }})</span>
</span>
</template>
<style scoped>
.quota-text {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.quota-text__num {
color: rgba(0, 0, 0, 0.88);
font-weight: 600;
}
.quota-text__usd {
margin-left: 6px;
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
font-weight: 400;
}
</style>
@@ -0,0 +1,120 @@
<script setup lang="ts">
/**
* 来源分解分段条(交互共性 §3.7:订阅 / 充值)——前端现算比例(公共约定 §5)
*/
import { computed } from "vue";
type SourceRow = {
billing_source: string;
count: number;
quota: number;
quota_usd: string;
};
const props = defineProps<{
rows: SourceRow[];
}>();
const segments = computed(() => {
const total = props.rows.reduce((s, r) => s + r.quota, 0);
return props.rows.map((r) => {
const pct = total > 0 ? (r.quota / total) * 100 : 0;
const isSub = r.billing_source === "subscription";
return {
key: r.billing_source,
label: isSub ? "订阅" : r.billing_source === "wallet" ? "充值" : r.billing_source,
pct,
count: r.count,
quota_usd: r.quota_usd,
color: isSub ? "#1677ff" : "#faad14",
};
});
});
const hasData = computed(() => segments.value.some((s) => s.pct > 0));
</script>
<template>
<div class="source-bar">
<div v-if="!hasData" class="source-bar__empty">
本时段内没有任何消费调用,因此没有可分解的来源。
</div>
<template v-else>
<div class="source-bar__track">
<div
v-for="seg in segments"
:key="seg.key"
class="source-bar__seg"
:style="{ width: `${seg.pct}%`, background: seg.color }"
:title="`${seg.label}来源:占额度 ${seg.pct.toFixed(1)}%,调用 ${seg.count} 次,$${seg.quota_usd}`"
/>
</div>
<div class="source-bar__legend">
<span v-for="seg in segments" :key="seg.key" class="source-bar__item">
<i class="source-bar__dot" :style="{ background: seg.color }" />
<span class="source-bar__label">{{ seg.label }}</span>
<span class="source-bar__meta">
调用 {{ seg.count }} 次,消耗 ${{ seg.quota_usd }}
(占 {{ seg.pct.toFixed(1) }}%)
</span>
</span>
</div>
</template>
</div>
</template>
<style scoped>
.source-bar__track {
display: flex;
height: 8px;
border-radius: 4px;
overflow: hidden;
background: #f0f0f0;
}
.source-bar__seg {
height: 100%;
min-width: 2px;
}
.source-bar__legend {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
font-size: 13px;
line-height: 22px;
color: rgba(0, 0, 0, 0.88);
}
.source-bar__item {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.source-bar__dot {
width: 8px;
height: 8px;
border-radius: 9999px;
flex-shrink: 0;
position: relative;
top: -1px;
}
.source-bar__label {
font-weight: 600;
min-width: 2.5em;
}
.source-bar__meta {
color: rgba(0, 0, 0, 0.65);
font-variant-numeric: tabular-nums;
}
.source-bar__empty {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
</style>
@@ -0,0 +1,33 @@
<script setup lang="ts">
/**
* 表格工具行刷新钮(纯图标 + tooltip)——放在筛选行下一行、右对齐
*/
import { ReloadOutlined } from "@antdv-next/icons";
defineProps<{ loading?: boolean }>();
const emit = defineEmits<{ (e: "refresh"): void }>();
</script>
<template>
<div class="table-refresh">
<a-tooltip title="刷新">
<a-button
:loading="loading"
aria-label="刷新"
@click="emit('refresh')"
>
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-tooltip>
</div>
</template>
<style scoped>
.table-refresh {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
</style>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import type { Dayjs } from "dayjs";
import dayjs from "dayjs";
import { computed } from "vue";
import {
recentDaysRange,
todayRange,
wallAsBeijingIso,
type RangeValue,
} from "@/utils/time.ts";
import type { RangeKind } from "@/composables/use-live-time-range.ts";
const props = defineProps<{
modelValue: RangeValue;
/** 外部预置(如「本订阅期间」);空则只保留内建快捷项 */
extraPresets?: Array<{ label: string; from: string; to: string }>;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: RangeValue): void;
/** 相对区间可自动跟日历推进;fixed 为绝对窗口 */
(e: "change", value: RangeValue, kind: RangeKind): void;
}>();
const pickerValue = computed<[Dayjs, Dayjs] | null>(() => {
if (!props.modelValue.from || !props.modelValue.to) return null;
return [dayjs(props.modelValue.from), dayjs(props.modelValue.to)];
});
const presets = computed(() => {
const builtIn = [
{
label: "今天",
value: rangeToDayjs(todayRange()),
},
{
label: "最近 7 天",
value: rangeToDayjs(recentDaysRange(7)),
},
{
label: "最近 30 天",
value: rangeToDayjs(recentDaysRange(30)),
},
];
const extra = (props.extraPresets ?? []).map((p) => ({
label: p.label,
value: rangeToDayjs({ from: p.from, to: p.to }),
}));
return [...builtIn, ...extra];
});
function rangeToDayjs(range: RangeValue): [Dayjs, Dayjs] {
return [dayjs(range.from), dayjs(range.to)];
}
function detectKind(value: RangeValue): RangeKind {
const fromD = dayjs(value.from).format("YYYY-MM-DD");
const toD = dayjs(value.to).format("YYYY-MM-DD");
const today = todayRange();
if (
dayjs(today.from).format("YYYY-MM-DD") === fromD &&
dayjs(today.to).format("YYYY-MM-DD") === toD
) {
return "today";
}
for (const days of [7, 30] as const) {
const r = recentDaysRange(days);
if (
dayjs(r.from).format("YYYY-MM-DD") === fromD &&
dayjs(r.to).format("YYYY-MM-DD") === toD
) {
return days === 7 ? "recent-7" : "recent-30";
}
}
return "fixed";
}
function onChange(dates: Dayjs[] | null) {
if (!dates || dates.length !== 2 || !dates[0] || !dates[1]) return;
const value = {
from: wallAsBeijingIso(dates[0]),
to: wallAsBeijingIso(dates[1]),
};
emit("update:modelValue", value);
emit("change", value, detectKind(value));
}
</script>
<template>
<a-range-picker
:value="pickerValue"
:presets="presets"
format="YYYY-MM-DD HH:mm:ss"
:placeholder="['开始时间', '结束时间']"
show-time
@change="onChange"
/>
</template>
@@ -0,0 +1,36 @@
import { computed, onUnmounted, ref } from "vue";
import { recentDaysRange, todayRange } from "@/utils/time.ts";
export type RangeValue = { from: string; to: string };
export type RangeKind = "today" | "recent-7" | "recent-30" | "fixed";
/**
* 可跟随「今天」推进的时间范围:
* - 今天 / 最近 7/30 天:定时重算窗口(页面挂过夜也不会陈旧)
* - fixed:自定义/订阅期间等绝对区间,窗口不挪
*/
export function useLiveTimeRange(initial: Exclude<RangeKind, "fixed"> = "recent-30") {
const kind = ref<RangeKind>(initial);
const fixed = ref<RangeValue>(recentDaysRange(30));
const nowTick = ref(Date.now());
const timer = setInterval(() => {
nowTick.value = Date.now();
}, 60_000);
onUnmounted(() => clearInterval(timer));
const range = computed<RangeValue>(() => {
void nowTick.value;
if (kind.value === "today") return todayRange();
if (kind.value === "recent-7") return recentDaysRange(7);
if (kind.value === "recent-30") return recentDaysRange(30);
return fixed.value;
});
function setRange(value: RangeValue, nextKind: RangeKind) {
kind.value = nextKind;
if (nextKind === "fixed") fixed.value = value;
}
return { range, setRange, kind };
}
@@ -0,0 +1,64 @@
import { computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
useMemberRangeStore,
type RangeKind,
type RangeValue,
} from "@/stores/member-range.ts";
/**
* 成员端共享时间范围 + 当前页 URL(from/to)双向同步。
* 切页时布局菜单带上 from/to,状态从 store 读——对账窗口不打断。
*/
export function useMemberRange() {
const store = useMemberRangeStore();
const route = useRoute();
const router = useRouter();
const range = computed(() => store.range);
function syncFromRoute() {
const from = route.query.from;
const to = route.query.to;
if (typeof from === "string" && from && typeof to === "string" && to) {
store.setRange({ from, to }, "fixed");
}
}
/** 在当前路径上写入 from/to(可并入页内其它 query) */
function pushRangeQuery(extra: Record<string, string> = {}) {
void router.replace({
path: route.path,
query: {
...pickNonRangeQuery(route.query),
...extra,
from: store.range.from,
to: store.range.to,
},
});
}
function onRangeChange(v: RangeValue, k: RangeKind) {
store.setRange(v, k);
pushRangeQuery();
}
syncFromRoute();
watch(() => route.query, () => syncFromRoute());
return {
range,
setRange: store.setRange,
onRangeChange,
pushRangeQuery,
};
}
function pickNonRangeQuery(q: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(q)) {
if (k === "from" || k === "to") continue;
if (typeof v === "string" && v) out[k] = v;
}
return out;
}
+295
View File
@@ -0,0 +1,295 @@
<script setup lang="ts">
import type { MenuInfo, MenuItemType } from "antdv-next";
import { theme } from "antdv-next";
import {
AccountBookOutlined,
AuditOutlined,
DashboardOutlined,
DownOutlined,
FileTextOutlined,
KeyOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SettingOutlined,
TeamOutlined,
UserOutlined,
} from "@antdv-next/icons";
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAdminSessionStore } from "@/stores/admin-session.ts";
import { useLogout, useSession } from "@/queries/session.ts";
const { token } = theme.useToken();
const route = useRoute();
const router = useRouter();
const adminSession = useAdminSessionStore();
// 必须解构:未解构时经 reactive 访问会解包 Ref,再取 .value 会得到 undefined
const {
data: sessionData,
isPending: sessionPending,
isFetching: sessionFetching,
} = useSession();
const logout = useLogout();
const collapsed = ref(false);
/** 品牌区两行:主标题 + 一句描述(与日志前缀 naku 同源) */
const brandTitle = "NAKU";
const brandDescription = "NewAPI 用量统计";
const username = computed(
() => adminSession.username || sessionData.value?.username || "",
);
/** 收起时仅剩图标——每项必须带 icon(交互共性侧边栏六项) */
const items: MenuItemType[] = [
{ key: "/admin/overview", icon: DashboardOutlined, label: "总览" },
{ key: "/admin/members", icon: TeamOutlined, label: "成员管理" },
{ key: "/admin/keys", icon: KeyOutlined, label: "Key 管理" },
{ key: "/admin/payments", icon: AccountBookOutlined, label: "支付记录" },
{ key: "/admin/logs", icon: FileTextOutlined, label: "调用日志" },
{ key: "/admin/audit", icon: AuditOutlined, label: "审计日志" },
{ key: "/admin/settings", icon: SettingOutlined, label: "账号设置" },
];
const selectedKeys = ref<string[]>([route.path]);
watch(
() => route.path,
(path) => {
selectedKeys.value = [path];
},
);
function onMenuClick({ key }: MenuInfo) {
void router.push(String(key));
}
const userMenuItems: MenuItemType[] = [
{ key: "settings", icon: UserOutlined, label: "账号设置" },
{ type: "divider", key: "d1" },
{ key: "logout", icon: LogoutOutlined, label: "退出", danger: true },
];
async function onUserMenuClick({ key }: MenuInfo) {
if (key === "settings") {
void router.push({ name: "admin-settings" });
return;
}
if (key === "logout") {
await logout.mutateAsync().catch(() => undefined);
void router.push({ name: "admin-login" });
}
}
</script>
<template>
<a-layout class="admin-layout">
<a-layout-sider
v-model:collapsed="collapsed"
collapsible
:width="220"
:collapsed-width="80"
>
<template #trigger>
<MenuUnfoldOutlined v-if="collapsed" />
<MenuFoldOutlined v-else />
</template>
<!-- 品牌区:展开「徽标 + 标题/描述」两行;收起只留徽标 -->
<div
class="admin-layout__brand"
:class="{ 'is-collapsed': collapsed }"
:title="collapsed ? `${brandTitle} · ${brandDescription}` : undefined"
>
<span class="admin-layout__mark" aria-hidden="true">N</span>
<div v-if="!collapsed" class="admin-layout__brand-text">
<div class="admin-layout__brand-title">{{ brandTitle }}</div>
<div class="admin-layout__brand-desc">{{ brandDescription }}</div>
</div>
</div>
<a-menu
theme="dark"
mode="inline"
:inline-collapsed="collapsed"
:selected-keys="selectedKeys"
:items="items"
@click="onMenuClick"
/>
</a-layout-sider>
<a-layout class="admin-layout__main">
<a-layout-header class="admin-layout__header">
<a-dropdown
:menu="{ items: userMenuItems }"
placement="bottomRight"
:trigger="['click']"
@menu-click="onUserMenuClick"
>
<a class="admin-layout__account">
<UserOutlined class="admin-layout__account-icon" />
<span v-if="username" class="admin-layout__username">{{ username }}</span>
<span v-else class="admin-layout__username is-muted">
{{ sessionPending || sessionFetching ? "…" : "未登录" }}
</span>
<DownOutlined class="admin-layout__account-caret" />
</a>
</a-dropdown>
</a-layout-header>
<a-layout-content class="admin-layout__content">
<div
class="admin-layout__surface"
:style="{
background: token.colorBgContainer,
borderRadius: `${token.borderRadiusLG}px`,
}"
>
<router-view />
</div>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<style scoped>
/* 固定外壳:侧栏 + 顶栏不动,仅内容区滚动 */
.admin-layout {
height: 100vh;
overflow: hidden;
}
.admin-layout__main {
height: 100vh;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/*
* 品牌区:恒高 64,不随收起跳动。
* 展开:徽标 + 两行文字(标题 title-md 级 14/600/22 · 描述 body-sm 12/400/20)——22+20=42,竖向居中。
* 收起:只居中徽标;悬停 title 为「标题 · 描述」。
*/
.admin-layout__brand {
height: 64px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 24px;
overflow: hidden;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
box-sizing: border-box;
}
.admin-layout__brand.is-collapsed {
justify-content: center;
padding: 0;
gap: 0;
}
.admin-layout__mark {
flex: 0 0 28px;
width: 28px;
height: 28px;
border-radius: 6px;
background: #1677ff;
color: #fff;
font-size: 15px;
font-weight: 600;
line-height: 28px;
text-align: center;
user-select: none;
}
.admin-layout__brand-text {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0;
}
.admin-layout__brand-title {
color: rgba(255, 255, 255, 0.92);
font-size: 14px;
font-weight: 600;
line-height: 22px;
letter-spacing: 0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-layout__brand-desc {
color: rgba(255, 255, 255, 0.45);
font-size: 12px;
font-weight: 400;
line-height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-layout__header {
flex: 0 0 64px;
display: flex;
align-items: center;
justify-content: flex-end;
background: #fff;
padding: 0 24px;
height: 64px;
line-height: 64px;
border-bottom: 1px solid #f0f0f0;
}
.admin-layout__account {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 8px;
height: 40px;
color: rgba(0, 0, 0, 0.88);
cursor: pointer;
border-radius: 6px;
transition: background-color 0.2s;
}
.admin-layout__account:hover {
background: rgba(0, 0, 0, 0.06);
}
.admin-layout__account-icon {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
}
.admin-layout__account-caret {
font-size: 10px;
color: rgba(0, 0, 0, 0.45);
}
.admin-layout__username {
color: rgba(0, 0, 0, 0.88);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-layout__username.is-muted {
color: rgba(0, 0, 0, 0.45);
}
.admin-layout__content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
background: #f5f5f5;
padding: 24px;
}
.admin-layout__surface {
padding: 24px;
min-height: 100%;
}
</style>
+273
View File
@@ -0,0 +1,273 @@
<script setup lang="ts">
import type { MenuInfo, MenuItemType } from "antdv-next";
import { theme } from "antdv-next";
import {
AccountBookOutlined,
DashboardOutlined,
DownOutlined,
FileTextOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
UserOutlined,
} from "@antdv-next/icons";
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useMemberTokenStore } from "@/stores/member-token.ts";
import { useMemberRangeStore } from "@/stores/member-range.ts";
import { useMemberMe } from "@/queries/member.ts";
const { token } = theme.useToken();
const route = useRoute();
const router = useRouter();
const tokenStore = useMemberTokenStore();
const rangeStore = useMemberRangeStore();
const meQuery = useMemberMe();
const collapsed = ref(false);
const brandTitle = "NAKU";
const brandDescription = "NewAPI 用量统计";
const memberName = computed(
() => tokenStore.memberName || meQuery.data.value?.name || "",
);
const items: MenuItemType[] = [
{ key: "/member", icon: DashboardOutlined, label: "总览" },
{ key: "/member/logs", icon: FileTextOutlined, label: "调用日志" },
{ key: "/member/payments", icon: AccountBookOutlined, label: "支付参数" },
];
const selectedKeys = ref<string[]>([route.path === "/member" ? "/member" : route.path]);
watch(
() => route.path,
(path) => {
selectedKeys.value = [path === "/member" ? "/member" : path];
},
);
function onMenuClick({ key }: MenuInfo) {
// 切页保留对账窗口(from/to),不带页内筛选
void router.push({
path: String(key),
query: {
from: rangeStore.range.from,
to: rangeStore.range.to,
},
});
}
const userMenuItems: MenuItemType[] = [
{ key: "logout", icon: LogoutOutlined, label: "退出", danger: true },
];
function onUserMenuClick({ key }: MenuInfo) {
if (key === "logout") {
tokenStore.clearToken();
void router.replace({ name: "member-login" });
}
}
</script>
<template>
<a-layout class="member-layout">
<a-layout-sider
v-model:collapsed="collapsed"
collapsible
:width="220"
:collapsed-width="80"
>
<template #trigger>
<MenuUnfoldOutlined v-if="collapsed" />
<MenuFoldOutlined v-else />
</template>
<div
class="member-layout__brand"
:class="{ 'is-collapsed': collapsed }"
:title="collapsed ? `${brandTitle} · ${brandDescription}` : undefined"
>
<span class="member-layout__mark" aria-hidden="true">N</span>
<div v-if="!collapsed" class="member-layout__brand-text">
<div class="member-layout__brand-title">{{ brandTitle }}</div>
<div class="member-layout__brand-desc">{{ brandDescription }}</div>
</div>
</div>
<a-menu
theme="dark"
mode="inline"
:inline-collapsed="collapsed"
:selected-keys="selectedKeys"
:items="items"
@click="onMenuClick"
/>
</a-layout-sider>
<a-layout class="member-layout__main">
<a-layout-header class="member-layout__header">
<a-dropdown
:menu="{ items: userMenuItems }"
placement="bottomRight"
:trigger="['click']"
@menu-click="onUserMenuClick"
>
<a class="member-layout__account">
<UserOutlined class="member-layout__account-icon" />
<span v-if="memberName" class="member-layout__username">{{ memberName }}</span>
<span v-else class="member-layout__username is-muted">…</span>
<DownOutlined class="member-layout__account-caret" />
</a>
</a-dropdown>
</a-layout-header>
<a-layout-content class="member-layout__content">
<div
class="member-layout__surface"
:style="{
background: token.colorBgContainer,
borderRadius: `${token.borderRadiusLG}px`,
}"
>
<router-view />
</div>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<style scoped>
.member-layout {
height: 100vh;
overflow: hidden;
}
.member-layout__main {
height: 100vh;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.member-layout__brand {
height: 64px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 24px;
overflow: hidden;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
box-sizing: border-box;
}
.member-layout__brand.is-collapsed {
justify-content: center;
padding: 0;
gap: 0;
}
.member-layout__mark {
flex: 0 0 28px;
width: 28px;
height: 28px;
border-radius: 6px;
background: #1677ff;
color: #fff;
font-size: 15px;
font-weight: 600;
line-height: 28px;
text-align: center;
user-select: none;
}
.member-layout__brand-text {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0;
}
.member-layout__brand-title {
color: rgba(255, 255, 255, 0.92);
font-size: 14px;
font-weight: 600;
line-height: 22px;
letter-spacing: 0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-layout__brand-desc {
color: rgba(255, 255, 255, 0.45);
font-size: 12px;
font-weight: 400;
line-height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-layout__header {
flex: 0 0 64px;
display: flex;
align-items: center;
justify-content: flex-end;
background: #fff;
padding: 0 24px;
height: 64px;
line-height: 64px;
border-bottom: 1px solid #f0f0f0;
}
.member-layout__account {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 8px;
height: 40px;
color: rgba(0, 0, 0, 0.88);
cursor: pointer;
border-radius: 6px;
transition: background-color 0.2s;
}
.member-layout__account:hover {
background: rgba(0, 0, 0, 0.06);
}
.member-layout__account-icon {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
}
.member-layout__account-caret {
font-size: 10px;
color: rgba(0, 0, 0, 0.45);
}
.member-layout__username {
color: rgba(0, 0, 0, 0.88);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-layout__username.is-muted {
color: rgba(0, 0, 0, 0.45);
}
.member-layout__content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
background: #f5f5f5;
padding: 24px;
}
.member-layout__surface {
padding: 24px;
min-height: 100%;
}
</style>
+17 -1
View File
@@ -1,9 +1,25 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
import Antd from "antdv-next";
import "antdv-next/dist/reset.css";
import App from "./App.vue";
import App from "./app.vue";
import { bindAuthRedirectToRouter, router } from "./router/index.ts";
import "./assets/main.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
});
const app = createApp(App);
app.use(createPinia());
bindAuthRedirectToRouter();
app.use(router);
app.use(VueQueryPlugin, { queryClient });
app.use(Antd);
app.mount("#app");
+279
View File
@@ -0,0 +1,279 @@
import { computed, type ComputedRef } from "vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import {
fetchKeys,
patchKey,
registerKey,
type KeyListFilters,
} from "@/api/admin/keys.ts";
import { fetchOverview } from "@/api/admin/overview.ts";
import { fetchEngine, fetchQpuLatest } from "@/api/admin/engine.ts";
import {
createSubscription,
deleteSubscription,
fetchSubscriptions,
updateSubscription,
type SubscriptionBody,
} from "@/api/admin/subscriptions.ts";
import {
createTopup,
deleteTopup,
fetchTopups,
updateTopup,
type TopupBody,
} from "@/api/admin/topups.ts";
import {
createMember,
deleteMember,
fetchMembers,
issueMemberToken,
patchMember,
} from "@/api/admin/members.ts";
import { fetchAuditLogs } from "@/api/admin/audit.ts";
import { fetchLogs } from "@/api/admin/logs.ts";
import { qk, type PageQuery, type TimeRange } from "@/api/query-keys.ts";
export function useAdminOverview(range: () => TimeRange) {
return useQuery({
queryKey: () => qk.admin.overview(range()),
queryFn: () => fetchOverview(range()),
// 页面可长时间挂起:定期拉取引擎新写入的数据
refetchInterval: 60_000,
});
}
export function useEngine() {
return useQuery({
queryKey: qk.admin.engine(),
queryFn: fetchEngine,
refetchInterval: 60_000,
});
}
export function useQpuLatest() {
return useQuery({
queryKey: qk.admin.qpuLatest(),
queryFn: fetchQpuLatest,
});
}
export function useKeys(filters: () => KeyListFilters) {
return useQuery({
queryKey: () => qk.admin.keys(filters()),
queryFn: () => fetchKeys(filters()),
// 切页回列表时立即反映最新归属/状态
staleTime: 0,
});
}
export function useRegisterKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: registerKey,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
void qc.invalidateQueries({ queryKey: qk.admin.members() });
},
});
}
export function usePatchKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
body,
}: {
id: number;
body: {
status?: "active" | "disabled";
member_id?: number | null;
billing_domain?: string | null;
};
}) => patchKey(id, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
// 归属变更会改成员「名下 Key 数」
void qc.invalidateQueries({ queryKey: qk.admin.members() });
},
});
}
export function useAdminLogs(
params: () => Parameters<typeof fetchLogs>[0],
) {
return useQuery({
queryKey: () => qk.admin.logs(params()),
queryFn: () => fetchLogs(params()),
});
}
export function useSubscriptions() {
return useQuery({
queryKey: qk.admin.subscriptions(),
queryFn: () => fetchSubscriptions(),
staleTime: 0,
});
}
export function useCreateSubscription() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: SubscriptionBody) => createSubscription(body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
},
});
}
export function useUpdateSubscription() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: SubscriptionBody }) =>
updateSubscription(id, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
},
});
}
export function useDeleteSubscription() {
const qc = useQueryClient();
return useMutation({
mutationFn: deleteSubscription,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
},
});
}
export function useTopups() {
return useQuery({
queryKey: qk.admin.topups(),
queryFn: () => fetchTopups(),
staleTime: 0,
});
}
export function useCreateTopup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: TopupBody) => createTopup(body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
},
});
}
export function useUpdateTopup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: TopupBody }) =>
updateTopup(id, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
},
});
}
export function useDeleteTopup() {
const qc = useQueryClient();
return useMutation({
mutationFn: deleteTopup,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
},
});
}
export function useMembers() {
return useQuery({
queryKey: qk.admin.members(),
queryFn: fetchMembers,
staleTime: 0,
});
}
export function useCreateMember() {
const qc = useQueryClient();
return useMutation({
mutationFn: createMember,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.members() });
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
},
});
}
export function usePatchMember() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
body,
}: {
id: number;
body: {
name?: string;
remark?: string;
status?: "active" | "disabled";
};
}) => patchMember(id, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.members() });
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
},
});
}
export function useDeleteMember() {
const qc = useQueryClient();
return useMutation({
mutationFn: deleteMember,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.members() });
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
},
});
}
export function useIssueMemberToken() {
const qc = useQueryClient();
return useMutation({
mutationFn: issueMemberToken,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.members() });
},
});
}
export function useAuditLogs(
params: () => PageQuery & { domain?: string; from?: string; to?: string },
) {
return useQuery({
queryKey: () => qk.admin.auditLogs(params()),
queryFn: () => fetchAuditLogs(params()),
});
}
/** 账本域联想:keys ∪ 订阅 ∪ 充值 已出现过的值(不强制) */
export function useBillingDomainOptions(): ComputedRef<
Array<{ value: string }>
> {
const keysQuery = useKeys(() => ({}));
const subsQuery = useSubscriptions();
const topupsQuery = useTopups();
return computed(() => {
const set = new Set<string>();
for (const k of keysQuery.data.value ?? []) {
if (k.billing_domain) set.add(k.billing_domain);
}
for (const s of subsQuery.data.value ?? []) {
if (s.billing_domain) set.add(s.billing_domain);
}
for (const t of topupsQuery.data.value ?? []) {
if (t.billing_domain) set.add(t.billing_domain);
}
return [...set].sort().map((value) => ({ value }));
});
}
+39
View File
@@ -0,0 +1,39 @@
import { useQuery } from "@tanstack/vue-query";
import {
fetchLogs,
fetchMe,
fetchOverview,
type MemberLogQuery,
} from "@/api/member/me.ts";
import { qk, type TimeRange } from "@/api/query-keys.ts";
export function useMemberMe() {
return useQuery({
queryKey: qk.member.me(),
queryFn: fetchMe,
retry: false,
});
}
/**
* 总览聚合。仅「总览」传 `refetchInterval`(看板心智);日志/支付不轮询。
*/
export function useMemberOverview(
range: () => TimeRange,
opts?: { refetchInterval?: number },
) {
return useQuery({
queryKey: () => qk.member.overview(range()),
queryFn: () => fetchOverview(range()),
...(opts?.refetchInterval != null
? { refetchInterval: opts.refetchInterval }
: {}),
});
}
export function useMemberLogs(params: () => MemberLogQuery) {
return useQuery({
queryKey: () => qk.member.logs(params()),
queryFn: () => fetchLogs(params()),
});
}
+73
View File
@@ -0,0 +1,73 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import {
changePassword,
fetchSession,
fetchSetupState,
login,
logout,
setup,
} from "@/api/admin/session.ts";
import { qk } from "@/api/query-keys.ts";
import { useAdminSessionStore } from "@/stores/admin-session.ts";
export function useSetupState() {
return useQuery({
queryKey: qk.admin.setupState(),
queryFn: fetchSetupState,
staleTime: 60_000,
});
}
export function useSession() {
const store = useAdminSessionStore();
return useQuery({
queryKey: qk.admin.session(),
queryFn: async () => {
const data = await fetchSession();
store.signIn(data.username);
return data;
},
staleTime: 60_000,
retry: false,
});
}
export function useLogin() {
const store = useAdminSessionStore();
const qc = useQueryClient();
return useMutation({
mutationFn: login,
onSuccess: (data) => {
store.signIn(data.username);
void qc.invalidateQueries({ queryKey: qk.admin.session() });
},
});
}
export function useSetup() {
const store = useAdminSessionStore();
const qc = useQueryClient();
return useMutation({
mutationFn: setup,
onSuccess: (data) => {
store.signIn(data.username);
void qc.invalidateQueries({ queryKey: qk.admin.setupState() });
},
});
}
export function useLogout() {
const store = useAdminSessionStore();
const qc = useQueryClient();
return useMutation({
mutationFn: logout,
onSuccess: () => {
store.signOut();
qc.clear();
},
});
}
export function useChangePassword() {
return useMutation({ mutationFn: changePassword });
}
+61
View File
@@ -0,0 +1,61 @@
import type { RouteRecordRaw } from "vue-router";
import AdminLayout from "@/layouts/admin-layout.vue";
/**
* 管理面路由 `/admin/*`(契约:页面路由永不进入 `/api`)。
* 侧边栏六项 + 账号设置(交互共性 §2)。
*/
export const adminRoutes: RouteRecordRaw[] = [
{
path: "/admin/login",
name: "admin-login",
component: () => import("@/views/admin/login-view.vue"),
meta: { public: true, world: "admin" },
},
{
path: "/admin",
component: AdminLayout,
meta: { requiresAdmin: true, world: "admin" },
children: [
{
path: "",
redirect: { name: "admin-overview" },
},
{
path: "overview",
name: "admin-overview",
component: () => import("@/views/admin/overview-view.vue"),
},
{
path: "logs",
name: "admin-logs",
component: () => import("@/views/admin/logs-view.vue"),
},
{
path: "keys",
name: "admin-keys",
component: () => import("@/views/admin/keys-view.vue"),
},
{
path: "payments",
name: "admin-payments",
component: () => import("@/views/admin/payments-view.vue"),
},
{
path: "members",
name: "admin-members",
component: () => import("@/views/admin/members-view.vue"),
},
{
path: "audit",
name: "admin-audit",
component: () => import("@/views/admin/audit-view.vue"),
},
{
path: "settings",
name: "admin-settings",
component: () => import("@/views/admin/settings-view.vue"),
},
],
},
];
+59
View File
@@ -0,0 +1,59 @@
import { createRouter, createWebHistory, type Router } from "vue-router";
import { adminRoutes } from "./admin.ts";
import { memberRoutes } from "./member.ts";
import { setAuthRedirect } from "@/api/auth-redirect.ts";
import { useAdminSessionStore } from "@/stores/admin-session.ts";
import { useMemberTokenStore } from "@/stores/member-token.ts";
declare module "vue-router" {
interface RouteMeta {
public?: boolean;
requiresAdmin?: boolean;
requiresMember?: boolean;
world?: "admin" | "member";
}
}
export const router: Router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", redirect: "/member" },
...adminRoutes,
...memberRoutes,
{ path: "/:pathMatch(.*)*", redirect: "/member" },
],
});
router.beforeEach((to) => {
if (to.meta.public) return true;
const memberToken = useMemberTokenStore();
// 管理面:硬刷新后 Pinia 为空但会话 cookie 仍在——交给 GET /session 与 401 分流裁决
if (to.meta.requiresMember && !memberToken.hasToken) {
return { name: "member-login" };
}
return true;
});
/** 401 分流接入 router(由 main.ts 调用一次) */
export function bindAuthRedirectToRouter(): void {
setAuthRedirect((world, reason) => {
const adminSession = useAdminSessionStore();
const memberToken = useMemberTokenStore();
if (world === "admin") {
adminSession.signOut();
void router.push({
name: "admin-login",
query: reason === "expired" ? { reason: "expired" } : {},
});
} else {
memberToken.clearToken();
void router.push({
name: "member-login",
query: reason === "expired" ? { reason: "expired" } : {},
});
}
});
}
+34
View File
@@ -0,0 +1,34 @@
import type { RouteRecordRaw } from "vue-router";
import MemberLayout from "@/layouts/member-layout.vue";
/** 成员视图 `/member/*`:登录壳 → 侧边栏三页(与管理端同构壳) */
export const memberRoutes: RouteRecordRaw[] = [
{
path: "/member/login",
name: "member-login",
component: () => import("@/views/member/login-view.vue"),
meta: { public: true, world: "member" },
},
{
path: "/member",
component: MemberLayout,
meta: { requiresMember: true, world: "member" },
children: [
{
path: "",
name: "member-overview",
component: () => import("@/views/member/overview-view.vue"),
},
{
path: "logs",
name: "member-logs",
component: () => import("@/views/member/logs-view.vue"),
},
{
path: "payments",
name: "member-payments",
component: () => import("@/views/member/payments-view.vue"),
},
],
},
];
+22
View File
@@ -0,0 +1,22 @@
import { defineStore } from "pinia";
/**
* 管理面登录态(客户端镜像)。
* 服务端事实以 GET /api/admin/session 为准——刷新后由 queries/session 恢复。
*/
export const useAdminSessionStore = defineStore("admin-session", {
state: () => ({
username: "" as string,
}),
getters: {
isLoggedIn: (s) => s.username.length > 0,
},
actions: {
signIn(username: string) {
this.username = username;
},
signOut() {
this.username = "";
},
},
});
+29
View File
@@ -0,0 +1,29 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { recentDaysRange } from "@/utils/time.ts";
export type RangeValue = { from: string; to: string };
export type RangeKind = "today" | "recent-7" | "recent-30" | "fixed";
/**
* 成员端全局时间范围——总览 / 调用日志 / 支付参数共用。
* 相对窗口在 setRange 时定格,避免每分钟改 key 打穿缓存;
* 「看板自动刷新」只属于总览页(refetchInterval),其余页按需请求。
*/
export const useMemberRangeStore = defineStore("member-range", () => {
const kind = ref<RangeKind>("recent-30");
const windowRange = ref<RangeValue>(recentDaysRange(30));
const fixed = ref<RangeValue>(recentDaysRange(30));
const range = computed<RangeValue>(() =>
kind.value === "fixed" ? fixed.value : windowRange.value,
);
function setRange(value: RangeValue, nextKind: RangeKind) {
kind.value = nextKind;
if (nextKind === "fixed") fixed.value = value;
else windowRange.value = value;
}
return { kind, range, setRange };
});
+48
View File
@@ -0,0 +1,48 @@
import { defineStore } from "pinia";
const STORAGE_KEY = "member_token";
function readToken(): string {
try {
return sessionStorage.getItem(STORAGE_KEY) ?? "";
} catch {
return "";
}
}
function writeToken(token: string): void {
try {
if (token) sessionStorage.setItem(STORAGE_KEY, token);
else sessionStorage.removeItem(STORAGE_KEY);
} catch {
// 隐私模式等场景忽略
}
}
/**
* 成员明文令牌——仅存在于登录壳输入与 sessionStorage(交互登录篇 §4)。
* 无登出端点:关标签即失效;作者重发令牌后旧令牌下一请求自然 401。
*/
export const useMemberTokenStore = defineStore("member-token", {
state: () => ({
token: readToken(),
memberName: "",
}),
getters: {
hasToken: (s) => s.token.length > 0,
},
actions: {
setToken(token: string) {
this.token = token;
writeToken(token);
},
setMemberName(name: string) {
this.memberName = name;
},
clearToken() {
this.token = "";
this.memberName = "";
writeToken("");
},
},
});
+23
View File
@@ -0,0 +1,23 @@
/** 呈现层格式化(不做业务换算——换算责任在后端,契约公共约定 §5) */
/** 千分位整数;口径内精确显示,不四舍五入 */
export function formatInt(value: number | null | undefined): string {
if (value == null) return "—";
return value.toLocaleString("en-US");
}
/** quota 原值 + 美元双单位展示片段 */
export function formatQuotaDual(
quota: number | null | undefined,
quotaUsd: string | null | undefined,
): string {
if (quota == null) return "—";
const usd = quotaUsd ?? "—";
return `${formatInt(quota)}($${usd})`;
}
/** 支付金额:纯数字、无货币符号(R9 币种无关) */
export function formatPaidAmount(value: number | null | undefined): string {
if (value == null) return "—";
return String(value);
}
+112
View File
@@ -0,0 +1,112 @@
/**
* 时间展示:API 响应时刻为 Unix 秒(UTC);界面一律北京时间(交互共性 §3.3)。
*/
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc);
dayjs.extend(timezone);
/** 北京时区展示(固定 +08:00,不随主机时区变) */
const DISPLAY_TZ = "Asia/Shanghai";
/** 请求时间序列化:ISO 8601 带偏移(契约 §4;墙钟按北京时间) */
const REQUEST_OFFSET = "+08:00";
/**
* 绝对时刻(Date / Unix / 带时区的 ISO)→ 北京墙钟 + `+08:00`。
* 先转 Asia/Shanghai,与主机时区无关。
*/
export function toRequestIso(value: Date | string | number | dayjs.Dayjs): string {
return shanghaiIso(dayjs(value as never));
}
/**
* 表单/日历墙钟(用户当作北京时间)→ `+08:00`。
* 不做时区换算,只给墙钟贴偏移。
*/
export function wallAsBeijingIso(value: Date | dayjs.Dayjs): string {
const wall = dayjs(value as never).format("YYYY-MM-DDTHH:mm:ss.SSS");
return `${wall}${REQUEST_OFFSET}`;
}
/** Unix 秒 → 可放进日历控件的「北京墙钟」dayjs */
export function unixToBeijingWall(unixSec: number): dayjs.Dayjs {
return dayjs(
dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD HH:mm:ss.SSS"),
);
}
/** 已在 Asia/Shanghai 上的时刻 → 带偏移 ISO */
function shanghaiIso(d: dayjs.Dayjs): string {
const wall = d.tz(DISPLAY_TZ).format("YYYY-MM-DDTHH:mm:ss.SSS");
return `${wall}${REQUEST_OFFSET}`;
}
/** Unix 秒 → 北京时间「YYYY-MM-DD HH:mm:ss」 */
export function formatDateTime(unixSec: number | null | undefined): string {
if (unixSec == null) return "—";
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD HH:mm:ss");
}
/** Unix 秒 → 北京时间「YYYY-MM-DD」(统计口径到日) */
export function formatDate(unixSec: number | null | undefined): string {
if (unixSec == null) return "—";
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD");
}
/** Unix 秒 → 北京时间「HH:mm:ss」(明细时刻) */
export function formatTimeOfDay(unixSec: number | null | undefined): string {
if (unixSec == null) return "—";
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("HH:mm:ss");
}
/** 北京时间日初 → 请求用 ISO(带 +08:00 偏移) */
export function beijingDayStartToIso(date: string): string {
return shanghaiIso(dayjs.tz(date, DISPLAY_TZ).startOf("day"));
}
/** 北京时间日界(次日零点,左闭右开的 to)→ ISO 带偏移 */
export function beijingDayEndToIso(date: string): string {
return shanghaiIso(dayjs.tz(date, DISPLAY_TZ).add(1, "day").startOf("day"));
}
export type RangeValue = { from: string; to: string };
/** 快捷项:最近 N 天(含今日),返回 [from, to) 的 ISO 带偏移 */
export function recentDaysRange(
days: number,
nowMs: number = Date.now(),
): { from: string; to: string } {
const to = dayjs(nowMs).tz(DISPLAY_TZ).add(1, "day").startOf("day");
const from = to.subtract(days, "day");
return { from: shanghaiIso(from), to: shanghaiIso(to) };
}
/** 快捷项:今天(北京时间日界,左闭右开) */
export function todayRange(nowMs: number = Date.now()): {
from: string;
to: string;
} {
return recentDaysRange(1, nowMs);
}
/** 统计口径标注:「2026-09-02 ~ 10-01」(北京时间日) */
export function formatRangeCaption(fromIso: string, toIso: string): string {
if (!fromIso || !toIso) return "—";
const from = dayjs(fromIso).tz(DISPLAY_TZ).format("YYYY-MM-DD");
const to = dayjs(toIso).tz(DISPLAY_TZ).format("YYYY-MM-DD");
return `${from} ~ ${to}`;
}
/** 「数据截止」到分钟(交互共性 §3.3) */
export function formatMinute(unixSec: number | null | undefined): string {
if (unixSec == null) return "—";
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD HH:mm");
}
/** 距今小时数(用于数据截止停滞判定) */
export function hoursSince(unixSec: number | null | undefined): number | null {
if (unixSec == null) return null;
return (Date.now() / 1000 - unixSec) / 3600;
}
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
/**
* 审计日志(交互设计-审计日志):只读时间线 + 域筛选 + 分页
* 表体内部滚动:高度按容器实测,避免内容区被撑出视口
*/
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import { useAuditLogs } from "@/queries/admin.ts";
import { formatDateTime, recentDaysRange } from "@/utils/time.ts";
const domain = ref<string | null>(null);
const page = ref(1);
const pageSize = ref(50);
const range = ref(recentDaysRange(30));
const boxRef = ref<HTMLElement | null>(null);
/** 表头 + 分页大约占掉的高度 */
const CHROME = 96;
const bodyY = ref(480);
const params = computed(() => ({
page: page.value,
page_size: pageSize.value,
domain: domain.value ?? undefined,
from: range.value.from,
to: range.value.to,
}));
const auditQuery = useAuditLogs(() => params.value);
const rows = computed(() => auditQuery.data.value?.items ?? []);
const total = computed(() => auditQuery.data.value?.total ?? 0);
const domainOptions = [
{ value: null, label: "全部域" },
{ value: "key", label: "Key" },
{ value: "payment", label: "支付" },
{ value: "member", label: "成员" },
{ value: "account", label: "账号" },
];
const columns = [
{ title: "时间", key: "created_at", width: 160 },
{ title: "操作者", key: "operator", width: 100 },
{ title: "域", key: "domain", width: 80 },
{ title: "操作", key: "action", width: 140 },
{ title: "摘要", key: "summary", ellipsis: true },
];
watch([domain, range], () => {
page.value = 1;
});
let ro: ResizeObserver | null = null;
function measure() {
const h = boxRef.value?.clientHeight ?? 0;
if (h > 0) bodyY.value = Math.max(200, h - CHROME);
}
onMounted(() => {
measure();
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
ro = new ResizeObserver(() => measure());
ro.observe(boxRef.value);
}
window.addEventListener("resize", measure);
});
onUnmounted(() => {
ro?.disconnect();
window.removeEventListener("resize", measure);
});
</script>
<template>
<section class="audit">
<PageHeader title="审计日志" subtitle="管理操作留痕,只读">
<template #actions>
<span class="audit-range-label">时间范围</span>
<TimeRangePicker v-model="range" />
<a-select
v-model:value="domain"
:options="domainOptions"
style="width: 140px"
placeholder="域"
allow-clear
/>
</template>
</PageHeader>
<TableRefresh
:loading="auditQuery.isFetching.value"
@refresh="() => auditQuery.refetch()"
/>
<div ref="boxRef" class="audit__box">
<a-table
:columns="columns"
:data-source="rows"
:row-key="(r: { id: number }) => r.id"
:loading="auditQuery.isPending.value || auditQuery.isFetching.value"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (t: number) => `共 ${t} 条`,
onChange: (p: number, ps: number) => {
page = p;
pageSize = ps;
},
}"
size="middle"
:scroll="{ y: bodyY }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'operator'">
{{ record.operator || "系统" }}
</template>
<template v-else-if="column.key === 'domain'">
<a-tag>{{ record.domain }}</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<span class="audit-action">{{ record.action }}</span>
</template>
<template v-else-if="column.key === 'summary'">
{{ record.summary }}
</template>
</template>
</a-table>
</div>
</section>
</template>
<style scoped>
/*
* 固定壳:视口高 − 顶栏 64 − 内容区 padding 24×2 − 内容面 padding 24×2
* 表体高度由 ResizeObserver 量出,内容区不随表格增高
*/
.audit {
height: calc(100vh - 64px - 48px - 48px);
display: flex;
flex-direction: column;
min-height: 0;
}
.audit__box {
flex: 1;
min-height: 0;
}
.audit-action {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
monospace;
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
}
.audit-range-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
line-height: 32px;
}
</style>
+448
View File
@@ -0,0 +1,448 @@
<script setup lang="ts">
/**
* Key 管理(交互设计-key管理):清单表 · 行内归属/启停 · 登记 · 停滞显形
*/
import { computed, h, ref, watch } from "vue";
import { Tooltip, message } from "antdv-next";
import { QuestionCircleOutlined } from "@antdv-next/icons";
import { useRoute, useRouter } from "vue-router";
import type { KeyItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import RegisterKeyModal from "@/components/admin/register-key-modal.vue";
import { useEngine, useKeys, useMembers, usePatchKey, useBillingDomainOptions } from "@/queries/admin.ts";
import { formatInt } from "@/utils/format.ts";
import { formatMinute, hoursSince } from "@/utils/time.ts";
const route = useRoute();
const router = useRouter();
const registerOpen = ref(false);
const highlightKeyId = ref<number | null>(null);
const filterMember = ref<number | "unassigned" | null>(null);
const filterStatus = ref<"active" | "disabled" | null>(null);
const filterDomain = ref<string | "none" | null>(null);
let domainSaveTimer: ReturnType<typeof setTimeout> | null = null;
const filterRecentFailed = ref(false);
/** URL → 筛选(总览跳入) */
function syncFromRoute() {
const q = route.query;
const m = q.member_id;
if (m === "unassigned") filterMember.value = "unassigned";
else if (typeof m === "string" && m) filterMember.value = Number(m);
else filterMember.value = null;
if (q.status === "active" || q.status === "disabled") filterStatus.value = q.status;
else filterStatus.value = null;
if (q.billing_domain === "none") filterDomain.value = "none";
else if (typeof q.billing_domain === "string" && q.billing_domain) {
filterDomain.value = q.billing_domain;
} else filterDomain.value = null;
filterRecentFailed.value = q.recent_failed === "1" || q.recent_failed === "true";
const kid = q.key_id;
highlightKeyId.value = typeof kid === "string" && kid ? Number(kid) : null;
}
syncFromRoute();
watch(
() => route.query,
() => syncFromRoute(),
);
function pushQuery() {
const query: Record<string, string> = {};
if (filterMember.value === "unassigned") query.member_id = "unassigned";
else if (filterMember.value != null) query.member_id = String(filterMember.value);
if (filterStatus.value) query.status = filterStatus.value;
if (filterDomain.value === "none") query.billing_domain = "none";
else if (filterDomain.value) query.billing_domain = filterDomain.value;
if (filterRecentFailed.value) query.recent_failed = "1";
if (highlightKeyId.value != null) query.key_id = String(highlightKeyId.value);
void router.replace({ path: "/admin/keys", query });
}
watch([filterMember, filterStatus, filterDomain, filterRecentFailed], () => {
pushQuery();
});
const filters = computed(() => ({
member_id: filterMember.value ?? undefined,
status: filterStatus.value ?? undefined,
billing_domain: filterDomain.value ?? undefined,
recent_failed: filterRecentFailed.value ? true : undefined,
}));
const keysQuery = useKeys(() => filters.value);
const engineQuery = useEngine();
const membersQuery = useMembers();
const patchKey = usePatchKey();
const rows = computed(() => {
const list = [...(keysQuery.data.value ?? [])];
// 异常在前:无截止/停滞 → 旧截止 → 新截止(交互:停滞预警)
list.sort((a, b) => {
const ta = a.log_synced_at ?? 0;
const tb = b.log_synced_at ?? 0;
return ta - tb;
});
return list;
});
const failedCount = computed(
() => engineQuery.data.value?.last_round?.failed ?? 0,
);
const hasActiveFilter = computed(
() =>
filterMember.value != null ||
filterStatus.value != null ||
filterDomain.value != null ||
filterRecentFailed.value,
);
const memberOptions = computed(() => [
{ value: "unassigned", label: "未归属" },
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
]);
const domainOptions = useBillingDomainOptions();
function isStalled(logSyncedAt: number | null | undefined): boolean {
const h = hoursSince(logSyncedAt);
return h != null && h > 2;
}
function tipTitle(label: string, tip: string) {
return h("span", { class: "overview-col-title" }, [
label,
h(
Tooltip,
{ title: tip },
() =>
h(QuestionCircleOutlined, {
class: "overview-col-title__icon",
"aria-hidden": "true",
}),
),
]);
}
const columns = [
{ title: "名称", key: "token_name", width: 160 },
{ title: "归属", key: "member_name", width: 140 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "状态", key: "status", width: 100 },
{
title: tipTitle(
"数据截止",
"该 key 最近一次日志同步成功的时刻;落后超过 2 小时标为停滞。检查引擎日志;若持续 401 请停用",
),
key: "data_cutoff",
width: 170,
},
{
title: tipTitle("累计已用", "上游累计已消耗额度(quota)"),
key: "total_used",
},
{
title: tipTitle("剩余", "上游剩余可用额度;不限额度显示「不限」"),
key: "total_available",
},
{ title: "登记时间", key: "registered_at", width: 160 },
{ title: "操作", key: "actions", width: 120 },
];
async function onMemberChange(
record: KeyItem,
memberId: number | "unassigned" | null,
) {
const next = memberId === "unassigned" || memberId == null ? null : memberId;
try {
await patchKey.mutateAsync({ id: record.id, body: { member_id: next } });
message.success("归属已更新");
} catch (e) {
message.error((e as ApiError).message || "归属更新失败");
}
}
async function onDomainChange(record: KeyItem, value: string) {
const next = value.trim() || null;
if ((record.billing_domain ?? null) === next) return;
if (domainSaveTimer != null) clearTimeout(domainSaveTimer);
const id = record.id;
domainSaveTimer = setTimeout(() => {
domainSaveTimer = null;
void saveDomain(id, next);
}, 400);
}
async function onDomainBlur(record: KeyItem, value: string) {
const next = value.trim() || null;
if (domainSaveTimer != null) {
clearTimeout(domainSaveTimer);
domainSaveTimer = null;
}
if ((record.billing_domain ?? null) === next) return;
await saveDomain(record.id, next);
}
async function saveDomain(id: number, billing_domain: string | null) {
try {
await patchKey.mutateAsync({ id, body: { billing_domain } });
message.success("账本域已更新");
} catch (e) {
message.error((e as ApiError).message || "账本域更新失败");
}
}
async function onToggleStatus(record: KeyItem) {
const next = record.status === "active" ? "disabled" : "active";
try {
await patchKey.mutateAsync({ id: record.id, body: { status: next } });
if (next === "disabled") {
message.success("已停用——移出轮询清单,数据保留");
} else {
message.success("已启用——下一轮同步(≤5 分钟)将自动验证");
}
} catch (e) {
message.error((e as ApiError).message || "状态更新失败");
}
}
function onRegistered(keyId: number) {
highlightKeyId.value = keyId;
pushQuery();
void keysQuery.refetch();
}
function onFailedClick() {
filterRecentFailed.value = !filterRecentFailed.value;
}
function onRowClass(record: KeyItem) {
return record.id === highlightKeyId.value ? "key-row--highlight" : "";
}
</script>
<template>
<section>
<PageHeader title="Key 管理" subtitle="登记、启停、归属与同步显形">
<template #actions>
<a-button type="primary" @click="registerOpen = true">登记 Key</a-button>
</template>
</PageHeader>
<div class="keys-toolbar">
<span class="keys-toolbar__summary">
<template v-if="failedCount > 0">
最近轮次失败
<a-button type="link" size="small" @click="onFailedClick">
{{ failedCount }} 枚
</a-button>
(点击筛选)
</template>
<template v-else>最近轮次无失败 key</template>
</span>
<a-space wrap>
<a-select
v-model:value="filterMember"
:options="[
{ value: null, label: '全部成员' },
{ value: 'unassigned', label: '未归属' },
...(membersQuery.data.value?.map((m) => ({ value: m.id, label: m.name })) ?? []),
]"
style="width: 140px"
placeholder="成员"
allow-clear
/>
<a-select
v-model:value="filterStatus"
:options="[
{ value: null, label: '全部状态' },
{ value: 'active', label: '启用中' },
{ value: 'disabled', label: '已停用' },
]"
style="width: 120px"
placeholder="状态"
allow-clear
/>
<a-select
v-model:value="filterDomain"
:options="[
{ value: null, label: '全部账本域' },
{ value: 'none', label: '未标注' },
...domainOptions,
]"
style="width: 140px"
placeholder="账本域"
allow-clear
/>
<a-checkbox v-model:checked="filterRecentFailed">最近轮次失败</a-checkbox>
</a-space>
</div>
<TableRefresh
:loading="keysQuery.isFetching.value"
@refresh="() => keysQuery.refetch()"
/>
<a-empty
v-if="!keysQuery.isPending.value && rows.length === 0"
:description="
hasActiveFilter
? '当前筛选条件下没有 key——可清除筛选,或登记新的 key'
: '还没有登记任何 key——把你在 NewAPI 创建的 sk-key 粘贴进来'
"
>
<a-button v-if="!hasActiveFilter" type="primary" @click="registerOpen = true">
登记 Key
</a-button>
</a-empty>
<a-table
v-else
:columns="columns"
:data-source="rows"
:row-key="(r: KeyItem) => r.id"
:loading="keysQuery.isPending.value || keysQuery.isFetching.value"
:pagination="false"
:scroll="{ x: 1100 }"
:row-class-name="onRowClass"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'token_name'">
{{ record.token_name }}
</template>
<template v-else-if="column.key === 'member_name'">
<a-select
:value="record.member_id == null ? 'unassigned' : record.member_id"
:options="memberOptions"
size="small"
style="width: 120px"
@change="(v: number | 'unassigned') => onMemberChange(record, v)"
/>
</template>
<template v-else-if="column.key === 'billing_domain'">
<a-auto-complete
:value="record.billing_domain ?? ''"
:options="domainOptions"
size="small"
style="width: 120px"
allow-clear
@change="(v: string) => onDomainChange(record, v)"
@blur="(e: FocusEvent) => onDomainBlur(record, (e.target as HTMLInputElement)?.value ?? '')"
/>
</template>
<template v-else-if="column.key === 'status'">
<a-badge
:status="record.status === 'active' ? 'success' : 'default'"
:text="record.status === 'active' ? '启用中' : '已停用'"
/>
</template>
<template v-else-if="column.key === 'data_cutoff'">
<a-tooltip
v-if="isStalled(record.log_synced_at)"
title="检查引擎日志;若持续 401 请停用"
>
<span class="keys-stall">{{ formatMinute(record.log_synced_at) }} 停滞</span>
</a-tooltip>
<span v-else-if="record.log_synced_at == null" class="keys-syncing">
同步中…
</span>
<span v-else>{{ formatMinute(record.log_synced_at) }}</span>
</template>
<template v-else-if="column.key === 'total_used'">
<span class="keys-num">{{ formatInt(record.total_used) }}</span>
</template>
<template v-else-if="column.key === 'total_available'">
<span class="keys-num">
{{ record.unlimited_quota ? "不限" : formatInt(record.total_available) }}
</span>
</template>
<template v-else-if="column.key === 'registered_at'">
{{ formatMinute(record.registered_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button
size="small"
@click="() => router.push({ path: '/admin/logs', query: { api_key_id: String(record.id) } })"
>
查调用
</a-button>
<a-popconfirm
v-if="record.status === 'active'"
title="停用后移出轮询清单(数据保留)"
description="成员若仍在用它调用 API 不受影响——只有采集停止"
ok-text="停用"
cancel-text="取消"
@confirm="() => onToggleStatus(record)"
>
<a-button size="small" danger>停用</a-button>
</a-popconfirm>
<a-button v-else size="small" @click="() => onToggleStatus(record)">
启用
</a-button>
</a-space>
</template>
</template>
</a-table>
<RegisterKeyModal
v-model:open="registerOpen"
:members="membersQuery.data.value ?? []"
@registered="onRegistered"
/>
</section>
</template>
<style scoped>
.keys-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 16px;
margin-bottom: 16px;
}
.keys-toolbar__summary {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.keys-stall {
color: #d48806;
font-weight: 600;
}
.keys-syncing {
color: rgba(0, 0, 0, 0.45);
}
.keys-num {
font-variant-numeric: tabular-nums;
}
:deep(.key-row--highlight) {
background: #e6f4ff;
}
.overview-col-title {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.overview-col-title__icon {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
cursor: help;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
/** 登录 + 空库三态分流(交互登录篇) */
import type { FormInstance } from "antdv-next";
import { computed, reactive, ref, shallowRef } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useLogin, useSetup, useSetupState } from "@/queries/session.ts";
const route = useRoute();
const router = useRouter();
const setupState = useSetupState();
const login = useLogin();
const setup = useSetup();
const formRef = shallowRef<FormInstance>();
const model = reactive({
username: "",
password: "",
passwordConfirm: "",
});
const error = ref("");
const isSetup = computed(() => setupState.data.value?.initialized === false);
const loading = computed(() => login.isPending.value || setup.isPending.value);
async function onFinish() {
error.value = "";
try {
if (isSetup.value) {
if (model.password !== model.passwordConfirm) {
error.value = "两次输入的密码不一致";
return;
}
await setup.mutateAsync({
username: model.username,
password: model.password,
});
} else {
await login.mutateAsync({
username: model.username,
password: model.password,
});
}
const redirect = route.query.redirect;
void router.replace(typeof redirect === "string" ? redirect : "/admin/overview");
} catch (e) {
error.value = e instanceof Error ? e.message : "登录失败";
}
}
</script>
<template>
<div class="login-shell">
<a-card class="login-shell__card" :title="isSetup ? '初始化管理员' : '管理面登录'">
<a-form
ref="formRef"
:model="model"
layout="vertical"
auto-complete="off"
@finish="onFinish"
>
<a-form-item
name="username"
label="用户名"
:rules="[{ required: true, message: '请输入用户名' }]"
>
<a-input v-model:value="model.username" autocomplete="username" />
</a-form-item>
<a-form-item
name="password"
label="密码"
:rules="[{ required: true, message: '请输入密码' }]"
>
<a-input-password
v-model:value="model.password"
autocomplete="current-password"
/>
</a-form-item>
<a-form-item
v-if="isSetup"
name="passwordConfirm"
label="确认密码"
:rules="[{ required: true, message: '请再次输入密码' }]"
>
<a-input-password v-model:value="model.passwordConfirm" />
</a-form-item>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
style="margin-bottom: 16px"
/>
<a-button type="primary" html-type="submit" block :loading="loading">
{{ isSetup ? "完成设置" : "登录" }}
</a-button>
</a-form>
</a-card>
</div>
</template>
<style scoped>
.login-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f0f2f5;
padding: 24px;
}
.login-shell__card {
width: 380px;
}
</style>
+617
View File
@@ -0,0 +1,617 @@
<script setup lang="ts">
/**
* 管理面调用日志(交互设计-调用日志):筛选 + 明细表 + 与 key/成员跳转
*/
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import type { LogItem, SubscriptionItem } from "@new-api-key-usage/core";
import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import LogsSummaryBar from "@/components/common/logs-summary-bar.vue";
import {
useLiveTimeRange,
type RangeKind,
} from "@/composables/use-live-time-range.ts";
import { useAdminLogs, useKeys, useMembers, useSubscriptions } from "@/queries/admin.ts";
import { formatDateTime } from "@/utils/time.ts";
import { formatInt, formatPaidAmount } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
const { range, setRange } = useLiveTimeRange("recent-30");
const memberId = ref<number | "unassigned" | null>(null);
const apiKeyId = ref<number | null>(null);
const modelFilter = ref<string | null>(null);
const billingSource = ref<string | null>(null);
const subscriptionId = ref<number | null>(null);
const typeFilter = ref<number | "error" | null>(null);
const page = ref(1);
const pageSize = ref(50);
const membersQuery = useMembers();
const keysQuery = useKeys(() => ({}));
const keyById = computed(() => {
const m = new Map<number, { token_name: string; member_id: number | null; member_name: string | null }>();
for (const k of keysQuery.data.value ?? []) {
m.set(k.id, {
token_name: k.token_name,
member_id: k.member_id,
member_name: k.member_name,
});
}
return m;
});
const params = computed(() => ({
...range.value,
page: page.value,
page_size: pageSize.value,
api_key_id: apiKeyId.value ?? undefined,
member_id: memberId.value ?? undefined,
model_name: modelFilter.value ?? undefined,
billing_source: billingSource.value ?? undefined,
subscription_id: subscriptionId.value ?? undefined,
type: typeFilter.value ?? undefined,
}));
const logsQuery = useAdminLogs(() => params.value);
const rows = computed(() => logsQuery.data.value?.items ?? []);
const total = computed(() => logsQuery.data.value?.total ?? 0);
const summary = computed(() => logsQuery.data.value?.summary);
const modelOptions = computed(() => [
{ value: null, label: "全部模型" },
...(logsQuery.data.value?.models ?? []).map((m) => ({ value: m, label: m })),
]);
const memberOptions = computed(() => [
{ value: null, label: "全部成员" },
{ value: "unassigned", label: "未归属" },
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
]);
const keyOptions = computed(() => [
{ value: null, label: "全部 Key" },
...(keysQuery.data.value ?? []).map((k) => ({
value: k.id,
label: k.token_name,
})),
]);
const columns = [
{ title: "时间", key: "created_at", width: 150 },
{ title: "Key", key: "token_name", width: 120 },
{ title: "归属", key: "member_name", width: 110 },
{ title: "模型", key: "model_name", dataIndex: "model_name", width: 110 },
{ title: "类型", key: "type", width: 80 },
{ title: "来源", key: "source", width: 110 },
{ title: "用量", key: "usage", width: 140 },
{ title: "Tokens", key: "tokens", width: 110 },
{ title: "操作", key: "actions", width: 72 },
];
const detailOpen = ref(false);
const detailRecord = ref<LogItem | null>(null);
function openDetail(record: LogItem) {
detailRecord.value = record;
detailOpen.value = true;
}
function formatRaw(record: LogItem | null): string {
if (!record) return "";
return JSON.stringify(record.raw ?? record, null, 2);
}
function syncFromRoute() {
const q = route.query;
const m = q.member_id;
if (m === "unassigned") memberId.value = "unassigned";
else if (typeof m === "string" && m) memberId.value = Number(m);
const k = q.api_key_id ?? q.key_id;
if (typeof k === "string" && k) apiKeyId.value = Number(k);
modelFilter.value =
typeof q.model_name === "string" && q.model_name ? q.model_name : null;
if (typeof q.billing_source === "string" && q.billing_source) {
billingSource.value = q.billing_source;
}
const s = q.subscription_id;
if (typeof s === "string" && s) subscriptionId.value = Number(s);
const t = q.type;
if (t === "2") typeFilter.value = 2;
else if (t === "error") typeFilter.value = "error";
else typeFilter.value = null;
const p = q.page;
if (typeof p === "string" && p) page.value = Number(p) || 1;
const from = q.from;
const to = q.to;
if (typeof from === "string" && from && typeof to === "string" && to) {
setRange({ from, to }, "fixed");
}
}
function pushQuery() {
const query: Record<string, string> = {
from: range.value.from,
to: range.value.to,
};
if (memberId.value === "unassigned") query.member_id = "unassigned";
else if (memberId.value != null) query.member_id = String(memberId.value);
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
if (modelFilter.value) query.model_name = modelFilter.value;
if (billingSource.value) query.billing_source = billingSource.value;
if (subscriptionId.value != null) {
query.subscription_id = String(subscriptionId.value);
}
if (typeFilter.value) query.type = String(typeFilter.value);
if (page.value > 1) query.page = String(page.value);
void router.replace({ path: "/admin/logs", query });
}
syncFromRoute();
watch(
() => route.query,
() => syncFromRoute(),
);
watch(
[memberId, apiKeyId, modelFilter, billingSource, subscriptionId, typeFilter],
() => {
page.value = 1;
pushQuery();
},
);
watch(page, () => pushQuery());
function sourceBadge(record: LogItem) {
if (record.billing_source === "subscription" && record.subscription_id != null) {
return {
text: `订阅 #${record.subscription_id}`,
color: "blue" as const,
kind: "sub" as const,
id: record.subscription_id,
};
}
if (record.billing_source === "wallet") {
return { text: "充值", color: "gold" as const, kind: "wallet" as const, id: null };
}
return { text: "未关联", color: undefined, kind: "none" as const, id: null };
}
const payModalOpen = ref(false);
const payModalSubId = ref<number | null>(null);
const subsQuery = useSubscriptions();
const paymentBySub = computed(() => {
const m = new Map<number, SubscriptionItem>();
for (const p of subsQuery.data.value ?? []) {
m.set(p.subscription_id, p);
}
return m;
});
const payModalPayment = computed(() =>
payModalSubId.value == null
? undefined
: paymentBySub.value.get(payModalSubId.value),
);
function onBadge(record: LogItem, e: MouseEvent) {
e.stopPropagation();
if (sourceBadge(record).kind !== "sub") return;
payModalSubId.value = sourceBadge(record).id;
payModalOpen.value = true;
}
function periodText(p: SubscriptionItem): string {
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${formatDateTime(p.period_start)} ~ ${end}`;
}
function goPayments() {
if (payModalSubId.value == null) return;
payModalOpen.value = false;
void router.push({
path: "/admin/payments",
query: { tab: "subs", subscription_id: String(payModalSubId.value) },
});
}
function goKey(keyId: number) {
void router.push({ path: "/admin/keys", query: { key_id: String(keyId) } });
}
function goMember(memberId: number | null) {
if (memberId == null) return;
void router.push({
path: "/admin/members",
query: { member_id: String(memberId) },
});
}
let ro: ResizeObserver | null = null;
function onRangeChange(
v: { from: string; to: string },
k: RangeKind,
) {
setRange(v, k);
page.value = 1;
pushQuery();
}
function onPageChange(p: number, ps: number) {
page.value = p;
pageSize.value = ps;
}
const boxRef = ref<HTMLElement | null>(null);
const bodyY = ref(480);
const CHROME = 96;
function measure() {
const h = boxRef.value?.clientHeight ?? 0;
if (h > 0) bodyY.value = Math.max(200, h - CHROME);
}
onMounted(() => {
measure();
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
ro = new ResizeObserver(() => measure());
ro.observe(boxRef.value);
}
window.addEventListener("resize", measure);
});
onUnmounted(() => {
ro?.disconnect();
window.removeEventListener("resize", measure);
});
</script>
<template>
<section class="call-logs">
<PageHeader title="调用日志" subtitle="全池模型请求查询与筛选">
<template #actions>
<span class="call-logs__label">时间范围</span>
<TimeRangePicker
:model-value="range"
@change="onRangeChange"
/>
</template>
</PageHeader>
<div class="call-logs__filters">
<a-select
v-model:value="memberId"
:options="memberOptions"
style="width: 140px"
allow-clear
placeholder="成员"
/>
<a-select
v-model:value="apiKeyId"
:options="keyOptions"
style="width: 160px"
allow-clear
placeholder="Key"
show-search
option-filter-prop="label"
/>
<a-select
v-model:value="modelFilter"
:options="modelOptions"
style="width: 140px"
allow-clear
placeholder="模型"
show-search
option-filter-prop="label"
/>
<a-select
v-model:value="billingSource"
:options="[
{ value: null, label: '全部来源' },
{ value: 'subscription', label: '订阅' },
{ value: 'wallet', label: '充值' },
{ value: 'none', label: '未关联' },
]"
style="width: 120px"
allow-clear
placeholder="来源"
/>
<a-input-number
v-model:value="subscriptionId"
:min="1"
:precision="0"
style="width: 120px"
placeholder="订阅 ID"
/>
<a-select
v-model:value="typeFilter"
:options="[
{ value: null, label: '全部类型' },
{ value: 2, label: '消费' },
{ value: 'error', label: '错误' },
]"
style="width: 120px"
placeholder="类型"
/>
</div>
<LogsSummaryBar
:hit-count="summary?.hit_count ?? 0"
:usage-count="summary?.usage_count ?? 0"
:error-count="summary?.error_count ?? 0"
:quota="summary?.quota ?? 0"
:quota-usd="summary?.quota_usd ?? '0.00'"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
/>
<TableRefresh
:loading="logsQuery.isFetching.value"
@refresh="() => logsQuery.refetch()"
/>
<a-empty
v-if="!logsQuery.isPending.value && rows.length === 0"
description="该时段内无调用日志"
/>
<div v-else ref="boxRef" class="call-logs__box">
<a-table
:columns="columns"
:data-source="rows"
:row-key="(r: LogItem) => r.id"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (t: number) => `共 ${t} 条`,
onChange: onPageChange,
}"
size="middle"
:scroll="{ y: bodyY }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'token_name'">
<a @click="goKey(record.api_key_id)">{{ record.token_name }}</a>
</template>
<template v-else-if="column.key === 'member_name'">
<a
v-if="keyById.get(record.api_key_id)?.member_id != null"
@click="goMember(keyById.get(record.api_key_id)!.member_id)"
>
{{ keyById.get(record.api_key_id)?.member_name ?? "成员" }}
</a>
<span v-else>未归属</span>
</template>
<template v-else-if="column.key === 'type'">
<a-tag :color="record.type === 2 ? 'blue' : 'red'">
{{ record.type === 2 ? "消费" : "错误" }}
</a-tag>
</template>
<template v-else-if="column.key === 'source'">
<a-tooltip
v-if="sourceBadge(record).kind === 'sub'"
title="点击查看该订阅支付参数"
>
<a-tag
color="blue"
class="call-logs__badge"
@click="(e: MouseEvent) => onBadge(record, e)"
>
{{ sourceBadge(record).text }}
</a-tag>
</a-tooltip>
<a-tag v-else :color="sourceBadge(record).color">
{{ sourceBadge(record).text }}
</a-tag>
</template>
<template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.quota" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'tokens'">
{{ formatInt(record.prompt_tokens) }} / {{ formatInt(record.completion_tokens) }}
</template>
<template v-else-if="column.key === 'actions'">
<a @click="openDetail(record)">详情</a>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="detailOpen"
title="调用详情"
:footer="null"
width="720"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
>
<div v-if="detailRecord" class="call-logs__detail">
<div class="call-logs__kv">
<span class="call-logs__note">时间</span>
<span>{{ formatDateTime(detailRecord.created_at) }}</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">Key / 归属</span>
<span>
{{ detailRecord.token_name }}
·
{{ keyById.get(detailRecord.api_key_id)?.member_name ?? "未归属" }}
</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">模型 / 类型</span>
<span>
{{ detailRecord.model_name }}
·
{{ detailRecord.type === 2 ? "消费" : "错误" }}
</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">用量</span>
<QuotaText :quota="detailRecord.quota" :quota-usd="detailRecord.quota_usd" />
</div>
<div class="call-logs__kv">
<span class="call-logs__note">Tokens</span>
<span class="call-logs__num">
提示 {{ formatInt(detailRecord.prompt_tokens) }} · 补全 {{ formatInt(detailRecord.completion_tokens) }}
</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">耗时</span>
<span class="call-logs__num">
{{ detailRecord.use_time }}s · {{ detailRecord.is_stream ? "流式" : "非流式" }}
</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">来源</span>
<a-tag :color="sourceBadge(detailRecord).color">
{{ sourceBadge(detailRecord).text }}
</a-tag>
</div>
<div v-if="detailRecord.content" class="call-logs__kv">
<span class="call-logs__note">内容</span>
<span class="call-logs__content">{{ detailRecord.content }}</span>
</div>
<div class="call-logs__raw">
<div class="call-logs__note">原始数据</div>
<pre class="call-logs__json">{{ formatRaw(detailRecord) }}</pre>
</div>
</div>
</a-modal>
<a-modal
v-model:open="payModalOpen"
:title="payModalSubId != null ? `订阅 #${payModalSubId} 支付参数` : '支付参数'"
:footer="null"
width="480"
centered
:styles="{ body: { maxHeight: '56vh', overflowY: 'auto' } }"
>
<div v-if="payModalPayment" class="call-logs__pay">
<div class="call-logs__kv">
<span class="call-logs__note">期间</span>
<span>{{ periodText(payModalPayment) }}</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">额度</span>
<QuotaText
:quota="payModalPayment.quota_granted"
:quota-usd="payModalPayment.quota_usd"
/>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">金额</span>
<span class="call-logs__num">{{ formatPaidAmount(payModalPayment.paid_amount) }}</span>
</div>
<div v-if="payModalPayment.remark" class="call-logs__kv">
<span class="call-logs__note">备注</span>
<span>{{ payModalPayment.remark }}</span>
</div>
</div>
<div v-else class="call-logs__note">该订阅无登记信息</div>
<div class="call-logs__foot">
<a @click="goPayments">在支付记录中查看</a>
</div>
</a-modal>
</section>
</template>
<style scoped>
.call-logs {
height: calc(100vh - 64px - 48px - 48px);
display: flex;
flex-direction: column;
min-height: 0;
}
.call-logs__label {
font-size: 13px;
line-height: 32px;
color: rgba(0, 0, 0, 0.65);
}
.call-logs__filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.call-logs__box {
flex: 1;
min-height: 0;
}
.call-logs__detail {
display: flex;
flex-direction: column;
gap: 12px;
}
.call-logs__kv {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
}
.call-logs__note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
min-width: 72px;
flex-shrink: 0;
}
.call-logs__num {
font-variant-numeric: tabular-nums;
}
.call-logs__content {
white-space: pre-wrap;
word-break: break-all;
flex: 1;
min-width: 0;
}
.call-logs__raw {
margin-top: 4px;
}
.call-logs__json {
margin: 4px 0 0;
padding: 12px;
max-height: 360px;
overflow: auto;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
font-size: 12px;
line-height: 20px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
}
.call-logs__badge {
cursor: pointer;
}
.call-logs__pay {
display: flex;
flex-direction: column;
gap: 12px;
}
.call-logs__foot {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
text-align: right;
}
</style>
+399
View File
@@ -0,0 +1,399 @@
<script setup lang="ts">
/**
* 成员管理(交互设计-成员管理):清单表 · 开户 · 档案修改 · 令牌明文/重发 · 启停 · 空壳删除
*/
import { computed, h, reactive, ref, watch } from "vue";
import { Tooltip, message } from "antdv-next";
import { QuestionCircleOutlined } from "@antdv-next/icons";
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
import type { MemberItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
import {
useCreateMember,
useDeleteMember,
useIssueMemberToken,
useMembers,
usePatchMember,
} from "@/queries/admin.ts";
import { formatMinute } from "@/utils/time.ts";
const membersQuery = useMembers();
const createMember = useCreateMember();
const patchMember = usePatchMember();
const deleteMember = useDeleteMember();
const issueToken = useIssueMemberToken();
const createOpen = ref(false);
const editOpen = ref(false);
const tokenOpen = ref(false);
const plainToken = ref("");
const tokenMemberName = ref("");
const createForm = reactive({ name: "", remark: "" });
const createError = ref("");
const editForm = reactive({ id: 0, name: "", remark: "" });
const editError = ref("");
const filterName = ref("");
const filterStatus = ref<"active" | "disabled" | null>(null);
function syncFiltersFromRoute() {
const q = route.query;
filterName.value = typeof q.name === "string" ? q.name : "";
filterStatus.value =
q.status === "active" || q.status === "disabled" ? q.status : null;
}
function pushFilterQuery() {
const query: Record<string, string> = {};
const mid = route.query.member_id;
if (typeof mid === "string" && mid) query.member_id = mid;
if (filterName.value.trim()) query.name = filterName.value.trim();
if (filterStatus.value) query.status = filterStatus.value;
void router.replace({ path: "/admin/members", query });
}
syncFiltersFromRoute();
watch(() => route.query, () => syncFiltersFromRoute());
watch([filterName, filterStatus], () => pushFilterQuery());
function tipTitle(label: string, tip: string) {
return h("span", { class: "overview-col-title" }, [
label,
h(
Tooltip,
{ title: tip },
() =>
h(QuestionCircleOutlined, {
class: "overview-col-title__icon",
"aria-hidden": "true",
}),
),
]);
}
const columns = [
{ title: "名称", key: "name", dataIndex: "name", width: 140 },
{ title: "备注", key: "remark" },
{ title: "状态", key: "status", width: 100 },
{
title: tipTitle("令牌签发", "明文已不可找回,如需新令牌请重发"),
key: "token_issued_at",
width: 170,
},
{ title: "名下 Key 数", key: "key_count", width: 110 },
{ title: "操作", key: "actions", width: 280 },
];
const rows = computed(() => {
let list = membersQuery.data.value ?? [];
const mid = route.query.member_id;
if (typeof mid === "string" && mid) {
const n = Number(mid);
list = list.filter((m) => m.id === n);
}
const name = filterName.value.trim();
if (name) {
const lower = name.toLowerCase();
list = list.filter(
(m) =>
m.name.toLowerCase().includes(lower) ||
(m.remark ?? "").toLowerCase().includes(lower),
);
}
if (filterStatus.value) {
list = list.filter((m) => m.status === filterStatus.value);
}
return list;
});
const hasFilter = computed(
() =>
!!filterName.value.trim() ||
filterStatus.value != null ||
(typeof route.query.member_id === "string" && !!route.query.member_id),
);
async function onCreate() {
createError.value = "";
const name = createForm.name.trim();
if (!name) {
createError.value = "请填写成员名称";
return;
}
try {
const res = await createMember.mutateAsync({
name,
remark: createForm.remark.trim(),
});
createOpen.value = false;
createForm.name = "";
createForm.remark = "";
plainToken.value = res.token;
tokenMemberName.value = res.member.name;
tokenOpen.value = true;
} catch (e) {
const err = e as ApiError;
createError.value = err.message || "开户失败";
}
}
async function onReissue(record: MemberItem) {
try {
const res = await issueToken.mutateAsync(record.id);
plainToken.value = res.token;
tokenMemberName.value = record.name;
tokenOpen.value = true;
} catch (e) {
message.error((e as ApiError).message || "重发失败");
}
}
async function onToggle(record: MemberItem) {
const next = record.status === "active" ? "disabled" : "active";
try {
await patchMember.mutateAsync({
id: record.id,
body: { status: next },
});
message.success(
next === "disabled"
? "已停用——该成员视图立即不可访问,数据保留"
: "已启用——下一请求即恢复",
);
} catch (e) {
message.error((e as ApiError).message || "状态更新失败");
}
}
function openEdit(record: MemberItem) {
editForm.id = record.id;
editForm.name = record.name;
editForm.remark = record.remark ?? "";
editError.value = "";
editOpen.value = true;
}
async function onEdit() {
editError.value = "";
const name = editForm.name.trim();
if (!name) {
editError.value = "请填写成员名称";
return;
}
try {
await patchMember.mutateAsync({
id: editForm.id,
body: { name, remark: editForm.remark },
});
editOpen.value = false;
message.success("已保存");
} catch (e) {
const err = e as ApiError;
editError.value = err.message || "保存失败";
}
}
async function onDelete(record: MemberItem) {
try {
await deleteMember.mutateAsync(record.id);
message.success("已删除——仅清理未挂 key 的空壳");
} catch (e) {
message.error((e as ApiError).message || "删除失败");
}
}
</script>
<template>
<section>
<PageHeader title="成员管理" subtitle="开户、档案修改、访问令牌与启停">
<template #actions>
<a-button type="primary" @click="createOpen = true">新建成员</a-button>
</template>
</PageHeader>
<div class="members-filters">
<a-input
v-model:value="filterName"
allow-clear
placeholder="名称 / 备注"
style="width: 180px"
/>
<a-select
v-model:value="filterStatus"
:options="[
{ value: null, label: '全部状态' },
{ value: 'active', label: '启用中' },
{ value: 'disabled', label: '已停用' },
]"
style="width: 120px"
allow-clear
placeholder="状态"
/>
</div>
<TableRefresh
:loading="membersQuery.isFetching.value"
@refresh="() => membersQuery.refetch()"
/>
<a-empty
v-if="!membersQuery.isPending.value && rows.length === 0"
:description="hasFilter ? '没有符合筛选的成员' : '还没有成员——为每位分摊朋友开户并发放访问令牌'"
>
<a-button v-if="!hasFilter" @click="createOpen = true">新建成员</a-button>
</a-empty>
<a-table
v-else
:columns="columns"
:data-source="rows"
:row-key="(r: MemberItem) => r.id"
:loading="membersQuery.isPending.value || membersQuery.isFetching.value"
:pagination="false"
size="middle"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
<template v-else-if="column.key === 'status'">
<a-badge
:status="record.status === 'active' ? 'success' : 'default'"
:text="record.status === 'active' ? '启用中' : '已停用'"
/>
</template>
<template v-else-if="column.key === 'token_issued_at'">
{{ formatMinute(record.token_issued_at) }}
</template>
<template v-else-if="column.key === 'key_count'">
{{ record.key_count }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button
size="small"
@click="() => router.push({ path: '/admin/logs', query: { member_id: String(record.id) } })"
>
查调用
</a-button>
<a-button size="small" @click="() => openEdit(record)">编辑</a-button>
<a-popconfirm
title="重发后旧令牌立即失效"
description="该成员正在用的访问将中断"
ok-text="重发"
cancel-text="取消"
@confirm="() => onReissue(record)"
>
<a-button size="small">重发令牌</a-button>
</a-popconfirm>
<a-popconfirm
v-if="record.status === 'active'"
title="停用后该成员的视图立即不可访问"
description="令牌验证含状态检查;数据保留"
ok-text="停用"
cancel-text="取消"
@confirm="() => onToggle(record)"
>
<a-button size="small" danger>停用</a-button>
</a-popconfirm>
<a-button v-else size="small" @click="() => onToggle(record)">
启用
</a-button>
<a-tooltip
v-if="record.key_count > 0"
title="名下仍有 key,只能停用"
>
<a-button size="small" danger disabled>删除</a-button>
</a-tooltip>
<a-popconfirm
v-else
title="删除后不可恢复(名称与令牌一并作废)"
description="仅清理未挂 key 的空壳"
ok-text="删除"
cancel-text="取消"
@confirm="() => onDelete(record)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<a-modal
v-model:open="editOpen"
title="编辑成员"
:confirm-loading="patchMember.isPending.value"
ok-text="保存"
cancel-text="取消"
@ok="onEdit"
>
<a-form layout="vertical">
<a-form-item label="名称" required>
<a-input v-model:value="editForm.name" placeholder="成员名称" />
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="editForm.remark" placeholder="备注(可选)" />
</a-form-item>
<a-alert v-if="editError" type="error" show-icon :message="editError" />
</a-form>
</a-modal>
<a-modal
v-model:open="createOpen"
title="新建成员"
:confirm-loading="createMember.isPending.value"
ok-text="开户并签发令牌"
cancel-text="取消"
@ok="onCreate"
>
<a-form layout="vertical">
<a-form-item label="名称" required>
<a-input v-model:value="createForm.name" placeholder="成员名称" />
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="createForm.remark" placeholder="备注(可选)" />
</a-form-item>
<a-alert v-if="createError" type="error" show-icon :message="createError" />
</a-form>
</a-modal>
<MemberTokenModal
v-model:open="tokenOpen"
:token="plainToken"
:member-name="tokenMemberName"
@update:open="(v: boolean) => { if (!v) { plainToken = ''; tokenMemberName = ''; } }"
/>
</section>
</template>
<style scoped>
.members-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.overview-col-title {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.overview-col-title__icon {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
cursor: help;
}
</style>
+356
View File
@@ -0,0 +1,356 @@
<script setup lang="ts">
/**
* 全员总览(交互设计-全员总览):健康带 · 时间范围 · 按成员 · 按 key
*/
import { computed, h } from "vue";
import { Tooltip } from "antdv-next";
import { QuestionCircleOutlined } from "@antdv-next/icons";
import { useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import SourceBar from "@/components/common/source-bar.vue";
import HealthBand from "@/components/admin/health-band.vue";
import {
useLiveTimeRange,
type RangeKind,
type RangeValue,
} from "@/composables/use-live-time-range.ts";
import { useAdminOverview, useEngine, useSubscriptions } from "@/queries/admin.ts";
import { formatMinute, toRequestIso } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts";
const router = useRouter();
const { range, setRange } = useLiveTimeRange("recent-30");
const overviewQuery = useAdminOverview(() => range.value);
const engineQuery = useEngine();
const paymentsQuery = useSubscriptions();
const overview = computed(() => overviewQuery.data.value);
const engine = computed(() => engineQuery.data.value);
const loading = computed(
() => overviewQuery.isPending.value || engineQuery.isPending.value,
);
/** 「本订阅期间」等预置:有 period 的订阅记录才出现(纯用量模式自动隐藏) */
const extraPresets = computed(() => {
const list = paymentsQuery.data.value ?? [];
return list
.filter((p) => p.period_start != null)
.map((p) => ({
label: `订阅 #${p.subscription_id} 期间`,
from: toRequestIso(new Date((p.period_start ?? 0) * 1000)),
to: toRequestIso(
new Date((p.period_end ?? Math.floor(Date.now() / 1000)) * 1000),
),
}))
.slice(0, 4);
});
const byMember = computed(() => overview.value?.by_member ?? []);
const byKey = computed(() => overview.value?.by_key ?? []);
const bySource = computed(() => overview.value?.by_source ?? []);
const totalUsage = computed(() => {
const rows = byMember.value;
const count = rows.reduce((s, r) => s + r.usage.count, 0);
const quota = rows.reduce((s, r) => s + r.usage.quota, 0);
// 比例展示用 quota;美元仅示意合计(后端已逐行换算,这里加总展示仅供参考)
return { count, quota };
});
function memberPercent(quota: number): number {
const total = totalUsage.value.quota;
return total > 0 ? Math.round((quota / total) * 1000) / 10 : 0;
}
function onRangeChange(value: RangeValue, kind: RangeKind) {
setRange(value, kind);
}
function onMemberClick(memberId: number | null) {
void router.push({
path: "/admin/keys",
query: memberId == null ? { member_id: "unassigned" } : { member_id: String(memberId) },
});
}
function onKeyClick(keyId: number) {
void router.push({
path: "/admin/logs",
query: { api_key_id: String(keyId), ...range.value },
});
}
function onMemberLogs(memberId: number | null) {
void router.push({
path: "/admin/logs",
query: {
...(memberId == null
? { member_id: "unassigned" }
: { member_id: String(memberId) }),
...range.value,
},
});
}
function goFailedKeys() {
void router.push({ path: "/admin/keys", query: { recent_failed: "1" } });
}
/** 表头:标题 + 问号说明 */
function tipTitle(label: string, tip: string) {
return h("span", { class: "overview-col-title" }, [
label,
h(
Tooltip,
{ title: tip },
() =>
h(QuestionCircleOutlined, {
class: "overview-col-title__icon",
"aria-hidden": "true",
}),
),
]);
}
const memberColumns = [
{ title: "成员", key: "member_name" },
{ title: "名下 Key 数", key: "key_count" },
{
title: tipTitle(
"调用次数",
"本时段内该成员名下的消费日志条数(type=2,一次调用一行)",
),
key: "count",
},
{
title: tipTitle(
"额度消耗",
"本时段消耗的 quota 原值,括号内为按当时换算率折算的美元",
),
key: "usage",
},
{
title: tipTitle(
"用量占比",
"该成员本时段 quota ÷ 全员同期 quota。只是用量事实占比,不是费用分摊",
),
key: "share",
},
{
title: tipTitle(
"同步截止",
"该成员名下各 key 的 log_synced_at 里最慢的一刻(北京时间)",
),
key: "data_cutoff",
},
{ title: "操作", key: "actions", width: 100 },
];
const keyColumns = [
{ title: "Key 名称", dataIndex: "token_name", key: "token_name" },
{ title: "归属成员", key: "member_name" },
{
title: tipTitle("当前状态", "启用中才会进入同步轮询;已停用仍可查历史"),
key: "status",
},
{
title: tipTitle("同步截止", "该枚 key 最近一次成功写入日志的同步时刻"),
key: "data_cutoff",
},
{
title: tipTitle("调用次数", "本时段消费日志条数(type=2)"),
key: "count",
},
{
title: tipTitle("额度消耗", "本时段 quota 原值与按当时换算率折算的美元"),
key: "usage",
},
];
</script>
<template>
<section>
<PageHeader
title="全员总览"
subtitle="健康与用量全景 · 仅统计消费行(type=2)"
class="overview-header"
>
<template #actions>
<span class="overview-range-label">时间范围</span>
<TimeRangePicker
:model-value="range"
:extra-presets="extraPresets"
@change="onRangeChange"
/>
</template>
</PageHeader>
<HealthBand
:engine="engine"
:overview="overview"
:loading="loading"
@go-failed-keys="goFailedKeys"
/>
<a-alert
v-if="overviewQuery.isError.value"
type="error"
show-icon
message="总览数据加载失败"
:description="overviewQuery.error.value?.message"
style="margin-bottom: 16px"
>
<template #action>
<a-button size="small" @click="() => overviewQuery.refetch()">重试</a-button>
</template>
</a-alert>
<!-- 来源分解 -->
<section class="overview-block">
<h2 class="overview-block__title">按来源分解</h2>
<SourceBar :rows="bySource" />
</section>
<!-- 按成员 -->
<section class="overview-block">
<h2 class="overview-block__title">按成员汇总</h2>
<TableRefresh
:loading="overviewQuery.isFetching.value"
@refresh="() => overviewQuery.refetch()"
/>
<a-empty
v-if="!loading && byMember.length === 0"
description="本时段内没有任何消费调用,因此没有成员用量可汇总"
/>
<a-table
v-else
:columns="memberColumns"
:data-source="byMember"
:row-key="(r: { member_id: number | null }) => r.member_id ?? 'unassigned'"
size="middle"
:pagination="false"
:scroll="{ x: 900 }"
:loading="overviewQuery.isFetching.value"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'member_name'">
<a @click="onMemberClick(record.member_id)">
{{ record.member_name ?? "未归属" }}
</a>
</template>
<template v-else-if="column.key === 'key_count'">
{{ record.key_count }}
</template>
<template v-else-if="column.key === 'count'">
{{ formatInt(record.usage.count) }}
</template>
<template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.usage.quota" :quota-usd="record.usage.quota_usd" />
</template>
<template v-else-if="column.key === 'share'">
<span class="overview-share-cell__num">
{{ memberPercent(record.usage.quota) }}%
</span>
</template>
<template v-else-if="column.key === 'data_cutoff'">
{{ formatMinute(record.data_cutoff) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-button
size="small"
@click="() => onMemberLogs(record.member_id)"
>
查调用
</a-button>
</template>
</template>
</a-table>
</section>
<!-- 按 key -->
<section class="overview-block">
<h2 class="overview-block__title">按 key 明细</h2>
<a-table
:columns="keyColumns"
:data-source="byKey"
:row-key="(r: { key_id: number }) => r.key_id"
size="middle"
:pagination="false"
:scroll="{ x: 900 }"
:loading="overviewQuery.isFetching.value"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'member_name'">
{{
record.member_name ??
(record.member_id == null
? "未归属"
: `成员 #${record.member_id}`)
}}
</template>
<template v-else-if="column.key === 'status'">
<a-badge
:status="record.status === 'active' ? 'success' : 'default'"
:text="record.status === 'active' ? '启用中' : '已停用'"
/>
</template>
<template v-else-if="column.key === 'data_cutoff'">
{{ formatMinute(record.data_cutoff) }}
</template>
<template v-else-if="column.key === 'count'">
{{ formatInt(record.usage.count) }}
</template>
<template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.usage.quota" :quota-usd="record.usage.quota_usd" />
</template>
<template v-else-if="column.key === 'token_name'">
<a @click="onKeyClick(record.key_id)">{{ record.token_name }}</a>
</template>
</template>
</a-table>
</section>
</section>
</template>
<style scoped>
.overview-range-label {
font-size: 13px;
line-height: 32px;
color: rgba(0, 0, 0, 0.65);
flex-shrink: 0;
}
.overview-block {
margin-bottom: 32px;
}
.overview-block__title {
margin: 0 0 16px;
font-size: 14px;
font-weight: 600;
line-height: 22px;
color: rgba(0, 0, 0, 0.88);
}
.overview-col-title {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.overview-col-title__icon {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
cursor: help;
}
.overview-share-cell__num {
font-variant-numeric: tabular-nums;
color: rgba(0, 0, 0, 0.88);
}
</style>
+464
View File
@@ -0,0 +1,464 @@
<script setup lang="ts">
/**
* 支付记录(交互设计-支付记录):订阅 / 充值两 Tab · 核对提示
*/
import { computed, ref, watch } from "vue";
import { message } from "antdv-next";
import type { SubscriptionItem, TopupItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import { fetchLogs } from "@/api/admin/logs.ts";
import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import PaymentFormModal from "@/components/admin/payment-form-modal.vue";
import QuotaText from "@/components/common/quota-text.vue";
import {
useDeleteSubscription,
useDeleteTopup,
useSubscriptions,
useTopups,
} from "@/queries/admin.ts";
import { formatDateTime, toRequestIso } from "@/utils/time.ts";
import { formatPaidAmount } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
const subsQuery = useSubscriptions();
const topupsQuery = useTopups();
const filterDomain = ref<string | "none" | null>(null);
const filterSubId = ref<number | null>(null);
const filterTradeNo = ref<string | null>(null);
const domainOptions = computed(() => {
const set = new Set<string>();
for (const s of subsQuery.data.value ?? []) {
if (s.billing_domain) set.add(s.billing_domain);
}
for (const t of topupsQuery.data.value ?? []) {
if (t.billing_domain) set.add(t.billing_domain);
}
return [...set].sort().map((v) => ({ value: v, label: v }));
});
const filteredSubs = computed(() => {
let list = subsQuery.data.value ?? [];
if (filterSubId.value != null) {
list = list.filter((r) => r.subscription_id === filterSubId.value);
}
if (!filterDomain.value) return list;
if (filterDomain.value === "none") {
return list.filter((r) => r.billing_domain == null);
}
return list.filter((r) => r.billing_domain === filterDomain.value);
});
const filteredTops = computed(() => {
let list = topupsQuery.data.value ?? [];
if (filterTradeNo.value) {
const q = filterTradeNo.value.trim().toLowerCase();
list = list.filter(
(r) =>
(r.trade_no ?? "").toLowerCase().includes(q) ||
(r.upstream_id != null && String(r.upstream_id).includes(q)),
);
}
if (!filterDomain.value) return list;
if (filterDomain.value === "none") {
return list.filter((r) => r.billing_domain == null);
}
return list.filter((r) => r.billing_domain === filterDomain.value);
});
const deleteSub = useDeleteSubscription();
const deleteTop = useDeleteTopup();
const tab = ref<"subs" | "topups">("subs");
const formOpen = ref(false);
const editingSub = ref<SubscriptionItem | null>(null);
const editingTop = ref<TopupItem | null>(null);
const missingSubs = ref<number[]>([]);
const checking = ref(false);
function syncFromRoute() {
tab.value = route.query.tab === "topups" ? "topups" : "subs";
const sid = route.query.subscription_id;
filterSubId.value = typeof sid === "string" && sid ? Number(sid) || null : null;
const tn = route.query.trade_no;
filterTradeNo.value = typeof tn === "string" && tn ? tn : null;
}
function pushQuery() {
const query: Record<string, string> = {};
if (tab.value === "topups") query.tab = "topups";
if (filterSubId.value != null) query.subscription_id = String(filterSubId.value);
if (filterTradeNo.value) query.trade_no = filterTradeNo.value;
void router.replace({ path: "/admin/payments", query });
}
syncFromRoute();
watch(() => route.query, () => syncFromRoute());
watch([tab, filterSubId, filterTradeNo], () => pushQuery());
const subRows = computed(() => [...filteredSubs.value]);
const topRows = computed(() => {
const list = [...filteredTops.value];
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
return list;
});
const subColumns = [
{ title: "订阅 ID", dataIndex: "subscription_id", key: "subscription_id", width: 100 },
{ title: "期间", key: "period", width: 200 },
{ title: "额度", key: "quota", width: 160 },
{ title: "付款金额 / 时间", key: "paid", width: 170 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "备注", key: "remark" },
{ title: "登记", key: "created_at", width: 150 },
{ title: "操作", key: "actions", width: 120 },
];
const topColumns = [
{ title: "时间", key: "paid_at", width: 150 },
{
title: "充值金额",
dataIndex: "amount",
key: "amount",
width: 100,
},
{ title: "实付金额", dataIndex: "money", key: "money", width: 100 },
{ title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "备注", key: "remark" },
{ title: "操作", key: "actions", width: 120 },
];
function periodLines(s: SubscriptionItem): { start: string; end: string } {
return {
start: formatDateTime(s.period_start),
end: s.period_end == null ? "进行中" : formatDateTime(s.period_end),
};
}
function onCreateSub() {
editingSub.value = null;
editingTop.value = null;
formOpen.value = true;
}
function onCreateTop() {
editingSub.value = null;
editingTop.value = null;
tab.value = "topups";
formOpen.value = true;
}
function onEditSub(s: SubscriptionItem) {
editingSub.value = s;
editingTop.value = null;
formOpen.value = true;
}
function onEditTop(t: TopupItem) {
editingTop.value = t;
editingSub.value = null;
formOpen.value = true;
}
async function onDeleteSub(s: SubscriptionItem) {
try {
await deleteSub.mutateAsync(s.id);
message.success("已删除");
} catch (e) {
message.error((e as ApiError).message || "删除失败");
}
}
async function onDeleteTop(t: TopupItem) {
try {
await deleteTop.mutateAsync(t.id);
message.success("已删除");
} catch (e) {
message.error((e as ApiError).message || "删除失败");
}
}
/** 观测订阅号 ↔ 已登记:只提示不回填;双表皆空则静默 */
async function reconcile(subs: SubscriptionItem[]) {
if (subs.length === 0 && topupsQuery.data.value?.length === 0) {
missingSubs.value = [];
return;
}
if (subs.length === 0) {
missingSubs.value = [];
return;
}
checking.value = true;
try {
const known = new Set(subs.map((s) => s.subscription_id));
const earliest = Math.min(
...subs.map((s) => s.period_start ?? 0),
Date.now() / 1000,
);
const from = toRequestIso(new Date((earliest - 30 * 86400) * 1000));
const to = toRequestIso(new Date());
const page = await fetchLogs({
from,
to,
page: 1,
page_size: 200,
type: 2,
});
const observed = new Set<number>();
for (const it of page.items) {
if (it.type === 2 && it.subscription_id != null) {
observed.add(it.subscription_id);
}
}
missingSubs.value = [...observed].filter((id) => !known.has(id)).sort((a, b) => a - b);
} catch {
missingSubs.value = [];
} finally {
checking.value = false;
}
}
watch(
() => subsQuery.data.value,
(subs) => {
if (!subs) return;
void reconcile(subs);
},
{ immediate: true },
);
</script>
<template>
<section>
<PageHeader title="支付记录" subtitle="订阅权益与充值入账登记">
<template #actions>
<a-button v-if="tab === 'subs'" type="primary" @click="onCreateSub">
登记订阅
</a-button>
<a-button v-else type="primary" @click="onCreateTop">登记充值</a-button>
</template>
</PageHeader>
<a-tabs v-model:active-key="tab">
<a-tab-pane key="subs" tab="订阅" />
<a-tab-pane key="topups" tab="充值" />
</a-tabs>
<div class="payments-toolbar">
<template v-if="tab === 'subs'">
<a-input-number
v-model:value="filterSubId"
:min="1"
:precision="0"
style="width: 140px"
placeholder="订阅 ID"
allow-clear
/>
</template>
<template v-else>
<a-input
v-model:value="filterTradeNo"
style="width: 180px"
placeholder="交易号 / 上游 ID"
allow-clear
/>
</template>
<a-select
v-model:value="filterDomain"
:options="[
{ value: null, label: '全部账本域' },
{ value: 'none', label: '未标注' },
...domainOptions,
]"
style="width: 160px"
placeholder="账本域"
allow-clear
/>
</div>
<a-alert
v-if="tab === 'subs' && missingSubs.length"
type="warning"
show-icon
style="margin-bottom: 16px"
:message="`观测到 ${missingSubs.length} 个订阅有消耗但尚无对应登记`"
>
<template #description>
<div class="pay-missing__tags">
<a-tag v-for="id in missingSubs" :key="id">#{{ id }}</a-tag>
</div>
<div class="pay-missing__note">
若本部署不登记支付,可忽略此提示;需要入账的请核对补录。
</div>
</template>
</a-alert>
<template v-if="tab === 'subs'">
<TableRefresh
:loading="subsQuery.isFetching.value"
@refresh="() => subsQuery.refetch()"
/>
<a-empty
v-if="!subsQuery.isPending.value && subRows.length === 0"
description="还没有订阅登记——从平台选用或手工登记"
>
<a-button @click="onCreateSub">登记订阅</a-button>
</a-empty>
<a-table
v-else
:columns="subColumns"
:data-source="subRows"
:row-key="(r: SubscriptionItem) => r.id"
:loading="subsQuery.isPending.value || subsQuery.isFetching.value || checking"
:pagination="false"
size="middle"
:scroll="{ x: 960 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'period'">
<div class="pay-period">
<span>{{ periodLines(record).start }}</span>
<span class="pay-period__end">~ {{ periodLines(record).end }}</span>
</div>
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_granted" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'paid'">
{{
record.paid_amount != null
? `${formatPaidAmount(record.paid_amount)} · ${formatDateTime(record.paid_at)}`
: "—"
}}
</template>
<template v-else-if="column.key === 'billing_domain'">
{{ record.billing_domain || "—" }}
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button size="small" @click="() => onEditSub(record)">编辑</a-button>
<a-popconfirm
title="删除后该条将消失"
ok-text="删除"
cancel-text="取消"
@confirm="() => onDeleteSub(record)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</template>
<template v-else>
<TableRefresh
:loading="topupsQuery.isFetching.value"
@refresh="() => topupsQuery.refetch()"
/>
<a-empty
v-if="!topupsQuery.isPending.value && topRows.length === 0"
description="还没有充值登记"
>
<a-button @click="onCreateTop">登记充值</a-button>
</a-empty>
<a-table
v-else
:columns="topColumns"
:data-source="topRows"
:row-key="(r: TopupItem) => r.id"
:loading="topupsQuery.isPending.value || topupsQuery.isFetching.value"
:pagination="false"
size="middle"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'paid_at'">
{{ formatDateTime(record.paid_at) }}
</template>
<template v-else-if="column.key === 'amount'">
{{ formatPaidAmount(record.amount) }}
</template>
<template v-else-if="column.key === 'money'">
{{ formatPaidAmount(record.money) }}
</template>
<template v-else-if="column.key === 'trade'">
{{ record.trade_no || "—" }}
<span v-if="record.payment_method" class="pay-note">
· {{ record.payment_method }}
</span>
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'billing_domain'">
{{ record.billing_domain || "—" }}
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button size="small" @click="() => onEditTop(record)">编辑</a-button>
<a-popconfirm
title="删除后该条将消失"
ok-text="删除"
cancel-text="取消"
@confirm="() => onDeleteTop(record)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</template>
<PaymentFormModal
v-model:open="formOpen"
:tab="tab"
:editing-sub="editingSub"
:editing-top="editingTop"
/>
</section>
</template>
<style scoped>
.payments-toolbar {
margin-bottom: 12px;
}
.pay-period {
display: flex;
flex-direction: column;
line-height: 20px;
font-variant-numeric: tabular-nums;
}
.pay-period__end {
color: rgba(0, 0, 0, 0.65);
}
.pay-missing__tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 4px;
}
.pay-missing__note,
.pay-note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
/**
* 账号设置——改密(交互设计-登录与会话 §3)
*/
import { computed, reactive, ref } from "vue";
import { message } from "antdv-next";
import type { ApiError } from "@/api/http.ts";
import PageHeader from "@/components/common/page-header.vue";
import { useChangePassword } from "@/queries/session.ts";
const changePassword = useChangePassword();
const form = reactive({
old_password: "",
new_password: "",
confirm_password: "",
});
const fieldError = ref<{ old_password?: string; new_password?: string }>({});
const formError = ref("");
const successMsg = ref("");
const canSubmit = computed(
() =>
form.old_password.length > 0 &&
form.new_password.length > 0 &&
form.confirm_password.length > 0,
);
async function onSubmit() {
if (!canSubmit.value || changePassword.isPending.value) return;
fieldError.value = {};
formError.value = "";
successMsg.value = "";
if (form.new_password !== form.confirm_password) {
formError.value = "两次输入的新密码不一致";
return;
}
try {
await changePassword.mutateAsync({
old_password: form.old_password,
new_password: form.new_password,
});
form.old_password = "";
form.new_password = "";
form.confirm_password = "";
successMsg.value = "密码已更新;其他设备的登录已失效";
message.success(successMsg.value);
} catch (e) {
const err = e as ApiError;
if (err.fields?.old_password) {
fieldError.value.old_password = err.fields.old_password;
} else if (err.fields?.new_password) {
fieldError.value.new_password = err.fields.new_password;
} else {
formError.value = err.message || "修改失败";
}
}
}
</script>
<template>
<section class="settings">
<PageHeader title="账号设置" subtitle="修改管理面登录密码" />
<a-form layout="vertical" class="settings__form">
<a-form-item
label="旧密码"
required
:validate-status="fieldError.old_password ? 'error' : undefined"
:help="fieldError.old_password"
>
<a-input-password
v-model:value="form.old_password"
autocomplete="current-password"
@press-enter="onSubmit"
/>
</a-form-item>
<a-form-item
label="新密码"
required
:validate-status="fieldError.new_password ? 'error' : undefined"
:help="fieldError.new_password"
>
<a-input-password
v-model:value="form.new_password"
autocomplete="new-password"
@press-enter="onSubmit"
/>
</a-form-item>
<a-form-item label="确认新密码" required>
<a-input-password
v-model:value="form.confirm_password"
autocomplete="new-password"
@press-enter="onSubmit"
/>
</a-form-item>
<a-alert
v-if="formError"
type="error"
show-icon
:message="formError"
style="margin-bottom: 16px"
/>
<a-alert
v-else-if="successMsg"
type="success"
show-icon
:message="successMsg"
style="margin-bottom: 16px"
/>
<a-button
type="primary"
:loading="changePassword.isPending.value"
:disabled="!canSubmit"
@click="onSubmit"
>
修改密码
</a-button>
</a-form>
</section>
</template>
<style scoped>
.settings__form {
max-width: 420px;
}
</style>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
/** 成员令牌登录壳——明文仅存 sessionStorage(交互登录篇 §4) */
import type { FormInstance } from "antdv-next";
import { reactive, ref, shallowRef } from "vue";
import { useRouter } from "vue-router";
import { useMemberTokenStore } from "@/stores/member-token.ts";
import { fetchMe } from "@/api/member/me.ts";
const router = useRouter();
const tokenStore = useMemberTokenStore();
const formRef = shallowRef<FormInstance>();
const model = reactive({ token: "" });
const error = ref("");
const pending = ref(false);
async function onFinish() {
error.value = "";
pending.value = true;
try {
tokenStore.setToken(model.token.trim());
const me = await fetchMe();
tokenStore.setMemberName(me.name);
void router.replace({ name: "member-overview" });
} catch {
tokenStore.clearToken();
error.value = "令牌无效或已停用";
} finally {
pending.value = false;
}
}
</script>
<template>
<div class="member-login-shell">
<a-card class="member-login-shell__card" title="成员入口">
<a-form
ref="formRef"
:model="model"
layout="vertical"
auto-complete="off"
@finish="onFinish"
>
<a-form-item
name="token"
label="令牌"
:rules="[{ required: true, message: '请输入成员令牌' }]"
>
<a-input-password v-model:value="model.token" placeholder="粘贴成员令牌" />
</a-form-item>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
style="margin-bottom: 16px"
/>
<a-button type="primary" html-type="submit" block :loading="pending">
进入
</a-button>
</a-form>
</a-card>
</div>
</template>
<style scoped>
.member-login-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f0f2f5;
padding: 24px;
}
.member-login-shell__card {
width: 380px;
}
</style>
+584
View File
@@ -0,0 +1,584 @@
<script setup lang="ts">
/**
* 成员调用日志(交互设计-成员视图 §4):
* 行展开 = 模型调用详情;来源徽章 = 订阅支付参数弹窗(R12)
*/
import { computed, h, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import type { LogItem, SubscriptionItem } from "@new-api-key-usage/core";
import { Tooltip } from "antdv-next";
import { QuestionCircleOutlined } from "@antdv-next/icons";
import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import LogsSummaryBar from "@/components/common/logs-summary-bar.vue";
import { useMemberRange } from "@/composables/use-member-range.ts";
import type { RangeKind } from "@/stores/member-range.ts";
import { useMemberLogs, useMemberOverview } from "@/queries/member.ts";
import { formatDateTime } from "@/utils/time.ts";
import { formatInt, formatPaidAmount } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
const { range, setRange } = useMemberRange();
const apiKeyId = ref<number | null>(null);
const modelFilter = ref<string | null>(null);
const billingSource = ref<string | null>(null);
const subscriptionId = ref<number | null>(null);
const typeFilter = ref<number | "error" | null>(null);
const page = ref(1);
const pageSize = ref(50);
const overviewQuery = useMemberOverview(() => range.value);
const payments = computed(
() => overviewQuery.data.value?.subscriptions ?? [],
);
const keys = computed(() => overviewQuery.data.value?.keys ?? []);
const params = computed(() => ({
...range.value,
page: page.value,
page_size: pageSize.value,
api_key_id: apiKeyId.value ?? undefined,
model_name: modelFilter.value ?? undefined,
billing_source: billingSource.value ?? undefined,
subscription_id: subscriptionId.value ?? undefined,
type: typeFilter.value ?? undefined,
}));
const logsQuery = useMemberLogs(() => params.value);
const rows = computed(() => logsQuery.data.value?.items ?? []);
const total = computed(() => logsQuery.data.value?.total ?? 0);
const summary = computed(() => logsQuery.data.value?.summary);
const modelOptions = computed(() => [
{ value: null, label: "全部模型" },
...(logsQuery.data.value?.models ?? []).map((m) => ({ value: m, label: m })),
]);
const keyOptions = computed(() => [
{ value: null, label: "全部 Key" },
...keys.value.map((k) => ({ value: k.key_id, label: k.token_name })),
]);
const paymentBySub = computed(() => {
const m = new Map<number, SubscriptionItem>();
for (const p of payments.value) {
m.set(p.subscription_id, p);
}
return m;
});
function tipTitle(label: string, tip: string) {
return h("span", { class: "mv-col-title" }, [
label,
h(Tooltip, { title: tip }, () => h(QuestionCircleOutlined, { class: "mv-col-title__icon" })),
]);
}
const columns = [
{ title: "时间", key: "created_at", width: 150 },
{ title: "Key", key: "token_name", dataIndex: "token_name", width: 120 },
{ title: "模型", key: "model_name", dataIndex: "model_name", width: 120 },
{ title: "类型", key: "type", width: 80 },
{ title: "来源", key: "source", width: 120 },
{
title: tipTitle("用量", "quota 原值与按当时换算率折算的美元"),
key: "usage",
width: 150,
},
{ title: "操作", key: "actions", width: 72 },
];
const detailOpen = ref(false);
const detailRecord = ref<LogItem | null>(null);
/** 正在从 URL 灌入状态——避免筛选 watcher 误重置 page */
const applyingRoute = ref(false);
function openDetail(record: LogItem) {
detailRecord.value = record;
detailOpen.value = true;
}
function formatRaw(record: LogItem | null): string {
if (!record) return "";
return JSON.stringify(record.raw ?? record, null, 2);
}
function sourceBadge(record: LogItem) {
if (record.billing_source === "subscription" && record.subscription_id != null) {
return {
text: `订阅 #${record.subscription_id}`,
kind: "sub" as const,
id: record.subscription_id,
};
}
if (record.billing_source === "wallet") {
return { text: "充值", kind: "wallet" as const, id: null };
}
return { text: "未关联", kind: "none" as const, id: null };
}
function onBadge(record: LogItem, e: MouseEvent) {
e.stopPropagation();
if (sourceBadge(record).kind !== "sub") return;
payModalSubId.value = sourceBadge(record).id;
payModalOpen.value = true;
}
function periodText(p: SubscriptionItem): string {
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${formatDateTime(p.period_start)} ~ ${end}`;
}
const payModalOpen = ref(false);
const payModalSubId = ref<number | null>(null);
const payModalPayment = computed(() =>
payModalSubId.value == null
? undefined
: paymentBySub.value.get(payModalSubId.value),
);
function syncFromRoute() {
applyingRoute.value = true;
const q = route.query;
const k = q.api_key_id ?? q.key_id;
apiKeyId.value = typeof k === "string" && k ? Number(k) || null : null;
modelFilter.value =
typeof q.model_name === "string" && q.model_name ? q.model_name : null;
billingSource.value =
typeof q.billing_source === "string" && q.billing_source
? q.billing_source
: null;
const s = q.subscription_id;
subscriptionId.value = typeof s === "string" && s ? Number(s) || null : null;
const t = q.type;
if (t === "2") typeFilter.value = 2;
else if (t === "error") typeFilter.value = "error";
else typeFilter.value = null;
const p = q.page;
page.value = typeof p === "string" && p ? Number(p) || 1 : 1;
void nextTick(() => {
applyingRoute.value = false;
});
}
function pushQuery() {
const query: Record<string, string> = {
from: range.value.from,
to: range.value.to,
};
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
if (modelFilter.value) query.model_name = modelFilter.value;
if (billingSource.value) query.billing_source = billingSource.value;
if (subscriptionId.value != null) {
query.subscription_id = String(subscriptionId.value);
}
if (typeFilter.value) query.type = String(typeFilter.value);
if (page.value > 1) query.page = String(page.value);
void router.replace({ path: "/member/logs", query });
}
syncFromRoute();
watch(() => route.query, () => syncFromRoute());
watch([apiKeyId, modelFilter, billingSource, subscriptionId, typeFilter], () => {
if (applyingRoute.value) return;
page.value = 1;
pushQuery();
});
watch(page, () => pushQuery());
function onRangeChange(v: { from: string; to: string }, k: RangeKind) {
setRange(v, k);
page.value = 1;
pushQuery();
}
function onPageChange(p: number, ps: number) {
page.value = p;
pageSize.value = ps;
}
const boxRef = ref<HTMLElement | null>(null);
const bodyY = ref(480);
const CHROME = 96;
let ro: ResizeObserver | null = null;
function measure() {
const h = boxRef.value?.clientHeight ?? 0;
if (h > 0) bodyY.value = Math.max(200, h - CHROME);
}
function bindObserve() {
ro?.disconnect();
ro = null;
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
ro = new ResizeObserver(() => measure());
ro.observe(boxRef.value);
}
measure();
}
onMounted(() => {
bindObserve();
window.addEventListener("resize", measure);
});
onUnmounted(() => {
ro?.disconnect();
window.removeEventListener("resize", measure);
});
// 空态/有数据切换会重建 box 节点,需重新挂 RO
watch(
() => rows.value.length === 0 && !logsQuery.isPending.value,
async () => {
await nextTick();
bindObserve();
},
);
</script>
<template>
<section class="member-logs">
<PageHeader title="调用日志" subtitle="名下 key 的模型请求流水">
<template #actions>
<span class="member-logs__label">时间范围</span>
<TimeRangePicker :model-value="range" @change="onRangeChange" />
</template>
</PageHeader>
<div class="member-logs__filters">
<a-select
v-model:value="apiKeyId"
:options="keyOptions"
style="width: 160px"
allow-clear
placeholder="Key"
show-search
option-filter-prop="label"
/>
<a-select
v-model:value="modelFilter"
:options="modelOptions"
style="width: 140px"
allow-clear
placeholder="模型"
show-search
option-filter-prop="label"
/>
<a-select
v-model:value="billingSource"
:options="[
{ value: null, label: '全部来源' },
{ value: 'subscription', label: '订阅' },
{ value: 'wallet', label: '充值' },
{ value: 'none', label: '未关联' },
]"
style="width: 120px"
allow-clear
placeholder="来源"
/>
<a-input-number
v-model:value="subscriptionId"
:min="1"
:precision="0"
style="width: 120px"
placeholder="订阅 ID"
/>
<a-select
v-model:value="typeFilter"
:options="[
{ value: null, label: '全部类型' },
{ value: 2, label: '消费' },
{ value: 'error', label: '错误' },
]"
style="width: 120px"
placeholder="类型"
/>
</div>
<LogsSummaryBar
:hit-count="summary?.hit_count ?? 0"
:usage-count="summary?.usage_count ?? 0"
:error-count="summary?.error_count ?? 0"
:quota="summary?.quota ?? 0"
:quota-usd="summary?.quota_usd ?? '0.00'"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
/>
<TableRefresh
:loading="logsQuery.isFetching.value"
@refresh="() => logsQuery.refetch()"
/>
<a-alert
v-if="logsQuery.isError.value"
type="error"
show-icon
message="调用日志加载失败"
:description="logsQuery.error.value?.message"
style="margin-bottom: 16px"
>
<template #action>
<a-button size="small" @click="() => logsQuery.refetch()">重试</a-button>
</template>
</a-alert>
<a-empty
v-if="!logsQuery.isPending.value && !logsQuery.isError.value && rows.length === 0"
description="该时段内无调用日志"
/>
<div v-else ref="boxRef" class="member-logs__box">
<a-table
:columns="columns"
:data-source="rows"
:row-key="(r: LogItem) => r.id"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (t: number) => `共 ${t} 条`,
onChange: onPageChange,
}"
size="middle"
:scroll="{ x: 780, y: bodyY }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.quota" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'type'">
<a-tag :color="record.type === 2 ? 'blue' : 'red'">
{{ record.type === 2 ? "消费" : "错误" }}
</a-tag>
</template>
<template v-else-if="column.key === 'source'">
<a-tooltip
v-if="sourceBadge(record).kind === 'sub'"
title="点击查看该订阅支付参数"
>
<a-tag
color="blue"
class="member-logs__badge"
@click="(e: MouseEvent) => onBadge(record, e)"
>
{{ sourceBadge(record).text }}
</a-tag>
</a-tooltip>
<a-tag v-else-if="sourceBadge(record).kind === 'wallet'" color="gold">
充值
</a-tag>
<a-tag v-else>未关联</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a @click="openDetail(record)">详情</a>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="detailOpen"
title="调用详情"
:footer="null"
width="720"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
>
<div v-if="detailRecord" class="member-logs__detail">
<div class="member-logs__kv">
<span class="member-logs__note">时间</span>
<span>{{ formatDateTime(detailRecord.created_at) }}</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">Key / 模型</span>
<span>{{ detailRecord.token_name }} · {{ detailRecord.model_name }}</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">类型</span>
<a-tag :color="detailRecord.type === 2 ? 'blue' : 'red'">
{{ detailRecord.type === 2 ? "消费" : "错误" }}
</a-tag>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">用量</span>
<QuotaText :quota="detailRecord.quota" :quota-usd="detailRecord.quota_usd" />
</div>
<div class="member-logs__kv">
<span class="member-logs__note">Tokens</span>
<span class="member-logs__num">
提示 {{ formatInt(detailRecord.prompt_tokens) }} · 补全 {{ formatInt(detailRecord.completion_tokens) }}
</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">耗时</span>
<span class="member-logs__num">
{{ detailRecord.use_time }}s · {{ detailRecord.is_stream ? "流式" : "非流式" }}
</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">来源</span>
<a-tag :color="sourceBadge(detailRecord).kind === 'sub' ? 'blue' : sourceBadge(detailRecord).kind === 'wallet' ? 'gold' : undefined">
{{ sourceBadge(detailRecord).text }}
</a-tag>
</div>
<div v-if="detailRecord.content" class="member-logs__kv">
<span class="member-logs__note">内容</span>
<span class="member-logs__content">{{ detailRecord.content }}</span>
</div>
<div class="member-logs__raw">
<div class="member-logs__note">原始数据</div>
<pre class="member-logs__json">{{ formatRaw(detailRecord) }}</pre>
</div>
</div>
</a-modal>
<a-modal
v-model:open="payModalOpen"
:title="payModalSubId != null ? `订阅 #${payModalSubId} 支付参数` : '支付参数'"
:footer="null"
width="480"
centered
:styles="{ body: { maxHeight: '56vh', overflowY: 'auto' } }"
>
<div v-if="payModalPayment" class="member-logs__pay">
<div class="member-logs__kv">
<span class="member-logs__note">期间</span>
<span>{{ periodText(payModalPayment) }}</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">额度</span>
<QuotaText
:quota="payModalPayment.quota_granted"
:quota-usd="payModalPayment.quota_usd"
/>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">金额</span>
<span class="member-logs__num">{{ formatPaidAmount(payModalPayment.paid_amount) }}</span>
</div>
<div v-if="payModalPayment.remark" class="member-logs__kv">
<span class="member-logs__note">备注</span>
<span>{{ payModalPayment.remark }}</span>
</div>
<div class="member-logs__foot">
支付参数在你所属账本域内公开;币种由成员与作者约定
</div>
</div>
<div v-else class="member-logs__note">该订阅无登记信息</div>
</a-modal>
</section>
</template>
<style scoped>
.member-logs {
display: flex;
flex-direction: column;
height: calc(100vh - 64px - 48px - 48px);
min-height: 0;
}
.member-logs__label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.member-logs__filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.member-logs__box {
flex: 1;
min-height: 0;
}
.member-logs__badge {
cursor: pointer;
}
.member-logs__detail {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-logs__expand {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 0;
}
.member-logs__pay {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-logs__foot {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.member-logs__kv {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.member-logs__note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
min-width: 72px;
}
.member-logs__num {
font-variant-numeric: tabular-nums;
}
.member-logs__content {
white-space: pre-wrap;
word-break: break-all;
flex: 1;
min-width: 0;
}
.member-logs__raw {
margin-top: 4px;
}
.member-logs__json {
margin: 4px 0 0;
padding: 12px;
max-height: 360px;
overflow: auto;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
font-size: 12px;
line-height: 20px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
}
.member-logs :deep(.mv-col-title) {
display: inline-flex;
align-items: center;
gap: 4px;
}
.member-logs :deep(.mv-col-title__icon) {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
</style>
+245
View File
@@ -0,0 +1,245 @@
<script setup lang="ts">
/**
* 成员总览(交互设计-成员视图 §3):紧凑摘要 + 我的 Key 表 + 支付入口
*/
import { computed } from "vue";
import { useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import SourceBar from "@/components/common/source-bar.vue";
import QuotaText from "@/components/common/quota-text.vue";
import { useMemberRange } from "@/composables/use-member-range.ts";
import { useMemberOverview } from "@/queries/member.ts";
import { formatMinute, formatRangeCaption, hoursSince } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts";
const router = useRouter();
const { range, onRangeChange } = useMemberRange();
/** 看板心智:仅总览自动刷新;其它成员页不轮询 */
const overviewQuery = useMemberOverview(() => range.value, {
refetchInterval: 60_000,
});
const usage = computed(() => overviewQuery.data.value?.usage);
const bySource = computed(() => overviewQuery.data.value?.by_source ?? []);
const keys = computed(() => overviewQuery.data.value?.keys ?? []);
const payments = computed(
() => overviewQuery.data.value?.subscriptions ?? [],
);
const extraPresets = computed(() =>
payments.value
.map((p) => ({
label: `订阅 #${p.subscription_id} 期间`,
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
to: new Date(
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
).toISOString(),
}))
.slice(0, 4),
);
const rangeCaption = computed(() =>
formatRangeCaption(range.value.from, range.value.to),
);
function isStalled(at: number | null | undefined): boolean {
const h = hoursSince(at);
return h != null && h > 2;
}
function goLogs(keyId?: number) {
const query: Record<string, string> = {
from: range.value.from,
to: range.value.to,
};
if (keyId != null) query.api_key_id = String(keyId);
void router.push({ path: "/member/logs", query });
}
const keyColumns = [
{ title: "Key 名称", dataIndex: "token_name", key: "token_name" },
{
title: "数据截止",
key: "data_cutoff",
},
{ title: "调用次数", key: "count" },
{ title: "额度消耗", key: "usage" },
{ title: "", key: "actions", width: 80 },
];
</script>
<template>
<section>
<PageHeader title="总览" subtitle="你在本池的名下用量 · 仅统计消费行">
<template #actions>
<span class="mv-range-label">时间范围</span>
<TimeRangePicker
:model-value="range"
:extra-presets="extraPresets"
@change="onRangeChange"
/>
</template>
</PageHeader>
<a-alert
v-if="overviewQuery.isError.value"
type="error"
show-icon
message="总览数据加载失败"
:description="overviewQuery.error.value?.message"
style="margin-bottom: 16px"
>
<template #action>
<a-button size="small" @click="() => overviewQuery.refetch()">重试</a-button>
</template>
</a-alert>
<a-skeleton
v-if="overviewQuery.isPending.value"
active
:paragraph="{ rows: 4 }"
style="margin-bottom: 24px"
/>
<a-empty
v-else-if="!overviewQuery.isError.value && keys.length === 0"
description="作者尚未为你分配 key"
style="margin-bottom: 24px"
/>
<template v-else-if="keys.length > 0">
<section class="mv-block">
<h2 class="mv-block__title">
用量摘要
<span class="mv-caption">{{ rangeCaption }}</span>
</h2>
<div class="mv-summary">
<div class="mv-summary__main">
<QuotaText
v-if="usage"
:quota="usage.quota"
:quota-usd="usage.quota_usd"
/>
<div class="mv-summary__sub">
调用 {{ formatInt(usage?.count ?? 0) }} 次 · 按当时换算率
<span v-if="usage && usage.count === 0" class="mv-note">· 该时段内无调用</span>
</div>
</div>
<SourceBar :rows="bySource" />
</div>
</section>
<section class="mv-block">
<h2 class="mv-block__title">我的 Key</h2>
<TableRefresh
:loading="overviewQuery.isFetching.value"
@refresh="() => overviewQuery.refetch()"
/>
<a-table
:columns="keyColumns"
:data-source="keys"
:row-key="(r: { key_id: number }) => r.key_id"
size="middle"
:pagination="false"
:scroll="{ x: 720 }"
:loading="overviewQuery.isFetching.value"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'data_cutoff'">
<span
v-if="record.log_synced_at == null"
class="mv-syncing"
>同步中…</span>
<span
v-else
:class="{ 'is-stall': isStalled(record.log_synced_at) }"
>
{{ isStalled(record.log_synced_at) ? "同步停滞 " : "同步 " }}{{ formatMinute(record.log_synced_at) }}
</span>
</template>
<template v-else-if="column.key === 'count'">
{{ formatInt(record.usage.count) }}
</template>
<template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.usage.quota" :quota-usd="record.usage.quota_usd" />
</template>
<template v-else-if="column.key === 'actions'">
<a-button size="small" @click="() => goLogs(record.key_id)">查调用</a-button>
</template>
</template>
</a-table>
</section>
<section class="mv-block">
<h2 class="mv-block__title">支付参数</h2>
<div class="mv-pay-entry">
<span v-if="payments.length === 0" class="mv-note">暂无支付记录信息</span>
<span v-else class="mv-note">
该时段涉及 {{ payments.length }} 笔登记参数(订阅 / 充值)
</span>
<a @click="() => router.push('/member/payments')">查看完整参数</a>
</div>
</section>
</template>
</section>
</template>
<style scoped>
.mv-range-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.mv-block {
margin-bottom: 24px;
}
.mv-block__title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
line-height: 22px;
}
.mv-caption {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: rgba(0, 0, 0, 0.45);
}
.mv-syncing {
color: rgba(0, 0, 0, 0.45);
}
.mv-summary {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.mv-summary__sub {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-pay-entry {
display: flex;
align-items: center;
gap: 16px;
}
.is-stall {
color: #faad14;
}
</style>
+230
View File
@@ -0,0 +1,230 @@
<script setup lang="ts">
/**
* 成员支付参数(交互设计-成员视图 §5):订阅 + 充值清单
*/
import { computed } from "vue";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import { useMemberRange } from "@/composables/use-member-range.ts";
import { useMemberOverview } from "@/queries/member.ts";
import { formatDateTime } from "@/utils/time.ts";
import { formatPaidAmount } from "@/utils/format.ts";
const { range, onRangeChange } = useMemberRange();
const overviewQuery = useMemberOverview(() => range.value);
const subs = computed(() => overviewQuery.data.value?.subscriptions ?? []);
const tops = computed(() => overviewQuery.data.value?.topups ?? []);
const extraPresets = computed(() =>
subs.value
.map((p) => ({
label: `订阅 #${p.subscription_id} 期间`,
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
to: new Date(
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
).toISOString(),
}))
.slice(0, 4),
);
function periodLines(p: {
period_start: number;
period_end: number | null;
}): { start: string; end: string } {
return {
start: formatDateTime(p.period_start),
end: p.period_end == null ? "进行中" : formatDateTime(p.period_end),
};
}
const subColumns = [
{
title: "订阅 ID",
dataIndex: "subscription_id",
key: "subscription_id",
width: 100,
},
{ title: "期间", key: "period", width: 200 },
{ title: "额度", key: "quota", width: 160 },
{ title: "付款金额 / 时间", key: "paid", width: 170 },
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
];
const topColumns = [
{ title: "时间", key: "paid_at", width: 150 },
{
title: "充值金额",
dataIndex: "amount",
key: "amount",
width: 100,
},
{
title: "实付金额",
dataIndex: "money",
key: "money",
width: 100,
},
{ title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 },
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
];
</script>
<template>
<section>
<PageHeader
title="支付参数"
subtitle="订阅与充值的登记参数 · 同账本域内可见"
>
<template #actions>
<span class="mv-range-label">时间范围</span>
<TimeRangePicker
:model-value="range"
:extra-presets="extraPresets"
@change="onRangeChange"
/>
</template>
</PageHeader>
<a-alert
v-if="overviewQuery.isError.value"
type="error"
show-icon
message="支付参数加载失败"
:description="overviewQuery.error.value?.message"
style="margin-bottom: 16px"
>
<template #action>
<a-button size="small" @click="() => overviewQuery.refetch()">重试</a-button>
</template>
</a-alert>
<a-skeleton
v-if="overviewQuery.isPending.value"
active
:paragraph="{ rows: 4 }"
/>
<a-empty
v-else-if="!overviewQuery.isError.value && subs.length === 0 && tops.length === 0"
description="暂无支付记录信息"
/>
<template v-else>
<h3 class="mv-sec">订阅</h3>
<TableRefresh
:loading="overviewQuery.isFetching.value"
@refresh="() => overviewQuery.refetch()"
/>
<a-empty v-if="subs.length === 0" description="暂无订阅登记" />
<a-table
v-else
:columns="subColumns"
:data-source="subs"
:row-key="(r: { id: number }) => r.id"
size="middle"
:pagination="false"
:scroll="{ x: 760 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'period'">
<div class="mv-period">
<span>{{ periodLines(record).start }}</span>
<span class="mv-period__end">~ {{ periodLines(record).end }}</span>
</div>
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_granted" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'paid'">
{{
record.paid_amount != null
? `${formatPaidAmount(record.paid_amount)} · ${formatDateTime(record.paid_at)}`
: "—"
}}
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
</template>
</a-table>
<h3 class="mv-sec">充值</h3>
<a-empty v-if="tops.length === 0" description="暂无充值登记" />
<a-table
v-else
:columns="topColumns"
:data-source="tops"
:row-key="(r: { id: number }) => r.id"
size="middle"
:pagination="false"
:scroll="{ x: 840 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'paid_at'">
{{ formatDateTime(record.paid_at) }}
</template>
<template v-else-if="column.key === 'amount'">
{{ formatPaidAmount(record.amount) }}
</template>
<template v-else-if="column.key === 'money'">
{{ formatPaidAmount(record.money) }}
</template>
<template v-else-if="column.key === 'trade'">
{{ record.trade_no || "—" }}
<span v-if="record.payment_method" class="mv-note">
· {{ record.payment_method }}
</span>
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
</template>
</a-table>
<div class="mv-foot">
支付参数在你所属账本域内公开;币种由成员与作者约定
</div>
</template>
</section>
</template>
<style scoped>
.mv-period {
display: flex;
flex-direction: column;
line-height: 20px;
font-variant-numeric: tabular-nums;
}
.mv-period__end {
color: rgba(0, 0, 0, 0.65);
}
.mv-range-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.mv-sec {
margin: 20px 0 12px;
font-size: 14px;
font-weight: 600;
}
.mv-note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-foot {
margin-top: 16px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
</style>
+23 -12
View File
@@ -1,20 +1,31 @@
import { fileURLToPath, URL } from 'node:url'
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
import vueDevTools from "vite-plugin-vue-devtools";
/** 后端母体端口(与根 .env 的 PORT / AGENTS 约定一致) */
const API_TARGET = process.env.VITE_API_PROXY_TARGET ?? "http://127.0.0.1:18456";
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx(),
vueDevTools(),
],
plugins: [vue(), vueJsx(), vueDevTools()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
})
server: {
// 固定开发口:避免在 5173/5174 被占时静默跳到 5175(strictPort 不自动改口)
port: 5175,
strictPort: true,
proxy: {
// 契约:/api/* 专属接口;SPA 路由不进 /api(公共约定 §2)
"/api": {
target: API_TARGET,
changeOrigin: true,
},
},
},
});
+99 -8
View File
@@ -5,7 +5,9 @@
"": {
"name": "new-api-key-usage",
"devDependencies": {
"@clack/prompts": "^1.8.1",
"@types/bun": "^1.4.2",
"citty": "^0.2.2",
"typescript": "^7.0.2",
},
},
@@ -36,8 +38,13 @@
"name": "@new-api-key-usage/web",
"version": "0.0.0",
"dependencies": {
"@antdv-next/icons": "^1.1.2",
"@new-api-key-usage/core": "workspace:*",
"antdv-next": "^1.6.0",
"@tanstack/vue-query": "^5.104.1",
"@vueuse/core": "15.0.0",
"antdv-next": "1.6.0",
"axios": "^1.20.0",
"dayjs": "1.11.23",
"pinia": "^4.0.3",
"vue": "^3.5.42",
"vue-router": "^5.3.1",
@@ -91,7 +98,7 @@
},
},
"packages": {
"@ant-design/colors": ["@ant-design/colors@8.0.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="],
"@ant-design/colors": ["@ant-design/colors@7.2.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6" } }, "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ=="],
"@ant-design/fast-color": ["@ant-design/fast-color@3.0.1", "", {}, "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw=="],
@@ -175,6 +182,10 @@
"@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="],
"@clack/core": ["@clack/core@1.5.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ=="],
"@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.2", "", {}, "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow=="],
"@csstools/css-calc": ["@csstools/css-calc@3.4.3", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA=="],
@@ -401,6 +412,12 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tanstack/match-sorter-utils": ["@tanstack/match-sorter-utils@9.2.6", "", { "dependencies": { "remove-accents": "0.5.0" } }, "sha512-DIpUSOhPfQ7u/qfUi/Wgm0wBO56MyBYXGvnlI0NcauDRPqdBUlAZZjW2Dxm92m+k1UWVhKw8YJ4f47+75Aujyw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.104.1", "", {}, "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw=="],
"@tanstack/vue-query": ["@tanstack/vue-query@5.104.1", "", { "dependencies": { "@tanstack/match-sorter-utils": "^9.1.2", "@tanstack/query-core": "5.104.1", "@vue/devtools-api": "^6.6.3", "vue-demi": "^0.14.10" }, "peerDependencies": { "@vue/composition-api": "^1.1.2", "vue": "^2.6.0 || ^3.3.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-3858wOmIoIazR9mZ2g8/QsKNccQlzxIY+4/g55OHbhhMeBEi+ig/8vne2e5N+Xx9g5MXuB7Nscfb6B4REPDWlw=="],
"@tsconfig/node24": ["@tsconfig/node24@24.0.5", "", {}, "sha512-DWC54fGPuhZgg3Ighg4Y/A/bA3ZdB5hnzAuv/MkQ/X85sVsBsMQxu8t7khutdYeiNksBbFdDyeqv1HSW2Zl8aA=="],
"@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
@@ -611,7 +628,7 @@
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.43", "", { "dependencies": { "@vue/compiler-dom": "3.5.43", "@vue/shared": "3.5.43" } }, "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg=="],
"@vue/devtools-api": ["@vue/devtools-api@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1" } }, "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A=="],
"@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
"@vue/devtools-core": ["@vue/devtools-core@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1", "@vue/devtools-shared": "^8.2.1" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-s/VfAY9oDTb/kFEWmy461jaFde2MIV1RO/gi1vwM+PAZBZ/Pc2Ndu3BNBdZUze8QDUuyYvElbEEGA83syjJfzA=="],
@@ -637,11 +654,11 @@
"@vue/tsconfig": ["@vue/tsconfig@0.9.1", "", { "peerDependencies": { "typescript": ">= 5.8", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w=="],
"@vueuse/core": ["@vueuse/core@14.4.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.4.0", "@vueuse/shared": "14.4.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ=="],
"@vueuse/core": ["@vueuse/core@15.0.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "15.0.0", "@vueuse/shared": "15.0.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng=="],
"@vueuse/metadata": ["@vueuse/metadata@14.4.0", "", {}, "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g=="],
"@vueuse/metadata": ["@vueuse/metadata@15.0.0", "", {}, "sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg=="],
"@vueuse/shared": ["@vueuse/shared@14.4.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g=="],
"@vueuse/shared": ["@vueuse/shared@15.0.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA=="],
"abbrev": ["abbrev@5.0.0", "", {}, "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w=="],
@@ -649,6 +666,8 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
@@ -665,6 +684,10 @@
"ast-walker-scope": ["ast-walker-scope@0.9.0", "", { "dependencies": { "@babel/parser": "^7.29.2", "@babel/types": "^7.29.0", "ast-kit": "^2.2.0" } }, "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.27", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ=="],
@@ -689,12 +712,18 @@
"cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001814", "", {}, "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA=="],
"chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="],
@@ -729,12 +758,16 @@
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"drizzle-kit": ["drizzle-kit@0.31.11", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg=="],
"drizzle-orm": ["drizzle-orm@0.45.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"editorconfig": ["editorconfig@3.0.2", "", { "dependencies": { "@one-ini/wasm": "0.2.1", "commander": "^14.0.3", "minimatch": "~10.2.4", "semver": "^7.7.4" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.444", "", {}, "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ=="],
@@ -743,8 +776,16 @@
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
@@ -789,6 +830,12 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
"fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -803,18 +850,36 @@
"flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="],
"follow-redirects": ["follow-redirects@1.16.1", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="],
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="],
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="],
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
@@ -823,6 +888,8 @@
"html-encoding-sniffer": ["html-encoding-sniffer@7.0.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1" } }, "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w=="],
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
@@ -909,6 +976,8 @@
"magic-string-ast": ["magic-string-ast@1.0.3", "", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="],
@@ -917,6 +986,10 @@
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
@@ -1013,6 +1086,8 @@
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="],
@@ -1025,6 +1100,8 @@
"readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="],
"remove-accents": ["remove-accents@0.5.0", "", {}, "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="],
@@ -1057,6 +1134,8 @@
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
@@ -1141,6 +1220,8 @@
"vue-component-type-helpers": ["vue-component-type-helpers@3.3.12", "", {}, "sha512-HwCqmgTfLFskQc4P5TRnbduCMyPat2Ezu0elLWOGn0dD/6BKHP15B5bxce6aDi+E4kblPtru8amIBQzu0Di3qQ=="],
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
"vue-eslint-parser": ["vue-eslint-parser@10.4.1", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", "eslint-visitor-keys": "^4.2.0 || ^5.0.0", "espree": "^10.3.0 || ^11.0.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA=="],
"vue-router": ["vue-router@5.3.1", "", { "dependencies": { "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.9.0", "chokidar": "^5.0.0", "confbox": "^0.2.4", "local-pkg": "^1.2.1", "magic-string": "^0.30.21", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "nostics": "^1.1.4", "pathe": "^2.0.3", "picomatch": "^4.0.5", "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.3.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", "pinia": "^3.0.4 || ^4.0.2", "vite": "^7.3.0 || ^8.0.0", "vue": "^3.5.34 || ^4.0.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-GDBZzgmILxA/kFnkFbJjQZdZ2QQbngnIMMuoUcjhZIfH1RGMaPjPwX5ASnV38qamuA9uhO0RDjSBHTDNG2uXyQ=="],
@@ -1179,7 +1260,7 @@
"zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
"@antdv-next/icons/@ant-design/colors": ["@ant-design/colors@7.2.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6" } }, "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ=="],
"@ant-design/colors/@ant-design/fast-color": ["@ant-design/fast-color@2.0.6", "", { "dependencies": { "@babel/runtime": "^7.24.7" } }, "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -1211,6 +1292,10 @@
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"antdv-next/@ant-design/colors": ["@ant-design/colors@8.0.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="],
"antdv-next/@vueuse/core": ["@vueuse/core@14.4.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.4.0", "@vueuse/shared": "14.4.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ=="],
"bun-types/@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -1221,6 +1306,8 @@
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"pinia/@vue/devtools-api": ["@vue/devtools-api@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1" } }, "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A=="],
"pkg-types/confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="],
"qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="],
@@ -1231,7 +1318,7 @@
"vite-plugin-vue-inspector/@vue/babel-plugin-jsx": ["@vue/babel-plugin-jsx@1.5.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.2", "@vue/babel-helper-vue-transform-on": "1.5.0", "@vue/babel-plugin-resolve-type": "1.5.0", "@vue/shared": "^3.5.18" }, "peerDependencies": { "@babel/core": "^7.0.0-0" }, "optionalPeers": ["@babel/core"] }, "sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw=="],
"@antdv-next/icons/@ant-design/colors/@ant-design/fast-color": ["@ant-design/fast-color@2.0.6", "", { "dependencies": { "@babel/runtime": "^7.24.7" } }, "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA=="],
"vue-router/@vue/devtools-api": ["@vue/devtools-api@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1" } }, "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
@@ -1281,6 +1368,10 @@
"@types/pg/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"antdv-next/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@14.4.0", "", {}, "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g=="],
"antdv-next/@vueuse/core/@vueuse/shared": ["@vueuse/shared@14.4.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g=="],
"bun-types/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+86 -8
View File
@@ -1,20 +1,98 @@
# 一键部署:docker compose up -d --build
# 验收:docker compose ps(migrate 应为 exited 0)
# 环境变量写在 .env(不进产物包;生产务必改密钥与 COOKIE_SECURE)
# 注意:DATABASE_URL 密码含 @ : / 等字符时需 URL 编码
services:
postgres:
container_name: naku-postgres
image: postgres:17-alpine
container_name: new-api-key-usage-pg
entrypoint: ["/bin/sh", "/entrypoint-pg.sh"]
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: new_api_key_usage
ports:
- "5432:5432"
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL in .env}
volumes:
- ./entrypoint-pg.sh:/entrypoint-pg.sh:ro
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d new_api_key_usage"]
test: ["CMD-SHELL", "pg_isready -U $$(echo $$DATABASE_URL | sed -E 's#^[^:]+://([^:]+):.*#\\1#')"]
interval: 5s
timeout: 3s
retries: 10
retries: 12
restart: unless-stopped
migrate:
container_name: naku-migrate
build:
context: .
target: app
entrypoint: ["/bin/sh", "/entrypoint-app.sh"]
command: ["/app/naku-migrate"]
environment:
DATABASE_URL: ${DATABASE_URL}
MIGRATIONS_FOLDER: /app/drizzle
volumes:
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
depends_on:
postgres:
condition: service_healthy
restart: "no"
server:
container_name: naku-server
build:
context: .
target: app
entrypoint: ["/bin/sh", "/entrypoint-app.sh"]
command: ["/app/naku-server"]
environment:
PORT: "18456"
DATABASE_URL: ${DATABASE_URL}
SK_KEY_ENCRYPTION_SECRET: ${SK_KEY_ENCRYPTION_SECRET:?set SK_KEY_ENCRYPTION_SECRET in .env}
NEWAPI_BASE: ${NEWAPI_BASE:?set NEWAPI_BASE in .env}
COOKIE_SECURE: ${COOKIE_SECURE:-true}
LOG_LEVEL: ${LOG_LEVEL:-info}
ADMIN_INIT_USERNAME: ${ADMIN_INIT_USERNAME:-}
ADMIN_INIT_PASSWORD: ${ADMIN_INIT_PASSWORD:-}
MIGRATIONS_FOLDER: /app/drizzle
volumes:
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
depends_on:
migrate:
condition: service_completed_successfully
restart: unless-stopped
engine:
container_name: naku-engine
build:
context: .
target: app
entrypoint: ["/bin/sh", "/entrypoint-app.sh"]
command: ["/app/naku-engine"]
environment:
DATABASE_URL: ${DATABASE_URL}
SK_KEY_ENCRYPTION_SECRET: ${SK_KEY_ENCRYPTION_SECRET}
NEWAPI_BASE: ${NEWAPI_BASE}
SYNC_INTERVAL_MS: ${SYNC_INTERVAL_MS:-300000}
LOG_LEVEL: ${LOG_LEVEL:-info}
MIGRATIONS_FOLDER: /app/drizzle
volumes:
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
depends_on:
migrate:
condition: service_completed_successfully
restart: unless-stopped
caddy:
container_name: naku-caddy
build:
context: .
target: proxy
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
depends_on:
- server
restart: unless-stopped
volumes:
pgdata:
+7 -7
View File
@@ -4,8 +4,8 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-02) |
| 关联 | 《交互设计-总览与共性》(§1 访问拓扑 · §3 呈现约定)·《交互设计-登录与会话》(鉴权档位与 401 分流)· 七篇表字段设计(DTO 字段权威)·《技术选型》§4(DTO 类型属 `packages/core`) |
| 状态 | 定稿(2026-10-02;R16 修订 2026-10-10) |
| 关联 | 《交互设计-总览与共性》(§1 访问拓扑 · §3 呈现约定)·《交互设计-登录与会话》(鉴权档位与 401 分流)· 各篇表字段设计(DTO 字段权威)·《技术选型》§4(DTO 类型属 `packages/core`) |
| 产出 | 路由前缀与隔离机制 · 鉴权两档 · 错误信封 · 时间 / 数值 / 分页 / 命名约定 |
---
@@ -15,7 +15,7 @@
- JSON over HTTP,UTF-8;请求与响应体均为 `application/json`
- 一套路由代码,两个世界靠**路径前缀与凭证中间件物理隔离**(共性 §1:令牌进不了管理面、会话进不了成员视图)
- DTO 类型定义于 `packages/core`,web 以纯类型依赖引用——前后端一份契约类型(技术选型 §4 依赖规则)
- **端点面向交互设计,不与数据表对齐**——每个端点对应一个功能面(页面 / 流程需要什么);表只是存储模型,按其事实性质获得恰好够用的操作面(引擎专写表纯只读、人工抄录表全 CRUD、账本实体软删除)
- **端点面向交互设计,不与数据表对齐**——每个端点对应一个功能面(页面 / 流程需要什么);表只是存储模型,按其事实性质获得恰好够用的操作面(引擎专写表纯只读、人工抄录表全 CRUD、账本实体软删除为主——**空壳例外可硬删**:无 key 归属的成员行不承载账本事实,见 R16)
## 2. 路由前缀与隔离机制
@@ -27,7 +27,7 @@
- **会话 cookie**:`HttpOnly + Secure + SameSite=Lax`,`Path=/api/admin`(「路径限管理面」裁决的落地);随请求自动携带、登出即删行
- **成员令牌**:`Authorization: Bearer <token>`;明文令牌只存在于登录壳输入与 sessionStorage(关标签即失效——《交互设计-登录与会话》§4)
- 401 分流为前端职责:管理面任意 401 → 登录页「会话已过期」;成员视图任意 401 → 登录壳「令牌无效或已停用」——后端 401 响应不带面特化信息,前端按所在面分流(共性 §1)
- **命名空间约定:`/api/*` 专属接口**——前端页面路由永不进入 `/api` 前缀;未知 `/api/*` 路径 = 接口 `404` 错误信封,非 `/api` 请求归前端托管(SPA history 回退「非 `/api` → index.html」,细则归骨架与部署设计)
- **命名空间约定:`/api/*` 专属接口**——前端页面路由永不进入 `/api` 前缀;未知 `/api/*` 路径 = 接口 `404` 错误信封,非 `/api` 请求归前端托管(SPA history 回退「非 `/api` → index.html」,细则归骨架设计)
## 3. 错误信封
@@ -57,7 +57,7 @@
## 4. 时间口径
- **响应时刻字段:Unix 秒整数**(UTC 时刻,如 `1780000000`)——无时区歧义;本地/北京时间由展示层转换
- **请求时间参数**(`from` / `to` 等):ISO 8601 UTC 字符串(便于试调);范围 **左闭右开 `[from, to)`**
- **请求时间参数**(`from` / `to`、`paid_at` / `period_*` 等):**ISO 8601 带时区偏移**(如 `2026-09-08T00:00:00.000+08:00`,便于人读;亦接受同绝对时刻的 `…Z`);范围 **左闭右开 `[from, to)`**
- 存储层维持 timestamptz / 上游 Unix 秒;DTO 输出统一 `toUnixSec`
- 快捷项(最近 7 / 30 天、本订阅期间、自定义起止)是前端预置逻辑:前端算出 `from/to` 再请求,后端无快捷参数
@@ -77,12 +77,12 @@
## 7. DTO 命名
- **snake_case、与表字段同名同构**——七篇表字段设计即 DTO 字段权威,无映射层
- **snake_case、与表字段同名同构**——各篇表字段设计即 DTO 字段权威,无映射层
- 前端不做 camelCase 转换:全栈 snake_case(组件 props 是 antd 的 API、与数据 DTO 互不干扰)
## 8. 本稿拟定约定(交互设计未覆盖,请作者重点审)
1. 时间传输格式 ISO 8601 UTC 字符串(§4);
1. 时间:**请求侧** ISO 8601 带偏移(§4);**响应侧** Unix 秒整数(§4);
2. 时间范围左闭右开 `[from, to)`(§4);
3. `quota_usd` 字符串两位小数 + 后端换算责任 + 微分取整规则(§5);
4. DTO 与表字段 snake_case 同构、无映射层(§7);
+32 -20
View File
@@ -1,11 +1,11 @@
# API契约-成员视图
> 成员视图(令牌档)全部端点:令牌验证与页头、页面聚合、日志明细。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/member`、Bearer 鉴权、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构;管理面草案 §10 的裁定 1(聚合仅 `type = 2`)与裁定 2(`token_name` 取当前名)在此沿用。成员世界**无**审计、无健康带、无改密(定位边界,《交互设计-成员视图》§3)。
> 成员视图(令牌档)全部端点:令牌验证与页头、总览/支付参数聚合、日志明细。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/member`、Bearer 鉴权、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构;管理面草案 §10 的裁定 1(聚合仅 `type = 2`)与裁定 2(`token_name` 取当前名)在此沿用。成员世界**无**审计、无健康带、无改密(定位边界,《交互设计-成员视图》§2)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-02) |
| 关联 | 《API契约-公共约定》(定稿——总纲)·《API契约-管理面》(草案——裁定沿用)·《交互设计-成员视图》·《交互设计-登录与会话》§4(登录壳)· `members` / `usage_logs` / `payments` 表字段设计(DTO 字段权威) |
| 状态 | 定稿(2026-10-12,作者认可实现) |
| 关联 | 《API契约-公共约定》(定稿——总纲)·《API契约-管理面》·《交互设计-成员视图》·《交互设计-登录与会话》§4(登录壳)· `members` / `usage_logs` / `subscriptions` / `topups` 表字段设计(DTO 字段权威) |
| 产出 | 端点清单(3 个)、请求 / 响应字段、裁定项(§5)、遗留项(§6) |
---
@@ -15,7 +15,7 @@
| # | 端点 | 方法 | 用途 |
|---|------|------|------|
| 1 | `/api/member/me` | GET | 令牌验证 + 页头(登录壳提交的落点) |
| 2 | `/api/member/overview` | GET | 单页聚合:总用量、按来源分解、我的 key、支付参数卡 |
| 2 | `/api/member/overview` | GET | 总览与支付参数聚合:总用量、按来源分解、我的 key、支付参数列表 |
| 3 | `/api/member/logs` | GET | 日志明细(名下 key,分页) |
## 2. 令牌验证与页头
@@ -27,7 +27,7 @@
- 登录壳提交 = 前端将令牌存 sessionStorage 后调本端点(`Authorization: Bearer`):验证通过(哈希命中 + `status = active`)→ 进入成员视图;`401` → 登录壳(前端固定文案「令牌无效或已停用」,不区分停用与无效——公共约定 §2)
- **不建会话**:令牌即长期凭证(`members` 表设计 3.2——无会话语义、无登出端点);旧令牌在作者重发后于下一请求自然 `401`
## 3. 单页聚合
## 3. 总览与支付参数聚合
### GET `/api/member/overview?from&to` → 200
@@ -45,24 +45,35 @@
{ "key_id": 5, "token_name": "小张-01", "log_synced_at": "2026-10-02T08:00:00Z",
"usage": { "count": 300, "quota": 1500000, "quota_usd": "3.00" } }
],
"payments": [
{ "id": 3, "kind": "subscription", "paid_at": "2026-09-01T00:00:00Z",
"paid_amount": 170, "remark": "九月拼车",
"period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z",
"subscription_id": 3, "quota_granted": 12500000, "quota_usd": "25.00" }
"subscriptions": [
{ "id": 3, "subscription_id": 44, "period_start": "2026-09-01T00:00:00Z",
"period_end": "2026-10-01T00:00:00Z", "quota_granted": 12500000, "quota_usd": "25.00",
"paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z", "billing_domain": "carpool", "remark": "九月拼车",
"created_at": "2026-09-02T12:00:00Z" }
],
"topups": [
{ "id": 1, "upstream_id": 17, "amount": 1, "money": 2.04,
"trade_no": "USR6…", "payment_method": "alipay", "payment_provider": "epay",
"status": "success", "paid_at": "…", "billing_domain": "carpool",
"quota_derived": 500000, "quota_usd": "1.00",
"remark": "", "created_at": "…" }
]
}
```
- `usage`:该时段总用量(聚合卡);`by_source`:订阅 / 充值分解(分段条);`keys`:我的 key 窄卡(`log_synced_at` 支撑停滞标注)
- `payments`:**支付参数卡 + 徽章展开行 + 「本订阅期间」快捷项的数据源**,判定规则见 §5 裁定 2;对成员**全字段可见**(含 `remark`——完全公开透明裁决);`payments` 全空 = 纯用量模式中性空态(前端事)
- 徽章展开行仅限「订阅 #N」徽章:「充值」不展开(余额池扣款挂不到具体某笔,展开无答案——§5 裁定 4)、「未关联」(日志缺 `subscription_id`)不可展开;降级:徽章 `subscription_id` 不在 `payments` 列表 → 「该订阅无登记信息」——空值场景各自归位(《交互设计-成员视图》§1.6)
- `usage`:该时段总用量(总览摘要条);`by_source`:订阅 / 充值分解(分段条);`keys`:我的 Key 表(`log_synced_at` 支撑停滞标注)
- `subscriptions` / `topups`:**支付参数页 + 订阅徽章弹窗 + 「本订阅期间」快捷项**的数据源,判定见 §5 裁定 2;对成员**同账本域内全字段可见**(含 `remark`);两集皆空 = 纯用量模式中性空态
- 订阅徽章弹窗仅限「订阅 #N」(查 `subscriptions`):「充值」不弹窗(余额池无逐笔)、「未关联」不可点;降级「该订阅无登记信息」
## 4. 日志明细
### GET `/api/member/logs?from&to` → 200 `{items, total}`
### GET `/api/member/logs?from&to` → 200 `{items, total, summary, models}`
分页(公共约定 §6);过滤 `from/to`(必填);范围恒为名下 key、恒 `type = 2`(§5 裁定 1——成员世界是「我的用量」视角,错误行排查属管理面)。
分页(公共约定 §6);过滤 `from/to`(必填);范围恒为名下 key;**默认全类型**(消费 + 错误,可 `type` 筛——2 / `error`)。
可选筛选(调用日志页,与管理面 `GET /api/admin/logs` 同名同义):`api_key_id` · `model_name` · `billing_source`(`subscription` / `wallet` / `none`)· `subscription_id` · `type`(2 / `error`)。
响应附带 `summary` / `models`(与管理面同构,见《API契约-管理面》§6):`summary` 为当前筛选下条数与 **type=2** 消费用量;`models` 为时间窗 + 主体筛选下去重的模型选项(不含 `model_name` / `type` 筛选)。
明细行 DTO 与管理面 `GET /api/admin/logs` **同构**(公共约定 §7——一份 DTO 类型两处复用):
@@ -73,18 +84,19 @@
"quota": 250000, "quota_usd": "0.50",
"prompt_tokens": 1200, "completion_tokens": 800,
"use_time": 3, "is_stream": true,
"billing_source": "subscription", "subscription_id": 3
"billing_source": "subscription", "subscription_id": 3,
"raw": { "id": 12345, "request_id": "…", "other": {}, "channel": 1 }
}
```
`token_name` 取当前名(管理面裁定 2 沿用)。
`token_name` 取当前名(管理面裁定 2 沿用)。`raw` 为**完整落库行**(上游字段原貌 + 系统字段,`created_at` 为 Unix 秒)——界面「原始数据」视图的数据源;管理面 `GET /api/admin/logs` 同构带出。
## 5. 本稿裁定项(请作者重点审)
1. **成员视图明细恒 `type = 2`**——对齐聚合口径;无 `type` 筛选参数(管理面明细的全类型排查能力不进成员世界);
2. **「该时段涉及」支付记录的判定**:订阅 = 该时段日志(`type = 2`)出现过的 `subscription_id` 对应记录 **∪ 进行中的订阅**(`period_end` 为空或未来——支撑「本订阅期间」快捷项与「该订阅无登记信息」降级);充值 = `paid_at ∈ [from, to)`(充值消耗无逐笔关联可查,按支付时间锚定);
1. **成员视图明细默认全类型**(消费 + 错误)——对齐「事实可查」;聚合仍仅 `type = 2`(用量口径不变);`type` 可筛 2 / `error`;
2. **「该时段涉及」登记的判定**(账本域隔离):成员的**我的域** = 名下 key 的 `billing_domain` 非空并集。订阅 = 该时段日志(`type = 2`)出现过的 `subscription_id` 对应 `subscriptions` 行(**事实直连**,含未标域) **∪**(进行中 **∧** `billing_domain ∈ 我的域`)——未标域进行中订阅不放行;充值 = **`billing_domain ∈ 我的域`** ∧ `paid_at ∈ [from, to)`——未标域充值不放行。管理面不受此限;
3. **明细行 DTO 与管理面同构**:同一 `LogItem` 类型,成员端经归属与 `type` 恒定过滤;
4. **「充值」徽章不展开**——余额池扣款与具体充值记录无逐笔关联,展开回答不了「这笔花在哪」(《交互设计-成员视图》§1.6 未定义处的落定:徽章展开行仅限订阅徽章,「充值」为纯标识)。
4. **「充值」徽章不弹窗**——余额池扣款与具体充值记录无逐笔关联,弹窗回答不了「这笔花在哪」(《交互设计-成员视图》§4.2:订阅徽章才开支付参数弹窗,「充值」为纯标识;行展开只放模型调用详情)。
## 6. 遗留项
+100 -31
View File
@@ -1,11 +1,11 @@
# API契约-管理面
> 管理面(作者,会话档)全部端点:引导与会话、引擎状态、全员总览、key 管理、日志明细(下钻)、支付记录、成员管理、审计日志。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/admin`、会话 cookie 鉴权、错误信封、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构。
> 管理面(作者,会话档)全部端点:引导与会话、引擎状态、全员总览、key 管理、日志明细(下钻)、订阅权益 / 充值入账、成员管理、审计日志。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/admin`、会话 cookie 鉴权、错误信封、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-02) |
| 关联 | 《API契约-公共约定》(定稿——总纲)· 八篇交互设计(定稿)· `api_keys` / `usage_logs` / `payments` / `qpu_history` / `members` 表字段设计(DTO 字段权威)· `需求清单.md` §9(访问主体模型) |
| 状态 | 定稿(2026-10-12,作者认可实现;含 10-09 支付拆表与 R16) |
| 关联 | 《API契约-公共约定》(定稿——总纲)· 八篇交互设计 · `api_keys` / `usage_logs` / `subscriptions` / `topups` / `qpu_history` / `members` / `users` / `sessions` 表字段设计(DTO 字段权威)· `需求清单.md` §9(访问主体模型) |
| 产出 | 端点清单、请求 / 响应字段、错误码、本稿裁定项(§10)与遗留项(§11) |
---
@@ -23,13 +23,15 @@
| 7 | `/api/admin/keys` | GET / POST | key 清单 / 登记 |
| 8 | `/api/admin/keys/:id` | PATCH | 启停 / 归属变更 |
| 9 | `/api/admin/logs` | GET | 日志明细(下钻,分页) |
| 10 | `/api/admin/payments` | GET / POST | 支付记录清单 / 登记 |
| 11 | `/api/admin/payments/:id` | PUT / DELETE | 编辑 / 删除 |
| 12 | `/api/admin/qpu/latest` | GET | 当前换算率(表单美元双输入用) |
| 13 | `/api/admin/members` | GET / POST | 成员清单 / 开户(令牌明文仅一次) |
| 14 | `/api/admin/members/:id` | PATCH | 停用 / 启用 |
| 15 | `/api/admin/members/:id/token` | POST | 令牌重发(明文仅一次) |
| 16 | `/api/admin/audit-logs` | GET | 审计日志(分页) |
| 10 | `/api/admin/subscriptions` | GET / POST | 订阅权益清单 / 登记 |
| 11 | `/api/admin/subscriptions/:id` | PUT / DELETE | 编辑 / 删除 |
| 12 | `/api/admin/topups` | GET / POST | 充值入账清单 / 登记 |
| 13 | `/api/admin/topups/:id` | PUT / DELETE | 编辑 / 删除 |
| 14 | `/api/admin/qpu/latest` | GET | 当前换算率(表单美元双输入用) |
| 15 | `/api/admin/members` | GET / POST | 成员清单 / 开户(令牌明文仅一次) |
| 16 | `/api/admin/members/:id` | PATCH / DELETE | 档案修改(name/remark)与启停 / 空壳删除(仅 `key_count = 0`) |
| 17 | `/api/admin/members/:id/token` | POST | 令牌重发(明文仅一次) |
| 18 | `/api/admin/audit-logs` | GET | 审计日志(分页) |
## 2. 引导与会话
@@ -117,12 +119,13 @@
### GET `/api/admin/keys` → 200 `[key]`
全量直返(基数 10–25,不分页——§10 裁定 3)。筛选参数:`member_id`(id 或 `unassigned`)、`status`、`recent_failed=true`(引擎最近轮次失败)。排序默认数据截止降序(前端事)。
全量直返(基数 10–25,不分页——§10 裁定 3)。筛选参数:`member_id`(id 或 `unassigned`)、`status`、`billing_domain`(值或 `none` = 未标注)、`recent_failed=true`(引擎最近轮次失败)。排序默认数据截止降序(前端事)。
```json
{
"id": 5, "token_name": "小张-01", "status": "active",
"member_id": 1, "member_name": "小张",
"billing_domain": "carpool",
"registered_at": "2026-09-20T10:00:00Z",
"log_synced_at": "2026-10-02T08:00:00Z", "usage_synced_at": "2026-10-02T08:00:00Z",
"total_used": 600000, "total_available": 940000, "total_granted": 1540000,
@@ -134,7 +137,7 @@
### POST `/api/admin/keys` → 201 `key`
`{ "sk_key": "sk-…", "member_id": null }`——登记四态:
`{ "sk_key": "sk-…", "member_id": null, "billing_domain": null }`——登记四态:
| 结果 | 响应 |
|------|------|
@@ -145,13 +148,34 @@
### PATCH `/api/admin/keys/:id` → 200 `key`
`{ "status": "disabled" }`(启用 / 停用)或 `{ "member_id": 3 }`(归属变更;`null` = 归还未归属)——部分更新,单字段或并存均可,审计按实际变更字段各落一行(§9 枚举)。非法值 `422`;key 不存在 `404`;`member_id` 引用不存在成员 `422`(`fields`)。
`{ "status": "disabled" }`(启用 / 停用)· `{ "member_id": 3 }`(归属变更;`null` = 归还未归属)或 `{ "billing_domain": "office" }`(账本域变更;`null` = 未标域)——部分更新,单字段或并存均可,审计按实际变更字段各落一行(§9 枚举)。非法值 `422`;key 不存在 `404`;`member_id` 引用不存在成员 `422`(`fields`)。
## 6. 日志明细(管理面下钻)
### GET `/api/admin/logs?from&to` → 200 `{items, total}`
### GET `/api/admin/logs?from&to` → 200 `{items, total, summary, models}`
分页(`page/page_size`,公共约定 §6)。过滤:`from/to`(必填)、`api_key_id`、`member_id`(经 `api_key_id → api_keys.member_id` 联查**当前归属**——行级归属语义)、`billing_source`、`subscription_id`、`type`。默认全类型(错误行排查入口——§10 裁定 1 的另一半)。
分页(`page/page_size`,公共约定 §6)。过滤:`from/to`(必填)、`api_key_id`、`member_id`(经 `api_key_id → api_keys.member_id` 联查**当前归属**——行级归属语义)、`model_name`、`billing_source`、`subscription_id`、`type`。默认全类型(错误行排查入口——§10 裁定 1 的另一半)。
**响应附加块**(汇总条与模型下拉的数据源;成员端 `/api/member/logs` 同构):
```json
{
"summary": {
"hit_count": 131, "usage_count": 128, "error_count": 3,
"quota": 1250000, "quota_usd": "2.50"
},
"models": ["claude-sonnet-4", "gpt-5"]
}
```
| 字段 | 口径 |
|------|------|
| `hit_count` | 当前筛选命中总数(= `total`;界面文案「请求次数」) |
| `usage_count` / `error_count` | 同筛选下 `type=2` / `type≠2` 条数 |
| `quota` / `quota_usd` | 同筛选下 **type=2** 行合计;美元按行 QPU 微分(公共约定 §5) |
| `models` | 时间窗 + 主体/来源/订阅筛选下去重 `model_name`(**不含** `model_name` / `type`),升序,供筛选下拉 |
裁定:条数跟全部筛选(含 `type` / `model_name`);用量字段恒 `type = 2`(即使请求 `type=error`)——界面标签「消费用量」。
```json
{
@@ -166,30 +190,66 @@
`token_name` 取**当前名**(联查 `api_keys`,与「展示名以当前为准」对齐——§10 裁定 2);`username` / `channel` / `ip` / `token_id` / `request_id` / `other` 等其余表字段暂不出 API,界面需要时再扩。
> **界面落点待补**:总览「行点击 → Key 管理页该 key 详情」——交互设计无成篇的「key 详情」视图,本端点先行、界面归交互补篇或实现期定(§11 遗留 1)。
> **界面落点**:《交互设计-调用日志》——管理面独立查询页;总览/Key/成员筛选深链 + 行内回跳。
## 7. 支付记录
## 7. 订阅权益与充值入账
### GET `/api/admin/payments` → 200 `[payment]`
> 原 `payments` 单表已拆(`payments建模评估.md` 方案 B):**订阅 = 权益**(期间 + 额度预算 + 可选付款事实),**充值 = 入账事件**。UI 菜单仍叫「支付记录」,API 按资源分组。
全量直返(年十位数,不分页),时间倒序(后端排序)。
### GET `/api/admin/subscriptions` → 200 `[subscription]`
全量直返(年个位数,不分页),`period_start` 倒序。
```json
{
"id": 3, "kind": "subscription", "paid_at": "2026-09-01T00:00:00Z",
"paid_amount": 170, "remark": "九月拼车",
"id": 3, "subscription_id": 44,
"period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z",
"subscription_id": 3, "quota_granted": 12500000, "quota_usd": "25.00"
"quota_granted": 12500000, "quota_usd": "25.00",
"paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z",
"billing_domain": "carpool",
"remark": "九月拼车", "created_at": "2026-09-02T12:00:00Z"
}
```
`quota_usd` 按支付时刻(`paid_at`)QPU 换算(§10 裁定 4);`kind = topup` 行 `period_*` / `subscription_id` 为 `null`。
- `quota_usd` 换算锚点优先级:`paid_at` → 否则 `created_at`(§10 裁定 4 修订)
- `paid_amount` / `paid_at` 可空(付款事实未知)
### POST `/api/admin/payments` → 201 `payment`
### POST `/api/admin/subscriptions` → 201 `subscription`
`kind` 分形态必填校验(`422`):公共 `{kind, paid_at, paid_amount, remark?}`;`subscription` 追加必填 `period_start` / `subscription_id` / `quota_granted`(`period_end` 可空 = 进行中);`topup` 追加必填 `quota_granted`。表单美元双输入为前端换算(按 `qpu/latest` 预览),落库恒原始 quota(表设计 3.6)。
必填:`subscription_id` · `period_start` · `quota_granted`;可空:`period_end`(=进行中)· `paid_amount` · `paid_at` · `billing_domain` · `remark`。美元双输入为前端换算,落库恒原始 quota。列表筛选支持 `billing_domain`(值或 `none`)。
### PUT `/api/admin/payments/:id` → 200 `payment` / DELETE → 204
### PUT `/api/admin/subscriptions/:id` / DELETE → 204
### GET `/api/admin/topups` → 200 `[topup]`
全量直返,`paid_at` 倒序。对齐上游充值单;**事件时刻单列**(上游 `complete_time`),**无 `quota_granted` 入库列**:
```json
{
"id": 1, "upstream_id": 17,
"amount": 1, "money": 2.04,
"trade_no": "USR6NOLqTPGa1791534153",
"payment_method": "alipay", "payment_provider": "epay",
"status": "success",
"paid_at": "2026-10-09T12:22:56Z",
"billing_domain": "carpool",
"quota_derived": 500000, "quota_usd": "1.00",
"remark": "", "created_at": "2026-10-09T13:00:00Z"
}
```
`quota_derived` / `quota_usd` 为**派生**(`amount × qpu`,锚点 `paid_at`),不入库。
### POST `/api/admin/topups` → 201 `topup`
必填:`paid_at` · `amount` · `money`;可空:`upstream_id` · `trade_no` · `payment_method` · `payment_provider` · `status` · `billing_domain` · `remark`。列表筛选支持 `billing_domain`(值或 `none`)。
### PUT `/api/admin/topups/:id` / DELETE → 204
### 平台辅助(登录代拉,密码不入库)
已有:`POST /api/admin/platform/login` · `POST /api/admin/platform/subscriptions`。
**新增**:`POST /api/admin/platform/topups` body `{ access_token, page, page_size }` → `{ items, total, page, page_size }`——透传 `/api/user/topup/self` **单页**;前端传统分页、可翻至尽头、无时间参数、无页数硬上限(见交互篇 §4.1)。不做后台定时同步。
编辑全字段、删除有确认(前端);改错即修,均落审计。
@@ -223,7 +283,16 @@
### PATCH `/api/admin/members/:id` → 200 `member`
`{ "status": "disabled" }` / `active`——停用即成员视图访问立即失效(令牌校验含状态判断)。
部分更新,单字段或并存均可,审计按实际变更字段各落一行(§9):
- `{ "status": "disabled" }` / `active`——停用即成员视图访问立即失效(令牌校验含状态判断)
- `{ "name": "小张" }` / `{ "remark": "同事" }`(R16 档案修改)——`name` 冲突 `409 member_name_taken`;不接受 `token_hash` / `created_at` / `id`
非法值 `422`;成员不存在 `404`。
### DELETE `/api/admin/members/:id` → 204
空壳删除(R16):**仅当该成员当前 `key_count = 0`**(无任何 `api_keys.member_id` 指向)才物理删除行;否则 `409 member_has_keys`(`fields` 可附 `key_count`)。删除后名称 UNIQUE 释放、令牌一并作废。名下有 key 的清理路径 = 停用(软删除语义不变)。均落审计。
### POST `/api/admin/members/:id/token` → 201
@@ -248,8 +317,8 @@
| 域 | action 枚举 |
|----|------------|
| key | `key.register` / `key.enable` / `key.disable` / `key.reassign`(归属变更) |
| payment | `payment.create` / `payment.update` / `payment.delete` |
| member | `member.create` / `member.enable` / `member.disable` / `member.token_issue`(开户自动签发)/ `member.token_reissue` |
| payment | `subscription.create` / `subscription.update` / `subscription.delete` / `topup.create` / `topup.update` / `topup.delete`(UI 菜单「支付记录」) |
| member | `member.create` / `member.update`(档案改 name/remark)/ `member.enable` / `member.disable` / `member.token_issue`(开户自动签发)/ `member.token_reissue` / `member.delete`(空壳硬删) |
| account | `account.password_change` / `account.init`(系统,operator null) |
每个管理面写操作各落一行;查询类与成员侧动作不留痕。**表结构归技术稿(members / api_keys 表设计既有标注),立稿时以本文 DTO 字段为对齐锚点**(§11 遗留 2)。
@@ -258,8 +327,8 @@
1. **用量聚合口径仅 `type = 2`(消费行)**——usage_logs §5 待定「type 过滤策略」落定:聚合与统计只计消费行(错误行入聚合即失真);明细查询默认全类型、可按 `type` 筛(错误排查入口);
2. **日志明细 `token_name` 取当前名**(联查 `api_keys`;快照名存库不出 API)——表设计 2.3 待定「展示口径」落定;
3. **有限基数清单不分页**:`keys` / `members` / `payments` 直返全量数组;公共约定 §6 的「列表类分页」指无界列表(`logs` / `audit-logs`);
4. **支付记录 `quota_usd` 按支付时刻(`paid_at`)QPU 换算**——对齐「按日志时刻取值」的历史正确性原则;
3. **有限基数清单不分页**:`keys` / `members` / `subscriptions` / `topups` 直返全量数组;公共约定 §6 的「列表类分页」指无界列表(`logs` / `audit-logs`);
4. **`quota_usd` 换算锚点**:充值恒 `paid_at`(= 上游 `complete_time`);订阅优先 `paid_at`、否则 `created_at`——对齐「按日志时刻取值」的历史正确性原则;
5. **公共约定随本稿与审阅增补四处**:状态表加 `429`(登录节流)、错误信封允许端点附机器可读附注字段(`409 key_already_registered` 附 `key_id`)、`/api` 命名空间专属接口(页面路由永不进入)、端点面向交互设计不与数据表对齐。
## 11. 遗留项(归后续立稿或作者裁决)
+3 -2
View File
@@ -32,6 +32,7 @@
| `key_ciphertext` | bytea | 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 | 调上游时解密原文(唯一用途) |
| `status` | text | 管理面启停(引擎只读) | `active` / `disabled`;轮询清单 = active;disabled 数据保留、仅移出轮询 |
| `member_id` | bigint FK NULL | 归属登记(管理通道) | → `members.id`;成员视图与按成员统计的锚点;空 = 未归属 |
| `billing_domain` | text NULL | 人工标注(登记 / 行内编辑) | **账本域标签**(如 `carpool` / `office`)——区分用途的自由文本,非身份、非权限;空 = 未标域;成员可见性与支付记录的 `billing_domain` 对齐 |
| `registered_at` | timestamptz | 登记时写入 | 空态文案「数据自该 key 登记起累积」 |
| `log_synced_at` | timestamptz NULL | 日志同步成功时写入 | **「数据截止 HH:mm」的唯一来源**;同步失败的显形信号(停滞) |
| `usage_synced_at` | timestamptz NULL | 额度采集成功时写入 | 额度信息的新鲜度(UI 若做)、排查 |
@@ -55,7 +56,7 @@
- `key_hash`(HMAC-SHA256,不可逆):只服务「命中判定」——没有它,每次查询都要把全部 key 送去上游验证
- `key_ciphertext`(AES-256-GCM,可解密):唯一用途是调上游
- 密钥 `SK_KEY_ENCRYPTION_SECRET` 经 env 注入(HKDF 派生 hmac / aes 子密钥);**密钥丢失 = 全部 key 需重新登记**(记入部署文档)
- 密钥 `SK_KEY_ENCRYPTION_SECRET` 经 env 注入(HKDF 派生 hmac / aes 子密钥);**密钥丢失 = 全部 key 需重新登记**
> **注解:为什么是两个字段(给非密码学背景的读者)**
>
@@ -97,7 +98,7 @@
### 3.7 表名:`api_keys`
- 理由:与页面 `/manage/api-keys` 同词对齐;`sk-` 只是 key 的实际格式前缀(不属表名职责),`api_keys` 为领域通用词,与其余各表(`usage_logs` / `payments` / `qpu_history`)风格一致
- 理由:与页面 `/manage/api-keys` 同词对齐;`sk-` 只是 key 的实际格式前缀(不属表名职责),`api_keys` 为领域通用词,与其余各表(`usage_logs` / `subscriptions` / `topups` / `qpu_history`)风格一致
### 3.8 归属列 `member_id`:外键在本表,且可空
+22 -11
View File
@@ -4,8 +4,8 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `docs/design/api_keys表字段设计.md` 3.9(归属列指向本表)· `docs/design/需求清单.md` §9 |
| 状态 | 定稿(2026-10-01;R16 修订 2026-10-10) |
| 关联 | `docs/design/api_keys表字段设计.md` 3.9(归属列指向本表)· `docs/design/需求清单.md` §9(访问主体模型)· R16(档案可改、空壳删除) |
| 范围 | 列结构 + 语义 + 写入时机;令牌生成细节归技术稿;方言映射归数据访问层设计 |
| 字段数 | 7 = 1 主键 + 6 业务字段 |
@@ -21,13 +21,13 @@
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | |
| `name` | text UNIQUE | 管理面录入 | 显示名——账目与视图中的署名 |
| `remark` | text | 管理面录入 | 备注(联系方式、是谁等;可空) |
| `status` | text | 管理面 | `active` / `disabled`——停用即成员视图访问立即失效(令牌校验含状态判断) |
| `token_hash` | text | 管理面生成令牌时 | 成员视图的访问凭证(哈希存储,明文只在生成时展示一次) |
| `token_issued_at` | timestamptz | 同上 | 当前令牌的签发时间(审计线索) |
| `created_at` | timestamptz | 开户时 | |
| `id` | bigserial PK | 自增 | 不可改 |
| `name` | text UNIQUE | 管理面录入(可改) | 显示名——账目与视图中的署名;开户后可改(R16) |
| `remark` | text | 管理面录入(可改) | 备注(联系方式、是谁等;可空);开户后可改(R16) |
| `status` | text | 管理面启停 | `active` / `disabled`——停用即成员视图访问立即失效(令牌校验含状态判断);**不并入档案编辑** |
| `token_hash` | text | 管理面生成令牌时 | 成员视图的访问凭证(哈希存储,明文只在生成时展示一次);仅随重发更新 |
| `token_issued_at` | timestamptz | 同上 | 当前令牌的签发时间(审计线索);仅随重发更新 |
| `created_at` | timestamptz | 开户时 | 不可改 |
## 3. 决策记录(含理由)
@@ -45,17 +45,28 @@
账目署名不可歧义。
### 3.4 软删除(`status`)而非物理删除
### 3.4 软删除(`status`)为主,空壳例外硬删(R16)
成员名下的 key 与历史用量是账本的一部分——「退拼」是停用(保留行、停访问),不是抹掉事实。
成员名下的 key 与历史用量是账本的一部分——「退拼」是停用(保留行、停访问),不是抹掉事实。**默认无物理删除**。
**空壳例外(R16)**:当前 `key_count = 0`(无任何 `api_keys.member_id` 指向本行)时允许硬删——该行不承载账本事实,删了只清掉误建/未使用的开户壳,并释放 UNIQUE 名称。名下有 key 一律拒绝删除(只能停用)。硬删与软删除不冲突:账本实体的默认语义仍是软删除,空壳删是「无账本可保」时的清理通道。
### 3.5 档案字段可改(`name` / `remark`)(R16)
- **动机**:开户后挂 key 前发现名称不对(误建、占位名)——已发出令牌时「改名」优于「删了重建」(重建会作废旧令牌)
- **可改字段收窄**:仅 `name` / `remark`;`status` / 令牌 / `id` / `created_at` 各走各的生命周期,不并入档案编辑
- **`name` 仍 UNIQUE**(3.3 不变);冲突 `409 member_name_taken`
- **展示口径 = 当前名**:日志 / 总览 / 成员视图联查实时取 `members.name`(与 `api_keys.token_name` 的「当前名为准」同则)——历史署名随改名漂移,可接受
## 4. 写入时机
| 时机 | 动作 |
|------|------|
| 开户(管理面) | INSERT(`name` / `remark`;生成令牌 → `token_hash` + `token_issued_at`) |
| 档案修改(R16) | UPDATE `name` 和/或 `remark` |
| 停用 / 启用 | UPDATE `status` |
| 重发令牌 | UPDATE `token_hash` + `token_issued_at`(旧令牌即失效) |
| 空壳删除(R16) | DELETE(仅 `key_count = 0`;名下有 key 拒绝) |
以上全部为管理面写操作,均落**审计日志**(审计为独立需求,表结构归技术稿)。
+141
View File
@@ -0,0 +1,141 @@
# payments 建模评估:充值与订阅要不要拆表
> 对「一张 `payments` 同构装下订阅 + 充值」的全面复盘。触发点:`paid_at` 语义在订阅行上更像 `created_at`,按付费时间筛会在**后补录**时丢行。**结论:方案 B 拆表已采纳**(2026-10-09)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-09,作者采纳方案 B) |
| 关联 | `subscriptions表字段设计.md` · `topups表字段设计.md`(替代原 `payments` 单表)· `NewAPI-接口范围.md` §2.⑨ §4.3 · `需求清单.md` R7/R8/R9 · `usage_logs表字段设计.md` 3.6 |
| 依据 | 上游订阅实体字段(钱包页 / `GET /api/subscription/self`)· 日志 `billing_source` / `subscription_id` · 现行登记与筛选交互 |
---
## 1. 领域里其实有两种东西
NewAPI 侧(接口参考 §4.3、日志 `other`):
| 上游概念 | 本质 | 时间轴 | 与消耗的关系 |
|----------|------|--------|--------------|
| **订阅**(`all_subscriptions[].id`) | **权益包**:`start_time`/`end_time` 覆盖期 + `amount_total`/`amount_used` 额度预算 + `created_at` | **期间** | 日志 `billing_source=subscription` + `subscription_id` 直连 |
| **充值**(`LogTypeTopup=1`) | **入账事件**:钱 → 钱包额度;账号级、无 token_id | **事件时刻** | 日志 `billing_source=wallet`,**无**逐笔 `subscription_id`(余额池) |
注意:上游**没有**「订阅付款单」——订阅的 `created_at` 是权益创建时刻,不是「作者何时把钱付给组织者」。后者只存在于社交层,正是 `payments` 要手工抄录的「支付事实」。
因此登记行混合了两类事实:
1. **权益事实**(订阅:编号、期间、总额度)——来自钱包页,客观
2. **金钱事实**(谁付了多少)——来自支付凭证/转账,社交层,**可与权益不同日**(预付、分期、事后补账)
## 2. 现行单表哪里拧着
`payments` 九列里约一半是「订阅专用可空」:
| 列 | 订阅 | 充值 | 问题 |
|----|------|------|------|
| `kind` | `subscription` | `topup` | 判别式列(合法但胖) |
| `paid_at` | 混用:真实付款日 **或** 登记日 | 真实充值日 | **语义分叉**(本次痛点) |
| `paid_amount` | 套餐价(可能与期间不同日) | 充值额 | 尚可 |
| `period_start/end` | 必填 | NULL | 可空列 |
| `subscription_id` | 必填 | NULL | 可空列 |
| `quota_granted` | 套餐总额度 | 充值换得额度 | 语义都是「换得的量」,可共用 |
| `remark` | ✓ | ✓ | |
| (无) | **登记时刻** | **登记时刻** | 补录/审计缺失 |
连带后果:
- **筛选**:按 `paid_at` 筛「这段时间的账」→ 后补录的订阅落在补录日,对不上用量窗口;按期间筛又对不上充值(无期间)。
- **QPU 换算锚点**:契约写「按 `paid_at` 换算 `quota_usd`」——若 `paid_at` 在订阅行上是登记日,换算率锚点也跟着漂。
- **校验**:kind 分支必填(契约已做),但类型系统层仍是「全可空 + 运行时 if」。
## 3. 三个方案
### 方案 A:单表保留,时间语义拆开(小改)
```
payments
kind, paid_amount, remark, quota_granted ← 共用
created_at timestamptz NOT NULL ← 新增:登记入库时刻
paid_at timestamptz? NULLABLE ← 仅「钱出手」时刻;订阅可空
period_start/end, subscription_id ← 仅订阅
```
| 筛选口径 | 规则 |
|----------|------|
| 登记时间 | `created_at`(后补录可查) |
| 充值 | `paid_at ∈ [from,to)` |
| 订阅 | 期间重叠:`period_start < to AND (period_end IS NULL OR period_end > from)` |
- **利**:迁移小(加一列 + 语义清洗存量 `paid_at`);API/DTO 形状基本不动。
- **弊**:仍是判别式胖行;「订阅的付款日」与「权益期间」继续挤在同一行心智里。
### 方案 B:拆两张表(对齐上游)
```
subscriptions topups
id PK id PK
subscription_id UNIQUE upstream_id? ← 上游充值单 id
period_start NOT NULL amount NOT NULL ← 平台计费金额(充值额)
period_end NULL money NOT NULL ← 实付
quota_granted NOT NULL trade_no / payment_* ← 渠道事实
paid_amount NULL paid_at NOT NULL ← = 上游 complete_time(唯一事件时刻)
paid_at NULL status
remark remark
created_at NOT NULL created_at NOT NULL
```
(2026-10-09 实测修正:上游充值单**无 `quota_granted`**——`amount × qpu` 派生;事件时刻**只留一条** `paid_at`=`complete_time`,`create_time` 不入库。)
- **利**:字段全非空(或意义明确);时间轴各归各;与 NewAPI「权益 vs 入账」同构;筛选规则按表定义,无 if-else。
- **弊**:登记 UI 两种表单(可仍一页两 Tab);「统一支付流水」要 UNION 视图;迁移把现表按 `kind` 拆走。
### 方案 C:三张表(金钱流水与权益彻底解耦)——**不建议**
`money_events` + `subscriptions` + 关联。年登记量个位数~十位数,过重。
## 4. 建议
**采用方案 B(拆表),展示层合并流水。**
理由摘要:
1. **领域本来就是两种东西**——上游也是。单表同构是登记方便,不是概念等价。
2. **时间轴不同**是刚性的:权益用期间、充值用事件、补录用登记时刻;塞进一列必然语义污染。
3. 规模极小(设计稿:年十行级)——拆表成本几乎只有代码路径,没有分片/联查负担。
4. `paid_at` 问题在 B 里消失:`topups.paid_at` 恒为事件;`subscriptions.paid_at` 明确为「套餐付款日,可空」;`created_at` 两表都有。
**统一入口**(交互不变感):
- 管理面「支付记录」页 = 两 Tab(订阅 / 充值)或一张 UNION 列表(按时间轴分别标注「期间 / 付费 / 登记」)。
- 成员「支付参数」页同源两集,R12 弹窗仍只挂订阅。
- 「该时段涉及」:订阅看期间重叠 ∪ 进行中;充值看 `paid_at`;补录审计看 `created_at`。
**字段语义钉死(两表通用原则)**:
| 时刻 | 含义 | 谁写 |
|------|------|------|
| `created_at` | 作者按下「登记」的时刻 | 系统 |
| `paid_at` | 钱真实出手时刻(凭证上的时间) | 人工;不知则空 |
| `period_*` | 用量覆盖期 `[start, end)` | 人工(抄钱包页) |
**迁移要点**(若采纳):
1. 存量 `kind=subscription` 行 → `subscriptions`;`kind=topup` → `topups`。
2. 存量订阅行的 `paid_at`:若当时填的是登记日 → 迁到 `created_at`,`paid_at` 置空(或作者按凭证补真付款日)。
3. `quota_usd` 换算锚点:优先 `paid_at`(有则),否则 `created_at` 或 `period_start`——契约里写死优先级。
4. 日志 `subscription_id` 直连改为指向 `subscriptions.subscription_id`(语义不变)。
5. 纯用量模式(零登记)不受影响。
**方案 A 的适用情形**:只想先止血筛选/补录、暂不动 CRUD 与 API 形状——则 A 是可接受的过渡;终态仍建议 B。
## 5. 与既有裁决的关系
| 裁决 | 拆表后 |
|------|--------|
| R7 充值与订阅同构(登记形态) | **展示/哲学同构**(只存事实、无分摊)不变;**物理模型分表** |
| R8 `subscription_id` 直连 | 不变,指向订阅表 |
| R9 币种无关、只存事实 | 不变 |
| 「payments」表名 | 订阅表可名 `subscriptions`;充值表 `topups`;若保留对外「支付记录」只作 UI 名 |
---
**已采纳方案 B(2026-10-09)**:表设计见 `subscriptions表字段设计.md` / `topups表字段设计.md`;契约与交互随拆表修订。
-98
View File
@@ -1,98 +0,0 @@
# payments 表字段设计
> 数据模型第三张表:支付记录——每笔支付事件(订阅购买 / 充值)一行。平台只存**事实**(支付金额、换得额度、来源参数),「支付金额与额度之间的比率」是查询时的派生视图,不存储、不做任何分摊计算。本文记录字段清单、语义与决策理由。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `docs/design/usage_logs表字段设计.md` 3.6(`subscription_id` 直连与本表)· `docs/design/qpu_history表字段设计.md`(quota → 额度美元的换算时间线)· `docs/references/NewAPI-接口范围.md` §2.⑨(`other.subscription_*` 字段依据) |
| 范围 | 列结构 + 语义 + 写入时机;方言映射归数据访问层设计 |
| 字段数 | 9 = 1 主键 + 8 人工录入(支付 4 · 期间 2 · 订阅 2) |
---
## 1. 表定位
- **一行 = 一笔支付事件**(订阅购买 / 充值)——同一模型兼容两种形态(`kind` 列区分)
- **本表是「只有作者知道」的信息的唯一落点**:支付金额、额度、订阅 ID——上游没有免 auth 接口可查,全靠作者手工抄录
- **币种无关**:`paid_amount` 是支付金额数值,**系统不预设币种**——币种是使用者之间的约定;「金额 ↔ 额度」的比率同样是派生视图(查询时相除即得)
- **不做分摊计算**:平台不把支付金额按用量比例分摊到日志或成员——分账是社交层问题;本表与日志的交汇只发生在**事实关联**(`subscription_id` 直连)与**参数展示**(订阅信息对使用者完全公开透明)
- 数据规模:年个位数到十位数行——无需索引设计
## 2. 字段清单
### 2.1 主键与支付字段(5 个)
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | |
| `kind` | text | 人工选择 | `subscription`(订阅)/ `topup`(充值) |
| `paid_at` | timestamptz | 人工填写 | 支付时间 |
| `paid_amount` | numeric | **人工必填** | **支付金额**(币种无关——数值本身,币种由使用者约定) |
| `remark` | text | 人工填写 | 备注 |
### 2.2 期间与订阅字段(4 个)
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `period_start` | timestamptz? | **人工录入**(钱包页面) | 仅订阅:该笔覆盖的用量期间起点;左闭 |
| `period_end` | timestamptz? | **人工录入**(钱包页面) | 期间终点;左开;未结束可空 |
| `subscription_id` | bigint? | **人工录入**(钱包页面「订阅 #N」) | 上游订阅实例 ID;与日志 `subscription_id` 直连(事实关联,不推算) |
| `quota_granted` | bigint? | **人工录入**(钱包页面「总额度」/ 充值记录) | 本笔换得的额度——**单位钉死为原始 quota 整数**(如 $25 额度 = 12,500,000;见 3.6) |
## 3. 决策记录(含理由)
### 3.1 登记 = 手工抄录(来源只有两处)
支付记录全部由作者手工录入,来源仅两处:**支付凭证**(`kind` / `paid_at` / `paid_amount`)与**钱包页面**(订阅 ID / 期间 / 总额度)。订阅关键信息只有作者可见(没有免 auth 接口可查)——系统不做回填与推断,日志观测仅用于核对提示(见 §4);必填 / 可空的校验细节归技术稿。
### 3.2 只存事实,不存派生数
- `paid_amount` 与 `quota_granted` 是不可替代的事实;「金额 ↔ 额度」的比率是派生数——查询时相除即得,事后核对永远可用;只存比率会丢失核对依据
- **日志同样不加任何金额 / 比率列**(`usage_logs` 零分摊字段):把支付金额按用量分摊到日志行的任何计算都不做——分摊口径是社交层问题,账本上只有客观事实
- 关联维度(`billing_source` / `subscription_id`)已在日志侧提为独立列(见 `usage_logs表字段设计.md` 3.6),统计按事实关联
### 3.3 为什么额度也手工录入(不采用日志感知)
日志逐条自带 `subscription_total`(实测),但回填它需要「订阅 ID → 该 ID 日志 total」的关联逻辑;钱包页面已直接给出总额度——手工抄录一步到位。**系统对支付记录零回填、零推断**,观测仅用于核对(见 §4)。
### 3.4 币种无关(不预设人民币)
`paid_amount` 是纯数值。作者记账用什么货币是他的事,展示层的货币符号是界面约定——数据层与币种彻底解耦,更换支付渠道 / 币种时零 schema 变化。
### 3.5 命名与类型
- 表名 `payments`(支付记录):订阅与充值同构,`purchases`(购买)只覆盖前者,`payments` 是两者的上位词
- 金额列 `numeric`(金额不用浮点);时间列统一 `timestamptz`
- 期间左闭右开(`[period_start, period_end)`)——与全项目期间惯例一致
- 订阅实例 ID 与日志同名 `subscription_id`——同一体系、同一含义(直连即用此列)
### 3.6 `quota_granted` 的单位:原始 quota,不是美元
- **存真值,不存换算快照**:上游订阅套餐的额度本体是 quota 整数,「$25」只是界面显示形态(quota ÷ `quota_per_unit`,随 `quota_display_type` 而变)——本列存本体
- **域一致性**:日志的 `quota` 同为原始整数——`SUM(usage_logs.quota)` 与 `quota_granted` 同域直接可比,零换算;若存美元则每次比较都要过 ÷QPU,而 QPU 是时间线变量(`qpu_history`),凭空引入取值时刻问题
- **类型精确**:quota 恒为整数;美元额度可能有小数,`bigint` 装不下
- **录入体验归交互层**:钱包页面显示美元形态——管理面表单支持输入美元、按当时 QPU 换算落库(或展示两态),换算逻辑不进数据模型
- 美元形态随时可派生:`quota_granted ÷ QPU`(展示层)
- **与 qpu_history 无概念重复**:quota 是**量**,QPU 是**换算率**——记录量不等于记录单位定义(记「¥170」不涵盖「1 美元 = 6.8 人民币」)。真值域(quota)不携带任何换算,换算率只在一处(`qpu_history` 时间线),美元永远现算现用;反过来若直接存美元,每个值都是某次换算的快照、各自隐含一个换算时刻——那才是真正的冗余,且上游改 QPU 后与日志统计失去同域可比性
## 4. 写入时机与维护
| 时机 | 动作 |
|------|------|
| 人工登记(管理通道) | INSERT:录入支付信息(时间 / 金额)与订阅信息(ID / 期间 / 总额度,抄自钱包页面)→ 确认 |
| 系统观测(仅核对) | 日志观测到的订阅 ID / `subscription_total` 仅作核对提示(如「观测到 #N 未对应任何支付记录」——即「未关联」分组的健康信号),不参与回填 |
| 编辑 / 删除 | 数据为人工登记,改错即修 |
## 5. 展示层边界(界面设计的输入)
- **订阅与支付参数对使用者完全公开透明**(访问主体模型裁决):期间、额度、支付金额、订阅 ID——成员视图与管理面同源可见;不存在「管理面独占的金额信息」
- 「按订阅维度统计」(某笔订阅的消耗占比等)数据天然支持——按 `subscription_id` 直连聚合即可
- 成员视角的日志行携带所属订阅/充值信息的具体形态,交互设计期定(见 `docs/design/需求清单.md` R12)
## 6. 待定事项(关联后续环节)
| 事项 | 归属 |
|------|------|
| 核对提示(观测到的订阅 ID ↔ 已录入记录比对)的交互 | 交互设计(待立) |
| 充值(`topup`)的 `quota_granted` 抄录来源(充值记录页)核实 | 实现期真实数据核查 |
@@ -0,0 +1,65 @@
# subscriptions 表字段设计
> 数据模型:**订阅权益登记**——一行 = 一笔上游订阅实例(覆盖期 + 额度预算 + 可选的套餐付款事实)。与 `topups`(充值入账事件)分表,对齐 NewAPI「权益 vs 入账」两分(见 `payments建模评估.md`,方案 B 已采纳)。平台只存**事实**,不做分摊计算。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-12,作者认可实现) |
| 关联 | `topups表字段设计.md`(充值事件)· `payments建模评估.md`(拆表裁决)· `usage_logs表字段设计.md` 3.6(`subscription_id` 直连)· `qpu_history表字段设计.md` · `docs/references/NewAPI-接口范围.md` §4.3 |
| 范围 | 列结构 + 语义 + 写入时机;方言映射归数据访问层 |
| 字段数 | 9 = 1 主键 + 1 上游编号 + 2 期间 + 1 额度 + 2 付款(可空) + 1 备注 + 1 登记时刻 |
---
## 1. 表定位
- **一行 = 一笔上游订阅实例**(权益),不是「一张付款单」
- 权益事实(`subscription_id` / 期间 / 总额度)抄自钱包页(`GET /api/subscription/self`);金钱事实(付了多少、何时付)来自支付凭证,**可空**——分期、赠送、未掌握凭证时允许不填
- `subscription_id` 与日志 `other.subscription_id` **同体系直连**(事实关联,不推算)
- 数据规模:年个位数行——无需独立索引设计(`subscription_id` UNIQUE 足够)
## 2. 字段清单
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | 本地主键 |
| `subscription_id` | bigint UNIQUE | 人工录入(钱包页「订阅 #N」) | 上游订阅实例 ID;日志直连键 |
| `period_start` | timestamptz | 人工录入 | 用量覆盖期起点;左闭 |
| `period_end` | timestamptz? | 人工录入 | 期间终点;左开;未结束可空 = 进行中 |
| `quota_granted` | bigint | 人工录入(钱包页 `amount_total`) | 套餐总额度——**原始 quota 整数**(与日志 `quota` 同域;充值侧无此列,额度由 `amount × qpu` 派生) |
| `paid_amount` | numeric? | 人工录入(支付凭证) | 该套餐实付金额;币种无关纯数值;未知可空 |
| `paid_at` | timestamptz? | 人工录入(支付凭证) | **钱出手时刻**(非登记时刻);未知可空 |
| `billing_domain` | text NULL | 人工标注(登记 / 编辑) | **账本域标签**(如 `carpool` / `office`);空 = 未标域,成员不可见(涉及该订阅 `subscription_id` 的日志事实直连除外) |
| `remark` | text | 人工填写 | 备注 |
| `created_at` | timestamptz | 系统 | **登记入库时刻**(补录审计) |
## 3. 时刻语义(三时刻钉死)
| 时刻 | 含义 | 谁写 |
|------|------|------|
| `created_at` | 作者按下「登记」 | 系统 |
| `paid_at` | 钱真实出手(凭证时间) | 人工;不知则空 |
| `period_start` / `period_end` | 用量覆盖期 | 人工(抄钱包页) |
筛选约定:**用量窗口看期间重叠**;**补录审计看 `created_at`**;**付款账看 `paid_at`(有则)**。
## 4. 决策摘要
- **不与充值同表**:时间轴不同(期间 vs 事件);见 `payments建模评估.md` 方案 B
- **只存事实、不存比率**:`paid_amount` 与 `quota_granted` 相除得比率;不落分摊
- **额度存原始 quota**(原 payments 3.6):与日志同域可比;美元展示现算
- **`paid_amount` / `paid_at` 可空**:社交层付款信息允许缺失;不阻塞权益登记
- **多订阅期间可重叠**(R8):不设互斥约束
## 5. 写入与维护
| 时机 | 动作 |
|------|------|
| 人工登记 | INSERT(管理通道);来源:钱包页 JSON 预填 + **辅助登录选用**(按 `subscription_id` 去重,已登记灰显)+ 支付凭证 |
| 编辑 / 删除 | 改错即修(人工数据) |
| 日志观测 | 仅核对提示(观测到 #N 无登记),不回填 |
## 6. 对外边界
- 参数对成员**同账本域内可见**(含 `remark` / 金额)——同域透明、跨域隔离;未标域默认对成员隐藏,例外:该订阅 `subscription_id` 出现在本人名下 key 的 `type = 2` 日志中(事实直连)
- 无「应付」、无分摊字段
+78
View File
@@ -0,0 +1,78 @@
# topups 表字段设计
> 数据模型:**充值入账事件**——一行 = 一次「钱 → 账号额度」的入账。与 `subscriptions`(订阅权益)分表(见 `payments建模评估.md`,方案 B)。字段对齐上游实测 `GET /api/user/topup/self`(2026-10-09 样本);**无 `quota_granted` 列**(派生),**事件时刻只存一条**(上游 `complete_time`,即平台列表展示的时间)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-12,作者认可实现) |
| 关联 | `subscriptions表字段设计.md` · `payments建模评估.md` · `NewAPI-接口范围.md` §4.5 · `qpu_history表字段设计.md` |
| 范围 | 列结构 + 语义 + 写入时机 |
| 字段数 | 10 = 1 主键 + 8 上游/业务事实 + 1 登记时刻 |
---
## 1. 表定位
- **一行 = 一次充值入账**(事件);时间主轴 = **`paid_at`**(上游 `complete_time`,平台展示时刻)
- 与消耗的关系:钱包/账号余额池,**无法**逐笔对应模型日志(`billing_source=wallet` 无 `subscription_id` 是正常态)
- 不存 `quota_granted` 真值列(上游没有;见 §3)
## 2. 字段清单
| 列 | 类型 | 上游字段 | 说明 |
|----|------|----------|------|
| `id` | bigserial PK | — | 本地主键 |
| `upstream_id` | bigint? | `id` | 上游充值单 ID(对账;手工补录可空) |
| `amount` | numeric | `amount` | **平台计费金额**(充值记账额,样本 `1`)——与实付 `money` 之间由**管理员约定汇率**换算;**不标币种/单位** |
| `money` | numeric | `money` | **实付金额**(样本 `2.04`);同样不预设币种 |
| `trade_no` | text? | `trade_no` | 交易号 |
| `payment_method` | text? | `payment_method` | 如 `alipay` |
| `payment_provider` | text? | `payment_provider` | 如 `epay` |
| `status` | text? | `status` | 如 `success`;失败单一般不入账本 |
| `paid_at` | timestamptz | **`complete_time`** | **入账时刻(唯一事件时间)**——平台列表展示的就是它;上游 `create_time` 不入库 |
| `billing_domain` | text NULL | 人工标注(登记 / 编辑) | **账本域标签**(如 `carpool` / `office`);空 = 未标域,成员不可见(充值无逐笔日志连线,无法用日志证明「与我相关」) |
| `remark` | text | — | 人工备注 |
| `created_at` | timestamptz | — | 本系统登记时刻(补录审计) |
> 样本(2026-10-09):`amount: 1` · `money: 2.04` · `trade_no: "USR6NOLqTPGa1791534153"` · `alipay`/`epay` · `create_time`/`complete_time` Unix 秒 · `status: "success"`。展示与筛选一律用 `complete_time` 落入的 `paid_at`。
## 3. 「换得额度」是派生
上游无 `quota_granted`:派生 `amount × quota_per_unit`(默认 QPU 500000)——`amount` 是平台计费侧充值额,与 quota 同域量纲。
- **只存事实**:`amount`(平台计费)与 `money`(实付)两真值;**汇率不入库**(管理员社交约定,查询/展示可现算 `money/amount`)
- 不在库中固化派生额度
## 4. 时刻语义
| 时刻 | 含义 |
|------|------|
| `paid_at` | 入账(= 上游 `complete_time`)——筛选、排序、QPU 锚点 |
| `created_at` | 登记进本表(补录审计) |
## 5. 写入与维护
| 时机 | 动作 |
|------|------|
| **辅助登录选用**(与订阅选择器同模式) | 控制台登录(密码不落库)→ 分页拉 `/api/user/topup/self` → 选用预填表单 → 确认入库 |
| 纯手工登记 | 抄充值页 / 账单;或粘贴 JSON 预填 |
| 编辑 / 删除 | 改错即修 |
| 后台定时同步 | **不做**(辅助登录仅代拉一次、token 会话内用) |
**分页请求策略**(接口仅 `p` / `page_size`,**无时间过滤**):
1. **传统分页 UI**:弹层底部「共 N 条 · 页码 / 上一页 / 下一页」;每次请求只取当前页(`page_size=10`)
2. **不设硬上限**——用户可一直翻到 `total` 尽头(找旧账必须靠翻页)
3. **不提供时间范围筛选**(上游收窄不了结果集;本地筛只会误导读不出的更早页)
4. **已登记去重**:按 `upstream_id` 灰显,不可重复选用
5. **401**:清会话 token → 重新登录;token 仅 sessionStorage
## 6. 对外边界
- 成员可见:`amount`、`money`、渠道、`paid_at`、备注、`billing_domain`;**仅本人名下 key 所属账本域内**;无逐笔与模型日志的连线;未标域默认不可见
- 纯用量模式可不登记
## 7. 迁移说明
- 原 `payments.kind=topup`:`paid_at` 语义本就落在入账日 → 直接迁入本表 `paid_at`
- 若曾手填 `quota_granted`:可反推 `amount = quota_granted / qpu` 填 `amount`,否则按实付与约定补记

Some files were not shown because too many files have changed in this diff Show More