Compare commits

...
45 Commits
Author SHA1 Message Date
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
144 changed files with 15721 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.* .env.*
!.env.example !.env.example
# 依赖与构建产物
node_modules/
dist/
build/
.turbo/
# 测试与工具
coverage/
*.tsbuildinfo
# Agent 本地接线(skills 本体只保留 .agents/skills)
.claude/
agent/
+41
View File
@@ -0,0 +1,41 @@
# 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` · `payments` · `qpu_history`)+ 三张访问主体表(`members` · `users` · `sessions`)设计见本目录,**七篇已定稿(2026-10-01,经作者逐篇审阅)**;同步架构(轮询 + `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 --env-file=.env scripts/seed-from-samples.ts` | 从根目录 `api*.json` 实测快照灌开发库(幂等);验收接口口径用 |
| `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) |
| `bun run dev:server` | 开发跑母体后端(hot reload) |
| `bun run dev:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) |
| `bun run dev:web` | 开发跑前端 Vite |
| `docker compose up -d` | 起开发用 PostgreSQL |
| `bun run --filter @new-api-key-usage/db db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
| `bun run --filter @new-api-key-usage/db db:push` | 推送到本地库(开发期) |
环境变量见 `.env.example`(复制为 `.env` 后填写)。
**默认端口 `18456`**(避开 3000/3001 等本机占用);`NEWAPI_BASE` 是上游 NewAPI 地址,与本服务端口无关。在线文档:`http://127.0.0.1:18456/api/docs`。
+93
View File
@@ -0,0 +1,93 @@
# 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 全部定案(含裁撤与延后),R 编号体系为后续设计文档的引用锚点 | 定稿(2026-10-01) |
| [`docs/design/技术选型.md`](docs/design/技术选型.md) | 母体技术栈裁决(CFW 延后至功能全集实现后,仅零成本 R14 预留):全栈 TS;Bun + Hono + Drizzle/pg;前端 Vue 3 + antdv-next;分层落地与回退;antdv-next 核验通过(§7.1) | 定稿(2026-10-02) |
| [`docs/design/api_keys表字段设计.md`](docs/design/api_keys表字段设计.md) | 数据模型第一张表:key 登记、生命周期与成员归属 | 定稿(2026-10-01) |
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 数据模型第二张表:日志明细、来源提列、去重与写入策略 | 同上 |
| [`docs/design/payments表字段设计.md`](docs/design/payments表字段设计.md) | 数据模型第三张表:支付记录(订阅/充值同构,币种无关,只存事实) | 同上 |
| [`docs/design/qpu_history表字段设计.md`](docs/design/qpu_history表字段设计.md) | 数据模型第四张表:换算参数时间线(自动采样、只读) | 同上 |
| [`docs/design/members表字段设计.md`](docs/design/members表字段设计.md) | 数据模型新增表:成员实体——key 归属、成员视图身份与按成员统计的锚点 | 定稿(2026-10-01) |
| [`docs/design/users表字段设计.md`](docs/design/users表字段设计.md) | 数据模型新增表:管理员(单行)——登录体系的身份落点 | 同上 |
| [`docs/design/sessions表字段设计.md`](docs/design/sessions表字段设计.md) | 数据模型新增表:管理面会话——cookie 令牌的服务端对应物 | 同上 |
| [`docs/design/交互设计-总览与共性.md`](docs/design/交互设计-总览与共性.md) | 交互设计总纲:访问拓扑、导航、跨页组件、状态与数值呈现约定 | 定稿(2026-10-01) |
| [`docs/design/交互设计-登录与会话.md`](docs/design/交互设计-登录与会话.md) | 管理面登录页、引导页、改密、成员登录壳 | 同上 |
| [`docs/design/交互设计-全员总览.md`](docs/design/交互设计-全员总览.md) | 管理面首页:健康带 + 按成员 / key 汇总 | 同上 |
| [`docs/design/交互设计-调用日志.md`](docs/design/交互设计-调用日志.md) | 管理面调用日志:筛选、明细表、与 key/成员/总览的跳转链 | 草案(待审阅) |
| [`docs/design/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 同上 |
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 订阅/充值同构表单、核对提示 | 同上 |
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、令牌签发/重发(明文仅一次)、停用 | 同上 |
| [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 同上 |
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员单页世界——R12 携带形态定案(徽章 + 展开行) | 同上 |
| [`docs/design/API契约-公共约定.md`](docs/design/API契约-公共约定.md) | API 契约总纲:路由隔离与鉴权两档、错误信封、时间 / 数值 / 分页 / 命名(8 条拟定约定经审通过)——后两篇的引用锚点 | 定稿(2026-10-02) |
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 16 端点:引导与会话三态、引擎状态、总览聚合、key 登记四态、下钻明细、支付、成员(令牌明文仅一次)、审计;5 项裁定(经审通过)+ 4 项遗留(key 详情已裁决:实现期定) | 定稿(2026-10-02) |
| [`docs/design/API契约-成员视图.md`](docs/design/API契约-成员视图.md) | 成员视图 3 端点:令牌验证与页头、单页聚合(含支付参数卡判定)、日志明细(DTO 与管理面同构);4 项裁定(经审通过)+ 1 项遗留 | 定稿(2026-10-02) |
| [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) | 独立 `apps/engine` 进程:手写 `setTimeout` 轮次、失败只记录、`engine_status` 快照供 `/api/admin/engine`;周期可配、**不为 CFW 预留** | 已实现(定稿 2026-10-05) |
| [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md) | 计费平台在 NewAPI 中可用的接口参考:鉴权档位、请求与返回结构、字段语义、实测行为、源码出处(**唯一上游参考**) | 事实依据 |
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(我们定的方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策)
- 生命周期:设计文档定稿后不搬家、只改状态(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);被取代时删除(git 历史即考古)
## 这是什么项目
- 名字直述用途:NewAPI 网关下按 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"
}
}
+91
View File
@@ -0,0 +1,91 @@
/** 必须先扩展 zod,再加载 core schema(见 zod-openapi-setup) */
import "./zod-openapi-setup.ts";
import {
changePasswordBody,
createMemberBody,
engineStatus,
errorEnvelope,
keyItem,
logItem,
logPage,
memberItem,
memberMe,
memberOverview,
memberWithToken,
patchKeyBody,
patchMemberBody,
paymentItem,
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 paymentItemSchema = nameSchema(paymentItem, "PaymentItem");
export const auditLogItemSchema = nameSchema(auditLogItem, "AuditLogItem");
export {
changePasswordBody as changePasswordBodySchema,
createMemberBody as createMemberBodySchema,
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;
+128
View File
@@ -0,0 +1,128 @@
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 { adminPaymentRoutes } from "./routes/admin-payments.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("/", adminPaymentRoutes());
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;
}
+287
View File
@@ -0,0 +1,287 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
qpuAt,
quotaToUsdStr,
resolveQpuTimeline,
toUnixSec,
type PaymentItem,
} from "@new-api-key-usage/core";
import type { PaymentRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import { err401, err404, err422, paymentItemSchema } from "../api-schemas.ts";
import { z } from "zod";
const createBody = z.object({
kind: z.enum(["subscription", "topup"]),
paid_at: z.string(),
paid_amount: z.number(),
remark: z.string().optional().default(""),
period_start: z.string().nullable().optional(),
period_end: z.string().nullable().optional(),
subscription_id: z.number().int().positive().nullable().optional(),
quota_granted: z.number().int().nullable().optional(),
});
function toPaymentItem(row: PaymentRow, quota_usd: string): PaymentItem {
return {
id: row.id,
kind: row.kind as PaymentItem["kind"],
paid_at: toUnixSec(row.paid_at)!,
paid_amount: row.paid_amount,
remark: row.remark,
period_start: toUnixSec(row.period_start),
period_end: toUnixSec(row.period_end),
subscription_id: row.subscription_id,
quota_granted: row.quota_granted,
quota_usd,
};
}
function validatePaymentBody(
body: Record<string, unknown>,
):
| { fields: Record<string, string> }
| {
values: {
kind: string;
paid_at: Date;
paid_amount: number;
remark: string;
period_start: Date | null;
period_end: Date | null;
subscription_id: number | null;
quota_granted: number | null;
};
} {
const fields: Record<string, string> = {};
const kind = body?.kind;
if (kind !== "subscription" && kind !== "topup") {
fields.kind = "请选择订阅或充值";
return { fields };
}
if (!body?.paid_at) fields.paid_at = "请填写支付时间";
if (typeof body?.paid_amount !== "number") {
fields.paid_amount = "请填写支付金额";
}
if (body?.quota_granted == null) fields.quota_granted = "请填写本笔额度";
if (kind === "subscription") {
if (!body?.period_start) fields.period_start = "订阅必须填写期间起点";
if (body?.subscription_id == null) {
fields.subscription_id = "订阅必须填写订阅编号";
}
}
if (Object.keys(fields).length > 0) return { fields };
return {
values: {
kind: kind as string,
paid_at: new Date(String(body.paid_at)),
paid_amount: Number(body.paid_amount),
remark: typeof body.remark === "string" ? body.remark : "",
period_start:
kind === "subscription" && body.period_start
? new Date(String(body.period_start))
: null,
period_end:
body.period_end != null && body.period_end !== ""
? new Date(String(body.period_end))
: null,
subscription_id:
kind === "subscription" && body.subscription_id != null
? Number(body.subscription_id)
: null,
quota_granted: Number(body.quota_granted),
},
};
}
export function adminPaymentRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/payments",
summary: "支付记录清单",
description: "全量直返、支付时间倒序;quota_usd 按 paid_at 时刻 QPU 换算",
tags: ["支付记录"],
security: [{ adminSession: [] }],
responses: {
200: {
description: "payment 数组",
content: {
"application/json": { schema: z.array(paymentItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const rows = await repos.payments.listAll();
const items = rows.map((row) => {
const qpu = qpuAt(row.paid_at, timeline);
return toPaymentItem(
row,
row.quota_granted != null
? quotaToUsdStr(row.quota_granted, qpu)
: "0.00",
);
});
return c.json(items, 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/payments",
summary: "登记支付",
description: "订阅/充值同构;kind 决定期间与订阅编号是否必填",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: createBody } },
required: true,
},
},
responses: {
201: {
description: "payment",
content: { "application/json": { schema: paymentItemSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const body = (await c.req.json().catch(() => ({}))) as Record<
string,
unknown
>;
const parsed = validatePaymentBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
const row = await repos.payments.insert(parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "payment.create",
summary: `登记支付 #${row.id} ${row.kind}`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const qpu = qpuAt(row.paid_at, timeline);
return c.json(
toPaymentItem(
row,
row.quota_granted != null
? quotaToUsdStr(row.quota_granted, qpu)
: "0.00",
),
201,
);
},
);
r.openapi(
createRoute({
method: "put",
path: "/payments/{id}",
summary: "编辑支付",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: { "application/json": { schema: createBody } },
required: true,
},
},
responses: {
200: {
description: "payment",
content: { "application/json": { schema: paymentItemSchema } },
},
401: err401,
404: err404,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = (await c.req.json().catch(() => ({}))) as Record<
string,
unknown
>;
const parsed = validatePaymentBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
if (!(await repos.payments.findById(id))) {
return jsonError(c, 404, ERR.not_found());
}
const row = await repos.payments.update(id, parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "payment.update",
summary: `编辑支付 #${row.id}`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const qpu = qpuAt(row.paid_at, timeline);
return c.json(
toPaymentItem(
row,
row.quota_granted != null
? quotaToUsdStr(row.quota_granted, qpu)
: "0.00",
),
200,
);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/payments/{id}",
summary: "删除支付",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
},
responses: {
204: { description: "No Content" },
401: err401,
404: err404,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const repos = c.get("repos");
const before = await repos.payments.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
await repos.payments.remove(id);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "payment.delete",
summary: `删除支付 #${id}`,
});
return c.body(null, 204);
},
);
return r;
}
+143
View File
@@ -0,0 +1,143 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import { z } from "zod";
import {
ERR,
fetchPlatformSubscriptions,
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") {
return jsonError(c, 401, ERR.session_expired());
}
return jsonError(c, 502, ERR.upstream_unreachable());
}
return c.json({ subscriptions: subs.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;
}
+370
View File
@@ -0,0 +1,370 @@
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,
};
});
return c.json({ items, total }, 200);
},
);
return r;
}
+297
View File
@@ -0,0 +1,297 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
resolveQpuTimeline,
sumUsage,
usageRowPrecise,
toUnixSec,
type LogItem,
type PaymentItem,
} from "@new-api-key-usage/core";
import {
jsonError,
requireMemberToken,
type AppVars,
} from "../middleware.ts";
import {
err401,
err422,
logPageSchema,
memberMeSchema,
memberOverviewSchema,
paymentItemSchema,
} 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 paymentRows = await repos.payments.listAll();
const usedSubIds = new Set(
rows.map((x) => x.subscription_id).filter((v): v is number => v != null),
);
const payments: PaymentItem[] = [];
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
for (const p of paymentRows) {
const inRange = p.paid_at >= range.from && p.paid_at < range.to;
const ongoing =
p.kind === "subscription" &&
(p.period_end == null ||
p.period_end > new Date() ||
(p.period_start != null &&
p.period_start <= range.to &&
(p.period_end ?? new Date()) >= range.from));
const involved =
p.kind === "topup"
? inRange
: usedSubIds.has(p.subscription_id ?? -1) || ongoing;
if (!involved) continue;
const qpu = qpuAt(p.paid_at, timeline);
payments.push({
id: p.id,
kind: p.kind as PaymentItem["kind"],
paid_at: toUnixSec(p.paid_at)!,
paid_amount: p.paid_amount,
remark: p.remark,
period_start: toUnixSec(p.period_start),
period_end: toUnixSec(p.period_end),
subscription_id: p.subscription_id,
quota_granted: p.quota_granted,
quota_usd:
p.quota_granted != null
? quotaToUsdStr(p.quota_granted, qpu)
: "0.00",
});
}
return c.json(
{
member: { id: memberId, name: member?.name ?? null },
usage,
by_source,
keys,
payments: payments.map((p) => ({
...p,
kind: p.kind as string,
period_start: p.period_start ?? null,
period_end: p.period_end ?? null,
subscription_id: p.subscription_id ?? null,
quota_granted: p.quota_granted ?? null,
})),
},
200,
);
},
);
r.openapi(
createRoute({
method: "get",
path: "/logs",
summary: "日志明细(名下 key)",
description: "分页;恒 type=2;DTO 与管理面同构",
tags: ["成员视图"],
security: [{ memberToken: [] }],
request: {
query: z.object({
from: z.string(),
to: z.string(),
page: z.string().optional(),
page_size: 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,
});
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,
};
});
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;
}
+26
View File
@@ -0,0 +1,26 @@
import type { PaymentItem } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type PaymentBody = Record<string, unknown>;
export async function fetchPayments(): Promise<PaymentItem[]> {
const { data } = await adminHttp.get<PaymentItem[]>("/payments");
return data;
}
export async function createPayment(body: PaymentBody): Promise<PaymentItem> {
const { data } = await adminHttp.post<PaymentItem>("/payments", body);
return data;
}
export async function updatePayment(
id: number,
body: PaymentBody,
): Promise<PaymentItem> {
const { data } = await adminHttp.put<PaymentItem>(`/payments/${id}`, body);
return data;
}
export async function deletePayment(id: number): Promise<void> {
await adminHttp.delete(`/payments/${id}`);
}
+35
View File
@@ -0,0 +1,35 @@
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 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;
}
+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);
}
+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);
}
+104
View File
@@ -0,0 +1,104 @@
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);
if (apiError.status === 401) {
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");
+55
View File
@@ -0,0 +1,55 @@
import type {
LogItem,
Paginated,
PaymentItem,
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;
}>;
payments: PaymentItem[];
};
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 async function fetchLogs(
range: { from: string; to: string; page: number; page_size: number },
): Promise<Paginated<LogItem>> {
const { data } = await memberHttp.get<Paginated<LogItem>>("/logs", {
params: range,
});
return data;
}
+56
View File
@@ -0,0 +1,56 @@
/**
* 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,
payments: () => ["admin", "payments"] 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) =>
["member", "logs", range.from, range.to, range.page, range.page_size] 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,302 @@
<script setup lang="ts">
/**
* 支付登记/编辑:订阅与充值同构;额度双输入(落库恒 quota 原值)
*/
import { computed, reactive, ref, watch } from "vue";
import type { Dayjs } from "dayjs";
import dayjs from "dayjs";
import type { PaymentItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import { unixToBeijingWall, wallAsBeijingIso } from "@/utils/time.ts";
import {
useCreatePayment,
usePayments,
useQpuLatest,
useUpdatePayment,
} from "@/queries/admin.ts";
import PlatformSubscriptionPicker from "@/components/admin/platform-subscription-picker.vue";
import type { PlatformSubscription } from "@/api/admin/platform.ts";
const props = defineProps<{
open: boolean;
/** 有则为编辑 */
editing?: PaymentItem | null;
}>();
const emit = defineEmits<{
(e: "update:open", v: boolean): void;
}>();
const createPay = useCreatePayment();
const updatePay = useUpdatePayment();
const qpuQuery = useQpuLatest();
const paymentsQuery = usePayments();
const qpu = computed(() => qpuQuery.data.value?.qpu ?? null);
const form = reactive({
kind: "subscription" as "subscription" | "topup",
paid_amount: null as number | null,
paid_at: null as Dayjs | null,
period_start: null as Dayjs | null,
period_end: null as Dayjs | null,
subscription_id: null as number | null,
remark: "",
quotaMode: "quota" as "quota" | "usd",
quota_granted: null as number | null,
quota_usd_input: null as number | null,
});
const error = ref("");
const pickerOpen = ref(false);
function onPickSubscription(s: PlatformSubscription) {
form.kind = "subscription";
form.subscription_id = s.id;
form.period_start = unixToBeijingWall(s.start_time);
form.period_end = s.end_time > 0 ? unixToBeijingWall(s.end_time) : null;
form.quotaMode = "quota";
form.quota_granted = s.amount_total;
form.quota_usd_input = null;
}
const isEdit = computed(() => props.editing != null);
const saving = computed(() => createPay.isPending.value || updatePay.isPending.value);
const quotaPreview = computed(() => {
if (form.quotaMode === "quota") {
const q = form.quota_granted;
if (q == null || !qpu.value) return null;
return `≈ $${(q / qpu.value).toFixed(2)}(按当前换算率)`;
}
const u = form.quota_usd_input;
if (u == null || !qpu.value) return null;
return `≈ ${Math.round(u * qpu.value)} quota`;
});
const quotaGrantedFinal = computed(() => {
if (form.quotaMode === "quota") return form.quota_granted;
if (form.quota_usd_input == null || !qpu.value) return null;
return Math.round(form.quota_usd_input * qpu.value);
});
watch(
() => props.open,
(v) => {
if (!v) return;
error.value = "";
const ed = props.editing;
if (ed) {
form.kind = ed.kind;
form.paid_amount = ed.paid_amount;
// 北京墙钟进控件,保存时 wallAsBeijingIso 可无损回写
form.paid_at = ed.paid_at != null ? unixToBeijingWall(ed.paid_at) : null;
form.period_start =
ed.period_start != null ? unixToBeijingWall(ed.period_start) : null;
form.period_end =
ed.period_end != null ? unixToBeijingWall(ed.period_end) : null;
form.subscription_id = ed.subscription_id;
form.remark = ed.remark;
form.quotaMode = "quota";
form.quota_granted = ed.quota_granted;
form.quota_usd_input = null;
} else {
form.kind = "subscription";
form.paid_amount = null;
form.paid_at = dayjs();
form.period_start = null;
form.period_end = null;
form.subscription_id = null;
form.remark = "";
form.quotaMode = "quota";
form.quota_granted = null;
form.quota_usd_input = null;
}
},
);
function toIso(d: Dayjs | null): string | null {
return d ? wallAsBeijingIso(d) : null;
}
async function onSubmit() {
error.value = "";
if (form.paid_amount == null) {
error.value = "请填写金额";
return;
}
if (!form.paid_at) {
error.value = "请填写支付时间";
return;
}
const quota = quotaGrantedFinal.value;
if (quota == null) {
error.value = "请填写额度";
return;
}
if (form.kind === "subscription") {
if (!form.period_start) {
error.value = "订阅必须填写期间起点";
return;
}
if (form.subscription_id == null) {
error.value = "订阅必须填写订阅编号";
return;
}
// 一笔订阅只应登记一条支付记录;改错走「编辑」
const dup = (paymentsQuery.data.value ?? []).find(
(p) =>
p.subscription_id === form.subscription_id &&
p.id !== (props.editing?.id ?? -1),
);
if (dup) {
error.value = `订阅 #${form.subscription_id} 已有支付登记——请编辑原记录,不要重复登记`;
return;
}
}
const body = {
kind: form.kind,
paid_at: toIso(form.paid_at)!,
paid_amount: form.paid_amount,
remark: form.remark.trim(),
quota_granted: quota,
...(form.kind === "subscription"
? {
period_start: toIso(form.period_start),
period_end: toIso(form.period_end),
subscription_id: form.subscription_id,
}
: {
period_start: null,
period_end: null,
subscription_id: null,
}),
};
try {
if (isEdit.value && props.editing) {
await updatePay.mutateAsync({ id: props.editing.id, body });
} else {
await createPay.mutateAsync(body);
}
emit("update:open", false);
} catch (e) {
error.value = (e as ApiError).message || "保存失败";
}
}
</script>
<template>
<a-modal
:open="open"
:title="isEdit ? '编辑支付记录' : '登记支付'"
:confirm-loading="saving"
ok-text="保存"
cancel-text="取消"
:styles="{ body: { maxHeight: '62vh', overflowY: 'auto' } }"
@update:open="emit('update:open', $event)"
@ok="onSubmit"
>
<a-form layout="vertical">
<a-form-item label="类型" required>
<a-radio-group v-model:value="form.kind" :disabled="isEdit">
<a-radio value="subscription">订阅</a-radio>
<a-radio value="topup">充值</a-radio>
</a-radio-group>
</a-form-item>
<a-button
v-if="form.kind === 'subscription' && !isEdit"
class="pay-form__pick"
block
@click="pickerOpen = true"
>
从平台选择订阅(自动填写编号、期间与额度)
</a-button>
<a-form-item label="金额(币种自定)" required>
<a-input-number
v-model:value="form.paid_amount"
:min="0"
:precision="2"
style="width: 100%"
/>
<div class="pay-form__note">金额数值——币种由你们约定,系统不预设</div>
</a-form-item>
<a-form-item label="支付时间" required>
<a-date-picker
v-model:value="form.paid_at"
show-time
style="width: 100%"
/>
</a-form-item>
<template v-if="form.kind === 'subscription'">
<a-form-item label="期间起" required>
<a-date-picker
v-model:value="form.period_start"
show-time
style="width: 100%"
/>
</a-form-item>
<a-form-item label="期间止(空 = 进行中)">
<a-date-picker
v-model:value="form.period_end"
show-time
style="width: 100%"
/>
</a-form-item>
<a-form-item label="订阅 ID" required>
<a-input-number
v-model:value="form.subscription_id"
:min="1"
:precision="0"
style="width: 100%"
placeholder="钱包页面「订阅 #N」"
/>
</a-form-item>
</template>
<a-form-item label="本笔额度" required>
<a-radio-group v-model:value="form.quotaMode">
<a-radio value="quota">原始 quota</a-radio>
<a-radio value="usd">按美元换算</a-radio>
</a-radio-group>
<a-input-number
v-if="form.quotaMode === 'quota'"
v-model:value="form.quota_granted"
:min="0"
:precision="0"
style="width: 100%; margin-top: 8px"
/>
<a-input-number
v-else
v-model:value="form.quota_usd_input"
:min="0"
:precision="2"
style="width: 100%; margin-top: 8px"
placeholder="美元金额"
/>
<div v-if="quotaPreview" class="pay-form__note">
{{ quotaPreview }} · 落库为原始 quota
</div>
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="form.remark" placeholder="备注(可选)" />
</a-form-item>
<a-alert v-if="error" type="error" show-icon :message="error" />
</a-form>
<PlatformSubscriptionPicker
v-model:open="pickerOpen"
@select="onPickSubscription"
/>
</a-modal>
</template>
<style scoped>
.pay-form__pick {
margin-bottom: 16px;
}
.pay-form__note {
margin-top: 4px;
font-size: 12px;
line-height: 20px;
color: rgba(0, 0, 0, 0.45);
}
</style>
@@ -0,0 +1,203 @@
<script setup lang="ts">
/**
* 订阅选择弹层:登录(仅一次/标签页)+ 列表选用,与登记表单解耦
*/
import { ref } 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;
}>();
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 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 */
}
}
async function load() {
if (!username.value.trim() || !password.value || loading.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) {
writeToken("");
error.value = "平台登录已失效——请重新输入密码";
} else {
error.value = err.message || "拉取失败";
}
list.value = [];
password.value = "";
} finally {
loading.value = false;
}
}
function onUse(s: PlatformSubscription) {
emit("select", s);
emit("update:open", false);
}
</script>
<template>
<a-modal
:open="open"
title="从平台选择订阅"
:footer="null"
:width="520"
destroy-on-hidden
@update:open="emit('update:open', $event)"
>
<template v-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"
@click="onUse(s)"
>
<div class="plat-pick__head">
<strong>订阅 #{{ s.id }}</strong>
<span class="plat-pick__status">{{ 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__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: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,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 };
}
+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>
+71
View File
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed } from "vue";
import { useMemberTokenStore } from "@/stores/member-token.ts";
import { useMemberMe } from "@/queries/member.ts";
const tokenStore = useMemberTokenStore();
const meQuery = useMemberMe();
const memberName = computed(
() => tokenStore.memberName || meQuery.data.value?.name || "",
);
</script>
<template>
<div class="member-layout">
<header class="member-layout__header">
<div class="member-layout__brand">成员入口</div>
<div class="member-layout__name">{{ memberName }}</div>
</header>
<main class="member-layout__main">
<div class="member-layout__surface">
<router-view />
</div>
</main>
</div>
</template>
<style scoped>
/* design.md:bg-layout 底 + bg-container 内容面;间距 24/16 对齐 spacing lg/md */
.member-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.member-layout__header {
height: 64px;
padding: 0 24px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
justify-content: space-between;
}
.member-layout__brand {
font-weight: 600;
font-size: 16px;
line-height: 24px;
}
.member-layout__name {
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
}
.member-layout__main {
flex: 1;
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 24px 16px 48px;
}
.member-layout__surface {
background: #fff;
border-radius: 8px;
padding: 24px;
}
</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");
+187
View File
@@ -0,0 +1,187 @@
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 {
createPayment,
deletePayment,
fetchPayments,
updatePayment,
} from "@/api/admin/payments.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 usePayments() {
return useQuery({
queryKey: qk.admin.payments(),
queryFn: fetchPayments,
});
}
export function useCreatePayment() {
const qc = useQueryClient();
return useMutation({
mutationFn: createPayment,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.payments() });
},
});
}
export function useUpdatePayment() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: Record<string, unknown> }) =>
updatePayment(id, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.payments() });
},
});
}
export function useDeletePayment() {
const qc = useQueryClient();
return useMutation({
mutationFn: deletePayment,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.payments() });
},
});
}
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()),
});
}
+29
View File
@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/vue-query";
import {
fetchLogs,
fetchMe,
fetchOverview,
} from "@/api/member/me.ts";
import { qk, type PageQuery, type TimeRange } from "@/api/query-keys.ts";
export function useMemberMe() {
return useQuery({
queryKey: qk.member.me(),
queryFn: fetchMe,
retry: false,
});
}
export function useMemberOverview(range: () => TimeRange) {
return useQuery({
queryKey: () => qk.member.overview(range()),
queryFn: () => fetchOverview(range()),
});
}
export function useMemberLogs(params: () => TimeRange & PageQuery) {
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" } : {},
});
}
});
}
+24
View File
@@ -0,0 +1,24 @@
import type { RouteRecordRaw } from "vue-router";
import MemberLayout from "@/layouts/member-layout.vue";
/** 成员视图 `/member/*`:登录壳 → 单页(交互共性 §1) */
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-home",
component: () => import("@/views/member/home-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 = "";
},
},
});
+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>
+340
View File
@@ -0,0 +1,340 @@
<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 },
];
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;
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="
(v, k) => {
setRange(v, k);
page = 1;
pushQuery();
}
"
/>
</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: (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 === '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>
</a-table>
</div>
</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;
}
</style>
+245
View File
@@ -0,0 +1,245 @@
<script setup lang="ts">
/**
* 成员管理(交互设计-成员管理):清单表 · 开户 · 令牌明文/重发 · 启停
*/
import { computed, h, reactive, ref } 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("");
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(() => {
const list = membersQuery.data.value ?? [];
const mid = route.query.member_id;
if (typeof mid === "string" && mid) {
const n = Number(mid);
return list.filter((m) => m.id === n);
}
return list;
});
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>
<a-empty
v-if="!membersQuery.isPending.value && rows.length === 0"
description="还没有成员——为每位分摊朋友开户并发放访问令牌"
>
<a-button @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>
.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, usePayments } 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 = usePayments();
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.kind === "subscription" && p.period_start != null)
.map((p) => ({
label: `订阅 #${p.subscription_id ?? p.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>
+217
View File
@@ -0,0 +1,217 @@
<script setup lang="ts">
/**
* 支付记录(交互设计-支付记录):同构登记 · 编辑删除 · 核对提示
*/
import { computed, ref, watch } from "vue";
import { message } from "antdv-next";
import type { PaymentItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import { fetchLogs } from "@/api/admin/logs.ts";
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 { useDeletePayment, usePayments } from "@/queries/admin.ts";
import { formatDateTime, toRequestIso } from "@/utils/time.ts";
import { formatPaidAmount } from "@/utils/format.ts";
const paymentsQuery = usePayments();
const deletePay = useDeletePayment();
const formOpen = ref(false);
const editing = ref<PaymentItem | null>(null);
const missingSubs = ref<number[]>([]);
const checking = ref(false);
const rows = computed(() => {
const list = [...(paymentsQuery.data.value ?? [])];
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
return list;
});
const columns = [
{ title: "类型", key: "kind", width: 90 },
{ title: "金额(币种自定)", key: "paid_amount", width: 140 },
{ title: "时间", key: "time", width: 180 },
{ title: "订阅 ID", key: "subscription_id", width: 100 },
{ title: "额度", key: "quota", width: 160 },
{ title: "备注", key: "remark" },
{ title: "操作", key: "actions", width: 120 },
];
function timeText(p: PaymentItem): string {
if (p.kind === "topup") return formatDateTime(p.paid_at);
const start = formatDateTime(p.period_start);
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${start} ~ ${end}`;
}
function onCreate() {
editing.value = null;
formOpen.value = true;
}
function onEdit(p: PaymentItem) {
editing.value = p;
formOpen.value = true;
}
async function onDelete(p: PaymentItem) {
try {
await deletePay.mutateAsync(p.id);
message.success("已删除");
} catch (e) {
message.error((e as ApiError).message || "删除失败");
}
}
/** 观测订阅号 ↔ 已登记:只提示不回填;payments 全空则静默 */
async function reconcile(pays: PaymentItem[]) {
if (pays.length === 0) {
missingSubs.value = [];
return;
}
checking.value = true;
try {
const known = new Set(
pays.map((p) => p.subscription_id).filter((v): v is number => v != null),
);
const earliest = Math.min(...pays.map((p) => p.paid_at ?? 0), Date.now() / 1000);
const from = toRequestIso(new Date((earliest - 30 * 86400) * 1000));
const to = toRequestIso(new Date());
const observed = new Set<number>();
let page = 1;
for (;;) {
const res = await fetchLogs({
from,
to,
page,
page_size: 200,
});
for (const it of res.items) {
if (it.type === 2 && it.subscription_id != null) {
observed.add(it.subscription_id);
}
}
if (page * 200 >= res.total || page >= 20) break;
page += 1;
}
missingSubs.value = [...observed]
.filter((id) => !known.has(id))
.sort((a, b) => a - b);
} catch {
missingSubs.value = [];
} finally {
checking.value = false;
}
}
// 数据到达/失效刷新后再核对(挂载时尚未 resolve)
watch(
() => paymentsQuery.data.value,
(pays) => {
if (!pays) return;
void reconcile(pays);
},
{ immediate: true },
);
</script>
<template>
<section>
<PageHeader title="支付记录" subtitle="订阅与充值的人工登记">
<template #actions>
<a-button type="primary" @click="onCreate">登记支付</a-button>
</template>
</PageHeader>
<a-alert
v-if="missingSubs.length"
type="warning"
show-icon
style="margin-bottom: 16px"
:message="`观测到 ${missingSubs.length} 个订阅有消耗但尚无对应登记`"
description="若本部署不登记支付,可忽略此提示;需要入账的请核对补录。"
>
<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>
<a-empty
v-if="!paymentsQuery.isPending.value && rows.length === 0"
description="还没有支付记录——订阅/充值同构登记"
>
<a-button @click="onCreate">登记支付</a-button>
</a-empty>
<a-table
v-else
:columns="columns"
:data-source="rows"
:row-key="(r: PaymentItem) => r.id"
:loading="paymentsQuery.isPending.value || paymentsQuery.isFetching.value || checking"
:pagination="false"
size="middle"
:scroll="{ x: 1000 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'kind'">
<a-tag :color="record.kind === 'subscription' ? 'blue' : 'gold'">
{{ record.kind === "subscription" ? "订阅" : "充值" }}
</a-tag>
</template>
<template v-else-if="column.key === 'paid_amount'">
{{ formatPaidAmount(record.paid_amount) }}
</template>
<template v-else-if="column.key === 'time'">
{{ timeText(record) }}
</template>
<template v-else-if="column.key === 'subscription_id'">
{{ record.subscription_id ?? "—" }}
</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 === 'remark'">
{{ record.remark || "—" }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button size="small" @click="() => onEdit(record)">编辑</a-button>
<a-popconfirm
title="删除后该条支付记录将消失"
description="改错即修,删除即消失"
ok-text="删除"
cancel-text="取消"
@confirm="() => onDelete(record)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<PaymentFormModal v-model:open="formOpen" :editing="editing" />
</section>
</template>
<style scoped>
.pay-missing__tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 4px;
}
.pay-missing__note {
font-size: 12px;
line-height: 20px;
color: rgba(0, 0, 0, 0.65);
}
</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>
+524
View File
@@ -0,0 +1,524 @@
<script setup lang="ts">
/**
* 成员视图单页(交互设计-成员视图):聚合 · 支付参数 · 我的 key · 日志徽章展开
*/
import { computed, h, onMounted, onUnmounted, ref, watch } from "vue";
import type { LogItem, PaymentItem } 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 SourceBar from "@/components/common/source-bar.vue";
import { useLiveTimeRange } from "@/composables/use-live-time-range.ts";
import { useMemberLogs, useMemberOverview, useMemberMe } from "@/queries/member.ts";
import { formatDateTime, formatMinute, hoursSince } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
const { range, setRange } = useLiveTimeRange("recent-30");
const meQuery = useMemberMe();
const overviewQuery = useMemberOverview(() => range.value);
const page = ref(1);
const pageSize = ref(50);
const logsQuery = useMemberLogs(() => ({
...range.value,
page: page.value,
page_size: pageSize.value,
}));
const overview = computed(() => overviewQuery.data.value);
const me = computed(() => overview.value?.member ?? meQuery.data.value ?? null);
const usage = computed(() => overview.value?.usage);
const bySource = computed(() => overview.value?.by_source ?? []);
const keys = computed(() => overview.value?.keys ?? []);
const payments = computed(() => overview.value?.payments ?? []);
const extraPresets = computed(() =>
payments.value
.filter((p) => p.kind === "subscription" && p.period_start != null)
.map((p) => ({
label: `订阅 #${p.subscription_id ?? p.id} 期间`,
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
to: new Date(
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
).toISOString(),
}))
.slice(0, 4),
);
const paymentBySub = computed(() => {
const m = new Map<number, PaymentItem>();
for (const p of payments.value) {
if (p.subscription_id != null) m.set(p.subscription_id, p);
}
return m;
});
const logs = computed(() => logsQuery.data.value?.items ?? []);
const logTotal = computed(() => logsQuery.data.value?.total ?? 0);
const expandedRowKeys = ref<number[]>([]);
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",
"aria-hidden": "true",
}),
),
]);
}
const logColumns = [
{ 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: "source", width: 120 },
{ title: "用量", key: "usage", width: 150 },
{
title: tipTitle("Tokens", "提示 tokens / 补全 tokens"),
key: "tokens",
width: 110,
},
];
function syncFromRoute() {
const from = route.query.from;
const to = route.query.to;
if (typeof from === "string" && from && typeof to === "string" && to) {
setRange({ from, to }, "fixed");
}
}
function pushRangeQuery() {
void router.replace({
path: "/member",
query: { from: range.value.from, to: range.value.to },
});
}
syncFromRoute();
watch(
() => route.query,
() => syncFromRoute(),
);
function onRangeChange(
v: { from: string; to: string },
kind: "recent-7" | "recent-30" | "fixed",
) {
setRange(v, kind);
page.value = 1;
expandedRowKeys.value = [];
pushRangeQuery();
}
function isStalled(at: number | null | undefined): boolean {
const h = hoursSince(at);
return h != null && h > 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) {
const b = sourceBadge(record);
if (b.kind !== "sub") return;
const i = expandedRowKeys.value.indexOf(record.id);
if (i >= 0) expandedRowKeys.value.splice(i, 1);
else expandedRowKeys.value.push(record.id);
}
function onExpand(expanded: boolean, record: LogItem) {
const i = expandedRowKeys.value.indexOf(record.id);
if (expanded && i < 0) expandedRowKeys.value.push(record.id);
if (!expanded && i >= 0) expandedRowKeys.value.splice(i, 1);
}
/** 表体滚动,避免整页被明细撑高 */
const boxRef = ref<HTMLElement | null>(null);
const bodyY = ref(420);
const CHROME = 120;
function measure() {
const h = boxRef.value?.clientHeight ?? 0;
if (h > 0) bodyY.value = Math.max(220, h - CHROME);
}
let ro: ResizeObserver | null = null;
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);
});
function periodText(p: PaymentItem): string {
if (p.kind !== "subscription") return formatDateTime(p.paid_at);
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${formatDateTime(p.period_start)} ~ ${end}`;
}
function expandPayment(record: LogItem): PaymentItem | undefined {
return paymentBySub.value.get(sourceBadge(record).id ?? -1);
}
</script>
<template>
<section class="mv">
<PageHeader
:title="me?.name ? me.name : '我的用量'"
subtitle="你在本池的名下用量"
>
<template #actions>
<span class="mv-range-label">时间范围</span>
<TimeRangePicker
:model-value="range"
:extra-presets="extraPresets"
@change="onRangeChange"
/>
</template>
</PageHeader>
<a-skeleton
v-if="overviewQuery.isPending.value"
active
:paragraph="{ rows: 4 }"
/>
<a-empty
v-else-if="keys.length === 0"
description="作者尚未为你分配 key"
/>
<div v-else class="mv-body">
<section class="mv-block">
<h2 class="mv-block__title">总用量</h2>
<template v-if="usage && usage.count > 0">
<div class="mv-agg">
<span>调用 {{ formatInt(usage.count) }} 次</span>
<QuotaText :quota="usage.quota" :quota-usd="usage.quota_usd" />
<span class="mv-note">按当时换算率</span>
</div>
<SourceBar :rows="bySource" />
</template>
<div v-else class="mv-empty">该时段内无调用</div>
</section>
<section class="mv-block">
<h2 class="mv-block__title">支付参数</h2>
<div v-if="payments.length === 0" class="mv-empty">暂无支付记录信息</div>
<template v-else>
<div v-for="p in payments" :key="p.id" class="mv-pay">
<div class="mv-pay__head">
<a-tag :color="p.kind === 'subscription' ? 'blue' : 'gold'">
{{ p.kind === "subscription" ? "订阅" : "充值" }}
</a-tag>
<strong v-if="p.kind === 'subscription'">
订阅 #{{ p.subscription_id }}
</strong>
</div>
<div class="mv-pay__grid">
<div class="mv-pay__row">
<span class="mv-pay__k">{{ p.kind === "subscription" ? "期间" : "支付时间" }}</span>
<span>{{ periodText(p) }}</span>
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">额度</span>
<QuotaText :quota="p.quota_granted" :quota-usd="p.quota_usd" />
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">金额</span>
<span class="mv-num">{{ p.paid_amount }}</span>
</div>
<div v-if="p.remark" class="mv-pay__row">
<span class="mv-pay__k">备注</span>
<span>{{ p.remark }}</span>
</div>
</div>
</div>
<div class="mv-note mv-foot">
支付参数对成员完全公开;币种由成员与作者约定
</div>
</template>
</section>
<section class="mv-block">
<h2 class="mv-block__title">我的 key</h2>
<div class="mv-keys">
<div v-for="k in keys" :key="k.key_id" class="mv-key">
<strong>{{ k.token_name }}</strong>
<span v-if="isStalled(k.log_synced_at)" class="mv-stall">
同步截止 {{ formatMinute(k.log_synced_at) }} · 停滞
</span>
<span v-else class="mv-note">
同步截止 {{ formatMinute(k.log_synced_at) }}
</span>
<span>调用 {{ formatInt(k.usage.count) }} 次</span>
<QuotaText :quota="k.usage.quota" :quota-usd="k.usage.quota_usd" />
</div>
</div>
</section>
<section class="mv-block mv-block--logs">
<h2 class="mv-block__title">日志明细</h2>
<div ref="boxRef" class="mv-logs">
<a-table
:columns="logColumns"
:data-source="logs"
:row-key="(r: LogItem) => r.id"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
:pagination="{
current: page,
pageSize,
total: logTotal,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100'],
onChange: (p: number, ps: number) => {
page = p;
pageSize = ps;
},
}"
size="middle"
:scroll="{ x: 800, y: bodyY }"
:expanded-row-keys="expandedRowKeys"
:expandable="{
showExpandColumn: false,
rowExpandable: (r: LogItem) => sourceBadge(r).kind === 'sub',
}"
@expand="onExpand"
>
<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 === 'tokens'">
{{ formatInt(record.prompt_tokens) }} / {{ formatInt(record.completion_tokens) }}
</template>
<template v-else-if="column.key === 'source'">
<a-tooltip
v-if="sourceBadge(record).kind === 'sub'"
title="点击查看该订阅支付参数"
>
<a-tag color="blue" class="mv-badge" @click="onBadge(record)">
{{ 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>
<template #expandedRowRender="{ record }">
<div class="mv-expand">
<template v-if="expandPayment(record)">
<div class="mv-expand__title">订阅支付参数</div>
<div class="mv-pay__grid">
<div class="mv-pay__row">
<span class="mv-pay__k">期间</span>
<span>{{ periodText(expandPayment(record)!) }}</span>
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">额度</span>
<QuotaText
:quota="expandPayment(record)!.quota_granted"
:quota-usd="expandPayment(record)!.quota_usd"
/>
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">金额</span>
<span class="mv-num">
{{ expandPayment(record)!.paid_amount }}
</span>
</div>
<div v-if="expandPayment(record)!.remark" class="mv-pay__row">
<span class="mv-pay__k">备注</span>
<span>{{ expandPayment(record)!.remark }}</span>
</div>
</div>
</template>
<div v-else class="mv-empty">该订阅无登记信息</div>
</div>
</template>
</a-table>
</div>
</section>
</div>
</section>
</template>
<style scoped>
.mv {
min-height: 0;
}
.mv-range-label {
font-size: 13px;
line-height: 32px;
color: rgba(0, 0, 0, 0.65);
}
.mv-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.mv-block {
margin-bottom: 16px;
}
.mv-block--logs {
flex: 1;
min-height: 280px;
display: flex;
flex-direction: column;
}
.mv-logs {
flex: 1;
min-height: 0;
}
.mv-block__title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
line-height: 22px;
}
.mv-agg {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 16px;
margin-bottom: 12px;
font-variant-numeric: tabular-nums;
}
.mv-note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-foot {
margin-top: 8px;
}
.mv-empty {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
.mv-num {
font-variant-numeric: tabular-nums;
}
.mv-pay {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.mv-pay__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.mv-pay__grid {
display: grid;
gap: 4px;
font-size: 13px;
}
.mv-pay__row {
display: flex;
gap: 12px;
}
.mv-pay__k {
width: 56px;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.45);
}
.mv-keys {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.mv-key {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
font-size: 13px;
}
.mv-stall {
color: #d48806;
font-weight: 600;
}
.mv-badge {
cursor: pointer;
}
.mv-expand {
padding: 8px 12px;
font-size: 13px;
}
.mv-expand__title {
font-weight: 600;
margin-bottom: 8px;
}
.mv-col-title {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.mv-col-title__icon {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
cursor: help;
}
</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-home" });
} 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>
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}
+28
View File
@@ -0,0 +1,28 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}
+28
View File
@@ -0,0 +1,28 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
import vueDevTools from "vite-plugin-vue-devtools";
/** 后端母体端口(与根 .env 的 PORT / AGENTS 约定一致) */
const API_TARGET = process.env.VITE_API_PROXY_TARGET ?? "http://127.0.0.1:18456";
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), vueJsx(), vueDevTools()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
proxy: {
// 契约:/api/* 专属接口;SPA 路由不进 /api(公共约定 §2)
"/api": {
target: API_TARGET,
changeOrigin: true,
},
},
},
});
+1421
View File
File diff suppressed because it is too large Load Diff

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