Compare commits
61
Commits
init
..
e293a8e5a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e293a8e5a9 | ||
|
|
34a7823e1a | ||
|
|
ea4efbe1d7 | ||
|
|
5f76bfc9d6 | ||
|
|
8816c32025 | ||
|
|
4be0853ade | ||
|
|
4a7a3e76ee | ||
|
|
502f985147 | ||
|
|
e3e0978b95 | ||
|
|
c32548e598 | ||
|
|
00c08b37ff | ||
|
|
c03fdc3d67 | ||
|
|
10d8f4c915 | ||
|
|
c7cc6f6dfd | ||
|
|
d4d740222e | ||
|
|
0269cf3386 | ||
|
|
665fa088f5 | ||
|
|
31d963e956 | ||
|
|
29f5f4f84c | ||
|
|
431b27ea58 | ||
|
|
008eb14adc | ||
|
|
2e91c593ab | ||
|
|
b2f65a1d55 | ||
|
|
932e74ba3b | ||
|
|
4d31c20d35 | ||
|
|
66a9a93914 | ||
|
|
1b57b75be0 | ||
|
|
ed2ff0f950 | ||
|
|
29e0317155 | ||
|
|
b33fc64a28 | ||
|
|
8b54629159 | ||
|
|
9aed3cbe8d | ||
|
|
b808be324d | ||
|
|
b16ddb1acb | ||
|
|
adbb6adb93 | ||
|
|
0adae25a8c | ||
|
|
311643065c | ||
|
|
2446d3bf2c | ||
|
|
3fb75ec699 | ||
|
|
bd5a9bf99f | ||
|
|
7ff87888a3 | ||
|
|
293141ea0a | ||
|
|
d6d7d9411f | ||
|
|
716ff96b07 | ||
|
|
129ce7e380 | ||
|
|
fbb989c3fb | ||
|
|
9ecf817df2 | ||
|
|
d47cb5543c | ||
|
|
3e12db1706 | ||
|
|
de48e5d39c | ||
|
|
54ea686f44 | ||
|
|
fa5b3a8942 | ||
|
|
7ab9d32495 | ||
|
|
867bb7d566 | ||
|
|
a2fa755b62 | ||
|
|
6454f4acfa | ||
|
|
6980d09750 | ||
|
|
4c55c3f15f | ||
|
|
4566ce8d58 | ||
|
|
db20dcb611 | ||
|
|
e4d25526f3 |
@@ -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`
|
||||
@@ -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.
|
||||
@@ -0,0 +1,17 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.tmp
|
||||
**/.vite
|
||||
*.log
|
||||
api*.json
|
||||
session-archive
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
.claude
|
||||
.agents
|
||||
agent
|
||||
@@ -0,0 +1,38 @@
|
||||
# ── new-api-key-usage · 环境变量示例 ──────────────────────────
|
||||
# 复制为 .env 后填写(.env 已被 .gitignore,勿提交)
|
||||
# 开发:任意可用的 PostgreSQL,DATABASE_URL 指向即可
|
||||
# 部署:docker compose up -d --build(见 README)
|
||||
|
||||
# 本服务端口(避开 3000/3001 等常见占用;容器内固定 18456 即可)
|
||||
PORT=18456
|
||||
|
||||
# PostgreSQL 连接串——数据库配置的唯一来源
|
||||
# 开发:指向你本机/已有的库(主机 127.0.0.1 或 localhost)
|
||||
# 部署:同一份即可;compose 会把主机改写为容器服务名 postgres
|
||||
# 密码含 @ : / 等字符时请 URL 编码
|
||||
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/new_api_key_usage
|
||||
|
||||
# 已登记 sk-key 的加密密钥:≥32 字节随机数,base64url。
|
||||
# 生成示例(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
|
||||
|
||||
# 同步引擎周期(毫秒,≥1000;默认 300000 = 5 分钟)
|
||||
# SYNC_INTERVAL_MS=300000
|
||||
|
||||
# 空库双通道:可选。用户名与密码同时存在时,首次 setup-state 静默落库;
|
||||
# 只填一个视为未配置。此后改 env 不改密;改密走管理面表单。
|
||||
# ADMIN_INIT_USERNAME=
|
||||
# ADMIN_INIT_PASSWORD=
|
||||
+14
@@ -13,3 +13,17 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 依赖与构建产物
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.turbo/
|
||||
|
||||
# 测试与工具
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Agent 本地接线(skills 本体只保留 .agents/skills)
|
||||
.claude/
|
||||
agent/
|
||||
|
||||
@@ -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/`(作者主动备份的会话记录)——不加入 git、不写 `.gitignore`,靠提交时人工/执行者目视把关
|
||||
|
||||
## 项目速览(速记)
|
||||
|
||||
- **本仓库 = 设计文档中心 + 实现主线**:实现代码与文档同仓演进,monorepo 结构(`apps/server` · `apps/engine` · `apps/web` · `packages/core` · `packages/db`)见 `docs/design/技术选型.md` §4
|
||||
- 数据模型:基线表(`api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history`)+ 三张访问主体表(`members` · `users` · `sessions`)——支付登记已拆为 `subscriptions` + `topups`(见 `docs/design/payments建模评估.md`);同步架构(轮询 + `request_id` 去重 + 攒库)的动因由上游物理约束决定
|
||||
- 需求总集与裁决档案见 `docs/design/需求清单.md`;交付形态已裁决(双形态并行,完整形态先行)
|
||||
- 上游约束速记:只有 sk-key、无时间过滤接口 → 轮询攒库是唯一路径(README「架构的根本动因」)
|
||||
|
||||
## 命令
|
||||
|
||||
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 run db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
|
||||
| `bun run db:push` | 推送到本地库(开发期) |
|
||||
| `bun run db:studio` | Drizzle Studio 看库 |
|
||||
| `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) |
|
||||
| `bun run dev:server` | 开发跑后端(hot reload) |
|
||||
| `bun run dev:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) |
|
||||
| `bun run dev:web` | 开发跑前端 Vite |
|
||||
| `bun run build:web` | 前端类型检查 + 产物构建 |
|
||||
| `docker compose up -d --build` | 部署一键起(server / engine / caddy / pg) |
|
||||
|
||||
环境变量见 `.env.example`(复制为 `.env` 后填写)。
|
||||
|
||||
**默认端口 `18456`**(避开 3000/3001 等本机占用);`NEWAPI_BASE` 是上游 NewAPI 地址,与本服务端口无关。在线文档:`http://127.0.0.1:18456/api/docs`。
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# 语法:docker/dockerfile:1
|
||||
# 构建产物:/app/naku-server · /app/naku-engine · /app/drizzle · /srv(前端)
|
||||
|
||||
# ---------- 构建:依赖 + 前端 + 两个单文件 ----------
|
||||
FROM oven/bun:1.4-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY package.json bun.lock ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# 前端静态资源
|
||||
RUN bun run --cwd apps/web build-only
|
||||
|
||||
# 单文件(linux-x64;构建发生在 Linux 容器内,无需交叉编译)
|
||||
RUN mkdir -p /out \
|
||||
&& bun build --compile --outfile /out/naku-server apps/server/src/index.ts \
|
||||
&& bun build --compile --outfile /out/naku-engine apps/engine/src/index.ts
|
||||
|
||||
# ---------- 应用运行时:两个二进制 + 迁移 SQL ----------
|
||||
FROM alpine:3.20 AS app
|
||||
WORKDIR /app
|
||||
# Bun 单文件仍需 C++ 运行时与 CA;从构建镜像拷贝,避免 apk 联网
|
||||
COPY --from=build /usr/lib/libstdc++.so.6 /usr/lib/libgcc_s.so.1 /usr/lib/
|
||||
COPY --from=build /etc/ssl/certs /etc/ssl/certs
|
||||
COPY --from=build /out/naku-server /out/naku-engine /app/
|
||||
COPY --from=build /src/packages/db/drizzle /app/drizzle
|
||||
ENV MIGRATIONS_FOLDER=/app/drizzle
|
||||
# 默认跑 server;compose 里 engine 覆盖 command
|
||||
CMD ["/app/naku-server"]
|
||||
|
||||
# ---------- 网关:Caddy + 前端静态资源 ----------
|
||||
FROM caddy:2-alpine AS proxy
|
||||
COPY deploy/Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --from=build /src/apps/web/dist /srv
|
||||
@@ -0,0 +1,177 @@
|
||||
# new-api-key-usage
|
||||
|
||||
> NewAPI 网关下的 key 用量统计工具(产品名 **NAKU**)
|
||||
|
||||
定时用每个 sk-key 轮询网关日志接口,按 `request_id` 去重,攒全量日志到数据库;任意时间范围的用量查询,即一条本地聚合查询。
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 当前状态 | **实现推进中**——设计文档主体定稿;后端 + 同步引擎 + 前端(管理面与成员视图)已落地;部署链与衍生形态待收尾 |
|
||||
| 技术栈 | 全栈 TypeScript(选型见 [`docs/design/技术选型.md`](docs/design/技术选型.md)):Bun + Hono + Drizzle + PostgreSQL · 前端 Vue 3 + antdv-next |
|
||||
|
||||
## 这是什么
|
||||
|
||||
面向 [NewAPI](https://github.com/QuantumNous/new-api) 实例的**独立计费辅助平台**:作者购买订阅额度后,创建多个 sk- API key 分发给多名使用者分摊成本。平台只提供**事实服务**——用量、来源标注、支付参数、多维统计;**不算「谁该付谁多少钱」**(分摊是人和人之间的事)。
|
||||
|
||||
- 名字直述用途:NewAPI 网关下按 key 查询用量(key usage);产品展示名 NAKU
|
||||
- 全场景统一小写 kebab-case:仓库名 / 包名一致
|
||||
|
||||
## 背景与约束
|
||||
|
||||
- 真实场景:NewAPI(Go + GORM 开源 LLM 网关)实例提供订阅服务,多使用者通过分发 API key 共享订阅额度——**非商业,纯成本分担**
|
||||
- 项目作者是该实例的**普通用户**:只有 sk-key,**没有实例管理员账号**
|
||||
- 一切数据获取只能走 key 级接口(账号级接口用 sk-key 访问会 401)
|
||||
- 交付形态为**双形态并行**:完整前后端部署在实例运营方提供的服务器上(作者不接触服务器)+ Cloudflare Workers 抽取版部署在作者自控账号(见 [`docs/design/需求清单.md`](docs/design/需求清单.md) R13)
|
||||
|
||||
**双形态并行的动因**:服务器部署形态下,每次迭代都需实例运营方手工升级;部署到作者自控的 Cloudflare 账号后,迭代部署权回到作者手里。母体完整前后端先行,Workers 抽取版延后至功能全集实现后。
|
||||
|
||||
## 核心能力
|
||||
|
||||
| # | 能力 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | key 用量查询 | 按时间范围查模型请求日志与用量;作者全员视角在管理面总览,成员视角在成员视图 |
|
||||
| 2 | key 登记 | 管理通道手动登记:入库并加入轮询;生命周期为手动启停(引擎只读 `status`) |
|
||||
| 3 | 支付事实登记 | 订阅权益与充值入账分表登记;对成员完全公开透明 |
|
||||
| 4 | 多维统计 | 按成员 / key / 计费来源 / 模型的任意时段聚合 |
|
||||
|
||||
完整需求范围(含刻意不做的部分)见 [`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` 去重入库 + 数据库。**部署形态可以变,攒库架构不变。** 上游接口完整实证(含源码出处)见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md)。
|
||||
|
||||
## 技术栈与结构
|
||||
|
||||
全栈 TypeScript,monorepo(Bun workspaces):
|
||||
|
||||
```text
|
||||
apps/
|
||||
server/ # 后端:Hono + Drizzle/pg + Scalar 在线文档
|
||||
engine/ # 同步引擎:独立进程,轮询去重入库
|
||||
web/ # 前端:Vue 3 + antdv-next + Vite
|
||||
packages/
|
||||
core/ # 业务核心:去重、折算、加密、上游客户端、DTO
|
||||
db/ # 数据访问层:Drizzle schema + 仓储
|
||||
```
|
||||
|
||||
数据模型:`api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history` + `members` · `users` · `sessions`。
|
||||
|
||||
## 快速开始
|
||||
|
||||
前置:Bun、一个可用的 PostgreSQL(本机已有或别处均可)。
|
||||
|
||||
```bash
|
||||
bun install
|
||||
cp .env.example .env # DATABASE_URL 指向你已有的 PostgreSQL
|
||||
bun run db:push # 开发期推送 schema
|
||||
bun run dev:server # 后端(默认 18456)
|
||||
bun run dev:engine # 同步引擎(另开终端)
|
||||
bun run dev:web # 前端 Vite(5175)
|
||||
```
|
||||
|
||||
在线 API 文档:`http://127.0.0.1:18456/api/docs`。
|
||||
|
||||
更多命令与协作约定见 [`AGENTS.md`](AGENTS.md)。
|
||||
|
||||
## 部署
|
||||
|
||||
生产形态:**Caddy**(前端静态 + `/api` 反代)+ **server / engine 单文件**(Bun `--compile`)+ PostgreSQL;启动时自动应用 `packages/db/drizzle` 迁移。开发不依赖本 Compose——把 `DATABASE_URL` 指到任意可用 PostgreSQL 即可。
|
||||
|
||||
### 起服务
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 必改:DATABASE_URL、SK_KEY_ENCRYPTION_SECRET、NEWAPI_BASE、COOKIE_SECURE
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
- 页面:`http://<服务器>/`;接口文档:`http://<服务器>/api/docs`
|
||||
- 升级:`git pull` 后重复 `docker compose up -d --build`(改表随启动迁移)
|
||||
|
||||
### 环境变量要点
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `DATABASE_URL` | 数据库配置的**唯一来源**(用户 / 密码 / 库名都在串里)。开发指向本机库;部署同一份即可,compose 会把主机改写为容器服务名 `postgres` |
|
||||
| `SK_KEY_ENCRYPTION_SECRET` | ≥32 字节随机数(base64url)。**生成一次、入库后不可更换**——丢失则已登记 key 无法解密,与库同等备份 |
|
||||
| `NEWAPI_BASE` | 上游 NewAPI 实例根地址 |
|
||||
| `COOKIE_SECURE` | HTTPS / 域名用 `true`;纯 IP + HTTP 用 `false`(否则登录后立刻 401) |
|
||||
| `ADMIN_INIT_USERNAME` / `ADMIN_INIT_PASSWORD` | 可选;同时存在时空库首次静默建管理员 |
|
||||
|
||||
### 服务分工
|
||||
|
||||
| 服务 | 职责 | 对外 |
|
||||
|------|------|------|
|
||||
| `caddy` | 前端静态 + `/api` 反代 | 仅 80 / 443 |
|
||||
| `server` | 管理面 / 成员视图 API | 内网 |
|
||||
| `engine` | 轮询上游、去重入库 | 内网 |
|
||||
| `postgres` | 数据(卷 `pgdata`) | 内网 |
|
||||
|
||||
### HTTPS
|
||||
|
||||
把 `deploy/Caddyfile` 站点行从 `:80` 改成域名(如 `your.domain.example`),重建后 Caddy 自动签证书;同时设 `COOKIE_SECURE=true`。
|
||||
|
||||
### 常用命令
|
||||
|
||||
```bash
|
||||
docker compose ps # 运行状态
|
||||
docker compose logs -f engine # 看同步
|
||||
docker compose restart server # 只重启后端
|
||||
docker compose down # 全停(数据在卷里,不丢)
|
||||
```
|
||||
|
||||
### 注意
|
||||
|
||||
1. `COOKIE_SECURE` 必须与访问方式匹配:HTTP + `true` 会「登录后立刻 401」
|
||||
2. `SK_KEY_ENCRYPTION_SECRET` 更换等于已登记 key 全部作废
|
||||
3. `DATABASE_URL` 密码含 `@` `:` `/` 等字符时做 URL 编码
|
||||
|
||||
## 文档地图
|
||||
|
||||
| 文档 | 什么时候读 | 状态 |
|
||||
|------|-----------|------|
|
||||
| [`docs/design/需求清单.md`](docs/design/需求清单.md) | **需求权威索引与裁决档案**——R1–R16 定案;含支付拆表与成员档案/空壳删除裁决 | 定稿(2026-10-10) |
|
||||
| [`docs/design/技术选型.md`](docs/design/技术选型.md) | 技术栈裁决:全栈 TS;Bun + Hono + Drizzle/pg;前端 Vue 3 + antdv-next;分层落地与回退 | 定稿(2026-10-02) |
|
||||
| [`docs/design/api_keys表字段设计.md`](docs/design/api_keys表字段设计.md) | key 登记、生命周期与成员归属 | 定稿(2026-10-01) |
|
||||
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 日志明细、来源提列、去重与写入策略 | 同上 |
|
||||
| [`docs/design/payments建模评估.md`](docs/design/payments建模评估.md) | 支付建模复盘:订阅权益 vs 充值入账两分;拆表已采纳 | 定稿(2026-10-09) |
|
||||
| [`docs/design/subscriptions表字段设计.md`](docs/design/subscriptions表字段设计.md) | 订阅权益:期间 + 额度预算 + 可选付款事实;`subscription_id` 直连日志 | 待审阅(2026-10-09) |
|
||||
| [`docs/design/topups表字段设计.md`](docs/design/topups表字段设计.md) | 充值入账事件:`paid_at` 主轴;不挂订阅 | 待审阅(2026-10-09) |
|
||||
| [`docs/design/qpu_history表字段设计.md`](docs/design/qpu_history表字段设计.md) | 换算参数时间线(自动采样、只读) | 定稿(2026-10-01) |
|
||||
| [`docs/design/members表字段设计.md`](docs/design/members表字段设计.md) | 成员实体——key 归属、身份与统计锚点;档案可改、空壳可删 | 定稿(2026-10-10) |
|
||||
| [`docs/design/users表字段设计.md`](docs/design/users表字段设计.md) | 管理员(单行)——登录体系的身份落点 | 同上 |
|
||||
| [`docs/design/sessions表字段设计.md`](docs/design/sessions表字段设计.md) | 管理面会话——cookie 令牌的服务端对应物 | 同上 |
|
||||
| [`docs/design/交互设计-总览与共性.md`](docs/design/交互设计-总览与共性.md) | 交互总纲:访问拓扑、导航、跨页组件、状态与数值呈现约定 | 定稿(2026-10-01) |
|
||||
| [`docs/design/交互设计-登录与会话.md`](docs/design/交互设计-登录与会话.md) | 登录页、引导页、改密、成员登录壳 | 同上 |
|
||||
| [`docs/design/交互设计-全员总览.md`](docs/design/交互设计-全员总览.md) | 管理面首页:健康带 + 按成员 / key 汇总 | 同上 |
|
||||
| [`docs/design/交互设计-调用日志.md`](docs/design/交互设计-调用日志.md) | 调用日志:筛选、明细表、与 key/成员/总览的跳转链 | 定稿(2026-10-08) |
|
||||
| [`docs/design/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 定稿(2026-10-01) |
|
||||
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 支付记录(两 Tab):订阅权益 / 充值入账登记、核对提示 | 待审阅(2026-10-09) |
|
||||
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、档案修改、令牌签发/重发、启停、空壳删除 | 定稿(2026-10-10) |
|
||||
| [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 定稿(2026-10-10) |
|
||||
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员视图三页(总览 / 调用日志 / 支付参数);订阅徽章展开 + 调用详情 | 定稿(2026-10-09) |
|
||||
| [`docs/design/API契约-公共约定.md`](docs/design/API契约-公共约定.md) | API 总纲:路由隔离与鉴权两档、错误信封、时间 / 数值 / 分页 / 命名 | 定稿(2026-10-10) |
|
||||
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 18 端点 | 待审阅(2026-10-09 支付拆表);R16 定稿 |
|
||||
| [`docs/design/API契约-成员视图.md`](docs/design/API契约-成员视图.md) | 成员视图 3 端点:`/me` · `/overview` · `/logs` | 待审阅(2026-10-09) |
|
||||
| [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) | 独立 `apps/engine`:轮次、失败只记录、`engine_status` 快照 | 已实现(定稿 2026-10-05) |
|
||||
| [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md) | 上游可用接口参考:鉴权档位、字段语义、实测行为、源码出处(**唯一上游参考**) | 事实依据 |
|
||||
|
||||
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策)
|
||||
- 生命周期:状态流转(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
|
||||
|
||||
## 关键事实清单(实测 / 源码确认,设计时不得违反)
|
||||
|
||||
> 出处均见 [`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. **`TokenAuthReadOnly`**:用量/日志接口用宽松鉴权,key 过期 / 额度耗尽仍可查询;**显式禁用会被 401 拒绝**
|
||||
6. **`DisplayTokenStatEnabled` 已开启**:billing 接口返回 token 级额度
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { appLogger, setupLogging } from "@new-api-key-usage/core";
|
||||
import { createDb, createRepos, runMigrations } 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 migrationsFolder =
|
||||
process.env.MIGRATIONS_FOLDER ??
|
||||
fileURLToPath(new URL("../../../packages/db/drizzle", import.meta.url));
|
||||
await runMigrations(db, migrationsFolder);
|
||||
log.info("数据库迁移已就绪");
|
||||
|
||||
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));
|
||||
});
|
||||
@@ -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(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun"],
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# deps
|
||||
node_modules/
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/** 必须先扩展 zod,再加载 core schema(见 zod-openapi-setup) */
|
||||
import "./zod-openapi-setup.ts";
|
||||
import {
|
||||
changePasswordBody,
|
||||
createMemberBody,
|
||||
createSubscriptionBody,
|
||||
createTopupBody,
|
||||
engineStatus,
|
||||
errorEnvelope,
|
||||
keyItem,
|
||||
logItem,
|
||||
logPage,
|
||||
memberItem,
|
||||
memberMe,
|
||||
memberOverview,
|
||||
memberWithToken,
|
||||
patchKeyBody,
|
||||
patchMemberBody,
|
||||
subscriptionItem,
|
||||
topupItem,
|
||||
auditLogItem,
|
||||
registerKeyBody,
|
||||
setupBody,
|
||||
setupState,
|
||||
loginBody,
|
||||
tokenOnly,
|
||||
usernameBody,
|
||||
} from "@new-api-key-usage/core";
|
||||
|
||||
/** OpenAPI 组件命名只在 server 层装饰,core 保持 Web 标准中立 */
|
||||
function nameSchema<T extends { openapi?: (n: string) => unknown }>(
|
||||
schema: T,
|
||||
name: string,
|
||||
): T {
|
||||
return typeof schema.openapi === "function"
|
||||
? (schema.openapi(name) as T)
|
||||
: schema;
|
||||
}
|
||||
|
||||
export const errorSchema = nameSchema(errorEnvelope, "ErrorEnvelope");
|
||||
export const setupStateSchema = nameSchema(setupState, "SetupState");
|
||||
export const engineStatusSchema = nameSchema(engineStatus, "EngineStatus");
|
||||
export const keyItemSchema = nameSchema(keyItem, "KeyItem");
|
||||
export const memberItemSchema = nameSchema(memberItem, "MemberItem");
|
||||
export const memberWithTokenSchema = nameSchema(
|
||||
memberWithToken,
|
||||
"MemberWithToken",
|
||||
);
|
||||
export const memberMeSchema = nameSchema(memberMe, "MemberMe");
|
||||
export const memberOverviewSchema = nameSchema(
|
||||
memberOverview,
|
||||
"MemberOverview",
|
||||
);
|
||||
export const logItemSchema = nameSchema(logItem, "LogItem");
|
||||
export const logPageSchema = nameSchema(logPage, "LogPage");
|
||||
export const subscriptionItemSchema = nameSchema(
|
||||
subscriptionItem,
|
||||
"SubscriptionItem",
|
||||
);
|
||||
export const topupItemSchema = nameSchema(topupItem, "TopupItem");
|
||||
export const auditLogItemSchema = nameSchema(auditLogItem, "AuditLogItem");
|
||||
|
||||
export {
|
||||
changePasswordBody as changePasswordBodySchema,
|
||||
createMemberBody as createMemberBodySchema,
|
||||
createSubscriptionBody as createSubscriptionBodySchema,
|
||||
createTopupBody as createTopupBodySchema,
|
||||
loginBody as loginBodySchema,
|
||||
patchKeyBody as patchKeyBodySchema,
|
||||
patchMemberBody as patchMemberBodySchema,
|
||||
registerKeyBody as registerKeyBodySchema,
|
||||
setupBody as setupBodySchema,
|
||||
tokenOnly as tokenOnlySchema,
|
||||
usernameBody as usernameSchema,
|
||||
};
|
||||
|
||||
export const err401 = {
|
||||
description: "凭证无效或过期",
|
||||
content: { "application/json": { schema: errorSchema } },
|
||||
} as const;
|
||||
|
||||
export const err404 = {
|
||||
description: "资源不存在",
|
||||
content: { "application/json": { schema: errorSchema } },
|
||||
} as const;
|
||||
|
||||
export const err409 = {
|
||||
description: "冲突",
|
||||
content: { "application/json": { schema: errorSchema } },
|
||||
} as const;
|
||||
|
||||
export const err422 = {
|
||||
description: "校验失败",
|
||||
content: { "application/json": { schema: errorSchema } },
|
||||
} as const;
|
||||
|
||||
export const err502 = {
|
||||
description: "上游不可达",
|
||||
content: { "application/json": { schema: errorSchema } },
|
||||
} as const;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { apiReference } from "@scalar/hono-api-reference";
|
||||
import { ERR, appLogger } from "@new-api-key-usage/core";
|
||||
import { createRepos, type Db } from "@new-api-key-usage/db";
|
||||
import { jsonError, type AppVars } from "./middleware.ts";
|
||||
import { adminSessionRoutes } from "./routes/admin-session.ts";
|
||||
import { adminPlatformRoutes } from "./routes/admin-platform.ts";
|
||||
import { adminEngineRoutes } from "./routes/admin-engine.ts";
|
||||
import { adminKeyRoutes } from "./routes/admin-keys.ts";
|
||||
import { adminMemberRoutes } from "./routes/admin-members.ts";
|
||||
import { adminUsageRoutes } from "./routes/admin-usage.ts";
|
||||
import { adminSubscriptionRoutes } from "./routes/admin-subscriptions.ts";
|
||||
import { adminTopupRoutes } from "./routes/admin-topups.ts";
|
||||
import { adminAuditRoutes } from "./routes/admin-audit.ts";
|
||||
import { memberViewRoutes } from "./routes/member-view.ts";
|
||||
|
||||
export interface CreateAppOptions {
|
||||
db: Db;
|
||||
skKeyEncryptionSecret: string;
|
||||
newapiBase: string;
|
||||
cookieSecure?: boolean;
|
||||
adminInitUsername?: string;
|
||||
adminInitPassword?: string;
|
||||
/** 可注入的上游验证(测试);默认走 real HTTP */
|
||||
fetchTokenUsage?: AppVars["fetchTokenUsage"];
|
||||
}
|
||||
|
||||
export function createApp(opts: CreateAppOptions) {
|
||||
const app = new OpenAPIHono<{ Variables: AppVars }>({
|
||||
defaultHook: (result, c) => {
|
||||
if (!result.success) {
|
||||
const fields: Record<string, string> = {};
|
||||
for (const issue of result.error.issues) {
|
||||
const key = issue.path.join(".") || "body";
|
||||
fields[key] = issue.message;
|
||||
}
|
||||
return jsonError(c, 422, ERR.validation_failed(fields));
|
||||
}
|
||||
},
|
||||
});
|
||||
const repos = createRepos(opts.db);
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
appLogger("http").info(
|
||||
`${c.req.method} ${c.req.path} -> ${c.res.status} | ${Date.now() - start}ms`,
|
||||
);
|
||||
}
|
||||
});
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("repos", repos);
|
||||
c.set("skKeyEncryptionSecret", opts.skKeyEncryptionSecret);
|
||||
c.set("newapiBase", opts.newapiBase);
|
||||
c.set("cookieSecure", opts.cookieSecure ?? true);
|
||||
if (opts.fetchTokenUsage) c.set("fetchTokenUsage", opts.fetchTokenUsage);
|
||||
if (opts.adminInitUsername)
|
||||
c.set("adminInitUsername", opts.adminInitUsername);
|
||||
if (opts.adminInitPassword)
|
||||
c.set("adminInitPassword", opts.adminInitPassword);
|
||||
await next();
|
||||
});
|
||||
|
||||
app.notFound((c) => {
|
||||
if (c.req.path.startsWith("/api")) {
|
||||
return jsonError(c, 404, ERR.not_found());
|
||||
}
|
||||
return c.text("Not Found", 404);
|
||||
});
|
||||
|
||||
app.onError((err, c) => {
|
||||
appLogger("server").error(`未捕获错误 ${c.req.method} ${c.req.path}:${err}`);
|
||||
return jsonError(c, 500, ERR.internal());
|
||||
});
|
||||
|
||||
app.openAPIRegistry.registerComponent("securitySchemes", "adminSession", {
|
||||
type: "apiKey",
|
||||
in: "cookie",
|
||||
name: "admin_session",
|
||||
description: "管理面会话 cookie(HttpOnly · Path=/api/admin)",
|
||||
});
|
||||
app.openAPIRegistry.registerComponent("securitySchemes", "memberToken", {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
description: "成员视图 Bearer 令牌",
|
||||
});
|
||||
|
||||
app.doc("/api/openapi.json", {
|
||||
openapi: "3.1.0",
|
||||
info: {
|
||||
title: "new-api-key-usage",
|
||||
version: "0.0.0",
|
||||
description:
|
||||
"NewAPI key 用量统计——管理面 `/api/admin/*`(会话 cookie)与成员视图 `/api/member/*`(Bearer)。成功响应无信封,错误见 ErrorEnvelope。",
|
||||
},
|
||||
tags: [
|
||||
{ name: "引导与会话", description: "三态判定 / 登录 / 改密" },
|
||||
{ name: "引擎", description: "同步引擎状态" },
|
||||
{ name: "Key 管理", description: "登记 / 启停 / 归属" },
|
||||
{ name: "成员管理", description: "开户 / 令牌 / 启停" },
|
||||
{ name: "成员视图", description: "令牌自助查询" },
|
||||
],
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/api/docs",
|
||||
apiReference({
|
||||
theme: "kepler",
|
||||
spec: { url: "/api/openapi.json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const admin = new OpenAPIHono<{ Variables: AppVars }>();
|
||||
admin.route("/", adminSessionRoutes());
|
||||
admin.route("/", adminEngineRoutes());
|
||||
admin.route("/", adminKeyRoutes());
|
||||
admin.route("/", adminMemberRoutes());
|
||||
admin.route("/", adminUsageRoutes());
|
||||
admin.route("/", adminSubscriptionRoutes());
|
||||
admin.route("/", adminTopupRoutes());
|
||||
admin.route("/", adminAuditRoutes());
|
||||
admin.route("/", adminPlatformRoutes());
|
||||
app.route("/api/admin", admin);
|
||||
|
||||
app.route("/api/member", memberViewRoutes());
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createApp } from "./app.ts";
|
||||
import { loadEnv } from "./env.ts";
|
||||
import { createDb, runMigrations } 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 migrationsFolder =
|
||||
process.env.MIGRATIONS_FOLDER ??
|
||||
fileURLToPath(new URL("../../../packages/db/drizzle", import.meta.url));
|
||||
|
||||
await runMigrations(db, migrationsFolder);
|
||||
appLogger("server").info("数据库迁移已就绪");
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ERR,
|
||||
fetchPlatformSubscriptions,
|
||||
fetchPlatformTopups,
|
||||
platformLogin,
|
||||
} from "@new-api-key-usage/core";
|
||||
import { jsonError, requireAdminSession, type AppVars } from "../middleware.ts";
|
||||
import { err401, err422, err502 } from "../api-schemas.ts";
|
||||
|
||||
const platformSubsSchema = z.object({
|
||||
subscriptions: z.array(
|
||||
z.object({
|
||||
id: z.number().int(),
|
||||
plan_id: z.number().int(),
|
||||
start_time: z.number().int(),
|
||||
end_time: z.number().int(),
|
||||
status: z.string(),
|
||||
amount_total: z.number(),
|
||||
amount_used: z.number(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const loginBody = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
const listBody = z.object({
|
||||
access_token: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* 登记辅助:控制台账号登录 + 拉订阅。密码不落库;access_token 由前端短暂持有。
|
||||
*/
|
||||
export function adminPlatformRoutes() {
|
||||
const r = new OpenAPIHono<{ Variables: AppVars }>();
|
||||
r.use("*", requireAdminSession);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/platform/login",
|
||||
summary: "登录 NewAPI 控制台(登记辅助)",
|
||||
tags: ["支付"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
body: {
|
||||
content: { "application/json": { schema: loginBody } },
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "access_token",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({ access_token: z.string() }),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: err401,
|
||||
422: err422,
|
||||
502: err502,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const username = String(body?.username ?? "");
|
||||
const password = String(body?.password ?? "");
|
||||
if (!username || !password) {
|
||||
return jsonError(
|
||||
c,
|
||||
422,
|
||||
ERR.validation_failed({
|
||||
username: username ? "" : "请填写用户名",
|
||||
password: password ? "" : "请填写密码",
|
||||
}),
|
||||
);
|
||||
}
|
||||
const login = await platformLogin(c.get("newapiBase"), username, password);
|
||||
if (!login.ok) {
|
||||
if (login.kind === "rejected") return jsonError(c, 422, ERR.invalid_credentials());
|
||||
if (login.kind === "unreachable") return jsonError(c, 502, ERR.upstream_unreachable());
|
||||
return jsonError(
|
||||
c,
|
||||
422,
|
||||
ERR.validation_failed({
|
||||
password: "登录未成功——可能需验证码/加密登录,或账号密码不正确",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return c.json({ access_token: login.data.access_token }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/platform/subscriptions",
|
||||
summary: "拉取订阅列表(登记辅助)",
|
||||
tags: ["支付"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
body: {
|
||||
content: { "application/json": { schema: listBody } },
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "subscriptions",
|
||||
content: { "application/json": { schema: platformSubsSchema } },
|
||||
},
|
||||
401: err401,
|
||||
422: err422,
|
||||
502: err502,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const token = String(body?.access_token ?? "");
|
||||
if (!token) {
|
||||
return jsonError(
|
||||
c,
|
||||
422,
|
||||
ERR.validation_failed({ access_token: "缺少 access_token" }),
|
||||
);
|
||||
}
|
||||
const subs = await fetchPlatformSubscriptions(c.get("newapiBase"), token);
|
||||
if (!subs.ok) {
|
||||
if (subs.kind === "rejected") {
|
||||
// 上游 token 失效 ≠ 管理端会话过期:绝不能回 401(前端会误登出)
|
||||
return jsonError(c, 422, ERR.platform_token_invalid());
|
||||
}
|
||||
return jsonError(c, 502, ERR.upstream_unreachable());
|
||||
}
|
||||
return c.json({ subscriptions: subs.data }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/platform/topups",
|
||||
summary: "分页拉取充值单(登记辅助)",
|
||||
tags: ["支付"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: listBody.extend({
|
||||
page: z.number().int().positive().optional(),
|
||||
page_size: z.number().int().positive().max(50).optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "topups page",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
items: z.array(z.looseObject({ id: z.number().int() })),
|
||||
total: z.number().int(),
|
||||
page: z.number().int(),
|
||||
page_size: z.number().int(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: err401,
|
||||
422: err422,
|
||||
502: err502,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const token = String(body?.access_token ?? "");
|
||||
if (!token) {
|
||||
return jsonError(
|
||||
c,
|
||||
422,
|
||||
ERR.validation_failed({ access_token: "缺少 access_token" }),
|
||||
);
|
||||
}
|
||||
const page = Number(body?.page ?? 1) || 1;
|
||||
const pageSize = Math.min(50, Number(body?.page_size ?? 10) || 10);
|
||||
const tops = await fetchPlatformTopups(
|
||||
c.get("newapiBase"),
|
||||
token,
|
||||
page,
|
||||
pageSize,
|
||||
);
|
||||
if (!tops.ok) {
|
||||
if (tops.kind === "rejected") {
|
||||
return jsonError(c, 422, ERR.platform_token_invalid());
|
||||
}
|
||||
return jsonError(c, 502, ERR.upstream_unreachable());
|
||||
}
|
||||
return c.json(tops.data, 200);
|
||||
},
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
||||
import { setCookie, deleteCookie, getCookie } from "hono/cookie";
|
||||
import {
|
||||
ERR,
|
||||
generateToken,
|
||||
hashPassword,
|
||||
hashToken,
|
||||
verifyPassword,
|
||||
} from "@new-api-key-usage/core";
|
||||
import {
|
||||
jsonError,
|
||||
requireAdminSession,
|
||||
SESSION_COOKIE,
|
||||
SESSION_TTL_SECONDS,
|
||||
type AppVars,
|
||||
} from "../middleware.ts";
|
||||
import {
|
||||
changePasswordBodySchema,
|
||||
err401,
|
||||
err409,
|
||||
err422,
|
||||
loginBodySchema,
|
||||
setupBodySchema,
|
||||
setupStateSchema,
|
||||
usernameSchema,
|
||||
} from "../api-schemas.ts";
|
||||
import {
|
||||
isLoginThrottled,
|
||||
recordLoginFailure,
|
||||
recordLoginSuccess,
|
||||
} from "../login-throttle.ts";
|
||||
|
||||
function setSessionCookie(
|
||||
c: Parameters<typeof setCookie>[0],
|
||||
plaintext: string,
|
||||
secure: boolean,
|
||||
) {
|
||||
setCookie(c, SESSION_COOKIE, plaintext, {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: "Lax",
|
||||
path: "/api/admin",
|
||||
maxAge: SESSION_TTL_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
/** 哑哈希:用户不存在时也跑一次 PBKDF2,抹平时序侧信道 */
|
||||
const DUMMY_PASSWORD_HASH =
|
||||
"pbkdf2$210000$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
export function adminSessionRoutes() {
|
||||
const r = new OpenAPIHono<{ Variables: AppVars }>();
|
||||
|
||||
// 只保护需要会话的端点:GET/DELETE /session、PUT /password
|
||||
// POST /session 是登录、setup* 是引导——不能套鉴权
|
||||
r.use("/session", async (c, next) => {
|
||||
if (c.req.method === "POST") return next();
|
||||
return requireAdminSession(c, next);
|
||||
});
|
||||
r.use("/password", requireAdminSession);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/setup-state",
|
||||
summary: "入口三态判定",
|
||||
description:
|
||||
"users 非空 → true(登录页);空库 + env 凭证在 → 静默落库后 true;空库 + 无凭证 → false(引导页)",
|
||||
tags: ["引导与会话"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "initialized",
|
||||
content: { "application/json": { schema: setupStateSchema } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const repos = c.get("repos");
|
||||
if ((await repos.users.countAll()) > 0) {
|
||||
return c.json({ initialized: true }, 200);
|
||||
}
|
||||
const initUser = c.get("adminInitUsername");
|
||||
const initPass = c.get("adminInitPassword");
|
||||
if (initUser && initPass) {
|
||||
try {
|
||||
const password_hash = await hashPassword(initPass);
|
||||
await repos.users.insert(initUser, password_hash);
|
||||
await repos.audit.insert({
|
||||
operator: null,
|
||||
domain: "account",
|
||||
action: "account.init",
|
||||
summary: "空库 env 静默重建管理员",
|
||||
});
|
||||
} catch {
|
||||
// 并发/重复初始化:唯一约束冲突时视为已初始化
|
||||
}
|
||||
return c.json({ initialized: true }, 200);
|
||||
}
|
||||
return c.json({ initialized: false }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/setup",
|
||||
summary: "引导页落库(一次性)",
|
||||
description: "仅空库 + 无 env 凭证时有效;成功即建立会话",
|
||||
tags: ["引导与会话"],
|
||||
request: {
|
||||
body: {
|
||||
content: { "application/json": { schema: setupBodySchema } },
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "username + Set-Cookie",
|
||||
content: { "application/json": { schema: usernameSchema } },
|
||||
},
|
||||
409: err409,
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const username =
|
||||
typeof body?.username === "string" ? body.username.trim() : "";
|
||||
const password = typeof body?.password === "string" ? body.password : "";
|
||||
const fields: Record<string, string> = {};
|
||||
if (!username) fields.username = "请填写用户名";
|
||||
if (!password) fields.password = "请填写密码";
|
||||
if (Object.keys(fields).length > 0) {
|
||||
return jsonError(c, 422, ERR.validation_failed(fields));
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
if ((await repos.users.countAll()) > 0) {
|
||||
return jsonError(c, 409, ERR.already_initialized());
|
||||
}
|
||||
const password_hash = await hashPassword(password);
|
||||
const user = await repos.users.insert(username, password_hash);
|
||||
const token = await generateToken();
|
||||
const expires_at = new Date(Date.now() + SESSION_TTL_SECONDS * 1000);
|
||||
await repos.sessions.insert(user.id, token.hash, expires_at);
|
||||
await repos.audit.insert({
|
||||
operator: username,
|
||||
domain: "account",
|
||||
action: "account.init",
|
||||
summary: `引导页创建管理员 ${username}`,
|
||||
});
|
||||
setSessionCookie(c, token.plaintext, c.get("cookieSecure"));
|
||||
return c.json({ username: user.username }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/session",
|
||||
summary: "登录",
|
||||
tags: ["引导与会话"],
|
||||
request: {
|
||||
body: {
|
||||
content: { "application/json": { schema: loginBodySchema } },
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "username + Set-Cookie",
|
||||
content: { "application/json": { schema: usernameSchema } },
|
||||
},
|
||||
401: err401,
|
||||
429: {
|
||||
description: "登录过于频繁",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: err422.content["application/json"].schema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const username = typeof body?.username === "string" ? body.username : "";
|
||||
const password = typeof body?.password === "string" ? body.password : "";
|
||||
if (isLoginThrottled(username)) {
|
||||
return jsonError(c, 429, ERR.too_many_requests());
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
const user = await repos.users.findByUsername(username);
|
||||
const ok = await verifyPassword(
|
||||
password,
|
||||
user?.password_hash ?? DUMMY_PASSWORD_HASH,
|
||||
);
|
||||
if (!user || !ok) {
|
||||
recordLoginFailure(username || "_");
|
||||
return jsonError(c, 401, ERR.invalid_credentials());
|
||||
}
|
||||
recordLoginSuccess(username);
|
||||
|
||||
const token = await generateToken();
|
||||
const expires_at = new Date(Date.now() + SESSION_TTL_SECONDS * 1000);
|
||||
await repos.sessions.insert(user.id, token.hash, expires_at);
|
||||
setSessionCookie(c, token.plaintext, c.get("cookieSecure"));
|
||||
return c.json({ username: user.username }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/session",
|
||||
summary: "当前会话",
|
||||
tags: ["引导与会话"],
|
||||
security: [{ adminSession: [] }],
|
||||
responses: {
|
||||
200: {
|
||||
description: "username",
|
||||
content: { "application/json": { schema: usernameSchema } },
|
||||
},
|
||||
401: err401,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json({ username: c.get("adminUsername") ?? "" }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "delete",
|
||||
path: "/session",
|
||||
summary: "登出",
|
||||
tags: ["引导与会话"],
|
||||
security: [{ adminSession: [] }],
|
||||
responses: {
|
||||
204: { description: "No Content" },
|
||||
401: err401,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const token = getCookie(c, SESSION_COOKIE);
|
||||
if (token) {
|
||||
await c.get("repos").sessions.deleteByTokenHash(await hashToken(token));
|
||||
}
|
||||
deleteCookie(c, SESSION_COOKIE, { path: "/api/admin" });
|
||||
return c.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "put",
|
||||
path: "/password",
|
||||
summary: "改密",
|
||||
description: "成功后删除其他会话、保留当前",
|
||||
tags: ["引导与会话"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
body: {
|
||||
content: { "application/json": { schema: changePasswordBodySchema } },
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
204: { description: "No Content" },
|
||||
401: err401,
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const old_password = String(body?.old_password ?? "");
|
||||
const new_password = String(body?.new_password ?? "");
|
||||
if (!new_password) {
|
||||
return jsonError(
|
||||
c,
|
||||
422,
|
||||
ERR.validation_failed({ new_password: "请填写新密码" }),
|
||||
);
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
const userId = c.get("adminUserId")!;
|
||||
const user = await repos.users.findById(userId);
|
||||
if (!user) return jsonError(c, 401, ERR.session_expired());
|
||||
const ok = await verifyPassword(old_password, user.password_hash);
|
||||
if (!ok) {
|
||||
return jsonError(
|
||||
c,
|
||||
422,
|
||||
ERR.validation_failed({ old_password: "旧密码不正确" }),
|
||||
);
|
||||
}
|
||||
await repos.users.updatePassword(
|
||||
userId,
|
||||
await hashPassword(new_password),
|
||||
);
|
||||
const token = getCookie(c, SESSION_COOKIE);
|
||||
const currentHash = token ? await hashToken(token) : null;
|
||||
const all = await repos.sessions.listByUser(userId);
|
||||
for (const s of all) {
|
||||
if (s.token_hash !== currentHash) {
|
||||
await repos.sessions.deleteById(s.id);
|
||||
}
|
||||
}
|
||||
await repos.audit.insert({
|
||||
operator: user.username,
|
||||
domain: "account",
|
||||
action: "account.password_change",
|
||||
summary: "修改密码,其他会话已失效",
|
||||
});
|
||||
return c.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
||||
import {
|
||||
ERR,
|
||||
errorBody,
|
||||
qpuAt,
|
||||
quotaToUsdStr,
|
||||
resolveQpuTimeline,
|
||||
toUnixSec,
|
||||
type SubscriptionItem,
|
||||
} from "@new-api-key-usage/core";
|
||||
import type { SubscriptionRow } from "@new-api-key-usage/db";
|
||||
import {
|
||||
jsonError,
|
||||
requireAdminSession,
|
||||
type AppVars,
|
||||
} from "../middleware.ts";
|
||||
import {
|
||||
createSubscriptionBodySchema,
|
||||
err401,
|
||||
err404,
|
||||
err422,
|
||||
errorSchema,
|
||||
subscriptionItemSchema,
|
||||
} from "../api-schemas.ts";
|
||||
import { z } from "zod";
|
||||
|
||||
function toSubscriptionItem(
|
||||
row: SubscriptionRow,
|
||||
quota_usd: string,
|
||||
): SubscriptionItem {
|
||||
return {
|
||||
id: row.id,
|
||||
subscription_id: row.subscription_id,
|
||||
period_start: toUnixSec(row.period_start)!,
|
||||
period_end: toUnixSec(row.period_end),
|
||||
quota_granted: row.quota_granted,
|
||||
quota_usd,
|
||||
paid_amount: row.paid_amount,
|
||||
paid_at: toUnixSec(row.paid_at),
|
||||
remark: row.remark,
|
||||
created_at: toUnixSec(row.created_at)!,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
|
||||
| { fields: Record<string, string> }
|
||||
| {
|
||||
values: {
|
||||
subscription_id: number;
|
||||
period_start: Date;
|
||||
period_end: Date | null;
|
||||
quota_granted: number;
|
||||
paid_amount: number | null;
|
||||
paid_at: Date | null;
|
||||
remark: string;
|
||||
};
|
||||
} {
|
||||
const fields: Record<string, string> = {};
|
||||
if (body.subscription_id == null || body.subscription_id <= 0) {
|
||||
fields.subscription_id = "请填写订阅编号";
|
||||
}
|
||||
if (!body.period_start) fields.period_start = "请填写期间起点";
|
||||
if (body.quota_granted == null || body.quota_granted <= 0) {
|
||||
fields.quota_granted = "请填写套餐总额度";
|
||||
}
|
||||
if (Object.keys(fields).length > 0) return { fields };
|
||||
return {
|
||||
values: {
|
||||
subscription_id: body.subscription_id,
|
||||
period_start: new Date(body.period_start),
|
||||
period_end: body.period_end
|
||||
? new Date(body.period_end)
|
||||
: null,
|
||||
quota_granted: body.quota_granted,
|
||||
paid_amount: body.paid_amount ?? null,
|
||||
paid_at: body.paid_at ? new Date(body.paid_at) : null,
|
||||
remark: body.remark ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function adminSubscriptionRoutes() {
|
||||
const r = new OpenAPIHono<{ Variables: AppVars }>();
|
||||
r.use("*", requireAdminSession);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/subscriptions",
|
||||
summary: "订阅权益清单",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
responses: {
|
||||
200: {
|
||||
description: "subscription 数组",
|
||||
content: {
|
||||
"application/json": { schema: z.array(subscriptionItemSchema) },
|
||||
},
|
||||
},
|
||||
401: err401,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const repos = c.get("repos");
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
const rows = await repos.subscriptions.listAll();
|
||||
return c.json(
|
||||
rows.map((row) =>
|
||||
toSubscriptionItem(
|
||||
row,
|
||||
quotaToUsdStr(
|
||||
row.quota_granted,
|
||||
qpuAt(row.paid_at ?? row.created_at, timeline),
|
||||
),
|
||||
),
|
||||
),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/subscriptions",
|
||||
summary: "登记订阅权益",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: createSubscriptionBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "subscription",
|
||||
content: {
|
||||
"application/json": { schema: subscriptionItemSchema },
|
||||
},
|
||||
},
|
||||
401: err401,
|
||||
409: {
|
||||
description: "重复登记",
|
||||
content: { "application/json": { schema: errorSchema } },
|
||||
},
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
const parsed = parseSubBody(body);
|
||||
if ("fields" in parsed) {
|
||||
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
const exists = await repos.subscriptions.findBySubscriptionId(
|
||||
parsed.values.subscription_id,
|
||||
);
|
||||
if (exists) {
|
||||
return jsonError(
|
||||
c,
|
||||
409,
|
||||
errorBody(
|
||||
"duplicate",
|
||||
`订阅 #${parsed.values.subscription_id} 已有登记`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const row = await repos.subscriptions.insert(parsed.values);
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "payment",
|
||||
action: "subscription.create",
|
||||
summary: `订阅 #${row.subscription_id} · 登记`,
|
||||
});
|
||||
return c.json(
|
||||
toSubscriptionItem(
|
||||
row,
|
||||
quotaToUsdStr(
|
||||
row.quota_granted,
|
||||
qpuAt(row.paid_at ?? row.created_at, timeline),
|
||||
),
|
||||
),
|
||||
201,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "put",
|
||||
path: "/subscriptions/{id}",
|
||||
summary: "编辑订阅权益",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: createSubscriptionBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "subscription",
|
||||
content: {
|
||||
"application/json": { schema: subscriptionItemSchema },
|
||||
},
|
||||
},
|
||||
401: err401,
|
||||
404: err404,
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const body = c.req.valid("json");
|
||||
const parsed = parseSubBody(body);
|
||||
if ("fields" in parsed) {
|
||||
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
if (!(await repos.subscriptions.findById(id))) {
|
||||
return jsonError(c, 404, ERR.not_found());
|
||||
}
|
||||
const row = await repos.subscriptions.update(id, parsed.values);
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "payment",
|
||||
action: "subscription.update",
|
||||
summary: `订阅 #${row.subscription_id} · 编辑`,
|
||||
});
|
||||
return c.json(
|
||||
toSubscriptionItem(
|
||||
row,
|
||||
quotaToUsdStr(
|
||||
row.quota_granted,
|
||||
qpuAt(row.paid_at ?? row.created_at, timeline),
|
||||
),
|
||||
),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "delete",
|
||||
path: "/subscriptions/{id}",
|
||||
summary: "删除订阅权益",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
204: { description: "deleted" },
|
||||
401: err401,
|
||||
404: err404,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const repos = c.get("repos");
|
||||
const before = await repos.subscriptions.findById(id);
|
||||
if (!before) return jsonError(c, 404, ERR.not_found());
|
||||
await repos.subscriptions.remove(id);
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "payment",
|
||||
action: "subscription.delete",
|
||||
summary: `订阅 #${before.subscription_id} · 删除`,
|
||||
});
|
||||
return c.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
||||
import {
|
||||
ERR,
|
||||
qpuAt,
|
||||
quotaToUsdStr,
|
||||
resolveQpuTimeline,
|
||||
toUnixSec,
|
||||
type TopupItem,
|
||||
} from "@new-api-key-usage/core";
|
||||
import type { TopupRow } from "@new-api-key-usage/db";
|
||||
import {
|
||||
jsonError,
|
||||
requireAdminSession,
|
||||
type AppVars,
|
||||
} from "../middleware.ts";
|
||||
import {
|
||||
createTopupBodySchema,
|
||||
err401,
|
||||
err404,
|
||||
err422,
|
||||
topupItemSchema,
|
||||
} from "../api-schemas.ts";
|
||||
import { z } from "zod";
|
||||
|
||||
function toTopupItem(row: TopupRow, qpu: number): TopupItem {
|
||||
const quota_derived = Math.round(row.amount * qpu);
|
||||
return {
|
||||
id: row.id,
|
||||
upstream_id: row.upstream_id,
|
||||
amount: row.amount,
|
||||
money: row.money,
|
||||
trade_no: row.trade_no,
|
||||
payment_method: row.payment_method,
|
||||
payment_provider: row.payment_provider,
|
||||
status: row.status,
|
||||
paid_at: toUnixSec(row.paid_at)!,
|
||||
quota_derived,
|
||||
quota_usd: quotaToUsdStr(quota_derived, qpu),
|
||||
remark: row.remark,
|
||||
created_at: toUnixSec(row.created_at)!,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
|
||||
| { fields: Record<string, string> }
|
||||
| {
|
||||
values: {
|
||||
upstream_id: number | null;
|
||||
amount: number;
|
||||
money: number;
|
||||
trade_no: string | null;
|
||||
payment_method: string | null;
|
||||
payment_provider: string | null;
|
||||
status: string | null;
|
||||
paid_at: Date;
|
||||
remark: string;
|
||||
};
|
||||
} {
|
||||
const fields: Record<string, string> = {};
|
||||
if (!body.paid_at) fields.paid_at = "请填写入账时间";
|
||||
if (typeof body.amount !== "number") fields.amount = "请填写计费单位金额";
|
||||
if (typeof body.money !== "number") fields.money = "请填写实付金额";
|
||||
if (Object.keys(fields).length > 0) return { fields };
|
||||
return {
|
||||
values: {
|
||||
upstream_id: body.upstream_id ?? null,
|
||||
amount: body.amount,
|
||||
money: body.money,
|
||||
trade_no: body.trade_no ?? null,
|
||||
payment_method: body.payment_method ?? null,
|
||||
payment_provider: body.payment_provider ?? null,
|
||||
status: body.status ?? "success",
|
||||
paid_at: new Date(body.paid_at),
|
||||
remark: body.remark ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function adminTopupRoutes() {
|
||||
const r = new OpenAPIHono<{ Variables: AppVars }>();
|
||||
r.use("*", requireAdminSession);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/topups",
|
||||
summary: "充值入账清单",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
responses: {
|
||||
200: {
|
||||
description: "topup 数组",
|
||||
content: {
|
||||
"application/json": { schema: z.array(topupItemSchema) },
|
||||
},
|
||||
},
|
||||
401: err401,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const repos = c.get("repos");
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
const rows = await repos.topups.listAll();
|
||||
return c.json(
|
||||
rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/topups",
|
||||
summary: "登记充值",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: createTopupBodySchema },
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "topup",
|
||||
content: { "application/json": { schema: topupItemSchema } },
|
||||
},
|
||||
401: err401,
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as z.infer<
|
||||
typeof createTopupBodySchema
|
||||
>;
|
||||
const parsed = parseTopupBody(body as z.infer<typeof createTopupBodySchema>);
|
||||
if ("fields" in parsed) {
|
||||
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
const row = await repos.topups.insert(parsed.values);
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "payment",
|
||||
action: "topup.create",
|
||||
summary: `充值 #${row.id} · 登记`,
|
||||
});
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
return c.json(toTopupItem(row, qpuAt(row.paid_at, timeline)), 201);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "put",
|
||||
path: "/topups/{id}",
|
||||
summary: "编辑充值",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: createTopupBodySchema },
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "topup",
|
||||
content: { "application/json": { schema: topupItemSchema } },
|
||||
},
|
||||
401: err401,
|
||||
404: err404,
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const body = (await c.req.json().catch(() => ({}))) as z.infer<
|
||||
typeof createTopupBodySchema
|
||||
>;
|
||||
const parsed = parseTopupBody(body as z.infer<typeof createTopupBodySchema>);
|
||||
if ("fields" in parsed) {
|
||||
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
if (!(await repos.topups.findById(id))) {
|
||||
return jsonError(c, 404, ERR.not_found());
|
||||
}
|
||||
const row = await repos.topups.update(id, parsed.values);
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "payment",
|
||||
action: "topup.update",
|
||||
summary: `充值 #${row.id} · 编辑`,
|
||||
});
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
return c.json(toTopupItem(row, qpuAt(row.paid_at, timeline)), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "delete",
|
||||
path: "/topups/{id}",
|
||||
summary: "删除充值",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
204: { description: "deleted" },
|
||||
401: err401,
|
||||
404: err404,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const repos = c.get("repos");
|
||||
const before = await repos.topups.findById(id);
|
||||
if (!before) return jsonError(c, 404, ERR.not_found());
|
||||
await repos.topups.remove(id);
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "payment",
|
||||
action: "topup.delete",
|
||||
summary: `充值 #${before.id} · 删除`,
|
||||
});
|
||||
return c.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
||||
import {
|
||||
ERR,
|
||||
resolveQpuTimeline,
|
||||
sumUsage,
|
||||
toUnixSec,
|
||||
usageRowPrecise,
|
||||
type LogItem,
|
||||
} from "@new-api-key-usage/core";
|
||||
import {
|
||||
jsonError,
|
||||
requireAdminSession,
|
||||
type AppVars,
|
||||
} from "../middleware.ts";
|
||||
import {
|
||||
err401,
|
||||
err422,
|
||||
logPageSchema,
|
||||
} from "../api-schemas.ts";
|
||||
import { z } from "zod";
|
||||
|
||||
const usageTotals = z.object({
|
||||
count: z.number().int(),
|
||||
quota: z.number(),
|
||||
quota_usd: z.string(),
|
||||
});
|
||||
|
||||
const overviewSchema = z.object({
|
||||
data_cutoff: z.number().int().nullable(),
|
||||
unassigned_subscription: usageTotals,
|
||||
by_source: z.array(
|
||||
z.object({
|
||||
billing_source: z.string(),
|
||||
count: z.number().int(),
|
||||
quota: z.number(),
|
||||
quota_usd: z.string(),
|
||||
}),
|
||||
),
|
||||
by_member: z.array(
|
||||
z.object({
|
||||
member_id: z.number().int().nullable(),
|
||||
member_name: z.string().nullable(),
|
||||
key_count: z.number().int(),
|
||||
data_cutoff: z.number().int().nullable(),
|
||||
usage: usageTotals,
|
||||
}),
|
||||
),
|
||||
by_key: z.array(
|
||||
z.object({
|
||||
key_id: z.number().int(),
|
||||
token_name: z.string(),
|
||||
member_id: z.number().int().nullable(),
|
||||
member_name: z.string().nullable(),
|
||||
status: z.string(),
|
||||
data_cutoff: z.number().int().nullable(),
|
||||
usage: usageTotals,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const qpuLatestSchema = z.object({
|
||||
qpu: z.number(),
|
||||
effective_from: z.number().int(),
|
||||
});
|
||||
|
||||
const timeRange = z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
});
|
||||
|
||||
function parseRange(
|
||||
query: { from?: string; to?: string },
|
||||
): { from: Date; to: Date } | { fields: Record<string, string> } {
|
||||
const fields: Record<string, string> = {};
|
||||
if (!query.from) fields.from = "缺少起始时间";
|
||||
if (!query.to) fields.to = "缺少结束时间";
|
||||
if (Object.keys(fields).length > 0) return { fields };
|
||||
const from = new Date(query.from!);
|
||||
const to = new Date(query.to!);
|
||||
if (Number.isNaN(from.getTime())) fields.from = "时间格式无效";
|
||||
if (Number.isNaN(to.getTime())) fields.to = "时间格式无效";
|
||||
else if (to.getTime() <= from.getTime()) fields.to = "结束时间必须晚于起始时间";
|
||||
if (Object.keys(fields).length > 0) return { fields };
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
export function adminUsageRoutes() {
|
||||
const r = new OpenAPIHono<{ Variables: AppVars }>();
|
||||
r.use("*", requireAdminSession);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/qpu/latest",
|
||||
summary: "当前换算率",
|
||||
description: "支付表单美元 ↔ quota 双输入换算用;库空时回退 500000",
|
||||
tags: ["用量"],
|
||||
security: [{ adminSession: [] }],
|
||||
responses: {
|
||||
200: {
|
||||
description: "qpu",
|
||||
content: { "application/json": { schema: qpuLatestSchema } },
|
||||
},
|
||||
401: err401,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const latest = await c.get("repos").qpu.latest();
|
||||
if (!latest) {
|
||||
return c.json(
|
||||
{ qpu: 500_000, effective_from: 0 },
|
||||
200,
|
||||
);
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
qpu: latest.qpu,
|
||||
effective_from: toUnixSec(latest.effective_from)!,
|
||||
},
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/overview",
|
||||
summary: "全员总览",
|
||||
description:
|
||||
"from/to 必填、左闭右开;聚合仅 type=2。unassigned_subscription 为健康指示器。",
|
||||
tags: ["用量"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: { query: timeRange },
|
||||
responses: {
|
||||
200: {
|
||||
description: "overview",
|
||||
content: { "application/json": { schema: overviewSchema } },
|
||||
},
|
||||
401: err401,
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const q = c.req.query();
|
||||
const range = parseRange(q);
|
||||
if ("fields" in range) {
|
||||
return jsonError(c, 422, ERR.validation_failed(range.fields));
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
const rows = await repos.logs.listForAggregate(range.from, range.to);
|
||||
|
||||
const data_cutoff = await repos.keysExtra.slowestLogSyncedAt();
|
||||
|
||||
const unassigned_rows = rows.filter(
|
||||
(x) => x.billing_source === "subscription" && x.subscription_id == null,
|
||||
);
|
||||
const unassigned_subscription = sumUsage(unassigned_rows, timeline);
|
||||
|
||||
const bySourceMap = new Map<
|
||||
string,
|
||||
Array<{ quota: number; created_at: Date }>
|
||||
>();
|
||||
for (const x of rows) {
|
||||
const key = x.billing_source ?? "unknown";
|
||||
const list = bySourceMap.get(key) ?? [];
|
||||
list.push({ quota: x.quota, created_at: x.created_at });
|
||||
bySourceMap.set(key, list);
|
||||
}
|
||||
const by_source = [...bySourceMap.entries()].map(([billing_source, list]) => ({
|
||||
billing_source,
|
||||
...sumUsage(list, timeline),
|
||||
}));
|
||||
|
||||
// 按 key / 成员(含未归属)
|
||||
const keyMap = new Map<
|
||||
number,
|
||||
{
|
||||
token_name: string;
|
||||
member_id: number | null;
|
||||
member_name: string | null;
|
||||
status: string;
|
||||
log_synced_at: Date | null;
|
||||
rows: Array<{ quota: number; created_at: Date }>;
|
||||
}
|
||||
>();
|
||||
const keyMeta = await repos.keysExtra.listIdsWithCutoff();
|
||||
const memberNameById = new Map(
|
||||
(await repos.members.listAll()).map((m) => [m.id, m.name]),
|
||||
);
|
||||
for (const k of keyMeta) {
|
||||
keyMap.set(k.id, {
|
||||
token_name: k.token_name,
|
||||
member_id: k.member_id,
|
||||
member_name:
|
||||
k.member_id == null ? null : (memberNameById.get(k.member_id) ?? null),
|
||||
status: k.status,
|
||||
log_synced_at: k.log_synced_at,
|
||||
rows: [],
|
||||
});
|
||||
}
|
||||
for (const x of rows) {
|
||||
const entry = keyMap.get(x.api_key_id);
|
||||
if (entry) {
|
||||
entry.rows.push({ quota: x.quota, created_at: x.created_at });
|
||||
if (x.member_name) entry.member_name = x.member_name;
|
||||
}
|
||||
}
|
||||
|
||||
const by_key = [...keyMap.entries()]
|
||||
.map(([key_id, v]) => ({
|
||||
key_id,
|
||||
token_name: v.token_name,
|
||||
member_id: v.member_id,
|
||||
member_name: v.member_name,
|
||||
status: v.status,
|
||||
data_cutoff: toUnixSec(v.log_synced_at),
|
||||
usage: sumUsage(v.rows, timeline),
|
||||
}))
|
||||
.sort((a, b) => b.usage.quota - a.usage.quota);
|
||||
|
||||
const memberBuckets = new Map<
|
||||
string,
|
||||
{
|
||||
member_id: number | null;
|
||||
member_name: string | null;
|
||||
key_count: number;
|
||||
cutoff: Date | null;
|
||||
rows: Array<{ quota: number; created_at: Date }>;
|
||||
}
|
||||
>();
|
||||
for (const [key_id, v] of keyMap) {
|
||||
const mid = v.member_id == null ? "null" : String(v.member_id);
|
||||
const b = memberBuckets.get(mid) ?? {
|
||||
member_id: v.member_id,
|
||||
member_name: v.member_name,
|
||||
key_count: 0,
|
||||
cutoff: null,
|
||||
rows: [],
|
||||
};
|
||||
b.key_count += 1;
|
||||
if (
|
||||
v.status === "active" &&
|
||||
v.log_synced_at &&
|
||||
(b.cutoff === null || v.log_synced_at < b.cutoff)
|
||||
) {
|
||||
b.cutoff = v.log_synced_at;
|
||||
}
|
||||
b.rows.push(...v.rows);
|
||||
if (!b.member_name && v.member_name) b.member_name = v.member_name;
|
||||
memberBuckets.set(mid, b);
|
||||
}
|
||||
|
||||
const by_member = [...memberBuckets.values()].map((b) => ({
|
||||
member_id: b.member_id,
|
||||
member_name: b.member_name,
|
||||
key_count: b.key_count,
|
||||
data_cutoff: toUnixSec(b.cutoff),
|
||||
usage: sumUsage(b.rows, timeline),
|
||||
}));
|
||||
|
||||
return c.json(
|
||||
{
|
||||
data_cutoff: toUnixSec(data_cutoff),
|
||||
unassigned_subscription,
|
||||
by_source,
|
||||
by_member,
|
||||
by_key,
|
||||
},
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/logs",
|
||||
summary: "日志明细(下钻)",
|
||||
description:
|
||||
"分页;from/to 必填;默认全类型(可 type 筛);token_name 取当前名",
|
||||
tags: ["用量"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
query: timeRange.extend({
|
||||
page: z.string().optional(),
|
||||
page_size: z.string().optional(),
|
||||
api_key_id: z.string().optional(),
|
||||
member_id: z.string().optional(),
|
||||
billing_source: z.string().optional(),
|
||||
subscription_id: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "items/total",
|
||||
content: { "application/json": { schema: logPageSchema } },
|
||||
},
|
||||
401: err401,
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const q = c.req.query();
|
||||
const range = parseRange(q);
|
||||
if ("fields" in range) {
|
||||
return jsonError(c, 422, ERR.validation_failed(range.fields));
|
||||
}
|
||||
const page = Math.max(1, Number(q.page ?? 1) || 1);
|
||||
const page_size = Math.min(200, Math.max(1, Number(q.page_size ?? 50) || 50));
|
||||
const repos = c.get("repos");
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
const { rows, total } = await repos.logs.listPage({
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
page,
|
||||
page_size,
|
||||
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
|
||||
member_id:
|
||||
q.member_id === "unassigned"
|
||||
? "unassigned"
|
||||
: q.member_id
|
||||
? Number(q.member_id)
|
||||
: undefined,
|
||||
billing_source:
|
||||
q.billing_source === "none" ? "none" : q.billing_source || undefined,
|
||||
subscription_id: q.subscription_id
|
||||
? Number(q.subscription_id)
|
||||
: undefined,
|
||||
type:
|
||||
q.type === "error"
|
||||
? "error"
|
||||
: q.type != null && q.type !== ""
|
||||
? Number(q.type)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const items: LogItem[] = rows.map((row) => {
|
||||
const { quota_usd } = usageRowPrecise(
|
||||
row.quota,
|
||||
row.created_at,
|
||||
timeline,
|
||||
);
|
||||
return {
|
||||
id: row.id,
|
||||
api_key_id: row.api_key_id,
|
||||
created_at: toUnixSec(row.created_at)!,
|
||||
token_name: row.token_name,
|
||||
model_name: row.model_name,
|
||||
type: row.type,
|
||||
content: row.content,
|
||||
quota: row.quota,
|
||||
quota_usd,
|
||||
prompt_tokens: row.prompt_tokens,
|
||||
completion_tokens: row.completion_tokens,
|
||||
use_time: row.use_time,
|
||||
is_stream: row.is_stream,
|
||||
billing_source: row.billing_source as LogItem["billing_source"],
|
||||
subscription_id: row.subscription_id,
|
||||
raw: {
|
||||
id: row.id,
|
||||
api_key_id: row.api_key_id,
|
||||
dedupe_key: row.dedupe_key,
|
||||
billing_source: row.billing_source,
|
||||
subscription_id: row.subscription_id,
|
||||
user_id: row.user_id,
|
||||
created_at: toUnixSec(row.created_at),
|
||||
type: row.type,
|
||||
content: row.content,
|
||||
username: row.username,
|
||||
token_name: row.token_name,
|
||||
model_name: row.model_name,
|
||||
quota: row.quota,
|
||||
prompt_tokens: row.prompt_tokens,
|
||||
completion_tokens: row.completion_tokens,
|
||||
use_time: row.use_time,
|
||||
is_stream: row.is_stream,
|
||||
channel: row.channel,
|
||||
channel_name: row.channel_name,
|
||||
token_id: row.token_id,
|
||||
group_name: row.group_name,
|
||||
ip: row.ip,
|
||||
request_id: row.request_id,
|
||||
upstream_request_id: row.upstream_request_id,
|
||||
other: row.other,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return c.json({ items, total }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
||||
import {
|
||||
ERR,
|
||||
resolveQpuTimeline,
|
||||
sumUsage,
|
||||
usageRowPrecise,
|
||||
toUnixSec,
|
||||
type LogItem,
|
||||
} from "@new-api-key-usage/core";
|
||||
import {
|
||||
jsonError,
|
||||
requireMemberToken,
|
||||
type AppVars,
|
||||
} from "../middleware.ts";
|
||||
import {
|
||||
err401,
|
||||
err422,
|
||||
logPageSchema,
|
||||
memberMeSchema,
|
||||
memberOverviewSchema,
|
||||
} from "../api-schemas.ts";
|
||||
import { z } from "zod";
|
||||
|
||||
function parseRange(
|
||||
query: { from?: string; to?: string },
|
||||
): { from: Date; to: Date } | { fields: Record<string, string> } {
|
||||
const fields: Record<string, string> = {};
|
||||
if (!query.from) fields.from = "缺少起始时间";
|
||||
if (!query.to) fields.to = "缺少结束时间";
|
||||
if (Object.keys(fields).length > 0) return { fields };
|
||||
const from = new Date(query.from!);
|
||||
const to = new Date(query.to!);
|
||||
if (Number.isNaN(from.getTime())) fields.from = "时间格式无效";
|
||||
if (Number.isNaN(to.getTime())) fields.to = "时间格式无效";
|
||||
else if (to.getTime() <= from.getTime()) fields.to = "结束时间必须晚于起始时间";
|
||||
if (Object.keys(fields).length > 0) return { fields };
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
/** 成员视图(`/api/member/*`,Bearer)——与管理面物理隔离 */
|
||||
export function memberViewRoutes() {
|
||||
const r = new OpenAPIHono<{ Variables: AppVars }>();
|
||||
r.use("*", requireMemberToken);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/me",
|
||||
summary: "令牌验证 + 页头",
|
||||
description: "不建会话;令牌即长期凭证",
|
||||
tags: ["成员视图"],
|
||||
security: [{ memberToken: [] }],
|
||||
responses: {
|
||||
200: {
|
||||
description: "member",
|
||||
content: { "application/json": { schema: memberMeSchema } },
|
||||
},
|
||||
401: err401,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const row = await c.get("repos").members.findById(c.get("memberId")!);
|
||||
if (!row) return jsonError(c, 401, ERR.session_expired());
|
||||
return c.json({ id: row.id, name: row.name }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/overview",
|
||||
summary: "单页聚合",
|
||||
description:
|
||||
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(完全公开透明)",
|
||||
tags: ["成员视图"],
|
||||
security: [{ memberToken: [] }],
|
||||
request: {
|
||||
query: z.object({ from: z.string(), to: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "overview",
|
||||
content: { "application/json": { schema: memberOverviewSchema } },
|
||||
},
|
||||
401: err401,
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const range = parseRange(c.req.query());
|
||||
if ("fields" in range) {
|
||||
return jsonError(c, 422, ERR.validation_failed(range.fields));
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
const memberId = c.get("memberId")!;
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
const member = await repos.members.findById(memberId);
|
||||
const rows = await repos.logs.listMemberForAggregate(
|
||||
memberId,
|
||||
range.from,
|
||||
range.to,
|
||||
);
|
||||
|
||||
const usage = sumUsage(
|
||||
rows.map((x) => ({ quota: x.quota, created_at: x.created_at })),
|
||||
timeline,
|
||||
);
|
||||
|
||||
const sourceMap = new Map<
|
||||
string,
|
||||
Array<{ quota: number; created_at: Date }>
|
||||
>();
|
||||
const keyMap = new Map<
|
||||
number,
|
||||
{
|
||||
token_name: string;
|
||||
log_synced_at: Date | null;
|
||||
rows: Array<{ quota: number; created_at: Date }>;
|
||||
}
|
||||
>();
|
||||
for (const x of rows) {
|
||||
const skey = x.billing_source ?? "unknown";
|
||||
const s = sourceMap.get(skey) ?? [];
|
||||
s.push({ quota: x.quota, created_at: x.created_at });
|
||||
sourceMap.set(skey, s);
|
||||
|
||||
const k = keyMap.get(x.api_key_id) ?? {
|
||||
token_name: x.token_name,
|
||||
log_synced_at: x.log_synced_at,
|
||||
rows: [],
|
||||
};
|
||||
k.rows.push({ quota: x.quota, created_at: x.created_at });
|
||||
keyMap.set(x.api_key_id, k);
|
||||
}
|
||||
|
||||
// 名下 key 全量(即使时段无用量)
|
||||
const allKeys = (await repos.keys.listAll()).filter(
|
||||
(k) => k.member_id === memberId,
|
||||
);
|
||||
for (const k of allKeys) {
|
||||
if (!keyMap.has(k.id)) {
|
||||
keyMap.set(k.id, {
|
||||
token_name: k.token_name,
|
||||
log_synced_at: k.log_synced_at,
|
||||
rows: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const by_source = [...sourceMap.entries()].map(([billing_source, list]) => ({
|
||||
billing_source,
|
||||
...sumUsage(list, timeline),
|
||||
}));
|
||||
|
||||
const keys = [...keyMap.entries()].map(([key_id, v]) => ({
|
||||
key_id,
|
||||
token_name: v.token_name,
|
||||
log_synced_at: toUnixSec(v.log_synced_at),
|
||||
usage: sumUsage(v.rows, timeline),
|
||||
}));
|
||||
|
||||
// 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to)
|
||||
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
|
||||
const subRows = await repos.subscriptions.listAll();
|
||||
const usedSubIds = new Set(
|
||||
rows.map((x) => x.subscription_id).filter((v): v is number => v != null),
|
||||
);
|
||||
const subscriptionsOut = [];
|
||||
for (const s of subRows) {
|
||||
const ongoing =
|
||||
s.period_end == null ||
|
||||
s.period_end > new Date() ||
|
||||
(s.period_start <= range.to &&
|
||||
(s.period_end ?? new Date()) >= range.from);
|
||||
const involved = usedSubIds.has(s.subscription_id) || ongoing;
|
||||
if (!involved) continue;
|
||||
const qpu = qpuAt(s.paid_at ?? s.created_at, timeline);
|
||||
subscriptionsOut.push({
|
||||
id: s.id,
|
||||
subscription_id: s.subscription_id,
|
||||
period_start: toUnixSec(s.period_start)!,
|
||||
period_end: toUnixSec(s.period_end),
|
||||
quota_granted: s.quota_granted,
|
||||
quota_usd: quotaToUsdStr(s.quota_granted, qpu),
|
||||
paid_amount: s.paid_amount,
|
||||
paid_at: toUnixSec(s.paid_at),
|
||||
remark: s.remark,
|
||||
created_at: toUnixSec(s.created_at)!,
|
||||
});
|
||||
}
|
||||
const topupRows = await repos.topups.listAll();
|
||||
const topupsOut = [];
|
||||
for (const t of topupRows) {
|
||||
if (!(t.paid_at >= range.from && t.paid_at < range.to)) continue;
|
||||
const qpu = qpuAt(t.paid_at, timeline);
|
||||
const quota_derived = Math.round(t.amount * qpu);
|
||||
topupsOut.push({
|
||||
id: t.id,
|
||||
upstream_id: t.upstream_id,
|
||||
amount: t.amount,
|
||||
money: t.money,
|
||||
trade_no: t.trade_no,
|
||||
payment_method: t.payment_method,
|
||||
payment_provider: t.payment_provider,
|
||||
status: t.status,
|
||||
paid_at: toUnixSec(t.paid_at)!,
|
||||
quota_derived,
|
||||
quota_usd: quotaToUsdStr(quota_derived, qpu),
|
||||
remark: t.remark,
|
||||
created_at: toUnixSec(t.created_at)!,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
member: { id: memberId, name: member?.name ?? null },
|
||||
usage,
|
||||
by_source,
|
||||
keys,
|
||||
subscriptions: subscriptionsOut,
|
||||
topups: topupsOut,
|
||||
},
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/logs",
|
||||
summary: "日志明细(名下 key)",
|
||||
description: "分页;默认全类型(可 type 筛);DTO 与管理面同构",
|
||||
tags: ["成员视图"],
|
||||
security: [{ memberToken: [] }],
|
||||
request: {
|
||||
query: z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
page: z.string().optional(),
|
||||
page_size: z.string().optional(),
|
||||
api_key_id: z.string().optional(),
|
||||
billing_source: z.string().optional(),
|
||||
subscription_id: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "items/total",
|
||||
content: { "application/json": { schema: logPageSchema } },
|
||||
},
|
||||
401: err401,
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const q = c.req.query();
|
||||
const range = parseRange(q);
|
||||
if ("fields" in range) {
|
||||
return jsonError(c, 422, ERR.validation_failed(range.fields));
|
||||
}
|
||||
const page = Math.max(1, Number(q.page ?? 1) || 1);
|
||||
const page_size = Math.min(
|
||||
200,
|
||||
Math.max(1, Number(q.page_size ?? 50) || 50),
|
||||
);
|
||||
const repos = c.get("repos");
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
const { rows, total } = await repos.logs.listMemberPage({
|
||||
member_id: c.get("memberId")!,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
page,
|
||||
page_size,
|
||||
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
|
||||
billing_source:
|
||||
q.billing_source === "none" ? "none" : q.billing_source || undefined,
|
||||
subscription_id: q.subscription_id
|
||||
? Number(q.subscription_id)
|
||||
: undefined,
|
||||
type:
|
||||
q.type === "error"
|
||||
? "error"
|
||||
: q.type != null && q.type !== ""
|
||||
? Number(q.type)
|
||||
: undefined,
|
||||
});
|
||||
const items: LogItem[] = rows.map((row) => {
|
||||
const { quota_usd } = usageRowPrecise(
|
||||
row.quota,
|
||||
row.created_at,
|
||||
timeline,
|
||||
);
|
||||
return {
|
||||
id: row.id,
|
||||
api_key_id: row.api_key_id,
|
||||
created_at: toUnixSec(row.created_at)!,
|
||||
token_name: row.token_name ?? "",
|
||||
model_name: row.model_name,
|
||||
type: row.type,
|
||||
content: row.content,
|
||||
quota: row.quota,
|
||||
quota_usd,
|
||||
prompt_tokens: row.prompt_tokens,
|
||||
completion_tokens: row.completion_tokens,
|
||||
use_time: row.use_time,
|
||||
is_stream: row.is_stream,
|
||||
billing_source: row.billing_source as LogItem["billing_source"],
|
||||
subscription_id: row.subscription_id,
|
||||
raw: {
|
||||
id: row.id,
|
||||
api_key_id: row.api_key_id,
|
||||
dedupe_key: row.dedupe_key,
|
||||
billing_source: row.billing_source,
|
||||
subscription_id: row.subscription_id,
|
||||
user_id: row.user_id,
|
||||
created_at: toUnixSec(row.created_at),
|
||||
type: row.type,
|
||||
content: row.content,
|
||||
username: row.username,
|
||||
token_name: row.token_name,
|
||||
model_name: row.model_name,
|
||||
quota: row.quota,
|
||||
prompt_tokens: row.prompt_tokens,
|
||||
completion_tokens: row.completion_tokens,
|
||||
use_time: row.use_time,
|
||||
is_stream: row.is_stream,
|
||||
channel: row.channel,
|
||||
channel_name: row.channel_name,
|
||||
token_id: row.token_id,
|
||||
group_name: row.group_name,
|
||||
ip: row.ip,
|
||||
request_id: row.request_id,
|
||||
upstream_request_id: row.upstream_request_id,
|
||||
other: row.other,
|
||||
},
|
||||
};
|
||||
});
|
||||
return c.json({ items, total }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun"],
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { adminHttp } from "../http.ts";
|
||||
|
||||
export type PlatformSubscription = {
|
||||
id: number;
|
||||
plan_id: number;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
status: string;
|
||||
amount_total: number;
|
||||
amount_used: number;
|
||||
};
|
||||
|
||||
export type PlatformTopup = {
|
||||
id: number;
|
||||
amount: number;
|
||||
money: number;
|
||||
trade_no?: string;
|
||||
payment_method?: string;
|
||||
payment_provider?: string;
|
||||
create_time?: number;
|
||||
complete_time?: number;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type PlatformLoginResult = { access_token: string };
|
||||
|
||||
/** 登录控制台(密码不落库);token 由调用方短暂保存 */
|
||||
export async function loginPlatform(body: {
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<PlatformLoginResult> {
|
||||
const { data } = await adminHttp.post<PlatformLoginResult>(
|
||||
"/platform/login",
|
||||
body,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listPlatformSubscriptions(
|
||||
accessToken: string,
|
||||
): Promise<PlatformSubscription[]> {
|
||||
const { data } = await adminHttp.post<{ subscriptions: PlatformSubscription[] }>(
|
||||
"/platform/subscriptions",
|
||||
{ access_token: accessToken },
|
||||
);
|
||||
return data.subscriptions;
|
||||
}
|
||||
|
||||
export async function listPlatformTopups(
|
||||
accessToken: string,
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
): Promise<{
|
||||
items: PlatformTopup[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}> {
|
||||
const { data } = await adminHttp.post<{
|
||||
items: PlatformTopup[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}>("/platform/topups", {
|
||||
access_token: accessToken,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { SubscriptionItem } from "@new-api-key-usage/core";
|
||||
import { adminHttp } from "../http.ts";
|
||||
|
||||
export type SubscriptionBody = {
|
||||
subscription_id: number;
|
||||
period_start: string;
|
||||
period_end?: string | null;
|
||||
quota_granted: number;
|
||||
paid_amount?: number | null;
|
||||
paid_at?: string | null;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
export async function fetchSubscriptions(): Promise<SubscriptionItem[]> {
|
||||
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createSubscription(
|
||||
body: SubscriptionBody,
|
||||
): Promise<SubscriptionItem> {
|
||||
const { data } = await adminHttp.post<SubscriptionItem>("/subscriptions", body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateSubscription(
|
||||
id: number,
|
||||
body: SubscriptionBody,
|
||||
): Promise<SubscriptionItem> {
|
||||
const { data } = await adminHttp.put<SubscriptionItem>(
|
||||
`/subscriptions/${id}`,
|
||||
body,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteSubscription(id: number): Promise<void> {
|
||||
await adminHttp.delete(`/subscriptions/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TopupItem } from "@new-api-key-usage/core";
|
||||
import { adminHttp } from "../http.ts";
|
||||
|
||||
export type TopupBody = {
|
||||
upstream_id?: number | null;
|
||||
amount: number;
|
||||
money: number;
|
||||
trade_no?: string | null;
|
||||
payment_method?: string | null;
|
||||
payment_provider?: string | null;
|
||||
status?: string | null;
|
||||
paid_at: string;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
export async function fetchTopups(): Promise<TopupItem[]> {
|
||||
const { data } = await adminHttp.get<TopupItem[]>("/topups");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createTopup(body: TopupBody): Promise<TopupItem> {
|
||||
const { data } = await adminHttp.post<TopupItem>("/topups", body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateTopup(
|
||||
id: number,
|
||||
body: TopupBody,
|
||||
): Promise<TopupItem> {
|
||||
const { data } = await adminHttp.put<TopupItem>(`/topups/${id}`, body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteTopup(id: number): Promise<void> {
|
||||
await adminHttp.delete(`/topups/${id}`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import axios, {
|
||||
AxiosError,
|
||||
type AxiosInstance,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
import type { ErrorEnvelope } from "@new-api-key-usage/core";
|
||||
import { redirectAuth, type AuthWorld } from "./auth-redirect.ts";
|
||||
|
||||
const MEMBER_TOKEN_KEY = "member_token";
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly fields: Record<string, string> | undefined;
|
||||
readonly keyId: number | undefined;
|
||||
|
||||
constructor(
|
||||
status: number,
|
||||
code: string,
|
||||
message: string,
|
||||
extra?: { fields?: Record<string, string>; key_id?: number },
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.fields = extra?.fields;
|
||||
this.keyId = extra?.key_id;
|
||||
}
|
||||
}
|
||||
|
||||
function parseErrorEnvelope(data: unknown): ErrorEnvelope | null {
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const env = data as Partial<ErrorEnvelope>;
|
||||
if (!env.error || typeof env.error !== "object") return null;
|
||||
const { code, message } = env.error;
|
||||
if (typeof code !== "string" || typeof message !== "string") return null;
|
||||
return env as ErrorEnvelope;
|
||||
}
|
||||
|
||||
function toApiError(error: AxiosError, world: AuthWorld): ApiError {
|
||||
const envelope = parseErrorEnvelope(error.response?.data);
|
||||
if (envelope) {
|
||||
return new ApiError(
|
||||
error.response?.status ?? 0,
|
||||
envelope.error.code,
|
||||
envelope.error.message,
|
||||
{
|
||||
fields: envelope.error.fields,
|
||||
key_id: envelope.error.key_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
const status = error.response?.status ?? 0;
|
||||
if (status === 401) {
|
||||
return new ApiError(401, "unauthorized", "凭证无效或已过期");
|
||||
}
|
||||
if (status === 0 || !error.response) {
|
||||
return new ApiError(0, "network", "网络异常,请稍后重试");
|
||||
}
|
||||
// 中间人非 JSON 错误体(代理 502 页等)回退通用文案
|
||||
return new ApiError(status, "internal", "服务暂时不可用,请稍后重试");
|
||||
}
|
||||
|
||||
function attachInterceptors(client: AxiosInstance, world: AuthWorld): void {
|
||||
if (world === "member") {
|
||||
client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = sessionStorage.getItem(MEMBER_TOKEN_KEY);
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
const apiError = toApiError(error, world);
|
||||
// 401 仅表示「本世界凭证失效」;上游平台 token 失效是 422 platform_token_invalid
|
||||
if (
|
||||
apiError.status === 401 &&
|
||||
apiError.code !== "platform_token_invalid"
|
||||
) {
|
||||
redirectAuth(world, "expired");
|
||||
}
|
||||
return Promise.reject(apiError);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createClient(world: AuthWorld, baseURL: string): AxiosInstance {
|
||||
const client = axios.create({
|
||||
baseURL,
|
||||
timeout: 30_000,
|
||||
// 管理面会话 cookie 需跨端口(Vite 5173 → API 18456)携带
|
||||
withCredentials: true,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
attachInterceptors(client, world);
|
||||
return client;
|
||||
}
|
||||
|
||||
/** 管理面:`/api/admin/*`,会话 cookie */
|
||||
export const adminHttp = createClient("admin", "/api/admin");
|
||||
|
||||
/** 成员视图:`/api/member/*`,Bearer 令牌 */
|
||||
export const memberHttp = createClient("member", "/api/member");
|
||||
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
LogItem,
|
||||
Paginated,
|
||||
SubscriptionItem,
|
||||
TopupItem,
|
||||
UnixSec,
|
||||
UsageTotals,
|
||||
} from "@new-api-key-usage/core";
|
||||
import { memberHttp } from "../http.ts";
|
||||
|
||||
export type MemberMe = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type MemberOverview = {
|
||||
member: { id: number | null; name: string | null };
|
||||
usage: UsageTotals;
|
||||
by_source: Array<{
|
||||
billing_source: string;
|
||||
count: number;
|
||||
quota: number;
|
||||
quota_usd: string;
|
||||
}>;
|
||||
keys: Array<{
|
||||
key_id: number;
|
||||
token_name: string;
|
||||
log_synced_at: UnixSec | null;
|
||||
usage: UsageTotals;
|
||||
}>;
|
||||
subscriptions: SubscriptionItem[];
|
||||
topups: TopupItem[];
|
||||
};
|
||||
|
||||
export async function fetchMe(): Promise<MemberMe> {
|
||||
const { data } = await memberHttp.get<MemberMe>("/me");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchOverview(range: {
|
||||
from: string;
|
||||
to: string;
|
||||
}): Promise<MemberOverview> {
|
||||
const { data } = await memberHttp.get<MemberOverview>("/overview", {
|
||||
params: range,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export type MemberLogQuery = {
|
||||
from: string;
|
||||
to: string;
|
||||
page: number;
|
||||
page_size: number;
|
||||
api_key_id?: number;
|
||||
billing_source?: string;
|
||||
subscription_id?: number;
|
||||
type?: number | "error";
|
||||
};
|
||||
|
||||
export async function fetchLogs(query: MemberLogQuery): Promise<Paginated<LogItem>> {
|
||||
const { data } = await memberHttp.get<Paginated<LogItem>>("/logs", {
|
||||
params: query,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* vue-query key 工厂——失效与缓存的唯一命名源。
|
||||
* 约定:`[面, 端点, …参数]`,参数保持可 JSON 序列化。
|
||||
*/
|
||||
export type KeyFilters = {
|
||||
member_id?: number | "unassigned";
|
||||
status?: "active" | "disabled";
|
||||
recent_failed?: boolean;
|
||||
};
|
||||
|
||||
export type PageQuery = {
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
|
||||
export type TimeRange = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
export const qk = {
|
||||
admin: {
|
||||
setupState: () => ["admin", "setup-state"] as const,
|
||||
session: () => ["admin", "session"] as const,
|
||||
engine: () => ["admin", "engine"] as const,
|
||||
overview: (range: TimeRange) =>
|
||||
["admin", "overview", range.from, range.to] as const,
|
||||
keys: (filters: KeyFilters) => ["admin", "keys", filters] as const,
|
||||
logs: (range: TimeRange & PageQuery & Record<string, unknown>) =>
|
||||
[
|
||||
"admin",
|
||||
"logs",
|
||||
range.from,
|
||||
range.to,
|
||||
range.page,
|
||||
range.page_size,
|
||||
range.api_key_id ?? "",
|
||||
range.member_id ?? "",
|
||||
range.billing_source ?? "",
|
||||
range.subscription_id ?? "",
|
||||
range.type ?? "",
|
||||
] as const,
|
||||
subscriptions: () => ["admin", "subscriptions"] as const,
|
||||
topups: () => ["admin", "topups"] as const,
|
||||
qpuLatest: () => ["admin", "qpu-latest"] as const,
|
||||
members: () => ["admin", "members"] as const,
|
||||
auditLogs: (q: PageQuery & { domain?: string; from?: string; to?: string }) =>
|
||||
["admin", "audit-logs", q.page, q.page_size, q.domain ?? "", q.from ?? "", q.to ?? ""] as const,
|
||||
},
|
||||
member: {
|
||||
me: () => ["member", "me"] as const,
|
||||
overview: (range: TimeRange) =>
|
||||
["member", "overview", range.from, range.to] as const,
|
||||
logs: (range: TimeRange & PageQuery & Record<string, unknown>) =>
|
||||
[
|
||||
"member",
|
||||
"logs",
|
||||
range.from,
|
||||
range.to,
|
||||
range.page,
|
||||
range.page_size,
|
||||
range.api_key_id ?? "",
|
||||
range.billing_source ?? "",
|
||||
range.subscription_id ?? "",
|
||||
range.type ?? "",
|
||||
] as const,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
// 路由出口 + 全局中文(DatePicker 等默认 en-US,须用 ConfigProvider)
|
||||
import zhCN from "antdv-next/locale/zh_CN";
|
||||
import dayjs from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
|
||||
dayjs.locale("zh-cn");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :locale="zhCN">
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
-apple-system,
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 健康带(交互-全员总览 §1.1):引擎轮次 · 全池数据截止 · 未关联订阅
|
||||
* 文案偏完整说明,不追求极简电报体。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import type { EngineStatus } from "@new-api-key-usage/core";
|
||||
import type { AdminOverview } from "@/api/admin/overview.ts";
|
||||
import { formatMinute, hoursSince } from "@/utils/time.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
engine: EngineStatus | undefined;
|
||||
overview: AdminOverview | undefined;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "go-failed-keys"): void;
|
||||
}>();
|
||||
|
||||
const lastRound = computed(() => props.engine?.last_round ?? null);
|
||||
const failedCount = computed(() => lastRound.value?.failed ?? 0);
|
||||
const cutoff = computed(() => props.overview?.data_cutoff ?? null);
|
||||
const cutoffHours = computed(() => hoursSince(cutoff.value));
|
||||
const cutoffStale = computed(
|
||||
() => cutoffHours.value != null && cutoffHours.value > 2,
|
||||
);
|
||||
const unassigned = computed(
|
||||
() => props.overview?.unassigned_subscription ?? null,
|
||||
);
|
||||
const unassignedBad = computed(
|
||||
() => (unassigned.value?.count ?? 0) > 0 || (unassigned.value?.quota ?? 0) > 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="health-band">
|
||||
<a-skeleton v-if="loading" active :paragraph="{ rows: 1 }" :title="false" />
|
||||
<template v-else>
|
||||
<div class="health-band__item">
|
||||
<div class="health-band__title">同步引擎</div>
|
||||
<div class="health-band__body">
|
||||
<template v-if="lastRound">
|
||||
最近一次同步轮次共处理
|
||||
<strong>{{ lastRound.keys_total }}</strong>
|
||||
枚 key,其中成功
|
||||
<strong>{{ lastRound.succeeded }}</strong>
|
||||
枚。
|
||||
<template v-if="failedCount > 0">
|
||||
有
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
class="health-band__fail-btn"
|
||||
@click="emit('go-failed-keys')"
|
||||
>
|
||||
{{ failedCount }} 枚拉取失败
|
||||
</a-button>
|
||||
,可到 Key 管理筛选「最近轮次失败」查看是哪几枚。
|
||||
</template>
|
||||
<template v-else>没有拉取失败。</template>
|
||||
</template>
|
||||
<span v-else class="health-band__muted">
|
||||
引擎尚未完成过同步轮次,或引擎进程未启动——此时用量数据可能还不完整。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="health-band__item">
|
||||
<div class="health-band__title">数据截止</div>
|
||||
<div class="health-band__body">
|
||||
<a-tooltip
|
||||
title="取全部 key 的 log_synced_at 中最慢的那一个;某枚 key 同步失败会一直不写该字段,从而拖住全池截止"
|
||||
>
|
||||
<span>全池同步截止于 <strong>{{ formatMinute(cutoff) }}</strong>(北京时间)。</span>
|
||||
</a-tooltip>
|
||||
<template v-if="cutoffStale">
|
||||
<span v-if="failedCount > 0" class="health-band__warn">
|
||||
截止时间落后超过 2 小时——通常是
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
class="health-band__fail-btn"
|
||||
@click="emit('go-failed-keys')"
|
||||
>
|
||||
同步失败的 key
|
||||
</a-button>
|
||||
一直未成功、拖住了全池最慢值;请到 Key 管理处理(必要时停用)。
|
||||
</span>
|
||||
<span v-else class="health-band__warn">
|
||||
截止时间落后超过 2 小时,而最近轮次没有失败 key——同步链路可能停滞,请检查引擎日志。
|
||||
</span>
|
||||
</template>
|
||||
<span v-else-if="cutoff != null" class="health-band__ok">
|
||||
目前在正常滞后范围内。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="health-band__item">
|
||||
<div class="health-band__title">未关联订阅</div>
|
||||
<div class="health-band__body">
|
||||
<a-tooltip
|
||||
title="统计的是「来源为订阅、但日志上没有订阅编号」的用量;正常情况下应始终为 0"
|
||||
>
|
||||
<template v-if="unassignedBad">
|
||||
<span class="health-band__danger">
|
||||
本时段发现
|
||||
{{ unassigned?.count }} 条 / ${{ unassigned?.quota_usd }}
|
||||
订阅来源用量未关联到任何订阅编号
|
||||
</span>
|
||||
(说明支付记录缺登记,或上游计费行为发生了变化),需要人工核对。
|
||||
</template>
|
||||
<template v-else>
|
||||
本时段没有出现「订阅来源却关联不到订阅编号」的用量,属正常状态。
|
||||
</template>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.health-band {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.health-band__item {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.health-band__title {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.health-band__body {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.health-band__body strong {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.health-band__muted {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.health-band__ok {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.health-band__warn {
|
||||
color: #d48806;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.health-band__danger {
|
||||
color: #cf1322;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.health-band__fail-btn {
|
||||
padding: 0 4px;
|
||||
height: auto;
|
||||
line-height: inherit;
|
||||
color: #cf1322;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 令牌明文对话框:仅签发/重发后出现一次(交互-成员管理 §3)
|
||||
*/
|
||||
import { ref, watch } from "vue";
|
||||
import { message } from "antdv-next";
|
||||
import { CopyOutlined, WarningOutlined } from "@antdv-next/icons";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
token: string;
|
||||
memberName?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:open", v: boolean): void;
|
||||
}>();
|
||||
|
||||
const confirmClose = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) {
|
||||
confirmClose.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function onCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.token);
|
||||
message.success("已复制");
|
||||
} catch {
|
||||
message.error("复制失败,请手动选中复制");
|
||||
}
|
||||
}
|
||||
|
||||
function tryClose() {
|
||||
if (!confirmClose.value) {
|
||||
confirmClose.value = true;
|
||||
return;
|
||||
}
|
||||
emit("update:open", false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="memberName ? `访问令牌 · ${memberName}` : '访问令牌'"
|
||||
:mask-closable="false"
|
||||
:closable="false"
|
||||
:keyboard="false"
|
||||
:footer="null"
|
||||
>
|
||||
<a-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
message="关闭后将无法再次查看——请立即复制并通过你信任的渠道发给成员"
|
||||
/>
|
||||
<div class="token-plain">
|
||||
<code class="token-plain__text">{{ token }}</code>
|
||||
<a-button @click="onCopy">
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="token-plain__footer">
|
||||
<template v-if="!confirmClose">
|
||||
<a-button @click="tryClose">关闭</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="token-plain__confirm">
|
||||
<WarningOutlined style="color: #faad14" />
|
||||
确认已保存并发送?
|
||||
</span>
|
||||
<a-space>
|
||||
<a-button @click="confirmClose = false">再看看</a-button>
|
||||
<a-button type="primary" @click="tryClose">我已保存并发送</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.token-plain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.token-plain__text {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.token-plain__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.token-plain__confirm {
|
||||
margin-right: auto;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,461 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 支付登记弹窗:订阅 / 充值分形态(拆表后)
|
||||
* 时间控件统一精确到秒(show-time)
|
||||
*/
|
||||
import type { Dayjs } from "dayjs";
|
||||
import dayjs from "dayjs";
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { message } from "antdv-next";
|
||||
import type { SubscriptionItem, TopupItem } from "@new-api-key-usage/core";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import PlatformSubscriptionPicker from "@/components/admin/platform-subscription-picker.vue";
|
||||
import PlatformTopupPicker from "@/components/admin/platform-topup-picker.vue";
|
||||
import type {
|
||||
PlatformSubscription,
|
||||
PlatformTopup,
|
||||
} from "@/api/admin/platform.ts";
|
||||
import {
|
||||
useCreateSubscription,
|
||||
useCreateTopup,
|
||||
useQpuLatest,
|
||||
useSubscriptions,
|
||||
useTopups,
|
||||
useUpdateSubscription,
|
||||
useUpdateTopup,
|
||||
} from "@/queries/admin.ts";
|
||||
import { unixToBeijingWall, wallAsBeijingIso } from "@/utils/time.ts";
|
||||
|
||||
const TIME_FMT = "YYYY-MM-DD HH:mm:ss";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
tab: "subs" | "topups";
|
||||
editingSub?: SubscriptionItem | null;
|
||||
editingTop?: TopupItem | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ (e: "update:open", v: boolean): void }>();
|
||||
|
||||
const createSub = useCreateSubscription();
|
||||
const updateSub = useUpdateSubscription();
|
||||
const createTop = useCreateTopup();
|
||||
const updateTop = useUpdateTopup();
|
||||
const subsQuery = useSubscriptions();
|
||||
const topupsQuery = useTopups();
|
||||
const qpuQuery = useQpuLatest();
|
||||
const qpu = computed(() => qpuQuery.data.value?.qpu ?? null);
|
||||
|
||||
/** 额度双输入旁的换算预览(不入库,仅展示) */
|
||||
const quotaPreview = computed(() => {
|
||||
if (isSub.value) {
|
||||
if (subForm.quotaMode === "quota") {
|
||||
const q = subForm.quota_granted;
|
||||
if (q == null || !qpu.value) return null;
|
||||
return `≈ $${(q / qpu.value).toFixed(2)}(按当前换算率)`;
|
||||
}
|
||||
const u = subForm.quota_usd;
|
||||
if (u == null || !qpu.value) return null;
|
||||
return `≈ ${Math.round(u * qpu.value)} quota`;
|
||||
}
|
||||
const a = topForm.amount;
|
||||
if (a == null || !qpu.value) return null;
|
||||
return `≈ ${Math.round(a * qpu.value)} quota(按当前换算率)`;
|
||||
});
|
||||
|
||||
const pickerOpen = ref(false);
|
||||
const topPickerOpen = ref(false);
|
||||
const error = ref("");
|
||||
const isSub = ref(props.tab === "subs");
|
||||
|
||||
const registeredSubIds = computed(
|
||||
() => (subsQuery.data.value ?? []).map((s) => s.subscription_id),
|
||||
);
|
||||
const registeredTopIds = computed(
|
||||
() =>
|
||||
(topupsQuery.data.value ?? [])
|
||||
.map((t) => t.upstream_id)
|
||||
.filter((v): v is number => v != null),
|
||||
);
|
||||
|
||||
const subForm = reactive({
|
||||
subscription_id: null as number | null,
|
||||
period_start: null as Dayjs | null,
|
||||
period_end: null as Dayjs | null,
|
||||
quota_granted: null as number | null,
|
||||
quotaMode: "quota" as "quota" | "usd",
|
||||
quota_usd: null as number | null,
|
||||
paid_amount: null as number | null,
|
||||
paid_at: null as Dayjs | null,
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const topForm = reactive({
|
||||
upstream_id: null as number | null,
|
||||
amount: null as number | null,
|
||||
money: null as number | null,
|
||||
trade_no: "",
|
||||
payment_method: "",
|
||||
payment_provider: "",
|
||||
paid_at: null as Dayjs | null,
|
||||
remark: "",
|
||||
});
|
||||
|
||||
function resetSub() {
|
||||
Object.assign(subForm, {
|
||||
subscription_id: null,
|
||||
period_start: null,
|
||||
period_end: null,
|
||||
quota_granted: null,
|
||||
quotaMode: "quota",
|
||||
quota_usd: null,
|
||||
paid_amount: null,
|
||||
paid_at: null,
|
||||
remark: "",
|
||||
});
|
||||
}
|
||||
|
||||
function resetTop() {
|
||||
Object.assign(topForm, {
|
||||
upstream_id: null,
|
||||
amount: null,
|
||||
money: null,
|
||||
trade_no: "",
|
||||
payment_method: "",
|
||||
payment_provider: "",
|
||||
paid_at: null,
|
||||
remark: "",
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (!v) return;
|
||||
error.value = "";
|
||||
isSub.value = props.tab === "subs";
|
||||
if (props.editingSub) {
|
||||
isSub.value = true;
|
||||
const s = props.editingSub;
|
||||
subForm.subscription_id = s.subscription_id;
|
||||
subForm.period_start = unixToBeijingWall(s.period_start);
|
||||
subForm.period_end =
|
||||
s.period_end != null ? unixToBeijingWall(s.period_end) : null;
|
||||
subForm.quota_granted = s.quota_granted;
|
||||
subForm.quotaMode = "quota";
|
||||
subForm.quota_usd = null;
|
||||
subForm.paid_amount = s.paid_amount;
|
||||
subForm.paid_at =
|
||||
s.paid_at != null ? unixToBeijingWall(s.paid_at) : null;
|
||||
subForm.remark = s.remark;
|
||||
} else {
|
||||
// 订阅的 paid_at 是「套餐付款时刻」,可空——不默认填今天
|
||||
resetSub();
|
||||
}
|
||||
if (props.editingTop) {
|
||||
isSub.value = false;
|
||||
const t = props.editingTop;
|
||||
topForm.upstream_id = t.upstream_id;
|
||||
topForm.amount = t.amount;
|
||||
topForm.money = t.money;
|
||||
topForm.trade_no = t.trade_no ?? "";
|
||||
topForm.payment_method = t.payment_method ?? "";
|
||||
topForm.payment_provider = t.payment_provider ?? "";
|
||||
topForm.paid_at = unixToBeijingWall(t.paid_at);
|
||||
topForm.remark = t.remark;
|
||||
} else {
|
||||
resetTop();
|
||||
topForm.paid_at = dayjs();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function onPickSub(s: PlatformSubscription) {
|
||||
subForm.subscription_id = s.id;
|
||||
subForm.period_start = unixToBeijingWall(s.start_time);
|
||||
subForm.period_end =
|
||||
s.end_time != null ? unixToBeijingWall(s.end_time) : null;
|
||||
subForm.quota_granted = s.amount_total;
|
||||
subForm.quotaMode = "quota";
|
||||
}
|
||||
|
||||
function onPickTop(t: PlatformTopup & { paid_at_iso: string }) {
|
||||
topForm.upstream_id = t.id;
|
||||
topForm.amount = t.amount;
|
||||
topForm.money = t.money;
|
||||
topForm.trade_no = t.trade_no ?? "";
|
||||
topForm.payment_method = t.payment_method ?? "";
|
||||
topForm.payment_provider = t.payment_provider ?? "";
|
||||
const sec = t.complete_time ?? t.create_time;
|
||||
topForm.paid_at =
|
||||
sec != null ? unixToBeijingWall(sec) : unixToBeijingWall(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function iso(v: Dayjs | null): string | null {
|
||||
// 北京墙钟进控件 → wallAsBeijingIso 无损回写(与旧登记表单一致)
|
||||
return v ? wallAsBeijingIso(v) : null;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
error.value = "";
|
||||
try {
|
||||
if (isSub.value) {
|
||||
if (!subForm.period_start) {
|
||||
error.value = "请填写期间起点";
|
||||
return;
|
||||
}
|
||||
if (subForm.subscription_id == null) {
|
||||
error.value = "请填写订阅编号";
|
||||
return;
|
||||
}
|
||||
const qpu = qpuQuery.data.value?.qpu ?? 500000;
|
||||
const quota =
|
||||
subForm.quotaMode === "quota"
|
||||
? subForm.quota_granted
|
||||
: subForm.quota_usd != null
|
||||
? Math.round(subForm.quota_usd * qpu)
|
||||
: null;
|
||||
if (quota == null) {
|
||||
error.value = "请填写套餐总额度";
|
||||
return;
|
||||
}
|
||||
if (subForm.paid_amount != null && subForm.paid_amount < 0) {
|
||||
error.value = "付款金额不能为负数";
|
||||
return;
|
||||
}
|
||||
const dup = (subsQuery.data.value ?? []).find(
|
||||
(p) =>
|
||||
p.subscription_id === subForm.subscription_id &&
|
||||
p.id !== props.editingSub?.id,
|
||||
);
|
||||
if (dup) {
|
||||
error.value = `订阅 #${subForm.subscription_id} 已有登记——请编辑原记录`;
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
subscription_id: subForm.subscription_id,
|
||||
period_start: iso(subForm.period_start)!,
|
||||
period_end: iso(subForm.period_end),
|
||||
quota_granted: quota,
|
||||
paid_amount: subForm.paid_amount,
|
||||
paid_at: iso(subForm.paid_at),
|
||||
remark: subForm.remark,
|
||||
};
|
||||
if (props.editingSub) {
|
||||
await updateSub.mutateAsync({ id: props.editingSub.id, body });
|
||||
} else {
|
||||
await createSub.mutateAsync(body);
|
||||
}
|
||||
} else {
|
||||
if (topForm.amount == null || topForm.amount <= 0) {
|
||||
error.value = "请填写充值金额 amount(须为正)";
|
||||
return;
|
||||
}
|
||||
if (topForm.money == null || topForm.money < 0) {
|
||||
error.value = "实付金额不能为负数";
|
||||
return;
|
||||
}
|
||||
if (!topForm.paid_at) {
|
||||
error.value = "请填写入账时间";
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
upstream_id: topForm.upstream_id,
|
||||
amount: topForm.amount,
|
||||
money: topForm.money,
|
||||
trade_no: topForm.trade_no || null,
|
||||
payment_method: topForm.payment_method || null,
|
||||
payment_provider: topForm.payment_provider || null,
|
||||
status: props.editingTop?.status ?? "success",
|
||||
paid_at: iso(topForm.paid_at)!,
|
||||
remark: topForm.remark,
|
||||
};
|
||||
if (props.editingTop) {
|
||||
await updateTop.mutateAsync({ id: props.editingTop.id, body });
|
||||
} else {
|
||||
await createTop.mutateAsync(body);
|
||||
}
|
||||
}
|
||||
message.success(props.editingSub || props.editingTop ? "已保存" : "已登记");
|
||||
emit("update:open", false);
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
if (err.fields) {
|
||||
error.value = Object.values(err.fields)[0] || "请检查表单";
|
||||
} else {
|
||||
error.value = err.message || "保存失败";
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="isSub ? (editingSub ? '编辑订阅' : '登记订阅') : editingTop ? '编辑充值' : '登记充值'"
|
||||
:confirm-loading="createSub.isPending.value || createTop.isPending.value || updateSub.isPending.value || updateTop.isPending.value"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
width="560"
|
||||
centered
|
||||
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
|
||||
@ok="onSubmit"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<a-radio-group
|
||||
v-if="!editingSub && !editingTop"
|
||||
v-model:value="isSub"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<a-radio :value="true">订阅</a-radio>
|
||||
<a-radio :value="false">充值</a-radio>
|
||||
</a-radio-group>
|
||||
|
||||
<a-form v-if="isSub" layout="vertical">
|
||||
<a-form-item label="订阅 ID" required>
|
||||
<a-space>
|
||||
<a-input-number
|
||||
v-model:value="subForm.subscription_id"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
style="width: 160px"
|
||||
/>
|
||||
<a-button @click="pickerOpen = true">从平台选用</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
<a-form-item label="期间起止" required>
|
||||
<a-date-picker
|
||||
v-model:value="subForm.period_start"
|
||||
:format="TIME_FMT"
|
||||
:show-time="{ defaultValue: undefined }"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<span style="margin: 0 8px">~</span>
|
||||
<a-date-picker
|
||||
v-model:value="subForm.period_end"
|
||||
:format="TIME_FMT"
|
||||
:show-time="{ defaultValue: undefined }"
|
||||
placeholder="止(空=进行中)"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="套餐总额度" required>
|
||||
<a-radio-group v-model:value="subForm.quotaMode" style="margin-right: 8px">
|
||||
<a-radio-button value="quota">quota</a-radio-button>
|
||||
<a-radio-button value="usd">美元</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-input-number
|
||||
v-if="subForm.quotaMode === 'quota'"
|
||||
v-model:value="subForm.quota_granted"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<a-input-number
|
||||
v-else
|
||||
v-model:value="subForm.quota_usd"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<div v-if="quotaPreview" class="form-note">
|
||||
{{ quotaPreview }} · 落库为原始 quota
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="付款金额 / 付款时间(可空)">
|
||||
<a-input-number
|
||||
v-model:value="subForm.paid_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 140px"
|
||||
placeholder="金额"
|
||||
/>
|
||||
<a-date-picker
|
||||
v-model:value="subForm.paid_at"
|
||||
:format="TIME_FMT"
|
||||
:show-time="{ defaultValue: undefined }"
|
||||
placeholder="钱出手时刻(可空)"
|
||||
style="width: 200px; margin-left: 8px"
|
||||
/>
|
||||
<div class="form-note">
|
||||
社交层付款事实:金额数值、币种由你们约定;未知可留空。登记时刻由系统另记
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-input v-model:value="subForm.remark" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-form v-else layout="vertical">
|
||||
<a-form-item label="充值金额 amount" required>
|
||||
<a-space>
|
||||
<a-input-number
|
||||
v-model:value="topForm.amount"
|
||||
:min="0"
|
||||
:precision="6"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<a-button @click="topPickerOpen = true">从平台选用</a-button>
|
||||
</a-space>
|
||||
<div class="form-note">
|
||||
平台计费侧充值额(不标币种);与「实付」之间汇率由管理员约定。换得 quota ≈ amount × QPU
|
||||
<template v-if="quotaPreview"> · {{ quotaPreview }}</template>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="实付金额 money" required>
|
||||
<a-input-number
|
||||
v-model:value="topForm.money"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<div class="form-note">实际支付金额(不预设币种)</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="入账时间 paid_at" required>
|
||||
<a-date-picker
|
||||
v-model:value="topForm.paid_at"
|
||||
:format="TIME_FMT"
|
||||
:show-time="{ defaultValue: undefined }"
|
||||
style="width: 220px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="交易号 / 渠道">
|
||||
<a-input v-model:value="topForm.trade_no" placeholder="trade_no" style="width: 220px" />
|
||||
<a-input
|
||||
v-model:value="topForm.payment_method"
|
||||
placeholder="method"
|
||||
style="width: 120px; margin-left: 8px"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="topForm.payment_provider"
|
||||
placeholder="provider"
|
||||
style="width: 120px; margin-left: 8px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-input v-model:value="topForm.remark" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<PlatformSubscriptionPicker
|
||||
v-model:open="pickerOpen"
|
||||
:registered-ids="registeredSubIds"
|
||||
@select="onPickSub"
|
||||
/>
|
||||
<PlatformTopupPicker
|
||||
v-model:open="topPickerOpen"
|
||||
:registered-ids="registeredTopIds"
|
||||
@select="onPickTop"
|
||||
/>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-note {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,264 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 订阅选择弹层:登录(仅一次/标签页)+ 列表选用,与登记表单解耦
|
||||
*/
|
||||
import { ref, watch } from "vue";
|
||||
import {
|
||||
listPlatformSubscriptions,
|
||||
loginPlatform,
|
||||
type PlatformSubscription,
|
||||
} from "@/api/admin/platform.ts";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import { formatDateTime } from "@/utils/time.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
/** 已登记的 subscription_id——灰显不可选用 */
|
||||
registeredIds?: number[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:open", v: boolean): void;
|
||||
(e: "select", sub: PlatformSubscription): void;
|
||||
}>();
|
||||
|
||||
const TOKEN_KEY = "naku_platform_token";
|
||||
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
/** 打开后检查登录并拉取中——避免闪现登录表单 */
|
||||
const booting = ref(false);
|
||||
const list = ref<PlatformSubscription[]>([]);
|
||||
|
||||
function readToken(): string {
|
||||
try {
|
||||
return sessionStorage.getItem(TOKEN_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function writeToken(token: string) {
|
||||
try {
|
||||
if (token) sessionStorage.setItem(TOKEN_KEY, token);
|
||||
else sessionStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) {
|
||||
// 有 token:先显示「检查中」,拉完再进列表/登录,不闪登录表单
|
||||
if (readToken()) {
|
||||
booting.value = true;
|
||||
void load(true).finally(() => {
|
||||
booting.value = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
list.value = [];
|
||||
error.value = "";
|
||||
password.value = "";
|
||||
booting.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function load(skipLoginCheck = false) {
|
||||
if (loading.value) return;
|
||||
if (!skipLoginCheck && (!username.value.trim() || !password.value)) return;
|
||||
error.value = "";
|
||||
loading.value = true;
|
||||
try {
|
||||
let token = readToken();
|
||||
if (!token) {
|
||||
const login = await loginPlatform({
|
||||
username: username.value.trim(),
|
||||
password: password.value,
|
||||
});
|
||||
token = login.access_token;
|
||||
writeToken(token);
|
||||
}
|
||||
password.value = "";
|
||||
list.value = await listPlatformSubscriptions(token);
|
||||
if (list.value.length === 0) {
|
||||
error.value = "账号下没有订阅记录";
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
if (err.status === 401 || err.code === "platform_token_invalid") {
|
||||
writeToken("");
|
||||
error.value = "平台登录已失效——请重新输入密码";
|
||||
} else {
|
||||
error.value = err.message || "拉取失败";
|
||||
}
|
||||
list.value = [];
|
||||
password.value = "";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isRegistered(s: PlatformSubscription): boolean {
|
||||
return (props.registeredIds ?? []).includes(s.id);
|
||||
}
|
||||
|
||||
function onUse(s: PlatformSubscription) {
|
||||
if (isRegistered(s)) return;
|
||||
emit("select", s);
|
||||
emit("update:open", false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="从平台选择订阅"
|
||||
:footer="null"
|
||||
:width="520"
|
||||
centered
|
||||
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
|
||||
destroy-on-hidden
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<div v-if="booting || (loading && list.length === 0)" class="plat-pick__boot">
|
||||
<a-spin />
|
||||
<span class="plat-pick__boot-text">正在检查登录并拉取…</span>
|
||||
</div>
|
||||
<template v-else-if="list.length === 0">
|
||||
<p class="plat-pick__hint">
|
||||
使用 NewAPI 控制台账号登录后拉取订阅,仅用于预填登记;密码不保存。
|
||||
</p>
|
||||
<a-form layout="vertical" @finish="load">
|
||||
<a-form-item label="账号">
|
||||
<a-input
|
||||
v-model:value="username"
|
||||
autocomplete="username"
|
||||
@press-enter="load"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="密码">
|
||||
<a-input-password
|
||||
v-model:value="password"
|
||||
autocomplete="current-password"
|
||||
@press-enter="load"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
block
|
||||
:loading="loading"
|
||||
:disabled="!username.trim() || !password"
|
||||
>
|
||||
登录并拉取
|
||||
</a-button>
|
||||
</a-form>
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="error"
|
||||
style="margin-top: 12px"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="plat-pick__list">
|
||||
<div
|
||||
v-for="s in list"
|
||||
:key="s.id"
|
||||
class="plat-pick__item"
|
||||
:class="{ 'is-registered': isRegistered(s) }"
|
||||
@click="onUse(s)"
|
||||
>
|
||||
<div class="plat-pick__head">
|
||||
<strong>订阅 #{{ s.id }}</strong>
|
||||
<span class="plat-pick__status">
|
||||
{{ isRegistered(s) ? "已登记" : s.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="plat-pick__meta">
|
||||
{{ formatDateTime(s.start_time) }} ~
|
||||
{{ s.end_time ? formatDateTime(s.end_time) : "进行中" }}
|
||||
· 额度 {{ s.amount_total }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plat-pick__boot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.plat-pick__boot-text {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.plat-pick__hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.plat-pick__list {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.plat-pick__item {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.plat-pick__item:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.plat-pick__item.is-registered {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.plat-pick__item.is-registered:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.plat-pick__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.plat-pick__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.plat-pick__status {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.plat-pick__meta {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 充值选用弹层:登录 + 传统分页;已登记按 upstream_id 灰显
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
import {
|
||||
listPlatformTopups,
|
||||
loginPlatform,
|
||||
type PlatformTopup,
|
||||
} from "@/api/admin/platform.ts";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import { formatDateTime } from "@/utils/time.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
registeredIds?: number[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:open", v: boolean): void;
|
||||
(e: "select", topup: PlatformTopup & { paid_at_iso: string }): void;
|
||||
}>();
|
||||
|
||||
const TOKEN_KEY = "naku_platform_token";
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
/** 打开后检查登录并拉取中——避免闪现登录表单 */
|
||||
const booting = ref(false);
|
||||
const items = ref<PlatformTopup[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(total.value / PAGE_SIZE)),
|
||||
);
|
||||
|
||||
function readToken(): string {
|
||||
try {
|
||||
return sessionStorage.getItem(TOKEN_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function writeToken(token: string) {
|
||||
try {
|
||||
if (token) sessionStorage.setItem(TOKEN_KEY, token);
|
||||
else sessionStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureToken(): Promise<string> {
|
||||
let token = readToken();
|
||||
if (token) return token;
|
||||
const login = await loginPlatform({
|
||||
username: username.value.trim(),
|
||||
password: password.value,
|
||||
});
|
||||
token = login.access_token;
|
||||
writeToken(token);
|
||||
password.value = "";
|
||||
return token;
|
||||
}
|
||||
|
||||
async function loadPage(p: number, opts?: { allowTokenOnly?: boolean }) {
|
||||
if (loading.value) return;
|
||||
if (!opts?.allowTokenOnly && !readToken() && (!username.value.trim() || !password.value)) {
|
||||
return;
|
||||
}
|
||||
error.value = "";
|
||||
loading.value = true;
|
||||
try {
|
||||
const token = await ensureToken();
|
||||
password.value = "";
|
||||
const data = await listPlatformTopups(token, p, PAGE_SIZE);
|
||||
items.value = data.items;
|
||||
total.value = data.total;
|
||||
page.value = data.page || p;
|
||||
if (items.value.length === 0 && p === 1) {
|
||||
error.value = "账号下没有充值记录";
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
if (err.status === 401 || err.code === "platform_token_invalid") {
|
||||
writeToken("");
|
||||
error.value = "平台登录已失效——请重新输入密码";
|
||||
} else {
|
||||
error.value = err.message || "拉取失败";
|
||||
}
|
||||
items.value = [];
|
||||
password.value = "";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogin() {
|
||||
if (!username.value.trim() || !password.value || loading.value) return;
|
||||
await loadPage(1);
|
||||
}
|
||||
|
||||
function isRegistered(t: PlatformTopup): boolean {
|
||||
return (props.registeredIds ?? []).includes(t.id);
|
||||
}
|
||||
|
||||
function onUse(t: PlatformTopup) {
|
||||
if (isRegistered(t)) return;
|
||||
const sec = t.complete_time ?? t.create_time ?? Math.floor(Date.now() / 1000);
|
||||
emit("select", {
|
||||
...t,
|
||||
paid_at_iso: new Date(sec * 1000).toISOString(),
|
||||
});
|
||||
emit("update:open", false);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) {
|
||||
if (readToken()) {
|
||||
booting.value = true;
|
||||
void loadPage(1, { allowTokenOnly: true }).finally(() => {
|
||||
booting.value = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
items.value = [];
|
||||
total.value = 0;
|
||||
page.value = 1;
|
||||
error.value = "";
|
||||
password.value = "";
|
||||
booting.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="从平台选择充值"
|
||||
:footer="null"
|
||||
:width="560"
|
||||
centered
|
||||
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
|
||||
destroy-on-hidden
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<div
|
||||
v-if="booting || (loading && items.length === 0)"
|
||||
class="top-pick__boot"
|
||||
>
|
||||
<a-spin />
|
||||
<span class="top-pick__boot-text">正在检查登录并拉取…</span>
|
||||
</div>
|
||||
<template v-else-if="items.length === 0 && !loading">
|
||||
<p class="top-pick__hint">
|
||||
登录 NewAPI 控制台拉取充值单(传统分页);密码不保存。
|
||||
</p>
|
||||
<a-form layout="vertical" @finish="onLogin">
|
||||
<a-form-item label="账号">
|
||||
<a-input v-model:value="username" autocomplete="username" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码">
|
||||
<a-input-password
|
||||
v-model:value="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
block
|
||||
:loading="loading"
|
||||
:disabled="!username.trim() || !password"
|
||||
>
|
||||
登录并拉取
|
||||
</a-button>
|
||||
</a-form>
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="error"
|
||||
style="margin-top: 12px"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="top-pick__list">
|
||||
<div
|
||||
v-for="t in items"
|
||||
:key="t.id"
|
||||
class="top-pick__item"
|
||||
:class="{ 'is-registered': isRegistered(t) }"
|
||||
@click="onUse(t)"
|
||||
>
|
||||
<div class="top-pick__head">
|
||||
<strong>
|
||||
{{ formatDateTime(t.complete_time ?? t.create_time) }}
|
||||
</strong>
|
||||
<span class="top-pick__status">
|
||||
{{ isRegistered(t) ? "已登记" : t.status || "" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="top-pick__meta">
|
||||
amount {{ t.amount }} · money {{ t.money }}
|
||||
<template v-if="t.trade_no"> · {{ t.trade_no }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-pick__pager">
|
||||
<a-pagination
|
||||
:current="page"
|
||||
:page-size="PAGE_SIZE"
|
||||
:total="total"
|
||||
size="small"
|
||||
:show-total="(t: number) => `共 ${t} 条`"
|
||||
@change="(p: number) => loadPage(p)"
|
||||
/>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="error"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.top-pick__boot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.top-pick__boot-text {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.top-pick__hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.top-pick__list {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.top-pick__item {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.top-pick__item:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.top-pick__item.is-registered {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.top-pick__item.is-registered:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.top-pick__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.top-pick__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.top-pick__status {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.top-pick__meta {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.top-pick__pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 登记 Key:sk-key 明文在 web 的唯一露面处(交互-key管理 §2)
|
||||
*/
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import type { MemberItem } from "@new-api-key-usage/core";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import { useCreateMember, useRegisterKey } from "@/queries/admin.ts";
|
||||
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
members: MemberItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:open", v: boolean): void;
|
||||
(e: "registered", keyId: number): void;
|
||||
}>();
|
||||
|
||||
const register = useRegisterKey();
|
||||
const createMember = useCreateMember();
|
||||
|
||||
const form = reactive({
|
||||
sk_key: "",
|
||||
member_id: null as number | "unassigned" | null,
|
||||
});
|
||||
const resultMsg = ref<{ type: "success" | "error" | "warning"; text: string } | null>(
|
||||
null,
|
||||
);
|
||||
const showNewMember = ref(false);
|
||||
const newMemberName = ref("");
|
||||
const newMemberRemark = ref("");
|
||||
const tokenOpen = ref(false);
|
||||
const plainToken = ref("");
|
||||
const tokenMemberName = ref("");
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (!v) {
|
||||
form.sk_key = "";
|
||||
form.member_id = null;
|
||||
resultMsg.value = null;
|
||||
showNewMember.value = false;
|
||||
newMemberName.value = "";
|
||||
newMemberRemark.value = "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const memberOptions = computed(() => [
|
||||
{ value: "unassigned", label: "未归属" },
|
||||
...props.members.map((m) => ({ value: m.id, label: m.name })),
|
||||
]);
|
||||
|
||||
async function onSubmit() {
|
||||
resultMsg.value = null;
|
||||
if (!form.sk_key.trim()) {
|
||||
resultMsg.value = { type: "warning", text: "请粘贴 sk-key" };
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const key = await register.mutateAsync({
|
||||
sk_key: form.sk_key.trim(),
|
||||
member_id:
|
||||
form.member_id === "unassigned" || form.member_id == null
|
||||
? null
|
||||
: form.member_id,
|
||||
});
|
||||
resultMsg.value = {
|
||||
type: "success",
|
||||
text: "已登记,首轮同步进行中(约 5 分钟内完成)",
|
||||
};
|
||||
emit("registered", key.id);
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
if (err.status === 409) {
|
||||
resultMsg.value = {
|
||||
type: "warning",
|
||||
text: "该 key 已在清单中",
|
||||
};
|
||||
if (err.keyId != null) emit("registered", err.keyId);
|
||||
} else if (err.status === 422) {
|
||||
// 上游拒绝 vs 本地表单校验(如 sk- 前缀)文案不同
|
||||
const fieldMsg = err.fields?.sk_key;
|
||||
resultMsg.value = {
|
||||
type: "error",
|
||||
text:
|
||||
fieldMsg ??
|
||||
(err.code === "validation_failed"
|
||||
? err.message || "请检查填写内容"
|
||||
: "上游拒绝了这枚 key——可能不存在或已被禁用"),
|
||||
};
|
||||
} else if (err.status === 502 || err.status === 0) {
|
||||
resultMsg.value = {
|
||||
type: "error",
|
||||
text: "上游站点暂时不可达,请稍后重试",
|
||||
};
|
||||
} else {
|
||||
resultMsg.value = {
|
||||
type: "error",
|
||||
text: err.message || "登记失败,请稍后重试",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreateMember() {
|
||||
const name = newMemberName.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const res = await createMember.mutateAsync({
|
||||
name,
|
||||
remark: newMemberRemark.value.trim(),
|
||||
});
|
||||
form.member_id = res.member.id;
|
||||
showNewMember.value = false;
|
||||
newMemberName.value = "";
|
||||
newMemberRemark.value = "";
|
||||
// POST /members 必回明文令牌——立刻展示,避免遗失
|
||||
plainToken.value = res.token;
|
||||
tokenMemberName.value = res.member.name;
|
||||
tokenOpen.value = true;
|
||||
resultMsg.value = {
|
||||
type: "success",
|
||||
text: `已新建成员「${res.member.name}」并选为归属,请保存下方令牌`,
|
||||
};
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
resultMsg.value = { type: "error", text: err.message || "新建成员失败" };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="登记 Key"
|
||||
:confirm-loading="register.isPending.value"
|
||||
ok-text="登记"
|
||||
cancel-text="取消"
|
||||
@update:open="emit('update:open', $event)"
|
||||
@ok="onSubmit"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="sk-key" required>
|
||||
<a-textarea
|
||||
v-model:value="form.sk_key"
|
||||
:rows="4"
|
||||
placeholder="粘贴你在 NewAPI 创建的 sk-…"
|
||||
/>
|
||||
<div class="register-key__note">
|
||||
粘贴后立即加密存储,界面不再回显明文
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="归属成员">
|
||||
<a-select
|
||||
v-model:value="form.member_id"
|
||||
:options="memberOptions"
|
||||
placeholder="未归属"
|
||||
allow-clear
|
||||
/>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
style="padding-left: 0"
|
||||
@click="showNewMember = !showNewMember"
|
||||
>
|
||||
+ 新建成员
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="showNewMember" label="新成员名称">
|
||||
<a-input
|
||||
v-model:value="newMemberName"
|
||||
placeholder="成员名称"
|
||||
@press-enter="onCreateMember"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="newMemberRemark"
|
||||
placeholder="备注(可选)"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
style="margin-top: 8px"
|
||||
:loading="createMember.isPending.value"
|
||||
@click="onCreateMember"
|
||||
>
|
||||
创建并选用
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-alert
|
||||
v-if="resultMsg"
|
||||
:type="resultMsg.type"
|
||||
show-icon
|
||||
:message="resultMsg.text"
|
||||
/>
|
||||
</a-form>
|
||||
<MemberTokenModal
|
||||
v-model:open="tokenOpen"
|
||||
:token="plainToken"
|
||||
:member-name="tokenMemberName"
|
||||
@update:open="(v: boolean) => { if (!v) { plainToken = ''; tokenMemberName = ''; } }"
|
||||
/>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.register-key__note {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 页面标题区——对齐 design.md 排版:
|
||||
* 标题 headline-sm(20/600/28)· 说明 body-sm 三级字(rgba 0.45)
|
||||
*/
|
||||
defineProps<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-header">
|
||||
<div class="page-header__text">
|
||||
<h1 class="page-header__title">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="page-header__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.page-header__subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.page-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
/* 与标题块上缘对齐时,控件 32px 高与 label 竖向居中 */
|
||||
min-height: 32px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 额度双单位:原始 quota + 美元(契约公共约定 §5;前端只呈现不换算)
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { formatInt } from "@/utils/format.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
quota: number | null | undefined;
|
||||
quotaUsd: string | null | undefined;
|
||||
}>();
|
||||
|
||||
const quotaText = computed(() => formatInt(props.quota));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="quota-text">
|
||||
<span class="quota-text__num">{{ quotaText }}</span>
|
||||
<span class="quota-text__usd">(${{ quotaUsd ?? "—" }})</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quota-text {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quota-text__num {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quota-text__usd {
|
||||
margin-left: 6px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 来源分解分段条(交互共性 §3.7:订阅 / 充值)——前端现算比例(公共约定 §5)
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
|
||||
type SourceRow = {
|
||||
billing_source: string;
|
||||
count: number;
|
||||
quota: number;
|
||||
quota_usd: string;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
rows: SourceRow[];
|
||||
}>();
|
||||
|
||||
const segments = computed(() => {
|
||||
const total = props.rows.reduce((s, r) => s + r.quota, 0);
|
||||
return props.rows.map((r) => {
|
||||
const pct = total > 0 ? (r.quota / total) * 100 : 0;
|
||||
const isSub = r.billing_source === "subscription";
|
||||
return {
|
||||
key: r.billing_source,
|
||||
label: isSub ? "订阅" : r.billing_source === "wallet" ? "充值" : r.billing_source,
|
||||
pct,
|
||||
count: r.count,
|
||||
quota_usd: r.quota_usd,
|
||||
color: isSub ? "#1677ff" : "#faad14",
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const hasData = computed(() => segments.value.some((s) => s.pct > 0));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="source-bar">
|
||||
<div v-if="!hasData" class="source-bar__empty">
|
||||
本时段内没有任何消费调用,因此没有可分解的来源。
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="source-bar__track">
|
||||
<div
|
||||
v-for="seg in segments"
|
||||
:key="seg.key"
|
||||
class="source-bar__seg"
|
||||
:style="{ width: `${seg.pct}%`, background: seg.color }"
|
||||
:title="`${seg.label}来源:占额度 ${seg.pct.toFixed(1)}%,调用 ${seg.count} 次,$${seg.quota_usd}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="source-bar__legend">
|
||||
<span v-for="seg in segments" :key="seg.key" class="source-bar__item">
|
||||
<i class="source-bar__dot" :style="{ background: seg.color }" />
|
||||
<span class="source-bar__label">{{ seg.label }}</span>
|
||||
<span class="source-bar__meta">
|
||||
调用 {{ seg.count }} 次,消耗 ${{ seg.quota_usd }}
|
||||
(占 {{ seg.pct.toFixed(1) }}%)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.source-bar__track {
|
||||
display: flex;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.source-bar__seg {
|
||||
height: 100%;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.source-bar__legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.source-bar__item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.source-bar__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.source-bar__label {
|
||||
font-weight: 600;
|
||||
min-width: 2.5em;
|
||||
}
|
||||
|
||||
.source-bar__meta {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.source-bar__empty {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 表格工具行刷新钮(纯图标 + tooltip)——放在筛选行下一行、右对齐
|
||||
*/
|
||||
import { ReloadOutlined } from "@antdv-next/icons";
|
||||
|
||||
defineProps<{ loading?: boolean }>();
|
||||
const emit = defineEmits<{ (e: "refresh"): void }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-refresh">
|
||||
<a-tooltip title="刷新">
|
||||
<a-button
|
||||
:loading="loading"
|
||||
aria-label="刷新"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-refresh {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from "dayjs";
|
||||
import dayjs from "dayjs";
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
recentDaysRange,
|
||||
wallAsBeijingIso,
|
||||
type RangeValue,
|
||||
} from "@/utils/time.ts";
|
||||
import type { RangeKind } from "@/composables/use-live-time-range.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: RangeValue;
|
||||
/** 外部预置(如「本订阅期间」);空则只保留内建快捷项 */
|
||||
extraPresets?: Array<{ label: string; from: string; to: string }>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: RangeValue): void;
|
||||
/** 相对区间可自动跟日历推进;fixed 为绝对窗口 */
|
||||
(e: "change", value: RangeValue, kind: RangeKind): void;
|
||||
}>();
|
||||
|
||||
const pickerValue = computed<[Dayjs, Dayjs] | null>(() => {
|
||||
if (!props.modelValue.from || !props.modelValue.to) return null;
|
||||
return [dayjs(props.modelValue.from), dayjs(props.modelValue.to)];
|
||||
});
|
||||
|
||||
const presets = computed(() => {
|
||||
const builtIn = [
|
||||
{
|
||||
label: "最近 7 天",
|
||||
value: rangeToDayjs(recentDaysRange(7)),
|
||||
},
|
||||
{
|
||||
label: "最近 30 天",
|
||||
value: rangeToDayjs(recentDaysRange(30)),
|
||||
},
|
||||
];
|
||||
const extra = (props.extraPresets ?? []).map((p) => ({
|
||||
label: p.label,
|
||||
value: rangeToDayjs({ from: p.from, to: p.to }),
|
||||
}));
|
||||
return [...builtIn, ...extra];
|
||||
});
|
||||
|
||||
function rangeToDayjs(range: RangeValue): [Dayjs, Dayjs] {
|
||||
return [dayjs(range.from), dayjs(range.to)];
|
||||
}
|
||||
|
||||
function detectKind(value: RangeValue): RangeKind {
|
||||
const fromD = dayjs(value.from).format("YYYY-MM-DD");
|
||||
const toD = dayjs(value.to).format("YYYY-MM-DD");
|
||||
for (const days of [7, 30] as const) {
|
||||
const r = recentDaysRange(days);
|
||||
if (
|
||||
dayjs(r.from).format("YYYY-MM-DD") === fromD &&
|
||||
dayjs(r.to).format("YYYY-MM-DD") === toD
|
||||
) {
|
||||
return days === 7 ? "recent-7" : "recent-30";
|
||||
}
|
||||
}
|
||||
return "fixed";
|
||||
}
|
||||
|
||||
function onChange(dates: Dayjs[] | null) {
|
||||
if (!dates || dates.length !== 2 || !dates[0] || !dates[1]) return;
|
||||
const value = {
|
||||
from: wallAsBeijingIso(dates[0]),
|
||||
to: wallAsBeijingIso(dates[1]),
|
||||
};
|
||||
emit("update:modelValue", value);
|
||||
emit("change", value, detectKind(value));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-range-picker
|
||||
:value="pickerValue"
|
||||
:presets="presets"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
:placeholder="['开始时间', '结束时间']"
|
||||
show-time
|
||||
@change="onChange"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
import { computed, onUnmounted, ref } from "vue";
|
||||
import { recentDaysRange } from "@/utils/time.ts";
|
||||
|
||||
export type RangeValue = { from: string; to: string };
|
||||
export type RangeKind = "recent-7" | "recent-30" | "fixed";
|
||||
|
||||
/**
|
||||
* 可跟随「今天」推进的时间范围:
|
||||
* - 最近 7/30 天:定时重算窗口(页面挂过夜也不会陈旧)
|
||||
* - fixed:自定义/订阅期间等绝对区间,窗口不挪
|
||||
*/
|
||||
export function useLiveTimeRange(initial: Exclude<RangeKind, "fixed"> = "recent-30") {
|
||||
const kind = ref<RangeKind>(initial);
|
||||
const fixed = ref<RangeValue>(recentDaysRange(30));
|
||||
const nowTick = ref(Date.now());
|
||||
|
||||
const timer = setInterval(() => {
|
||||
nowTick.value = Date.now();
|
||||
}, 60_000);
|
||||
onUnmounted(() => clearInterval(timer));
|
||||
|
||||
const range = computed<RangeValue>(() => {
|
||||
void nowTick.value;
|
||||
if (kind.value === "recent-7") return recentDaysRange(7);
|
||||
if (kind.value === "recent-30") return recentDaysRange(30);
|
||||
return fixed.value;
|
||||
});
|
||||
|
||||
function setRange(value: RangeValue, nextKind: RangeKind) {
|
||||
kind.value = nextKind;
|
||||
if (nextKind === "fixed") fixed.value = value;
|
||||
}
|
||||
|
||||
return { range, setRange, kind };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { computed, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
useMemberRangeStore,
|
||||
type RangeKind,
|
||||
type RangeValue,
|
||||
} from "@/stores/member-range.ts";
|
||||
|
||||
/**
|
||||
* 成员端共享时间范围 + 当前页 URL(from/to)双向同步。
|
||||
* 切页时布局菜单带上 from/to,状态从 store 读——对账窗口不打断。
|
||||
*/
|
||||
export function useMemberRange() {
|
||||
const store = useMemberRangeStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const range = computed(() => store.range);
|
||||
|
||||
function syncFromRoute() {
|
||||
const from = route.query.from;
|
||||
const to = route.query.to;
|
||||
if (typeof from === "string" && from && typeof to === "string" && to) {
|
||||
store.setRange({ from, to }, "fixed");
|
||||
}
|
||||
}
|
||||
|
||||
/** 在当前路径上写入 from/to(可并入页内其它 query) */
|
||||
function pushRangeQuery(extra: Record<string, string> = {}) {
|
||||
void router.replace({
|
||||
path: route.path,
|
||||
query: {
|
||||
...pickNonRangeQuery(route.query),
|
||||
...extra,
|
||||
from: store.range.from,
|
||||
to: store.range.to,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onRangeChange(v: RangeValue, k: RangeKind) {
|
||||
store.setRange(v, k);
|
||||
pushRangeQuery();
|
||||
}
|
||||
|
||||
syncFromRoute();
|
||||
watch(() => route.query, () => syncFromRoute());
|
||||
|
||||
return {
|
||||
range,
|
||||
setRange: store.setRange,
|
||||
onRangeChange,
|
||||
pushRangeQuery,
|
||||
};
|
||||
}
|
||||
|
||||
function pickNonRangeQuery(q: Record<string, unknown>): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(q)) {
|
||||
if (k === "from" || k === "to") continue;
|
||||
if (typeof v === "string" && v) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,273 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuInfo, MenuItemType } from "antdv-next";
|
||||
import { theme } from "antdv-next";
|
||||
import {
|
||||
AccountBookOutlined,
|
||||
DashboardOutlined,
|
||||
DownOutlined,
|
||||
FileTextOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
UserOutlined,
|
||||
} from "@antdv-next/icons";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useMemberTokenStore } from "@/stores/member-token.ts";
|
||||
import { useMemberRangeStore } from "@/stores/member-range.ts";
|
||||
import { useMemberMe } from "@/queries/member.ts";
|
||||
|
||||
const { token } = theme.useToken();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const tokenStore = useMemberTokenStore();
|
||||
const rangeStore = useMemberRangeStore();
|
||||
const meQuery = useMemberMe();
|
||||
|
||||
const collapsed = ref(false);
|
||||
const brandTitle = "NAKU";
|
||||
const brandDescription = "NewAPI 用量统计";
|
||||
const memberName = computed(
|
||||
() => tokenStore.memberName || meQuery.data.value?.name || "",
|
||||
);
|
||||
|
||||
const items: MenuItemType[] = [
|
||||
{ key: "/member", icon: DashboardOutlined, label: "总览" },
|
||||
{ key: "/member/logs", icon: FileTextOutlined, label: "调用日志" },
|
||||
{ key: "/member/payments", icon: AccountBookOutlined, label: "支付参数" },
|
||||
];
|
||||
|
||||
const selectedKeys = ref<string[]>([route.path === "/member" ? "/member" : route.path]);
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
selectedKeys.value = [path === "/member" ? "/member" : path];
|
||||
},
|
||||
);
|
||||
|
||||
function onMenuClick({ key }: MenuInfo) {
|
||||
// 切页保留对账窗口(from/to),不带页内筛选
|
||||
void router.push({
|
||||
path: String(key),
|
||||
query: {
|
||||
from: rangeStore.range.from,
|
||||
to: rangeStore.range.to,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const userMenuItems: MenuItemType[] = [
|
||||
{ key: "logout", icon: LogoutOutlined, label: "退出", danger: true },
|
||||
];
|
||||
|
||||
function onUserMenuClick({ key }: MenuInfo) {
|
||||
if (key === "logout") {
|
||||
tokenStore.clearToken();
|
||||
void router.replace({ name: "member-login" });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout class="member-layout">
|
||||
<a-layout-sider
|
||||
v-model:collapsed="collapsed"
|
||||
collapsible
|
||||
:width="220"
|
||||
:collapsed-width="80"
|
||||
>
|
||||
<template #trigger>
|
||||
<MenuUnfoldOutlined v-if="collapsed" />
|
||||
<MenuFoldOutlined v-else />
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="member-layout__brand"
|
||||
:class="{ 'is-collapsed': collapsed }"
|
||||
:title="collapsed ? `${brandTitle} · ${brandDescription}` : undefined"
|
||||
>
|
||||
<span class="member-layout__mark" aria-hidden="true">N</span>
|
||||
<div v-if="!collapsed" class="member-layout__brand-text">
|
||||
<div class="member-layout__brand-title">{{ brandTitle }}</div>
|
||||
<div class="member-layout__brand-desc">{{ brandDescription }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
:inline-collapsed="collapsed"
|
||||
:selected-keys="selectedKeys"
|
||||
:items="items"
|
||||
@click="onMenuClick"
|
||||
/>
|
||||
</a-layout-sider>
|
||||
<a-layout class="member-layout__main">
|
||||
<a-layout-header class="member-layout__header">
|
||||
<a-dropdown
|
||||
:menu="{ items: userMenuItems }"
|
||||
placement="bottomRight"
|
||||
:trigger="['click']"
|
||||
@menu-click="onUserMenuClick"
|
||||
>
|
||||
<a class="member-layout__account">
|
||||
<UserOutlined class="member-layout__account-icon" />
|
||||
<span v-if="memberName" class="member-layout__username">{{ memberName }}</span>
|
||||
<span v-else class="member-layout__username is-muted">…</span>
|
||||
<DownOutlined class="member-layout__account-caret" />
|
||||
</a>
|
||||
</a-dropdown>
|
||||
</a-layout-header>
|
||||
<a-layout-content class="member-layout__content">
|
||||
<div
|
||||
class="member-layout__surface"
|
||||
:style="{
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: `${token.borderRadiusLG}px`,
|
||||
}"
|
||||
>
|
||||
<router-view />
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.member-layout {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.member-layout__main {
|
||||
height: 100vh;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.member-layout__brand {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 24px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.member-layout__brand.is-collapsed {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.member-layout__mark {
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.member-layout__brand-text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.member-layout__brand-title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.member-layout__brand-desc {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.member-layout__header {
|
||||
flex: 0 0 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
background: #fff;
|
||||
padding: 0 24px;
|
||||
height: 64px;
|
||||
line-height: 64px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.member-layout__account {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
height: 40px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.member-layout__account:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.member-layout__account-icon {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.member-layout__account-caret {
|
||||
font-size: 10px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.member-layout__username {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-layout__username.is-muted {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.member-layout__content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: #f5f5f5;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.member-layout__surface {
|
||||
padding: 24px;
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -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");
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||
import {
|
||||
fetchKeys,
|
||||
patchKey,
|
||||
registerKey,
|
||||
type KeyListFilters,
|
||||
} from "@/api/admin/keys.ts";
|
||||
import { fetchOverview } from "@/api/admin/overview.ts";
|
||||
import { fetchEngine, fetchQpuLatest } from "@/api/admin/engine.ts";
|
||||
import {
|
||||
createSubscription,
|
||||
deleteSubscription,
|
||||
fetchSubscriptions,
|
||||
updateSubscription,
|
||||
type SubscriptionBody,
|
||||
} from "@/api/admin/subscriptions.ts";
|
||||
import {
|
||||
createTopup,
|
||||
deleteTopup,
|
||||
fetchTopups,
|
||||
updateTopup,
|
||||
type TopupBody,
|
||||
} from "@/api/admin/topups.ts";
|
||||
import {
|
||||
createMember,
|
||||
fetchMembers,
|
||||
issueMemberToken,
|
||||
patchMember,
|
||||
} from "@/api/admin/members.ts";
|
||||
import { fetchAuditLogs } from "@/api/admin/audit.ts";
|
||||
import { fetchLogs } from "@/api/admin/logs.ts";
|
||||
import { qk, type PageQuery, type TimeRange } from "@/api/query-keys.ts";
|
||||
|
||||
export function useAdminOverview(range: () => TimeRange) {
|
||||
return useQuery({
|
||||
queryKey: () => qk.admin.overview(range()),
|
||||
queryFn: () => fetchOverview(range()),
|
||||
// 页面可长时间挂起:定期拉取引擎新写入的数据
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEngine() {
|
||||
return useQuery({
|
||||
queryKey: qk.admin.engine(),
|
||||
queryFn: fetchEngine,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useQpuLatest() {
|
||||
return useQuery({
|
||||
queryKey: qk.admin.qpuLatest(),
|
||||
queryFn: fetchQpuLatest,
|
||||
});
|
||||
}
|
||||
|
||||
export function useKeys(filters: () => KeyListFilters) {
|
||||
return useQuery({
|
||||
queryKey: () => qk.admin.keys(filters()),
|
||||
queryFn: () => fetchKeys(filters()),
|
||||
// 切页回列表时立即反映最新归属/状态
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRegisterKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: registerKey,
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePatchKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
}: {
|
||||
id: number;
|
||||
body: { status?: "active" | "disabled"; member_id?: number | null };
|
||||
}) => patchKey(id, body),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||
// 归属变更会改成员「名下 Key 数」
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminLogs(
|
||||
params: () => Parameters<typeof fetchLogs>[0],
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: () => qk.admin.logs(params()),
|
||||
queryFn: () => fetchLogs(params()),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubscriptions() {
|
||||
return useQuery({
|
||||
queryKey: qk.admin.subscriptions(),
|
||||
queryFn: fetchSubscriptions,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSubscription() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: SubscriptionBody) => createSubscription(body),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSubscription() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: SubscriptionBody }) =>
|
||||
updateSubscription(id, body),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSubscription() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteSubscription,
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTopups() {
|
||||
return useQuery({
|
||||
queryKey: qk.admin.topups(),
|
||||
queryFn: fetchTopups,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTopup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: TopupBody) => createTopup(body),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTopup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: TopupBody }) =>
|
||||
updateTopup(id, body),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTopup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteTopup,
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMembers() {
|
||||
return useQuery({
|
||||
queryKey: qk.admin.members(),
|
||||
queryFn: fetchMembers,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateMember() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createMember,
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePatchMember() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
}: {
|
||||
id: number;
|
||||
body: { status: "active" | "disabled" };
|
||||
}) => patchMember(id, body),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useIssueMemberToken() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: issueMemberToken,
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuditLogs(
|
||||
params: () => PageQuery & { domain?: string; from?: string; to?: string },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: () => qk.admin.auditLogs(params()),
|
||||
queryFn: () => fetchAuditLogs(params()),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import {
|
||||
fetchLogs,
|
||||
fetchMe,
|
||||
fetchOverview,
|
||||
type MemberLogQuery,
|
||||
} from "@/api/member/me.ts";
|
||||
import { qk, type TimeRange } from "@/api/query-keys.ts";
|
||||
|
||||
export function useMemberMe() {
|
||||
return useQuery({
|
||||
queryKey: qk.member.me(),
|
||||
queryFn: fetchMe,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 总览聚合。仅「总览」传 `refetchInterval`(看板心智);日志/支付不轮询。
|
||||
*/
|
||||
export function useMemberOverview(
|
||||
range: () => TimeRange,
|
||||
opts?: { refetchInterval?: number },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: () => qk.member.overview(range()),
|
||||
queryFn: () => fetchOverview(range()),
|
||||
...(opts?.refetchInterval != null
|
||||
? { refetchInterval: opts.refetchInterval }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMemberLogs(params: () => MemberLogQuery) {
|
||||
return useQuery({
|
||||
queryKey: () => qk.member.logs(params()),
|
||||
queryFn: () => fetchLogs(params()),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createRouter, createWebHistory, type Router } from "vue-router";
|
||||
import { adminRoutes } from "./admin.ts";
|
||||
import { memberRoutes } from "./member.ts";
|
||||
import { setAuthRedirect } from "@/api/auth-redirect.ts";
|
||||
import { useAdminSessionStore } from "@/stores/admin-session.ts";
|
||||
import { useMemberTokenStore } from "@/stores/member-token.ts";
|
||||
|
||||
declare module "vue-router" {
|
||||
interface RouteMeta {
|
||||
public?: boolean;
|
||||
requiresAdmin?: boolean;
|
||||
requiresMember?: boolean;
|
||||
world?: "admin" | "member";
|
||||
}
|
||||
}
|
||||
|
||||
export const router: Router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: "/", redirect: "/member" },
|
||||
...adminRoutes,
|
||||
...memberRoutes,
|
||||
{ path: "/:pathMatch(.*)*", redirect: "/member" },
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.public) return true;
|
||||
|
||||
const memberToken = useMemberTokenStore();
|
||||
|
||||
// 管理面:硬刷新后 Pinia 为空但会话 cookie 仍在——交给 GET /session 与 401 分流裁决
|
||||
if (to.meta.requiresMember && !memberToken.hasToken) {
|
||||
return { name: "member-login" };
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
/** 401 分流接入 router(由 main.ts 调用一次) */
|
||||
export function bindAuthRedirectToRouter(): void {
|
||||
setAuthRedirect((world, reason) => {
|
||||
const adminSession = useAdminSessionStore();
|
||||
const memberToken = useMemberTokenStore();
|
||||
if (world === "admin") {
|
||||
adminSession.signOut();
|
||||
void router.push({
|
||||
name: "admin-login",
|
||||
query: reason === "expired" ? { reason: "expired" } : {},
|
||||
});
|
||||
} else {
|
||||
memberToken.clearToken();
|
||||
void router.push({
|
||||
name: "member-login",
|
||||
query: reason === "expired" ? { reason: "expired" } : {},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import MemberLayout from "@/layouts/member-layout.vue";
|
||||
|
||||
/** 成员视图 `/member/*`:登录壳 → 侧边栏三页(与管理端同构壳) */
|
||||
export const memberRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/member/login",
|
||||
name: "member-login",
|
||||
component: () => import("@/views/member/login-view.vue"),
|
||||
meta: { public: true, world: "member" },
|
||||
},
|
||||
{
|
||||
path: "/member",
|
||||
component: MemberLayout,
|
||||
meta: { requiresMember: true, world: "member" },
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "member-overview",
|
||||
component: () => import("@/views/member/overview-view.vue"),
|
||||
},
|
||||
{
|
||||
path: "logs",
|
||||
name: "member-logs",
|
||||
component: () => import("@/views/member/logs-view.vue"),
|
||||
},
|
||||
{
|
||||
path: "payments",
|
||||
name: "member-payments",
|
||||
component: () => import("@/views/member/payments-view.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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 = "";
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { recentDaysRange } from "@/utils/time.ts";
|
||||
|
||||
export type RangeValue = { from: string; to: string };
|
||||
export type RangeKind = "recent-7" | "recent-30" | "fixed";
|
||||
|
||||
/**
|
||||
* 成员端全局时间范围——总览 / 调用日志 / 支付参数共用。
|
||||
* 相对窗口在 setRange 时定格,避免每分钟改 key 打穿缓存;
|
||||
* 「看板自动刷新」只属于总览页(refetchInterval),其余页按需请求。
|
||||
*/
|
||||
export const useMemberRangeStore = defineStore("member-range", () => {
|
||||
const kind = ref<RangeKind>("recent-30");
|
||||
const windowRange = ref<RangeValue>(recentDaysRange(30));
|
||||
const fixed = ref<RangeValue>(recentDaysRange(30));
|
||||
|
||||
const range = computed<RangeValue>(() =>
|
||||
kind.value === "fixed" ? fixed.value : windowRange.value,
|
||||
);
|
||||
|
||||
function setRange(value: RangeValue, nextKind: RangeKind) {
|
||||
kind.value = nextKind;
|
||||
if (nextKind === "fixed") fixed.value = value;
|
||||
else windowRange.value = value;
|
||||
}
|
||||
|
||||
return { kind, range, setRange };
|
||||
});
|
||||
@@ -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("");
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 审计日志(交互设计-审计日志):只读时间线 + 域筛选 + 分页
|
||||
* 表体内部滚动:高度按容器实测,避免内容区被撑出视口
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import PageHeader from "@/components/common/page-header.vue";
|
||||
import TableRefresh from "@/components/common/table-refresh.vue";
|
||||
import TimeRangePicker from "@/components/common/time-range-picker.vue";
|
||||
import { useAuditLogs } from "@/queries/admin.ts";
|
||||
import { formatDateTime, recentDaysRange } from "@/utils/time.ts";
|
||||
|
||||
const domain = ref<string | null>(null);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(50);
|
||||
const range = ref(recentDaysRange(30));
|
||||
const boxRef = ref<HTMLElement | null>(null);
|
||||
/** 表头 + 分页大约占掉的高度 */
|
||||
const CHROME = 96;
|
||||
const bodyY = ref(480);
|
||||
|
||||
const params = computed(() => ({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
domain: domain.value ?? undefined,
|
||||
from: range.value.from,
|
||||
to: range.value.to,
|
||||
}));
|
||||
|
||||
const auditQuery = useAuditLogs(() => params.value);
|
||||
const rows = computed(() => auditQuery.data.value?.items ?? []);
|
||||
const total = computed(() => auditQuery.data.value?.total ?? 0);
|
||||
|
||||
const domainOptions = [
|
||||
{ value: null, label: "全部域" },
|
||||
{ value: "key", label: "Key" },
|
||||
{ value: "payment", label: "支付" },
|
||||
{ value: "member", label: "成员" },
|
||||
{ value: "account", label: "账号" },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ title: "时间", key: "created_at", width: 160 },
|
||||
{ title: "操作者", key: "operator", width: 100 },
|
||||
{ title: "域", key: "domain", width: 80 },
|
||||
{ title: "操作", key: "action", width: 140 },
|
||||
{ title: "摘要", key: "summary", ellipsis: true },
|
||||
];
|
||||
|
||||
watch([domain, range], () => {
|
||||
page.value = 1;
|
||||
});
|
||||
|
||||
let ro: ResizeObserver | null = null;
|
||||
|
||||
function measure() {
|
||||
const h = boxRef.value?.clientHeight ?? 0;
|
||||
if (h > 0) bodyY.value = Math.max(200, h - CHROME);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
measure();
|
||||
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
|
||||
ro = new ResizeObserver(() => measure());
|
||||
ro.observe(boxRef.value);
|
||||
}
|
||||
window.addEventListener("resize", measure);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
ro?.disconnect();
|
||||
window.removeEventListener("resize", measure);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="audit">
|
||||
<PageHeader title="审计日志" subtitle="管理操作留痕,只读">
|
||||
<template #actions>
|
||||
<span class="audit-range-label">时间范围</span>
|
||||
<TimeRangePicker v-model="range" />
|
||||
<a-select
|
||||
v-model:value="domain"
|
||||
:options="domainOptions"
|
||||
style="width: 140px"
|
||||
placeholder="域"
|
||||
allow-clear
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<TableRefresh
|
||||
:loading="auditQuery.isFetching.value"
|
||||
@refresh="() => auditQuery.refetch()"
|
||||
/>
|
||||
|
||||
<div ref="boxRef" class="audit__box">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:row-key="(r: { id: number }) => r.id"
|
||||
:loading="auditQuery.isPending.value || auditQuery.isFetching.value"
|
||||
:pagination="{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100', '200'],
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
onChange: (p: number, ps: number) => {
|
||||
page = p;
|
||||
pageSize = ps;
|
||||
},
|
||||
}"
|
||||
size="middle"
|
||||
:scroll="{ y: bodyY }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'created_at'">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'operator'">
|
||||
{{ record.operator || "系统" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'domain'">
|
||||
<a-tag>{{ record.domain }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<span class="audit-action">{{ record.action }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'summary'">
|
||||
{{ record.summary }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 固定壳:视口高 − 顶栏 64 − 内容区 padding 24×2 − 内容面 padding 24×2
|
||||
* 表体高度由 ResizeObserver 量出,内容区不随表格增高
|
||||
*/
|
||||
.audit {
|
||||
height: calc(100vh - 64px - 48px - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.audit__box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.audit-action {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
|
||||
monospace;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.audit-range-label {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 32px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,383 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Key 管理(交互设计-key管理):清单表 · 行内归属/启停 · 登记 · 停滞显形
|
||||
*/
|
||||
import { computed, h, ref, watch } from "vue";
|
||||
import { Tooltip, message } from "antdv-next";
|
||||
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import type { KeyItem } from "@new-api-key-usage/core";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import PageHeader from "@/components/common/page-header.vue";
|
||||
import TableRefresh from "@/components/common/table-refresh.vue";
|
||||
import RegisterKeyModal from "@/components/admin/register-key-modal.vue";
|
||||
import { useEngine, useKeys, useMembers, usePatchKey } 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>
|
||||
|
||||
<TableRefresh
|
||||
:loading="keysQuery.isFetching.value"
|
||||
@refresh="() => keysQuery.refetch()"
|
||||
/>
|
||||
|
||||
<a-empty
|
||||
v-if="!keysQuery.isPending.value && rows.length === 0"
|
||||
:description="
|
||||
hasActiveFilter
|
||||
? '当前筛选条件下没有 key——可清除筛选,或登记新的 key'
|
||||
: '还没有登记任何 key——把你在 NewAPI 创建的 sk-key 粘贴进来'
|
||||
"
|
||||
>
|
||||
<a-button v-if="!hasActiveFilter" type="primary" @click="registerOpen = true">
|
||||
登记 Key
|
||||
</a-button>
|
||||
</a-empty>
|
||||
|
||||
<a-table
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:row-key="(r: KeyItem) => r.id"
|
||||
:loading="keysQuery.isPending.value || keysQuery.isFetching.value"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 1100 }"
|
||||
:row-class-name="onRowClass"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'token_name'">
|
||||
{{ record.token_name }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'member_name'">
|
||||
<a-select
|
||||
:value="record.member_id == null ? 'unassigned' : record.member_id"
|
||||
:options="memberOptions"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
@change="(v: number | 'unassigned') => onMemberChange(record, v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === '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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,479 @@
|
||||
<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 TableRefresh from "@/components/common/table-refresh.vue";
|
||||
import TimeRangePicker from "@/components/common/time-range-picker.vue";
|
||||
import QuotaText from "@/components/common/quota-text.vue";
|
||||
import { useLiveTimeRange } from "@/composables/use-live-time-range.ts";
|
||||
import { useAdminLogs, useKeys, useMembers } from "@/queries/admin.ts";
|
||||
import { formatDateTime } from "@/utils/time.ts";
|
||||
import { formatInt } from "@/utils/format.ts";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { range, setRange } = useLiveTimeRange("recent-30");
|
||||
|
||||
const memberId = ref<number | "unassigned" | null>(null);
|
||||
const apiKeyId = ref<number | null>(null);
|
||||
const billingSource = ref<string | null>(null);
|
||||
const subscriptionId = ref<number | null>(null);
|
||||
const typeFilter = ref<number | "error" | null>(null);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(50);
|
||||
|
||||
const membersQuery = useMembers();
|
||||
const keysQuery = useKeys(() => ({}));
|
||||
|
||||
const keyById = computed(() => {
|
||||
const m = new Map<number, { token_name: string; member_id: number | null; member_name: string | null }>();
|
||||
for (const k of keysQuery.data.value ?? []) {
|
||||
m.set(k.id, {
|
||||
token_name: k.token_name,
|
||||
member_id: k.member_id,
|
||||
member_name: k.member_name,
|
||||
});
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
const params = computed(() => ({
|
||||
...range.value,
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
api_key_id: apiKeyId.value ?? undefined,
|
||||
member_id: memberId.value ?? undefined,
|
||||
billing_source: billingSource.value ?? undefined,
|
||||
subscription_id: subscriptionId.value ?? undefined,
|
||||
type: typeFilter.value ?? undefined,
|
||||
}));
|
||||
|
||||
const logsQuery = useAdminLogs(() => params.value);
|
||||
const rows = computed(() => logsQuery.data.value?.items ?? []);
|
||||
const total = computed(() => logsQuery.data.value?.total ?? 0);
|
||||
|
||||
const memberOptions = computed(() => [
|
||||
{ value: null, label: "全部成员" },
|
||||
{ value: "unassigned", label: "未归属" },
|
||||
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
|
||||
]);
|
||||
|
||||
const keyOptions = computed(() => [
|
||||
{ value: null, label: "全部 Key" },
|
||||
...(keysQuery.data.value ?? []).map((k) => ({
|
||||
value: k.id,
|
||||
label: k.token_name,
|
||||
})),
|
||||
]);
|
||||
|
||||
const columns = [
|
||||
{ title: "时间", key: "created_at", width: 150 },
|
||||
{ title: "Key", key: "token_name", width: 120 },
|
||||
{ title: "归属", key: "member_name", width: 110 },
|
||||
{ title: "模型", key: "model_name", dataIndex: "model_name", width: 110 },
|
||||
{ title: "类型", key: "type", width: 80 },
|
||||
{ title: "来源", key: "source", width: 110 },
|
||||
{ title: "用量", key: "usage", width: 140 },
|
||||
{ title: "Tokens", key: "tokens", width: 110 },
|
||||
{ title: "操作", key: "actions", width: 72 },
|
||||
];
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detailRecord = ref<LogItem | null>(null);
|
||||
|
||||
function openDetail(record: LogItem) {
|
||||
detailRecord.value = record;
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
function formatRaw(record: LogItem | null): string {
|
||||
if (!record) return "";
|
||||
return JSON.stringify(record.raw ?? record, null, 2);
|
||||
}
|
||||
|
||||
function syncFromRoute() {
|
||||
const q = route.query;
|
||||
const m = q.member_id;
|
||||
if (m === "unassigned") memberId.value = "unassigned";
|
||||
else if (typeof m === "string" && m) memberId.value = Number(m);
|
||||
const k = q.api_key_id ?? q.key_id;
|
||||
if (typeof k === "string" && k) apiKeyId.value = Number(k);
|
||||
if (typeof q.billing_source === "string" && q.billing_source) {
|
||||
billingSource.value = q.billing_source;
|
||||
}
|
||||
const s = q.subscription_id;
|
||||
if (typeof s === "string" && s) subscriptionId.value = Number(s);
|
||||
const t = q.type;
|
||||
if (t === "2") typeFilter.value = 2;
|
||||
else if (t === "error") typeFilter.value = "error";
|
||||
else typeFilter.value = null;
|
||||
const p = q.page;
|
||||
if (typeof p === "string" && p) page.value = Number(p) || 1;
|
||||
const from = q.from;
|
||||
const to = q.to;
|
||||
if (typeof from === "string" && from && typeof to === "string" && to) {
|
||||
setRange({ from, to }, "fixed");
|
||||
}
|
||||
}
|
||||
|
||||
function pushQuery() {
|
||||
const query: Record<string, string> = {
|
||||
from: range.value.from,
|
||||
to: range.value.to,
|
||||
};
|
||||
if (memberId.value === "unassigned") query.member_id = "unassigned";
|
||||
else if (memberId.value != null) query.member_id = String(memberId.value);
|
||||
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
|
||||
if (billingSource.value) query.billing_source = billingSource.value;
|
||||
if (subscriptionId.value != null) {
|
||||
query.subscription_id = String(subscriptionId.value);
|
||||
}
|
||||
if (typeFilter.value) query.type = String(typeFilter.value);
|
||||
if (page.value > 1) query.page = String(page.value);
|
||||
void router.replace({ path: "/admin/logs", query });
|
||||
}
|
||||
|
||||
syncFromRoute();
|
||||
watch(
|
||||
() => route.query,
|
||||
() => syncFromRoute(),
|
||||
);
|
||||
watch(
|
||||
[memberId, apiKeyId, billingSource, subscriptionId, typeFilter],
|
||||
() => {
|
||||
page.value = 1;
|
||||
pushQuery();
|
||||
},
|
||||
);
|
||||
watch(page, () => pushQuery());
|
||||
|
||||
function sourceBadge(record: LogItem) {
|
||||
if (record.billing_source === "subscription" && record.subscription_id != null) {
|
||||
return { text: `订阅 #${record.subscription_id}`, color: "blue" as const };
|
||||
}
|
||||
if (record.billing_source === "wallet") {
|
||||
return { text: "充值", color: "gold" as const };
|
||||
}
|
||||
return { text: "未关联", color: undefined };
|
||||
}
|
||||
|
||||
function goKey(keyId: number) {
|
||||
void router.push({ path: "/admin/keys", query: { key_id: String(keyId) } });
|
||||
}
|
||||
|
||||
function goMember(memberId: number | null) {
|
||||
if (memberId == null) return;
|
||||
void router.push({
|
||||
path: "/admin/members",
|
||||
query: { member_id: String(memberId) },
|
||||
});
|
||||
}
|
||||
|
||||
let ro: ResizeObserver | null = null;
|
||||
|
||||
function onRangeChange(
|
||||
v: { from: string; to: string },
|
||||
k: "recent-7" | "recent-30" | "fixed",
|
||||
) {
|
||||
setRange(v, k);
|
||||
page.value = 1;
|
||||
pushQuery();
|
||||
}
|
||||
|
||||
function onPageChange(p: number, ps: number) {
|
||||
page.value = p;
|
||||
pageSize.value = ps;
|
||||
}
|
||||
|
||||
const boxRef = ref<HTMLElement | null>(null);
|
||||
const bodyY = ref(480);
|
||||
const CHROME = 96;
|
||||
|
||||
function measure() {
|
||||
const h = boxRef.value?.clientHeight ?? 0;
|
||||
if (h > 0) bodyY.value = Math.max(200, h - CHROME);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
measure();
|
||||
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
|
||||
ro = new ResizeObserver(() => measure());
|
||||
ro.observe(boxRef.value);
|
||||
}
|
||||
window.addEventListener("resize", measure);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
ro?.disconnect();
|
||||
window.removeEventListener("resize", measure);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="call-logs">
|
||||
<PageHeader title="调用日志" subtitle="全池模型请求查询与筛选">
|
||||
<template #actions>
|
||||
<span class="call-logs__label">时间范围</span>
|
||||
<TimeRangePicker
|
||||
:model-value="range"
|
||||
@change="onRangeChange"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="call-logs__filters">
|
||||
<a-select
|
||||
v-model:value="memberId"
|
||||
:options="memberOptions"
|
||||
style="width: 140px"
|
||||
allow-clear
|
||||
placeholder="成员"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="apiKeyId"
|
||||
:options="keyOptions"
|
||||
style="width: 160px"
|
||||
allow-clear
|
||||
placeholder="Key"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="billingSource"
|
||||
:options="[
|
||||
{ value: null, label: '全部来源' },
|
||||
{ value: 'subscription', label: '订阅' },
|
||||
{ value: 'wallet', label: '充值' },
|
||||
{ value: 'none', label: '未关联' },
|
||||
]"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
placeholder="来源"
|
||||
/>
|
||||
<a-input-number
|
||||
v-model:value="subscriptionId"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
style="width: 120px"
|
||||
placeholder="订阅 ID"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="typeFilter"
|
||||
:options="[
|
||||
{ value: null, label: '全部类型' },
|
||||
{ value: 2, label: '消费' },
|
||||
{ value: 'error', label: '错误' },
|
||||
]"
|
||||
style="width: 120px"
|
||||
placeholder="类型"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TableRefresh
|
||||
:loading="logsQuery.isFetching.value"
|
||||
@refresh="() => logsQuery.refetch()"
|
||||
/>
|
||||
|
||||
<a-empty
|
||||
v-if="!logsQuery.isPending.value && rows.length === 0"
|
||||
description="该时段内无调用日志"
|
||||
/>
|
||||
<div v-else ref="boxRef" class="call-logs__box">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:row-key="(r: LogItem) => r.id"
|
||||
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
|
||||
:pagination="{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100', '200'],
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
onChange: onPageChange,
|
||||
}"
|
||||
size="middle"
|
||||
:scroll="{ y: bodyY }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'created_at'">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'token_name'">
|
||||
<a @click="goKey(record.api_key_id)">{{ record.token_name }}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'member_name'">
|
||||
<a
|
||||
v-if="keyById.get(record.api_key_id)?.member_id != null"
|
||||
@click="goMember(keyById.get(record.api_key_id)!.member_id)"
|
||||
>
|
||||
{{ keyById.get(record.api_key_id)?.member_name ?? "成员" }}
|
||||
</a>
|
||||
<span v-else>未归属</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<a-tag :color="record.type === 2 ? 'blue' : 'red'">
|
||||
{{ record.type === 2 ? "消费" : "错误" }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'source'">
|
||||
<a-tag :color="sourceBadge(record).color">
|
||||
{{ sourceBadge(record).text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'usage'">
|
||||
<QuotaText :quota="record.quota" :quota-usd="record.quota_usd" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'tokens'">
|
||||
{{ formatInt(record.prompt_tokens) }} / {{ formatInt(record.completion_tokens) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a @click="openDetail(record)">详情</a>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
v-model:open="detailOpen"
|
||||
title="调用详情"
|
||||
:footer="null"
|
||||
width="720"
|
||||
centered
|
||||
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
|
||||
>
|
||||
<div v-if="detailRecord" class="call-logs__detail">
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">时间</span>
|
||||
<span>{{ formatDateTime(detailRecord.created_at) }}</span>
|
||||
</div>
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">Key / 归属</span>
|
||||
<span>
|
||||
{{ detailRecord.token_name }}
|
||||
·
|
||||
{{ keyById.get(detailRecord.api_key_id)?.member_name ?? "未归属" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">模型 / 类型</span>
|
||||
<span>
|
||||
{{ detailRecord.model_name }}
|
||||
·
|
||||
{{ detailRecord.type === 2 ? "消费" : "错误" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">用量</span>
|
||||
<QuotaText :quota="detailRecord.quota" :quota-usd="detailRecord.quota_usd" />
|
||||
</div>
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">Tokens</span>
|
||||
<span class="call-logs__num">
|
||||
提示 {{ formatInt(detailRecord.prompt_tokens) }} · 补全 {{ formatInt(detailRecord.completion_tokens) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">耗时</span>
|
||||
<span class="call-logs__num">
|
||||
{{ detailRecord.use_time }}s · {{ detailRecord.is_stream ? "流式" : "非流式" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">来源</span>
|
||||
<a-tag :color="sourceBadge(detailRecord).color">
|
||||
{{ sourceBadge(detailRecord).text }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div v-if="detailRecord.content" class="call-logs__kv">
|
||||
<span class="call-logs__note">内容</span>
|
||||
<span class="call-logs__content">{{ detailRecord.content }}</span>
|
||||
</div>
|
||||
<div class="call-logs__raw">
|
||||
<div class="call-logs__note">原始数据</div>
|
||||
<pre class="call-logs__json">{{ formatRaw(detailRecord) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.call-logs {
|
||||
height: calc(100vh - 64px - 48px - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.call-logs__label {
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.call-logs__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.call-logs__box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.call-logs__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.call-logs__kv {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.call-logs__note {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
min-width: 72px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.call-logs__num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.call-logs__content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.call-logs__raw {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.call-logs__json {
|
||||
margin: 4px 0 0;
|
||||
padding: 12px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,320 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 成员管理(交互设计-成员管理):清单表 · 开户 · 令牌明文/重发 · 启停
|
||||
*/
|
||||
import { computed, h, reactive, ref, watch } from "vue";
|
||||
import { Tooltip, message } from "antdv-next";
|
||||
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
import type { MemberItem } from "@new-api-key-usage/core";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import PageHeader from "@/components/common/page-header.vue";
|
||||
import TableRefresh from "@/components/common/table-refresh.vue";
|
||||
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
|
||||
import {
|
||||
useCreateMember,
|
||||
useIssueMemberToken,
|
||||
useMembers,
|
||||
usePatchMember,
|
||||
} from "@/queries/admin.ts";
|
||||
import { formatMinute } from "@/utils/time.ts";
|
||||
|
||||
const membersQuery = useMembers();
|
||||
const createMember = useCreateMember();
|
||||
const patchMember = usePatchMember();
|
||||
const issueToken = useIssueMemberToken();
|
||||
|
||||
const createOpen = ref(false);
|
||||
const tokenOpen = ref(false);
|
||||
const plainToken = ref("");
|
||||
const tokenMemberName = ref("");
|
||||
|
||||
const createForm = reactive({ name: "", remark: "" });
|
||||
const createError = ref("");
|
||||
|
||||
const filterName = ref("");
|
||||
const filterStatus = ref<"active" | "disabled" | null>(null);
|
||||
|
||||
function syncFiltersFromRoute() {
|
||||
const q = route.query;
|
||||
filterName.value = typeof q.name === "string" ? q.name : "";
|
||||
filterStatus.value =
|
||||
q.status === "active" || q.status === "disabled" ? q.status : null;
|
||||
}
|
||||
|
||||
function pushFilterQuery() {
|
||||
const query: Record<string, string> = {};
|
||||
const mid = route.query.member_id;
|
||||
if (typeof mid === "string" && mid) query.member_id = mid;
|
||||
if (filterName.value.trim()) query.name = filterName.value.trim();
|
||||
if (filterStatus.value) query.status = filterStatus.value;
|
||||
void router.replace({ path: "/admin/members", query });
|
||||
}
|
||||
|
||||
syncFiltersFromRoute();
|
||||
watch(() => route.query, () => syncFiltersFromRoute());
|
||||
watch([filterName, filterStatus], () => pushFilterQuery());
|
||||
|
||||
function tipTitle(label: string, tip: string) {
|
||||
return h("span", { class: "overview-col-title" }, [
|
||||
label,
|
||||
h(
|
||||
Tooltip,
|
||||
{ title: tip },
|
||||
() =>
|
||||
h(QuestionCircleOutlined, {
|
||||
class: "overview-col-title__icon",
|
||||
"aria-hidden": "true",
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: "名称", key: "name", dataIndex: "name", width: 140 },
|
||||
{ title: "备注", key: "remark" },
|
||||
{ title: "状态", key: "status", width: 100 },
|
||||
{
|
||||
title: tipTitle("令牌签发", "明文已不可找回,如需新令牌请重发"),
|
||||
key: "token_issued_at",
|
||||
width: 170,
|
||||
},
|
||||
{ title: "名下 Key 数", key: "key_count", width: 110 },
|
||||
{ title: "操作", key: "actions", width: 200 },
|
||||
];
|
||||
|
||||
const rows = computed(() => {
|
||||
let list = membersQuery.data.value ?? [];
|
||||
const mid = route.query.member_id;
|
||||
if (typeof mid === "string" && mid) {
|
||||
const n = Number(mid);
|
||||
list = list.filter((m) => m.id === n);
|
||||
}
|
||||
const name = filterName.value.trim();
|
||||
if (name) {
|
||||
const lower = name.toLowerCase();
|
||||
list = list.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(lower) ||
|
||||
(m.remark ?? "").toLowerCase().includes(lower),
|
||||
);
|
||||
}
|
||||
if (filterStatus.value) {
|
||||
list = list.filter((m) => m.status === filterStatus.value);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const hasFilter = computed(
|
||||
() =>
|
||||
!!filterName.value.trim() ||
|
||||
filterStatus.value != null ||
|
||||
(typeof route.query.member_id === "string" && !!route.query.member_id),
|
||||
);
|
||||
|
||||
async function onCreate() {
|
||||
createError.value = "";
|
||||
const name = createForm.name.trim();
|
||||
if (!name) {
|
||||
createError.value = "请填写成员名称";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await createMember.mutateAsync({
|
||||
name,
|
||||
remark: createForm.remark.trim(),
|
||||
});
|
||||
createOpen.value = false;
|
||||
createForm.name = "";
|
||||
createForm.remark = "";
|
||||
plainToken.value = res.token;
|
||||
tokenMemberName.value = res.member.name;
|
||||
tokenOpen.value = true;
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
createError.value = err.message || "开户失败";
|
||||
}
|
||||
}
|
||||
|
||||
async function onReissue(record: MemberItem) {
|
||||
try {
|
||||
const res = await issueToken.mutateAsync(record.id);
|
||||
plainToken.value = res.token;
|
||||
tokenMemberName.value = record.name;
|
||||
tokenOpen.value = true;
|
||||
} catch (e) {
|
||||
message.error((e as ApiError).message || "重发失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggle(record: MemberItem) {
|
||||
const next = record.status === "active" ? "disabled" : "active";
|
||||
try {
|
||||
await patchMember.mutateAsync({
|
||||
id: record.id,
|
||||
body: { status: next },
|
||||
});
|
||||
message.success(
|
||||
next === "disabled"
|
||||
? "已停用——该成员视图立即不可访问,数据保留"
|
||||
: "已启用——下一请求即恢复",
|
||||
);
|
||||
} catch (e) {
|
||||
message.error((e as ApiError).message || "状态更新失败");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<PageHeader title="成员管理" subtitle="开户、访问令牌与启停">
|
||||
<template #actions>
|
||||
<a-button type="primary" @click="createOpen = true">新建成员</a-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="members-filters">
|
||||
<a-input
|
||||
v-model:value="filterName"
|
||||
allow-clear
|
||||
placeholder="名称 / 备注"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filterStatus"
|
||||
:options="[
|
||||
{ value: null, label: '全部状态' },
|
||||
{ value: 'active', label: '启用中' },
|
||||
{ value: 'disabled', label: '已停用' },
|
||||
]"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
placeholder="状态"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TableRefresh
|
||||
:loading="membersQuery.isFetching.value"
|
||||
@refresh="() => membersQuery.refetch()"
|
||||
/>
|
||||
|
||||
<a-empty
|
||||
v-if="!membersQuery.isPending.value && rows.length === 0"
|
||||
:description="hasFilter ? '没有符合筛选的成员' : '还没有成员——为每位分摊朋友开户并发放访问令牌'"
|
||||
>
|
||||
<a-button v-if="!hasFilter" @click="createOpen = true">新建成员</a-button>
|
||||
</a-empty>
|
||||
|
||||
<a-table
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:row-key="(r: MemberItem) => r.id"
|
||||
:loading="membersQuery.isPending.value || membersQuery.isFetching.value"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
:scroll="{ x: 900 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'remark'">
|
||||
{{ record.remark || "—" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-badge
|
||||
:status="record.status === 'active' ? 'success' : 'default'"
|
||||
:text="record.status === 'active' ? '启用中' : '已停用'"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'token_issued_at'">
|
||||
{{ formatMinute(record.token_issued_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'key_count'">
|
||||
{{ record.key_count }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button
|
||||
size="small"
|
||||
@click="() => router.push({ path: '/admin/logs', query: { member_id: String(record.id) } })"
|
||||
>
|
||||
查调用
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="重发后旧令牌立即失效"
|
||||
description="该成员正在用的访问将中断"
|
||||
ok-text="重发"
|
||||
cancel-text="取消"
|
||||
@confirm="() => onReissue(record)"
|
||||
>
|
||||
<a-button size="small">重发令牌</a-button>
|
||||
</a-popconfirm>
|
||||
<a-popconfirm
|
||||
v-if="record.status === 'active'"
|
||||
title="停用后该成员的视图立即不可访问"
|
||||
description="令牌验证含状态检查;数据保留"
|
||||
ok-text="停用"
|
||||
cancel-text="取消"
|
||||
@confirm="() => onToggle(record)"
|
||||
>
|
||||
<a-button size="small" danger>停用</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button v-else size="small" @click="() => onToggle(record)">
|
||||
启用
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-modal
|
||||
v-model:open="createOpen"
|
||||
title="新建成员"
|
||||
:confirm-loading="createMember.isPending.value"
|
||||
ok-text="开户并签发令牌"
|
||||
cancel-text="取消"
|
||||
@ok="onCreate"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="名称" required>
|
||||
<a-input v-model:value="createForm.name" placeholder="成员名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-input v-model:value="createForm.remark" placeholder="备注(可选)" />
|
||||
</a-form-item>
|
||||
<a-alert v-if="createError" type="error" show-icon :message="createError" />
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<MemberTokenModal
|
||||
v-model:open="tokenOpen"
|
||||
:token="plainToken"
|
||||
:member-name="tokenMemberName"
|
||||
@update:open="(v: boolean) => { if (!v) { plainToken = ''; tokenMemberName = ''; } }"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.members-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.overview-col-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-col-title__icon {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
cursor: help;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 全员总览(交互设计-全员总览):健康带 · 时间范围 · 按成员 · 按 key
|
||||
*/
|
||||
import { computed, h } from "vue";
|
||||
import { Tooltip } from "antdv-next";
|
||||
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
||||
import { useRouter } from "vue-router";
|
||||
import PageHeader from "@/components/common/page-header.vue";
|
||||
import TableRefresh from "@/components/common/table-refresh.vue";
|
||||
import TimeRangePicker from "@/components/common/time-range-picker.vue";
|
||||
import QuotaText from "@/components/common/quota-text.vue";
|
||||
import SourceBar from "@/components/common/source-bar.vue";
|
||||
import HealthBand from "@/components/admin/health-band.vue";
|
||||
import {
|
||||
useLiveTimeRange,
|
||||
type RangeKind,
|
||||
type RangeValue,
|
||||
} from "@/composables/use-live-time-range.ts";
|
||||
import { useAdminOverview, useEngine, useSubscriptions } from "@/queries/admin.ts";
|
||||
import { formatMinute, toRequestIso } from "@/utils/time.ts";
|
||||
import { formatInt } from "@/utils/format.ts";
|
||||
|
||||
const router = useRouter();
|
||||
const { range, setRange } = useLiveTimeRange("recent-30");
|
||||
|
||||
const overviewQuery = useAdminOverview(() => range.value);
|
||||
const engineQuery = useEngine();
|
||||
const paymentsQuery = useSubscriptions();
|
||||
|
||||
const overview = computed(() => overviewQuery.data.value);
|
||||
const engine = computed(() => engineQuery.data.value);
|
||||
const loading = computed(
|
||||
() => overviewQuery.isPending.value || engineQuery.isPending.value,
|
||||
);
|
||||
|
||||
/** 「本订阅期间」等预置:有 period 的订阅记录才出现(纯用量模式自动隐藏) */
|
||||
const extraPresets = computed(() => {
|
||||
const list = paymentsQuery.data.value ?? [];
|
||||
return list
|
||||
.filter((p) => p.period_start != null)
|
||||
.map((p) => ({
|
||||
label: `订阅 #${p.subscription_id} 期间`,
|
||||
from: toRequestIso(new Date((p.period_start ?? 0) * 1000)),
|
||||
to: toRequestIso(
|
||||
new Date((p.period_end ?? Math.floor(Date.now() / 1000)) * 1000),
|
||||
),
|
||||
}))
|
||||
.slice(0, 4);
|
||||
});
|
||||
|
||||
const byMember = computed(() => overview.value?.by_member ?? []);
|
||||
const byKey = computed(() => overview.value?.by_key ?? []);
|
||||
const bySource = computed(() => overview.value?.by_source ?? []);
|
||||
|
||||
const totalUsage = computed(() => {
|
||||
const rows = byMember.value;
|
||||
const count = rows.reduce((s, r) => s + r.usage.count, 0);
|
||||
const quota = rows.reduce((s, r) => s + r.usage.quota, 0);
|
||||
// 比例展示用 quota;美元仅示意合计(后端已逐行换算,这里加总展示仅供参考)
|
||||
return { count, quota };
|
||||
});
|
||||
|
||||
function memberPercent(quota: number): number {
|
||||
const total = totalUsage.value.quota;
|
||||
return total > 0 ? Math.round((quota / total) * 1000) / 10 : 0;
|
||||
}
|
||||
|
||||
function onRangeChange(value: RangeValue, kind: RangeKind) {
|
||||
setRange(value, kind);
|
||||
}
|
||||
|
||||
function onMemberClick(memberId: number | null) {
|
||||
void router.push({
|
||||
path: "/admin/keys",
|
||||
query: memberId == null ? { member_id: "unassigned" } : { member_id: String(memberId) },
|
||||
});
|
||||
}
|
||||
|
||||
function onKeyClick(keyId: number) {
|
||||
void router.push({
|
||||
path: "/admin/logs",
|
||||
query: { api_key_id: String(keyId), ...range.value },
|
||||
});
|
||||
}
|
||||
|
||||
function onMemberLogs(memberId: number | null) {
|
||||
void router.push({
|
||||
path: "/admin/logs",
|
||||
query: {
|
||||
...(memberId == null
|
||||
? { member_id: "unassigned" }
|
||||
: { member_id: String(memberId) }),
|
||||
...range.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function goFailedKeys() {
|
||||
void router.push({ path: "/admin/keys", query: { recent_failed: "1" } });
|
||||
}
|
||||
|
||||
/** 表头:标题 + 问号说明 */
|
||||
function tipTitle(label: string, tip: string) {
|
||||
return h("span", { class: "overview-col-title" }, [
|
||||
label,
|
||||
h(
|
||||
Tooltip,
|
||||
{ title: tip },
|
||||
() =>
|
||||
h(QuestionCircleOutlined, {
|
||||
class: "overview-col-title__icon",
|
||||
"aria-hidden": "true",
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
const memberColumns = [
|
||||
{ title: "成员", key: "member_name" },
|
||||
{ title: "名下 Key 数", key: "key_count" },
|
||||
{
|
||||
title: tipTitle(
|
||||
"调用次数",
|
||||
"本时段内该成员名下的消费日志条数(type=2,一次调用一行)",
|
||||
),
|
||||
key: "count",
|
||||
},
|
||||
{
|
||||
title: tipTitle(
|
||||
"额度消耗",
|
||||
"本时段消耗的 quota 原值,括号内为按当时换算率折算的美元",
|
||||
),
|
||||
key: "usage",
|
||||
},
|
||||
{
|
||||
title: tipTitle(
|
||||
"用量占比",
|
||||
"该成员本时段 quota ÷ 全员同期 quota。只是用量事实占比,不是费用分摊",
|
||||
),
|
||||
key: "share",
|
||||
},
|
||||
{
|
||||
title: tipTitle(
|
||||
"同步截止",
|
||||
"该成员名下各 key 的 log_synced_at 里最慢的一刻(北京时间)",
|
||||
),
|
||||
key: "data_cutoff",
|
||||
},
|
||||
{ title: "操作", key: "actions", width: 100 },
|
||||
];
|
||||
|
||||
const keyColumns = [
|
||||
{ title: "Key 名称", dataIndex: "token_name", key: "token_name" },
|
||||
{ title: "归属成员", key: "member_name" },
|
||||
{
|
||||
title: tipTitle("当前状态", "启用中才会进入同步轮询;已停用仍可查历史"),
|
||||
key: "status",
|
||||
},
|
||||
{
|
||||
title: tipTitle("同步截止", "该枚 key 最近一次成功写入日志的同步时刻"),
|
||||
key: "data_cutoff",
|
||||
},
|
||||
{
|
||||
title: tipTitle("调用次数", "本时段消费日志条数(type=2)"),
|
||||
key: "count",
|
||||
},
|
||||
{
|
||||
title: tipTitle("额度消耗", "本时段 quota 原值与按当时换算率折算的美元"),
|
||||
key: "usage",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<PageHeader
|
||||
title="全员总览"
|
||||
subtitle="健康与用量全景 · 仅统计消费行(type=2)"
|
||||
class="overview-header"
|
||||
>
|
||||
<template #actions>
|
||||
<span class="overview-range-label">时间范围</span>
|
||||
<TimeRangePicker
|
||||
:model-value="range"
|
||||
:extra-presets="extraPresets"
|
||||
@change="onRangeChange"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<HealthBand
|
||||
:engine="engine"
|
||||
:overview="overview"
|
||||
:loading="loading"
|
||||
@go-failed-keys="goFailedKeys"
|
||||
/>
|
||||
|
||||
<a-alert
|
||||
v-if="overviewQuery.isError.value"
|
||||
type="error"
|
||||
show-icon
|
||||
message="总览数据加载失败"
|
||||
:description="overviewQuery.error.value?.message"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<template #action>
|
||||
<a-button size="small" @click="() => overviewQuery.refetch()">重试</a-button>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- 来源分解 -->
|
||||
<section class="overview-block">
|
||||
<h2 class="overview-block__title">按来源分解</h2>
|
||||
<SourceBar :rows="bySource" />
|
||||
</section>
|
||||
|
||||
<!-- 按成员 -->
|
||||
<section class="overview-block">
|
||||
<h2 class="overview-block__title">按成员汇总</h2>
|
||||
<TableRefresh
|
||||
:loading="overviewQuery.isFetching.value"
|
||||
@refresh="() => overviewQuery.refetch()"
|
||||
/>
|
||||
<a-empty
|
||||
v-if="!loading && byMember.length === 0"
|
||||
description="本时段内没有任何消费调用,因此没有成员用量可汇总"
|
||||
/>
|
||||
<a-table
|
||||
v-else
|
||||
:columns="memberColumns"
|
||||
:data-source="byMember"
|
||||
:row-key="(r: { member_id: number | null }) => r.member_id ?? 'unassigned'"
|
||||
size="middle"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 900 }"
|
||||
:loading="overviewQuery.isFetching.value"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'member_name'">
|
||||
<a @click="onMemberClick(record.member_id)">
|
||||
{{ record.member_name ?? "未归属" }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'key_count'">
|
||||
{{ record.key_count }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'count'">
|
||||
{{ formatInt(record.usage.count) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'usage'">
|
||||
<QuotaText :quota="record.usage.quota" :quota-usd="record.usage.quota_usd" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'share'">
|
||||
<span class="overview-share-cell__num">
|
||||
{{ memberPercent(record.usage.quota) }}%
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'data_cutoff'">
|
||||
{{ formatMinute(record.data_cutoff) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-button
|
||||
size="small"
|
||||
@click="() => onMemberLogs(record.member_id)"
|
||||
>
|
||||
查调用
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
|
||||
<!-- 按 key -->
|
||||
<section class="overview-block">
|
||||
<h2 class="overview-block__title">按 key 明细</h2>
|
||||
<a-table
|
||||
:columns="keyColumns"
|
||||
:data-source="byKey"
|
||||
:row-key="(r: { key_id: number }) => r.key_id"
|
||||
size="middle"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 900 }"
|
||||
:loading="overviewQuery.isFetching.value"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'member_name'">
|
||||
{{
|
||||
record.member_name ??
|
||||
(record.member_id == null
|
||||
? "未归属"
|
||||
: `成员 #${record.member_id}`)
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-badge
|
||||
:status="record.status === 'active' ? 'success' : 'default'"
|
||||
:text="record.status === 'active' ? '启用中' : '已停用'"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'data_cutoff'">
|
||||
{{ formatMinute(record.data_cutoff) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'count'">
|
||||
{{ formatInt(record.usage.count) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'usage'">
|
||||
<QuotaText :quota="record.usage.quota" :quota-usd="record.usage.quota_usd" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'token_name'">
|
||||
<a @click="onKeyClick(record.key_id)">{{ record.token_name }}</a>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overview-range-label {
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.overview-block {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.overview-block__title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.overview-col-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-col-title__icon {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.overview-share-cell__num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,372 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 支付记录(交互设计-支付记录):订阅 / 充值两 Tab · 核对提示
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { message } from "antdv-next";
|
||||
import type { SubscriptionItem, TopupItem } from "@new-api-key-usage/core";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import { fetchLogs } from "@/api/admin/logs.ts";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import PageHeader from "@/components/common/page-header.vue";
|
||||
import TableRefresh from "@/components/common/table-refresh.vue";
|
||||
import PaymentFormModal from "@/components/admin/payment-form-modal.vue";
|
||||
import QuotaText from "@/components/common/quota-text.vue";
|
||||
import {
|
||||
useDeleteSubscription,
|
||||
useDeleteTopup,
|
||||
useSubscriptions,
|
||||
useTopups,
|
||||
} from "@/queries/admin.ts";
|
||||
import { formatDateTime, toRequestIso } from "@/utils/time.ts";
|
||||
import { formatPaidAmount } from "@/utils/format.ts";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const subsQuery = useSubscriptions();
|
||||
const topupsQuery = useTopups();
|
||||
const deleteSub = useDeleteSubscription();
|
||||
const deleteTop = useDeleteTopup();
|
||||
|
||||
const tab = ref<"subs" | "topups">("subs");
|
||||
const formOpen = ref(false);
|
||||
const editingSub = ref<SubscriptionItem | null>(null);
|
||||
const editingTop = ref<TopupItem | null>(null);
|
||||
const missingSubs = ref<number[]>([]);
|
||||
const checking = ref(false);
|
||||
|
||||
function syncFromRoute() {
|
||||
tab.value = route.query.tab === "topups" ? "topups" : "subs";
|
||||
}
|
||||
|
||||
function pushTab() {
|
||||
void router.replace({
|
||||
path: "/admin/payments",
|
||||
query: tab.value === "topups" ? { tab: "topups" } : {},
|
||||
});
|
||||
}
|
||||
|
||||
syncFromRoute();
|
||||
watch(() => route.query, () => syncFromRoute());
|
||||
watch(tab, () => pushTab());
|
||||
|
||||
const subRows = computed(() => [...(subsQuery.data.value ?? [])]);
|
||||
const topRows = computed(() => {
|
||||
const list = [...(topupsQuery.data.value ?? [])];
|
||||
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
|
||||
return list;
|
||||
});
|
||||
|
||||
const subColumns = [
|
||||
{ title: "订阅 ID", dataIndex: "subscription_id", key: "subscription_id", width: 100 },
|
||||
{ title: "期间", key: "period", width: 200 },
|
||||
{ title: "额度", key: "quota", width: 160 },
|
||||
{ title: "付款金额 / 时间", key: "paid", width: 170 },
|
||||
{ title: "备注", key: "remark" },
|
||||
{ title: "登记", key: "created_at", width: 150 },
|
||||
{ title: "操作", key: "actions", width: 120 },
|
||||
];
|
||||
|
||||
const topColumns = [
|
||||
{ title: "时间", key: "paid_at", width: 150 },
|
||||
{
|
||||
title: "充值金额",
|
||||
dataIndex: "amount",
|
||||
key: "amount",
|
||||
width: 100,
|
||||
},
|
||||
{ title: "实付金额", dataIndex: "money", key: "money", width: 100 },
|
||||
{ title: "交易号 / 渠道", key: "trade", width: 180 },
|
||||
{ title: "派生额度", key: "quota", width: 150 },
|
||||
{ title: "备注", key: "remark" },
|
||||
{ title: "操作", key: "actions", width: 120 },
|
||||
];
|
||||
|
||||
function periodLines(s: SubscriptionItem): { start: string; end: string } {
|
||||
return {
|
||||
start: formatDateTime(s.period_start),
|
||||
end: s.period_end == null ? "进行中" : formatDateTime(s.period_end),
|
||||
};
|
||||
}
|
||||
|
||||
function onCreateSub() {
|
||||
editingSub.value = null;
|
||||
editingTop.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function onCreateTop() {
|
||||
editingSub.value = null;
|
||||
editingTop.value = null;
|
||||
tab.value = "topups";
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function onEditSub(s: SubscriptionItem) {
|
||||
editingSub.value = s;
|
||||
editingTop.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function onEditTop(t: TopupItem) {
|
||||
editingTop.value = t;
|
||||
editingSub.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
async function onDeleteSub(s: SubscriptionItem) {
|
||||
try {
|
||||
await deleteSub.mutateAsync(s.id);
|
||||
message.success("已删除");
|
||||
} catch (e) {
|
||||
message.error((e as ApiError).message || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteTop(t: TopupItem) {
|
||||
try {
|
||||
await deleteTop.mutateAsync(t.id);
|
||||
message.success("已删除");
|
||||
} catch (e) {
|
||||
message.error((e as ApiError).message || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** 观测订阅号 ↔ 已登记:只提示不回填;双表皆空则静默 */
|
||||
async function reconcile(subs: SubscriptionItem[]) {
|
||||
if (subs.length === 0 && topupsQuery.data.value?.length === 0) {
|
||||
missingSubs.value = [];
|
||||
return;
|
||||
}
|
||||
if (subs.length === 0) {
|
||||
missingSubs.value = [];
|
||||
return;
|
||||
}
|
||||
checking.value = true;
|
||||
try {
|
||||
const known = new Set(subs.map((s) => s.subscription_id));
|
||||
const earliest = Math.min(
|
||||
...subs.map((s) => s.period_start ?? 0),
|
||||
Date.now() / 1000,
|
||||
);
|
||||
const from = toRequestIso(new Date((earliest - 30 * 86400) * 1000));
|
||||
const to = toRequestIso(new Date());
|
||||
const page = await fetchLogs({
|
||||
from,
|
||||
to,
|
||||
page: 1,
|
||||
page_size: 200,
|
||||
type: 2,
|
||||
});
|
||||
const observed = new Set<number>();
|
||||
for (const it of page.items) {
|
||||
if (it.type === 2 && it.subscription_id != null) {
|
||||
observed.add(it.subscription_id);
|
||||
}
|
||||
}
|
||||
missingSubs.value = [...observed].filter((id) => !known.has(id)).sort((a, b) => a - b);
|
||||
} catch {
|
||||
missingSubs.value = [];
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => subsQuery.data.value,
|
||||
(subs) => {
|
||||
if (!subs) return;
|
||||
void reconcile(subs);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<PageHeader title="支付记录" subtitle="订阅权益与充值入账登记">
|
||||
<template #actions>
|
||||
<a-button v-if="tab === 'subs'" type="primary" @click="onCreateSub">
|
||||
登记订阅
|
||||
</a-button>
|
||||
<a-button v-else type="primary" @click="onCreateTop">登记充值</a-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<a-tabs v-model:active-key="tab">
|
||||
<a-tab-pane key="subs" tab="订阅" />
|
||||
<a-tab-pane key="topups" tab="充值" />
|
||||
</a-tabs>
|
||||
|
||||
<a-alert
|
||||
v-if="tab === 'subs' && missingSubs.length"
|
||||
type="warning"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
:message="`观测到 ${missingSubs.length} 个订阅有消耗但尚无对应登记`"
|
||||
>
|
||||
<template #description>
|
||||
<div class="pay-missing__tags">
|
||||
<a-tag v-for="id in missingSubs" :key="id">#{{ id }}</a-tag>
|
||||
</div>
|
||||
<div class="pay-missing__note">
|
||||
若本部署不登记支付,可忽略此提示;需要入账的请核对补录。
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<template v-if="tab === 'subs'">
|
||||
<TableRefresh
|
||||
:loading="subsQuery.isFetching.value"
|
||||
@refresh="() => subsQuery.refetch()"
|
||||
/>
|
||||
<a-empty
|
||||
v-if="!subsQuery.isPending.value && subRows.length === 0"
|
||||
description="还没有订阅登记——从平台选用或手工登记"
|
||||
>
|
||||
<a-button @click="onCreateSub">登记订阅</a-button>
|
||||
</a-empty>
|
||||
<a-table
|
||||
v-else
|
||||
:columns="subColumns"
|
||||
:data-source="subRows"
|
||||
:row-key="(r: SubscriptionItem) => r.id"
|
||||
:loading="subsQuery.isPending.value || subsQuery.isFetching.value || checking"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
:scroll="{ x: 960 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'period'">
|
||||
<div class="pay-period">
|
||||
<span>{{ periodLines(record).start }}</span>
|
||||
<span class="pay-period__end">~ {{ periodLines(record).end }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'quota'">
|
||||
<QuotaText :quota="record.quota_granted" :quota-usd="record.quota_usd" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'paid'">
|
||||
{{
|
||||
record.paid_amount != null
|
||||
? `${formatPaidAmount(record.paid_amount)} · ${formatDateTime(record.paid_at)}`
|
||||
: "—"
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'remark'">
|
||||
{{ record.remark || "—" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'created_at'">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button size="small" @click="() => onEditSub(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="删除后该条将消失"
|
||||
ok-text="删除"
|
||||
cancel-text="取消"
|
||||
@confirm="() => onDeleteSub(record)"
|
||||
>
|
||||
<a-button size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<TableRefresh
|
||||
:loading="topupsQuery.isFetching.value"
|
||||
@refresh="() => topupsQuery.refetch()"
|
||||
/>
|
||||
<a-empty
|
||||
v-if="!topupsQuery.isPending.value && topRows.length === 0"
|
||||
description="还没有充值登记"
|
||||
>
|
||||
<a-button @click="onCreateTop">登记充值</a-button>
|
||||
</a-empty>
|
||||
<a-table
|
||||
v-else
|
||||
:columns="topColumns"
|
||||
:data-source="topRows"
|
||||
:row-key="(r: TopupItem) => r.id"
|
||||
:loading="topupsQuery.isPending.value || topupsQuery.isFetching.value"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
:scroll="{ x: 900 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'paid_at'">
|
||||
{{ formatDateTime(record.paid_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'amount'">
|
||||
{{ formatPaidAmount(record.amount) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'money'">
|
||||
{{ formatPaidAmount(record.money) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'trade'">
|
||||
{{ record.trade_no || "—" }}
|
||||
<span v-if="record.payment_method" class="pay-note">
|
||||
· {{ record.payment_method }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'quota'">
|
||||
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'remark'">
|
||||
{{ record.remark || "—" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button size="small" @click="() => onEditTop(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="删除后该条将消失"
|
||||
ok-text="删除"
|
||||
cancel-text="取消"
|
||||
@confirm="() => onDeleteTop(record)"
|
||||
>
|
||||
<a-button size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<PaymentFormModal
|
||||
v-model:open="formOpen"
|
||||
:tab="tab"
|
||||
:editing-sub="editingSub"
|
||||
:editing-top="editingTop"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pay-period {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 20px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pay-period__end {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.pay-missing__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pay-missing__note,
|
||||
.pay-note {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
/** 成员令牌登录壳——明文仅存 sessionStorage(交互登录篇 §4) */
|
||||
import type { FormInstance } from "antdv-next";
|
||||
import { reactive, ref, shallowRef } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useMemberTokenStore } from "@/stores/member-token.ts";
|
||||
import { fetchMe } from "@/api/member/me.ts";
|
||||
|
||||
const router = useRouter();
|
||||
const tokenStore = useMemberTokenStore();
|
||||
const formRef = shallowRef<FormInstance>();
|
||||
const model = reactive({ token: "" });
|
||||
const error = ref("");
|
||||
const pending = ref(false);
|
||||
|
||||
async function onFinish() {
|
||||
error.value = "";
|
||||
pending.value = true;
|
||||
try {
|
||||
tokenStore.setToken(model.token.trim());
|
||||
const me = await fetchMe();
|
||||
tokenStore.setMemberName(me.name);
|
||||
void router.replace({ name: "member-overview" });
|
||||
} catch {
|
||||
tokenStore.clearToken();
|
||||
error.value = "令牌无效或已停用";
|
||||
} finally {
|
||||
pending.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="member-login-shell">
|
||||
<a-card class="member-login-shell__card" title="成员入口">
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="model"
|
||||
layout="vertical"
|
||||
auto-complete="off"
|
||||
@finish="onFinish"
|
||||
>
|
||||
<a-form-item
|
||||
name="token"
|
||||
label="令牌"
|
||||
:rules="[{ required: true, message: '请输入成员令牌' }]"
|
||||
>
|
||||
<a-input-password v-model:value="model.token" placeholder="粘贴成员令牌" />
|
||||
</a-form-item>
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="error"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<a-button type="primary" html-type="submit" block :loading="pending">
|
||||
进入
|
||||
</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.member-login-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f0f2f5;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.member-login-shell__card {
|
||||
width: 380px;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user