Compare commits

..
55 Commits
Author SHA1 Message Date
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
skycurtain 9ecf817df2 docs: 清理骨架搭建指南并校准 README 进度表述 2026-10-07 13:30:33 +08:00
skycurtain d47cb5543c feat(engine): 实现同步引擎与 engine_status 快照
apps/engine 独立进程(syncRound + 防重叠调度);上游 log/token;QPU 采样;
server 读 engine_status;时刻出参 toUnixSec;中文日志;zod-openapi 扩展顺序修复。
2026-10-07 12:54:13 +08:00
skycurtain 3e12db1706 docs: 同步引擎设计定稿,API 时刻改为 Unix 秒
独立 apps/engine、setTimeout 轮次、engine_status 快照、不为 CFW 预留;
公共约定 §4 响应时刻统一 Unix 秒;契约 engine 增 sync_interval_ms。
2026-10-07 12:53:37 +08:00
skycurtain de48e5d39c perf: 去掉 handler 内层重复鉴权
鉴权只保留各路由 r.use 一次,受保护请求少一轮会话查库。
2026-10-06 13:19:53 +08:00
skycurtain 54ea686f44 fix: server 审查修复——member_id 校验、访问日志 finally、静默初始化并发
非法 member_id 不再以 NaN 入库;HTTP 访问日志覆盖异常路径;setup-state 静默建管理员遇唯一冲突视为已初始化。
2026-10-06 12:55:18 +08:00
skycurtain fa5b3a8942 fix: apps/server 继承根 tsconfig,消除 allowImportingTsExtensions 报错
包内相对导入均为 .ts 扩展名,包级配置须与根一致(extends + allowImportingTsExtensions)。
2026-10-06 12:14:38 +08:00
skycurtain 7ab9d32495 chore: 运行日志统一中文表述
访问日志改为自管中间件(弃 hono/logger 英文格式),启动与未捕获错误文案中文化。
2026-10-06 12:04:32 +08:00
skycurtain 867bb7d566 docs: 命令登记与 SK_KEY_ENCRYPTION_SECRET 命名对齐
AGENTS 命令表、会话留档约定;骨架搭建指南与设计文档键名对齐代码。
2026-10-06 01:30:52 +08:00
skycurtain a2fa755b62 feat(web): antdv-next §3.7 组件能力 spike
create-vue + antdv-next 覆盖侧边栏/展开行/对话框/范围选择/徽章等验收清单。
2026-10-06 01:30:52 +08:00
skycurtain 6454f4acfa feat(server): 管理面与成员视图 API + Scalar 在线文档
契约 19 路径全量:引导/会话/改密、engine 桩、keys、overview/logs、payments、
qpu、members、audit、成员视图。鉴权优先、422 信封、登录节流、上游验证接线。
2026-10-06 01:30:52 +08:00
skycurtain 6980d09750 feat(core,db): 业务核心与数据访问层
core:DTO/错误信封/时间口径/微分折算/dedupe_key/加密与 PBKDF2/契约 zod/logtape/上游客户端。
db:八表 Drizzle schema + 仓储 + drizzle 配置。
2026-10-06 01:30:51 +08:00
skycurtain 4c55c3f15f chore: monorepo 工作区与开发环境
Bun workspaces + 根 tsconfig、开发用 PostgreSQL compose、.env.example(端口 18456、SK_KEY_ENCRYPTION_SECRET 等)。
2026-10-06 01:30:51 +08:00
skycurtain 4566ce8d58 docs: 技术选型与 API 契约定稿——四篇经作者逐篇审阅定稿,仓库转入实现主线 2026-10-03 19:35:02 +08:00
skycurtain db20dcb611 docs: 交互设计定稿——八篇按功能分篇,经作者逐篇审阅定稿
- 八篇:总览与共性 / 登录与会话 / 全员总览 / key 管理 / 支付记录 / 成员管理 / 审计日志 / 成员视图
- 关键定案:R12 携带形态(来源徽章 + 展开行,两种空值场景区分)、纯用量模式(部门实例——无支付记录的完整子集,共性 §3.8)、响应式只做布局响应、组件能力需求清单(选型验收标准)、审计日志保留操作者列(前向兼容多管理员)、Key 与成员管理分页 + 「+ 新建成员」缝合流程
- 审阅期修订全记录见需求清单 §9「交互设计八篇定稿」
2026-10-01 23:58:28 +08:00
skycurtain e4d25526f3 docs: 设计阶段定稿——需求清单(15 项全定案)与七篇表设计定稿
- 需求清单:独立计费平台定位,R1-R15 全部定案(12 项裁决 + R10 裁撤 + R3/R4 延后),裁决档案含访问主体模型(作者登录体系 + 成员长期令牌)、分摊哲学(平台不做价值判断只提供事实服务)、双形态交付(完整前后端先行,CFW 抽取版后续)
- 数据模型定稿(一表一文档):api_keys(16 列,401 状态机裁撤——引擎记录不决策)/ usage_logs(25 列,billing_source 与 subscription_id 提列,非消费行去重含 content 指纹)/ payments(币种无关支付记录,只存事实)/ qpu_history(按日志时刻取值)/ members(成员实体与长期令牌)/ users(空库双通道初始化与重置)/ sessions(服务端会话,否决 JWT)
- 接口参考:NewAPI-接口范围融合字段级实证,定位 docs/references/(计费平台可用接口参考,唯一上游参考)
- README / AGENTS:新版设计语境(项目沿革、重开设计动因、协作约定、文档自立原则)
2026-10-01 14:54:06 +08:00
156 changed files with 19776 additions and 0 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.
+30
View File
@@ -0,0 +1,30 @@
# ── new-api-key-usage · 本地环境变量示例 ──────────────────────────
# 复制为 .env 后填写(.env 已被 .gitignore,勿提交)
# 本服务端口(避开 3000/3001 等常见占用)
PORT=18456
# PostgreSQL 连接串(开发 compose 默认如下)
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/new_api_key_usage
# 已登记 sk-key 的加密密钥:≥32 字节随机数,base64url。
# 生成示例(PowerShell):
# $r=[Security.Cryptography.RandomNumberGenerator]::Create();$b=New-Object byte[] 32;$r.GetBytes($b);[Convert]::ToBase64String($b)
# ⚠ 入库后不可更换;丢失 = 存量 key_ciphertext 无法解密。与库同等备份。
SK_KEY_ENCRYPTION_SECRET=
# 上游 NewAPI 实例根地址(不是本服务)
NEWAPI_BASE=http://127.0.0.1:8080
# 会话 cookie 是否带 Secure(默认 true)。
# 本地 http://localhost 或 127.0.0.1 调试:现代浏览器可保留 true;
# 局域网 IP / 自定义域名 / 出现「登录后立刻 401」时改为 false。
COOKIE_SECURE=false
# 业务日志级别:debug | info | warning | error | fatal(访问日志另由 hono/logger 输出)
LOG_LEVEL=info
# 空库双通道:可选。用户名与密码同时存在时,首次 setup-state 静默落库;
# 只填一个视为未配置。此后改 env 不改密;改密走管理面表单。
# ADMIN_INIT_USERNAME=
# ADMIN_INIT_PASSWORD=
+14
View File
@@ -13,3 +13,17 @@
.env
.env.*
!.env.example
# 依赖与构建产物
node_modules/
dist/
build/
.turbo/
# 测试与工具
coverage/
*.tsbuildinfo
# Agent 本地接线(skills 本体只保留 .agents/skills)
.claude/
agent/
+42
View File
@@ -0,0 +1,42 @@
# AGENTS.md
给 AI 协作者的仓库指引。**开始任何工作前,先读 [`README.md`](README.md)**——它是全景入口:项目是什么、为什么长这样、走到哪了。
## 协作约定(长期有效)
- **Git**:仅当项目作者明确说"提交 / commit"时才执行提交;`git push` 永远由项目作者手工执行,AI 绝不推送
- **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`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`(本分支)= 设计文档中心 + 实现主线**:实现代码与文档同分支演进(代码落点已定案),monorepo 结构(`apps/server` · `apps/web` · `packages/core` · `packages/db`)见 `docs/design/技术选型.md` §4
- 数据模型:基线表(`api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history`)+ 三张访问主体表(`members` · `users` · `sessions`)——`payments` 单表已按方案 B 拆为 `subscriptions` + `topups`(见 `docs/design/payments建模评估.md`);同步架构(轮询 + `request_id` 去重 + 攒库)的动因由上游物理约束决定,架构结论不变
- 需求总集与待裁决项(平台身份体系、费用分摊与账单、充值折算、多订阅、数据资产保护)见 `docs/design/需求清单.md`;交付形态已裁决(双形态并行,完整形态先行)
- 上游约束速记:只有 sk-key、无时间过滤接口 → 轮询攒库是唯一路径(README「架构的根本动因」)
## 命令
Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun` 需在 PowerShell 7(`pwsh`)里跑**——mise shim 由 pwsh profile 激活;Windows PowerShell 5.1 会报 command not found,改用 `pwsh -c "…"` 或绝对路径。
| 命令 | 用途 |
|------|------|
| `bun install` | 安装 / 链接 workspace 依赖(在仓库根) |
| `bun test` | 全部测试(core 契约 + server HTTP + **集成**);集成需 `DATABASE_URL`(compose 中的 PG),无库时自动跳过 |
| `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:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) |
| `bun run dev:web` | 开发跑前端 Vite |
| `bun run build:web` | 前端类型检查 + 产物构建 |
| `docker compose up -d` | 起开发用 PostgreSQL |
环境变量见 `.env.example`(复制为 `.env` 后填写)。
**默认端口 `18456`**(避开 3000/3001 等本机占用);`NEWAPI_BASE` 是上游 NewAPI 地址,与本服务端口无关。在线文档:`http://127.0.0.1:18456/api/docs`。
+95
View File
@@ -0,0 +1,95 @@
# new-api-key-usage
> 针对 NewAPI 平台的 key 用量统计工具
定时用每个 sk-key 轮询网关日志接口,按 `request_id` 去重,攒全量日志到数据库;任意时间范围的用量查询,即一条本地聚合查询。
| 项目 | 内容 |
|------|------|
| 当前状态 | **实现推进中**(`v1` 分支 = 文档中心 + 实现主线)——设计文档全部定稿;后端 + 同步引擎已落地;前端总览与 Key 管理已实现,其余页面进行中 |
| 技术栈 | 全栈 TypeScript(选型定稿见 [`docs/design/技术选型.md`](docs/design/技术选型.md)):母体 Bun + Hono + Drizzle + PostgreSQL · 前端 Vue 3 + antdv-next |
## 项目沿革
| 阶段 | 形态 | 结局 |
|------|------|------|
| 初版方案 | Cloudflare Workers | 弃——当时运营方愿意提供服务器资源 |
| 新版设计(当前) | **本分支,设计已收官、实现推进中** | 捡回 Cloudflare Workers(**serverless 后端**:Cron 定时轮询 + 云端数据库攒库,非初版原案复刻)作为后续衍生形态;母体完整前后端先行,CFW 延后至功能全集实现后 |
**双形态并行的直接动因**:服务器部署形态下,每次迭代都需实例运营方在服务器上手工升级,测试阶段迭代频繁而运营方无暇跟随;若部署到作者自控的 Cloudflare 账号(Workers + Pages),迭代部署权回到作者手里。
## 文档地图
| 文档 | 什么时候读 | 状态 |
|------|-----------|------|
| [`docs/design/需求清单.md`](docs/design/需求清单.md) | **需求权威索引与裁决档案**——R1–R15 全部定案;2026-10-09 增补支付拆表裁决 | 定稿(2026-10-09) |
| [`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) | 支付建模复盘:订阅权益 vs 充值入账两分;**方案 B 拆表已采纳**(2026-10-09) | 定稿(2026-10-09) |
| [`docs/design/subscriptions表字段设计.md`](docs/design/subscriptions表字段设计.md) | 订阅权益:期间 + 额度预算 + 可选付款事实;`subscription_id` 直连日志 | 待审阅(2026-10-09) |
| [`docs/design/topups表字段设计.md`](docs/design/topups表字段设计.md) | 充值入账事件:`paid_at` 主轴;不挂订阅 | 待审阅(2026-10-09) |
| [`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/交互设计-调用日志.md`](docs/design/交互设计-调用日志.md) | 管理面调用日志:筛选、明细表、与 key/成员/总览的跳转链 | 草案(待审阅) |
| [`docs/design/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 同上 |
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 支付记录页(两 Tab):订阅权益 / 充值入账登记、核对提示 | 待审阅(2026-10-09 拆表重写) |
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、令牌签发/重发(明文仅一次)、停用 | 同上 |
| [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 同上 |
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员世界——管理端同构壳三页(总览 / 调用日志 / 支付参数);R12:订阅徽章弹窗 + 行展开调用详情 | 定稿(2026-10-09) |
| [`docs/design/API契约-公共约定.md`](docs/design/API契约-公共约定.md) | API 契约总纲:路由隔离与鉴权两档、错误信封、时间 / 数值 / 分页 / 命名(8 条拟定约定经审通过)——后两篇的引用锚点 | 定稿(2026-10-02) |
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 18 端点:引导与会话、引擎、总览、key、日志、**订阅/充值拆资源**、成员、审计 | 待审阅(2026-10-09 §7 拆表修订) |
| [`docs/design/API契约-成员视图.md`](docs/design/API契约-成员视图.md) | 成员视图 3 端点:`/me` · `/overview`(`subscriptions`+`topups`)· `/logs` | 待审阅(2026-10-09 拆表修订) |
| [`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 网关下按 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 查询(纯只读) |
基线刻意收缩的部分(费用分摊计算、计费周期管理、多组支持、登录页、admin 角色、会话记忆)与新增域(数据资产保护等)的完整需求盘点见 [`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——这是“按时间段统计用量”无法纯客户端实现的**核心障碍**
3. `/api/log/token` 硬上限 1000 条、不支持任何参数(分页 / limit / 时间过滤全被忽略)
→ 结论:轮询同步 + `request_id` 去重入库 + 数据库。**部署形态可以变(运营方服务器 / Cloudflare Workers),攒库架构不变。**上游接口完整实证(含源码出处)见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md)。
## 关键事实清单(实测 / 源码确认,设计时不得违反)
> 出处均见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md),此处仅索引。
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 拒绝**
6. **`DisplayTokenStatEnabled` 已开启**:billing 接口返回 token 级额度
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@new-api-key-usage/engine",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts"
},
"dependencies": {
"@new-api-key-usage/core": "workspace:*",
"@new-api-key-usage/db": "workspace:*"
}
}
+43
View File
@@ -0,0 +1,43 @@
export interface EngineEnv {
DATABASE_URL: string;
SK_KEY_ENCRYPTION_SECRET: string;
NEWAPI_BASE: string;
SYNC_INTERVAL_MS: number;
LOG_LEVEL: "debug" | "info" | "warning" | "error" | "fatal";
}
function parseLogLevel(
value: string | undefined,
): "debug" | "info" | "warning" | "error" | "fatal" {
const v = (value ?? "info").trim().toLowerCase();
if (
v === "debug" ||
v === "info" ||
v === "warning" ||
v === "error" ||
v === "fatal"
) {
return v;
}
throw new Error("LOG_LEVEL must be debug|info|warning|error|fatal");
}
export function loadEnv(source: Record<string, string | undefined>): EngineEnv {
const databaseUrl = source.DATABASE_URL;
const secret = source.SK_KEY_ENCRYPTION_SECRET;
if (!databaseUrl) throw new Error("DATABASE_URL is required");
if (!secret) throw new Error("SK_KEY_ENCRYPTION_SECRET is required");
const interval = Number(source.SYNC_INTERVAL_MS ?? 300_000);
if (!Number.isInteger(interval) || interval < 1000) {
throw new Error("SYNC_INTERVAL_MS must be an integer >= 1000");
}
return {
DATABASE_URL: databaseUrl,
SK_KEY_ENCRYPTION_SECRET: secret,
NEWAPI_BASE: source.NEWAPI_BASE ?? "http://127.0.0.1:8080",
SYNC_INTERVAL_MS: interval,
LOG_LEVEL: parseLogLevel(source.LOG_LEVEL),
};
}
+29
View File
@@ -0,0 +1,29 @@
import { appLogger, setupLogging } from "@new-api-key-usage/core";
import { createDb, createRepos } from "@new-api-key-usage/db";
import { loadEnv } from "./env.ts";
import { createSyncLoop } from "./loop.ts";
const env = loadEnv(process.env);
setupLogging(env.LOG_LEVEL);
const log = appLogger("engine");
const db = createDb(env.DATABASE_URL);
const repos = createRepos(db);
const loop = createSyncLoop({
repos,
newapiBase: env.NEWAPI_BASE,
skKeyEncryptionSecret: env.SK_KEY_ENCRYPTION_SECRET,
intervalMs: env.SYNC_INTERVAL_MS,
});
loop.start();
log.info`apps/engine 已就绪`;
process.on("SIGINT", () => {
void loop.stop().then(() => process.exit(0));
});
process.on("SIGTERM", () => {
void loop.stop().then(() => process.exit(0));
});
+75
View File
@@ -0,0 +1,75 @@
/**
* 调度循环(方案 A):一轮结束 → setTimeout 下一枪;inFlight 防重叠。
* 启停不得抹掉已有 last_round。
*/
import { appLogger } from "@new-api-key-usage/core";
import { syncRound, type SyncRoundDeps } from "./sync-round.ts";
const log = appLogger("engine");
export interface LoopOptions extends SyncRoundDeps {
intervalMs: number;
}
export function createSyncLoop(opts: LoopOptions) {
let inFlight = false;
let stopped = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let last_round: unknown = null;
async function persist(running: boolean) {
await opts.repos.engine.upsertSnapshot({
running,
sync_interval_ms: opts.intervalMs,
last_round,
});
}
async function runOnce(): Promise<void> {
if (stopped) return;
if (inFlight) {
log.warn("上一轮尚未结束,跳过本枪");
schedule();
return;
}
inFlight = true;
try {
last_round = await syncRound(opts);
await persist(!stopped);
} catch (e) {
log.error(`同步轮次异常:${e}`);
await persist(!stopped).catch(() => {});
} finally {
inFlight = false;
schedule();
}
}
function schedule() {
if (stopped) return;
timer = setTimeout(() => {
void runOnce();
}, opts.intervalMs);
}
return {
start() {
log.info(`同步引擎启动:周期=${opts.intervalMs}ms`);
void (async () => {
const snap = await opts.repos.engine.getSnapshot();
last_round = snap?.last_round ?? null;
await persist(true).catch(() => {});
void runOnce();
})();
},
async stop() {
stopped = true;
if (timer) clearTimeout(timer);
// 等在跑的一轮结束,避免 stop 与 upsert 交错
for (let i = 0; i < 50 && inFlight; i++) {
await new Promise((r) => setTimeout(r, 100));
}
await persist(false).catch(() => {});
},
};
}
+174
View File
@@ -0,0 +1,174 @@
/**
* 同步轮次:按《同步引擎设计》§3 编排。
* 只经 packages/core(上游 + 去重)与 packages/db(仓储)写库。
*/
import {
appLogger,
buildDedupeKey,
createUpstreamClient,
KeyCrypto,
parseOtherJson,
toUnixSec,
} from "@new-api-key-usage/core";
import type { Repos } from "@new-api-key-usage/db";
const log = appLogger("engine");
export interface SyncRoundDeps {
repos: Repos;
newapiBase: string;
skKeyEncryptionSecret: string;
}
export interface LastRound {
started_at: number;
keys_total: number;
succeeded: number;
failed: number;
failed_api_key_ids: number[];
}
function parseOther(other: string | undefined): {
billing_source: string | null;
subscription_id: number | null;
raw: Record<string, unknown> | null;
} {
return parseOtherJson(other);
}
export async function syncRound(deps: SyncRoundDeps): Promise<LastRound> {
const started = Date.now();
const started_at = toUnixSec(new Date())!;
const client = createUpstreamClient({ baseUrl: deps.newapiBase });
const crypto = await KeyCrypto.fromEncryptionSecret(
deps.skKeyEncryptionSecret,
);
const keys = await deps.repos.keys.listActive();
const failed_api_key_ids: number[] = [];
let succeeded = 0;
let pulled = 0;
let inserted = 0;
let skipped = 0;
for (const key of keys) {
try {
const sk = await crypto.openKey(key.key_ciphertext);
const usage = await client.fetchTokenUsage(sk);
if (!usage.ok) {
failed_api_key_ids.push(key.id);
log.warn(`同步失败 key_id=${key.id} 原因=usage/${usage.kind}`);
continue;
}
await deps.repos.keys.update(key.id, {
token_name: usage.data.name ?? key.token_name,
total_granted: usage.data.total_granted,
total_used: usage.data.total_used,
total_available: usage.data.total_available,
unlimited_quota: usage.data.unlimited_quota,
model_limits: usage.data.model_limits,
model_limits_enabled: usage.data.model_limits_enabled,
expires_at: normalizeExpires(usage.data.expires_at),
usage_synced_at: new Date(),
});
const logs = await client.fetchTokenLogs(sk);
if (!logs.ok) {
failed_api_key_ids.push(key.id);
log.warn(`同步失败 key_id=${key.id} 原因=logs/${logs.kind}`);
continue;
}
const rows = [...(logs.data ?? [])].sort((a, b) => {
const ta = a.created_at ?? 0;
const tb = b.created_at ?? 0;
if (ta !== tb) return ta - tb;
return String(a.request_id ?? "").localeCompare(String(b.request_id ?? ""));
});
pulled += rows.length;
for (const row of rows) {
const other = parseOther(row.other);
const created_at = new Date(row.created_at * 1000);
const dedupe_key = await buildDedupeKey({
api_key_id: key.id,
request_id: row.request_id,
type: row.type ?? 2,
created_at: row.created_at,
model_name: row.model_name ?? "",
quota: row.quota ?? 0,
prompt_tokens: row.prompt_tokens ?? 0,
completion_tokens: row.completion_tokens ?? 0,
use_time: row.use_time ?? 0,
content: row.content ?? "",
});
const ok = await deps.repos.keys.insertLog({
api_key_id: key.id,
dedupe_key,
billing_source: other.billing_source,
subscription_id: other.subscription_id,
user_id: row.user_id ?? 0,
created_at,
type: row.type ?? 2,
content: row.content ?? "",
username: row.username ?? "",
token_name: row.token_name ?? "",
model_name: row.model_name ?? "",
quota: row.quota ?? 0,
prompt_tokens: row.prompt_tokens ?? 0,
completion_tokens: row.completion_tokens ?? 0,
use_time: row.use_time ?? 0,
is_stream: !!row.is_stream,
channel: row.channel ?? 0,
channel_name: row.channel_name ?? "",
token_id: row.token_id ?? 0,
group_name: row.group ?? "",
ip: row.ip ?? "",
request_id: row.request_id ?? "",
upstream_request_id: row.upstream_request_id ?? null,
other: other.raw,
});
if (ok) inserted += 1;
else skipped += 1;
}
await deps.repos.keys.update(key.id, { log_synced_at: new Date() });
succeeded += 1;
} catch (e) {
failed_api_key_ids.push(key.id);
log.error(`同步异常 key_id=${key.id} ${e}`);
}
}
// QPU 采样:值变化才追加
const qpuRes = await client.fetchQuotaPerUnit();
if (qpuRes.ok) {
const latest = await deps.repos.qpu.latest();
const next = qpuRes.data.quota_per_unit;
if (!latest || latest.qpu !== next) {
await deps.repos.qpu.insertChange(next, new Date());
log.info(`QPU 变更 ${latest?.qpu ?? "-"} -> ${next}`);
}
} else {
log.warn(`QPU 采样失败 ${qpuRes.kind}`);
}
const last_round: LastRound = {
started_at,
keys_total: keys.length,
succeeded,
failed: failed_api_key_ids.length,
failed_api_key_ids,
};
log.info(
`同步轮次完成 key=${keys.length} 成功=${succeeded} 失败=${failed_api_key_ids.length} 拉取=${pulled} 新写=${inserted} 去重跳过=${skipped} 耗时=${Date.now() - started}ms`,
);
return last_round;
}
function normalizeExpires(unixSeconds: number | undefined | null): Date | null {
if (!unixSeconds) return null;
return new Date(unixSeconds * 1000);
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["bun"],
"allowImportingTsExtensions": true
},
"include": ["src/**/*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
# deps
node_modules/
+13
View File
@@ -0,0 +1,13 @@
# 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
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@new-api-key-usage/server",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"dev": "bun run --hot src/index.ts"
},
"dependencies": {
"@hono/zod-openapi": "^1.6.3",
"@new-api-key-usage/core": "workspace:*",
"@new-api-key-usage/db": "workspace:*",
"@scalar/hono-api-reference": "^0.12.9",
"hono": "^4.13.13",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/bun": "latest"
}
}
+100
View File
@@ -0,0 +1,100 @@
/** 必须先扩展 zod,再加载 core schema(见 zod-openapi-setup) */
import "./zod-openapi-setup.ts";
import {
changePasswordBody,
createMemberBody,
createSubscriptionBody,
createTopupBody,
engineStatus,
errorEnvelope,
keyItem,
logItem,
logPage,
memberItem,
memberMe,
memberOverview,
memberWithToken,
patchKeyBody,
patchMemberBody,
subscriptionItem,
topupItem,
auditLogItem,
registerKeyBody,
setupBody,
setupState,
loginBody,
tokenOnly,
usernameBody,
} from "@new-api-key-usage/core";
/** OpenAPI 组件命名只在 server 层装饰,core 保持 Web 标准中立 */
function nameSchema<T extends { openapi?: (n: string) => unknown }>(
schema: T,
name: string,
): T {
return typeof schema.openapi === "function"
? (schema.openapi(name) as T)
: schema;
}
export const errorSchema = nameSchema(errorEnvelope, "ErrorEnvelope");
export const setupStateSchema = nameSchema(setupState, "SetupState");
export const engineStatusSchema = nameSchema(engineStatus, "EngineStatus");
export const keyItemSchema = nameSchema(keyItem, "KeyItem");
export const memberItemSchema = nameSchema(memberItem, "MemberItem");
export const memberWithTokenSchema = nameSchema(
memberWithToken,
"MemberWithToken",
);
export const memberMeSchema = nameSchema(memberMe, "MemberMe");
export const memberOverviewSchema = nameSchema(
memberOverview,
"MemberOverview",
);
export const logItemSchema = nameSchema(logItem, "LogItem");
export const logPageSchema = nameSchema(logPage, "LogPage");
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,
registerKeyBody as registerKeyBodySchema,
setupBody as setupBodySchema,
tokenOnly as tokenOnlySchema,
usernameBody as usernameSchema,
};
export const err401 = {
description: "凭证无效或过期",
content: { "application/json": { schema: errorSchema } },
} as const;
export const err404 = {
description: "资源不存在",
content: { "application/json": { schema: errorSchema } },
} as const;
export const err409 = {
description: "冲突",
content: { "application/json": { schema: errorSchema } },
} as const;
export const err422 = {
description: "校验失败",
content: { "application/json": { schema: errorSchema } },
} as const;
export const err502 = {
description: "上游不可达",
content: { "application/json": { schema: errorSchema } },
} as const;
+130
View File
@@ -0,0 +1,130 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { apiReference } from "@scalar/hono-api-reference";
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 { 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";
export interface CreateAppOptions {
db: Db;
skKeyEncryptionSecret: string;
newapiBase: string;
cookieSecure?: boolean;
adminInitUsername?: string;
adminInitPassword?: string;
/** 可注入的上游验证(测试);默认走 real HTTP */
fetchTokenUsage?: AppVars["fetchTokenUsage"];
}
export function createApp(opts: CreateAppOptions) {
const app = new OpenAPIHono<{ Variables: AppVars }>({
defaultHook: (result, c) => {
if (!result.success) {
const fields: Record<string, string> = {};
for (const issue of result.error.issues) {
const key = issue.path.join(".") || "body";
fields[key] = issue.message;
}
return jsonError(c, 422, ERR.validation_failed(fields));
}
},
});
const repos = createRepos(opts.db);
app.use("*", async (c, next) => {
const start = Date.now();
try {
await next();
} finally {
appLogger("http").info(
`${c.req.method} ${c.req.path} -> ${c.res.status} | ${Date.now() - start}ms`,
);
}
});
app.use("*", async (c, next) => {
c.set("repos", repos);
c.set("skKeyEncryptionSecret", opts.skKeyEncryptionSecret);
c.set("newapiBase", opts.newapiBase);
c.set("cookieSecure", opts.cookieSecure ?? true);
if (opts.fetchTokenUsage) c.set("fetchTokenUsage", opts.fetchTokenUsage);
if (opts.adminInitUsername)
c.set("adminInitUsername", opts.adminInitUsername);
if (opts.adminInitPassword)
c.set("adminInitPassword", opts.adminInitPassword);
await next();
});
app.notFound((c) => {
if (c.req.path.startsWith("/api")) {
return jsonError(c, 404, ERR.not_found());
}
return c.text("Not Found", 404);
});
app.onError((err, c) => {
appLogger("server").error(`未捕获错误 ${c.req.method} ${c.req.path}:${err}`);
return jsonError(c, 500, ERR.internal());
});
app.openAPIRegistry.registerComponent("securitySchemes", "adminSession", {
type: "apiKey",
in: "cookie",
name: "admin_session",
description: "管理面会话 cookie(HttpOnly · Path=/api/admin)",
});
app.openAPIRegistry.registerComponent("securitySchemes", "memberToken", {
type: "http",
scheme: "bearer",
description: "成员视图 Bearer 令牌",
});
app.doc("/api/openapi.json", {
openapi: "3.1.0",
info: {
title: "new-api-key-usage",
version: "0.0.0",
description:
"NewAPI key 用量统计——管理面 `/api/admin/*`(会话 cookie)与成员视图 `/api/member/*`(Bearer)。成功响应无信封,错误见 ErrorEnvelope。",
},
tags: [
{ name: "引导与会话", description: "三态判定 / 登录 / 改密" },
{ name: "引擎", description: "同步引擎状态" },
{ name: "Key 管理", description: "登记 / 启停 / 归属" },
{ name: "成员管理", description: "开户 / 令牌 / 启停" },
{ name: "成员视图", description: "令牌自助查询" },
],
});
app.get(
"/api/docs",
apiReference({
theme: "kepler",
spec: { url: "/api/openapi.json" },
}),
);
const admin = new OpenAPIHono<{ Variables: AppVars }>();
admin.route("/", adminSessionRoutes());
admin.route("/", adminEngineRoutes());
admin.route("/", adminKeyRoutes());
admin.route("/", adminMemberRoutes());
admin.route("/", adminUsageRoutes());
admin.route("/", adminSubscriptionRoutes());
admin.route("/", adminTopupRoutes());
admin.route("/", adminAuditRoutes());
admin.route("/", adminPlatformRoutes());
app.route("/api/admin", admin);
app.route("/api/member", memberViewRoutes());
return app;
}
+69
View File
@@ -0,0 +1,69 @@
export interface Env {
/** 本服务监听端口(避开 3000/3001 等占用) */
PORT: number;
/** PostgreSQL 连接串 */
DATABASE_URL: string;
/**
* 已登记 sk-key 的加密密钥(≥32 字节 base64url)。
* 经 HKDF 派生 hmac / aes 子密钥;**入库后不可更换**,否则存量 key_ciphertext 无法解密。
*/
SK_KEY_ENCRYPTION_SECRET: string;
/** 上游 NewAPI 实例根地址(非本服务) */
NEWAPI_BASE: string;
/** 会话 cookie 是否带 Secure;默认 true。本地 HTTP 调试可设 false */
COOKIE_SECURE: boolean;
/** 业务日志级别,默认 info */
LOG_LEVEL: "debug" | "info" | "warning" | "error" | "fatal";
/** 空库 + 二者均存在时静默落库管理员(与引导页双通道) */
ADMIN_INIT_USERNAME?: string;
ADMIN_INIT_PASSWORD?: string;
}
function parseBool(value: string | undefined, fallback: boolean): boolean {
if (value === undefined || value === "") return fallback;
const v = value.trim().toLowerCase();
if (v === "1" || v === "true" || v === "yes" || v === "on") return true;
if (v === "0" || v === "false" || v === "no" || v === "off") return false;
throw new Error(`invalid boolean: ${value}`);
}
function parseLogLevel(
value: string | undefined,
): "debug" | "info" | "warning" | "error" | "fatal" {
const v = (value ?? "info").trim().toLowerCase();
if (
v === "debug" ||
v === "info" ||
v === "warning" ||
v === "error" ||
v === "fatal"
) {
return v;
}
throw new Error(`LOG_LEVEL must be debug|info|warning|error|fatal`);
}
export function loadEnv(source: Record<string, string | undefined>): Env {
const databaseUrl = source.DATABASE_URL;
const skKeyEncryptionSecret = source.SK_KEY_ENCRYPTION_SECRET;
if (!databaseUrl) throw new Error("DATABASE_URL is required");
if (!skKeyEncryptionSecret) {
throw new Error("SK_KEY_ENCRYPTION_SECRET is required");
}
const port = Number(source.PORT ?? 18456);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("PORT must be an integer in 1..65535");
}
return {
PORT: port,
DATABASE_URL: databaseUrl,
SK_KEY_ENCRYPTION_SECRET: skKeyEncryptionSecret,
NEWAPI_BASE: source.NEWAPI_BASE ?? "http://127.0.0.1:8080",
COOKIE_SECURE: parseBool(source.COOKIE_SECURE, true),
LOG_LEVEL: parseLogLevel(source.LOG_LEVEL),
ADMIN_INIT_USERNAME: source.ADMIN_INIT_USERNAME,
ADMIN_INIT_PASSWORD: source.ADMIN_INIT_PASSWORD,
};
}
+27
View File
@@ -0,0 +1,27 @@
import { createApp } from "./app.ts";
import { loadEnv } from "./env.ts";
import { createDb } from "@new-api-key-usage/db";
import { appLogger, setupLogging } from "@new-api-key-usage/core";
const env = loadEnv(process.env);
setupLogging(env.LOG_LEVEL);
const db = createDb(env.DATABASE_URL);
const app = createApp({
db,
skKeyEncryptionSecret: env.SK_KEY_ENCRYPTION_SECRET,
newapiBase: env.NEWAPI_BASE,
cookieSecure: env.COOKIE_SECURE,
adminInitUsername: env.ADMIN_INIT_USERNAME,
adminInitPassword: env.ADMIN_INIT_PASSWORD,
});
appLogger("server").info(
`服务已启动,端口 ${env.PORT}(cookie_secure=${env.COOKIE_SECURE},日志级别=${env.LOG_LEVEL})`,
);
export default {
port: env.PORT,
fetch: app.fetch,
};
+44
View File
@@ -0,0 +1,44 @@
/**
* 登录节流(契约:429,最小实现)
* 同一用户名失败计数 + 固定退避;进程内存即可(单机母体)。
*/
const WINDOW_MS = 5 * 60_000;
const MAX_FAILURES = 5;
const LOCK_MS = 60_000;
type Entry = { count: number; firstAt: number; lockedUntil: number };
const buckets = new Map<string, Entry>();
export function isLoginThrottled(username: string): boolean {
const e = buckets.get(username);
if (!e) return false;
if (e.lockedUntil > Date.now()) return true;
if (Date.now() - e.firstAt > WINDOW_MS) buckets.delete(username);
return false;
}
export function recordLoginFailure(username: string): void {
const now = Date.now();
const e = buckets.get(username);
if (!e || now - e.firstAt > WINDOW_MS) {
buckets.set(username, { count: 1, firstAt: now, lockedUntil: 0 });
return;
}
e.count += 1;
if (e.count >= MAX_FAILURES) {
e.lockedUntil = now + LOCK_MS;
e.count = 0;
e.firstAt = now;
}
}
export function recordLoginSuccess(username: string): void {
buckets.delete(username);
}
/** 测试用 */
export function resetLoginThrottle(): void {
buckets.clear();
}
+63
View File
@@ -0,0 +1,63 @@
import type { Context, Next } from "hono";
import { getCookie } from "hono/cookie";
import { ERR, hashToken, type ErrorBody } from "@new-api-key-usage/core";
import type { Repos } from "@new-api-key-usage/db";
export const SESSION_COOKIE = "admin_session";
export const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;
export type AppVars = {
repos: Repos;
skKeyEncryptionSecret: string;
newapiBase: string;
cookieSecure: boolean;
/** 登记时调上游验证;测试可注入 */
fetchTokenUsage?: (skKey: string) => Promise<
| { ok: true; data: import("@new-api-key-usage/core").TokenUsage }
| { ok: false; kind: string }
>;
adminInitUsername?: string;
adminInitPassword?: string;
adminUsername?: string;
adminUserId?: number;
memberId?: number;
};
/** 错误信封统一出口;返回 any 以兼容 OpenAPIHono 严格响应联合 */
export function jsonError(
c: Context,
status: 400 | 401 | 404 | 409 | 422 | 429 | 500 | 502,
body: ErrorBody,
): any {
return c.json(body, status);
}
export async function requireAdminSession(
c: Context<{ Variables: AppVars }>,
next: Next,
) {
const token = getCookie(c, SESSION_COOKIE);
if (!token) return jsonError(c, 401, ERR.session_expired());
const tokenHash = await hashToken(token);
const row = await c.get("repos").sessions.findValid(tokenHash, new Date());
if (!row) return jsonError(c, 401, ERR.session_expired());
c.set("adminUserId", row.user_id);
c.set("adminUsername", row.username);
await next();
}
export async function requireMemberToken(
c: Context<{ Variables: AppVars }>,
next: Next,
) {
const header = c.req.header("Authorization") ?? "";
const m = /^Bearer\s+(.+)$/i.exec(header);
if (!m?.[1]) return jsonError(c, 401, ERR.session_expired());
const tokenHash = await hashToken(m[1]);
const row = await c.get("repos").members.findByTokenHash(tokenHash);
if (!row || row.status !== "active") {
return jsonError(c, 401, ERR.session_expired());
}
c.set("memberId", row.id);
await next();
}
+76
View File
@@ -0,0 +1,76 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import { toUnixSec, type AuditLogItem } from "@new-api-key-usage/core";
import {
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import { auditLogItemSchema, err401, err422 } from "../api-schemas.ts";
import { z } from "zod";
const pageSchema = z.object({
items: z.array(auditLogItemSchema),
total: z.number().int(),
});
export function adminAuditRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/audit-logs",
summary: "审计日志",
description: "分页、时间倒序;可按 domain 与 [from,to) 过滤",
tags: ["审计"],
security: [{ adminSession: [] }],
request: {
query: z.object({
page: z.string().optional(),
page_size: z.string().optional(),
domain: z
.enum(["key", "payment", "member", "account"])
.optional(),
from: z.string().optional(),
to: z.string().optional(),
}),
},
responses: {
200: {
description: "items/total",
content: { "application/json": { schema: pageSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const q = c.req.query();
const page = Math.max(1, Number(q.page ?? 1) || 1);
const page_size = Math.min(
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,
});
const items: AuditLogItem[] = rows.map((row) => ({
id: row.id,
created_at: toUnixSec(row.created_at)!,
operator: row.operator,
domain: row.domain as AuditLogItem["domain"],
action: row.action,
summary: row.summary,
}));
return c.json({ items, total }, 200);
},
);
return r;
}
+51
View File
@@ -0,0 +1,51 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import type { EngineStatus } from "@new-api-key-usage/core";
import { requireAdminSession, type AppVars } from "../middleware.ts";
import { engineStatusSchema, err401 } from "../api-schemas.ts";
export type EngineStatusView = EngineStatus & { sync_interval_ms: number };
export function toEngineStatusView(row: {
running: boolean;
sync_interval_ms: number;
last_round: unknown;
} | null): EngineStatusView {
if (!row) {
return { running: false, sync_interval_ms: 300_000, last_round: null };
}
return {
running: row.running,
sync_interval_ms: row.sync_interval_ms,
last_round: (row.last_round as EngineStatus["last_round"]) ?? null,
};
}
export function adminEngineRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/engine",
summary: "引擎状态",
description:
"同步引擎单行快照(engine_status);last_round 为 null 表示尚未跑过。sync_interval_ms 只读。",
tags: ["引擎"],
security: [{ adminSession: [] }],
responses: {
200: {
description: "status",
content: { "application/json": { schema: engineStatusSchema } },
},
401: err401,
},
}),
async (c) => {
const row = await c.get("repos").engine.getSnapshot();
return c.json(toEngineStatusView(row), 200);
},
);
return r;
}
+325
View File
@@ -0,0 +1,325 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
KeyCrypto,
normalizeExpiresAt,
toUnixSec,
type KeyItem,
} from "@new-api-key-usage/core";
import type { ApiKeyRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import {
err401,
err404,
err409,
err422,
keyItemSchema,
patchKeyBodySchema,
registerKeyBodySchema,
} from "../api-schemas.ts";
import { z } from "zod";
function toKeyItem(row: ApiKeyRow & { member_name?: string | null }): KeyItem {
return {
id: row.id,
token_name: row.token_name,
status: row.status as "active" | "disabled",
member_id: row.member_id,
member_name: row.member_name ?? null,
registered_at: toUnixSec(row.registered_at)!,
log_synced_at: toUnixSec(row.log_synced_at),
usage_synced_at: toUnixSec(row.usage_synced_at),
total_used: row.total_used,
total_available: row.total_available,
total_granted: row.total_granted,
unlimited_quota: row.unlimited_quota,
expires_at: toUnixSec(row.expires_at),
};
}
export function adminKeyRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
// 鉴权优先于请求体校验(未登录恒 401)
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/keys",
summary: "key 清单",
description:
"全量直返(基数 10–25,不分页)。key_hash / key_ciphertext 永不出 API。",
tags: ["Key 管理"],
security: [{ adminSession: [] }],
request: {
query: z.object({
member_id: z.string().optional(),
status: z.enum(["active", "disabled"]).optional(),
recent_failed: z.enum(["true", "false"]).optional(),
}),
},
responses: {
200: {
description: "key 数组",
content: {
"application/json": { schema: z.array(keyItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const q = c.req.query();
const filter: {
member_id?: number | "unassigned";
status?: string;
} = {};
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;
}
let rows = await c.get("repos").keys.listAll(filter);
if (q.recent_failed === "true") {
const snap = await c.get("repos").engine.getSnapshot();
const failed = new Set(
((snap?.last_round as { failed_api_key_ids?: number[] } | null)
?.failed_api_key_ids) ?? [],
);
rows = rows.filter((r) => failed.has(r.id));
}
return c.json(rows.map(toKeyItem), 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/keys",
summary: "登记 key",
description:
"四态:201 成功 / 409 已登记(附 key_id)/ 422 上游拒绝 / 502 上游不可达。查库优先于上游验证。",
tags: ["Key 管理"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: registerKeyBodySchema } },
required: true,
},
},
responses: {
201: {
description: "key 实体",
content: { "application/json": { schema: keyItemSchema } },
},
401: err401,
409: err409,
422: err422,
502: {
description: "上游不可达",
content: {
"application/json": {
schema: err422.content["application/json"].schema,
},
},
},
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const sk_key = typeof body?.sk_key === "string" ? body.sk_key.trim() : "";
let member_id: number | null = null;
if (body?.member_id !== undefined && body?.member_id !== null) {
const n = Number(body.member_id);
if (!Number.isInteger(n) || n <= 0) {
return jsonError(
c,
422,
ERR.validation_failed({ member_id: "成员 id 无效" }),
);
}
member_id = n;
}
if (!sk_key || !sk_key.startsWith("sk-")) {
return jsonError(
c,
422,
ERR.validation_failed({
sk_key: "请粘贴有效的 sk- 开头的 key",
}),
);
}
const repos = c.get("repos");
const crypto = await KeyCrypto.fromEncryptionSecret(
c.get("skKeyEncryptionSecret"),
);
const key_hash = await crypto.hashKey(sk_key);
const existing = await repos.keys.findByHash(key_hash);
if (existing) {
return jsonError(c, 409, ERR.key_already_registered(existing.id));
}
if (member_id != null) {
const m = await repos.members.findById(member_id);
if (!m) {
return jsonError(
c,
422,
ERR.validation_failed({ member_id: "成员不存在" }),
);
}
}
const key_ciphertext = await crypto.sealKey(sk_key);
// 上游验证(登记四态:422 拒绝 / 502 不可达)
const fetchUsage =
c.get("fetchTokenUsage") ??
(async (key: string) => {
const { createUpstreamClient } = await import(
"@new-api-key-usage/core"
);
const client = createUpstreamClient({
baseUrl: c.get("newapiBase"),
});
return client.fetchTokenUsage(key);
});
const upstream = await fetchUsage(sk_key);
if (!upstream.ok) {
if (upstream.kind === "rejected") {
return jsonError(
c,
422,
ERR.validation_failed({
sk_key: "上游拒绝了这枚 key——可能不存在或已被禁用",
}),
);
}
return jsonError(c, 502, ERR.upstream_unreachable());
}
const usage = upstream.data;
const row = await repos.keys.insert({
key_hash,
key_ciphertext,
member_id,
token_name: usage.name ?? "",
});
// 首次登记即写入上游额度字段(表设计 §4:首轮日志同步异步补)
if (row.id) {
await repos.keys.update(row.id, {
token_name: usage.name ?? "",
total_granted: usage.total_granted,
total_used: usage.total_used,
total_available: usage.total_available,
unlimited_quota: usage.unlimited_quota,
model_limits: usage.model_limits,
model_limits_enabled: usage.model_limits_enabled,
expires_at: normalizeExpiresAt(usage.expires_at),
usage_synced_at: new Date(),
});
}
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "key",
action: "key.register",
summary: `登记 key #${row.id}${row.token_name ? ` ${row.token_name}` : ""}`,
});
const fresh = (await repos.keys.findById(row.id)) ?? row;
return c.json(toKeyItem({ ...fresh, member_name: null }), 201);
},
);
r.openapi(
createRoute({
method: "patch",
path: "/keys/{id}",
summary: "启停 / 归属变更",
description: "部分更新;审计按实际变更字段各落一行",
tags: ["Key 管理"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: { "application/json": { schema: patchKeyBodySchema } },
required: true,
},
},
responses: {
200: {
description: "key 实体",
content: { "application/json": { schema: keyItemSchema } },
},
401: err401,
404: err404,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = await c.req.json().catch(() => null);
const repos = c.get("repos");
const before = await repos.keys.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
const patch: Partial<ApiKeyRow> = {};
const actions: Array<{ action: string; summary: string }> = [];
if (body?.status === "active" || body?.status === "disabled") {
if (body.status !== before.status) {
patch.status = body.status;
actions.push({
action: body.status === "active" ? "key.enable" : "key.disable",
summary: `key #${before.id} · ${body.status === "active" ? "启用" : "停用"}`,
});
}
}
if (body && "member_id" in body) {
let nextMember: number | null = null;
if (body.member_id != null) {
const n = Number(body.member_id);
if (!Number.isInteger(n) || n <= 0) {
return jsonError(
c,
422,
ERR.validation_failed({ member_id: "成员 id 无效" }),
);
}
nextMember = n;
}
if (nextMember !== before.member_id) {
patch.member_id = nextMember;
actions.push({
action: "key.reassign",
summary: `key #${before.id} · 归属变更`,
});
}
}
if (Object.keys(patch).length === 0) {
return jsonError(
c,
422,
ERR.validation_failed({ body: "没有需要更新的字段" }),
);
}
const after = await repos.keys.update(id, patch);
for (const a of actions) {
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "key",
action: a.action,
summary: a.summary,
});
}
return c.json(toKeyItem({ ...after, member_name: null }), 200);
},
);
return r;
}
+217
View File
@@ -0,0 +1,217 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import { ERR, generateToken, toUnixSec } from "@new-api-key-usage/core";
import type { MemberRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import {
createMemberBodySchema,
err401,
err404,
err409,
err422,
memberItemSchema,
memberWithTokenSchema,
patchMemberBodySchema,
tokenOnlySchema,
} from "../api-schemas.ts";
import { z } from "zod";
function toMemberItem(row: MemberRow, key_count: number) {
return {
id: row.id,
name: row.name,
remark: row.remark,
status: row.status as "active" | "disabled",
token_issued_at: toUnixSec(row.token_issued_at),
created_at: toUnixSec(row.created_at)!,
key_count,
};
}
export function adminMemberRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/members",
summary: "成员清单",
tags: ["成员管理"],
security: [{ adminSession: [] }],
responses: {
200: {
description: "成员数组",
content: {
"application/json": { schema: z.array(memberItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const repos = c.get("repos");
const rows = await repos.members.listAll();
const items = [];
for (const row of rows) {
items.push(toMemberItem(row, await repos.members.countKeys(row.id)));
}
return c.json(items, 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/members",
summary: "开户(自动签发令牌)",
description: "响应附明文 token——仅此一次;名称冲突 409",
tags: ["成员管理"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: createMemberBodySchema } },
required: true,
},
},
responses: {
201: {
description: "member + 明文 token",
content: { "application/json": { schema: memberWithTokenSchema } },
},
401: err401,
409: err409,
422: err422,
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const name = typeof body?.name === "string" ? body.name.trim() : "";
const remark = typeof body?.remark === "string" ? body.remark : "";
if (!name) {
return jsonError(
c,
422,
ERR.validation_failed({ name: "请填写成员名称" }),
);
}
const repos = c.get("repos");
if (await repos.members.findByName(name)) {
return jsonError(c, 409, ERR.member_name_taken());
}
const token = await generateToken();
const row = await repos.members.insert({
name,
remark,
token_hash: token.hash,
token_issued_at: new Date(),
});
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: "member.create",
summary: `开户 ${name}`,
});
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: "member.token_issue",
summary: `签发令牌 ${name}`,
});
return c.json(
{
member: toMemberItem(row, 0),
token: token.plaintext,
},
201,
);
},
);
r.openapi(
createRoute({
method: "patch",
path: "/members/{id}",
summary: "停用 / 启用",
tags: ["成员管理"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: { "application/json": { schema: patchMemberBodySchema } },
required: true,
},
},
responses: {
200: {
description: "成员实体",
content: { "application/json": { schema: memberItemSchema } },
},
401: err401,
404: err404,
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);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: status === "active" ? "member.enable" : "member.disable",
summary: `成员 ${row.name} · ${status === "active" ? "启用" : "停用"}`,
});
return c.json(toMemberItem(row, 0), 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/members/{id}/token",
summary: "令牌重发",
description: "旧令牌即刻失效;明文仅此响应一次",
tags: ["成员管理"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
},
responses: {
201: {
description: "明文 token",
content: { "application/json": { schema: tokenOnlySchema } },
},
401: err401,
404: err404,
},
}),
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 token = await generateToken();
await repos.members.reissueToken(id, token.hash, new Date());
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: "member.token_reissue",
summary: `重发令牌 ${before.name}`,
});
return c.json({ token: token.plaintext }, 201);
},
);
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;
}
+319
View File
@@ -0,0 +1,319 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import { setCookie, deleteCookie, getCookie } from "hono/cookie";
import {
ERR,
generateToken,
hashPassword,
hashToken,
verifyPassword,
} from "@new-api-key-usage/core";
import {
jsonError,
requireAdminSession,
SESSION_COOKIE,
SESSION_TTL_SECONDS,
type AppVars,
} from "../middleware.ts";
import {
changePasswordBodySchema,
err401,
err409,
err422,
loginBodySchema,
setupBodySchema,
setupStateSchema,
usernameSchema,
} from "../api-schemas.ts";
import {
isLoginThrottled,
recordLoginFailure,
recordLoginSuccess,
} from "../login-throttle.ts";
function setSessionCookie(
c: Parameters<typeof setCookie>[0],
plaintext: string,
secure: boolean,
) {
setCookie(c, SESSION_COOKIE, plaintext, {
httpOnly: true,
secure,
sameSite: "Lax",
path: "/api/admin",
maxAge: SESSION_TTL_SECONDS,
});
}
/** 哑哈希:用户不存在时也跑一次 PBKDF2,抹平时序侧信道 */
const DUMMY_PASSWORD_HASH =
"pbkdf2$210000$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
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",
path: "/setup-state",
summary: "入口三态判定",
description:
"users 非空 → true(登录页);空库 + env 凭证在 → 静默落库后 true;空库 + 无凭证 → false(引导页)",
tags: ["引导与会话"],
responses: {
200: {
description: "initialized",
content: { "application/json": { schema: setupStateSchema } },
},
},
}),
async (c) => {
const repos = c.get("repos");
if ((await repos.users.countAll()) > 0) {
return c.json({ initialized: true }, 200);
}
const initUser = c.get("adminInitUsername");
const initPass = c.get("adminInitPassword");
if (initUser && initPass) {
try {
const password_hash = await hashPassword(initPass);
await repos.users.insert(initUser, password_hash);
await repos.audit.insert({
operator: null,
domain: "account",
action: "account.init",
summary: "空库 env 静默重建管理员",
});
} catch {
// 并发/重复初始化:唯一约束冲突时视为已初始化
}
return c.json({ initialized: true }, 200);
}
return c.json({ initialized: false }, 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/setup",
summary: "引导页落库(一次性)",
description: "仅空库 + 无 env 凭证时有效;成功即建立会话",
tags: ["引导与会话"],
request: {
body: {
content: { "application/json": { schema: setupBodySchema } },
required: true,
},
},
responses: {
200: {
description: "username + Set-Cookie",
content: { "application/json": { schema: usernameSchema } },
},
409: err409,
422: err422,
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const username =
typeof body?.username === "string" ? body.username.trim() : "";
const password = typeof body?.password === "string" ? body.password : "";
const fields: Record<string, string> = {};
if (!username) fields.username = "请填写用户名";
if (!password) fields.password = "请填写密码";
if (Object.keys(fields).length > 0) {
return jsonError(c, 422, ERR.validation_failed(fields));
}
const repos = c.get("repos");
if ((await repos.users.countAll()) > 0) {
return jsonError(c, 409, ERR.already_initialized());
}
const password_hash = await hashPassword(password);
const user = await repos.users.insert(username, password_hash);
const token = await generateToken();
const expires_at = new Date(Date.now() + SESSION_TTL_SECONDS * 1000);
await repos.sessions.insert(user.id, token.hash, expires_at);
await repos.audit.insert({
operator: username,
domain: "account",
action: "account.init",
summary: `引导页创建管理员 ${username}`,
});
setSessionCookie(c, token.plaintext, c.get("cookieSecure"));
return c.json({ username: user.username }, 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/session",
summary: "登录",
tags: ["引导与会话"],
request: {
body: {
content: { "application/json": { schema: loginBodySchema } },
required: true,
},
},
responses: {
200: {
description: "username + Set-Cookie",
content: { "application/json": { schema: usernameSchema } },
},
401: err401,
429: {
description: "登录过于频繁",
content: {
"application/json": {
schema: err422.content["application/json"].schema,
},
},
},
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const username = typeof body?.username === "string" ? body.username : "";
const password = typeof body?.password === "string" ? body.password : "";
if (isLoginThrottled(username)) {
return jsonError(c, 429, ERR.too_many_requests());
}
const repos = c.get("repos");
const user = await repos.users.findByUsername(username);
const ok = await verifyPassword(
password,
user?.password_hash ?? DUMMY_PASSWORD_HASH,
);
if (!user || !ok) {
recordLoginFailure(username || "_");
return jsonError(c, 401, ERR.invalid_credentials());
}
recordLoginSuccess(username);
const token = await generateToken();
const expires_at = new Date(Date.now() + SESSION_TTL_SECONDS * 1000);
await repos.sessions.insert(user.id, token.hash, expires_at);
setSessionCookie(c, token.plaintext, c.get("cookieSecure"));
return c.json({ username: user.username }, 200);
},
);
r.openapi(
createRoute({
method: "get",
path: "/session",
summary: "当前会话",
tags: ["引导与会话"],
security: [{ adminSession: [] }],
responses: {
200: {
description: "username",
content: { "application/json": { schema: usernameSchema } },
},
401: err401,
},
}),
async (c) => {
return c.json({ username: c.get("adminUsername") ?? "" }, 200);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/session",
summary: "登出",
tags: ["引导与会话"],
security: [{ adminSession: [] }],
responses: {
204: { description: "No Content" },
401: err401,
},
}),
async (c) => {
const token = getCookie(c, SESSION_COOKIE);
if (token) {
await c.get("repos").sessions.deleteByTokenHash(await hashToken(token));
}
deleteCookie(c, SESSION_COOKIE, { path: "/api/admin" });
return c.body(null, 204);
},
);
r.openapi(
createRoute({
method: "put",
path: "/password",
summary: "改密",
description: "成功后删除其他会话、保留当前",
tags: ["引导与会话"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: changePasswordBodySchema } },
required: true,
},
},
responses: {
204: { description: "No Content" },
401: err401,
422: err422,
},
}),
async (c) => {
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) {
return jsonError(
c,
422,
ERR.validation_failed({ new_password: "请填写新密码" }),
);
}
const repos = c.get("repos");
const userId = c.get("adminUserId")!;
const user = await repos.users.findById(userId);
if (!user) return jsonError(c, 401, ERR.session_expired());
const ok = await verifyPassword(old_password, user.password_hash);
if (!ok) {
return jsonError(
c,
422,
ERR.validation_failed({ old_password: "旧密码不正确" }),
);
}
await repos.users.updatePassword(
userId,
await hashPassword(new_password),
);
const token = getCookie(c, SESSION_COOKIE);
const currentHash = token ? await hashToken(token) : null;
const all = await repos.sessions.listByUser(userId);
for (const s of all) {
if (s.token_hash !== currentHash) {
await repos.sessions.deleteById(s.id);
}
}
await repos.audit.insert({
operator: user.username,
domain: "account",
action: "account.password_change",
summary: "修改密码,其他会话已失效",
});
return c.body(null, 204);
},
);
return r;
}
@@ -0,0 +1,282 @@
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),
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;
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,
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: [] }],
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 rows = await repos.subscriptions.listAll();
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;
}
+238
View File
@@ -0,0 +1,238 @@
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),
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;
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),
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: [] }],
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 rows = await repos.topups.listAll();
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;
}
+397
View File
@@ -0,0 +1,397 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
resolveQpuTimeline,
sumUsage,
toUnixSec,
usageRowPrecise,
type LogItem,
} from "@new-api-key-usage/core";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import {
err401,
err422,
logPageSchema,
} from "../api-schemas.ts";
import { z } from "zod";
const usageTotals = z.object({
count: z.number().int(),
quota: z.number(),
quota_usd: z.string(),
});
const overviewSchema = z.object({
data_cutoff: z.number().int().nullable(),
unassigned_subscription: usageTotals,
by_source: z.array(
z.object({
billing_source: z.string(),
count: z.number().int(),
quota: z.number(),
quota_usd: z.string(),
}),
),
by_member: z.array(
z.object({
member_id: z.number().int().nullable(),
member_name: z.string().nullable(),
key_count: z.number().int(),
data_cutoff: z.number().int().nullable(),
usage: usageTotals,
}),
),
by_key: z.array(
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,
}),
),
});
const qpuLatestSchema = z.object({
qpu: z.number(),
effective_from: z.number().int(),
});
const timeRange = z.object({
from: z.string(),
to: z.string(),
});
function parseRange(
query: { from?: string; to?: string },
): { from: Date; to: Date } | { fields: Record<string, string> } {
const fields: Record<string, string> = {};
if (!query.from) fields.from = "缺少起始时间";
if (!query.to) fields.to = "缺少结束时间";
if (Object.keys(fields).length > 0) return { fields };
const from = new Date(query.from!);
const to = new Date(query.to!);
if (Number.isNaN(from.getTime())) fields.from = "时间格式无效";
if (Number.isNaN(to.getTime())) fields.to = "时间格式无效";
else if (to.getTime() <= from.getTime()) fields.to = "结束时间必须晚于起始时间";
if (Object.keys(fields).length > 0) return { fields };
return { from, to };
}
export function adminUsageRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/qpu/latest",
summary: "当前换算率",
description: "支付表单美元 ↔ quota 双输入换算用;库空时回退 500000",
tags: ["用量"],
security: [{ adminSession: [] }],
responses: {
200: {
description: "qpu",
content: { "application/json": { schema: qpuLatestSchema } },
},
401: err401,
},
}),
async (c) => {
const latest = await c.get("repos").qpu.latest();
if (!latest) {
return c.json(
{ qpu: 500_000, effective_from: 0 },
200,
);
}
return c.json(
{
qpu: latest.qpu,
effective_from: toUnixSec(latest.effective_from)!,
},
200,
);
},
);
r.openapi(
createRoute({
method: "get",
path: "/overview",
summary: "全员总览",
description:
"from/to 必填、左闭右开;聚合仅 type=2。unassigned_subscription 为健康指示器。",
tags: ["用量"],
security: [{ adminSession: [] }],
request: { query: timeRange },
responses: {
200: {
description: "overview",
content: { "application/json": { schema: overviewSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const q = c.req.query();
const range = parseRange(q);
if ("fields" in range) {
return jsonError(c, 422, ERR.validation_failed(range.fields));
}
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const rows = await repos.logs.listForAggregate(range.from, range.to);
const data_cutoff = await repos.keysExtra.slowestLogSyncedAt();
const unassigned_rows = rows.filter(
(x) => x.billing_source === "subscription" && x.subscription_id == null,
);
const unassigned_subscription = sumUsage(unassigned_rows, timeline);
const bySourceMap = new Map<
string,
Array<{ quota: number; created_at: Date }>
>();
for (const x of rows) {
const key = x.billing_source ?? "unknown";
const list = bySourceMap.get(key) ?? [];
list.push({ quota: x.quota, created_at: x.created_at });
bySourceMap.set(key, list);
}
const by_source = [...bySourceMap.entries()].map(([billing_source, list]) => ({
billing_source,
...sumUsage(list, timeline),
}));
// 按 key / 成员(含未归属)
const keyMap = new Map<
number,
{
token_name: string;
member_id: number | null;
member_name: string | null;
status: string;
log_synced_at: Date | null;
rows: Array<{ quota: number; created_at: Date }>;
}
>();
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:
k.member_id == null ? null : (memberNameById.get(k.member_id) ?? null),
status: k.status,
log_synced_at: k.log_synced_at,
rows: [],
});
}
for (const x of rows) {
const entry = keyMap.get(x.api_key_id);
if (entry) {
entry.rows.push({ quota: x.quota, created_at: x.created_at });
if (x.member_name) entry.member_name = x.member_name;
}
}
const by_key = [...keyMap.entries()]
.map(([key_id, v]) => ({
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),
}))
.sort((a, b) => b.usage.quota - a.usage.quota);
const memberBuckets = new Map<
string,
{
member_id: number | null;
member_name: string | null;
key_count: number;
cutoff: Date | null;
rows: Array<{ quota: number; created_at: Date }>;
}
>();
for (const [key_id, v] of keyMap) {
const mid = v.member_id == null ? "null" : String(v.member_id);
const b = memberBuckets.get(mid) ?? {
member_id: v.member_id,
member_name: v.member_name,
key_count: 0,
cutoff: null,
rows: [],
};
b.key_count += 1;
if (
v.status === "active" &&
v.log_synced_at &&
(b.cutoff === null || v.log_synced_at < b.cutoff)
) {
b.cutoff = v.log_synced_at;
}
b.rows.push(...v.rows);
if (!b.member_name && v.member_name) b.member_name = v.member_name;
memberBuckets.set(mid, b);
}
const by_member = [...memberBuckets.values()].map((b) => ({
member_id: b.member_id,
member_name: b.member_name,
key_count: b.key_count,
data_cutoff: toUnixSec(b.cutoff),
usage: sumUsage(b.rows, timeline),
}));
return c.json(
{
data_cutoff: toUnixSec(data_cutoff),
unassigned_subscription,
by_source,
by_member,
by_key,
},
200,
);
},
);
r.openapi(
createRoute({
method: "get",
path: "/logs",
summary: "日志明细(下钻)",
description:
"分页;from/to 必填;默认全类型(可 type 筛);token_name 取当前名",
tags: ["用量"],
security: [{ adminSession: [] }],
request: {
query: timeRange.extend({
page: z.string().optional(),
page_size: z.string().optional(),
api_key_id: z.string().optional(),
member_id: z.string().optional(),
billing_source: z.string().optional(),
subscription_id: z.string().optional(),
type: z.string().optional(),
}),
},
responses: {
200: {
description: "items/total",
content: { "application/json": { schema: logPageSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const q = c.req.query();
const range = parseRange(q);
if ("fields" in range) {
return jsonError(c, 422, ERR.validation_failed(range.fields));
}
const page = Math.max(1, Number(q.page ?? 1) || 1);
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({
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 === "unassigned"
? "unassigned"
: 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 === "error"
? "error"
: q.type != null && q.type !== ""
? Number(q.type)
: undefined,
});
const items: LogItem[] = rows.map((row) => {
const { quota_usd } = usageRowPrecise(
row.quota,
row.created_at,
timeline,
);
return {
id: row.id,
api_key_id: row.api_key_id,
created_at: toUnixSec(row.created_at)!,
token_name: row.token_name,
model_name: row.model_name,
type: row.type,
content: row.content,
quota: row.quota,
quota_usd,
prompt_tokens: row.prompt_tokens,
completion_tokens: row.completion_tokens,
use_time: row.use_time,
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 r;
}
+345
View File
@@ -0,0 +1,345 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
resolveQpuTimeline,
sumUsage,
usageRowPrecise,
toUnixSec,
type LogItem,
} from "@new-api-key-usage/core";
import {
jsonError,
requireMemberToken,
type AppVars,
} from "../middleware.ts";
import {
err401,
err422,
logPageSchema,
memberMeSchema,
memberOverviewSchema,
} from "../api-schemas.ts";
import { z } from "zod";
function parseRange(
query: { from?: string; to?: string },
): { from: Date; to: Date } | { fields: Record<string, string> } {
const fields: Record<string, string> = {};
if (!query.from) fields.from = "缺少起始时间";
if (!query.to) fields.to = "缺少结束时间";
if (Object.keys(fields).length > 0) return { fields };
const from = new Date(query.from!);
const to = new Date(query.to!);
if (Number.isNaN(from.getTime())) fields.from = "时间格式无效";
if (Number.isNaN(to.getTime())) fields.to = "时间格式无效";
else if (to.getTime() <= from.getTime()) fields.to = "结束时间必须晚于起始时间";
if (Object.keys(fields).length > 0) return { fields };
return { from, to };
}
/** 成员视图(`/api/member/*`,Bearer)——与管理面物理隔离 */
export function memberViewRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireMemberToken);
r.openapi(
createRoute({
method: "get",
path: "/me",
summary: "令牌验证 + 页头",
description: "不建会话;令牌即长期凭证",
tags: ["成员视图"],
security: [{ memberToken: [] }],
responses: {
200: {
description: "member",
content: { "application/json": { schema: memberMeSchema } },
},
401: err401,
},
}),
async (c) => {
const row = await c.get("repos").members.findById(c.get("memberId")!);
if (!row) return jsonError(c, 401, ERR.session_expired());
return c.json({ id: row.id, name: row.name }, 200);
},
);
r.openapi(
createRoute({
method: "get",
path: "/overview",
summary: "单页聚合",
description:
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(完全公开透明)",
tags: ["成员视图"],
security: [{ memberToken: [] }],
request: {
query: z.object({ from: z.string(), to: z.string() }),
},
responses: {
200: {
description: "overview",
content: { "application/json": { schema: memberOverviewSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const range = parseRange(c.req.query());
if ("fields" in range) {
return jsonError(c, 422, ERR.validation_failed(range.fields));
}
const repos = c.get("repos");
const memberId = c.get("memberId")!;
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const member = await repos.members.findById(memberId);
const rows = await repos.logs.listMemberForAggregate(
memberId,
range.from,
range.to,
);
const usage = sumUsage(
rows.map((x) => ({ quota: x.quota, created_at: x.created_at })),
timeline,
);
const sourceMap = new Map<
string,
Array<{ quota: number; created_at: Date }>
>();
const keyMap = new Map<
number,
{
token_name: string;
log_synced_at: Date | null;
rows: Array<{ quota: number; created_at: Date }>;
}
>();
for (const x of rows) {
const skey = x.billing_source ?? "unknown";
const s = sourceMap.get(skey) ?? [];
s.push({ quota: x.quota, created_at: x.created_at });
sourceMap.set(skey, s);
const k = keyMap.get(x.api_key_id) ?? {
token_name: x.token_name,
log_synced_at: x.log_synced_at,
rows: [],
};
k.rows.push({ quota: x.quota, created_at: x.created_at });
keyMap.set(x.api_key_id, k);
}
// 名下 key 全量(即使时段无用量)
const allKeys = (await repos.keys.listAll()).filter(
(k) => k.member_id === memberId,
);
for (const k of allKeys) {
if (!keyMap.has(k.id)) {
keyMap.set(k.id, {
token_name: k.token_name,
log_synced_at: k.log_synced_at,
rows: [],
});
}
}
const by_source = [...sourceMap.entries()].map(([billing_source, list]) => ({
billing_source,
...sumUsage(list, timeline),
}));
const keys = [...keyMap.entries()].map(([key_id, v]) => ({
key_id,
token_name: v.token_name,
log_synced_at: toUnixSec(v.log_synced_at),
usage: sumUsage(v.rows, timeline),
}));
// 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to)
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
const subRows = await repos.subscriptions.listAll();
const usedSubIds = new Set(
rows.map((x) => x.subscription_id).filter((v): v is number => v != null),
);
const subscriptionsOut = [];
for (const s of subRows) {
const ongoing =
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) || ongoing;
if (!involved) 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),
remark: s.remark,
created_at: toUnixSec(s.created_at)!,
});
}
const topupRows = await repos.topups.listAll();
const topupsOut = [];
for (const t of topupRows) {
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)!,
quota_derived,
quota_usd: quotaToUsdStr(quota_derived, qpu),
remark: t.remark,
created_at: toUnixSec(t.created_at)!,
});
}
return c.json(
{
member: { id: memberId, name: member?.name ?? null },
usage,
by_source,
keys,
subscriptions: subscriptionsOut,
topups: topupsOut,
},
200,
);
},
);
r.openapi(
createRoute({
method: "get",
path: "/logs",
summary: "日志明细(名下 key)",
description: "分页;默认全类型(可 type 筛);DTO 与管理面同构",
tags: ["成员视图"],
security: [{ memberToken: [] }],
request: {
query: z.object({
from: z.string(),
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(),
type: z.string().optional(),
}),
},
responses: {
200: {
description: "items/total",
content: { "application/json": { schema: logPageSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const q = c.req.query();
const range = parseRange(q);
if ("fields" in range) {
return jsonError(c, 422, ERR.validation_failed(range.fields));
}
const page = Math.max(1, Number(q.page ?? 1) || 1);
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.listMemberPage({
member_id: c.get("memberId")!,
from: range.from,
to: range.to,
page,
page_size,
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,
type:
q.type === "error"
? "error"
: q.type != null && q.type !== ""
? Number(q.type)
: undefined,
});
const items: LogItem[] = rows.map((row) => {
const { quota_usd } = usageRowPrecise(
row.quota,
row.created_at,
timeline,
);
return {
id: row.id,
api_key_id: row.api_key_id,
created_at: toUnixSec(row.created_at)!,
token_name: row.token_name ?? "",
model_name: row.model_name,
type: row.type,
content: row.content,
quota: row.quota,
quota_usd,
prompt_tokens: row.prompt_tokens,
completion_tokens: row.completion_tokens,
use_time: row.use_time,
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 r;
}
+7
View File
@@ -0,0 +1,7 @@
/** 必须在 core 的 zod schema 被求值之前执行(api-schemas 首行 side-effect import) */
import { z } from "zod";
import { extendZodWithOpenApi } from "@hono/zod-openapi";
extendZodWithOpenApi(z);
export {};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["bun"],
"allowImportingTsExtensions": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+39
View File
@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+54
View File
@@ -0,0 +1,54 @@
# 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
```
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+32
View File
@@ -0,0 +1,32 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginVitest from '@vitest/eslint-plugin'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
{
"name": "@new-api-key-usage/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s \"lint:*\"",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@antdv-next/icons": "^1.1.2",
"@new-api-key-usage/core": "workspace:*",
"@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"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.5",
"@types/jsdom": "^30.0.0",
"@types/node": "^24.13.4",
"@vitejs/plugin-vue": "^6.0.8",
"@vitejs/plugin-vue-jsx": "^5.1.6",
"@vitest/eslint-plugin": "^1.6.27",
"@vue/eslint-config-typescript": "^14.9.0",
"@vue/test-utils": "^2.5.0",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.82.0",
"eslint-plugin-vue": "~10.11.0",
"jiti": "^2.7.0",
"jsdom": "^30.0.1",
"npm-run-all2": "^9.0.3",
"oxlint": "~1.82.0",
"prettier": "3.9.6",
"typescript": "~6.0.0",
"vite": "^8.2.2",
"vite-plugin-vue-devtools": "^8.2.1",
"vitest": "^4.1.11",
"vue-eslint-parser": "^10.4.1",
"vue-tsc": "^3.3.11"
},
"engines": {
"node": "^22.18.0 || >=24.12.0"
}
}
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.

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

+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;
}
+29
View File
@@ -0,0 +1,29 @@
import type { KeyItem } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type KeyListFilters = {
member_id?: number | "unassigned";
status?: "active" | "disabled";
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;
}): 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 },
): Promise<KeyItem> {
const { data } = await adminHttp.patch<KeyItem>(`/keys/${id}`, body);
return data;
}
+23
View File
@@ -0,0 +1,23 @@
import type { LogItem, Paginated } 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;
type?: number | "error";
};
export async function fetchLogs(
params: AdminLogFilters,
): Promise<Paginated<LogItem>> {
const { data } = await adminHttp.get<Paginated<LogItem>>("/logs", {
params,
});
return data;
}
+35
View File
@@ -0,0 +1,35 @@
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: { status: "active" | "disabled" },
): Promise<MemberItem> {
const { data } = await adminHttp.patch<MemberItem>(`/members/${id}`, body);
return data;
}
/** 重发:旧令牌即刻失效,明文仅此一次 */
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);
}
+39
View File
@@ -0,0 +1,39 @@
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;
remark?: string;
};
export async function fetchSubscriptions(): Promise<SubscriptionItem[]> {
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions");
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}`);
}
+36
View File
@@ -0,0 +1,36 @@
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;
remark?: string;
};
export async function fetchTopups(): Promise<TopupItem[]> {
const { data } = await adminHttp.get<TopupItem[]>("/topups");
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 {
LogItem,
Paginated,
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;
type?: number | "error";
};
export async function fetchLogs(query: MemberLogQuery): Promise<Paginated<LogItem>> {
const { data } = await memberHttp.get<Paginated<LogItem>>("/logs", {
params: query,
});
return data;
}
+68
View File
@@ -0,0 +1,68 @@
/**
* 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.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.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>
+8
View File
@@ -0,0 +1,8 @@
body {
margin: 0;
font-family:
-apple-system,
"PingFang SC",
"Microsoft YaHei",
sans-serif;
}
@@ -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,122 @@
<script setup lang="ts">
/**
* 令牌明文对话框:仅签发/重发后出现一次(交互-成员管理 §3)
*/
import { ref, watch } from "vue";
import { message } from "antdv-next";
import { CopyOutlined, WarningOutlined } from "@antdv-next/icons";
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;
}
},
);
async function onCopy() {
try {
await navigator.clipboard.writeText(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,461 @@
<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 {
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 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,
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,
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,
remark: "",
});
}
function resetTop() {
Object.assign(topForm, {
upstream_id: null,
amount: null,
money: null,
trade_no: "",
payment_method: "",
payment_provider: "",
paid_at: null,
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.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.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),
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)!,
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-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-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 { computed, 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,217 @@
<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 } 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 form = reactive({
sk_key: "",
member_id: null as number | "unassigned" | null,
});
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;
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,
});
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 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,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,86 @@
<script setup lang="ts">
import type { Dayjs } from "dayjs";
import dayjs from "dayjs";
import { computed } from "vue";
import {
recentDaysRange,
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: "最近 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");
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,35 @@
import { computed, onUnmounted, ref } from "vue";
import { recentDaysRange } from "@/utils/time.ts";
export type RangeValue = { from: string; to: string };
export type RangeKind = "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 === "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>
+25
View File
@@ -0,0 +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 { 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");
+235
View File
@@ -0,0 +1,235 @@
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,
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 };
}) => 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: { status: "active" | "disabled" };
}) => patchMember(id, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.members() });
},
});
}
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()),
});
}
+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: "/admin/overview" },
...adminRoutes,
...memberRoutes,
{ path: "/:pathMatch(.*)*", redirect: "/admin/overview" },
],
});
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 = "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);
}
+104
View File
@@ -0,0 +1,104 @@
/**
* 时间展示: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) };
}
/** 统计口径标注:「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;
}
+163
View File
@@ -0,0 +1,163 @@
<script setup lang="ts">
/**
* 审计日志(交互设计-审计日志):只读时间线 + 域筛选 + 分页
* 表体内部滚动:高度按容器实测,避免内容区被撑出视口
*/
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import PageHeader from "@/components/common/page-header.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>
<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>
+377
View File
@@ -0,0 +1,377 @@
<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 RegisterKeyModal from "@/components/admin/register-key-modal.vue";
import { useEngine, useKeys, useMembers, usePatchKey } 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 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;
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 (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, filterRecentFailed], () => {
pushQuery();
});
const filters = computed(() => ({
member_id: filterMember.value ?? undefined,
status: filterStatus.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 ||
filterRecentFailed.value,
);
const memberOptions = computed(() => [
{ value: "unassigned", label: "未归属" },
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
]);
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: "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 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-checkbox v-model:checked="filterRecentFailed">最近轮次失败</a-checkbox>
</a-space>
</div>
<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 === '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>
+473
View File
@@ -0,0 +1,473 @@
<script setup lang="ts">
/**
* 管理面调用日志(交互设计-调用日志):筛选 + 明细表 + 与 key/成员跳转
*/
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import type { LogItem } from "@new-api-key-usage/core";
import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import { useLiveTimeRange } from "@/composables/use-live-time-range.ts";
import { useAdminLogs, useKeys, useMembers } from "@/queries/admin.ts";
import { formatDateTime, recentDaysRange } from "@/utils/time.ts";
import { formatInt } 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 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,
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 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);
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 (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, 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 };
}
if (record.billing_source === "wallet") {
return { text: "充值", color: "gold" as const };
}
return { text: "未关联", color: undefined };
}
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: "recent-7" | "recent-30" | "fixed",
) {
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="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>
<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-tag :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>
</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;
}
</style>
+314
View File
@@ -0,0 +1,314 @@
<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 MemberTokenModal from "@/components/admin/member-token-modal.vue";
import {
useCreateMember,
useIssueMemberToken,
useMembers,
usePatchMember,
} from "@/queries/admin.ts";
import { formatMinute } from "@/utils/time.ts";
const membersQuery = useMembers();
const createMember = useCreateMember();
const patchMember = usePatchMember();
const issueToken = useIssueMemberToken();
const createOpen = ref(false);
const tokenOpen = ref(false);
const plainToken = ref("");
const tokenMemberName = ref("");
const createForm = reactive({ name: "", remark: "" });
const createError = 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: 200 },
];
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 || "状态更新失败");
}
}
</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>
<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-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-space>
</template>
</template>
</a-table>
<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>
+351
View File
@@ -0,0 +1,351 @@
<script setup lang="ts">
/**
* 全员总览(交互设计-全员总览):健康带 · 时间范围 · 按成员 · 按 key
*/
import { computed, h, ref } 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 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>
<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>
+363
View File
@@ -0,0 +1,363 @@
<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 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 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";
}
function pushTab() {
void router.replace({
path: "/admin/payments",
query: tab.value === "topups" ? { tab: "topups" } : {},
});
}
syncFromRoute();
watch(() => route.query, () => syncFromRoute());
watch(tab, () => pushTab());
const subRows = computed(() => [...(subsQuery.data.value ?? [])]);
const topRows = computed(() => {
const list = [...(topupsQuery.data.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: "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: "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>
<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'">
<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 === '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>
<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 === '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>
.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>
+548
View File
@@ -0,0 +1,548 @@
<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 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 { 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 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,
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 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;
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 (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, billingSource, subscriptionId, typeFilter], () => {
if (applyingRoute.value) return;
page.value = 1;
pushQuery();
});
watch(page, () => pushQuery());
function onRangeChange(v: { from: string; to: string }, k: "recent-7" | "recent-30" | "fixed") {
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="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>
<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: 16px;
}
.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>

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