Compare commits
61
Commits
9ecf817df2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29019fc5d5 | ||
|
|
8d4a5c4f94 | ||
|
|
ddb1b56459 | ||
|
|
fdc5942a72 | ||
|
|
199972a2dc | ||
|
|
b0e9ea750b | ||
|
|
843ab4433a | ||
|
|
75a567611b | ||
|
|
c0051cff03 | ||
|
|
2dbd625e87 | ||
|
|
905d4ce77e | ||
|
|
86782c63ad | ||
|
|
9966f03347 | ||
|
|
d0bab545b1 | ||
|
|
f4f5ed0b0f | ||
|
|
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 |
@@ -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,19 @@
|
||||
# 构建上下文只保留 release/ 与 Caddyfile(Dockerfile 仅 COPY 这两处)
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules
|
||||
**/node_modules
|
||||
apps
|
||||
packages
|
||||
scripts
|
||||
docs
|
||||
*.log
|
||||
api*.json
|
||||
session-archive
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
.claude
|
||||
.agents
|
||||
agent
|
||||
+17
-3
@@ -1,10 +1,15 @@
|
||||
# ── new-api-key-usage · 本地环境变量示例 ──────────────────────────
|
||||
# ── new-api-key-usage · 环境变量示例 ──────────────────────────
|
||||
# 复制为 .env 后填写(.env 已被 .gitignore,勿提交)
|
||||
# 开发:任意可用的 PostgreSQL,DATABASE_URL 指向即可
|
||||
# 部署:放在服务器约定目录(不进产物包);chmod 600;与库同等备份
|
||||
|
||||
# 本服务端口(避开 3000/3001 等常见占用)
|
||||
# 本服务端口(避开 3000/3001 等常见占用;compose 内 server 固定 18456)
|
||||
PORT=18456
|
||||
|
||||
# PostgreSQL 连接串(开发 compose 默认如下)
|
||||
# PostgreSQL 连接串——数据库配置的唯一来源
|
||||
# 开发:指向你本机/已有的库(主机 127.0.0.1 或 localhost)
|
||||
# 部署:同一份即可;compose 会把主机改写为容器服务名 postgres
|
||||
# 密码含 @ : / 等字符时请 URL 编码
|
||||
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/new_api_key_usage
|
||||
|
||||
# 已登记 sk-key 的加密密钥:≥32 字节随机数,base64url。
|
||||
@@ -19,11 +24,20 @@ NEWAPI_BASE=http://127.0.0.1:8080
|
||||
# 会话 cookie 是否带 Secure(默认 true)。
|
||||
# 本地 http://localhost 或 127.0.0.1 调试:现代浏览器可保留 true;
|
||||
# 局域网 IP / 自定义域名 / 出现「登录后立刻 401」时改为 false。
|
||||
# HTTPS + 域名部署务必 true。
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# Caddy 对外端口(宿主机被占用时改这里,例如 28080 / 28443)
|
||||
# 仅影响宿主机映射;容器内仍是 80/443
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
|
||||
# 业务日志级别:debug | info | warning | error | fatal(访问日志另由 hono/logger 输出)
|
||||
LOG_LEVEL=info
|
||||
|
||||
# 同步引擎周期(毫秒,≥1000;默认 300000 = 5 分钟)
|
||||
# SYNC_INTERVAL_MS=300000
|
||||
|
||||
# 空库双通道:可选。用户名与密码同时存在时,首次 setup-state 静默落库;
|
||||
# 只填一个视为未配置。此后改 env 不改密;改密走管理面表单。
|
||||
# ADMIN_INIT_USERNAME=
|
||||
|
||||
@@ -17,9 +17,16 @@
|
||||
# 依赖与构建产物
|
||||
node_modules/
|
||||
dist/
|
||||
bin/
|
||||
build/
|
||||
.turbo/
|
||||
release/
|
||||
.bun-cache/
|
||||
|
||||
# 测试与工具
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Agent 本地接线(skills 本体只保留 .agents/skills)
|
||||
.claude/
|
||||
agent/
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
## 协作约定(长期有效)
|
||||
|
||||
- **Git**:仅当项目作者明确说"提交 / commit"时才执行提交;`git push` 永远由项目作者手工执行,AI 绝不推送
|
||||
- **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/references/`(平台可用接口参考——实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**(本分支暂无此目录,需要时再建)
|
||||
- **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/references/`(平台可用接口参考——实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**(需要时再建)
|
||||
- **代码文件命名**:所有**目录名与文件名**一律 kebab-case(如 `time-range-picker.vue` · `admin-session.ts`);**组件在模板中引用时用大驼峰**(`<TimeRangePicker />`)——文件名 kebab、模板标签 PascalCase
|
||||
- **文档状态**:头部表格标「状态」(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);设计文档定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
|
||||
- **部署**:交付物必须保证「一条命令链」可部署;**双形态并行已裁决**——完整前后端(运营方服务器)先行,Cloudflare Workers 抽取版(作者自部署)后续(见 `docs/design/需求清单.md` R13)
|
||||
- **不引入版本叙事**:不使用 v1/v2/v3 之类版本编号,不以「MVP」等标签或指称显式描述任何历史阶段;文档内容必须**自立**——不得引用、描述任何当前分支之外、可能被删除的分支内容
|
||||
- **本地不入库也不 ignore 的物料**(AI 提交时务必避开):`api*.json`(上游实测快照)、`session-archive/`(作者主动备份的会话记录,如 `v1-设计-*.md`)——不加入 git、不写 `.gitignore`,靠提交时人工/执行者目视把关;`v1-设计-1.md` 为历史对话原档,允许保留旧术语,不作现行依据
|
||||
- **不引入版本叙事**:不使用 v1/v2/v3 之类版本编号,不以「MVP」等标签或指称显式描述任何历史阶段;文档内容必须**自立**——不得引用、描述任何当前仓库之外、可能被删除的分支或目录内容
|
||||
- **本地不入库也不 ignore 的物料**(AI 提交时务必避开):`api*.json`(上游实测快照)、`session-archive/`(作者主动备份的会话记录)——不加入 git、不写 `.gitignore`,靠提交时人工/执行者目视把关
|
||||
|
||||
## 项目速览(速记)
|
||||
|
||||
- **`design`(本分支)= 设计文档中心 + 实现主线**:实现代码与文档同分支演进(代码落点已定案),monorepo 结构(`apps/server` · `apps/web` · `packages/core` · `packages/db`)见 `docs/design/技术选型.md` §4
|
||||
- 数据模型:四张基线表(`api_keys` · `usage_logs` · `payments` · `qpu_history`)+ 三张访问主体表(`members` · `users` · `sessions`)设计见本目录,**七篇已定稿(2026-10-01,经作者逐篇审阅)**;同步架构(轮询 + `request_id` 去重 + 攒库)的动因由上游物理约束决定,架构结论不变
|
||||
- 需求总集与待裁决项(平台身份体系、费用分摊与账单、充值折算、多订阅、数据资产保护)见 `docs/design/需求清单.md`;交付形态已裁决(双形态并行,完整形态先行)
|
||||
- **本仓库 = 设计文档中心 + 实现主线**:实现代码与文档同仓演进,monorepo 结构(`apps/server` · `apps/engine` · `apps/web` · `packages/core` · `packages/db`)见 `docs/design/技术选型.md` §4
|
||||
- 数据模型:基线表(`api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history`)+ 三张访问主体表(`members` · `users` · `sessions`)——支付登记已拆为 `subscriptions` + `topups`(见 `docs/design/payments建模评估.md`);同步架构(轮询 + `request_id` 去重 + 攒库)的动因由上游物理约束决定
|
||||
- 需求总集与裁决档案见 `docs/design/需求清单.md`;交付形态已裁决(双形态并行,完整形态先行)
|
||||
- 上游约束速记:只有 sk-key、无时间过滤接口 → 轮询攒库是唯一路径(README「架构的根本动因」)
|
||||
|
||||
## 命令
|
||||
@@ -25,15 +25,17 @@ Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun`
|
||||
| 命令 | 用途 |
|
||||
|------|------|
|
||||
| `bun install` | 安装 / 链接 workspace 依赖(在仓库根) |
|
||||
| `bun test` | 全部测试(core 契约 + server HTTP + **集成**);集成需 `DATABASE_URL`(compose 中的 PG),无库时自动跳过 |
|
||||
| `bun --env-file=.env scripts/seed-from-samples.ts` | 从根目录 `api*.json` 实测快照灌开发库(幂等);验收接口口径用 |
|
||||
| `bun run db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
|
||||
| `bun run db:push` | 推送到本地库(开发期;**服务启动不自动迁**) |
|
||||
| `bun run db:studio` | Drizzle Studio 看库 |
|
||||
| `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) |
|
||||
| `bun run dev:server` | 开发跑母体后端(hot reload) |
|
||||
| `bun run dev:server` | 开发跑后端(hot reload) |
|
||||
| `bun run dev:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) |
|
||||
| `bun run dev:web` | 开发跑前端 Vite |
|
||||
| `docker compose up -d` | 起开发用 PostgreSQL |
|
||||
| `bun run --filter @new-api-key-usage/db db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
|
||||
| `bun run --filter @new-api-key-usage/db db:push` | 推送到本地库(开发期) |
|
||||
| `bun run build:web` | 前端类型检查 + 产物构建 |
|
||||
| `bun run build:server` | 编译后端单文件 → `bin/naku-server`(当前平台) |
|
||||
| `bun run build:engine` | 编译同步引擎单文件 → `bin/naku-engine`(当前平台) |
|
||||
| `bun run pack` | 打包/构建 CLI(citty + clack):`pack release` 交互打部署包 → `release/`;`pack build` 本地构建 → `bin/`;支持 `--yes` / `--target` / `--only`;`pack release --yes` 非交互打部署包(目标默认 linux-x64) |
|
||||
|
||||
环境变量见 `.env.example`(复制为 `.env` 后填写)。
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 站点地址按部署环境改:
|
||||
# 本地/内网 HTTP → :80
|
||||
# 公网 HTTPS → your.domain.example(Caddy 自动签证书)
|
||||
:80 {
|
||||
root * /srv
|
||||
|
||||
# 接口反代到 server 容器
|
||||
handle /api/* {
|
||||
reverse_proxy server:18456
|
||||
}
|
||||
|
||||
# SPA:非 /api 的路径回退 index.html
|
||||
handle {
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# 语法:docker/dockerfile:1
|
||||
# 只装配 release/ 产物,构建期不编译业务代码
|
||||
|
||||
# ---------- 应用运行时 ----------
|
||||
# 用 Debian slim:与 Bun --compile(glibc)同源,兼容面更稳;
|
||||
# CA 与 libstdc++ 显式安装,不依赖 slim 是否自带
|
||||
FROM debian:bookworm-slim AS app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libstdc++6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
# --chmod:Windows 打包 / 跨机传输常丢可执行位,缺 x 时容器内 exec 以 126 退出
|
||||
COPY --chmod=755 release/naku-server release/naku-engine release/naku-migrate /app/
|
||||
COPY release/drizzle /app/drizzle
|
||||
ENV MIGRATIONS_FOLDER=/app/drizzle
|
||||
CMD ["/app/naku-server"]
|
||||
|
||||
# ---------- 网关(Caddy + 前端静态) ----------
|
||||
FROM caddy:2-alpine AS proxy
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY release/dist /srv
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 skycurtain
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,92 +1,172 @@
|
||||
# new-api-key-usage
|
||||
|
||||
> 针对 NewAPI 平台的 key 用量统计工具
|
||||
> NewAPI 网关下的 key 用量统计工具(产品名 **NAKU**)
|
||||
|
||||
定时用每个 sk-key 轮询网关日志接口,按 `request_id` 去重,攒全量日志到数据库;任意时间范围的用量查询,即一条本地聚合查询。
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 当前状态 | **实现推进中**(`design` 分支 = 文档中心 + 实现主线)——设计文档全部定稿(含同步引擎);后端全量落地(core / db / server / engine + Scalar 在线文档),同步引擎已人工验收;**前端仅有 antdv-next 能力 spike,真实页面待开工** |
|
||||
| 技术栈 | 全栈 TypeScript(选型定稿见 [`docs/design/技术选型.md`](docs/design/技术选型.md)):母体 Bun + Hono + Drizzle + PostgreSQL · 前端 Vue 3 + antdv-next |
|
||||
| 当前状态 | **实现推进中**——设计文档主体定稿;后端 + 同步引擎 + 前端(管理面与成员视图)已落地;部署链已定(见 [`docs/design/部署方案.md`](docs/design/部署方案.md));Cloudflare Workers 抽取版待做 |
|
||||
| 技术栈 | 全栈 TypeScript(选型见 [`docs/design/技术选型.md`](docs/design/技术选型.md)):Bun + Hono + Drizzle + PostgreSQL · 前端 Vue 3 + antdv-next |
|
||||
|
||||
## 项目沿革
|
||||
## 这是什么
|
||||
|
||||
| 阶段 | 形态 | 结局 |
|
||||
|------|------|------|
|
||||
| 初版方案 | Cloudflare Workers | 弃——当时运营方愿意提供服务器资源 |
|
||||
| 新版设计(当前) | **本分支,设计已收官、实现推进中** | 捡回 Cloudflare Workers(**serverless 后端**:Cron 定时轮询 + 云端数据库攒库,非初版原案复刻)作为后续衍生形态;母体完整前后端先行,CFW 延后至功能全集实现后 |
|
||||
面向 [NewAPI](https://github.com/QuantumNous/new-api) 实例的**独立计费辅助平台**:订阅持有人创建多个 sk- API key 分发给多名使用者,平台把「谁用了多少」的事实记录清楚——用量、来源标注、支付参数、多维统计;**不算「谁该付谁多少钱」**(分摊是人和人之间的事)。
|
||||
|
||||
**双形态并行的直接动因**:服务器部署形态下,每次迭代都需实例运营方在服务器上手工升级,测试阶段迭代频繁而运营方无暇跟随;若部署到作者自控的 Cloudflare 账号(Workers + Pages),迭代部署权回到作者手里。
|
||||
**适用场景**:同一份订阅额度由多人共享的小团队 / 成本分担群体。
|
||||
|
||||
## 文档地图
|
||||
- 名字直述用途:NewAPI 网关下按 key 查询用量(key usage);产品展示名 NAKU
|
||||
- 仓库名 / 包名统一小写 kebab-case
|
||||
|
||||
| 文档 | 什么时候读 | 状态 |
|
||||
|------|-----------|------|
|
||||
| [`docs/design/需求清单.md`](docs/design/需求清单.md) | **需求权威索引与裁决档案**——R1–R15 全部定案(含裁撤与延后),R 编号体系为后续设计文档的引用锚点 | 定稿(2026-10-01) |
|
||||
| [`docs/design/技术选型.md`](docs/design/技术选型.md) | 母体技术栈裁决(CFW 延后至功能全集实现后,仅零成本 R14 预留):全栈 TS;Bun + Hono + Drizzle/pg;前端 Vue 3 + antdv-next;分层落地与回退;antdv-next 核验通过(§7.1) | 定稿(2026-10-02) |
|
||||
| [`docs/design/api_keys表字段设计.md`](docs/design/api_keys表字段设计.md) | 数据模型第一张表:key 登记、生命周期与成员归属 | 定稿(2026-10-01) |
|
||||
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 数据模型第二张表:日志明细、来源提列、去重与写入策略 | 同上 |
|
||||
| [`docs/design/payments表字段设计.md`](docs/design/payments表字段设计.md) | 数据模型第三张表:支付记录(订阅/充值同构,币种无关,只存事实) | 同上 |
|
||||
| [`docs/design/qpu_history表字段设计.md`](docs/design/qpu_history表字段设计.md) | 数据模型第四张表:换算参数时间线(自动采样、只读) | 同上 |
|
||||
| [`docs/design/members表字段设计.md`](docs/design/members表字段设计.md) | 数据模型新增表:成员实体——key 归属、成员视图身份与按成员统计的锚点 | 定稿(2026-10-01) |
|
||||
| [`docs/design/users表字段设计.md`](docs/design/users表字段设计.md) | 数据模型新增表:管理员(单行)——登录体系的身份落点 | 同上 |
|
||||
| [`docs/design/sessions表字段设计.md`](docs/design/sessions表字段设计.md) | 数据模型新增表:管理面会话——cookie 令牌的服务端对应物 | 同上 |
|
||||
| [`docs/design/交互设计-总览与共性.md`](docs/design/交互设计-总览与共性.md) | 交互设计总纲:访问拓扑、导航、跨页组件、状态与数值呈现约定 | 定稿(2026-10-01) |
|
||||
| [`docs/design/交互设计-登录与会话.md`](docs/design/交互设计-登录与会话.md) | 管理面登录页、引导页、改密、成员登录壳 | 同上 |
|
||||
| [`docs/design/交互设计-全员总览.md`](docs/design/交互设计-全员总览.md) | 管理面首页:健康带 + 按成员 / key 汇总 | 同上 |
|
||||
| [`docs/design/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 同上 |
|
||||
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 订阅/充值同构表单、核对提示 | 同上 |
|
||||
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、令牌签发/重发(明文仅一次)、停用 | 同上 |
|
||||
| [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 同上 |
|
||||
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员单页世界——R12 携带形态定案(徽章 + 展开行) | 同上 |
|
||||
| [`docs/design/API契约-公共约定.md`](docs/design/API契约-公共约定.md) | API 契约总纲:路由隔离与鉴权两档、错误信封、时间 / 数值 / 分页 / 命名(8 条拟定约定经审通过)——后两篇的引用锚点 | 定稿(2026-10-02) |
|
||||
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 16 端点:引导与会话三态、引擎状态、总览聚合、key 登记四态、下钻明细、支付、成员(令牌明文仅一次)、审计;5 项裁定(经审通过)+ 4 项遗留(key 详情已裁决:实现期定) | 定稿(2026-10-02) |
|
||||
| [`docs/design/API契约-成员视图.md`](docs/design/API契约-成员视图.md) | 成员视图 3 端点:令牌验证与页头、单页聚合(含支付参数卡判定)、日志明细(DTO 与管理面同构);4 项裁定(经审通过)+ 1 项遗留 | 定稿(2026-10-02) |
|
||||
| [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) | 独立 `apps/engine` 进程:手写 `setTimeout` 轮次、失败只记录、`engine_status` 快照供 `/api/admin/engine`;周期可配、**不为 CFW 预留** | 已实现(定稿 2026-10-05) |
|
||||
| [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md) | 计费平台在 NewAPI 中可用的接口参考:鉴权档位、请求与返回结构、字段语义、实测行为、源码出处(**唯一上游参考**) | 事实依据 |
|
||||
## 背景与约束
|
||||
|
||||
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(我们定的方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策)
|
||||
- 生命周期:设计文档定稿后不搬家、只改状态(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);被取代时删除(git 历史即考古)
|
||||
- 典型场景:NewAPI(Go + GORM 开源 LLM 网关)实例提供订阅服务,多名使用者通过分发 API key 共享订阅额度——**非商业,纯成本分担**
|
||||
- 平台持有方通常只是实例的**普通用户**:只有 sk-key,**没有实例管理员账号**
|
||||
- 一切数据获取只能走 key 级接口(账号级接口用 sk-key 访问会 401)——这是整个数据链路的设计前提
|
||||
- 部署形态为**双形态并行**:完整前后端跑在自有或托管服务器上(见 [`docs/design/部署方案.md`](docs/design/部署方案.md));Cloudflare Workers 抽取版部署在自己掌控的账号里(见 [`docs/design/需求清单.md`](docs/design/需求清单.md) R13)
|
||||
|
||||
## 这是什么项目
|
||||
**双形态并行的动因**:服务器部署形态下,每次迭代都要经服务器管理员手工升级;Workers 形态把迭代部署权收回自己手里。完整前后端先行,抽取版延后至功能全集实现后。
|
||||
|
||||
- 名字直述用途:NewAPI 网关下按 key 查询用量(key usage)
|
||||
- 全场景统一小写 kebab-case:仓库名 / 包名 / 文档标题一致,无大写变体
|
||||
## 核心能力
|
||||
|
||||
## 背景故事
|
||||
|
||||
- 本工具源于一个真实场景:[NewAPI](https://github.com/QuantumNous/new-api)(Go + GORM 的开源 LLM 网关)实例提供订阅服务,多个使用者通过分发 API key 共享同一份订阅额度
|
||||
- 项目作者是该实例的**普通用户**:购买了订阅额度,创建多个 sk- API key 分发给多名使用者分摊使用成本——**非商业,纯成本分担**
|
||||
- 关键身份约束:项目作者**只有 sk-key,没有实例的管理员账号**。因此:
|
||||
- 一切数据获取只能走 key 级接口(账号级接口用 sk-key 访问会 401)
|
||||
- 部署形态已裁决为**双形态并行**:完整前后端部署在实例运营方提供的服务器上(作者不接触服务器)+ Cloudflare Workers 抽取版部署在作者自控账号(见 [`docs/design/需求清单.md`](docs/design/需求清单.md) R13)
|
||||
|
||||
## 核心需求(基线摘要;完整范围见需求清单)
|
||||
|
||||
| # | 需求 | 说明 |
|
||||
| # | 能力 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | key 用量查询 | 按时间范围查模型请求日志与用量;作者的全员视角经管理面总览实现——「凭 sk-key 的开放查询」与「多 key 合并查询」机制已裁撤(见需求清单裁决记录) |
|
||||
| 2 | key 登记 | 管理通道手动登记:登记后入库并加入轮询;查看面凭已登记的 key 查询(纯只读) |
|
||||
| 1 | key 用量查询 | 按时间范围查模型请求日志与用量;管理端全员视角,成员端各自视角 |
|
||||
| 2 | key 登记 | 管理通道手动登记:入库并加入轮询;生命周期为手动启停(引擎只读 `status`) |
|
||||
| 3 | 支付事实登记 | 订阅权益与充值入账分表登记;`billing_domain` 标账本域,成员同域可见 |
|
||||
| 4 | 多维统计 | 按成员 / key / 计费来源 / 模型的任意时段聚合 |
|
||||
|
||||
基线刻意收缩的部分(费用分摊计算、计费周期管理、多组支持、登录页、admin 角色、会话记忆)与新增域(数据资产保护等)的完整需求盘点见 [`docs/design/需求清单.md`](docs/design/需求清单.md)。
|
||||
完整需求范围(含刻意不做的部分)见 [`docs/design/需求清单.md`](docs/design/需求清单.md)。
|
||||
|
||||
## 架构的根本动因:为什么必须攒数据
|
||||
|
||||
这不是优化选项,是上游接口的物理约束决定的**唯一路径**(不随部署形态改变):
|
||||
|
||||
1. sk-key 可用的接口(`/api/usage/token`、`/api/log/token` 等)返回的都是**累计值**或**最近 1000 条**,没有任何接口支持时间范围过滤
|
||||
2. 真正适合算“当月用量”的接口(`/api/log/self/stat`,支持时间范围 + quota 汇总)需要账号登录态,sk-key 访问 401——这是“按时间段统计用量”无法纯客户端实现的**核心障碍**
|
||||
2. 真正适合算「当月用量」的接口(`/api/log/self/stat`,支持时间范围 + quota 汇总)需要账号登录态,sk-key 访问 401——这是「按时间段统计用量」无法纯客户端实现的**核心障碍**
|
||||
3. `/api/log/token` 硬上限 1000 条、不支持任何参数(分页 / limit / 时间过滤全被忽略)
|
||||
|
||||
→ 结论:轮询同步 + `request_id` 去重入库 + 数据库。**部署形态可以变(运营方服务器 / Cloudflare Workers),攒库架构不变。**上游接口完整实证(含源码出处)见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md)。
|
||||
→ 结论:轮询同步 + `request_id` 去重入库 + 数据库。**部署形态可以变,攒库架构不变。** 上游接口完整实证(含源码出处)见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md)。
|
||||
|
||||
## 技术栈与结构
|
||||
|
||||
全栈 TypeScript,monorepo(Bun workspaces):
|
||||
|
||||
```text
|
||||
apps/
|
||||
server/ # 后端:Hono + Drizzle/pg + Scalar 在线文档
|
||||
engine/ # 同步引擎:独立进程,轮询去重入库
|
||||
web/ # 前端:Vue 3 + antdv-next + Vite
|
||||
packages/
|
||||
core/ # 业务核心:去重、折算、加密、上游客户端、DTO
|
||||
db/ # 数据访问层:Drizzle schema + 仓储
|
||||
```
|
||||
|
||||
数据模型:`api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history` + `members` · `users` · `sessions`。
|
||||
|
||||
## 快速开始
|
||||
|
||||
前置:Bun、一个可用的 PostgreSQL(本机已有或别处均可)。
|
||||
|
||||
```bash
|
||||
bun install
|
||||
cp .env.example .env # DATABASE_URL 指向你已有的 PostgreSQL
|
||||
bun run db:push # 开发期推送 schema(启动不自动迁)
|
||||
bun run dev:server # 后端(默认 18456)
|
||||
bun run dev:engine # 同步引擎(另开终端)
|
||||
bun run dev:web # 前端 Vite(5175)
|
||||
```
|
||||
|
||||
在线 API 文档:`http://127.0.0.1:18456/api/docs`。
|
||||
|
||||
更多命令与协作约定见 [`AGENTS.md`](AGENTS.md)。
|
||||
|
||||
## 部署
|
||||
|
||||
方案详文见 [`docs/design/部署方案.md`](docs/design/部署方案.md)。形态一句话:**本地打产物包 → 覆盖服务器 `release/` → 一条 compose 命令 → 看容器**。
|
||||
|
||||
### 本地打包
|
||||
|
||||
```bash
|
||||
bun run pack release
|
||||
# 交互选:目标平台 · 打包内容 · 是否 tar.gz
|
||||
# 产出 release/:dist · naku-server · naku-engine · naku-migrate · drizzle/
|
||||
|
||||
# 非交互(CI / 一键):
|
||||
bun run pack release --yes
|
||||
# 或 bun run pack release --yes --target=bun-linux-arm64 --only=dist,naku-server
|
||||
```
|
||||
|
||||
### 服务器
|
||||
|
||||
首次安装:把 `docker-compose.yml` · `Dockerfile` · `.dockerignore` · `Caddyfile` · `entrypoint-*.sh` 放到约定目录(如 `/opt/naku/`),再放 `release/`,填写 `.env`(`chmod 600`)。日常更新只覆盖 `release/`。
|
||||
|
||||
```bash
|
||||
cd /opt/naku
|
||||
docker compose up -d --build
|
||||
docker compose ps # migrate 应为 exited(0),其余 running/healthy
|
||||
```
|
||||
|
||||
- 页面:`http://<服务器>/`(若改了 `HTTP_PORT` 则加端口);接口文档:`http://<服务器>/api/docs`
|
||||
- 迁移:由 `migrate` 一次性服务在启动时幂等执行(journal),重复执行不会多灌
|
||||
- 端口:默认 80/443;宿主机被占用时在 `.env` 设 `HTTP_PORT` / `HTTPS_PORT`,再执行固定命令
|
||||
- HTTPS:`Caddyfile` 站点行改成域名,`.env` 设 `COOKIE_SECURE=true`,再执行固定命令
|
||||
|
||||
### 注意
|
||||
|
||||
1. `.env` 不进产物包、不进 git;含 `SK_KEY_ENCRYPTION_SECRET`,须与数据库同等备份
|
||||
2. `COOKIE_SECURE` 必须与访问方式匹配:HTTP + `true` 会「登录后立刻 401」
|
||||
3. `DATABASE_URL` 密码含 `@` `:` `/` 等字符时做 URL 编码
|
||||
|
||||
## 文档地图
|
||||
|
||||
| 文档 | 什么时候读 | 状态 |
|
||||
|------|-----------|------|
|
||||
| [`docs/design/需求清单.md`](docs/design/需求清单.md) | **需求权威索引与裁决档案**——R1–R16 定案;含支付拆表与成员档案/空壳删除裁决 | 定稿(2026-10-10) |
|
||||
| [`docs/design/技术选型.md`](docs/design/技术选型.md) | 技术栈裁决:全栈 TS;Bun + Hono + Drizzle/pg;前端 Vue 3 + antdv-next;分层落地与回退 | 定稿(2026-10-02) |
|
||||
| [`docs/design/部署方案.md`](docs/design/部署方案.md) | 母体交付与更新:产物包、服务器目录、容器编排、迁移时机、安全约定 | 已实现(定稿 2026-10-12) |
|
||||
| [`docs/design/api_keys表字段设计.md`](docs/design/api_keys表字段设计.md) | key 登记、生命周期与成员归属 | 定稿(2026-10-01) |
|
||||
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 日志明细、来源提列、去重与写入策略 | 同上 |
|
||||
| [`docs/design/payments建模评估.md`](docs/design/payments建模评估.md) | 支付建模复盘:订阅权益 vs 充值入账两分;拆表已采纳 | 定稿(2026-10-09) |
|
||||
| [`docs/design/subscriptions表字段设计.md`](docs/design/subscriptions表字段设计.md) | 订阅权益:期间 + 额度预算 + 可选付款事实;`subscription_id` 直连日志 | 定稿(2026-10-12) |
|
||||
| [`docs/design/topups表字段设计.md`](docs/design/topups表字段设计.md) | 充值入账事件:`paid_at` 主轴;不挂订阅 | 定稿(2026-10-12) |
|
||||
| [`docs/design/qpu_history表字段设计.md`](docs/design/qpu_history表字段设计.md) | 换算参数时间线(自动采样、只读) | 定稿(2026-10-01) |
|
||||
| [`docs/design/members表字段设计.md`](docs/design/members表字段设计.md) | 成员实体——key 归属、身份与统计锚点;档案可改、空壳可删 | 定稿(2026-10-10) |
|
||||
| [`docs/design/users表字段设计.md`](docs/design/users表字段设计.md) | 管理员(单行)——登录体系的身份落点 | 同上 |
|
||||
| [`docs/design/sessions表字段设计.md`](docs/design/sessions表字段设计.md) | 管理面会话——cookie 令牌的服务端对应物 | 同上 |
|
||||
| [`docs/design/交互设计-总览与共性.md`](docs/design/交互设计-总览与共性.md) | 交互总纲:访问拓扑、导航、跨页组件、状态与数值呈现约定 | 定稿(2026-10-01) |
|
||||
| [`docs/design/交互设计-登录与会话.md`](docs/design/交互设计-登录与会话.md) | 登录页、引导页、改密、成员登录壳 | 同上 |
|
||||
| [`docs/design/交互设计-全员总览.md`](docs/design/交互设计-全员总览.md) | 管理面首页:健康带 + 按成员 / key 汇总 | 同上 |
|
||||
| [`docs/design/交互设计-调用日志.md`](docs/design/交互设计-调用日志.md) | 调用日志:筛选、汇总条、来源徽章支付弹窗、明细表与跳转链 | 定稿(2026-10-08;模型筛选与汇总条、徽章弹窗修订) |
|
||||
| [`docs/design/实现方案-日志模型筛选与汇总条.md`](docs/design/实现方案-日志模型筛选与汇总条.md) | 日志页模型筛选 + 条数/消费用量汇总条:API、数据层、前端改造与验收 | 已实现(2026-10-13) |
|
||||
| [`docs/design/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 定稿(2026-10-01) |
|
||||
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 支付记录(两 Tab):订阅权益 / 充值入账登记、核对提示、ID/交易号筛选 | 定稿(2026-10-12;补筛选) |
|
||||
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、档案修改、令牌签发/重发、启停、空壳删除 | 定稿(2026-10-10) |
|
||||
| [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 定稿(2026-10-10) |
|
||||
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员视图三页(总览 / 调用日志 / 支付参数);订阅徽章展开 + 调用详情 | 定稿(2026-10-09) |
|
||||
| [`docs/design/API契约-公共约定.md`](docs/design/API契约-公共约定.md) | API 总纲:路由隔离与鉴权两档、错误信封、时间 / 数值 / 分页 / 命名 | 定稿(2026-10-10) |
|
||||
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 18 端点 | 定稿(2026-10-12) |
|
||||
| [`docs/design/API契约-成员视图.md`](docs/design/API契约-成员视图.md) | 成员视图 3 端点:`/me` · `/overview` · `/logs` | 定稿(2026-10-12) |
|
||||
| [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) | 独立 `apps/engine`:轮次、失败只记录、`engine_status` 快照 | 已实现(定稿 2026-10-05) |
|
||||
| [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md) | 上游可用接口参考:鉴权档位、字段语义、实测行为、源码出处(**唯一上游参考**) | 事实依据 |
|
||||
|
||||
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策)
|
||||
- 生命周期:状态流转(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
|
||||
- AI 协作物料:[`AGENTS.md`](AGENTS.md)(协作约定)与 `.agents/skills/`(技能包)随仓库分发——参与开发的 Agent 从这里读取项目约定,人工贡献者亦可参考
|
||||
|
||||
## 关键事实清单(实测 / 源码确认,设计时不得违反)
|
||||
|
||||
> 出处均见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md),此处仅索引。
|
||||
|
||||
1. **`QuotaPerUnit = 500000`**:quota ÷ 500000 = 额度美元——**它不是实付金额**:折算到实付人民币还需乘「购买汇率」(由运营方定价、可变,历史上调整过一次)
|
||||
1. **`QuotaPerUnit = 500000`**:quota ÷ 500000 = 额度美元——**它不是实付金额**;折算到实付金额还需乘「购买汇率」(由运营方定价、可变)
|
||||
2. **去重必须用 `request_id`**:`/api/log/token` 返回的 `id` 是每次从 1 重编的展示序号,绝不能用于去重
|
||||
3. **`created_at` 是 UTC Unix 秒**(三字段交叉实测),北京时间输入需转换
|
||||
4. **1000 条硬上限**:窗口只保留最近 1000 条——实测当前站点的日志速率下约等于 3 天;5 分钟级同步余量充分,但同步停摆超过约 3 天会永久丢数据
|
||||
5. **`TokenAuthReadOnly`**:用量/日志接口用宽松鉴权,key 过期 / 额度耗尽仍可查询(用完也能对账);**显式禁用会被 401 拒绝**
|
||||
3. **`created_at` 是 UTC Unix 秒**,本地时间输入需转换
|
||||
4. **1000 条硬上限**:窗口只保留最近 1000 条——当前日志速率下约等于 3 天;同步停摆超过窗口会永久丢数据
|
||||
5. **`TokenAuthReadOnly`**:用量/日志接口用宽松鉴权,key 过期 / 额度耗尽仍可查询;**显式禁用会被 401 拒绝**
|
||||
6. **`DisplayTokenStatEnabled` 已开启**:billing 接口返回 token 级额度
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) © 2026 skycurtain
|
||||
|
||||
NAKU 是 NewAPI 的**独立客户端**,仅通过 HTTP API 交互,不包含其源码;与上游许可证相互独立。上游 NewAPI 为 AGPL-3.0,本项目不受其传染,亦不衍生自它。
|
||||
|
||||
@@ -8,6 +8,7 @@ setupLogging(env.LOG_LEVEL);
|
||||
const log = appLogger("engine");
|
||||
|
||||
const db = createDb(env.DATABASE_URL);
|
||||
|
||||
const repos = createRepos(db);
|
||||
|
||||
const loop = createSyncLoop({
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# apps/server
|
||||
|
||||
母体后端(Hono + Bun)。
|
||||
|
||||
```sh
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
默认端口 **18456**(可用 `PORT` 覆盖):
|
||||
|
||||
- 在线文档:http://127.0.0.1:18456/api/docs
|
||||
- OpenAPI:http://127.0.0.1:18456/api/openapi.json
|
||||
@@ -3,6 +3,8 @@ import "./zod-openapi-setup.ts";
|
||||
import {
|
||||
changePasswordBody,
|
||||
createMemberBody,
|
||||
createSubscriptionBody,
|
||||
createTopupBody,
|
||||
engineStatus,
|
||||
errorEnvelope,
|
||||
keyItem,
|
||||
@@ -14,7 +16,8 @@ import {
|
||||
memberWithToken,
|
||||
patchKeyBody,
|
||||
patchMemberBody,
|
||||
paymentItem,
|
||||
subscriptionItem,
|
||||
topupItem,
|
||||
auditLogItem,
|
||||
registerKeyBody,
|
||||
setupBody,
|
||||
@@ -50,12 +53,18 @@ export const memberOverviewSchema = nameSchema(
|
||||
);
|
||||
export const logItemSchema = nameSchema(logItem, "LogItem");
|
||||
export const logPageSchema = nameSchema(logPage, "LogPage");
|
||||
export const paymentItemSchema = nameSchema(paymentItem, "PaymentItem");
|
||||
export const subscriptionItemSchema = nameSchema(
|
||||
subscriptionItem,
|
||||
"SubscriptionItem",
|
||||
);
|
||||
export const topupItemSchema = nameSchema(topupItem, "TopupItem");
|
||||
export const auditLogItemSchema = nameSchema(auditLogItem, "AuditLogItem");
|
||||
|
||||
export {
|
||||
changePasswordBody as changePasswordBodySchema,
|
||||
createMemberBody as createMemberBodySchema,
|
||||
createSubscriptionBody as createSubscriptionBodySchema,
|
||||
createTopupBody as createTopupBodySchema,
|
||||
loginBody as loginBodySchema,
|
||||
patchKeyBody as patchKeyBodySchema,
|
||||
patchMemberBody as patchMemberBodySchema,
|
||||
@@ -84,3 +93,8 @@ export const err422 = {
|
||||
description: "校验失败",
|
||||
content: { "application/json": { schema: errorSchema } },
|
||||
} as const;
|
||||
|
||||
export const err502 = {
|
||||
description: "上游不可达",
|
||||
content: { "application/json": { schema: errorSchema } },
|
||||
} as const;
|
||||
|
||||
@@ -4,11 +4,13 @@ import { ERR, appLogger } from "@new-api-key-usage/core";
|
||||
import { createRepos, type Db } from "@new-api-key-usage/db";
|
||||
import { jsonError, type AppVars } from "./middleware.ts";
|
||||
import { adminSessionRoutes } from "./routes/admin-session.ts";
|
||||
import { adminPlatformRoutes } from "./routes/admin-platform.ts";
|
||||
import { adminEngineRoutes } from "./routes/admin-engine.ts";
|
||||
import { adminKeyRoutes } from "./routes/admin-keys.ts";
|
||||
import { adminMemberRoutes } from "./routes/admin-members.ts";
|
||||
import { adminUsageRoutes } from "./routes/admin-usage.ts";
|
||||
import { adminPaymentRoutes } from "./routes/admin-payments.ts";
|
||||
import { adminSubscriptionRoutes } from "./routes/admin-subscriptions.ts";
|
||||
import { adminTopupRoutes } from "./routes/admin-topups.ts";
|
||||
import { adminAuditRoutes } from "./routes/admin-audit.ts";
|
||||
import { memberViewRoutes } from "./routes/member-view.ts";
|
||||
|
||||
@@ -97,7 +99,7 @@ export function createApp(opts: CreateAppOptions) {
|
||||
{ name: "引导与会话", description: "三态判定 / 登录 / 改密" },
|
||||
{ name: "引擎", description: "同步引擎状态" },
|
||||
{ name: "Key 管理", description: "登记 / 启停 / 归属" },
|
||||
{ name: "成员管理", description: "开户 / 令牌 / 启停" },
|
||||
{ name: "成员管理", description: "开户 / 档案 / 令牌 / 启停 / 空壳删除" },
|
||||
{ name: "成员视图", description: "令牌自助查询" },
|
||||
],
|
||||
});
|
||||
@@ -116,8 +118,10 @@ export function createApp(opts: CreateAppOptions) {
|
||||
admin.route("/", adminKeyRoutes());
|
||||
admin.route("/", adminMemberRoutes());
|
||||
admin.route("/", adminUsageRoutes());
|
||||
admin.route("/", adminPaymentRoutes());
|
||||
admin.route("/", adminSubscriptionRoutes());
|
||||
admin.route("/", adminTopupRoutes());
|
||||
admin.route("/", adminAuditRoutes());
|
||||
admin.route("/", adminPlatformRoutes());
|
||||
app.route("/api/admin", admin);
|
||||
|
||||
app.route("/api/member", memberViewRoutes());
|
||||
|
||||
@@ -21,7 +21,7 @@ export function adminAuditRoutes() {
|
||||
method: "get",
|
||||
path: "/audit-logs",
|
||||
summary: "审计日志",
|
||||
description: "分页、时间倒序;可按 domain 过滤(key/payment/member/account)",
|
||||
description: "分页、时间倒序;可按 domain 与 [from,to) 过滤",
|
||||
tags: ["审计"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
@@ -31,6 +31,8 @@ export function adminAuditRoutes() {
|
||||
domain: z
|
||||
.enum(["key", "payment", "member", "account"])
|
||||
.optional(),
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
@@ -49,8 +51,12 @@ export function adminAuditRoutes() {
|
||||
200,
|
||||
Math.max(1, Number(q.page_size ?? 50) || 50),
|
||||
);
|
||||
const from = q.from ? new Date(q.from) : undefined;
|
||||
const to = q.to ? new Date(q.to) : undefined;
|
||||
const { rows, total } = await c.get("repos").audit.listPage({
|
||||
domain: q.domain,
|
||||
from: from && !Number.isNaN(from.getTime()) ? from : undefined,
|
||||
to: to && !Number.isNaN(to.getTime()) ? to : undefined,
|
||||
page,
|
||||
page_size,
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ function toKeyItem(row: ApiKeyRow & { member_name?: string | null }): KeyItem {
|
||||
status: row.status as "active" | "disabled",
|
||||
member_id: row.member_id,
|
||||
member_name: row.member_name ?? null,
|
||||
billing_domain: row.billing_domain,
|
||||
registered_at: toUnixSec(row.registered_at)!,
|
||||
log_synced_at: toUnixSec(row.log_synced_at),
|
||||
usage_synced_at: toUnixSec(row.usage_synced_at),
|
||||
@@ -59,6 +60,7 @@ export function adminKeyRoutes() {
|
||||
query: z.object({
|
||||
member_id: z.string().optional(),
|
||||
status: z.enum(["active", "disabled"]).optional(),
|
||||
billing_domain: z.string().optional(),
|
||||
recent_failed: z.enum(["true", "false"]).optional(),
|
||||
}),
|
||||
},
|
||||
@@ -77,12 +79,15 @@ export function adminKeyRoutes() {
|
||||
const filter: {
|
||||
member_id?: number | "unassigned";
|
||||
status?: string;
|
||||
billing_domain?: string | "none";
|
||||
} = {};
|
||||
if (q.member_id === "unassigned") filter.member_id = "unassigned";
|
||||
else if (q.member_id) filter.member_id = Number(q.member_id);
|
||||
if (q.status === "active" || q.status === "disabled") {
|
||||
filter.status = q.status;
|
||||
}
|
||||
if (q.billing_domain === "none") filter.billing_domain = "none";
|
||||
else if (q.billing_domain) filter.billing_domain = q.billing_domain;
|
||||
let rows = await c.get("repos").keys.listAll(filter);
|
||||
if (q.recent_failed === "true") {
|
||||
const snap = await c.get("repos").engine.getSnapshot();
|
||||
@@ -144,6 +149,10 @@ export function adminKeyRoutes() {
|
||||
}
|
||||
member_id = n;
|
||||
}
|
||||
let billing_domain: string | null = null;
|
||||
if (typeof body?.billing_domain === "string" && body.billing_domain.trim()) {
|
||||
billing_domain = body.billing_domain.trim();
|
||||
}
|
||||
if (!sk_key || !sk_key.startsWith("sk-")) {
|
||||
return jsonError(
|
||||
c,
|
||||
@@ -208,6 +217,7 @@ export function adminKeyRoutes() {
|
||||
key_hash,
|
||||
key_ciphertext,
|
||||
member_id,
|
||||
billing_domain,
|
||||
token_name: usage.name ?? "",
|
||||
});
|
||||
// 首次登记即写入上游额度字段(表设计 §4:首轮日志同步异步补)
|
||||
@@ -241,7 +251,7 @@ export function adminKeyRoutes() {
|
||||
createRoute({
|
||||
method: "patch",
|
||||
path: "/keys/{id}",
|
||||
summary: "启停 / 归属变更",
|
||||
summary: "启停 / 归属 / 账本域变更",
|
||||
description: "部分更新;审计按实际变更字段各落一行",
|
||||
tags: ["Key 管理"],
|
||||
security: [{ adminSession: [] }],
|
||||
@@ -301,6 +311,19 @@ export function adminKeyRoutes() {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (body && "billing_domain" in body) {
|
||||
let nextDomain: string | null = null;
|
||||
if (typeof body.billing_domain === "string" && body.billing_domain.trim()) {
|
||||
nextDomain = body.billing_domain.trim();
|
||||
}
|
||||
if (nextDomain !== before.billing_domain) {
|
||||
patch.billing_domain = nextDomain;
|
||||
actions.push({
|
||||
action: "key.reassign",
|
||||
summary: `key #${before.id} · 账本域变更`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return jsonError(
|
||||
c,
|
||||
|
||||
@@ -135,7 +135,8 @@ export function adminMemberRoutes() {
|
||||
createRoute({
|
||||
method: "patch",
|
||||
path: "/members/{id}",
|
||||
summary: "停用 / 启用",
|
||||
summary: "档案修改 / 停用 / 启用",
|
||||
description: "部分更新 name/remark/status;审计按实际变更字段各落一行",
|
||||
tags: ["成员管理"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
@@ -152,27 +153,123 @@ export function adminMemberRoutes() {
|
||||
},
|
||||
401: err401,
|
||||
404: err404,
|
||||
409: err409,
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const status = body?.status;
|
||||
if (status !== "active" && status !== "disabled") {
|
||||
return jsonError(c, 422, ERR.validation_failed({ status: "非法状态" }));
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
const before = await repos.members.findById(id);
|
||||
if (!before) return jsonError(c, 404, ERR.not_found());
|
||||
const row = await repos.members.updateStatus(id, status);
|
||||
|
||||
const profile: { name?: string; remark?: string } = {};
|
||||
const actions: Array<{ action: string; summary: string }> = [];
|
||||
|
||||
if (typeof body?.name === "string") {
|
||||
const name = body.name.trim();
|
||||
if (!name || name.length > 64) {
|
||||
return jsonError(
|
||||
c,
|
||||
422,
|
||||
ERR.validation_failed({ name: "名称无效" }),
|
||||
);
|
||||
}
|
||||
if (name !== before.name) {
|
||||
const taken = await repos.members.findByName(name);
|
||||
if (taken && taken.id !== id) {
|
||||
return jsonError(c, 409, ERR.member_name_taken());
|
||||
}
|
||||
profile.name = name;
|
||||
actions.push({
|
||||
action: "member.update",
|
||||
summary: `成员 ${before.name} · 改名为 ${name}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (typeof body?.remark === "string") {
|
||||
if (body.remark !== before.remark) {
|
||||
profile.remark = body.remark;
|
||||
actions.push({
|
||||
action: "member.update",
|
||||
summary: `成员 ${before.name} · 修改备注`,
|
||||
});
|
||||
}
|
||||
}
|
||||
let nextStatus: "active" | "disabled" | null = null;
|
||||
if (body?.status === "active" || body?.status === "disabled") {
|
||||
if (body.status !== before.status) {
|
||||
nextStatus = body.status;
|
||||
actions.push({
|
||||
action: body.status === "active" ? "member.enable" : "member.disable",
|
||||
summary: `成员 ${before.name} · ${body.status === "active" ? "启用" : "停用"}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(profile).length === 0 && !nextStatus) {
|
||||
return jsonError(
|
||||
c,
|
||||
422,
|
||||
ERR.validation_failed({ body: "没有需要更新的字段" }),
|
||||
);
|
||||
}
|
||||
|
||||
let row = before;
|
||||
if (Object.keys(profile).length > 0) {
|
||||
row = await repos.members.updateProfile(id, profile);
|
||||
}
|
||||
if (nextStatus) {
|
||||
row = await repos.members.updateStatus(id, nextStatus);
|
||||
}
|
||||
for (const a of actions) {
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "member",
|
||||
action: status === "active" ? "member.enable" : "member.disable",
|
||||
summary: `成员 ${row.name} · ${status === "active" ? "启用" : "停用"}`,
|
||||
action: a.action,
|
||||
summary: a.summary,
|
||||
});
|
||||
return c.json(toMemberItem(row, 0), 200);
|
||||
}
|
||||
return c.json(toMemberItem(row, await repos.members.countKeys(id)), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "delete",
|
||||
path: "/members/{id}",
|
||||
summary: "空壳删除",
|
||||
description: "仅 key_count = 0 可硬删;名下有 key 返回 409 member_has_keys",
|
||||
tags: ["成员管理"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
204: { description: "已删除" },
|
||||
401: err401,
|
||||
404: err404,
|
||||
409: err409,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const repos = c.get("repos");
|
||||
const before = await repos.members.findById(id);
|
||||
if (!before) return jsonError(c, 404, ERR.not_found());
|
||||
const keyCount = await repos.members.countKeys(id);
|
||||
if (keyCount > 0) {
|
||||
return jsonError(c, 409, ERR.member_has_keys(keyCount));
|
||||
}
|
||||
await repos.members.deleteById(id);
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "member",
|
||||
action: "member.delete",
|
||||
summary: `删除成员 ${before.name}`,
|
||||
});
|
||||
return c.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
||||
import {
|
||||
ERR,
|
||||
qpuAt,
|
||||
quotaToUsdStr,
|
||||
resolveQpuTimeline,
|
||||
toUnixSec,
|
||||
type PaymentItem,
|
||||
} from "@new-api-key-usage/core";
|
||||
import type { PaymentRow } from "@new-api-key-usage/db";
|
||||
import {
|
||||
jsonError,
|
||||
requireAdminSession,
|
||||
type AppVars,
|
||||
} from "../middleware.ts";
|
||||
import { err401, err404, err422, paymentItemSchema } from "../api-schemas.ts";
|
||||
import { z } from "zod";
|
||||
|
||||
const createBody = z.object({
|
||||
kind: z.enum(["subscription", "topup"]),
|
||||
paid_at: z.string(),
|
||||
paid_amount: z.number(),
|
||||
remark: z.string().optional().default(""),
|
||||
period_start: z.string().nullable().optional(),
|
||||
period_end: z.string().nullable().optional(),
|
||||
subscription_id: z.number().int().positive().nullable().optional(),
|
||||
quota_granted: z.number().int().nullable().optional(),
|
||||
});
|
||||
|
||||
function toPaymentItem(row: PaymentRow, quota_usd: string): PaymentItem {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as PaymentItem["kind"],
|
||||
paid_at: toUnixSec(row.paid_at)!,
|
||||
paid_amount: row.paid_amount,
|
||||
remark: row.remark,
|
||||
period_start: toUnixSec(row.period_start),
|
||||
period_end: toUnixSec(row.period_end),
|
||||
subscription_id: row.subscription_id,
|
||||
quota_granted: row.quota_granted,
|
||||
quota_usd,
|
||||
};
|
||||
}
|
||||
|
||||
function validatePaymentBody(
|
||||
body: Record<string, unknown>,
|
||||
):
|
||||
| { fields: Record<string, string> }
|
||||
| {
|
||||
values: {
|
||||
kind: string;
|
||||
paid_at: Date;
|
||||
paid_amount: number;
|
||||
remark: string;
|
||||
period_start: Date | null;
|
||||
period_end: Date | null;
|
||||
subscription_id: number | null;
|
||||
quota_granted: number | null;
|
||||
};
|
||||
} {
|
||||
const fields: Record<string, string> = {};
|
||||
const kind = body?.kind;
|
||||
if (kind !== "subscription" && kind !== "topup") {
|
||||
fields.kind = "请选择订阅或充值";
|
||||
return { fields };
|
||||
}
|
||||
if (!body?.paid_at) fields.paid_at = "请填写支付时间";
|
||||
if (typeof body?.paid_amount !== "number") {
|
||||
fields.paid_amount = "请填写支付金额";
|
||||
}
|
||||
if (body?.quota_granted == null) fields.quota_granted = "请填写本笔额度";
|
||||
if (kind === "subscription") {
|
||||
if (!body?.period_start) fields.period_start = "订阅必须填写期间起点";
|
||||
if (body?.subscription_id == null) {
|
||||
fields.subscription_id = "订阅必须填写订阅编号";
|
||||
}
|
||||
}
|
||||
if (Object.keys(fields).length > 0) return { fields };
|
||||
return {
|
||||
values: {
|
||||
kind: kind as string,
|
||||
paid_at: new Date(String(body.paid_at)),
|
||||
paid_amount: Number(body.paid_amount),
|
||||
remark: typeof body.remark === "string" ? body.remark : "",
|
||||
period_start:
|
||||
kind === "subscription" && body.period_start
|
||||
? new Date(String(body.period_start))
|
||||
: null,
|
||||
period_end:
|
||||
body.period_end != null && body.period_end !== ""
|
||||
? new Date(String(body.period_end))
|
||||
: null,
|
||||
subscription_id:
|
||||
kind === "subscription" && body.subscription_id != null
|
||||
? Number(body.subscription_id)
|
||||
: null,
|
||||
quota_granted: Number(body.quota_granted),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function adminPaymentRoutes() {
|
||||
const r = new OpenAPIHono<{ Variables: AppVars }>();
|
||||
r.use("*", requireAdminSession);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/payments",
|
||||
summary: "支付记录清单",
|
||||
description: "全量直返、支付时间倒序;quota_usd 按 paid_at 时刻 QPU 换算",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
responses: {
|
||||
200: {
|
||||
description: "payment 数组",
|
||||
content: {
|
||||
"application/json": { schema: z.array(paymentItemSchema) },
|
||||
},
|
||||
},
|
||||
401: err401,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const repos = c.get("repos");
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
const rows = await repos.payments.listAll();
|
||||
const items = rows.map((row) => {
|
||||
const qpu = qpuAt(row.paid_at, timeline);
|
||||
return toPaymentItem(
|
||||
row,
|
||||
row.quota_granted != null
|
||||
? quotaToUsdStr(row.quota_granted, qpu)
|
||||
: "0.00",
|
||||
);
|
||||
});
|
||||
return c.json(items, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/payments",
|
||||
summary: "登记支付",
|
||||
description: "订阅/充值同构;kind 决定期间与订阅编号是否必填",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
body: {
|
||||
content: { "application/json": { schema: createBody } },
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "payment",
|
||||
content: { "application/json": { schema: paymentItemSchema } },
|
||||
},
|
||||
401: err401,
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const parsed = validatePaymentBody(body);
|
||||
if ("fields" in parsed) {
|
||||
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
const row = await repos.payments.insert(parsed.values);
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "payment",
|
||||
action: "payment.create",
|
||||
summary: `登记支付 #${row.id} ${row.kind}`,
|
||||
});
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
const qpu = qpuAt(row.paid_at, timeline);
|
||||
return c.json(
|
||||
toPaymentItem(
|
||||
row,
|
||||
row.quota_granted != null
|
||||
? quotaToUsdStr(row.quota_granted, qpu)
|
||||
: "0.00",
|
||||
),
|
||||
201,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "put",
|
||||
path: "/payments/{id}",
|
||||
summary: "编辑支付",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: {
|
||||
content: { "application/json": { schema: createBody } },
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "payment",
|
||||
content: { "application/json": { schema: paymentItemSchema } },
|
||||
},
|
||||
401: err401,
|
||||
404: err404,
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const body = (await c.req.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const parsed = validatePaymentBody(body);
|
||||
if ("fields" in parsed) {
|
||||
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
if (!(await repos.payments.findById(id))) {
|
||||
return jsonError(c, 404, ERR.not_found());
|
||||
}
|
||||
const row = await repos.payments.update(id, parsed.values);
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "payment",
|
||||
action: "payment.update",
|
||||
summary: `编辑支付 #${row.id}`,
|
||||
});
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
const qpu = qpuAt(row.paid_at, timeline);
|
||||
return c.json(
|
||||
toPaymentItem(
|
||||
row,
|
||||
row.quota_granted != null
|
||||
? quotaToUsdStr(row.quota_granted, qpu)
|
||||
: "0.00",
|
||||
),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "delete",
|
||||
path: "/payments/{id}",
|
||||
summary: "删除支付",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
204: { description: "No Content" },
|
||||
401: err401,
|
||||
404: err404,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const repos = c.get("repos");
|
||||
const before = await repos.payments.findById(id);
|
||||
if (!before) return jsonError(c, 404, ERR.not_found());
|
||||
await repos.payments.remove(id);
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "payment",
|
||||
action: "payment.delete",
|
||||
summary: `删除支付 #${id}`,
|
||||
});
|
||||
return c.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -51,6 +51,14 @@ const DUMMY_PASSWORD_HASH =
|
||||
export function adminSessionRoutes() {
|
||||
const r = new OpenAPIHono<{ Variables: AppVars }>();
|
||||
|
||||
// 只保护需要会话的端点:GET/DELETE /session、PUT /password
|
||||
// POST /session 是登录、setup* 是引导——不能套鉴权
|
||||
r.use("/session", async (c, next) => {
|
||||
if (c.req.method === "POST") return next();
|
||||
return requireAdminSession(c, next);
|
||||
});
|
||||
r.use("/password", requireAdminSession);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
@@ -121,7 +129,7 @@ export function adminSessionRoutes() {
|
||||
const password = typeof body?.password === "string" ? body.password : "";
|
||||
const fields: Record<string, string> = {};
|
||||
if (!username) fields.username = "请填写用户名";
|
||||
if (password.length < 8) fields.password = "密码至少 8 位";
|
||||
if (!password) fields.password = "请填写密码";
|
||||
if (Object.keys(fields).length > 0) {
|
||||
return jsonError(c, 422, ERR.validation_failed(fields));
|
||||
}
|
||||
@@ -266,11 +274,11 @@ export function adminSessionRoutes() {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const old_password = String(body?.old_password ?? "");
|
||||
const new_password = String(body?.new_password ?? "");
|
||||
if (new_password.length < 8) {
|
||||
if (!new_password) {
|
||||
return jsonError(
|
||||
c,
|
||||
422,
|
||||
ERR.validation_failed({ new_password: "密码至少 8 位" }),
|
||||
ERR.validation_failed({ new_password: "请填写新密码" }),
|
||||
);
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
||||
import {
|
||||
ERR,
|
||||
errorBody,
|
||||
qpuAt,
|
||||
quotaToUsdStr,
|
||||
resolveQpuTimeline,
|
||||
toUnixSec,
|
||||
type SubscriptionItem,
|
||||
} from "@new-api-key-usage/core";
|
||||
import type { SubscriptionRow } from "@new-api-key-usage/db";
|
||||
import {
|
||||
jsonError,
|
||||
requireAdminSession,
|
||||
type AppVars,
|
||||
} from "../middleware.ts";
|
||||
import {
|
||||
createSubscriptionBodySchema,
|
||||
err401,
|
||||
err404,
|
||||
err422,
|
||||
errorSchema,
|
||||
subscriptionItemSchema,
|
||||
} from "../api-schemas.ts";
|
||||
import { z } from "zod";
|
||||
|
||||
function toSubscriptionItem(
|
||||
row: SubscriptionRow,
|
||||
quota_usd: string,
|
||||
): SubscriptionItem {
|
||||
return {
|
||||
id: row.id,
|
||||
subscription_id: row.subscription_id,
|
||||
period_start: toUnixSec(row.period_start)!,
|
||||
period_end: toUnixSec(row.period_end),
|
||||
quota_granted: row.quota_granted,
|
||||
quota_usd,
|
||||
paid_amount: row.paid_amount,
|
||||
paid_at: toUnixSec(row.paid_at),
|
||||
billing_domain: row.billing_domain,
|
||||
remark: row.remark,
|
||||
created_at: toUnixSec(row.created_at)!,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
|
||||
| { fields: Record<string, string> }
|
||||
| {
|
||||
values: {
|
||||
subscription_id: number;
|
||||
period_start: Date;
|
||||
period_end: Date | null;
|
||||
quota_granted: number;
|
||||
paid_amount: number | null;
|
||||
paid_at: Date | null;
|
||||
billing_domain: string | null;
|
||||
remark: string;
|
||||
};
|
||||
} {
|
||||
const fields: Record<string, string> = {};
|
||||
if (body.subscription_id == null || body.subscription_id <= 0) {
|
||||
fields.subscription_id = "请填写订阅编号";
|
||||
}
|
||||
if (!body.period_start) fields.period_start = "请填写期间起点";
|
||||
if (body.quota_granted == null || body.quota_granted <= 0) {
|
||||
fields.quota_granted = "请填写套餐总额度";
|
||||
}
|
||||
if (Object.keys(fields).length > 0) return { fields };
|
||||
return {
|
||||
values: {
|
||||
subscription_id: body.subscription_id,
|
||||
period_start: new Date(body.period_start),
|
||||
period_end: body.period_end
|
||||
? new Date(body.period_end)
|
||||
: null,
|
||||
quota_granted: body.quota_granted,
|
||||
paid_amount: body.paid_amount ?? null,
|
||||
paid_at: body.paid_at ? new Date(body.paid_at) : null,
|
||||
billing_domain:
|
||||
typeof body.billing_domain === "string" && body.billing_domain.trim()
|
||||
? body.billing_domain.trim()
|
||||
: null,
|
||||
remark: body.remark ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function adminSubscriptionRoutes() {
|
||||
const r = new OpenAPIHono<{ Variables: AppVars }>();
|
||||
r.use("*", requireAdminSession);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/subscriptions",
|
||||
summary: "订阅权益清单",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
query: z.object({
|
||||
billing_domain: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "subscription 数组",
|
||||
content: {
|
||||
"application/json": { schema: z.array(subscriptionItemSchema) },
|
||||
},
|
||||
},
|
||||
401: err401,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const repos = c.get("repos");
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
const q = c.req.query();
|
||||
const filter =
|
||||
q.billing_domain === "none"
|
||||
? { billing_domain: "none" as const }
|
||||
: q.billing_domain
|
||||
? { billing_domain: q.billing_domain }
|
||||
: undefined;
|
||||
const rows = await repos.subscriptions.listAll(filter);
|
||||
return c.json(
|
||||
rows.map((row) =>
|
||||
toSubscriptionItem(
|
||||
row,
|
||||
quotaToUsdStr(
|
||||
row.quota_granted,
|
||||
qpuAt(row.paid_at ?? row.created_at, timeline),
|
||||
),
|
||||
),
|
||||
),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/subscriptions",
|
||||
summary: "登记订阅权益",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: createSubscriptionBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "subscription",
|
||||
content: {
|
||||
"application/json": { schema: subscriptionItemSchema },
|
||||
},
|
||||
},
|
||||
401: err401,
|
||||
409: {
|
||||
description: "重复登记",
|
||||
content: { "application/json": { schema: errorSchema } },
|
||||
},
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
const parsed = parseSubBody(body);
|
||||
if ("fields" in parsed) {
|
||||
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
const exists = await repos.subscriptions.findBySubscriptionId(
|
||||
parsed.values.subscription_id,
|
||||
);
|
||||
if (exists) {
|
||||
return jsonError(
|
||||
c,
|
||||
409,
|
||||
errorBody(
|
||||
"duplicate",
|
||||
`订阅 #${parsed.values.subscription_id} 已有登记`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const row = await repos.subscriptions.insert(parsed.values);
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "payment",
|
||||
action: "subscription.create",
|
||||
summary: `订阅 #${row.subscription_id} · 登记`,
|
||||
});
|
||||
return c.json(
|
||||
toSubscriptionItem(
|
||||
row,
|
||||
quotaToUsdStr(
|
||||
row.quota_granted,
|
||||
qpuAt(row.paid_at ?? row.created_at, timeline),
|
||||
),
|
||||
),
|
||||
201,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "put",
|
||||
path: "/subscriptions/{id}",
|
||||
summary: "编辑订阅权益",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: createSubscriptionBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "subscription",
|
||||
content: {
|
||||
"application/json": { schema: subscriptionItemSchema },
|
||||
},
|
||||
},
|
||||
401: err401,
|
||||
404: err404,
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const body = c.req.valid("json");
|
||||
const parsed = parseSubBody(body);
|
||||
if ("fields" in parsed) {
|
||||
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
if (!(await repos.subscriptions.findById(id))) {
|
||||
return jsonError(c, 404, ERR.not_found());
|
||||
}
|
||||
const row = await repos.subscriptions.update(id, parsed.values);
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "payment",
|
||||
action: "subscription.update",
|
||||
summary: `订阅 #${row.subscription_id} · 编辑`,
|
||||
});
|
||||
return c.json(
|
||||
toSubscriptionItem(
|
||||
row,
|
||||
quotaToUsdStr(
|
||||
row.quota_granted,
|
||||
qpuAt(row.paid_at ?? row.created_at, timeline),
|
||||
),
|
||||
),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "delete",
|
||||
path: "/subscriptions/{id}",
|
||||
summary: "删除订阅权益",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
204: { description: "deleted" },
|
||||
401: err401,
|
||||
404: err404,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const repos = c.get("repos");
|
||||
const before = await repos.subscriptions.findById(id);
|
||||
if (!before) return jsonError(c, 404, ERR.not_found());
|
||||
await repos.subscriptions.remove(id);
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "payment",
|
||||
action: "subscription.delete",
|
||||
summary: `订阅 #${before.subscription_id} · 删除`,
|
||||
});
|
||||
return c.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
||||
import {
|
||||
ERR,
|
||||
qpuAt,
|
||||
quotaToUsdStr,
|
||||
resolveQpuTimeline,
|
||||
toUnixSec,
|
||||
type TopupItem,
|
||||
} from "@new-api-key-usage/core";
|
||||
import type { TopupRow } from "@new-api-key-usage/db";
|
||||
import {
|
||||
jsonError,
|
||||
requireAdminSession,
|
||||
type AppVars,
|
||||
} from "../middleware.ts";
|
||||
import {
|
||||
createTopupBodySchema,
|
||||
err401,
|
||||
err404,
|
||||
err422,
|
||||
topupItemSchema,
|
||||
} from "../api-schemas.ts";
|
||||
import { z } from "zod";
|
||||
|
||||
function toTopupItem(row: TopupRow, qpu: number): TopupItem {
|
||||
const quota_derived = Math.round(row.amount * qpu);
|
||||
return {
|
||||
id: row.id,
|
||||
upstream_id: row.upstream_id,
|
||||
amount: row.amount,
|
||||
money: row.money,
|
||||
trade_no: row.trade_no,
|
||||
payment_method: row.payment_method,
|
||||
payment_provider: row.payment_provider,
|
||||
status: row.status,
|
||||
paid_at: toUnixSec(row.paid_at)!,
|
||||
quota_derived,
|
||||
quota_usd: quotaToUsdStr(quota_derived, qpu),
|
||||
billing_domain: row.billing_domain,
|
||||
remark: row.remark,
|
||||
created_at: toUnixSec(row.created_at)!,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
|
||||
| { fields: Record<string, string> }
|
||||
| {
|
||||
values: {
|
||||
upstream_id: number | null;
|
||||
amount: number;
|
||||
money: number;
|
||||
trade_no: string | null;
|
||||
payment_method: string | null;
|
||||
payment_provider: string | null;
|
||||
status: string | null;
|
||||
paid_at: Date;
|
||||
billing_domain: string | null;
|
||||
remark: string;
|
||||
};
|
||||
} {
|
||||
const fields: Record<string, string> = {};
|
||||
if (!body.paid_at) fields.paid_at = "请填写入账时间";
|
||||
if (typeof body.amount !== "number") fields.amount = "请填写计费单位金额";
|
||||
if (typeof body.money !== "number") fields.money = "请填写实付金额";
|
||||
if (Object.keys(fields).length > 0) return { fields };
|
||||
return {
|
||||
values: {
|
||||
upstream_id: body.upstream_id ?? null,
|
||||
amount: body.amount,
|
||||
money: body.money,
|
||||
trade_no: body.trade_no ?? null,
|
||||
payment_method: body.payment_method ?? null,
|
||||
payment_provider: body.payment_provider ?? null,
|
||||
status: body.status ?? "success",
|
||||
paid_at: new Date(body.paid_at),
|
||||
billing_domain:
|
||||
typeof body.billing_domain === "string" && body.billing_domain.trim()
|
||||
? body.billing_domain.trim()
|
||||
: null,
|
||||
remark: body.remark ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function adminTopupRoutes() {
|
||||
const r = new OpenAPIHono<{ Variables: AppVars }>();
|
||||
r.use("*", requireAdminSession);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/topups",
|
||||
summary: "充值入账清单",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
query: z.object({
|
||||
billing_domain: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "topup 数组",
|
||||
content: {
|
||||
"application/json": { schema: z.array(topupItemSchema) },
|
||||
},
|
||||
},
|
||||
401: err401,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const repos = c.get("repos");
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
const q = c.req.query();
|
||||
const filter =
|
||||
q.billing_domain === "none"
|
||||
? { billing_domain: "none" as const }
|
||||
: q.billing_domain
|
||||
? { billing_domain: q.billing_domain }
|
||||
: undefined;
|
||||
const rows = await repos.topups.listAll(filter);
|
||||
return c.json(
|
||||
rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/topups",
|
||||
summary: "登记充值",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: createTopupBodySchema },
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "topup",
|
||||
content: { "application/json": { schema: topupItemSchema } },
|
||||
},
|
||||
401: err401,
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as z.infer<
|
||||
typeof createTopupBodySchema
|
||||
>;
|
||||
const parsed = parseTopupBody(body as z.infer<typeof createTopupBodySchema>);
|
||||
if ("fields" in parsed) {
|
||||
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
const row = await repos.topups.insert(parsed.values);
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "payment",
|
||||
action: "topup.create",
|
||||
summary: `充值 #${row.id} · 登记`,
|
||||
});
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
return c.json(toTopupItem(row, qpuAt(row.paid_at, timeline)), 201);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "put",
|
||||
path: "/topups/{id}",
|
||||
summary: "编辑充值",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: createTopupBodySchema },
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "topup",
|
||||
content: { "application/json": { schema: topupItemSchema } },
|
||||
},
|
||||
401: err401,
|
||||
404: err404,
|
||||
422: err422,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const body = (await c.req.json().catch(() => ({}))) as z.infer<
|
||||
typeof createTopupBodySchema
|
||||
>;
|
||||
const parsed = parseTopupBody(body as z.infer<typeof createTopupBodySchema>);
|
||||
if ("fields" in parsed) {
|
||||
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
|
||||
}
|
||||
const repos = c.get("repos");
|
||||
if (!(await repos.topups.findById(id))) {
|
||||
return jsonError(c, 404, ERR.not_found());
|
||||
}
|
||||
const row = await repos.topups.update(id, parsed.values);
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "payment",
|
||||
action: "topup.update",
|
||||
summary: `充值 #${row.id} · 编辑`,
|
||||
});
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
return c.json(toTopupItem(row, qpuAt(row.paid_at, timeline)), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: "delete",
|
||||
path: "/topups/{id}",
|
||||
summary: "删除充值",
|
||||
tags: ["支付记录"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
204: { description: "deleted" },
|
||||
401: err401,
|
||||
404: err404,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const repos = c.get("repos");
|
||||
const before = await repos.topups.findById(id);
|
||||
if (!before) return jsonError(c, 404, ERR.not_found());
|
||||
await repos.topups.remove(id);
|
||||
await repos.audit.insert({
|
||||
operator: c.get("adminUsername") ?? null,
|
||||
domain: "payment",
|
||||
action: "topup.delete",
|
||||
summary: `充值 #${before.id} · 删除`,
|
||||
});
|
||||
return c.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
resolveQpuTimeline,
|
||||
sumUsage,
|
||||
toUnixSec,
|
||||
usageRowPrecise,
|
||||
type LogItem,
|
||||
} from "@new-api-key-usage/core";
|
||||
import {
|
||||
@@ -49,6 +50,7 @@ const overviewSchema = z.object({
|
||||
key_id: z.number().int(),
|
||||
token_name: z.string(),
|
||||
member_id: z.number().int().nullable(),
|
||||
member_name: z.string().nullable(),
|
||||
status: z.string(),
|
||||
data_cutoff: z.number().int().nullable(),
|
||||
usage: usageTotals,
|
||||
@@ -184,11 +186,15 @@ export function adminUsageRoutes() {
|
||||
}
|
||||
>();
|
||||
const keyMeta = await repos.keysExtra.listIdsWithCutoff();
|
||||
const memberNameById = new Map(
|
||||
(await repos.members.listAll()).map((m) => [m.id, m.name]),
|
||||
);
|
||||
for (const k of keyMeta) {
|
||||
keyMap.set(k.id, {
|
||||
token_name: k.token_name,
|
||||
member_id: k.member_id,
|
||||
member_name: null,
|
||||
member_name:
|
||||
k.member_id == null ? null : (memberNameById.get(k.member_id) ?? null),
|
||||
status: k.status,
|
||||
log_synced_at: k.log_synced_at,
|
||||
rows: [],
|
||||
@@ -207,6 +213,7 @@ export function adminUsageRoutes() {
|
||||
key_id,
|
||||
token_name: v.token_name,
|
||||
member_id: v.member_id,
|
||||
member_name: v.member_name,
|
||||
status: v.status,
|
||||
data_cutoff: toUnixSec(v.log_synced_at),
|
||||
usage: sumUsage(v.rows, timeline),
|
||||
@@ -234,6 +241,7 @@ export function adminUsageRoutes() {
|
||||
};
|
||||
b.key_count += 1;
|
||||
if (
|
||||
v.status === "active" &&
|
||||
v.log_synced_at &&
|
||||
(b.cutoff === null || v.log_synced_at < b.cutoff)
|
||||
) {
|
||||
@@ -271,7 +279,7 @@ export function adminUsageRoutes() {
|
||||
path: "/logs",
|
||||
summary: "日志明细(下钻)",
|
||||
description:
|
||||
"分页;from/to 必填;默认全类型(可 type 筛);token_name 取当前名",
|
||||
"分页;from/to 必填;默认全类型(可 type / model_name 筛);token_name 取当前名;附带 summary 与 models",
|
||||
tags: ["用量"],
|
||||
security: [{ adminSession: [] }],
|
||||
request: {
|
||||
@@ -282,12 +290,13 @@ export function adminUsageRoutes() {
|
||||
member_id: z.string().optional(),
|
||||
billing_source: z.string().optional(),
|
||||
subscription_id: z.string().optional(),
|
||||
model_name: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "items/total",
|
||||
description: "items/total/summary/models",
|
||||
content: { "application/json": { schema: logPageSchema } },
|
||||
},
|
||||
401: err401,
|
||||
@@ -304,23 +313,42 @@ export function adminUsageRoutes() {
|
||||
const page_size = Math.min(200, Math.max(1, Number(q.page_size ?? 50) || 50));
|
||||
const repos = c.get("repos");
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
const { rows, total } = await repos.logs.listPage({
|
||||
const filters = {
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
page,
|
||||
page_size,
|
||||
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
|
||||
member_id: q.member_id ? Number(q.member_id) : undefined,
|
||||
billing_source: q.billing_source || undefined,
|
||||
member_id:
|
||||
q.member_id === "unassigned"
|
||||
? ("unassigned" as const)
|
||||
: q.member_id
|
||||
? Number(q.member_id)
|
||||
: undefined,
|
||||
billing_source:
|
||||
q.billing_source === "none" ? "none" : q.billing_source || undefined,
|
||||
subscription_id: q.subscription_id
|
||||
? Number(q.subscription_id)
|
||||
: undefined,
|
||||
type: q.type != null && q.type !== "" ? Number(q.type) : undefined,
|
||||
model_name: q.model_name || undefined,
|
||||
type:
|
||||
q.type === "error"
|
||||
? ("error" as const)
|
||||
: q.type != null && q.type !== ""
|
||||
? Number(q.type)
|
||||
: undefined,
|
||||
};
|
||||
const { rows, total } = await repos.logs.listPage({
|
||||
...filters,
|
||||
page,
|
||||
page_size,
|
||||
});
|
||||
const sum = await repos.logs.summarize(filters);
|
||||
const usage = sumUsage(sum.usage_rows, timeline);
|
||||
const models = await repos.logs.listModels(filters);
|
||||
|
||||
const items: LogItem[] = rows.map((row) => {
|
||||
const { quota_usd } = sumUsage(
|
||||
[{ quota: row.quota, created_at: row.created_at }],
|
||||
const { quota_usd } = usageRowPrecise(
|
||||
row.quota,
|
||||
row.created_at,
|
||||
timeline,
|
||||
);
|
||||
return {
|
||||
@@ -339,10 +367,51 @@ export function adminUsageRoutes() {
|
||||
is_stream: row.is_stream,
|
||||
billing_source: row.billing_source as LogItem["billing_source"],
|
||||
subscription_id: row.subscription_id,
|
||||
raw: {
|
||||
id: row.id,
|
||||
api_key_id: row.api_key_id,
|
||||
dedupe_key: row.dedupe_key,
|
||||
billing_source: row.billing_source,
|
||||
subscription_id: row.subscription_id,
|
||||
user_id: row.user_id,
|
||||
created_at: toUnixSec(row.created_at),
|
||||
type: row.type,
|
||||
content: row.content,
|
||||
username: row.username,
|
||||
token_name: row.token_name,
|
||||
model_name: row.model_name,
|
||||
quota: row.quota,
|
||||
prompt_tokens: row.prompt_tokens,
|
||||
completion_tokens: row.completion_tokens,
|
||||
use_time: row.use_time,
|
||||
is_stream: row.is_stream,
|
||||
channel: row.channel,
|
||||
channel_name: row.channel_name,
|
||||
token_id: row.token_id,
|
||||
group_name: row.group_name,
|
||||
ip: row.ip,
|
||||
request_id: row.request_id,
|
||||
upstream_request_id: row.upstream_request_id,
|
||||
other: row.other,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return c.json({ items, total }, 200);
|
||||
return c.json(
|
||||
{
|
||||
items,
|
||||
total,
|
||||
summary: {
|
||||
hit_count: sum.hit_count,
|
||||
usage_count: sum.usage_count,
|
||||
error_count: sum.error_count,
|
||||
quota: usage.quota,
|
||||
quota_usd: usage.quota_usd,
|
||||
},
|
||||
models,
|
||||
},
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
ERR,
|
||||
resolveQpuTimeline,
|
||||
sumUsage,
|
||||
usageRowPrecise,
|
||||
toUnixSec,
|
||||
type LogItem,
|
||||
type PaymentItem,
|
||||
} from "@new-api-key-usage/core";
|
||||
import {
|
||||
jsonError,
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
logPageSchema,
|
||||
memberMeSchema,
|
||||
memberOverviewSchema,
|
||||
paymentItemSchema,
|
||||
} from "../api-schemas.ts";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -72,7 +71,7 @@ export function memberViewRoutes() {
|
||||
path: "/overview",
|
||||
summary: "单页聚合",
|
||||
description:
|
||||
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(完全公开透明)",
|
||||
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(同账本域内可见)",
|
||||
tags: ["成员视图"],
|
||||
security: [{ memberToken: [] }],
|
||||
request: {
|
||||
@@ -160,42 +159,66 @@ export function memberViewRoutes() {
|
||||
usage: sumUsage(v.rows, timeline),
|
||||
}));
|
||||
|
||||
// 「该时段涉及」支付:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to)
|
||||
const paymentRows = await repos.payments.listAll();
|
||||
// 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 我域内进行中;
|
||||
// 充值 = 我域内且 paid_at ∈ [from,to)。未标域默认藏(仅「涉及我日志」的订阅例外)。
|
||||
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
|
||||
const myDomains = new Set<string>();
|
||||
for (const k of allKeys) {
|
||||
if (k.billing_domain) myDomains.add(k.billing_domain);
|
||||
}
|
||||
const subRows = await repos.subscriptions.listAll();
|
||||
const usedSubIds = new Set(
|
||||
rows.map((x) => x.subscription_id).filter((v): v is number => v != null),
|
||||
);
|
||||
const payments: PaymentItem[] = [];
|
||||
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
|
||||
for (const p of paymentRows) {
|
||||
const inRange = p.paid_at >= range.from && p.paid_at < range.to;
|
||||
const subscriptionsOut = [];
|
||||
for (const s of subRows) {
|
||||
const ongoing =
|
||||
p.kind === "subscription" &&
|
||||
(p.period_end == null ||
|
||||
p.period_end > new Date() ||
|
||||
(p.period_start != null &&
|
||||
p.period_start <= range.to &&
|
||||
(p.period_end ?? new Date()) >= range.from));
|
||||
const involved =
|
||||
p.kind === "topup"
|
||||
? inRange
|
||||
: usedSubIds.has(p.subscription_id ?? -1) || ongoing;
|
||||
if (!involved) continue;
|
||||
const qpu = qpuAt(p.paid_at, timeline);
|
||||
payments.push({
|
||||
id: p.id,
|
||||
kind: p.kind as PaymentItem["kind"],
|
||||
paid_at: toUnixSec(p.paid_at)!,
|
||||
paid_amount: p.paid_amount,
|
||||
remark: p.remark,
|
||||
period_start: toUnixSec(p.period_start),
|
||||
period_end: toUnixSec(p.period_end),
|
||||
subscription_id: p.subscription_id,
|
||||
quota_granted: p.quota_granted,
|
||||
quota_usd:
|
||||
p.quota_granted != null
|
||||
? quotaToUsdStr(p.quota_granted, qpu)
|
||||
: "0.00",
|
||||
s.period_end == null ||
|
||||
s.period_end > new Date() ||
|
||||
(s.period_start <= range.to &&
|
||||
(s.period_end ?? new Date()) >= range.from);
|
||||
const involved = usedSubIds.has(s.subscription_id);
|
||||
const inMyDomain =
|
||||
s.billing_domain != null && myDomains.has(s.billing_domain);
|
||||
// 涉及我日志的始终可见(事实直连);其余仅我域内进行中可见
|
||||
if (!involved && !(ongoing && inMyDomain)) continue;
|
||||
const qpu = qpuAt(s.paid_at ?? s.created_at, timeline);
|
||||
subscriptionsOut.push({
|
||||
id: s.id,
|
||||
subscription_id: s.subscription_id,
|
||||
period_start: toUnixSec(s.period_start)!,
|
||||
period_end: toUnixSec(s.period_end),
|
||||
quota_granted: s.quota_granted,
|
||||
quota_usd: quotaToUsdStr(s.quota_granted, qpu),
|
||||
paid_amount: s.paid_amount,
|
||||
paid_at: toUnixSec(s.paid_at),
|
||||
billing_domain: s.billing_domain,
|
||||
remark: s.remark,
|
||||
created_at: toUnixSec(s.created_at)!,
|
||||
});
|
||||
}
|
||||
const topupRows = await repos.topups.listAll();
|
||||
const topupsOut = [];
|
||||
for (const t of topupRows) {
|
||||
if (t.billing_domain == null || !myDomains.has(t.billing_domain)) continue;
|
||||
if (!(t.paid_at >= range.from && t.paid_at < range.to)) continue;
|
||||
const qpu = qpuAt(t.paid_at, timeline);
|
||||
const quota_derived = Math.round(t.amount * qpu);
|
||||
topupsOut.push({
|
||||
id: t.id,
|
||||
upstream_id: t.upstream_id,
|
||||
amount: t.amount,
|
||||
money: t.money,
|
||||
trade_no: t.trade_no,
|
||||
payment_method: t.payment_method,
|
||||
payment_provider: t.payment_provider,
|
||||
status: t.status,
|
||||
paid_at: toUnixSec(t.paid_at)!,
|
||||
billing_domain: t.billing_domain,
|
||||
quota_derived,
|
||||
quota_usd: quotaToUsdStr(quota_derived, qpu),
|
||||
remark: t.remark,
|
||||
created_at: toUnixSec(t.created_at)!,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -205,14 +228,8 @@ export function memberViewRoutes() {
|
||||
usage,
|
||||
by_source,
|
||||
keys,
|
||||
payments: payments.map((p) => ({
|
||||
...p,
|
||||
kind: p.kind as string,
|
||||
period_start: p.period_start ?? null,
|
||||
period_end: p.period_end ?? null,
|
||||
subscription_id: p.subscription_id ?? null,
|
||||
quota_granted: p.quota_granted ?? null,
|
||||
})),
|
||||
subscriptions: subscriptionsOut,
|
||||
topups: topupsOut,
|
||||
},
|
||||
200,
|
||||
);
|
||||
@@ -224,7 +241,8 @@ export function memberViewRoutes() {
|
||||
method: "get",
|
||||
path: "/logs",
|
||||
summary: "日志明细(名下 key)",
|
||||
description: "分页;恒 type=2;DTO 与管理面同构",
|
||||
description:
|
||||
"分页;默认全类型(可 type / model_name 筛);DTO 与管理面同构;附带 summary 与 models",
|
||||
tags: ["成员视图"],
|
||||
security: [{ memberToken: [] }],
|
||||
request: {
|
||||
@@ -233,11 +251,16 @@ export function memberViewRoutes() {
|
||||
to: z.string(),
|
||||
page: z.string().optional(),
|
||||
page_size: z.string().optional(),
|
||||
api_key_id: z.string().optional(),
|
||||
billing_source: z.string().optional(),
|
||||
subscription_id: z.string().optional(),
|
||||
model_name: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "items/total",
|
||||
description: "items/total/summary/models",
|
||||
content: { "application/json": { schema: logPageSchema } },
|
||||
},
|
||||
401: err401,
|
||||
@@ -257,16 +280,37 @@ export function memberViewRoutes() {
|
||||
);
|
||||
const repos = c.get("repos");
|
||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||
const { rows, total } = await repos.logs.listMemberPage({
|
||||
member_id: c.get("memberId")!,
|
||||
const member_id = c.get("memberId")!;
|
||||
const filters = {
|
||||
member_id,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
|
||||
billing_source:
|
||||
q.billing_source === "none" ? "none" : q.billing_source || undefined,
|
||||
subscription_id: q.subscription_id
|
||||
? Number(q.subscription_id)
|
||||
: undefined,
|
||||
model_name: q.model_name || undefined,
|
||||
type:
|
||||
q.type === "error"
|
||||
? ("error" as const)
|
||||
: q.type != null && q.type !== ""
|
||||
? Number(q.type)
|
||||
: undefined,
|
||||
};
|
||||
const { rows, total } = await repos.logs.listMemberPage({
|
||||
...filters,
|
||||
page,
|
||||
page_size,
|
||||
});
|
||||
const sum = await repos.logs.summarizeMember(filters);
|
||||
const usage = sumUsage(sum.usage_rows, timeline);
|
||||
const models = await repos.logs.listMemberModels(filters);
|
||||
const items: LogItem[] = rows.map((row) => {
|
||||
const { quota_usd } = sumUsage(
|
||||
[{ quota: row.quota, created_at: row.created_at }],
|
||||
const { quota_usd } = usageRowPrecise(
|
||||
row.quota,
|
||||
row.created_at,
|
||||
timeline,
|
||||
);
|
||||
return {
|
||||
@@ -285,9 +329,50 @@ export function memberViewRoutes() {
|
||||
is_stream: row.is_stream,
|
||||
billing_source: row.billing_source as LogItem["billing_source"],
|
||||
subscription_id: row.subscription_id,
|
||||
raw: {
|
||||
id: row.id,
|
||||
api_key_id: row.api_key_id,
|
||||
dedupe_key: row.dedupe_key,
|
||||
billing_source: row.billing_source,
|
||||
subscription_id: row.subscription_id,
|
||||
user_id: row.user_id,
|
||||
created_at: toUnixSec(row.created_at),
|
||||
type: row.type,
|
||||
content: row.content,
|
||||
username: row.username,
|
||||
token_name: row.token_name,
|
||||
model_name: row.model_name,
|
||||
quota: row.quota,
|
||||
prompt_tokens: row.prompt_tokens,
|
||||
completion_tokens: row.completion_tokens,
|
||||
use_time: row.use_time,
|
||||
is_stream: row.is_stream,
|
||||
channel: row.channel,
|
||||
channel_name: row.channel_name,
|
||||
token_id: row.token_id,
|
||||
group_name: row.group_name,
|
||||
ip: row.ip,
|
||||
request_id: row.request_id,
|
||||
upstream_request_id: row.upstream_request_id,
|
||||
other: row.other,
|
||||
},
|
||||
};
|
||||
});
|
||||
return c.json({ items, total }, 200);
|
||||
return c.json(
|
||||
{
|
||||
items,
|
||||
total,
|
||||
summary: {
|
||||
hit_count: sum.hit_count,
|
||||
usage_count: sum.usage_count,
|
||||
error_count: sum.error_count,
|
||||
quota: usage.quota,
|
||||
quota_usd: usage.quota_usd,
|
||||
},
|
||||
models,
|
||||
},
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# apps/web
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
bun install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
bun dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
bun run build
|
||||
```
|
||||
|
||||
### Run Unit Tests with [Vitest](https://vitest.dev/)
|
||||
|
||||
```sh
|
||||
bun test:unit
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
bun lint
|
||||
```
|
||||
+4
-1
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>antdv-next §3.7 能力 spike</title>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>NAKU · NewAPI 用量统计</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -16,8 +16,13 @@
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@antdv-next/icons": "^1.1.2",
|
||||
"@new-api-key-usage/core": "workspace:*",
|
||||
"antdv-next": "^1.6.0",
|
||||
"@tanstack/vue-query": "^5.104.1",
|
||||
"@vueuse/core": "15.0.0",
|
||||
"antdv-next": "1.6.0",
|
||||
"axios": "^1.20.0",
|
||||
"dayjs": "1.11.23",
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.42",
|
||||
"vue-router": "^5.3.1"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 636 B |
@@ -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 |
@@ -1,168 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* antdv-next §3.7 能力 spike——逐项覆盖《交互设计-总览与共性》清单。
|
||||
* 任一项不覆盖即触发回退(Element Plus / ant-design-vue)并回报作者。
|
||||
*/
|
||||
import { h, ref } from "vue";
|
||||
|
||||
const collapsed = ref(false);
|
||||
const drawerOpen = ref(false);
|
||||
const modalOpen = ref(false);
|
||||
const confirmLoading = ref(false);
|
||||
const range = ref<[string, string] | null>(null);
|
||||
const skKey = ref("");
|
||||
const quotaMode = ref<"usd" | "quota">("usd");
|
||||
const page = ref(1);
|
||||
|
||||
const dataSource = [
|
||||
{
|
||||
key: "1",
|
||||
token_name: "小张-01",
|
||||
billing_source: "subscription",
|
||||
status: "active",
|
||||
quota: 250000,
|
||||
quota_usd: "0.50",
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
token_name: "小李-02",
|
||||
billing_source: "wallet",
|
||||
status: "disabled",
|
||||
quota: 100000,
|
||||
quota_usd: "0.20",
|
||||
},
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ title: "名称", dataIndex: "token_name", key: "token_name" },
|
||||
{
|
||||
title: "来源",
|
||||
key: "billing_source",
|
||||
customRender: ({ record }: { record: (typeof dataSource)[0] }) =>
|
||||
h(
|
||||
"a-tag",
|
||||
{ color: record.billing_source === "subscription" ? "blue" : "gold" },
|
||||
() => (record.billing_source === "subscription" ? "订阅" : "充值"),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
customRender: ({ record }: { record: (typeof dataSource)[0] }) =>
|
||||
h(
|
||||
"a-badge",
|
||||
{
|
||||
status: record.status === "active" ? "success" : "default",
|
||||
text: record.status === "active" ? "启用" : "停用",
|
||||
},
|
||||
),
|
||||
},
|
||||
{ title: "quota", dataIndex: "quota", key: "quota" },
|
||||
{ title: "额度美元", dataIndex: "quota_usd", key: "quota_usd" },
|
||||
];
|
||||
|
||||
function onCopy() {
|
||||
// toast:复制反馈
|
||||
(window as unknown as { antd?: { message?: { success: (m: string) => void } } })
|
||||
.antd?.message?.success?.("已复制");
|
||||
}
|
||||
|
||||
function onRangeChange(vals: [string, string] | null) {
|
||||
range.value = vals;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="min-height: 100vh">
|
||||
<a-layout-sider v-model:collapsed="collapsed" collapsible>
|
||||
<div style="color: #fff; padding: 16px; font-weight: 600">
|
||||
new-api-key-usage
|
||||
</div>
|
||||
<a-menu theme="dark" mode="inline" default-selected-keys="['spike']">
|
||||
<a-menu-item key="spike">能力 Spike</a-menu-item>
|
||||
<a-menu-item key="overview" disabled>全员总览</a-menu-item>
|
||||
<a-menu-item key="keys" disabled>Key 管理</a-menu-item>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
<a-layout>
|
||||
<a-layout-content style="margin: 16px; background: #fff; padding: 16px">
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
closable
|
||||
message="antdv-next §3.7 能力 spike"
|
||||
description="可折叠侧边栏 / 抽屉 · 密集表格与展开行 · 对话框与二次确认 · message / Alert · DatePicker 范围 · 大号粘贴 · 徽章 / Segmented / Skeleton / Pagination"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<a-space direction="vertical" style="width: 100%" :size="16">
|
||||
<a-space wrap>
|
||||
<a-button type="primary" @click="drawerOpen = true">打开抽屉</a-button>
|
||||
<a-button @click="modalOpen = true">打开对话框</a-button>
|
||||
<a-button @click="onCopy">message.toast</a-button>
|
||||
<a-popconfirm title="确认停用?" @confirm="onCopy">
|
||||
<a-button danger>二次确认</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
|
||||
<a-space wrap>
|
||||
<a-range-picker
|
||||
style="width: 320px"
|
||||
@change="(_: unknown, vals: [string, string]) => onRangeChange(vals)"
|
||||
/>
|
||||
<a-segmented
|
||||
v-model:value="quotaMode"
|
||||
:options="[
|
||||
{ label: '美元', value: 'usd' },
|
||||
{ label: 'quota', value: 'quota' },
|
||||
]"
|
||||
/>
|
||||
</a-space>
|
||||
|
||||
<a-textarea
|
||||
v-model:value="skKey"
|
||||
:rows="4"
|
||||
placeholder="大号粘贴输入框:sk-key 登记对话框用"
|
||||
/>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
:scroll="{ x: 800 }"
|
||||
:expand-column-width="48"
|
||||
>
|
||||
<template #expandedRowRender="{ record }">
|
||||
<div style="padding: 8px">
|
||||
来源徽章展开行:{{ record.billing_source }} ·
|
||||
<a-tag color="green">订阅 #3</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-pagination v-model:current="page" :total="50" show-size-changer />
|
||||
<a-skeleton active :paragraph="{ rows: 2 }" />
|
||||
</a-space>
|
||||
|
||||
<a-drawer
|
||||
v-model:open="drawerOpen"
|
||||
title="可折叠侧边栏 / 抽屉"
|
||||
placement="right"
|
||||
:width="320"
|
||||
>
|
||||
<p>抽屉能力:筛选面板 / 详情。</p>
|
||||
</a-drawer>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalOpen"
|
||||
title="对话框"
|
||||
:confirm-loading="confirmLoading"
|
||||
@ok="modalOpen = false"
|
||||
>
|
||||
<p>对话框 + 二次确认能力。</p>
|
||||
</a-modal>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -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,35 @@
|
||||
import type { KeyItem } from "@new-api-key-usage/core";
|
||||
import { adminHttp } from "../http.ts";
|
||||
|
||||
export type KeyListFilters = {
|
||||
member_id?: number | "unassigned";
|
||||
status?: "active" | "disabled";
|
||||
billing_domain?: string | "none";
|
||||
recent_failed?: boolean;
|
||||
};
|
||||
|
||||
export async function fetchKeys(filters: KeyListFilters = {}): Promise<KeyItem[]> {
|
||||
const { data } = await adminHttp.get<KeyItem[]>("/keys", { params: filters });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function registerKey(body: {
|
||||
sk_key: string;
|
||||
member_id?: number | null;
|
||||
billing_domain?: string | null;
|
||||
}): Promise<KeyItem> {
|
||||
const { data } = await adminHttp.post<KeyItem>("/keys", body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function patchKey(
|
||||
id: number,
|
||||
body: {
|
||||
status?: "active" | "disabled";
|
||||
member_id?: number | null;
|
||||
billing_domain?: string | null;
|
||||
},
|
||||
): Promise<KeyItem> {
|
||||
const { data } = await adminHttp.patch<KeyItem>(`/keys/${id}`, body);
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { LogPage } from "@new-api-key-usage/core";
|
||||
import { adminHttp } from "../http.ts";
|
||||
|
||||
export type AdminLogFilters = {
|
||||
from: string;
|
||||
to: string;
|
||||
page: number;
|
||||
page_size: number;
|
||||
api_key_id?: number;
|
||||
member_id?: number | "unassigned";
|
||||
billing_source?: string;
|
||||
subscription_id?: number;
|
||||
model_name?: string;
|
||||
type?: number | "error";
|
||||
};
|
||||
|
||||
export async function fetchLogs(params: AdminLogFilters): Promise<LogPage> {
|
||||
const { data } = await adminHttp.get<LogPage>("/logs", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { MemberItem } from "@new-api-key-usage/core";
|
||||
import { adminHttp } from "../http.ts";
|
||||
|
||||
export type MemberWithToken = {
|
||||
member: MemberItem;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export async function fetchMembers(): Promise<MemberItem[]> {
|
||||
const { data } = await adminHttp.get<MemberItem[]>("/members");
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 开户:响应含令牌明文(仅此一次) */
|
||||
export async function createMember(body: {
|
||||
name: string;
|
||||
remark?: string;
|
||||
}): Promise<MemberWithToken> {
|
||||
const { data } = await adminHttp.post<MemberWithToken>("/members", body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function patchMember(
|
||||
id: number,
|
||||
body: {
|
||||
name?: string;
|
||||
remark?: string;
|
||||
status?: "active" | "disabled";
|
||||
},
|
||||
): Promise<MemberItem> {
|
||||
const { data } = await adminHttp.patch<MemberItem>(`/members/${id}`, body);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 空壳删除:仅 key_count = 0 */
|
||||
export async function deleteMember(id: number): Promise<void> {
|
||||
await adminHttp.delete(`/members/${id}`);
|
||||
}
|
||||
|
||||
/** 重发:旧令牌即刻失效,明文仅此一次 */
|
||||
export async function issueMemberToken(id: number): Promise<{ token: string }> {
|
||||
const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`);
|
||||
return data;
|
||||
}
|
||||
@@ -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,44 @@
|
||||
import type { SubscriptionItem } from "@new-api-key-usage/core";
|
||||
import { adminHttp } from "../http.ts";
|
||||
|
||||
export type SubscriptionBody = {
|
||||
subscription_id: number;
|
||||
period_start: string;
|
||||
period_end?: string | null;
|
||||
quota_granted: number;
|
||||
paid_amount?: number | null;
|
||||
paid_at?: string | null;
|
||||
billing_domain?: string | null;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
export async function fetchSubscriptions(filter?: {
|
||||
billing_domain?: string | "none";
|
||||
}): Promise<SubscriptionItem[]> {
|
||||
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions", {
|
||||
params: filter,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createSubscription(
|
||||
body: SubscriptionBody,
|
||||
): Promise<SubscriptionItem> {
|
||||
const { data } = await adminHttp.post<SubscriptionItem>("/subscriptions", body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateSubscription(
|
||||
id: number,
|
||||
body: SubscriptionBody,
|
||||
): Promise<SubscriptionItem> {
|
||||
const { data } = await adminHttp.put<SubscriptionItem>(
|
||||
`/subscriptions/${id}`,
|
||||
body,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteSubscription(id: number): Promise<void> {
|
||||
await adminHttp.delete(`/subscriptions/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { TopupItem } from "@new-api-key-usage/core";
|
||||
import { adminHttp } from "../http.ts";
|
||||
|
||||
export type TopupBody = {
|
||||
upstream_id?: number | null;
|
||||
amount: number;
|
||||
money: number;
|
||||
trade_no?: string | null;
|
||||
payment_method?: string | null;
|
||||
payment_provider?: string | null;
|
||||
status?: string | null;
|
||||
paid_at: string;
|
||||
billing_domain?: string | null;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
export async function fetchTopups(filter?: {
|
||||
billing_domain?: string | "none";
|
||||
}): Promise<TopupItem[]> {
|
||||
const { data } = await adminHttp.get<TopupItem[]>("/topups", {
|
||||
params: filter,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createTopup(body: TopupBody): Promise<TopupItem> {
|
||||
const { data } = await adminHttp.post<TopupItem>("/topups", body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateTopup(
|
||||
id: number,
|
||||
body: TopupBody,
|
||||
): Promise<TopupItem> {
|
||||
const { data } = await adminHttp.put<TopupItem>(`/topups/${id}`, body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteTopup(id: number): Promise<void> {
|
||||
await adminHttp.delete(`/topups/${id}`);
|
||||
}
|
||||
@@ -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 {
|
||||
LogPage,
|
||||
SubscriptionItem,
|
||||
TopupItem,
|
||||
UnixSec,
|
||||
UsageTotals,
|
||||
} from "@new-api-key-usage/core";
|
||||
import { memberHttp } from "../http.ts";
|
||||
|
||||
export type MemberMe = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type MemberOverview = {
|
||||
member: { id: number | null; name: string | null };
|
||||
usage: UsageTotals;
|
||||
by_source: Array<{
|
||||
billing_source: string;
|
||||
count: number;
|
||||
quota: number;
|
||||
quota_usd: string;
|
||||
}>;
|
||||
keys: Array<{
|
||||
key_id: number;
|
||||
token_name: string;
|
||||
log_synced_at: UnixSec | null;
|
||||
usage: UsageTotals;
|
||||
}>;
|
||||
subscriptions: SubscriptionItem[];
|
||||
topups: TopupItem[];
|
||||
};
|
||||
|
||||
export async function fetchMe(): Promise<MemberMe> {
|
||||
const { data } = await memberHttp.get<MemberMe>("/me");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchOverview(range: {
|
||||
from: string;
|
||||
to: string;
|
||||
}): Promise<MemberOverview> {
|
||||
const { data } = await memberHttp.get<MemberOverview>("/overview", {
|
||||
params: range,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export type MemberLogQuery = {
|
||||
from: string;
|
||||
to: string;
|
||||
page: number;
|
||||
page_size: number;
|
||||
api_key_id?: number;
|
||||
billing_source?: string;
|
||||
subscription_id?: number;
|
||||
model_name?: string;
|
||||
type?: number | "error";
|
||||
};
|
||||
|
||||
export async function fetchLogs(query: MemberLogQuery): Promise<LogPage> {
|
||||
const { data } = await memberHttp.get<LogPage>("/logs", {
|
||||
params: query,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* vue-query key 工厂——失效与缓存的唯一命名源。
|
||||
* 约定:`[面, 端点, …参数]`,参数保持可 JSON 序列化。
|
||||
*/
|
||||
export type KeyFilters = {
|
||||
member_id?: number | "unassigned";
|
||||
status?: "active" | "disabled";
|
||||
recent_failed?: boolean;
|
||||
};
|
||||
|
||||
export type PageQuery = {
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
|
||||
export type TimeRange = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
export const qk = {
|
||||
admin: {
|
||||
setupState: () => ["admin", "setup-state"] as const,
|
||||
session: () => ["admin", "session"] as const,
|
||||
engine: () => ["admin", "engine"] as const,
|
||||
overview: (range: TimeRange) =>
|
||||
["admin", "overview", range.from, range.to] as const,
|
||||
keys: (filters: KeyFilters) => ["admin", "keys", filters] as const,
|
||||
logs: (range: TimeRange & PageQuery & Record<string, unknown>) =>
|
||||
[
|
||||
"admin",
|
||||
"logs",
|
||||
range.from,
|
||||
range.to,
|
||||
range.page,
|
||||
range.page_size,
|
||||
range.api_key_id ?? "",
|
||||
range.member_id ?? "",
|
||||
range.model_name ?? "",
|
||||
range.billing_source ?? "",
|
||||
range.subscription_id ?? "",
|
||||
range.type ?? "",
|
||||
] as const,
|
||||
subscriptions: () => ["admin", "subscriptions"] as const,
|
||||
topups: () => ["admin", "topups"] as const,
|
||||
qpuLatest: () => ["admin", "qpu-latest"] as const,
|
||||
members: () => ["admin", "members"] as const,
|
||||
auditLogs: (q: PageQuery & { domain?: string; from?: string; to?: string }) =>
|
||||
["admin", "audit-logs", q.page, q.page_size, q.domain ?? "", q.from ?? "", q.to ?? ""] as const,
|
||||
},
|
||||
member: {
|
||||
me: () => ["member", "me"] as const,
|
||||
overview: (range: TimeRange) =>
|
||||
["member", "overview", range.from, range.to] as const,
|
||||
logs: (range: TimeRange & PageQuery & Record<string, unknown>) =>
|
||||
[
|
||||
"member",
|
||||
"logs",
|
||||
range.from,
|
||||
range.to,
|
||||
range.page,
|
||||
range.page_size,
|
||||
range.api_key_id ?? "",
|
||||
range.model_name ?? "",
|
||||
range.billing_source ?? "",
|
||||
range.subscription_id ?? "",
|
||||
range.type ?? "",
|
||||
] as const,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
// 路由出口 + 全局中文(DatePicker 等默认 en-US,须用 ConfigProvider)
|
||||
import zhCN from "antdv-next/locale/zh_CN";
|
||||
import dayjs from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
|
||||
dayjs.locale("zh-cn");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :locale="zhCN">
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 健康带(交互-全员总览 §1.1):引擎轮次 · 全池数据截止 · 未关联订阅
|
||||
* 文案偏完整说明,不追求极简电报体。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import type { EngineStatus } from "@new-api-key-usage/core";
|
||||
import type { AdminOverview } from "@/api/admin/overview.ts";
|
||||
import { formatMinute, hoursSince } from "@/utils/time.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
engine: EngineStatus | undefined;
|
||||
overview: AdminOverview | undefined;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "go-failed-keys"): void;
|
||||
}>();
|
||||
|
||||
const lastRound = computed(() => props.engine?.last_round ?? null);
|
||||
const failedCount = computed(() => lastRound.value?.failed ?? 0);
|
||||
const cutoff = computed(() => props.overview?.data_cutoff ?? null);
|
||||
const cutoffHours = computed(() => hoursSince(cutoff.value));
|
||||
const cutoffStale = computed(
|
||||
() => cutoffHours.value != null && cutoffHours.value > 2,
|
||||
);
|
||||
const unassigned = computed(
|
||||
() => props.overview?.unassigned_subscription ?? null,
|
||||
);
|
||||
const unassignedBad = computed(
|
||||
() => (unassigned.value?.count ?? 0) > 0 || (unassigned.value?.quota ?? 0) > 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="health-band">
|
||||
<a-skeleton v-if="loading" active :paragraph="{ rows: 1 }" :title="false" />
|
||||
<template v-else>
|
||||
<div class="health-band__item">
|
||||
<div class="health-band__title">同步引擎</div>
|
||||
<div class="health-band__body">
|
||||
<template v-if="lastRound">
|
||||
最近一次同步轮次共处理
|
||||
<strong>{{ lastRound.keys_total }}</strong>
|
||||
枚 key,其中成功
|
||||
<strong>{{ lastRound.succeeded }}</strong>
|
||||
枚。
|
||||
<template v-if="failedCount > 0">
|
||||
有
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
class="health-band__fail-btn"
|
||||
@click="emit('go-failed-keys')"
|
||||
>
|
||||
{{ failedCount }} 枚拉取失败
|
||||
</a-button>
|
||||
,可到 Key 管理筛选「最近轮次失败」查看是哪几枚。
|
||||
</template>
|
||||
<template v-else>没有拉取失败。</template>
|
||||
</template>
|
||||
<span v-else class="health-band__muted">
|
||||
引擎尚未完成过同步轮次,或引擎进程未启动——此时用量数据可能还不完整。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="health-band__item">
|
||||
<div class="health-band__title">数据截止</div>
|
||||
<div class="health-band__body">
|
||||
<a-tooltip
|
||||
title="取全部 key 的 log_synced_at 中最慢的那一个;某枚 key 同步失败会一直不写该字段,从而拖住全池截止"
|
||||
>
|
||||
<span>全池同步截止于 <strong>{{ formatMinute(cutoff) }}</strong>(北京时间)。</span>
|
||||
</a-tooltip>
|
||||
<template v-if="cutoffStale">
|
||||
<span v-if="failedCount > 0" class="health-band__warn">
|
||||
截止时间落后超过 2 小时——通常是
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
class="health-band__fail-btn"
|
||||
@click="emit('go-failed-keys')"
|
||||
>
|
||||
同步失败的 key
|
||||
</a-button>
|
||||
一直未成功、拖住了全池最慢值;请到 Key 管理处理(必要时停用)。
|
||||
</span>
|
||||
<span v-else class="health-band__warn">
|
||||
截止时间落后超过 2 小时,而最近轮次没有失败 key——同步链路可能停滞,请检查引擎日志。
|
||||
</span>
|
||||
</template>
|
||||
<span v-else-if="cutoff != null" class="health-band__ok">
|
||||
目前在正常滞后范围内。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="health-band__item">
|
||||
<div class="health-band__title">未关联订阅</div>
|
||||
<div class="health-band__body">
|
||||
<a-tooltip
|
||||
title="统计的是「来源为订阅、但日志上没有订阅编号」的用量;正常情况下应始终为 0"
|
||||
>
|
||||
<template v-if="unassignedBad">
|
||||
<span class="health-band__danger">
|
||||
本时段发现
|
||||
{{ unassigned?.count }} 条 / ${{ unassigned?.quota_usd }}
|
||||
订阅来源用量未关联到任何订阅编号
|
||||
</span>
|
||||
(说明支付记录缺登记,或上游计费行为发生了变化),需要人工核对。
|
||||
</template>
|
||||
<template v-else>
|
||||
本时段没有出现「订阅来源却关联不到订阅编号」的用量,属正常状态。
|
||||
</template>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.health-band {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.health-band__item {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.health-band__title {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.health-band__body {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.health-band__body strong {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.health-band__muted {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.health-band__ok {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.health-band__warn {
|
||||
color: #d48806;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.health-band__danger {
|
||||
color: #cf1322;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.health-band__fail-btn {
|
||||
padding: 0 4px;
|
||||
height: auto;
|
||||
line-height: inherit;
|
||||
color: #cf1322;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 令牌明文对话框:仅签发/重发后出现一次(交互-成员管理 §3)
|
||||
*/
|
||||
import { ref, watch } from "vue";
|
||||
import { message } from "antdv-next";
|
||||
import { CopyOutlined, WarningOutlined } from "@antdv-next/icons";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
token: string;
|
||||
memberName?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:open", v: boolean): void;
|
||||
}>();
|
||||
|
||||
const confirmClose = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) {
|
||||
confirmClose.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** legacy: true —— HTTP 等非 Secure Context 无 Clipboard API 时退回 execCommand */
|
||||
const { copy } = useClipboard({ legacy: true });
|
||||
|
||||
async function onCopy() {
|
||||
try {
|
||||
await copy(props.token);
|
||||
message.success("已复制");
|
||||
} catch {
|
||||
message.error("复制失败,请手动选中复制");
|
||||
}
|
||||
}
|
||||
|
||||
function tryClose() {
|
||||
if (!confirmClose.value) {
|
||||
confirmClose.value = true;
|
||||
return;
|
||||
}
|
||||
emit("update:open", false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="memberName ? `访问令牌 · ${memberName}` : '访问令牌'"
|
||||
:mask-closable="false"
|
||||
:closable="false"
|
||||
:keyboard="false"
|
||||
:footer="null"
|
||||
>
|
||||
<a-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
message="关闭后将无法再次查看——请立即复制并通过你信任的渠道发给成员"
|
||||
/>
|
||||
<div class="token-plain">
|
||||
<code class="token-plain__text">{{ token }}</code>
|
||||
<a-button @click="onCopy">
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="token-plain__footer">
|
||||
<template v-if="!confirmClose">
|
||||
<a-button @click="tryClose">关闭</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="token-plain__confirm">
|
||||
<WarningOutlined style="color: #faad14" />
|
||||
确认已保存并发送?
|
||||
</span>
|
||||
<a-space>
|
||||
<a-button @click="confirmClose = false">再看看</a-button>
|
||||
<a-button type="primary" @click="tryClose">我已保存并发送</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.token-plain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.token-plain__text {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.token-plain__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.token-plain__confirm {
|
||||
margin-right: auto;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,486 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 支付登记弹窗:订阅 / 充值分形态(拆表后)
|
||||
* 时间控件统一精确到秒(show-time)
|
||||
*/
|
||||
import type { Dayjs } from "dayjs";
|
||||
import dayjs from "dayjs";
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { message } from "antdv-next";
|
||||
import type { SubscriptionItem, TopupItem } from "@new-api-key-usage/core";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import PlatformSubscriptionPicker from "@/components/admin/platform-subscription-picker.vue";
|
||||
import PlatformTopupPicker from "@/components/admin/platform-topup-picker.vue";
|
||||
import type {
|
||||
PlatformSubscription,
|
||||
PlatformTopup,
|
||||
} from "@/api/admin/platform.ts";
|
||||
import {
|
||||
useBillingDomainOptions,
|
||||
useCreateSubscription,
|
||||
useCreateTopup,
|
||||
useQpuLatest,
|
||||
useSubscriptions,
|
||||
useTopups,
|
||||
useUpdateSubscription,
|
||||
useUpdateTopup,
|
||||
} from "@/queries/admin.ts";
|
||||
import { unixToBeijingWall, wallAsBeijingIso } from "@/utils/time.ts";
|
||||
|
||||
const TIME_FMT = "YYYY-MM-DD HH:mm:ss";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
tab: "subs" | "topups";
|
||||
editingSub?: SubscriptionItem | null;
|
||||
editingTop?: TopupItem | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ (e: "update:open", v: boolean): void }>();
|
||||
|
||||
const createSub = useCreateSubscription();
|
||||
const updateSub = useUpdateSubscription();
|
||||
const createTop = useCreateTopup();
|
||||
const updateTop = useUpdateTopup();
|
||||
const subsQuery = useSubscriptions();
|
||||
const topupsQuery = useTopups();
|
||||
const qpuQuery = useQpuLatest();
|
||||
const qpu = computed(() => qpuQuery.data.value?.qpu ?? null);
|
||||
|
||||
/** 额度双输入旁的换算预览(不入库,仅展示) */
|
||||
const quotaPreview = computed(() => {
|
||||
if (isSub.value) {
|
||||
if (subForm.quotaMode === "quota") {
|
||||
const q = subForm.quota_granted;
|
||||
if (q == null || !qpu.value) return null;
|
||||
return `≈ $${(q / qpu.value).toFixed(2)}(按当前换算率)`;
|
||||
}
|
||||
const u = subForm.quota_usd;
|
||||
if (u == null || !qpu.value) return null;
|
||||
return `≈ ${Math.round(u * qpu.value)} quota`;
|
||||
}
|
||||
const a = topForm.amount;
|
||||
if (a == null || !qpu.value) return null;
|
||||
return `≈ ${Math.round(a * qpu.value)} quota(按当前换算率)`;
|
||||
});
|
||||
|
||||
const pickerOpen = ref(false);
|
||||
const topPickerOpen = ref(false);
|
||||
const error = ref("");
|
||||
const isSub = ref(props.tab === "subs");
|
||||
|
||||
const registeredSubIds = computed(
|
||||
() => (subsQuery.data.value ?? []).map((s) => s.subscription_id),
|
||||
);
|
||||
const registeredTopIds = computed(
|
||||
() =>
|
||||
(topupsQuery.data.value ?? [])
|
||||
.map((t) => t.upstream_id)
|
||||
.filter((v): v is number => v != null),
|
||||
);
|
||||
|
||||
const domainOptions = useBillingDomainOptions();
|
||||
|
||||
const subForm = reactive({
|
||||
subscription_id: null as number | null,
|
||||
period_start: null as Dayjs | null,
|
||||
period_end: null as Dayjs | null,
|
||||
quota_granted: null as number | null,
|
||||
quotaMode: "quota" as "quota" | "usd",
|
||||
quota_usd: null as number | null,
|
||||
paid_amount: null as number | null,
|
||||
paid_at: null as Dayjs | null,
|
||||
billing_domain: "",
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const topForm = reactive({
|
||||
upstream_id: null as number | null,
|
||||
amount: null as number | null,
|
||||
money: null as number | null,
|
||||
trade_no: "",
|
||||
payment_method: "",
|
||||
payment_provider: "",
|
||||
paid_at: null as Dayjs | null,
|
||||
billing_domain: "",
|
||||
remark: "",
|
||||
});
|
||||
|
||||
function resetSub() {
|
||||
Object.assign(subForm, {
|
||||
subscription_id: null,
|
||||
period_start: null,
|
||||
period_end: null,
|
||||
quota_granted: null,
|
||||
quotaMode: "quota",
|
||||
quota_usd: null,
|
||||
paid_amount: null,
|
||||
paid_at: null,
|
||||
billing_domain: "",
|
||||
remark: "",
|
||||
});
|
||||
}
|
||||
|
||||
function resetTop() {
|
||||
Object.assign(topForm, {
|
||||
upstream_id: null,
|
||||
amount: null,
|
||||
money: null,
|
||||
trade_no: "",
|
||||
payment_method: "",
|
||||
payment_provider: "",
|
||||
paid_at: null,
|
||||
billing_domain: "",
|
||||
remark: "",
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (!v) return;
|
||||
error.value = "";
|
||||
isSub.value = props.tab === "subs";
|
||||
if (props.editingSub) {
|
||||
isSub.value = true;
|
||||
const s = props.editingSub;
|
||||
subForm.subscription_id = s.subscription_id;
|
||||
subForm.period_start = unixToBeijingWall(s.period_start);
|
||||
subForm.period_end =
|
||||
s.period_end != null ? unixToBeijingWall(s.period_end) : null;
|
||||
subForm.quota_granted = s.quota_granted;
|
||||
subForm.quotaMode = "quota";
|
||||
subForm.quota_usd = null;
|
||||
subForm.paid_amount = s.paid_amount;
|
||||
subForm.paid_at =
|
||||
s.paid_at != null ? unixToBeijingWall(s.paid_at) : null;
|
||||
subForm.billing_domain = s.billing_domain ?? "";
|
||||
subForm.remark = s.remark;
|
||||
} else {
|
||||
// 订阅的 paid_at 是「套餐付款时刻」,可空——不默认填今天
|
||||
resetSub();
|
||||
}
|
||||
if (props.editingTop) {
|
||||
isSub.value = false;
|
||||
const t = props.editingTop;
|
||||
topForm.upstream_id = t.upstream_id;
|
||||
topForm.amount = t.amount;
|
||||
topForm.money = t.money;
|
||||
topForm.trade_no = t.trade_no ?? "";
|
||||
topForm.payment_method = t.payment_method ?? "";
|
||||
topForm.payment_provider = t.payment_provider ?? "";
|
||||
topForm.paid_at = unixToBeijingWall(t.paid_at);
|
||||
topForm.billing_domain = t.billing_domain ?? "";
|
||||
topForm.remark = t.remark;
|
||||
} else {
|
||||
resetTop();
|
||||
topForm.paid_at = dayjs();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function onPickSub(s: PlatformSubscription) {
|
||||
subForm.subscription_id = s.id;
|
||||
subForm.period_start = unixToBeijingWall(s.start_time);
|
||||
subForm.period_end =
|
||||
s.end_time != null ? unixToBeijingWall(s.end_time) : null;
|
||||
subForm.quota_granted = s.amount_total;
|
||||
subForm.quotaMode = "quota";
|
||||
}
|
||||
|
||||
function onPickTop(t: PlatformTopup & { paid_at_iso: string }) {
|
||||
topForm.upstream_id = t.id;
|
||||
topForm.amount = t.amount;
|
||||
topForm.money = t.money;
|
||||
topForm.trade_no = t.trade_no ?? "";
|
||||
topForm.payment_method = t.payment_method ?? "";
|
||||
topForm.payment_provider = t.payment_provider ?? "";
|
||||
const sec = t.complete_time ?? t.create_time;
|
||||
topForm.paid_at =
|
||||
sec != null ? unixToBeijingWall(sec) : unixToBeijingWall(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function iso(v: Dayjs | null): string | null {
|
||||
// 北京墙钟进控件 → wallAsBeijingIso 无损回写(与旧登记表单一致)
|
||||
return v ? wallAsBeijingIso(v) : null;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
error.value = "";
|
||||
try {
|
||||
if (isSub.value) {
|
||||
if (!subForm.period_start) {
|
||||
error.value = "请填写期间起点";
|
||||
return;
|
||||
}
|
||||
if (subForm.subscription_id == null) {
|
||||
error.value = "请填写订阅编号";
|
||||
return;
|
||||
}
|
||||
const qpu = qpuQuery.data.value?.qpu ?? 500000;
|
||||
const quota =
|
||||
subForm.quotaMode === "quota"
|
||||
? subForm.quota_granted
|
||||
: subForm.quota_usd != null
|
||||
? Math.round(subForm.quota_usd * qpu)
|
||||
: null;
|
||||
if (quota == null) {
|
||||
error.value = "请填写套餐总额度";
|
||||
return;
|
||||
}
|
||||
if (subForm.paid_amount != null && subForm.paid_amount < 0) {
|
||||
error.value = "付款金额不能为负数";
|
||||
return;
|
||||
}
|
||||
const dup = (subsQuery.data.value ?? []).find(
|
||||
(p) =>
|
||||
p.subscription_id === subForm.subscription_id &&
|
||||
p.id !== props.editingSub?.id,
|
||||
);
|
||||
if (dup) {
|
||||
error.value = `订阅 #${subForm.subscription_id} 已有登记——请编辑原记录`;
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
subscription_id: subForm.subscription_id,
|
||||
period_start: iso(subForm.period_start)!,
|
||||
period_end: iso(subForm.period_end),
|
||||
quota_granted: quota,
|
||||
paid_amount: subForm.paid_amount,
|
||||
paid_at: iso(subForm.paid_at),
|
||||
billing_domain: subForm.billing_domain.trim() || null,
|
||||
remark: subForm.remark,
|
||||
};
|
||||
if (props.editingSub) {
|
||||
await updateSub.mutateAsync({ id: props.editingSub.id, body });
|
||||
} else {
|
||||
await createSub.mutateAsync(body);
|
||||
}
|
||||
} else {
|
||||
if (topForm.amount == null || topForm.amount <= 0) {
|
||||
error.value = "请填写充值金额 amount(须为正)";
|
||||
return;
|
||||
}
|
||||
if (topForm.money == null || topForm.money < 0) {
|
||||
error.value = "实付金额不能为负数";
|
||||
return;
|
||||
}
|
||||
if (!topForm.paid_at) {
|
||||
error.value = "请填写入账时间";
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
upstream_id: topForm.upstream_id,
|
||||
amount: topForm.amount,
|
||||
money: topForm.money,
|
||||
trade_no: topForm.trade_no || null,
|
||||
payment_method: topForm.payment_method || null,
|
||||
payment_provider: topForm.payment_provider || null,
|
||||
status: props.editingTop?.status ?? "success",
|
||||
paid_at: iso(topForm.paid_at)!,
|
||||
billing_domain: topForm.billing_domain.trim() || null,
|
||||
remark: topForm.remark,
|
||||
};
|
||||
if (props.editingTop) {
|
||||
await updateTop.mutateAsync({ id: props.editingTop.id, body });
|
||||
} else {
|
||||
await createTop.mutateAsync(body);
|
||||
}
|
||||
}
|
||||
message.success(props.editingSub || props.editingTop ? "已保存" : "已登记");
|
||||
emit("update:open", false);
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
if (err.fields) {
|
||||
error.value = Object.values(err.fields)[0] || "请检查表单";
|
||||
} else {
|
||||
error.value = err.message || "保存失败";
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="isSub ? (editingSub ? '编辑订阅' : '登记订阅') : editingTop ? '编辑充值' : '登记充值'"
|
||||
:confirm-loading="createSub.isPending.value || createTop.isPending.value || updateSub.isPending.value || updateTop.isPending.value"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
width="560"
|
||||
centered
|
||||
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
|
||||
@ok="onSubmit"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<a-radio-group
|
||||
v-if="!editingSub && !editingTop"
|
||||
v-model:value="isSub"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<a-radio :value="true">订阅</a-radio>
|
||||
<a-radio :value="false">充值</a-radio>
|
||||
</a-radio-group>
|
||||
|
||||
<a-form v-if="isSub" layout="vertical">
|
||||
<a-form-item label="订阅 ID" required>
|
||||
<a-space>
|
||||
<a-input-number
|
||||
v-model:value="subForm.subscription_id"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
style="width: 160px"
|
||||
/>
|
||||
<a-button @click="pickerOpen = true">从平台选用</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
<a-form-item label="期间起止" required>
|
||||
<a-date-picker
|
||||
v-model:value="subForm.period_start"
|
||||
:format="TIME_FMT"
|
||||
:show-time="{ defaultValue: undefined }"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<span style="margin: 0 8px">~</span>
|
||||
<a-date-picker
|
||||
v-model:value="subForm.period_end"
|
||||
:format="TIME_FMT"
|
||||
:show-time="{ defaultValue: undefined }"
|
||||
placeholder="止(空=进行中)"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="套餐总额度" required>
|
||||
<a-radio-group v-model:value="subForm.quotaMode" style="margin-right: 8px">
|
||||
<a-radio-button value="quota">quota</a-radio-button>
|
||||
<a-radio-button value="usd">美元</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-input-number
|
||||
v-if="subForm.quotaMode === 'quota'"
|
||||
v-model:value="subForm.quota_granted"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<a-input-number
|
||||
v-else
|
||||
v-model:value="subForm.quota_usd"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<div v-if="quotaPreview" class="form-note">
|
||||
{{ quotaPreview }} · 落库为原始 quota
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="付款金额 / 付款时间(可空)">
|
||||
<a-input-number
|
||||
v-model:value="subForm.paid_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 140px"
|
||||
placeholder="金额"
|
||||
/>
|
||||
<a-date-picker
|
||||
v-model:value="subForm.paid_at"
|
||||
:format="TIME_FMT"
|
||||
:show-time="{ defaultValue: undefined }"
|
||||
placeholder="钱出手时刻(可空)"
|
||||
style="width: 200px; margin-left: 8px"
|
||||
/>
|
||||
<div class="form-note">
|
||||
社交层付款事实:金额数值、币种由你们约定;未知可留空。登记时刻由系统另记
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="账本域">
|
||||
<a-auto-complete
|
||||
v-model:value="subForm.billing_domain"
|
||||
:options="domainOptions"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-input v-model:value="subForm.remark" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-form v-else layout="vertical">
|
||||
<a-form-item label="充值金额 amount" required>
|
||||
<a-space>
|
||||
<a-input-number
|
||||
v-model:value="topForm.amount"
|
||||
:min="0"
|
||||
:precision="6"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<a-button @click="topPickerOpen = true">从平台选用</a-button>
|
||||
</a-space>
|
||||
<div class="form-note">
|
||||
平台计费侧充值额(不标币种);与「实付」之间汇率由管理员约定。换得 quota ≈ amount × QPU
|
||||
<template v-if="quotaPreview"> · {{ quotaPreview }}</template>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="实付金额 money" required>
|
||||
<a-input-number
|
||||
v-model:value="topForm.money"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<div class="form-note">实际支付金额(不预设币种)</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="入账时间 paid_at" required>
|
||||
<a-date-picker
|
||||
v-model:value="topForm.paid_at"
|
||||
:format="TIME_FMT"
|
||||
:show-time="{ defaultValue: undefined }"
|
||||
style="width: 220px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="交易号 / 渠道">
|
||||
<a-input v-model:value="topForm.trade_no" placeholder="trade_no" style="width: 220px" />
|
||||
<a-input
|
||||
v-model:value="topForm.payment_method"
|
||||
placeholder="method"
|
||||
style="width: 120px; margin-left: 8px"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="topForm.payment_provider"
|
||||
placeholder="provider"
|
||||
style="width: 120px; margin-left: 8px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="账本域">
|
||||
<a-auto-complete
|
||||
v-model:value="topForm.billing_domain"
|
||||
:options="domainOptions"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-input v-model:value="topForm.remark" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<PlatformSubscriptionPicker
|
||||
v-model:open="pickerOpen"
|
||||
:registered-ids="registeredSubIds"
|
||||
@select="onPickSub"
|
||||
/>
|
||||
<PlatformTopupPicker
|
||||
v-model:open="topPickerOpen"
|
||||
:registered-ids="registeredTopIds"
|
||||
@select="onPickTop"
|
||||
/>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-note {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,264 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 订阅选择弹层:登录(仅一次/标签页)+ 列表选用,与登记表单解耦
|
||||
*/
|
||||
import { ref, watch } from "vue";
|
||||
import {
|
||||
listPlatformSubscriptions,
|
||||
loginPlatform,
|
||||
type PlatformSubscription,
|
||||
} from "@/api/admin/platform.ts";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import { formatDateTime } from "@/utils/time.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
/** 已登记的 subscription_id——灰显不可选用 */
|
||||
registeredIds?: number[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:open", v: boolean): void;
|
||||
(e: "select", sub: PlatformSubscription): void;
|
||||
}>();
|
||||
|
||||
const TOKEN_KEY = "naku_platform_token";
|
||||
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
/** 打开后检查登录并拉取中——避免闪现登录表单 */
|
||||
const booting = ref(false);
|
||||
const list = ref<PlatformSubscription[]>([]);
|
||||
|
||||
function readToken(): string {
|
||||
try {
|
||||
return sessionStorage.getItem(TOKEN_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function writeToken(token: string) {
|
||||
try {
|
||||
if (token) sessionStorage.setItem(TOKEN_KEY, token);
|
||||
else sessionStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) {
|
||||
// 有 token:先显示「检查中」,拉完再进列表/登录,不闪登录表单
|
||||
if (readToken()) {
|
||||
booting.value = true;
|
||||
void load(true).finally(() => {
|
||||
booting.value = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
list.value = [];
|
||||
error.value = "";
|
||||
password.value = "";
|
||||
booting.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function load(skipLoginCheck = false) {
|
||||
if (loading.value) return;
|
||||
if (!skipLoginCheck && (!username.value.trim() || !password.value)) return;
|
||||
error.value = "";
|
||||
loading.value = true;
|
||||
try {
|
||||
let token = readToken();
|
||||
if (!token) {
|
||||
const login = await loginPlatform({
|
||||
username: username.value.trim(),
|
||||
password: password.value,
|
||||
});
|
||||
token = login.access_token;
|
||||
writeToken(token);
|
||||
}
|
||||
password.value = "";
|
||||
list.value = await listPlatformSubscriptions(token);
|
||||
if (list.value.length === 0) {
|
||||
error.value = "账号下没有订阅记录";
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
if (err.status === 401 || err.code === "platform_token_invalid") {
|
||||
writeToken("");
|
||||
error.value = "平台登录已失效——请重新输入密码";
|
||||
} else {
|
||||
error.value = err.message || "拉取失败";
|
||||
}
|
||||
list.value = [];
|
||||
password.value = "";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isRegistered(s: PlatformSubscription): boolean {
|
||||
return (props.registeredIds ?? []).includes(s.id);
|
||||
}
|
||||
|
||||
function onUse(s: PlatformSubscription) {
|
||||
if (isRegistered(s)) return;
|
||||
emit("select", s);
|
||||
emit("update:open", false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="从平台选择订阅"
|
||||
:footer="null"
|
||||
:width="520"
|
||||
centered
|
||||
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
|
||||
destroy-on-hidden
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<div v-if="booting || (loading && list.length === 0)" class="plat-pick__boot">
|
||||
<a-spin />
|
||||
<span class="plat-pick__boot-text">正在检查登录并拉取…</span>
|
||||
</div>
|
||||
<template v-else-if="list.length === 0">
|
||||
<p class="plat-pick__hint">
|
||||
使用 NewAPI 控制台账号登录后拉取订阅,仅用于预填登记;密码不保存。
|
||||
</p>
|
||||
<a-form layout="vertical" @finish="load">
|
||||
<a-form-item label="账号">
|
||||
<a-input
|
||||
v-model:value="username"
|
||||
autocomplete="username"
|
||||
@press-enter="load"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="密码">
|
||||
<a-input-password
|
||||
v-model:value="password"
|
||||
autocomplete="current-password"
|
||||
@press-enter="load"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
block
|
||||
:loading="loading"
|
||||
:disabled="!username.trim() || !password"
|
||||
>
|
||||
登录并拉取
|
||||
</a-button>
|
||||
</a-form>
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="error"
|
||||
style="margin-top: 12px"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="plat-pick__list">
|
||||
<div
|
||||
v-for="s in list"
|
||||
:key="s.id"
|
||||
class="plat-pick__item"
|
||||
:class="{ 'is-registered': isRegistered(s) }"
|
||||
@click="onUse(s)"
|
||||
>
|
||||
<div class="plat-pick__head">
|
||||
<strong>订阅 #{{ s.id }}</strong>
|
||||
<span class="plat-pick__status">
|
||||
{{ isRegistered(s) ? "已登记" : s.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="plat-pick__meta">
|
||||
{{ formatDateTime(s.start_time) }} ~
|
||||
{{ s.end_time ? formatDateTime(s.end_time) : "进行中" }}
|
||||
· 额度 {{ s.amount_total }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plat-pick__boot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.plat-pick__boot-text {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.plat-pick__hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.plat-pick__list {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.plat-pick__item {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.plat-pick__item:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.plat-pick__item.is-registered {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.plat-pick__item.is-registered:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.plat-pick__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.plat-pick__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.plat-pick__status {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.plat-pick__meta {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 充值选用弹层:登录 + 传统分页;已登记按 upstream_id 灰显
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
import {
|
||||
listPlatformTopups,
|
||||
loginPlatform,
|
||||
type PlatformTopup,
|
||||
} from "@/api/admin/platform.ts";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import { formatDateTime } from "@/utils/time.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
registeredIds?: number[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:open", v: boolean): void;
|
||||
(e: "select", topup: PlatformTopup & { paid_at_iso: string }): void;
|
||||
}>();
|
||||
|
||||
const TOKEN_KEY = "naku_platform_token";
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
/** 打开后检查登录并拉取中——避免闪现登录表单 */
|
||||
const booting = ref(false);
|
||||
const items = ref<PlatformTopup[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(total.value / PAGE_SIZE)),
|
||||
);
|
||||
|
||||
function readToken(): string {
|
||||
try {
|
||||
return sessionStorage.getItem(TOKEN_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function writeToken(token: string) {
|
||||
try {
|
||||
if (token) sessionStorage.setItem(TOKEN_KEY, token);
|
||||
else sessionStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureToken(): Promise<string> {
|
||||
let token = readToken();
|
||||
if (token) return token;
|
||||
const login = await loginPlatform({
|
||||
username: username.value.trim(),
|
||||
password: password.value,
|
||||
});
|
||||
token = login.access_token;
|
||||
writeToken(token);
|
||||
password.value = "";
|
||||
return token;
|
||||
}
|
||||
|
||||
async function loadPage(p: number, opts?: { allowTokenOnly?: boolean }) {
|
||||
if (loading.value) return;
|
||||
if (!opts?.allowTokenOnly && !readToken() && (!username.value.trim() || !password.value)) {
|
||||
return;
|
||||
}
|
||||
error.value = "";
|
||||
loading.value = true;
|
||||
try {
|
||||
const token = await ensureToken();
|
||||
password.value = "";
|
||||
const data = await listPlatformTopups(token, p, PAGE_SIZE);
|
||||
items.value = data.items;
|
||||
total.value = data.total;
|
||||
page.value = data.page || p;
|
||||
if (items.value.length === 0 && p === 1) {
|
||||
error.value = "账号下没有充值记录";
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
if (err.status === 401 || err.code === "platform_token_invalid") {
|
||||
writeToken("");
|
||||
error.value = "平台登录已失效——请重新输入密码";
|
||||
} else {
|
||||
error.value = err.message || "拉取失败";
|
||||
}
|
||||
items.value = [];
|
||||
password.value = "";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogin() {
|
||||
if (!username.value.trim() || !password.value || loading.value) return;
|
||||
await loadPage(1);
|
||||
}
|
||||
|
||||
function isRegistered(t: PlatformTopup): boolean {
|
||||
return (props.registeredIds ?? []).includes(t.id);
|
||||
}
|
||||
|
||||
function onUse(t: PlatformTopup) {
|
||||
if (isRegistered(t)) return;
|
||||
const sec = t.complete_time ?? t.create_time ?? Math.floor(Date.now() / 1000);
|
||||
emit("select", {
|
||||
...t,
|
||||
paid_at_iso: new Date(sec * 1000).toISOString(),
|
||||
});
|
||||
emit("update:open", false);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) {
|
||||
if (readToken()) {
|
||||
booting.value = true;
|
||||
void loadPage(1, { allowTokenOnly: true }).finally(() => {
|
||||
booting.value = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
items.value = [];
|
||||
total.value = 0;
|
||||
page.value = 1;
|
||||
error.value = "";
|
||||
password.value = "";
|
||||
booting.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="从平台选择充值"
|
||||
:footer="null"
|
||||
:width="560"
|
||||
centered
|
||||
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
|
||||
destroy-on-hidden
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<div
|
||||
v-if="booting || (loading && items.length === 0)"
|
||||
class="top-pick__boot"
|
||||
>
|
||||
<a-spin />
|
||||
<span class="top-pick__boot-text">正在检查登录并拉取…</span>
|
||||
</div>
|
||||
<template v-else-if="items.length === 0 && !loading">
|
||||
<p class="top-pick__hint">
|
||||
登录 NewAPI 控制台拉取充值单(传统分页);密码不保存。
|
||||
</p>
|
||||
<a-form layout="vertical" @finish="onLogin">
|
||||
<a-form-item label="账号">
|
||||
<a-input v-model:value="username" autocomplete="username" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码">
|
||||
<a-input-password
|
||||
v-model:value="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
block
|
||||
:loading="loading"
|
||||
:disabled="!username.trim() || !password"
|
||||
>
|
||||
登录并拉取
|
||||
</a-button>
|
||||
</a-form>
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="error"
|
||||
style="margin-top: 12px"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="top-pick__list">
|
||||
<div
|
||||
v-for="t in items"
|
||||
:key="t.id"
|
||||
class="top-pick__item"
|
||||
:class="{ 'is-registered': isRegistered(t) }"
|
||||
@click="onUse(t)"
|
||||
>
|
||||
<div class="top-pick__head">
|
||||
<strong>
|
||||
{{ formatDateTime(t.complete_time ?? t.create_time) }}
|
||||
</strong>
|
||||
<span class="top-pick__status">
|
||||
{{ isRegistered(t) ? "已登记" : t.status || "" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="top-pick__meta">
|
||||
amount {{ t.amount }} · money {{ t.money }}
|
||||
<template v-if="t.trade_no"> · {{ t.trade_no }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-pick__pager">
|
||||
<a-pagination
|
||||
:current="page"
|
||||
:page-size="PAGE_SIZE"
|
||||
:total="total"
|
||||
size="small"
|
||||
:show-total="(t: number) => `共 ${t} 条`"
|
||||
@change="(p: number) => loadPage(p)"
|
||||
/>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="error"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.top-pick__boot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.top-pick__boot-text {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.top-pick__hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.top-pick__list {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.top-pick__item {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.top-pick__item:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.top-pick__item.is-registered {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.top-pick__item.is-registered:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.top-pick__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.top-pick__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.top-pick__status {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.top-pick__meta {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.top-pick__pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 登记 Key:sk-key 明文在 web 的唯一露面处(交互-key管理 §2)
|
||||
*/
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import type { MemberItem } from "@new-api-key-usage/core";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import { useCreateMember, useRegisterKey, useBillingDomainOptions } from "@/queries/admin.ts";
|
||||
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
members: MemberItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:open", v: boolean): void;
|
||||
(e: "registered", keyId: number): void;
|
||||
}>();
|
||||
|
||||
const register = useRegisterKey();
|
||||
const createMember = useCreateMember();
|
||||
const domainOptions = useBillingDomainOptions();
|
||||
|
||||
const form = reactive({
|
||||
sk_key: "",
|
||||
member_id: null as number | "unassigned" | null,
|
||||
billing_domain: "",
|
||||
});
|
||||
const resultMsg = ref<{ type: "success" | "error" | "warning"; text: string } | null>(
|
||||
null,
|
||||
);
|
||||
const showNewMember = ref(false);
|
||||
const newMemberName = ref("");
|
||||
const newMemberRemark = ref("");
|
||||
const tokenOpen = ref(false);
|
||||
const plainToken = ref("");
|
||||
const tokenMemberName = ref("");
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (!v) {
|
||||
form.sk_key = "";
|
||||
form.member_id = null;
|
||||
form.billing_domain = "";
|
||||
resultMsg.value = null;
|
||||
showNewMember.value = false;
|
||||
newMemberName.value = "";
|
||||
newMemberRemark.value = "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const memberOptions = computed(() => [
|
||||
{ value: "unassigned", label: "未归属" },
|
||||
...props.members.map((m) => ({ value: m.id, label: m.name })),
|
||||
]);
|
||||
|
||||
async function onSubmit() {
|
||||
resultMsg.value = null;
|
||||
if (!form.sk_key.trim()) {
|
||||
resultMsg.value = { type: "warning", text: "请粘贴 sk-key" };
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const key = await register.mutateAsync({
|
||||
sk_key: form.sk_key.trim(),
|
||||
member_id:
|
||||
form.member_id === "unassigned" || form.member_id == null
|
||||
? null
|
||||
: form.member_id,
|
||||
billing_domain: form.billing_domain.trim() || null,
|
||||
});
|
||||
resultMsg.value = {
|
||||
type: "success",
|
||||
text: "已登记,首轮同步进行中(约 5 分钟内完成)",
|
||||
};
|
||||
emit("registered", key.id);
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
if (err.status === 409) {
|
||||
resultMsg.value = {
|
||||
type: "warning",
|
||||
text: "该 key 已在清单中",
|
||||
};
|
||||
if (err.keyId != null) emit("registered", err.keyId);
|
||||
} else if (err.status === 422) {
|
||||
// 上游拒绝 vs 本地表单校验(如 sk- 前缀)文案不同
|
||||
const fieldMsg = err.fields?.sk_key;
|
||||
resultMsg.value = {
|
||||
type: "error",
|
||||
text:
|
||||
fieldMsg ??
|
||||
(err.code === "validation_failed"
|
||||
? err.message || "请检查填写内容"
|
||||
: "上游拒绝了这枚 key——可能不存在或已被禁用"),
|
||||
};
|
||||
} else if (err.status === 502 || err.status === 0) {
|
||||
resultMsg.value = {
|
||||
type: "error",
|
||||
text: "上游站点暂时不可达,请稍后重试",
|
||||
};
|
||||
} else {
|
||||
resultMsg.value = {
|
||||
type: "error",
|
||||
text: err.message || "登记失败,请稍后重试",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreateMember() {
|
||||
const name = newMemberName.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const res = await createMember.mutateAsync({
|
||||
name,
|
||||
remark: newMemberRemark.value.trim(),
|
||||
});
|
||||
form.member_id = res.member.id;
|
||||
showNewMember.value = false;
|
||||
newMemberName.value = "";
|
||||
newMemberRemark.value = "";
|
||||
// POST /members 必回明文令牌——立刻展示,避免遗失
|
||||
plainToken.value = res.token;
|
||||
tokenMemberName.value = res.member.name;
|
||||
tokenOpen.value = true;
|
||||
resultMsg.value = {
|
||||
type: "success",
|
||||
text: `已新建成员「${res.member.name}」并选为归属,请保存下方令牌`,
|
||||
};
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
resultMsg.value = { type: "error", text: err.message || "新建成员失败" };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="登记 Key"
|
||||
:confirm-loading="register.isPending.value"
|
||||
ok-text="登记"
|
||||
cancel-text="取消"
|
||||
@update:open="emit('update:open', $event)"
|
||||
@ok="onSubmit"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="sk-key" required>
|
||||
<a-textarea
|
||||
v-model:value="form.sk_key"
|
||||
:rows="4"
|
||||
placeholder="粘贴你在 NewAPI 创建的 sk-…"
|
||||
/>
|
||||
<div class="register-key__note">
|
||||
粘贴后立即加密存储,界面不再回显明文
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="归属成员">
|
||||
<a-select
|
||||
v-model:value="form.member_id"
|
||||
:options="memberOptions"
|
||||
placeholder="未归属"
|
||||
allow-clear
|
||||
/>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
style="padding-left: 0"
|
||||
@click="showNewMember = !showNewMember"
|
||||
>
|
||||
+ 新建成员
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item label="账本域">
|
||||
<a-auto-complete
|
||||
v-model:value="form.billing_domain"
|
||||
:options="domainOptions"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="showNewMember" label="新成员名称">
|
||||
<a-input
|
||||
v-model:value="newMemberName"
|
||||
placeholder="成员名称"
|
||||
@press-enter="onCreateMember"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="newMemberRemark"
|
||||
placeholder="备注(可选)"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
style="margin-top: 8px"
|
||||
:loading="createMember.isPending.value"
|
||||
@click="onCreateMember"
|
||||
>
|
||||
创建并选用
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-alert
|
||||
v-if="resultMsg"
|
||||
:type="resultMsg.type"
|
||||
show-icon
|
||||
:message="resultMsg.text"
|
||||
/>
|
||||
</a-form>
|
||||
<MemberTokenModal
|
||||
v-model:open="tokenOpen"
|
||||
:token="plainToken"
|
||||
:member-name="tokenMemberName"
|
||||
@update:open="(v: boolean) => { if (!v) { plainToken = ''; tokenMemberName = ''; } }"
|
||||
/>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.register-key__note {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 调用日志汇总条(交互设计-调用日志 §2.5):
|
||||
* 单行低高度——请求次数 + 消费用量;用量恒 type=2 口径由上层保证。
|
||||
*/
|
||||
import QuotaText from "./quota-text.vue";
|
||||
|
||||
defineProps<{
|
||||
hitCount: number;
|
||||
usageCount: number;
|
||||
errorCount: number;
|
||||
quota: number;
|
||||
quotaUsd: string;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="logs-summary-bar" :class="{ 'is-loading': loading }">
|
||||
<div class="logs-summary-bar__seg">
|
||||
<span class="logs-summary-bar__label">请求次数</span>
|
||||
<span class="logs-summary-bar__value">
|
||||
<template v-if="loading">—</template>
|
||||
<template v-else>
|
||||
{{ hitCount }} 条<template v-if="errorCount > 0">
|
||||
(消费 {{ usageCount }} / 错误 {{ errorCount }})</template>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="logs-summary-bar__seg">
|
||||
<span class="logs-summary-bar__label">消费用量</span>
|
||||
<span class="logs-summary-bar__value">
|
||||
<QuotaText v-if="!loading" :quota="quota" :quota-usd="quotaUsd" />
|
||||
<template v-else>—</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logs-summary-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #e8ecf4;
|
||||
border-radius: 6px;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.logs-summary-bar.is-loading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.logs-summary-bar__seg {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logs-summary-bar__label {
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
}
|
||||
|
||||
.logs-summary-bar__value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1f2329;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 页面标题区——对齐 design.md 排版:
|
||||
* 标题 headline-sm(20/600/28)· 说明 body-sm 三级字(rgba 0.45)
|
||||
*/
|
||||
defineProps<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-header">
|
||||
<div class="page-header__text">
|
||||
<h1 class="page-header__title">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="page-header__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.page-header__subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.page-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
/* 与标题块上缘对齐时,控件 32px 高与 label 竖向居中 */
|
||||
min-height: 32px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 额度双单位:原始 quota + 美元(契约公共约定 §5;前端只呈现不换算)
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { formatInt } from "@/utils/format.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
quota: number | null | undefined;
|
||||
quotaUsd: string | null | undefined;
|
||||
}>();
|
||||
|
||||
const quotaText = computed(() => formatInt(props.quota));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="quota-text">
|
||||
<span class="quota-text__num">{{ quotaText }}</span>
|
||||
<span class="quota-text__usd">(${{ quotaUsd ?? "—" }})</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quota-text {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quota-text__num {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quota-text__usd {
|
||||
margin-left: 6px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 来源分解分段条(交互共性 §3.7:订阅 / 充值)——前端现算比例(公共约定 §5)
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
|
||||
type SourceRow = {
|
||||
billing_source: string;
|
||||
count: number;
|
||||
quota: number;
|
||||
quota_usd: string;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
rows: SourceRow[];
|
||||
}>();
|
||||
|
||||
const segments = computed(() => {
|
||||
const total = props.rows.reduce((s, r) => s + r.quota, 0);
|
||||
return props.rows.map((r) => {
|
||||
const pct = total > 0 ? (r.quota / total) * 100 : 0;
|
||||
const isSub = r.billing_source === "subscription";
|
||||
return {
|
||||
key: r.billing_source,
|
||||
label: isSub ? "订阅" : r.billing_source === "wallet" ? "充值" : r.billing_source,
|
||||
pct,
|
||||
count: r.count,
|
||||
quota_usd: r.quota_usd,
|
||||
color: isSub ? "#1677ff" : "#faad14",
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const hasData = computed(() => segments.value.some((s) => s.pct > 0));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="source-bar">
|
||||
<div v-if="!hasData" class="source-bar__empty">
|
||||
本时段内没有任何消费调用,因此没有可分解的来源。
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="source-bar__track">
|
||||
<div
|
||||
v-for="seg in segments"
|
||||
:key="seg.key"
|
||||
class="source-bar__seg"
|
||||
:style="{ width: `${seg.pct}%`, background: seg.color }"
|
||||
:title="`${seg.label}来源:占额度 ${seg.pct.toFixed(1)}%,调用 ${seg.count} 次,$${seg.quota_usd}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="source-bar__legend">
|
||||
<span v-for="seg in segments" :key="seg.key" class="source-bar__item">
|
||||
<i class="source-bar__dot" :style="{ background: seg.color }" />
|
||||
<span class="source-bar__label">{{ seg.label }}</span>
|
||||
<span class="source-bar__meta">
|
||||
调用 {{ seg.count }} 次,消耗 ${{ seg.quota_usd }}
|
||||
(占 {{ seg.pct.toFixed(1) }}%)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.source-bar__track {
|
||||
display: flex;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.source-bar__seg {
|
||||
height: 100%;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.source-bar__legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.source-bar__item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.source-bar__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.source-bar__label {
|
||||
font-weight: 600;
|
||||
min-width: 2.5em;
|
||||
}
|
||||
|
||||
.source-bar__meta {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.source-bar__empty {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 表格工具行刷新钮(纯图标 + tooltip)——放在筛选行下一行、右对齐
|
||||
*/
|
||||
import { ReloadOutlined } from "@antdv-next/icons";
|
||||
|
||||
defineProps<{ loading?: boolean }>();
|
||||
const emit = defineEmits<{ (e: "refresh"): void }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-refresh">
|
||||
<a-tooltip title="刷新">
|
||||
<a-button
|
||||
:loading="loading"
|
||||
aria-label="刷新"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-refresh {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from "dayjs";
|
||||
import dayjs from "dayjs";
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
recentDaysRange,
|
||||
todayRange,
|
||||
wallAsBeijingIso,
|
||||
type RangeValue,
|
||||
} from "@/utils/time.ts";
|
||||
import type { RangeKind } from "@/composables/use-live-time-range.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: RangeValue;
|
||||
/** 外部预置(如「本订阅期间」);空则只保留内建快捷项 */
|
||||
extraPresets?: Array<{ label: string; from: string; to: string }>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: RangeValue): void;
|
||||
/** 相对区间可自动跟日历推进;fixed 为绝对窗口 */
|
||||
(e: "change", value: RangeValue, kind: RangeKind): void;
|
||||
}>();
|
||||
|
||||
const pickerValue = computed<[Dayjs, Dayjs] | null>(() => {
|
||||
if (!props.modelValue.from || !props.modelValue.to) return null;
|
||||
return [dayjs(props.modelValue.from), dayjs(props.modelValue.to)];
|
||||
});
|
||||
|
||||
const presets = computed(() => {
|
||||
const builtIn = [
|
||||
{
|
||||
label: "今天",
|
||||
value: rangeToDayjs(todayRange()),
|
||||
},
|
||||
{
|
||||
label: "最近 7 天",
|
||||
value: rangeToDayjs(recentDaysRange(7)),
|
||||
},
|
||||
{
|
||||
label: "最近 30 天",
|
||||
value: rangeToDayjs(recentDaysRange(30)),
|
||||
},
|
||||
];
|
||||
const extra = (props.extraPresets ?? []).map((p) => ({
|
||||
label: p.label,
|
||||
value: rangeToDayjs({ from: p.from, to: p.to }),
|
||||
}));
|
||||
return [...builtIn, ...extra];
|
||||
});
|
||||
|
||||
function rangeToDayjs(range: RangeValue): [Dayjs, Dayjs] {
|
||||
return [dayjs(range.from), dayjs(range.to)];
|
||||
}
|
||||
|
||||
function detectKind(value: RangeValue): RangeKind {
|
||||
const fromD = dayjs(value.from).format("YYYY-MM-DD");
|
||||
const toD = dayjs(value.to).format("YYYY-MM-DD");
|
||||
const today = todayRange();
|
||||
if (
|
||||
dayjs(today.from).format("YYYY-MM-DD") === fromD &&
|
||||
dayjs(today.to).format("YYYY-MM-DD") === toD
|
||||
) {
|
||||
return "today";
|
||||
}
|
||||
for (const days of [7, 30] as const) {
|
||||
const r = recentDaysRange(days);
|
||||
if (
|
||||
dayjs(r.from).format("YYYY-MM-DD") === fromD &&
|
||||
dayjs(r.to).format("YYYY-MM-DD") === toD
|
||||
) {
|
||||
return days === 7 ? "recent-7" : "recent-30";
|
||||
}
|
||||
}
|
||||
return "fixed";
|
||||
}
|
||||
|
||||
function onChange(dates: Dayjs[] | null) {
|
||||
if (!dates || dates.length !== 2 || !dates[0] || !dates[1]) return;
|
||||
const value = {
|
||||
from: wallAsBeijingIso(dates[0]),
|
||||
to: wallAsBeijingIso(dates[1]),
|
||||
};
|
||||
emit("update:modelValue", value);
|
||||
emit("change", value, detectKind(value));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-range-picker
|
||||
:value="pickerValue"
|
||||
:presets="presets"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
:placeholder="['开始时间', '结束时间']"
|
||||
show-time
|
||||
@change="onChange"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { computed, onUnmounted, ref } from "vue";
|
||||
import { recentDaysRange, todayRange } from "@/utils/time.ts";
|
||||
|
||||
export type RangeValue = { from: string; to: string };
|
||||
export type RangeKind = "today" | "recent-7" | "recent-30" | "fixed";
|
||||
|
||||
/**
|
||||
* 可跟随「今天」推进的时间范围:
|
||||
* - 今天 / 最近 7/30 天:定时重算窗口(页面挂过夜也不会陈旧)
|
||||
* - fixed:自定义/订阅期间等绝对区间,窗口不挪
|
||||
*/
|
||||
export function useLiveTimeRange(initial: Exclude<RangeKind, "fixed"> = "recent-30") {
|
||||
const kind = ref<RangeKind>(initial);
|
||||
const fixed = ref<RangeValue>(recentDaysRange(30));
|
||||
const nowTick = ref(Date.now());
|
||||
|
||||
const timer = setInterval(() => {
|
||||
nowTick.value = Date.now();
|
||||
}, 60_000);
|
||||
onUnmounted(() => clearInterval(timer));
|
||||
|
||||
const range = computed<RangeValue>(() => {
|
||||
void nowTick.value;
|
||||
if (kind.value === "today") return todayRange();
|
||||
if (kind.value === "recent-7") return recentDaysRange(7);
|
||||
if (kind.value === "recent-30") return recentDaysRange(30);
|
||||
return fixed.value;
|
||||
});
|
||||
|
||||
function setRange(value: RangeValue, nextKind: RangeKind) {
|
||||
kind.value = nextKind;
|
||||
if (nextKind === "fixed") fixed.value = value;
|
||||
}
|
||||
|
||||
return { range, setRange, kind };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { computed, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
useMemberRangeStore,
|
||||
type RangeKind,
|
||||
type RangeValue,
|
||||
} from "@/stores/member-range.ts";
|
||||
|
||||
/**
|
||||
* 成员端共享时间范围 + 当前页 URL(from/to)双向同步。
|
||||
* 切页时布局菜单带上 from/to,状态从 store 读——对账窗口不打断。
|
||||
*/
|
||||
export function useMemberRange() {
|
||||
const store = useMemberRangeStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const range = computed(() => store.range);
|
||||
|
||||
function syncFromRoute() {
|
||||
const from = route.query.from;
|
||||
const to = route.query.to;
|
||||
if (typeof from === "string" && from && typeof to === "string" && to) {
|
||||
store.setRange({ from, to }, "fixed");
|
||||
}
|
||||
}
|
||||
|
||||
/** 在当前路径上写入 from/to(可并入页内其它 query) */
|
||||
function pushRangeQuery(extra: Record<string, string> = {}) {
|
||||
void router.replace({
|
||||
path: route.path,
|
||||
query: {
|
||||
...pickNonRangeQuery(route.query),
|
||||
...extra,
|
||||
from: store.range.from,
|
||||
to: store.range.to,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onRangeChange(v: RangeValue, k: RangeKind) {
|
||||
store.setRange(v, k);
|
||||
pushRangeQuery();
|
||||
}
|
||||
|
||||
syncFromRoute();
|
||||
watch(() => route.query, () => syncFromRoute());
|
||||
|
||||
return {
|
||||
range,
|
||||
setRange: store.setRange,
|
||||
onRangeChange,
|
||||
pushRangeQuery,
|
||||
};
|
||||
}
|
||||
|
||||
function pickNonRangeQuery(q: Record<string, unknown>): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(q)) {
|
||||
if (k === "from" || k === "to") continue;
|
||||
if (typeof v === "string" && v) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -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>
|
||||
+17
-1
@@ -1,9 +1,25 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
||||
import Antd from "antdv-next";
|
||||
import "antdv-next/dist/reset.css";
|
||||
import App from "./App.vue";
|
||||
import App from "./app.vue";
|
||||
import { bindAuthRedirectToRouter, router } from "./router/index.ts";
|
||||
import "./assets/main.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
bindAuthRedirectToRouter();
|
||||
app.use(router);
|
||||
app.use(VueQueryPlugin, { queryClient });
|
||||
app.use(Antd);
|
||||
app.mount("#app");
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { computed, type ComputedRef } from "vue";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||
import {
|
||||
fetchKeys,
|
||||
patchKey,
|
||||
registerKey,
|
||||
type KeyListFilters,
|
||||
} from "@/api/admin/keys.ts";
|
||||
import { fetchOverview } from "@/api/admin/overview.ts";
|
||||
import { fetchEngine, fetchQpuLatest } from "@/api/admin/engine.ts";
|
||||
import {
|
||||
createSubscription,
|
||||
deleteSubscription,
|
||||
fetchSubscriptions,
|
||||
updateSubscription,
|
||||
type SubscriptionBody,
|
||||
} from "@/api/admin/subscriptions.ts";
|
||||
import {
|
||||
createTopup,
|
||||
deleteTopup,
|
||||
fetchTopups,
|
||||
updateTopup,
|
||||
type TopupBody,
|
||||
} from "@/api/admin/topups.ts";
|
||||
import {
|
||||
createMember,
|
||||
deleteMember,
|
||||
fetchMembers,
|
||||
issueMemberToken,
|
||||
patchMember,
|
||||
} from "@/api/admin/members.ts";
|
||||
import { fetchAuditLogs } from "@/api/admin/audit.ts";
|
||||
import { fetchLogs } from "@/api/admin/logs.ts";
|
||||
import { qk, type PageQuery, type TimeRange } from "@/api/query-keys.ts";
|
||||
|
||||
export function useAdminOverview(range: () => TimeRange) {
|
||||
return useQuery({
|
||||
queryKey: () => qk.admin.overview(range()),
|
||||
queryFn: () => fetchOverview(range()),
|
||||
// 页面可长时间挂起:定期拉取引擎新写入的数据
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEngine() {
|
||||
return useQuery({
|
||||
queryKey: qk.admin.engine(),
|
||||
queryFn: fetchEngine,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useQpuLatest() {
|
||||
return useQuery({
|
||||
queryKey: qk.admin.qpuLatest(),
|
||||
queryFn: fetchQpuLatest,
|
||||
});
|
||||
}
|
||||
|
||||
export function useKeys(filters: () => KeyListFilters) {
|
||||
return useQuery({
|
||||
queryKey: () => qk.admin.keys(filters()),
|
||||
queryFn: () => fetchKeys(filters()),
|
||||
// 切页回列表时立即反映最新归属/状态
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRegisterKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: registerKey,
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePatchKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
}: {
|
||||
id: number;
|
||||
body: {
|
||||
status?: "active" | "disabled";
|
||||
member_id?: number | null;
|
||||
billing_domain?: string | null;
|
||||
};
|
||||
}) => patchKey(id, body),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||
// 归属变更会改成员「名下 Key 数」
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminLogs(
|
||||
params: () => Parameters<typeof fetchLogs>[0],
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: () => qk.admin.logs(params()),
|
||||
queryFn: () => fetchLogs(params()),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubscriptions() {
|
||||
return useQuery({
|
||||
queryKey: qk.admin.subscriptions(),
|
||||
queryFn: () => fetchSubscriptions(),
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSubscription() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: SubscriptionBody) => createSubscription(body),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSubscription() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: SubscriptionBody }) =>
|
||||
updateSubscription(id, body),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSubscription() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteSubscription,
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTopups() {
|
||||
return useQuery({
|
||||
queryKey: qk.admin.topups(),
|
||||
queryFn: () => fetchTopups(),
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTopup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: TopupBody) => createTopup(body),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTopup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: TopupBody }) =>
|
||||
updateTopup(id, body),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTopup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteTopup,
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMembers() {
|
||||
return useQuery({
|
||||
queryKey: qk.admin.members(),
|
||||
queryFn: fetchMembers,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateMember() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createMember,
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePatchMember() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
}: {
|
||||
id: number;
|
||||
body: {
|
||||
name?: string;
|
||||
remark?: string;
|
||||
status?: "active" | "disabled";
|
||||
};
|
||||
}) => patchMember(id, body),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMember() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteMember,
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useIssueMemberToken() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: issueMemberToken,
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuditLogs(
|
||||
params: () => PageQuery & { domain?: string; from?: string; to?: string },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: () => qk.admin.auditLogs(params()),
|
||||
queryFn: () => fetchAuditLogs(params()),
|
||||
});
|
||||
}
|
||||
|
||||
/** 账本域联想:keys ∪ 订阅 ∪ 充值 已出现过的值(不强制) */
|
||||
export function useBillingDomainOptions(): ComputedRef<
|
||||
Array<{ value: string }>
|
||||
> {
|
||||
const keysQuery = useKeys(() => ({}));
|
||||
const subsQuery = useSubscriptions();
|
||||
const topupsQuery = useTopups();
|
||||
return computed(() => {
|
||||
const set = new Set<string>();
|
||||
for (const k of keysQuery.data.value ?? []) {
|
||||
if (k.billing_domain) set.add(k.billing_domain);
|
||||
}
|
||||
for (const s of subsQuery.data.value ?? []) {
|
||||
if (s.billing_domain) set.add(s.billing_domain);
|
||||
}
|
||||
for (const t of topupsQuery.data.value ?? []) {
|
||||
if (t.billing_domain) set.add(t.billing_domain);
|
||||
}
|
||||
return [...set].sort().map((value) => ({ value }));
|
||||
});
|
||||
}
|
||||
@@ -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 = "today" | "recent-7" | "recent-30" | "fixed";
|
||||
|
||||
/**
|
||||
* 成员端全局时间范围——总览 / 调用日志 / 支付参数共用。
|
||||
* 相对窗口在 setRange 时定格,避免每分钟改 key 打穿缓存;
|
||||
* 「看板自动刷新」只属于总览页(refetchInterval),其余页按需请求。
|
||||
*/
|
||||
export const useMemberRangeStore = defineStore("member-range", () => {
|
||||
const kind = ref<RangeKind>("recent-30");
|
||||
const windowRange = ref<RangeValue>(recentDaysRange(30));
|
||||
const fixed = ref<RangeValue>(recentDaysRange(30));
|
||||
|
||||
const range = computed<RangeValue>(() =>
|
||||
kind.value === "fixed" ? fixed.value : windowRange.value,
|
||||
);
|
||||
|
||||
function setRange(value: RangeValue, nextKind: RangeKind) {
|
||||
kind.value = nextKind;
|
||||
if (nextKind === "fixed") fixed.value = value;
|
||||
else windowRange.value = value;
|
||||
}
|
||||
|
||||
return { kind, range, setRange };
|
||||
});
|
||||
@@ -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,112 @@
|
||||
/**
|
||||
* 时间展示:API 响应时刻为 Unix 秒(UTC);界面一律北京时间(交互共性 §3.3)。
|
||||
*/
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
/** 北京时区展示(固定 +08:00,不随主机时区变) */
|
||||
const DISPLAY_TZ = "Asia/Shanghai";
|
||||
/** 请求时间序列化:ISO 8601 带偏移(契约 §4;墙钟按北京时间) */
|
||||
const REQUEST_OFFSET = "+08:00";
|
||||
|
||||
/**
|
||||
* 绝对时刻(Date / Unix / 带时区的 ISO)→ 北京墙钟 + `+08:00`。
|
||||
* 先转 Asia/Shanghai,与主机时区无关。
|
||||
*/
|
||||
export function toRequestIso(value: Date | string | number | dayjs.Dayjs): string {
|
||||
return shanghaiIso(dayjs(value as never));
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单/日历墙钟(用户当作北京时间)→ `+08:00`。
|
||||
* 不做时区换算,只给墙钟贴偏移。
|
||||
*/
|
||||
export function wallAsBeijingIso(value: Date | dayjs.Dayjs): string {
|
||||
const wall = dayjs(value as never).format("YYYY-MM-DDTHH:mm:ss.SSS");
|
||||
return `${wall}${REQUEST_OFFSET}`;
|
||||
}
|
||||
|
||||
/** Unix 秒 → 可放进日历控件的「北京墙钟」dayjs */
|
||||
export function unixToBeijingWall(unixSec: number): dayjs.Dayjs {
|
||||
return dayjs(
|
||||
dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD HH:mm:ss.SSS"),
|
||||
);
|
||||
}
|
||||
|
||||
/** 已在 Asia/Shanghai 上的时刻 → 带偏移 ISO */
|
||||
function shanghaiIso(d: dayjs.Dayjs): string {
|
||||
const wall = d.tz(DISPLAY_TZ).format("YYYY-MM-DDTHH:mm:ss.SSS");
|
||||
return `${wall}${REQUEST_OFFSET}`;
|
||||
}
|
||||
|
||||
/** Unix 秒 → 北京时间「YYYY-MM-DD HH:mm:ss」 */
|
||||
export function formatDateTime(unixSec: number | null | undefined): string {
|
||||
if (unixSec == null) return "—";
|
||||
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD HH:mm:ss");
|
||||
}
|
||||
|
||||
/** Unix 秒 → 北京时间「YYYY-MM-DD」(统计口径到日) */
|
||||
export function formatDate(unixSec: number | null | undefined): string {
|
||||
if (unixSec == null) return "—";
|
||||
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD");
|
||||
}
|
||||
|
||||
/** Unix 秒 → 北京时间「HH:mm:ss」(明细时刻) */
|
||||
export function formatTimeOfDay(unixSec: number | null | undefined): string {
|
||||
if (unixSec == null) return "—";
|
||||
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("HH:mm:ss");
|
||||
}
|
||||
|
||||
/** 北京时间日初 → 请求用 ISO(带 +08:00 偏移) */
|
||||
export function beijingDayStartToIso(date: string): string {
|
||||
return shanghaiIso(dayjs.tz(date, DISPLAY_TZ).startOf("day"));
|
||||
}
|
||||
|
||||
/** 北京时间日界(次日零点,左闭右开的 to)→ ISO 带偏移 */
|
||||
export function beijingDayEndToIso(date: string): string {
|
||||
return shanghaiIso(dayjs.tz(date, DISPLAY_TZ).add(1, "day").startOf("day"));
|
||||
}
|
||||
|
||||
export type RangeValue = { from: string; to: string };
|
||||
|
||||
/** 快捷项:最近 N 天(含今日),返回 [from, to) 的 ISO 带偏移 */
|
||||
export function recentDaysRange(
|
||||
days: number,
|
||||
nowMs: number = Date.now(),
|
||||
): { from: string; to: string } {
|
||||
const to = dayjs(nowMs).tz(DISPLAY_TZ).add(1, "day").startOf("day");
|
||||
const from = to.subtract(days, "day");
|
||||
return { from: shanghaiIso(from), to: shanghaiIso(to) };
|
||||
}
|
||||
|
||||
/** 快捷项:今天(北京时间日界,左闭右开) */
|
||||
export function todayRange(nowMs: number = Date.now()): {
|
||||
from: string;
|
||||
to: string;
|
||||
} {
|
||||
return recentDaysRange(1, nowMs);
|
||||
}
|
||||
|
||||
/** 统计口径标注:「2026-09-02 ~ 10-01」(北京时间日) */
|
||||
export function formatRangeCaption(fromIso: string, toIso: string): string {
|
||||
if (!fromIso || !toIso) return "—";
|
||||
const from = dayjs(fromIso).tz(DISPLAY_TZ).format("YYYY-MM-DD");
|
||||
const to = dayjs(toIso).tz(DISPLAY_TZ).format("YYYY-MM-DD");
|
||||
return `${from} ~ ${to}`;
|
||||
}
|
||||
|
||||
/** 「数据截止」到分钟(交互共性 §3.3) */
|
||||
export function formatMinute(unixSec: number | null | undefined): string {
|
||||
if (unixSec == null) return "—";
|
||||
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD HH:mm");
|
||||
}
|
||||
|
||||
/** 距今小时数(用于数据截止停滞判定) */
|
||||
export function hoursSince(unixSec: number | null | undefined): number | null {
|
||||
if (unixSec == null) return null;
|
||||
return (Date.now() / 1000 - unixSec) / 3600;
|
||||
}
|
||||
@@ -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,448 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Key 管理(交互设计-key管理):清单表 · 行内归属/启停 · 登记 · 停滞显形
|
||||
*/
|
||||
import { computed, h, ref, watch } from "vue";
|
||||
import { Tooltip, message } from "antdv-next";
|
||||
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import type { KeyItem } from "@new-api-key-usage/core";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import PageHeader from "@/components/common/page-header.vue";
|
||||
import TableRefresh from "@/components/common/table-refresh.vue";
|
||||
import RegisterKeyModal from "@/components/admin/register-key-modal.vue";
|
||||
import { useEngine, useKeys, useMembers, usePatchKey, useBillingDomainOptions } from "@/queries/admin.ts";
|
||||
import { formatInt } from "@/utils/format.ts";
|
||||
import { formatMinute, hoursSince } from "@/utils/time.ts";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const registerOpen = ref(false);
|
||||
const highlightKeyId = ref<number | null>(null);
|
||||
|
||||
const filterMember = ref<number | "unassigned" | null>(null);
|
||||
const filterStatus = ref<"active" | "disabled" | null>(null);
|
||||
const filterDomain = ref<string | "none" | null>(null);
|
||||
let domainSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const filterRecentFailed = ref(false);
|
||||
|
||||
/** URL → 筛选(总览跳入) */
|
||||
function syncFromRoute() {
|
||||
const q = route.query;
|
||||
const m = q.member_id;
|
||||
if (m === "unassigned") filterMember.value = "unassigned";
|
||||
else if (typeof m === "string" && m) filterMember.value = Number(m);
|
||||
else filterMember.value = null;
|
||||
|
||||
if (q.status === "active" || q.status === "disabled") filterStatus.value = q.status;
|
||||
else filterStatus.value = null;
|
||||
if (q.billing_domain === "none") filterDomain.value = "none";
|
||||
else if (typeof q.billing_domain === "string" && q.billing_domain) {
|
||||
filterDomain.value = q.billing_domain;
|
||||
} else filterDomain.value = null;
|
||||
|
||||
filterRecentFailed.value = q.recent_failed === "1" || q.recent_failed === "true";
|
||||
|
||||
const kid = q.key_id;
|
||||
highlightKeyId.value = typeof kid === "string" && kid ? Number(kid) : null;
|
||||
}
|
||||
syncFromRoute();
|
||||
watch(
|
||||
() => route.query,
|
||||
() => syncFromRoute(),
|
||||
);
|
||||
|
||||
function pushQuery() {
|
||||
const query: Record<string, string> = {};
|
||||
if (filterMember.value === "unassigned") query.member_id = "unassigned";
|
||||
else if (filterMember.value != null) query.member_id = String(filterMember.value);
|
||||
if (filterStatus.value) query.status = filterStatus.value;
|
||||
if (filterDomain.value === "none") query.billing_domain = "none";
|
||||
else if (filterDomain.value) query.billing_domain = filterDomain.value;
|
||||
if (filterRecentFailed.value) query.recent_failed = "1";
|
||||
if (highlightKeyId.value != null) query.key_id = String(highlightKeyId.value);
|
||||
void router.replace({ path: "/admin/keys", query });
|
||||
}
|
||||
|
||||
watch([filterMember, filterStatus, filterDomain, filterRecentFailed], () => {
|
||||
pushQuery();
|
||||
});
|
||||
|
||||
const filters = computed(() => ({
|
||||
member_id: filterMember.value ?? undefined,
|
||||
status: filterStatus.value ?? undefined,
|
||||
billing_domain: filterDomain.value ?? undefined,
|
||||
recent_failed: filterRecentFailed.value ? true : undefined,
|
||||
}));
|
||||
|
||||
const keysQuery = useKeys(() => filters.value);
|
||||
const engineQuery = useEngine();
|
||||
const membersQuery = useMembers();
|
||||
const patchKey = usePatchKey();
|
||||
|
||||
const rows = computed(() => {
|
||||
const list = [...(keysQuery.data.value ?? [])];
|
||||
// 异常在前:无截止/停滞 → 旧截止 → 新截止(交互:停滞预警)
|
||||
list.sort((a, b) => {
|
||||
const ta = a.log_synced_at ?? 0;
|
||||
const tb = b.log_synced_at ?? 0;
|
||||
return ta - tb;
|
||||
});
|
||||
return list;
|
||||
});
|
||||
|
||||
const failedCount = computed(
|
||||
() => engineQuery.data.value?.last_round?.failed ?? 0,
|
||||
);
|
||||
|
||||
const hasActiveFilter = computed(
|
||||
() =>
|
||||
filterMember.value != null ||
|
||||
filterStatus.value != null ||
|
||||
filterDomain.value != null ||
|
||||
filterRecentFailed.value,
|
||||
);
|
||||
|
||||
const memberOptions = computed(() => [
|
||||
{ value: "unassigned", label: "未归属" },
|
||||
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
|
||||
]);
|
||||
|
||||
const domainOptions = useBillingDomainOptions();
|
||||
|
||||
function isStalled(logSyncedAt: number | null | undefined): boolean {
|
||||
const h = hoursSince(logSyncedAt);
|
||||
return h != null && h > 2;
|
||||
}
|
||||
|
||||
function tipTitle(label: string, tip: string) {
|
||||
return h("span", { class: "overview-col-title" }, [
|
||||
label,
|
||||
h(
|
||||
Tooltip,
|
||||
{ title: tip },
|
||||
() =>
|
||||
h(QuestionCircleOutlined, {
|
||||
class: "overview-col-title__icon",
|
||||
"aria-hidden": "true",
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: "名称", key: "token_name", width: 160 },
|
||||
{ title: "归属", key: "member_name", width: 140 },
|
||||
{ title: "账本域", key: "billing_domain", width: 110 },
|
||||
{ title: "状态", key: "status", width: 100 },
|
||||
{
|
||||
title: tipTitle(
|
||||
"数据截止",
|
||||
"该 key 最近一次日志同步成功的时刻;落后超过 2 小时标为停滞。检查引擎日志;若持续 401 请停用",
|
||||
),
|
||||
key: "data_cutoff",
|
||||
width: 170,
|
||||
},
|
||||
{
|
||||
title: tipTitle("累计已用", "上游累计已消耗额度(quota)"),
|
||||
key: "total_used",
|
||||
},
|
||||
{
|
||||
title: tipTitle("剩余", "上游剩余可用额度;不限额度显示「不限」"),
|
||||
key: "total_available",
|
||||
},
|
||||
{ title: "登记时间", key: "registered_at", width: 160 },
|
||||
{ title: "操作", key: "actions", width: 120 },
|
||||
];
|
||||
|
||||
async function onMemberChange(
|
||||
record: KeyItem,
|
||||
memberId: number | "unassigned" | null,
|
||||
) {
|
||||
const next = memberId === "unassigned" || memberId == null ? null : memberId;
|
||||
try {
|
||||
await patchKey.mutateAsync({ id: record.id, body: { member_id: next } });
|
||||
message.success("归属已更新");
|
||||
} catch (e) {
|
||||
message.error((e as ApiError).message || "归属更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function onDomainChange(record: KeyItem, value: string) {
|
||||
const next = value.trim() || null;
|
||||
if ((record.billing_domain ?? null) === next) return;
|
||||
if (domainSaveTimer != null) clearTimeout(domainSaveTimer);
|
||||
const id = record.id;
|
||||
domainSaveTimer = setTimeout(() => {
|
||||
domainSaveTimer = null;
|
||||
void saveDomain(id, next);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
async function onDomainBlur(record: KeyItem, value: string) {
|
||||
const next = value.trim() || null;
|
||||
if (domainSaveTimer != null) {
|
||||
clearTimeout(domainSaveTimer);
|
||||
domainSaveTimer = null;
|
||||
}
|
||||
if ((record.billing_domain ?? null) === next) return;
|
||||
await saveDomain(record.id, next);
|
||||
}
|
||||
|
||||
async function saveDomain(id: number, billing_domain: string | null) {
|
||||
try {
|
||||
await patchKey.mutateAsync({ id, body: { billing_domain } });
|
||||
message.success("账本域已更新");
|
||||
} catch (e) {
|
||||
message.error((e as ApiError).message || "账本域更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleStatus(record: KeyItem) {
|
||||
const next = record.status === "active" ? "disabled" : "active";
|
||||
try {
|
||||
await patchKey.mutateAsync({ id: record.id, body: { status: next } });
|
||||
if (next === "disabled") {
|
||||
message.success("已停用——移出轮询清单,数据保留");
|
||||
} else {
|
||||
message.success("已启用——下一轮同步(≤5 分钟)将自动验证");
|
||||
}
|
||||
} catch (e) {
|
||||
message.error((e as ApiError).message || "状态更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
function onRegistered(keyId: number) {
|
||||
highlightKeyId.value = keyId;
|
||||
pushQuery();
|
||||
void keysQuery.refetch();
|
||||
}
|
||||
|
||||
function onFailedClick() {
|
||||
filterRecentFailed.value = !filterRecentFailed.value;
|
||||
}
|
||||
|
||||
function onRowClass(record: KeyItem) {
|
||||
return record.id === highlightKeyId.value ? "key-row--highlight" : "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<PageHeader title="Key 管理" subtitle="登记、启停、归属与同步显形">
|
||||
<template #actions>
|
||||
<a-button type="primary" @click="registerOpen = true">登记 Key</a-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="keys-toolbar">
|
||||
<span class="keys-toolbar__summary">
|
||||
<template v-if="failedCount > 0">
|
||||
最近轮次失败
|
||||
<a-button type="link" size="small" @click="onFailedClick">
|
||||
{{ failedCount }} 枚
|
||||
</a-button>
|
||||
(点击筛选)
|
||||
</template>
|
||||
<template v-else>最近轮次无失败 key</template>
|
||||
</span>
|
||||
<a-space wrap>
|
||||
<a-select
|
||||
v-model:value="filterMember"
|
||||
:options="[
|
||||
{ value: null, label: '全部成员' },
|
||||
{ value: 'unassigned', label: '未归属' },
|
||||
...(membersQuery.data.value?.map((m) => ({ value: m.id, label: m.name })) ?? []),
|
||||
]"
|
||||
style="width: 140px"
|
||||
placeholder="成员"
|
||||
allow-clear
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filterStatus"
|
||||
:options="[
|
||||
{ value: null, label: '全部状态' },
|
||||
{ value: 'active', label: '启用中' },
|
||||
{ value: 'disabled', label: '已停用' },
|
||||
]"
|
||||
style="width: 120px"
|
||||
placeholder="状态"
|
||||
allow-clear
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filterDomain"
|
||||
:options="[
|
||||
{ value: null, label: '全部账本域' },
|
||||
{ value: 'none', label: '未标注' },
|
||||
...domainOptions,
|
||||
]"
|
||||
style="width: 140px"
|
||||
placeholder="账本域"
|
||||
allow-clear
|
||||
/>
|
||||
<a-checkbox v-model:checked="filterRecentFailed">最近轮次失败</a-checkbox>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<TableRefresh
|
||||
:loading="keysQuery.isFetching.value"
|
||||
@refresh="() => keysQuery.refetch()"
|
||||
/>
|
||||
|
||||
<a-empty
|
||||
v-if="!keysQuery.isPending.value && rows.length === 0"
|
||||
:description="
|
||||
hasActiveFilter
|
||||
? '当前筛选条件下没有 key——可清除筛选,或登记新的 key'
|
||||
: '还没有登记任何 key——把你在 NewAPI 创建的 sk-key 粘贴进来'
|
||||
"
|
||||
>
|
||||
<a-button v-if="!hasActiveFilter" type="primary" @click="registerOpen = true">
|
||||
登记 Key
|
||||
</a-button>
|
||||
</a-empty>
|
||||
|
||||
<a-table
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:row-key="(r: KeyItem) => r.id"
|
||||
:loading="keysQuery.isPending.value || keysQuery.isFetching.value"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 1100 }"
|
||||
:row-class-name="onRowClass"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'token_name'">
|
||||
{{ record.token_name }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'member_name'">
|
||||
<a-select
|
||||
:value="record.member_id == null ? 'unassigned' : record.member_id"
|
||||
:options="memberOptions"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
@change="(v: number | 'unassigned') => onMemberChange(record, v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'billing_domain'">
|
||||
<a-auto-complete
|
||||
:value="record.billing_domain ?? ''"
|
||||
:options="domainOptions"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
@change="(v: string) => onDomainChange(record, v)"
|
||||
@blur="(e: FocusEvent) => onDomainBlur(record, (e.target as HTMLInputElement)?.value ?? '')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-badge
|
||||
:status="record.status === 'active' ? 'success' : 'default'"
|
||||
:text="record.status === 'active' ? '启用中' : '已停用'"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'data_cutoff'">
|
||||
<a-tooltip
|
||||
v-if="isStalled(record.log_synced_at)"
|
||||
title="检查引擎日志;若持续 401 请停用"
|
||||
>
|
||||
<span class="keys-stall">{{ formatMinute(record.log_synced_at) }} 停滞</span>
|
||||
</a-tooltip>
|
||||
<span v-else-if="record.log_synced_at == null" class="keys-syncing">
|
||||
同步中…
|
||||
</span>
|
||||
<span v-else>{{ formatMinute(record.log_synced_at) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'total_used'">
|
||||
<span class="keys-num">{{ formatInt(record.total_used) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'total_available'">
|
||||
<span class="keys-num">
|
||||
{{ record.unlimited_quota ? "不限" : formatInt(record.total_available) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'registered_at'">
|
||||
{{ formatMinute(record.registered_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button
|
||||
size="small"
|
||||
@click="() => router.push({ path: '/admin/logs', query: { api_key_id: String(record.id) } })"
|
||||
>
|
||||
查调用
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-if="record.status === 'active'"
|
||||
title="停用后移出轮询清单(数据保留)"
|
||||
description="成员若仍在用它调用 API 不受影响——只有采集停止"
|
||||
ok-text="停用"
|
||||
cancel-text="取消"
|
||||
@confirm="() => onToggleStatus(record)"
|
||||
>
|
||||
<a-button size="small" danger>停用</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button v-else size="small" @click="() => onToggleStatus(record)">
|
||||
启用
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<RegisterKeyModal
|
||||
v-model:open="registerOpen"
|
||||
:members="membersQuery.data.value ?? []"
|
||||
@registered="onRegistered"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.keys-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.keys-toolbar__summary {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.keys-stall {
|
||||
color: #d48806;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.keys-syncing {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.keys-num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
:deep(.key-row--highlight) {
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.overview-col-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-col-title__icon {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
cursor: help;
|
||||
}
|
||||
</style>
|
||||
@@ -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,617 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 管理面调用日志(交互设计-调用日志):筛选 + 明细表 + 与 key/成员跳转
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import type { LogItem, SubscriptionItem } from "@new-api-key-usage/core";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import PageHeader from "@/components/common/page-header.vue";
|
||||
import TableRefresh from "@/components/common/table-refresh.vue";
|
||||
import TimeRangePicker from "@/components/common/time-range-picker.vue";
|
||||
import QuotaText from "@/components/common/quota-text.vue";
|
||||
import LogsSummaryBar from "@/components/common/logs-summary-bar.vue";
|
||||
import {
|
||||
useLiveTimeRange,
|
||||
type RangeKind,
|
||||
} from "@/composables/use-live-time-range.ts";
|
||||
import { useAdminLogs, useKeys, useMembers, useSubscriptions } from "@/queries/admin.ts";
|
||||
import { formatDateTime } from "@/utils/time.ts";
|
||||
import { formatInt, formatPaidAmount } from "@/utils/format.ts";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { range, setRange } = useLiveTimeRange("recent-30");
|
||||
|
||||
const memberId = ref<number | "unassigned" | null>(null);
|
||||
const apiKeyId = ref<number | null>(null);
|
||||
const modelFilter = ref<string | null>(null);
|
||||
const billingSource = ref<string | null>(null);
|
||||
const subscriptionId = ref<number | null>(null);
|
||||
const typeFilter = ref<number | "error" | null>(null);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(50);
|
||||
|
||||
const membersQuery = useMembers();
|
||||
const keysQuery = useKeys(() => ({}));
|
||||
|
||||
const keyById = computed(() => {
|
||||
const m = new Map<number, { token_name: string; member_id: number | null; member_name: string | null }>();
|
||||
for (const k of keysQuery.data.value ?? []) {
|
||||
m.set(k.id, {
|
||||
token_name: k.token_name,
|
||||
member_id: k.member_id,
|
||||
member_name: k.member_name,
|
||||
});
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
const params = computed(() => ({
|
||||
...range.value,
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
api_key_id: apiKeyId.value ?? undefined,
|
||||
member_id: memberId.value ?? undefined,
|
||||
model_name: modelFilter.value ?? undefined,
|
||||
billing_source: billingSource.value ?? undefined,
|
||||
subscription_id: subscriptionId.value ?? undefined,
|
||||
type: typeFilter.value ?? undefined,
|
||||
}));
|
||||
|
||||
const logsQuery = useAdminLogs(() => params.value);
|
||||
const rows = computed(() => logsQuery.data.value?.items ?? []);
|
||||
const total = computed(() => logsQuery.data.value?.total ?? 0);
|
||||
const summary = computed(() => logsQuery.data.value?.summary);
|
||||
const modelOptions = computed(() => [
|
||||
{ value: null, label: "全部模型" },
|
||||
...(logsQuery.data.value?.models ?? []).map((m) => ({ value: m, label: m })),
|
||||
]);
|
||||
|
||||
const memberOptions = computed(() => [
|
||||
{ value: null, label: "全部成员" },
|
||||
{ value: "unassigned", label: "未归属" },
|
||||
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
|
||||
]);
|
||||
|
||||
const keyOptions = computed(() => [
|
||||
{ value: null, label: "全部 Key" },
|
||||
...(keysQuery.data.value ?? []).map((k) => ({
|
||||
value: k.id,
|
||||
label: k.token_name,
|
||||
})),
|
||||
]);
|
||||
|
||||
const columns = [
|
||||
{ title: "时间", key: "created_at", width: 150 },
|
||||
{ title: "Key", key: "token_name", width: 120 },
|
||||
{ title: "归属", key: "member_name", width: 110 },
|
||||
{ title: "模型", key: "model_name", dataIndex: "model_name", width: 110 },
|
||||
{ title: "类型", key: "type", width: 80 },
|
||||
{ title: "来源", key: "source", width: 110 },
|
||||
{ title: "用量", key: "usage", width: 140 },
|
||||
{ title: "Tokens", key: "tokens", width: 110 },
|
||||
{ title: "操作", key: "actions", width: 72 },
|
||||
];
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detailRecord = ref<LogItem | null>(null);
|
||||
|
||||
function openDetail(record: LogItem) {
|
||||
detailRecord.value = record;
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
function formatRaw(record: LogItem | null): string {
|
||||
if (!record) return "";
|
||||
return JSON.stringify(record.raw ?? record, null, 2);
|
||||
}
|
||||
|
||||
function syncFromRoute() {
|
||||
const q = route.query;
|
||||
const m = q.member_id;
|
||||
if (m === "unassigned") memberId.value = "unassigned";
|
||||
else if (typeof m === "string" && m) memberId.value = Number(m);
|
||||
const k = q.api_key_id ?? q.key_id;
|
||||
if (typeof k === "string" && k) apiKeyId.value = Number(k);
|
||||
modelFilter.value =
|
||||
typeof q.model_name === "string" && q.model_name ? q.model_name : null;
|
||||
if (typeof q.billing_source === "string" && q.billing_source) {
|
||||
billingSource.value = q.billing_source;
|
||||
}
|
||||
const s = q.subscription_id;
|
||||
if (typeof s === "string" && s) subscriptionId.value = Number(s);
|
||||
const t = q.type;
|
||||
if (t === "2") typeFilter.value = 2;
|
||||
else if (t === "error") typeFilter.value = "error";
|
||||
else typeFilter.value = null;
|
||||
const p = q.page;
|
||||
if (typeof p === "string" && p) page.value = Number(p) || 1;
|
||||
const from = q.from;
|
||||
const to = q.to;
|
||||
if (typeof from === "string" && from && typeof to === "string" && to) {
|
||||
setRange({ from, to }, "fixed");
|
||||
}
|
||||
}
|
||||
|
||||
function pushQuery() {
|
||||
const query: Record<string, string> = {
|
||||
from: range.value.from,
|
||||
to: range.value.to,
|
||||
};
|
||||
if (memberId.value === "unassigned") query.member_id = "unassigned";
|
||||
else if (memberId.value != null) query.member_id = String(memberId.value);
|
||||
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
|
||||
if (modelFilter.value) query.model_name = modelFilter.value;
|
||||
if (billingSource.value) query.billing_source = billingSource.value;
|
||||
if (subscriptionId.value != null) {
|
||||
query.subscription_id = String(subscriptionId.value);
|
||||
}
|
||||
if (typeFilter.value) query.type = String(typeFilter.value);
|
||||
if (page.value > 1) query.page = String(page.value);
|
||||
void router.replace({ path: "/admin/logs", query });
|
||||
}
|
||||
|
||||
syncFromRoute();
|
||||
watch(
|
||||
() => route.query,
|
||||
() => syncFromRoute(),
|
||||
);
|
||||
watch(
|
||||
[memberId, apiKeyId, modelFilter, billingSource, subscriptionId, typeFilter],
|
||||
() => {
|
||||
page.value = 1;
|
||||
pushQuery();
|
||||
},
|
||||
);
|
||||
watch(page, () => pushQuery());
|
||||
|
||||
function sourceBadge(record: LogItem) {
|
||||
if (record.billing_source === "subscription" && record.subscription_id != null) {
|
||||
return {
|
||||
text: `订阅 #${record.subscription_id}`,
|
||||
color: "blue" as const,
|
||||
kind: "sub" as const,
|
||||
id: record.subscription_id,
|
||||
};
|
||||
}
|
||||
if (record.billing_source === "wallet") {
|
||||
return { text: "充值", color: "gold" as const, kind: "wallet" as const, id: null };
|
||||
}
|
||||
return { text: "未关联", color: undefined, kind: "none" as const, id: null };
|
||||
}
|
||||
|
||||
const payModalOpen = ref(false);
|
||||
const payModalSubId = ref<number | null>(null);
|
||||
const subsQuery = useSubscriptions();
|
||||
const paymentBySub = computed(() => {
|
||||
const m = new Map<number, SubscriptionItem>();
|
||||
for (const p of subsQuery.data.value ?? []) {
|
||||
m.set(p.subscription_id, p);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
const payModalPayment = computed(() =>
|
||||
payModalSubId.value == null
|
||||
? undefined
|
||||
: paymentBySub.value.get(payModalSubId.value),
|
||||
);
|
||||
|
||||
function onBadge(record: LogItem, e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (sourceBadge(record).kind !== "sub") return;
|
||||
payModalSubId.value = sourceBadge(record).id;
|
||||
payModalOpen.value = true;
|
||||
}
|
||||
|
||||
function periodText(p: SubscriptionItem): string {
|
||||
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
|
||||
return `${formatDateTime(p.period_start)} ~ ${end}`;
|
||||
}
|
||||
|
||||
function goPayments() {
|
||||
if (payModalSubId.value == null) return;
|
||||
payModalOpen.value = false;
|
||||
void router.push({
|
||||
path: "/admin/payments",
|
||||
query: { tab: "subs", subscription_id: String(payModalSubId.value) },
|
||||
});
|
||||
}
|
||||
|
||||
function goKey(keyId: number) {
|
||||
void router.push({ path: "/admin/keys", query: { key_id: String(keyId) } });
|
||||
}
|
||||
|
||||
function goMember(memberId: number | null) {
|
||||
if (memberId == null) return;
|
||||
void router.push({
|
||||
path: "/admin/members",
|
||||
query: { member_id: String(memberId) },
|
||||
});
|
||||
}
|
||||
|
||||
let ro: ResizeObserver | null = null;
|
||||
|
||||
function onRangeChange(
|
||||
v: { from: string; to: string },
|
||||
k: RangeKind,
|
||||
) {
|
||||
setRange(v, k);
|
||||
page.value = 1;
|
||||
pushQuery();
|
||||
}
|
||||
|
||||
function onPageChange(p: number, ps: number) {
|
||||
page.value = p;
|
||||
pageSize.value = ps;
|
||||
}
|
||||
|
||||
const boxRef = ref<HTMLElement | null>(null);
|
||||
const bodyY = ref(480);
|
||||
const CHROME = 96;
|
||||
|
||||
function measure() {
|
||||
const h = boxRef.value?.clientHeight ?? 0;
|
||||
if (h > 0) bodyY.value = Math.max(200, h - CHROME);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
measure();
|
||||
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
|
||||
ro = new ResizeObserver(() => measure());
|
||||
ro.observe(boxRef.value);
|
||||
}
|
||||
window.addEventListener("resize", measure);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
ro?.disconnect();
|
||||
window.removeEventListener("resize", measure);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="call-logs">
|
||||
<PageHeader title="调用日志" subtitle="全池模型请求查询与筛选">
|
||||
<template #actions>
|
||||
<span class="call-logs__label">时间范围</span>
|
||||
<TimeRangePicker
|
||||
:model-value="range"
|
||||
@change="onRangeChange"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="call-logs__filters">
|
||||
<a-select
|
||||
v-model:value="memberId"
|
||||
:options="memberOptions"
|
||||
style="width: 140px"
|
||||
allow-clear
|
||||
placeholder="成员"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="apiKeyId"
|
||||
:options="keyOptions"
|
||||
style="width: 160px"
|
||||
allow-clear
|
||||
placeholder="Key"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="modelFilter"
|
||||
:options="modelOptions"
|
||||
style="width: 140px"
|
||||
allow-clear
|
||||
placeholder="模型"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="billingSource"
|
||||
:options="[
|
||||
{ value: null, label: '全部来源' },
|
||||
{ value: 'subscription', label: '订阅' },
|
||||
{ value: 'wallet', label: '充值' },
|
||||
{ value: 'none', label: '未关联' },
|
||||
]"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
placeholder="来源"
|
||||
/>
|
||||
<a-input-number
|
||||
v-model:value="subscriptionId"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
style="width: 120px"
|
||||
placeholder="订阅 ID"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="typeFilter"
|
||||
:options="[
|
||||
{ value: null, label: '全部类型' },
|
||||
{ value: 2, label: '消费' },
|
||||
{ value: 'error', label: '错误' },
|
||||
]"
|
||||
style="width: 120px"
|
||||
placeholder="类型"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LogsSummaryBar
|
||||
:hit-count="summary?.hit_count ?? 0"
|
||||
:usage-count="summary?.usage_count ?? 0"
|
||||
:error-count="summary?.error_count ?? 0"
|
||||
:quota="summary?.quota ?? 0"
|
||||
:quota-usd="summary?.quota_usd ?? '0.00'"
|
||||
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
|
||||
/>
|
||||
|
||||
<TableRefresh
|
||||
:loading="logsQuery.isFetching.value"
|
||||
@refresh="() => logsQuery.refetch()"
|
||||
/>
|
||||
|
||||
<a-empty
|
||||
v-if="!logsQuery.isPending.value && rows.length === 0"
|
||||
description="该时段内无调用日志"
|
||||
/>
|
||||
<div v-else ref="boxRef" class="call-logs__box">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:row-key="(r: LogItem) => r.id"
|
||||
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
|
||||
:pagination="{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100', '200'],
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
onChange: onPageChange,
|
||||
}"
|
||||
size="middle"
|
||||
:scroll="{ y: bodyY }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'created_at'">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'token_name'">
|
||||
<a @click="goKey(record.api_key_id)">{{ record.token_name }}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'member_name'">
|
||||
<a
|
||||
v-if="keyById.get(record.api_key_id)?.member_id != null"
|
||||
@click="goMember(keyById.get(record.api_key_id)!.member_id)"
|
||||
>
|
||||
{{ keyById.get(record.api_key_id)?.member_name ?? "成员" }}
|
||||
</a>
|
||||
<span v-else>未归属</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<a-tag :color="record.type === 2 ? 'blue' : 'red'">
|
||||
{{ record.type === 2 ? "消费" : "错误" }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'source'">
|
||||
<a-tooltip
|
||||
v-if="sourceBadge(record).kind === 'sub'"
|
||||
title="点击查看该订阅支付参数"
|
||||
>
|
||||
<a-tag
|
||||
color="blue"
|
||||
class="call-logs__badge"
|
||||
@click="(e: MouseEvent) => onBadge(record, e)"
|
||||
>
|
||||
{{ sourceBadge(record).text }}
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
<a-tag v-else :color="sourceBadge(record).color">
|
||||
{{ sourceBadge(record).text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'usage'">
|
||||
<QuotaText :quota="record.quota" :quota-usd="record.quota_usd" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'tokens'">
|
||||
{{ formatInt(record.prompt_tokens) }} / {{ formatInt(record.completion_tokens) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a @click="openDetail(record)">详情</a>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
v-model:open="detailOpen"
|
||||
title="调用详情"
|
||||
:footer="null"
|
||||
width="720"
|
||||
centered
|
||||
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
|
||||
>
|
||||
<div v-if="detailRecord" class="call-logs__detail">
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">时间</span>
|
||||
<span>{{ formatDateTime(detailRecord.created_at) }}</span>
|
||||
</div>
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">Key / 归属</span>
|
||||
<span>
|
||||
{{ detailRecord.token_name }}
|
||||
·
|
||||
{{ keyById.get(detailRecord.api_key_id)?.member_name ?? "未归属" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">模型 / 类型</span>
|
||||
<span>
|
||||
{{ detailRecord.model_name }}
|
||||
·
|
||||
{{ detailRecord.type === 2 ? "消费" : "错误" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">用量</span>
|
||||
<QuotaText :quota="detailRecord.quota" :quota-usd="detailRecord.quota_usd" />
|
||||
</div>
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">Tokens</span>
|
||||
<span class="call-logs__num">
|
||||
提示 {{ formatInt(detailRecord.prompt_tokens) }} · 补全 {{ formatInt(detailRecord.completion_tokens) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">耗时</span>
|
||||
<span class="call-logs__num">
|
||||
{{ detailRecord.use_time }}s · {{ detailRecord.is_stream ? "流式" : "非流式" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">来源</span>
|
||||
<a-tag :color="sourceBadge(detailRecord).color">
|
||||
{{ sourceBadge(detailRecord).text }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div v-if="detailRecord.content" class="call-logs__kv">
|
||||
<span class="call-logs__note">内容</span>
|
||||
<span class="call-logs__content">{{ detailRecord.content }}</span>
|
||||
</div>
|
||||
<div class="call-logs__raw">
|
||||
<div class="call-logs__note">原始数据</div>
|
||||
<pre class="call-logs__json">{{ formatRaw(detailRecord) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="payModalOpen"
|
||||
:title="payModalSubId != null ? `订阅 #${payModalSubId} 支付参数` : '支付参数'"
|
||||
:footer="null"
|
||||
width="480"
|
||||
centered
|
||||
:styles="{ body: { maxHeight: '56vh', overflowY: 'auto' } }"
|
||||
>
|
||||
<div v-if="payModalPayment" class="call-logs__pay">
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">期间</span>
|
||||
<span>{{ periodText(payModalPayment) }}</span>
|
||||
</div>
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">额度</span>
|
||||
<QuotaText
|
||||
:quota="payModalPayment.quota_granted"
|
||||
:quota-usd="payModalPayment.quota_usd"
|
||||
/>
|
||||
</div>
|
||||
<div class="call-logs__kv">
|
||||
<span class="call-logs__note">金额</span>
|
||||
<span class="call-logs__num">{{ formatPaidAmount(payModalPayment.paid_amount) }}</span>
|
||||
</div>
|
||||
<div v-if="payModalPayment.remark" class="call-logs__kv">
|
||||
<span class="call-logs__note">备注</span>
|
||||
<span>{{ payModalPayment.remark }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="call-logs__note">该订阅无登记信息</div>
|
||||
<div class="call-logs__foot">
|
||||
<a @click="goPayments">在支付记录中查看</a>
|
||||
</div>
|
||||
</a-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.call-logs {
|
||||
height: calc(100vh - 64px - 48px - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.call-logs__label {
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.call-logs__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.call-logs__box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.call-logs__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.call-logs__kv {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.call-logs__note {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
min-width: 72px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.call-logs__num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.call-logs__content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.call-logs__raw {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.call-logs__json {
|
||||
margin: 4px 0 0;
|
||||
padding: 12px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
}
|
||||
|
||||
.call-logs__badge {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.call-logs__pay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.call-logs__foot {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,399 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 成员管理(交互设计-成员管理):清单表 · 开户 · 档案修改 · 令牌明文/重发 · 启停 · 空壳删除
|
||||
*/
|
||||
import { computed, h, reactive, ref, watch } from "vue";
|
||||
import { Tooltip, message } from "antdv-next";
|
||||
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
import type { MemberItem } from "@new-api-key-usage/core";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import PageHeader from "@/components/common/page-header.vue";
|
||||
import TableRefresh from "@/components/common/table-refresh.vue";
|
||||
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
|
||||
import {
|
||||
useCreateMember,
|
||||
useDeleteMember,
|
||||
useIssueMemberToken,
|
||||
useMembers,
|
||||
usePatchMember,
|
||||
} from "@/queries/admin.ts";
|
||||
import { formatMinute } from "@/utils/time.ts";
|
||||
|
||||
const membersQuery = useMembers();
|
||||
const createMember = useCreateMember();
|
||||
const patchMember = usePatchMember();
|
||||
const deleteMember = useDeleteMember();
|
||||
const issueToken = useIssueMemberToken();
|
||||
|
||||
const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const tokenOpen = ref(false);
|
||||
const plainToken = ref("");
|
||||
const tokenMemberName = ref("");
|
||||
|
||||
const createForm = reactive({ name: "", remark: "" });
|
||||
const createError = ref("");
|
||||
|
||||
const editForm = reactive({ id: 0, name: "", remark: "" });
|
||||
const editError = ref("");
|
||||
|
||||
const filterName = ref("");
|
||||
const filterStatus = ref<"active" | "disabled" | null>(null);
|
||||
|
||||
function syncFiltersFromRoute() {
|
||||
const q = route.query;
|
||||
filterName.value = typeof q.name === "string" ? q.name : "";
|
||||
filterStatus.value =
|
||||
q.status === "active" || q.status === "disabled" ? q.status : null;
|
||||
}
|
||||
|
||||
function pushFilterQuery() {
|
||||
const query: Record<string, string> = {};
|
||||
const mid = route.query.member_id;
|
||||
if (typeof mid === "string" && mid) query.member_id = mid;
|
||||
if (filterName.value.trim()) query.name = filterName.value.trim();
|
||||
if (filterStatus.value) query.status = filterStatus.value;
|
||||
void router.replace({ path: "/admin/members", query });
|
||||
}
|
||||
|
||||
syncFiltersFromRoute();
|
||||
watch(() => route.query, () => syncFiltersFromRoute());
|
||||
watch([filterName, filterStatus], () => pushFilterQuery());
|
||||
|
||||
function tipTitle(label: string, tip: string) {
|
||||
return h("span", { class: "overview-col-title" }, [
|
||||
label,
|
||||
h(
|
||||
Tooltip,
|
||||
{ title: tip },
|
||||
() =>
|
||||
h(QuestionCircleOutlined, {
|
||||
class: "overview-col-title__icon",
|
||||
"aria-hidden": "true",
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: "名称", key: "name", dataIndex: "name", width: 140 },
|
||||
{ title: "备注", key: "remark" },
|
||||
{ title: "状态", key: "status", width: 100 },
|
||||
{
|
||||
title: tipTitle("令牌签发", "明文已不可找回,如需新令牌请重发"),
|
||||
key: "token_issued_at",
|
||||
width: 170,
|
||||
},
|
||||
{ title: "名下 Key 数", key: "key_count", width: 110 },
|
||||
{ title: "操作", key: "actions", width: 280 },
|
||||
];
|
||||
|
||||
const rows = computed(() => {
|
||||
let list = membersQuery.data.value ?? [];
|
||||
const mid = route.query.member_id;
|
||||
if (typeof mid === "string" && mid) {
|
||||
const n = Number(mid);
|
||||
list = list.filter((m) => m.id === n);
|
||||
}
|
||||
const name = filterName.value.trim();
|
||||
if (name) {
|
||||
const lower = name.toLowerCase();
|
||||
list = list.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(lower) ||
|
||||
(m.remark ?? "").toLowerCase().includes(lower),
|
||||
);
|
||||
}
|
||||
if (filterStatus.value) {
|
||||
list = list.filter((m) => m.status === filterStatus.value);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const hasFilter = computed(
|
||||
() =>
|
||||
!!filterName.value.trim() ||
|
||||
filterStatus.value != null ||
|
||||
(typeof route.query.member_id === "string" && !!route.query.member_id),
|
||||
);
|
||||
|
||||
async function onCreate() {
|
||||
createError.value = "";
|
||||
const name = createForm.name.trim();
|
||||
if (!name) {
|
||||
createError.value = "请填写成员名称";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await createMember.mutateAsync({
|
||||
name,
|
||||
remark: createForm.remark.trim(),
|
||||
});
|
||||
createOpen.value = false;
|
||||
createForm.name = "";
|
||||
createForm.remark = "";
|
||||
plainToken.value = res.token;
|
||||
tokenMemberName.value = res.member.name;
|
||||
tokenOpen.value = true;
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
createError.value = err.message || "开户失败";
|
||||
}
|
||||
}
|
||||
|
||||
async function onReissue(record: MemberItem) {
|
||||
try {
|
||||
const res = await issueToken.mutateAsync(record.id);
|
||||
plainToken.value = res.token;
|
||||
tokenMemberName.value = record.name;
|
||||
tokenOpen.value = true;
|
||||
} catch (e) {
|
||||
message.error((e as ApiError).message || "重发失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggle(record: MemberItem) {
|
||||
const next = record.status === "active" ? "disabled" : "active";
|
||||
try {
|
||||
await patchMember.mutateAsync({
|
||||
id: record.id,
|
||||
body: { status: next },
|
||||
});
|
||||
message.success(
|
||||
next === "disabled"
|
||||
? "已停用——该成员视图立即不可访问,数据保留"
|
||||
: "已启用——下一请求即恢复",
|
||||
);
|
||||
} catch (e) {
|
||||
message.error((e as ApiError).message || "状态更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(record: MemberItem) {
|
||||
editForm.id = record.id;
|
||||
editForm.name = record.name;
|
||||
editForm.remark = record.remark ?? "";
|
||||
editError.value = "";
|
||||
editOpen.value = true;
|
||||
}
|
||||
|
||||
async function onEdit() {
|
||||
editError.value = "";
|
||||
const name = editForm.name.trim();
|
||||
if (!name) {
|
||||
editError.value = "请填写成员名称";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await patchMember.mutateAsync({
|
||||
id: editForm.id,
|
||||
body: { name, remark: editForm.remark },
|
||||
});
|
||||
editOpen.value = false;
|
||||
message.success("已保存");
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
editError.value = err.message || "保存失败";
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(record: MemberItem) {
|
||||
try {
|
||||
await deleteMember.mutateAsync(record.id);
|
||||
message.success("已删除——仅清理未挂 key 的空壳");
|
||||
} catch (e) {
|
||||
message.error((e as ApiError).message || "删除失败");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<PageHeader title="成员管理" subtitle="开户、档案修改、访问令牌与启停">
|
||||
<template #actions>
|
||||
<a-button type="primary" @click="createOpen = true">新建成员</a-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="members-filters">
|
||||
<a-input
|
||||
v-model:value="filterName"
|
||||
allow-clear
|
||||
placeholder="名称 / 备注"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filterStatus"
|
||||
:options="[
|
||||
{ value: null, label: '全部状态' },
|
||||
{ value: 'active', label: '启用中' },
|
||||
{ value: 'disabled', label: '已停用' },
|
||||
]"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
placeholder="状态"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TableRefresh
|
||||
:loading="membersQuery.isFetching.value"
|
||||
@refresh="() => membersQuery.refetch()"
|
||||
/>
|
||||
|
||||
<a-empty
|
||||
v-if="!membersQuery.isPending.value && rows.length === 0"
|
||||
:description="hasFilter ? '没有符合筛选的成员' : '还没有成员——为每位分摊朋友开户并发放访问令牌'"
|
||||
>
|
||||
<a-button v-if="!hasFilter" @click="createOpen = true">新建成员</a-button>
|
||||
</a-empty>
|
||||
|
||||
<a-table
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:row-key="(r: MemberItem) => r.id"
|
||||
:loading="membersQuery.isPending.value || membersQuery.isFetching.value"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
:scroll="{ x: 900 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'remark'">
|
||||
{{ record.remark || "—" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-badge
|
||||
:status="record.status === 'active' ? 'success' : 'default'"
|
||||
:text="record.status === 'active' ? '启用中' : '已停用'"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'token_issued_at'">
|
||||
{{ formatMinute(record.token_issued_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'key_count'">
|
||||
{{ record.key_count }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button
|
||||
size="small"
|
||||
@click="() => router.push({ path: '/admin/logs', query: { member_id: String(record.id) } })"
|
||||
>
|
||||
查调用
|
||||
</a-button>
|
||||
<a-button size="small" @click="() => openEdit(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="重发后旧令牌立即失效"
|
||||
description="该成员正在用的访问将中断"
|
||||
ok-text="重发"
|
||||
cancel-text="取消"
|
||||
@confirm="() => onReissue(record)"
|
||||
>
|
||||
<a-button size="small">重发令牌</a-button>
|
||||
</a-popconfirm>
|
||||
<a-popconfirm
|
||||
v-if="record.status === 'active'"
|
||||
title="停用后该成员的视图立即不可访问"
|
||||
description="令牌验证含状态检查;数据保留"
|
||||
ok-text="停用"
|
||||
cancel-text="取消"
|
||||
@confirm="() => onToggle(record)"
|
||||
>
|
||||
<a-button size="small" danger>停用</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button v-else size="small" @click="() => onToggle(record)">
|
||||
启用
|
||||
</a-button>
|
||||
<a-tooltip
|
||||
v-if="record.key_count > 0"
|
||||
title="名下仍有 key,只能停用"
|
||||
>
|
||||
<a-button size="small" danger disabled>删除</a-button>
|
||||
</a-tooltip>
|
||||
<a-popconfirm
|
||||
v-else
|
||||
title="删除后不可恢复(名称与令牌一并作废)"
|
||||
description="仅清理未挂 key 的空壳"
|
||||
ok-text="删除"
|
||||
cancel-text="取消"
|
||||
@confirm="() => onDelete(record)"
|
||||
>
|
||||
<a-button size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-modal
|
||||
v-model:open="editOpen"
|
||||
title="编辑成员"
|
||||
:confirm-loading="patchMember.isPending.value"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="onEdit"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="名称" required>
|
||||
<a-input v-model:value="editForm.name" placeholder="成员名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-input v-model:value="editForm.remark" placeholder="备注(可选)" />
|
||||
</a-form-item>
|
||||
<a-alert v-if="editError" type="error" show-icon :message="editError" />
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="createOpen"
|
||||
title="新建成员"
|
||||
:confirm-loading="createMember.isPending.value"
|
||||
ok-text="开户并签发令牌"
|
||||
cancel-text="取消"
|
||||
@ok="onCreate"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="名称" required>
|
||||
<a-input v-model:value="createForm.name" placeholder="成员名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-input v-model:value="createForm.remark" placeholder="备注(可选)" />
|
||||
</a-form-item>
|
||||
<a-alert v-if="createError" type="error" show-icon :message="createError" />
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<MemberTokenModal
|
||||
v-model:open="tokenOpen"
|
||||
:token="plainToken"
|
||||
:member-name="tokenMemberName"
|
||||
@update:open="(v: boolean) => { if (!v) { plainToken = ''; tokenMemberName = ''; } }"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.members-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.overview-col-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-col-title__icon {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
cursor: help;
|
||||
}
|
||||
</style>
|
||||
@@ -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,464 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 支付记录(交互设计-支付记录):订阅 / 充值两 Tab · 核对提示
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { message } from "antdv-next";
|
||||
import type { SubscriptionItem, TopupItem } from "@new-api-key-usage/core";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import { fetchLogs } from "@/api/admin/logs.ts";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import PageHeader from "@/components/common/page-header.vue";
|
||||
import TableRefresh from "@/components/common/table-refresh.vue";
|
||||
import PaymentFormModal from "@/components/admin/payment-form-modal.vue";
|
||||
import QuotaText from "@/components/common/quota-text.vue";
|
||||
import {
|
||||
useDeleteSubscription,
|
||||
useDeleteTopup,
|
||||
useSubscriptions,
|
||||
useTopups,
|
||||
} from "@/queries/admin.ts";
|
||||
import { formatDateTime, toRequestIso } from "@/utils/time.ts";
|
||||
import { formatPaidAmount } from "@/utils/format.ts";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const subsQuery = useSubscriptions();
|
||||
const topupsQuery = useTopups();
|
||||
|
||||
const filterDomain = ref<string | "none" | null>(null);
|
||||
const filterSubId = ref<number | null>(null);
|
||||
const filterTradeNo = ref<string | null>(null);
|
||||
const domainOptions = computed(() => {
|
||||
const set = new Set<string>();
|
||||
for (const s of subsQuery.data.value ?? []) {
|
||||
if (s.billing_domain) set.add(s.billing_domain);
|
||||
}
|
||||
for (const t of topupsQuery.data.value ?? []) {
|
||||
if (t.billing_domain) set.add(t.billing_domain);
|
||||
}
|
||||
return [...set].sort().map((v) => ({ value: v, label: v }));
|
||||
});
|
||||
|
||||
const filteredSubs = computed(() => {
|
||||
let list = subsQuery.data.value ?? [];
|
||||
if (filterSubId.value != null) {
|
||||
list = list.filter((r) => r.subscription_id === filterSubId.value);
|
||||
}
|
||||
if (!filterDomain.value) return list;
|
||||
if (filterDomain.value === "none") {
|
||||
return list.filter((r) => r.billing_domain == null);
|
||||
}
|
||||
return list.filter((r) => r.billing_domain === filterDomain.value);
|
||||
});
|
||||
|
||||
const filteredTops = computed(() => {
|
||||
let list = topupsQuery.data.value ?? [];
|
||||
if (filterTradeNo.value) {
|
||||
const q = filterTradeNo.value.trim().toLowerCase();
|
||||
list = list.filter(
|
||||
(r) =>
|
||||
(r.trade_no ?? "").toLowerCase().includes(q) ||
|
||||
(r.upstream_id != null && String(r.upstream_id).includes(q)),
|
||||
);
|
||||
}
|
||||
if (!filterDomain.value) return list;
|
||||
if (filterDomain.value === "none") {
|
||||
return list.filter((r) => r.billing_domain == null);
|
||||
}
|
||||
return list.filter((r) => r.billing_domain === filterDomain.value);
|
||||
});
|
||||
const deleteSub = useDeleteSubscription();
|
||||
const deleteTop = useDeleteTopup();
|
||||
|
||||
const tab = ref<"subs" | "topups">("subs");
|
||||
const formOpen = ref(false);
|
||||
const editingSub = ref<SubscriptionItem | null>(null);
|
||||
const editingTop = ref<TopupItem | null>(null);
|
||||
const missingSubs = ref<number[]>([]);
|
||||
const checking = ref(false);
|
||||
|
||||
function syncFromRoute() {
|
||||
tab.value = route.query.tab === "topups" ? "topups" : "subs";
|
||||
const sid = route.query.subscription_id;
|
||||
filterSubId.value = typeof sid === "string" && sid ? Number(sid) || null : null;
|
||||
const tn = route.query.trade_no;
|
||||
filterTradeNo.value = typeof tn === "string" && tn ? tn : null;
|
||||
}
|
||||
|
||||
function pushQuery() {
|
||||
const query: Record<string, string> = {};
|
||||
if (tab.value === "topups") query.tab = "topups";
|
||||
if (filterSubId.value != null) query.subscription_id = String(filterSubId.value);
|
||||
if (filterTradeNo.value) query.trade_no = filterTradeNo.value;
|
||||
void router.replace({ path: "/admin/payments", query });
|
||||
}
|
||||
|
||||
syncFromRoute();
|
||||
watch(() => route.query, () => syncFromRoute());
|
||||
watch([tab, filterSubId, filterTradeNo], () => pushQuery());
|
||||
|
||||
const subRows = computed(() => [...filteredSubs.value]);
|
||||
const topRows = computed(() => {
|
||||
const list = [...filteredTops.value];
|
||||
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
|
||||
return list;
|
||||
});
|
||||
|
||||
const subColumns = [
|
||||
{ title: "订阅 ID", dataIndex: "subscription_id", key: "subscription_id", width: 100 },
|
||||
{ title: "期间", key: "period", width: 200 },
|
||||
{ title: "额度", key: "quota", width: 160 },
|
||||
{ title: "付款金额 / 时间", key: "paid", width: 170 },
|
||||
{ title: "账本域", key: "billing_domain", width: 110 },
|
||||
{ title: "备注", key: "remark" },
|
||||
{ title: "登记", key: "created_at", width: 150 },
|
||||
{ title: "操作", key: "actions", width: 120 },
|
||||
];
|
||||
|
||||
const topColumns = [
|
||||
{ title: "时间", key: "paid_at", width: 150 },
|
||||
{
|
||||
title: "充值金额",
|
||||
dataIndex: "amount",
|
||||
key: "amount",
|
||||
width: 100,
|
||||
},
|
||||
{ title: "实付金额", dataIndex: "money", key: "money", width: 100 },
|
||||
{ title: "交易号 / 渠道", key: "trade", width: 180 },
|
||||
{ title: "派生额度", key: "quota", width: 150 },
|
||||
{ title: "账本域", key: "billing_domain", width: 110 },
|
||||
{ title: "备注", key: "remark" },
|
||||
{ title: "操作", key: "actions", width: 120 },
|
||||
];
|
||||
|
||||
function periodLines(s: SubscriptionItem): { start: string; end: string } {
|
||||
return {
|
||||
start: formatDateTime(s.period_start),
|
||||
end: s.period_end == null ? "进行中" : formatDateTime(s.period_end),
|
||||
};
|
||||
}
|
||||
|
||||
function onCreateSub() {
|
||||
editingSub.value = null;
|
||||
editingTop.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function onCreateTop() {
|
||||
editingSub.value = null;
|
||||
editingTop.value = null;
|
||||
tab.value = "topups";
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function onEditSub(s: SubscriptionItem) {
|
||||
editingSub.value = s;
|
||||
editingTop.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function onEditTop(t: TopupItem) {
|
||||
editingTop.value = t;
|
||||
editingSub.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
async function onDeleteSub(s: SubscriptionItem) {
|
||||
try {
|
||||
await deleteSub.mutateAsync(s.id);
|
||||
message.success("已删除");
|
||||
} catch (e) {
|
||||
message.error((e as ApiError).message || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteTop(t: TopupItem) {
|
||||
try {
|
||||
await deleteTop.mutateAsync(t.id);
|
||||
message.success("已删除");
|
||||
} catch (e) {
|
||||
message.error((e as ApiError).message || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** 观测订阅号 ↔ 已登记:只提示不回填;双表皆空则静默 */
|
||||
async function reconcile(subs: SubscriptionItem[]) {
|
||||
if (subs.length === 0 && topupsQuery.data.value?.length === 0) {
|
||||
missingSubs.value = [];
|
||||
return;
|
||||
}
|
||||
if (subs.length === 0) {
|
||||
missingSubs.value = [];
|
||||
return;
|
||||
}
|
||||
checking.value = true;
|
||||
try {
|
||||
const known = new Set(subs.map((s) => s.subscription_id));
|
||||
const earliest = Math.min(
|
||||
...subs.map((s) => s.period_start ?? 0),
|
||||
Date.now() / 1000,
|
||||
);
|
||||
const from = toRequestIso(new Date((earliest - 30 * 86400) * 1000));
|
||||
const to = toRequestIso(new Date());
|
||||
const page = await fetchLogs({
|
||||
from,
|
||||
to,
|
||||
page: 1,
|
||||
page_size: 200,
|
||||
type: 2,
|
||||
});
|
||||
const observed = new Set<number>();
|
||||
for (const it of page.items) {
|
||||
if (it.type === 2 && it.subscription_id != null) {
|
||||
observed.add(it.subscription_id);
|
||||
}
|
||||
}
|
||||
missingSubs.value = [...observed].filter((id) => !known.has(id)).sort((a, b) => a - b);
|
||||
} catch {
|
||||
missingSubs.value = [];
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => subsQuery.data.value,
|
||||
(subs) => {
|
||||
if (!subs) return;
|
||||
void reconcile(subs);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<PageHeader title="支付记录" subtitle="订阅权益与充值入账登记">
|
||||
<template #actions>
|
||||
<a-button v-if="tab === 'subs'" type="primary" @click="onCreateSub">
|
||||
登记订阅
|
||||
</a-button>
|
||||
<a-button v-else type="primary" @click="onCreateTop">登记充值</a-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<a-tabs v-model:active-key="tab">
|
||||
<a-tab-pane key="subs" tab="订阅" />
|
||||
<a-tab-pane key="topups" tab="充值" />
|
||||
</a-tabs>
|
||||
|
||||
<div class="payments-toolbar">
|
||||
<template v-if="tab === 'subs'">
|
||||
<a-input-number
|
||||
v-model:value="filterSubId"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
style="width: 140px"
|
||||
placeholder="订阅 ID"
|
||||
allow-clear
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-input
|
||||
v-model:value="filterTradeNo"
|
||||
style="width: 180px"
|
||||
placeholder="交易号 / 上游 ID"
|
||||
allow-clear
|
||||
/>
|
||||
</template>
|
||||
<a-select
|
||||
v-model:value="filterDomain"
|
||||
:options="[
|
||||
{ value: null, label: '全部账本域' },
|
||||
{ value: 'none', label: '未标注' },
|
||||
...domainOptions,
|
||||
]"
|
||||
style="width: 160px"
|
||||
placeholder="账本域"
|
||||
allow-clear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-if="tab === 'subs' && missingSubs.length"
|
||||
type="warning"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
:message="`观测到 ${missingSubs.length} 个订阅有消耗但尚无对应登记`"
|
||||
>
|
||||
<template #description>
|
||||
<div class="pay-missing__tags">
|
||||
<a-tag v-for="id in missingSubs" :key="id">#{{ id }}</a-tag>
|
||||
</div>
|
||||
<div class="pay-missing__note">
|
||||
若本部署不登记支付,可忽略此提示;需要入账的请核对补录。
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<template v-if="tab === 'subs'">
|
||||
<TableRefresh
|
||||
:loading="subsQuery.isFetching.value"
|
||||
@refresh="() => subsQuery.refetch()"
|
||||
/>
|
||||
<a-empty
|
||||
v-if="!subsQuery.isPending.value && subRows.length === 0"
|
||||
description="还没有订阅登记——从平台选用或手工登记"
|
||||
>
|
||||
<a-button @click="onCreateSub">登记订阅</a-button>
|
||||
</a-empty>
|
||||
<a-table
|
||||
v-else
|
||||
:columns="subColumns"
|
||||
:data-source="subRows"
|
||||
:row-key="(r: SubscriptionItem) => r.id"
|
||||
:loading="subsQuery.isPending.value || subsQuery.isFetching.value || checking"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
:scroll="{ x: 960 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'period'">
|
||||
<div class="pay-period">
|
||||
<span>{{ periodLines(record).start }}</span>
|
||||
<span class="pay-period__end">~ {{ periodLines(record).end }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'quota'">
|
||||
<QuotaText :quota="record.quota_granted" :quota-usd="record.quota_usd" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'paid'">
|
||||
{{
|
||||
record.paid_amount != null
|
||||
? `${formatPaidAmount(record.paid_amount)} · ${formatDateTime(record.paid_at)}`
|
||||
: "—"
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'billing_domain'">
|
||||
{{ record.billing_domain || "—" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'remark'">
|
||||
{{ record.remark || "—" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'created_at'">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button size="small" @click="() => onEditSub(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="删除后该条将消失"
|
||||
ok-text="删除"
|
||||
cancel-text="取消"
|
||||
@confirm="() => onDeleteSub(record)"
|
||||
>
|
||||
<a-button size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<TableRefresh
|
||||
:loading="topupsQuery.isFetching.value"
|
||||
@refresh="() => topupsQuery.refetch()"
|
||||
/>
|
||||
<a-empty
|
||||
v-if="!topupsQuery.isPending.value && topRows.length === 0"
|
||||
description="还没有充值登记"
|
||||
>
|
||||
<a-button @click="onCreateTop">登记充值</a-button>
|
||||
</a-empty>
|
||||
<a-table
|
||||
v-else
|
||||
:columns="topColumns"
|
||||
:data-source="topRows"
|
||||
:row-key="(r: TopupItem) => r.id"
|
||||
:loading="topupsQuery.isPending.value || topupsQuery.isFetching.value"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
:scroll="{ x: 900 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'paid_at'">
|
||||
{{ formatDateTime(record.paid_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'amount'">
|
||||
{{ formatPaidAmount(record.amount) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'money'">
|
||||
{{ formatPaidAmount(record.money) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'trade'">
|
||||
{{ record.trade_no || "—" }}
|
||||
<span v-if="record.payment_method" class="pay-note">
|
||||
· {{ record.payment_method }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'quota'">
|
||||
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'billing_domain'">
|
||||
{{ record.billing_domain || "—" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'remark'">
|
||||
{{ record.remark || "—" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button size="small" @click="() => onEditTop(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="删除后该条将消失"
|
||||
ok-text="删除"
|
||||
cancel-text="取消"
|
||||
@confirm="() => onDeleteTop(record)"
|
||||
>
|
||||
<a-button size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<PaymentFormModal
|
||||
v-model:open="formOpen"
|
||||
:tab="tab"
|
||||
:editing-sub="editingSub"
|
||||
:editing-top="editingTop"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.payments-toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pay-period {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 20px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pay-period__end {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.pay-missing__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pay-missing__note,
|
||||
.pay-note {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,584 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 成员调用日志(交互设计-成员视图 §4):
|
||||
* 行展开 = 模型调用详情;来源徽章 = 订阅支付参数弹窗(R12)
|
||||
*/
|
||||
import { computed, h, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import type { LogItem, SubscriptionItem } from "@new-api-key-usage/core";
|
||||
import { Tooltip } from "antdv-next";
|
||||
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import PageHeader from "@/components/common/page-header.vue";
|
||||
import TableRefresh from "@/components/common/table-refresh.vue";
|
||||
import TimeRangePicker from "@/components/common/time-range-picker.vue";
|
||||
import QuotaText from "@/components/common/quota-text.vue";
|
||||
import LogsSummaryBar from "@/components/common/logs-summary-bar.vue";
|
||||
import { useMemberRange } from "@/composables/use-member-range.ts";
|
||||
import type { RangeKind } from "@/stores/member-range.ts";
|
||||
import { useMemberLogs, useMemberOverview } from "@/queries/member.ts";
|
||||
import { formatDateTime } from "@/utils/time.ts";
|
||||
import { formatInt, formatPaidAmount } from "@/utils/format.ts";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { range, setRange } = useMemberRange();
|
||||
|
||||
const apiKeyId = ref<number | null>(null);
|
||||
const modelFilter = ref<string | null>(null);
|
||||
const billingSource = ref<string | null>(null);
|
||||
const subscriptionId = ref<number | null>(null);
|
||||
const typeFilter = ref<number | "error" | null>(null);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(50);
|
||||
|
||||
const overviewQuery = useMemberOverview(() => range.value);
|
||||
const payments = computed(
|
||||
() => overviewQuery.data.value?.subscriptions ?? [],
|
||||
);
|
||||
const keys = computed(() => overviewQuery.data.value?.keys ?? []);
|
||||
|
||||
const params = computed(() => ({
|
||||
...range.value,
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
api_key_id: apiKeyId.value ?? undefined,
|
||||
model_name: modelFilter.value ?? undefined,
|
||||
billing_source: billingSource.value ?? undefined,
|
||||
subscription_id: subscriptionId.value ?? undefined,
|
||||
type: typeFilter.value ?? undefined,
|
||||
}));
|
||||
|
||||
const logsQuery = useMemberLogs(() => params.value);
|
||||
const rows = computed(() => logsQuery.data.value?.items ?? []);
|
||||
const total = computed(() => logsQuery.data.value?.total ?? 0);
|
||||
const summary = computed(() => logsQuery.data.value?.summary);
|
||||
const modelOptions = computed(() => [
|
||||
{ value: null, label: "全部模型" },
|
||||
...(logsQuery.data.value?.models ?? []).map((m) => ({ value: m, label: m })),
|
||||
]);
|
||||
|
||||
const keyOptions = computed(() => [
|
||||
{ value: null, label: "全部 Key" },
|
||||
...keys.value.map((k) => ({ value: k.key_id, label: k.token_name })),
|
||||
]);
|
||||
|
||||
const paymentBySub = computed(() => {
|
||||
const m = new Map<number, SubscriptionItem>();
|
||||
for (const p of payments.value) {
|
||||
m.set(p.subscription_id, p);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
function tipTitle(label: string, tip: string) {
|
||||
return h("span", { class: "mv-col-title" }, [
|
||||
label,
|
||||
h(Tooltip, { title: tip }, () => h(QuestionCircleOutlined, { class: "mv-col-title__icon" })),
|
||||
]);
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: "时间", key: "created_at", width: 150 },
|
||||
{ title: "Key", key: "token_name", dataIndex: "token_name", width: 120 },
|
||||
{ title: "模型", key: "model_name", dataIndex: "model_name", width: 120 },
|
||||
{ title: "类型", key: "type", width: 80 },
|
||||
{ title: "来源", key: "source", width: 120 },
|
||||
{
|
||||
title: tipTitle("用量", "quota 原值与按当时换算率折算的美元"),
|
||||
key: "usage",
|
||||
width: 150,
|
||||
},
|
||||
{ title: "操作", key: "actions", width: 72 },
|
||||
];
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detailRecord = ref<LogItem | null>(null);
|
||||
/** 正在从 URL 灌入状态——避免筛选 watcher 误重置 page */
|
||||
const applyingRoute = ref(false);
|
||||
|
||||
function openDetail(record: LogItem) {
|
||||
detailRecord.value = record;
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
function formatRaw(record: LogItem | null): string {
|
||||
if (!record) return "";
|
||||
return JSON.stringify(record.raw ?? record, null, 2);
|
||||
}
|
||||
|
||||
function sourceBadge(record: LogItem) {
|
||||
if (record.billing_source === "subscription" && record.subscription_id != null) {
|
||||
return {
|
||||
text: `订阅 #${record.subscription_id}`,
|
||||
kind: "sub" as const,
|
||||
id: record.subscription_id,
|
||||
};
|
||||
}
|
||||
if (record.billing_source === "wallet") {
|
||||
return { text: "充值", kind: "wallet" as const, id: null };
|
||||
}
|
||||
return { text: "未关联", kind: "none" as const, id: null };
|
||||
}
|
||||
|
||||
function onBadge(record: LogItem, e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (sourceBadge(record).kind !== "sub") return;
|
||||
payModalSubId.value = sourceBadge(record).id;
|
||||
payModalOpen.value = true;
|
||||
}
|
||||
|
||||
function periodText(p: SubscriptionItem): string {
|
||||
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
|
||||
return `${formatDateTime(p.period_start)} ~ ${end}`;
|
||||
}
|
||||
|
||||
const payModalOpen = ref(false);
|
||||
const payModalSubId = ref<number | null>(null);
|
||||
const payModalPayment = computed(() =>
|
||||
payModalSubId.value == null
|
||||
? undefined
|
||||
: paymentBySub.value.get(payModalSubId.value),
|
||||
);
|
||||
|
||||
function syncFromRoute() {
|
||||
applyingRoute.value = true;
|
||||
const q = route.query;
|
||||
const k = q.api_key_id ?? q.key_id;
|
||||
apiKeyId.value = typeof k === "string" && k ? Number(k) || null : null;
|
||||
modelFilter.value =
|
||||
typeof q.model_name === "string" && q.model_name ? q.model_name : null;
|
||||
billingSource.value =
|
||||
typeof q.billing_source === "string" && q.billing_source
|
||||
? q.billing_source
|
||||
: null;
|
||||
const s = q.subscription_id;
|
||||
subscriptionId.value = typeof s === "string" && s ? Number(s) || null : null;
|
||||
const t = q.type;
|
||||
if (t === "2") typeFilter.value = 2;
|
||||
else if (t === "error") typeFilter.value = "error";
|
||||
else typeFilter.value = null;
|
||||
const p = q.page;
|
||||
page.value = typeof p === "string" && p ? Number(p) || 1 : 1;
|
||||
void nextTick(() => {
|
||||
applyingRoute.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function pushQuery() {
|
||||
const query: Record<string, string> = {
|
||||
from: range.value.from,
|
||||
to: range.value.to,
|
||||
};
|
||||
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
|
||||
if (modelFilter.value) query.model_name = modelFilter.value;
|
||||
if (billingSource.value) query.billing_source = billingSource.value;
|
||||
if (subscriptionId.value != null) {
|
||||
query.subscription_id = String(subscriptionId.value);
|
||||
}
|
||||
if (typeFilter.value) query.type = String(typeFilter.value);
|
||||
if (page.value > 1) query.page = String(page.value);
|
||||
void router.replace({ path: "/member/logs", query });
|
||||
}
|
||||
|
||||
syncFromRoute();
|
||||
watch(() => route.query, () => syncFromRoute());
|
||||
watch([apiKeyId, modelFilter, billingSource, subscriptionId, typeFilter], () => {
|
||||
if (applyingRoute.value) return;
|
||||
page.value = 1;
|
||||
pushQuery();
|
||||
});
|
||||
watch(page, () => pushQuery());
|
||||
|
||||
function onRangeChange(v: { from: string; to: string }, k: RangeKind) {
|
||||
setRange(v, k);
|
||||
page.value = 1;
|
||||
pushQuery();
|
||||
}
|
||||
|
||||
function onPageChange(p: number, ps: number) {
|
||||
page.value = p;
|
||||
pageSize.value = ps;
|
||||
}
|
||||
|
||||
const boxRef = ref<HTMLElement | null>(null);
|
||||
const bodyY = ref(480);
|
||||
const CHROME = 96;
|
||||
let ro: ResizeObserver | null = null;
|
||||
|
||||
function measure() {
|
||||
const h = boxRef.value?.clientHeight ?? 0;
|
||||
if (h > 0) bodyY.value = Math.max(200, h - CHROME);
|
||||
}
|
||||
|
||||
function bindObserve() {
|
||||
ro?.disconnect();
|
||||
ro = null;
|
||||
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
|
||||
ro = new ResizeObserver(() => measure());
|
||||
ro.observe(boxRef.value);
|
||||
}
|
||||
measure();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
bindObserve();
|
||||
window.addEventListener("resize", measure);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
ro?.disconnect();
|
||||
window.removeEventListener("resize", measure);
|
||||
});
|
||||
// 空态/有数据切换会重建 box 节点,需重新挂 RO
|
||||
watch(
|
||||
() => rows.value.length === 0 && !logsQuery.isPending.value,
|
||||
async () => {
|
||||
await nextTick();
|
||||
bindObserve();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="member-logs">
|
||||
<PageHeader title="调用日志" subtitle="名下 key 的模型请求流水">
|
||||
<template #actions>
|
||||
<span class="member-logs__label">时间范围</span>
|
||||
<TimeRangePicker :model-value="range" @change="onRangeChange" />
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="member-logs__filters">
|
||||
<a-select
|
||||
v-model:value="apiKeyId"
|
||||
:options="keyOptions"
|
||||
style="width: 160px"
|
||||
allow-clear
|
||||
placeholder="Key"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="modelFilter"
|
||||
:options="modelOptions"
|
||||
style="width: 140px"
|
||||
allow-clear
|
||||
placeholder="模型"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="billingSource"
|
||||
:options="[
|
||||
{ value: null, label: '全部来源' },
|
||||
{ value: 'subscription', label: '订阅' },
|
||||
{ value: 'wallet', label: '充值' },
|
||||
{ value: 'none', label: '未关联' },
|
||||
]"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
placeholder="来源"
|
||||
/>
|
||||
<a-input-number
|
||||
v-model:value="subscriptionId"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
style="width: 120px"
|
||||
placeholder="订阅 ID"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="typeFilter"
|
||||
:options="[
|
||||
{ value: null, label: '全部类型' },
|
||||
{ value: 2, label: '消费' },
|
||||
{ value: 'error', label: '错误' },
|
||||
]"
|
||||
style="width: 120px"
|
||||
placeholder="类型"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LogsSummaryBar
|
||||
:hit-count="summary?.hit_count ?? 0"
|
||||
:usage-count="summary?.usage_count ?? 0"
|
||||
:error-count="summary?.error_count ?? 0"
|
||||
:quota="summary?.quota ?? 0"
|
||||
:quota-usd="summary?.quota_usd ?? '0.00'"
|
||||
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
|
||||
/>
|
||||
|
||||
<TableRefresh
|
||||
:loading="logsQuery.isFetching.value"
|
||||
@refresh="() => logsQuery.refetch()"
|
||||
/>
|
||||
|
||||
<a-alert
|
||||
v-if="logsQuery.isError.value"
|
||||
type="error"
|
||||
show-icon
|
||||
message="调用日志加载失败"
|
||||
:description="logsQuery.error.value?.message"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<template #action>
|
||||
<a-button size="small" @click="() => logsQuery.refetch()">重试</a-button>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-empty
|
||||
v-if="!logsQuery.isPending.value && !logsQuery.isError.value && rows.length === 0"
|
||||
description="该时段内无调用日志"
|
||||
/>
|
||||
<div v-else ref="boxRef" class="member-logs__box">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:row-key="(r: LogItem) => r.id"
|
||||
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
|
||||
:pagination="{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100', '200'],
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
onChange: onPageChange,
|
||||
}"
|
||||
size="middle"
|
||||
:scroll="{ x: 780, y: bodyY }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'created_at'">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'usage'">
|
||||
<QuotaText :quota="record.quota" :quota-usd="record.quota_usd" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<a-tag :color="record.type === 2 ? 'blue' : 'red'">
|
||||
{{ record.type === 2 ? "消费" : "错误" }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'source'">
|
||||
<a-tooltip
|
||||
v-if="sourceBadge(record).kind === 'sub'"
|
||||
title="点击查看该订阅支付参数"
|
||||
>
|
||||
<a-tag
|
||||
color="blue"
|
||||
class="member-logs__badge"
|
||||
@click="(e: MouseEvent) => onBadge(record, e)"
|
||||
>
|
||||
{{ sourceBadge(record).text }}
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
<a-tag v-else-if="sourceBadge(record).kind === 'wallet'" color="gold">
|
||||
充值
|
||||
</a-tag>
|
||||
<a-tag v-else>未关联</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a @click="openDetail(record)">详情</a>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
v-model:open="detailOpen"
|
||||
title="调用详情"
|
||||
:footer="null"
|
||||
width="720"
|
||||
centered
|
||||
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
|
||||
>
|
||||
<div v-if="detailRecord" class="member-logs__detail">
|
||||
<div class="member-logs__kv">
|
||||
<span class="member-logs__note">时间</span>
|
||||
<span>{{ formatDateTime(detailRecord.created_at) }}</span>
|
||||
</div>
|
||||
<div class="member-logs__kv">
|
||||
<span class="member-logs__note">Key / 模型</span>
|
||||
<span>{{ detailRecord.token_name }} · {{ detailRecord.model_name }}</span>
|
||||
</div>
|
||||
<div class="member-logs__kv">
|
||||
<span class="member-logs__note">类型</span>
|
||||
<a-tag :color="detailRecord.type === 2 ? 'blue' : 'red'">
|
||||
{{ detailRecord.type === 2 ? "消费" : "错误" }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="member-logs__kv">
|
||||
<span class="member-logs__note">用量</span>
|
||||
<QuotaText :quota="detailRecord.quota" :quota-usd="detailRecord.quota_usd" />
|
||||
</div>
|
||||
<div class="member-logs__kv">
|
||||
<span class="member-logs__note">Tokens</span>
|
||||
<span class="member-logs__num">
|
||||
提示 {{ formatInt(detailRecord.prompt_tokens) }} · 补全 {{ formatInt(detailRecord.completion_tokens) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="member-logs__kv">
|
||||
<span class="member-logs__note">耗时</span>
|
||||
<span class="member-logs__num">
|
||||
{{ detailRecord.use_time }}s · {{ detailRecord.is_stream ? "流式" : "非流式" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="member-logs__kv">
|
||||
<span class="member-logs__note">来源</span>
|
||||
<a-tag :color="sourceBadge(detailRecord).kind === 'sub' ? 'blue' : sourceBadge(detailRecord).kind === 'wallet' ? 'gold' : undefined">
|
||||
{{ sourceBadge(detailRecord).text }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div v-if="detailRecord.content" class="member-logs__kv">
|
||||
<span class="member-logs__note">内容</span>
|
||||
<span class="member-logs__content">{{ detailRecord.content }}</span>
|
||||
</div>
|
||||
<div class="member-logs__raw">
|
||||
<div class="member-logs__note">原始数据</div>
|
||||
<pre class="member-logs__json">{{ formatRaw(detailRecord) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="payModalOpen"
|
||||
:title="payModalSubId != null ? `订阅 #${payModalSubId} 支付参数` : '支付参数'"
|
||||
:footer="null"
|
||||
width="480"
|
||||
centered
|
||||
:styles="{ body: { maxHeight: '56vh', overflowY: 'auto' } }"
|
||||
>
|
||||
<div v-if="payModalPayment" class="member-logs__pay">
|
||||
<div class="member-logs__kv">
|
||||
<span class="member-logs__note">期间</span>
|
||||
<span>{{ periodText(payModalPayment) }}</span>
|
||||
</div>
|
||||
<div class="member-logs__kv">
|
||||
<span class="member-logs__note">额度</span>
|
||||
<QuotaText
|
||||
:quota="payModalPayment.quota_granted"
|
||||
:quota-usd="payModalPayment.quota_usd"
|
||||
/>
|
||||
</div>
|
||||
<div class="member-logs__kv">
|
||||
<span class="member-logs__note">金额</span>
|
||||
<span class="member-logs__num">{{ formatPaidAmount(payModalPayment.paid_amount) }}</span>
|
||||
</div>
|
||||
<div v-if="payModalPayment.remark" class="member-logs__kv">
|
||||
<span class="member-logs__note">备注</span>
|
||||
<span>{{ payModalPayment.remark }}</span>
|
||||
</div>
|
||||
<div class="member-logs__foot">
|
||||
支付参数在你所属账本域内公开;币种由成员与作者约定
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="member-logs__note">该订阅无登记信息</div>
|
||||
</a-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.member-logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 64px - 48px - 48px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.member-logs__label {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.member-logs__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.member-logs__box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.member-logs__badge {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.member-logs__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.member-logs__expand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.member-logs__pay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.member-logs__foot {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.member-logs__kv {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.member-logs__note {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.member-logs__num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.member-logs__content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-logs__raw {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.member-logs__json {
|
||||
margin: 4px 0 0;
|
||||
padding: 12px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
}
|
||||
|
||||
.member-logs :deep(.mv-col-title) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.member-logs :deep(.mv-col-title__icon) {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 成员总览(交互设计-成员视图 §3):紧凑摘要 + 我的 Key 表 + 支付入口
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import PageHeader from "@/components/common/page-header.vue";
|
||||
import TableRefresh from "@/components/common/table-refresh.vue";
|
||||
import TimeRangePicker from "@/components/common/time-range-picker.vue";
|
||||
import SourceBar from "@/components/common/source-bar.vue";
|
||||
import QuotaText from "@/components/common/quota-text.vue";
|
||||
import { useMemberRange } from "@/composables/use-member-range.ts";
|
||||
import { useMemberOverview } from "@/queries/member.ts";
|
||||
import { formatMinute, formatRangeCaption, hoursSince } from "@/utils/time.ts";
|
||||
import { formatInt } from "@/utils/format.ts";
|
||||
|
||||
const router = useRouter();
|
||||
const { range, onRangeChange } = useMemberRange();
|
||||
/** 看板心智:仅总览自动刷新;其它成员页不轮询 */
|
||||
const overviewQuery = useMemberOverview(() => range.value, {
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const usage = computed(() => overviewQuery.data.value?.usage);
|
||||
const bySource = computed(() => overviewQuery.data.value?.by_source ?? []);
|
||||
const keys = computed(() => overviewQuery.data.value?.keys ?? []);
|
||||
const payments = computed(
|
||||
() => overviewQuery.data.value?.subscriptions ?? [],
|
||||
);
|
||||
|
||||
const extraPresets = computed(() =>
|
||||
payments.value
|
||||
.map((p) => ({
|
||||
label: `订阅 #${p.subscription_id} 期间`,
|
||||
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
|
||||
to: new Date(
|
||||
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
|
||||
).toISOString(),
|
||||
}))
|
||||
.slice(0, 4),
|
||||
);
|
||||
|
||||
const rangeCaption = computed(() =>
|
||||
formatRangeCaption(range.value.from, range.value.to),
|
||||
);
|
||||
|
||||
function isStalled(at: number | null | undefined): boolean {
|
||||
const h = hoursSince(at);
|
||||
return h != null && h > 2;
|
||||
}
|
||||
|
||||
function goLogs(keyId?: number) {
|
||||
const query: Record<string, string> = {
|
||||
from: range.value.from,
|
||||
to: range.value.to,
|
||||
};
|
||||
if (keyId != null) query.api_key_id = String(keyId);
|
||||
void router.push({ path: "/member/logs", query });
|
||||
}
|
||||
|
||||
const keyColumns = [
|
||||
{ title: "Key 名称", dataIndex: "token_name", key: "token_name" },
|
||||
{
|
||||
title: "数据截止",
|
||||
key: "data_cutoff",
|
||||
},
|
||||
{ title: "调用次数", key: "count" },
|
||||
{ title: "额度消耗", key: "usage" },
|
||||
{ title: "", key: "actions", width: 80 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<PageHeader title="总览" subtitle="你在本池的名下用量 · 仅统计消费行">
|
||||
<template #actions>
|
||||
<span class="mv-range-label">时间范围</span>
|
||||
<TimeRangePicker
|
||||
:model-value="range"
|
||||
:extra-presets="extraPresets"
|
||||
@change="onRangeChange"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<a-alert
|
||||
v-if="overviewQuery.isError.value"
|
||||
type="error"
|
||||
show-icon
|
||||
message="总览数据加载失败"
|
||||
:description="overviewQuery.error.value?.message"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<template #action>
|
||||
<a-button size="small" @click="() => overviewQuery.refetch()">重试</a-button>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-skeleton
|
||||
v-if="overviewQuery.isPending.value"
|
||||
active
|
||||
:paragraph="{ rows: 4 }"
|
||||
style="margin-bottom: 24px"
|
||||
/>
|
||||
<a-empty
|
||||
v-else-if="!overviewQuery.isError.value && keys.length === 0"
|
||||
description="作者尚未为你分配 key"
|
||||
style="margin-bottom: 24px"
|
||||
/>
|
||||
|
||||
<template v-else-if="keys.length > 0">
|
||||
<section class="mv-block">
|
||||
<h2 class="mv-block__title">
|
||||
用量摘要
|
||||
<span class="mv-caption">{{ rangeCaption }}</span>
|
||||
</h2>
|
||||
<div class="mv-summary">
|
||||
<div class="mv-summary__main">
|
||||
<QuotaText
|
||||
v-if="usage"
|
||||
:quota="usage.quota"
|
||||
:quota-usd="usage.quota_usd"
|
||||
/>
|
||||
<div class="mv-summary__sub">
|
||||
调用 {{ formatInt(usage?.count ?? 0) }} 次 · 按当时换算率
|
||||
<span v-if="usage && usage.count === 0" class="mv-note">· 该时段内无调用</span>
|
||||
</div>
|
||||
</div>
|
||||
<SourceBar :rows="bySource" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mv-block">
|
||||
<h2 class="mv-block__title">我的 Key</h2>
|
||||
<TableRefresh
|
||||
:loading="overviewQuery.isFetching.value"
|
||||
@refresh="() => overviewQuery.refetch()"
|
||||
/>
|
||||
<a-table
|
||||
:columns="keyColumns"
|
||||
:data-source="keys"
|
||||
:row-key="(r: { key_id: number }) => r.key_id"
|
||||
size="middle"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 720 }"
|
||||
:loading="overviewQuery.isFetching.value"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'data_cutoff'">
|
||||
<span
|
||||
v-if="record.log_synced_at == null"
|
||||
class="mv-syncing"
|
||||
>同步中…</span>
|
||||
<span
|
||||
v-else
|
||||
:class="{ 'is-stall': isStalled(record.log_synced_at) }"
|
||||
>
|
||||
{{ isStalled(record.log_synced_at) ? "同步停滞 " : "同步 " }}{{ formatMinute(record.log_synced_at) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'count'">
|
||||
{{ formatInt(record.usage.count) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'usage'">
|
||||
<QuotaText :quota="record.usage.quota" :quota-usd="record.usage.quota_usd" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-button size="small" @click="() => goLogs(record.key_id)">查调用</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
|
||||
<section class="mv-block">
|
||||
<h2 class="mv-block__title">支付参数</h2>
|
||||
<div class="mv-pay-entry">
|
||||
<span v-if="payments.length === 0" class="mv-note">暂无支付记录信息</span>
|
||||
<span v-else class="mv-note">
|
||||
该时段涉及 {{ payments.length }} 笔登记参数(订阅 / 充值)
|
||||
</span>
|
||||
<a @click="() => router.push('/member/payments')">查看完整参数</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mv-range-label {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.mv-block {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.mv-block__title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.mv-caption {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.mv-syncing {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.mv-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mv-summary__sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.mv-note {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.mv-pay-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.is-stall {
|
||||
color: #faad14;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 成员支付参数(交互设计-成员视图 §5):订阅 + 充值清单
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import PageHeader from "@/components/common/page-header.vue";
|
||||
import TableRefresh from "@/components/common/table-refresh.vue";
|
||||
import TimeRangePicker from "@/components/common/time-range-picker.vue";
|
||||
import QuotaText from "@/components/common/quota-text.vue";
|
||||
import { useMemberRange } from "@/composables/use-member-range.ts";
|
||||
import { useMemberOverview } from "@/queries/member.ts";
|
||||
import { formatDateTime } from "@/utils/time.ts";
|
||||
import { formatPaidAmount } from "@/utils/format.ts";
|
||||
|
||||
const { range, onRangeChange } = useMemberRange();
|
||||
const overviewQuery = useMemberOverview(() => range.value);
|
||||
const subs = computed(() => overviewQuery.data.value?.subscriptions ?? []);
|
||||
const tops = computed(() => overviewQuery.data.value?.topups ?? []);
|
||||
|
||||
const extraPresets = computed(() =>
|
||||
subs.value
|
||||
.map((p) => ({
|
||||
label: `订阅 #${p.subscription_id} 期间`,
|
||||
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
|
||||
to: new Date(
|
||||
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
|
||||
).toISOString(),
|
||||
}))
|
||||
.slice(0, 4),
|
||||
);
|
||||
|
||||
function periodLines(p: {
|
||||
period_start: number;
|
||||
period_end: number | null;
|
||||
}): { start: string; end: string } {
|
||||
return {
|
||||
start: formatDateTime(p.period_start),
|
||||
end: p.period_end == null ? "进行中" : formatDateTime(p.period_end),
|
||||
};
|
||||
}
|
||||
|
||||
const subColumns = [
|
||||
{
|
||||
title: "订阅 ID",
|
||||
dataIndex: "subscription_id",
|
||||
key: "subscription_id",
|
||||
width: 100,
|
||||
},
|
||||
{ title: "期间", key: "period", width: 200 },
|
||||
{ title: "额度", key: "quota", width: 160 },
|
||||
{ title: "付款金额 / 时间", key: "paid", width: 170 },
|
||||
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
|
||||
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
|
||||
];
|
||||
|
||||
const topColumns = [
|
||||
{ title: "时间", key: "paid_at", width: 150 },
|
||||
{
|
||||
title: "充值金额",
|
||||
dataIndex: "amount",
|
||||
key: "amount",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: "实付金额",
|
||||
dataIndex: "money",
|
||||
key: "money",
|
||||
width: 100,
|
||||
},
|
||||
{ title: "交易号 / 渠道", key: "trade", width: 180 },
|
||||
{ title: "派生额度", key: "quota", width: 150 },
|
||||
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
|
||||
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<PageHeader
|
||||
title="支付参数"
|
||||
subtitle="订阅与充值的登记参数 · 同账本域内可见"
|
||||
>
|
||||
<template #actions>
|
||||
<span class="mv-range-label">时间范围</span>
|
||||
<TimeRangePicker
|
||||
:model-value="range"
|
||||
:extra-presets="extraPresets"
|
||||
@change="onRangeChange"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<a-alert
|
||||
v-if="overviewQuery.isError.value"
|
||||
type="error"
|
||||
show-icon
|
||||
message="支付参数加载失败"
|
||||
:description="overviewQuery.error.value?.message"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<template #action>
|
||||
<a-button size="small" @click="() => overviewQuery.refetch()">重试</a-button>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-skeleton
|
||||
v-if="overviewQuery.isPending.value"
|
||||
active
|
||||
:paragraph="{ rows: 4 }"
|
||||
/>
|
||||
<a-empty
|
||||
v-else-if="!overviewQuery.isError.value && subs.length === 0 && tops.length === 0"
|
||||
description="暂无支付记录信息"
|
||||
/>
|
||||
<template v-else>
|
||||
<h3 class="mv-sec">订阅</h3>
|
||||
<TableRefresh
|
||||
:loading="overviewQuery.isFetching.value"
|
||||
@refresh="() => overviewQuery.refetch()"
|
||||
/>
|
||||
<a-empty v-if="subs.length === 0" description="暂无订阅登记" />
|
||||
<a-table
|
||||
v-else
|
||||
:columns="subColumns"
|
||||
:data-source="subs"
|
||||
:row-key="(r: { id: number }) => r.id"
|
||||
size="middle"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 760 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'period'">
|
||||
<div class="mv-period">
|
||||
<span>{{ periodLines(record).start }}</span>
|
||||
<span class="mv-period__end">~ {{ periodLines(record).end }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'quota'">
|
||||
<QuotaText :quota="record.quota_granted" :quota-usd="record.quota_usd" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'paid'">
|
||||
{{
|
||||
record.paid_amount != null
|
||||
? `${formatPaidAmount(record.paid_amount)} · ${formatDateTime(record.paid_at)}`
|
||||
: "—"
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'remark'">
|
||||
{{ record.remark || "—" }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<h3 class="mv-sec">充值</h3>
|
||||
<a-empty v-if="tops.length === 0" description="暂无充值登记" />
|
||||
<a-table
|
||||
v-else
|
||||
:columns="topColumns"
|
||||
:data-source="tops"
|
||||
:row-key="(r: { id: number }) => r.id"
|
||||
size="middle"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 840 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'paid_at'">
|
||||
{{ formatDateTime(record.paid_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'amount'">
|
||||
{{ formatPaidAmount(record.amount) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'money'">
|
||||
{{ formatPaidAmount(record.money) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'trade'">
|
||||
{{ record.trade_no || "—" }}
|
||||
<span v-if="record.payment_method" class="mv-note">
|
||||
· {{ record.payment_method }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'quota'">
|
||||
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'remark'">
|
||||
{{ record.remark || "—" }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<div class="mv-foot">
|
||||
支付参数在你所属账本域内公开;币种由成员与作者约定
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mv-period {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 20px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mv-period__end {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.mv-range-label {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.mv-sec {
|
||||
margin: 20px 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mv-note {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.mv-foot {
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
+23
-12
@@ -1,20 +1,31 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import vueJsx from "@vitejs/plugin-vue-jsx";
|
||||
import vueDevTools from "vite-plugin-vue-devtools";
|
||||
|
||||
/** 后端母体端口(与根 .env 的 PORT / AGENTS 约定一致) */
|
||||
const API_TARGET = process.env.VITE_API_PROXY_TARGET ?? "http://127.0.0.1:18456";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
vueDevTools(),
|
||||
],
|
||||
plugins: [vue(), vueJsx(), vueDevTools()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
server: {
|
||||
// 固定开发口:避免在 5173/5174 被占时静默跳到 5175(strictPort 不自动改口)
|
||||
port: 5175,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
// 契约:/api/* 专属接口;SPA 路由不进 /api(公共约定 §2)
|
||||
"/api": {
|
||||
target: API_TARGET,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
"": {
|
||||
"name": "new-api-key-usage",
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^1.8.1",
|
||||
"@types/bun": "^1.4.2",
|
||||
"citty": "^0.2.2",
|
||||
"typescript": "^7.0.2",
|
||||
},
|
||||
},
|
||||
@@ -36,8 +38,13 @@
|
||||
"name": "@new-api-key-usage/web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@antdv-next/icons": "^1.1.2",
|
||||
"@new-api-key-usage/core": "workspace:*",
|
||||
"antdv-next": "^1.6.0",
|
||||
"@tanstack/vue-query": "^5.104.1",
|
||||
"@vueuse/core": "15.0.0",
|
||||
"antdv-next": "1.6.0",
|
||||
"axios": "^1.20.0",
|
||||
"dayjs": "1.11.23",
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.42",
|
||||
"vue-router": "^5.3.1",
|
||||
@@ -91,7 +98,7 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ant-design/colors": ["@ant-design/colors@8.0.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="],
|
||||
"@ant-design/colors": ["@ant-design/colors@7.2.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6" } }, "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ=="],
|
||||
|
||||
"@ant-design/fast-color": ["@ant-design/fast-color@3.0.1", "", {}, "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw=="],
|
||||
|
||||
@@ -175,6 +182,10 @@
|
||||
|
||||
"@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="],
|
||||
|
||||
"@clack/core": ["@clack/core@1.5.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ=="],
|
||||
|
||||
"@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.2", "", {}, "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@3.4.3", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA=="],
|
||||
@@ -401,6 +412,12 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tanstack/match-sorter-utils": ["@tanstack/match-sorter-utils@9.2.6", "", { "dependencies": { "remove-accents": "0.5.0" } }, "sha512-DIpUSOhPfQ7u/qfUi/Wgm0wBO56MyBYXGvnlI0NcauDRPqdBUlAZZjW2Dxm92m+k1UWVhKw8YJ4f47+75Aujyw=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.104.1", "", {}, "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw=="],
|
||||
|
||||
"@tanstack/vue-query": ["@tanstack/vue-query@5.104.1", "", { "dependencies": { "@tanstack/match-sorter-utils": "^9.1.2", "@tanstack/query-core": "5.104.1", "@vue/devtools-api": "^6.6.3", "vue-demi": "^0.14.10" }, "peerDependencies": { "@vue/composition-api": "^1.1.2", "vue": "^2.6.0 || ^3.3.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-3858wOmIoIazR9mZ2g8/QsKNccQlzxIY+4/g55OHbhhMeBEi+ig/8vne2e5N+Xx9g5MXuB7Nscfb6B4REPDWlw=="],
|
||||
|
||||
"@tsconfig/node24": ["@tsconfig/node24@24.0.5", "", {}, "sha512-DWC54fGPuhZgg3Ighg4Y/A/bA3ZdB5hnzAuv/MkQ/X85sVsBsMQxu8t7khutdYeiNksBbFdDyeqv1HSW2Zl8aA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
|
||||
@@ -611,7 +628,7 @@
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.43", "", { "dependencies": { "@vue/compiler-dom": "3.5.43", "@vue/shared": "3.5.43" } }, "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1" } }, "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A=="],
|
||||
"@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
|
||||
|
||||
"@vue/devtools-core": ["@vue/devtools-core@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1", "@vue/devtools-shared": "^8.2.1" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-s/VfAY9oDTb/kFEWmy461jaFde2MIV1RO/gi1vwM+PAZBZ/Pc2Ndu3BNBdZUze8QDUuyYvElbEEGA83syjJfzA=="],
|
||||
|
||||
@@ -637,11 +654,11 @@
|
||||
|
||||
"@vue/tsconfig": ["@vue/tsconfig@0.9.1", "", { "peerDependencies": { "typescript": ">= 5.8", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w=="],
|
||||
|
||||
"@vueuse/core": ["@vueuse/core@14.4.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.4.0", "@vueuse/shared": "14.4.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ=="],
|
||||
"@vueuse/core": ["@vueuse/core@15.0.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "15.0.0", "@vueuse/shared": "15.0.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng=="],
|
||||
|
||||
"@vueuse/metadata": ["@vueuse/metadata@14.4.0", "", {}, "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g=="],
|
||||
"@vueuse/metadata": ["@vueuse/metadata@15.0.0", "", {}, "sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg=="],
|
||||
|
||||
"@vueuse/shared": ["@vueuse/shared@14.4.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g=="],
|
||||
"@vueuse/shared": ["@vueuse/shared@15.0.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA=="],
|
||||
|
||||
"abbrev": ["abbrev@5.0.0", "", {}, "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w=="],
|
||||
|
||||
@@ -649,6 +666,8 @@
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||
|
||||
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
|
||||
"alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
|
||||
@@ -665,6 +684,10 @@
|
||||
|
||||
"ast-walker-scope": ["ast-walker-scope@0.9.0", "", { "dependencies": { "@babel/parser": "^7.29.2", "@babel/types": "^7.29.0", "ast-kit": "^2.2.0" } }, "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.27", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ=="],
|
||||
@@ -689,12 +712,18 @@
|
||||
|
||||
"cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001814", "", {}, "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA=="],
|
||||
|
||||
"chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="],
|
||||
|
||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="],
|
||||
@@ -729,12 +758,16 @@
|
||||
|
||||
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.31.11", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.45.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"editorconfig": ["editorconfig@3.0.2", "", { "dependencies": { "@one-ini/wasm": "0.2.1", "commander": "^14.0.3", "minimatch": "~10.2.4", "semver": "^7.7.4" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.444", "", {}, "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ=="],
|
||||
@@ -743,8 +776,16 @@
|
||||
|
||||
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
@@ -789,6 +830,12 @@
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
|
||||
|
||||
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
|
||||
|
||||
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
|
||||
|
||||
"fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
@@ -803,18 +850,36 @@
|
||||
|
||||
"flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.16.1", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="],
|
||||
|
||||
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="],
|
||||
|
||||
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="],
|
||||
|
||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
@@ -823,6 +888,8 @@
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@7.0.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1" } }, "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
@@ -909,6 +976,8 @@
|
||||
|
||||
"magic-string-ast": ["magic-string-ast@1.0.3", "", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="],
|
||||
@@ -917,6 +986,10 @@
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
|
||||
|
||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
@@ -1013,6 +1086,8 @@
|
||||
|
||||
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="],
|
||||
@@ -1025,6 +1100,8 @@
|
||||
|
||||
"readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="],
|
||||
|
||||
"remove-accents": ["remove-accents@0.5.0", "", {}, "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="],
|
||||
@@ -1057,6 +1134,8 @@
|
||||
|
||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||
@@ -1141,6 +1220,8 @@
|
||||
|
||||
"vue-component-type-helpers": ["vue-component-type-helpers@3.3.12", "", {}, "sha512-HwCqmgTfLFskQc4P5TRnbduCMyPat2Ezu0elLWOGn0dD/6BKHP15B5bxce6aDi+E4kblPtru8amIBQzu0Di3qQ=="],
|
||||
|
||||
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||
|
||||
"vue-eslint-parser": ["vue-eslint-parser@10.4.1", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", "eslint-visitor-keys": "^4.2.0 || ^5.0.0", "espree": "^10.3.0 || ^11.0.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA=="],
|
||||
|
||||
"vue-router": ["vue-router@5.3.1", "", { "dependencies": { "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.9.0", "chokidar": "^5.0.0", "confbox": "^0.2.4", "local-pkg": "^1.2.1", "magic-string": "^0.30.21", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "nostics": "^1.1.4", "pathe": "^2.0.3", "picomatch": "^4.0.5", "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.3.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", "pinia": "^3.0.4 || ^4.0.2", "vite": "^7.3.0 || ^8.0.0", "vue": "^3.5.34 || ^4.0.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-GDBZzgmILxA/kFnkFbJjQZdZ2QQbngnIMMuoUcjhZIfH1RGMaPjPwX5ASnV38qamuA9uhO0RDjSBHTDNG2uXyQ=="],
|
||||
@@ -1179,7 +1260,7 @@
|
||||
|
||||
"zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
|
||||
|
||||
"@antdv-next/icons/@ant-design/colors": ["@ant-design/colors@7.2.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6" } }, "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ=="],
|
||||
"@ant-design/colors/@ant-design/fast-color": ["@ant-design/fast-color@2.0.6", "", { "dependencies": { "@babel/runtime": "^7.24.7" } }, "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
@@ -1211,6 +1292,10 @@
|
||||
|
||||
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"antdv-next/@ant-design/colors": ["@ant-design/colors@8.0.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="],
|
||||
|
||||
"antdv-next/@vueuse/core": ["@vueuse/core@14.4.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.4.0", "@vueuse/shared": "14.4.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
@@ -1221,6 +1306,8 @@
|
||||
|
||||
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
||||
"pinia/@vue/devtools-api": ["@vue/devtools-api@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1" } }, "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A=="],
|
||||
|
||||
"pkg-types/confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="],
|
||||
|
||||
"qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="],
|
||||
@@ -1231,7 +1318,7 @@
|
||||
|
||||
"vite-plugin-vue-inspector/@vue/babel-plugin-jsx": ["@vue/babel-plugin-jsx@1.5.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.2", "@vue/babel-helper-vue-transform-on": "1.5.0", "@vue/babel-plugin-resolve-type": "1.5.0", "@vue/shared": "^3.5.18" }, "peerDependencies": { "@babel/core": "^7.0.0-0" }, "optionalPeers": ["@babel/core"] }, "sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw=="],
|
||||
|
||||
"@antdv-next/icons/@ant-design/colors/@ant-design/fast-color": ["@ant-design/fast-color@2.0.6", "", { "dependencies": { "@babel/runtime": "^7.24.7" } }, "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA=="],
|
||||
"vue-router/@vue/devtools-api": ["@vue/devtools-api@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1" } }, "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
@@ -1281,6 +1368,10 @@
|
||||
|
||||
"@types/pg/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"antdv-next/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@14.4.0", "", {}, "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g=="],
|
||||
|
||||
"antdv-next/@vueuse/core/@vueuse/shared": ["@vueuse/shared@14.4.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
+86
-8
@@ -1,20 +1,98 @@
|
||||
# 一键部署:docker compose up -d --build
|
||||
# 验收:docker compose ps(migrate 应为 exited 0)
|
||||
# 环境变量写在 .env(不进产物包;生产务必改密钥与 COOKIE_SECURE)
|
||||
# 注意:DATABASE_URL 密码含 @ : / 等字符时需 URL 编码
|
||||
|
||||
services:
|
||||
postgres:
|
||||
container_name: naku-postgres
|
||||
image: postgres:17-alpine
|
||||
container_name: new-api-key-usage-pg
|
||||
entrypoint: ["/bin/sh", "/entrypoint-pg.sh"]
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: new_api_key_usage
|
||||
ports:
|
||||
- "5432:5432"
|
||||
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL in .env}
|
||||
volumes:
|
||||
- ./entrypoint-pg.sh:/entrypoint-pg.sh:ro
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d new_api_key_usage"]
|
||||
test: ["CMD-SHELL", "pg_isready -U $$(echo $$DATABASE_URL | sed -E 's#^[^:]+://([^:]+):.*#\\1#')"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
retries: 12
|
||||
restart: unless-stopped
|
||||
|
||||
migrate:
|
||||
container_name: naku-migrate
|
||||
build:
|
||||
context: .
|
||||
target: app
|
||||
entrypoint: ["/bin/sh", "/entrypoint-app.sh"]
|
||||
command: ["/app/naku-migrate"]
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
MIGRATIONS_FOLDER: /app/drizzle
|
||||
volumes:
|
||||
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
server:
|
||||
container_name: naku-server
|
||||
build:
|
||||
context: .
|
||||
target: app
|
||||
entrypoint: ["/bin/sh", "/entrypoint-app.sh"]
|
||||
command: ["/app/naku-server"]
|
||||
environment:
|
||||
PORT: "18456"
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
SK_KEY_ENCRYPTION_SECRET: ${SK_KEY_ENCRYPTION_SECRET:?set SK_KEY_ENCRYPTION_SECRET in .env}
|
||||
NEWAPI_BASE: ${NEWAPI_BASE:?set NEWAPI_BASE in .env}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-true}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
ADMIN_INIT_USERNAME: ${ADMIN_INIT_USERNAME:-}
|
||||
ADMIN_INIT_PASSWORD: ${ADMIN_INIT_PASSWORD:-}
|
||||
MIGRATIONS_FOLDER: /app/drizzle
|
||||
volumes:
|
||||
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
restart: unless-stopped
|
||||
|
||||
engine:
|
||||
container_name: naku-engine
|
||||
build:
|
||||
context: .
|
||||
target: app
|
||||
entrypoint: ["/bin/sh", "/entrypoint-app.sh"]
|
||||
command: ["/app/naku-engine"]
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
SK_KEY_ENCRYPTION_SECRET: ${SK_KEY_ENCRYPTION_SECRET}
|
||||
NEWAPI_BASE: ${NEWAPI_BASE}
|
||||
SYNC_INTERVAL_MS: ${SYNC_INTERVAL_MS:-300000}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
MIGRATIONS_FOLDER: /app/drizzle
|
||||
volumes:
|
||||
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
restart: unless-stopped
|
||||
|
||||
caddy:
|
||||
container_name: naku-caddy
|
||||
build:
|
||||
context: .
|
||||
target: proxy
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
- "${HTTPS_PORT:-443}:443"
|
||||
depends_on:
|
||||
- server
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 状态 | 定稿(2026-10-02) |
|
||||
| 关联 | 《交互设计-总览与共性》(§1 访问拓扑 · §3 呈现约定)·《交互设计-登录与会话》(鉴权档位与 401 分流)· 七篇表字段设计(DTO 字段权威)·《技术选型》§4(DTO 类型属 `packages/core`) |
|
||||
| 状态 | 定稿(2026-10-02;R16 修订 2026-10-10) |
|
||||
| 关联 | 《交互设计-总览与共性》(§1 访问拓扑 · §3 呈现约定)·《交互设计-登录与会话》(鉴权档位与 401 分流)· 各篇表字段设计(DTO 字段权威)·《技术选型》§4(DTO 类型属 `packages/core`) |
|
||||
| 产出 | 路由前缀与隔离机制 · 鉴权两档 · 错误信封 · 时间 / 数值 / 分页 / 命名约定 |
|
||||
|
||||
---
|
||||
@@ -15,7 +15,7 @@
|
||||
- JSON over HTTP,UTF-8;请求与响应体均为 `application/json`
|
||||
- 一套路由代码,两个世界靠**路径前缀与凭证中间件物理隔离**(共性 §1:令牌进不了管理面、会话进不了成员视图)
|
||||
- DTO 类型定义于 `packages/core`,web 以纯类型依赖引用——前后端一份契约类型(技术选型 §4 依赖规则)
|
||||
- **端点面向交互设计,不与数据表对齐**——每个端点对应一个功能面(页面 / 流程需要什么);表只是存储模型,按其事实性质获得恰好够用的操作面(引擎专写表纯只读、人工抄录表全 CRUD、账本实体软删除)
|
||||
- **端点面向交互设计,不与数据表对齐**——每个端点对应一个功能面(页面 / 流程需要什么);表只是存储模型,按其事实性质获得恰好够用的操作面(引擎专写表纯只读、人工抄录表全 CRUD、账本实体软删除为主——**空壳例外可硬删**:无 key 归属的成员行不承载账本事实,见 R16)
|
||||
|
||||
## 2. 路由前缀与隔离机制
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
- **会话 cookie**:`HttpOnly + Secure + SameSite=Lax`,`Path=/api/admin`(「路径限管理面」裁决的落地);随请求自动携带、登出即删行
|
||||
- **成员令牌**:`Authorization: Bearer <token>`;明文令牌只存在于登录壳输入与 sessionStorage(关标签即失效——《交互设计-登录与会话》§4)
|
||||
- 401 分流为前端职责:管理面任意 401 → 登录页「会话已过期」;成员视图任意 401 → 登录壳「令牌无效或已停用」——后端 401 响应不带面特化信息,前端按所在面分流(共性 §1)
|
||||
- **命名空间约定:`/api/*` 专属接口**——前端页面路由永不进入 `/api` 前缀;未知 `/api/*` 路径 = 接口 `404` 错误信封,非 `/api` 请求归前端托管(SPA history 回退「非 `/api` → index.html」,细则归骨架与部署设计)
|
||||
- **命名空间约定:`/api/*` 专属接口**——前端页面路由永不进入 `/api` 前缀;未知 `/api/*` 路径 = 接口 `404` 错误信封,非 `/api` 请求归前端托管(SPA history 回退「非 `/api` → index.html」,细则归骨架设计)
|
||||
|
||||
## 3. 错误信封
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
## 4. 时间口径
|
||||
|
||||
- **响应时刻字段:Unix 秒整数**(UTC 时刻,如 `1780000000`)——无时区歧义;本地/北京时间由展示层转换
|
||||
- **请求时间参数**(`from` / `to` 等):ISO 8601 UTC 字符串(便于试调);范围 **左闭右开 `[from, to)`**
|
||||
- **请求时间参数**(`from` / `to`、`paid_at` / `period_*` 等):**ISO 8601 带时区偏移**(如 `2026-09-08T00:00:00.000+08:00`,便于人读;亦接受同绝对时刻的 `…Z`);范围 **左闭右开 `[from, to)`**
|
||||
- 存储层维持 timestamptz / 上游 Unix 秒;DTO 输出统一 `toUnixSec`
|
||||
- 快捷项(最近 7 / 30 天、本订阅期间、自定义起止)是前端预置逻辑:前端算出 `from/to` 再请求,后端无快捷参数
|
||||
|
||||
@@ -77,12 +77,12 @@
|
||||
|
||||
## 7. DTO 命名
|
||||
|
||||
- **snake_case、与表字段同名同构**——七篇表字段设计即 DTO 字段权威,无映射层
|
||||
- **snake_case、与表字段同名同构**——各篇表字段设计即 DTO 字段权威,无映射层
|
||||
- 前端不做 camelCase 转换:全栈 snake_case(组件 props 是 antd 的 API、与数据 DTO 互不干扰)
|
||||
|
||||
## 8. 本稿拟定约定(交互设计未覆盖,请作者重点审)
|
||||
|
||||
1. 时间传输格式 ISO 8601 UTC 字符串(§4);
|
||||
1. 时间:**请求侧** ISO 8601 带偏移(§4);**响应侧** Unix 秒整数(§4);
|
||||
2. 时间范围左闭右开 `[from, to)`(§4);
|
||||
3. `quota_usd` 字符串两位小数 + 后端换算责任 + 微分取整规则(§5);
|
||||
4. DTO 与表字段 snake_case 同构、无映射层(§7);
|
||||
|
||||
+32
-20
@@ -1,11 +1,11 @@
|
||||
# API契约-成员视图
|
||||
|
||||
> 成员视图(令牌档)全部端点:令牌验证与页头、页面聚合、日志明细。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/member`、Bearer 鉴权、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构;管理面草案 §10 的裁定 1(聚合仅 `type = 2`)与裁定 2(`token_name` 取当前名)在此沿用。成员世界**无**审计、无健康带、无改密(定位边界,《交互设计-成员视图》§3)。
|
||||
> 成员视图(令牌档)全部端点:令牌验证与页头、总览/支付参数聚合、日志明细。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/member`、Bearer 鉴权、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构;管理面草案 §10 的裁定 1(聚合仅 `type = 2`)与裁定 2(`token_name` 取当前名)在此沿用。成员世界**无**审计、无健康带、无改密(定位边界,《交互设计-成员视图》§2)。
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 状态 | 定稿(2026-10-02) |
|
||||
| 关联 | 《API契约-公共约定》(定稿——总纲)·《API契约-管理面》(草案——裁定沿用)·《交互设计-成员视图》·《交互设计-登录与会话》§4(登录壳)· `members` / `usage_logs` / `payments` 表字段设计(DTO 字段权威) |
|
||||
| 状态 | 定稿(2026-10-12,作者认可实现) |
|
||||
| 关联 | 《API契约-公共约定》(定稿——总纲)·《API契约-管理面》·《交互设计-成员视图》·《交互设计-登录与会话》§4(登录壳)· `members` / `usage_logs` / `subscriptions` / `topups` 表字段设计(DTO 字段权威) |
|
||||
| 产出 | 端点清单(3 个)、请求 / 响应字段、裁定项(§5)、遗留项(§6) |
|
||||
|
||||
---
|
||||
@@ -15,7 +15,7 @@
|
||||
| # | 端点 | 方法 | 用途 |
|
||||
|---|------|------|------|
|
||||
| 1 | `/api/member/me` | GET | 令牌验证 + 页头(登录壳提交的落点) |
|
||||
| 2 | `/api/member/overview` | GET | 单页聚合:总用量、按来源分解、我的 key、支付参数卡 |
|
||||
| 2 | `/api/member/overview` | GET | 总览与支付参数聚合:总用量、按来源分解、我的 key、支付参数列表 |
|
||||
| 3 | `/api/member/logs` | GET | 日志明细(名下 key,分页) |
|
||||
|
||||
## 2. 令牌验证与页头
|
||||
@@ -27,7 +27,7 @@
|
||||
- 登录壳提交 = 前端将令牌存 sessionStorage 后调本端点(`Authorization: Bearer`):验证通过(哈希命中 + `status = active`)→ 进入成员视图;`401` → 登录壳(前端固定文案「令牌无效或已停用」,不区分停用与无效——公共约定 §2)
|
||||
- **不建会话**:令牌即长期凭证(`members` 表设计 3.2——无会话语义、无登出端点);旧令牌在作者重发后于下一请求自然 `401`
|
||||
|
||||
## 3. 单页聚合
|
||||
## 3. 总览与支付参数聚合
|
||||
|
||||
### GET `/api/member/overview?from&to` → 200
|
||||
|
||||
@@ -45,24 +45,35 @@
|
||||
{ "key_id": 5, "token_name": "小张-01", "log_synced_at": "2026-10-02T08:00:00Z",
|
||||
"usage": { "count": 300, "quota": 1500000, "quota_usd": "3.00" } }
|
||||
],
|
||||
"payments": [
|
||||
{ "id": 3, "kind": "subscription", "paid_at": "2026-09-01T00:00:00Z",
|
||||
"paid_amount": 170, "remark": "九月拼车",
|
||||
"period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z",
|
||||
"subscription_id": 3, "quota_granted": 12500000, "quota_usd": "25.00" }
|
||||
"subscriptions": [
|
||||
{ "id": 3, "subscription_id": 44, "period_start": "2026-09-01T00:00:00Z",
|
||||
"period_end": "2026-10-01T00:00:00Z", "quota_granted": 12500000, "quota_usd": "25.00",
|
||||
"paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z", "billing_domain": "carpool", "remark": "九月拼车",
|
||||
"created_at": "2026-09-02T12:00:00Z" }
|
||||
],
|
||||
"topups": [
|
||||
{ "id": 1, "upstream_id": 17, "amount": 1, "money": 2.04,
|
||||
"trade_no": "USR6…", "payment_method": "alipay", "payment_provider": "epay",
|
||||
"status": "success", "paid_at": "…", "billing_domain": "carpool",
|
||||
"quota_derived": 500000, "quota_usd": "1.00",
|
||||
"remark": "", "created_at": "…" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `usage`:该时段总用量(聚合卡);`by_source`:订阅 / 充值分解(分段条);`keys`:我的 key 窄卡(`log_synced_at` 支撑停滞标注)
|
||||
- `payments`:**支付参数卡 + 徽章展开行 + 「本订阅期间」快捷项的数据源**,判定规则见 §5 裁定 2;对成员**全字段可见**(含 `remark`——完全公开透明裁决);`payments` 全空 = 纯用量模式中性空态(前端事)
|
||||
- 徽章展开行仅限「订阅 #N」徽章:「充值」不展开(余额池扣款挂不到具体某笔,展开无答案——§5 裁定 4)、「未关联」(日志缺 `subscription_id`)不可展开;降级:徽章 `subscription_id` 不在 `payments` 列表 → 「该订阅无登记信息」——空值场景各自归位(《交互设计-成员视图》§1.6)
|
||||
- `usage`:该时段总用量(总览摘要条);`by_source`:订阅 / 充值分解(分段条);`keys`:我的 Key 表(`log_synced_at` 支撑停滞标注)
|
||||
- `subscriptions` / `topups`:**支付参数页 + 订阅徽章弹窗 + 「本订阅期间」快捷项**的数据源,判定见 §5 裁定 2;对成员**同账本域内全字段可见**(含 `remark`);两集皆空 = 纯用量模式中性空态
|
||||
- 订阅徽章弹窗仅限「订阅 #N」(查 `subscriptions`):「充值」不弹窗(余额池无逐笔)、「未关联」不可点;降级「该订阅无登记信息」
|
||||
|
||||
## 4. 日志明细
|
||||
|
||||
### GET `/api/member/logs?from&to` → 200 `{items, total}`
|
||||
### GET `/api/member/logs?from&to` → 200 `{items, total, summary, models}`
|
||||
|
||||
分页(公共约定 §6);过滤 `from/to`(必填);范围恒为名下 key、恒 `type = 2`(§5 裁定 1——成员世界是「我的用量」视角,错误行排查属管理面)。
|
||||
分页(公共约定 §6);过滤 `from/to`(必填);范围恒为名下 key;**默认全类型**(消费 + 错误,可 `type` 筛——2 / `error`)。
|
||||
|
||||
可选筛选(调用日志页,与管理面 `GET /api/admin/logs` 同名同义):`api_key_id` · `model_name` · `billing_source`(`subscription` / `wallet` / `none`)· `subscription_id` · `type`(2 / `error`)。
|
||||
|
||||
响应附带 `summary` / `models`(与管理面同构,见《API契约-管理面》§6):`summary` 为当前筛选下条数与 **type=2** 消费用量;`models` 为时间窗 + 主体筛选下去重的模型选项(不含 `model_name` / `type` 筛选)。
|
||||
|
||||
明细行 DTO 与管理面 `GET /api/admin/logs` **同构**(公共约定 §7——一份 DTO 类型两处复用):
|
||||
|
||||
@@ -73,18 +84,19 @@
|
||||
"quota": 250000, "quota_usd": "0.50",
|
||||
"prompt_tokens": 1200, "completion_tokens": 800,
|
||||
"use_time": 3, "is_stream": true,
|
||||
"billing_source": "subscription", "subscription_id": 3
|
||||
"billing_source": "subscription", "subscription_id": 3,
|
||||
"raw": { "id": 12345, "request_id": "…", "other": {}, "channel": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
`token_name` 取当前名(管理面裁定 2 沿用)。
|
||||
`token_name` 取当前名(管理面裁定 2 沿用)。`raw` 为**完整落库行**(上游字段原貌 + 系统字段,`created_at` 为 Unix 秒)——界面「原始数据」视图的数据源;管理面 `GET /api/admin/logs` 同构带出。
|
||||
|
||||
## 5. 本稿裁定项(请作者重点审)
|
||||
|
||||
1. **成员视图明细恒 `type = 2`**——对齐聚合口径;无 `type` 筛选参数(管理面明细的全类型排查能力不进成员世界);
|
||||
2. **「该时段涉及」支付记录的判定**:订阅 = 该时段日志(`type = 2`)出现过的 `subscription_id` 对应记录 **∪ 进行中的订阅**(`period_end` 为空或未来——支撑「本订阅期间」快捷项与「该订阅无登记信息」降级);充值 = `paid_at ∈ [from, to)`(充值消耗无逐笔关联可查,按支付时间锚定);
|
||||
1. **成员视图明细默认全类型**(消费 + 错误)——对齐「事实可查」;聚合仍仅 `type = 2`(用量口径不变);`type` 可筛 2 / `error`;
|
||||
2. **「该时段涉及」登记的判定**(账本域隔离):成员的**我的域** = 名下 key 的 `billing_domain` 非空并集。订阅 = 该时段日志(`type = 2`)出现过的 `subscription_id` 对应 `subscriptions` 行(**事实直连**,含未标域) **∪**(进行中 **∧** `billing_domain ∈ 我的域`)——未标域进行中订阅不放行;充值 = **`billing_domain ∈ 我的域`** ∧ `paid_at ∈ [from, to)`——未标域充值不放行。管理面不受此限;
|
||||
3. **明细行 DTO 与管理面同构**:同一 `LogItem` 类型,成员端经归属与 `type` 恒定过滤;
|
||||
4. **「充值」徽章不展开**——余额池扣款与具体充值记录无逐笔关联,展开回答不了「这笔花在哪」(《交互设计-成员视图》§1.6 未定义处的落定:徽章展开行仅限订阅徽章,「充值」为纯标识)。
|
||||
4. **「充值」徽章不弹窗**——余额池扣款与具体充值记录无逐笔关联,弹窗回答不了「这笔花在哪」(《交互设计-成员视图》§4.2:订阅徽章才开支付参数弹窗,「充值」为纯标识;行展开只放模型调用详情)。
|
||||
|
||||
## 6. 遗留项
|
||||
|
||||
|
||||
+100
-31
@@ -1,11 +1,11 @@
|
||||
# API契约-管理面
|
||||
|
||||
> 管理面(作者,会话档)全部端点:引导与会话、引擎状态、全员总览、key 管理、日志明细(下钻)、支付记录、成员管理、审计日志。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/admin`、会话 cookie 鉴权、错误信封、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构。
|
||||
> 管理面(作者,会话档)全部端点:引导与会话、引擎状态、全员总览、key 管理、日志明细(下钻)、订阅权益 / 充值入账、成员管理、审计日志。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/admin`、会话 cookie 鉴权、错误信封、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构。
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 状态 | 定稿(2026-10-02) |
|
||||
| 关联 | 《API契约-公共约定》(定稿——总纲)· 八篇交互设计(定稿)· `api_keys` / `usage_logs` / `payments` / `qpu_history` / `members` 表字段设计(DTO 字段权威)· `需求清单.md` §9(访问主体模型) |
|
||||
| 状态 | 定稿(2026-10-12,作者认可实现;含 10-09 支付拆表与 R16) |
|
||||
| 关联 | 《API契约-公共约定》(定稿——总纲)· 八篇交互设计 · `api_keys` / `usage_logs` / `subscriptions` / `topups` / `qpu_history` / `members` / `users` / `sessions` 表字段设计(DTO 字段权威)· `需求清单.md` §9(访问主体模型) |
|
||||
| 产出 | 端点清单、请求 / 响应字段、错误码、本稿裁定项(§10)与遗留项(§11) |
|
||||
|
||||
---
|
||||
@@ -23,13 +23,15 @@
|
||||
| 7 | `/api/admin/keys` | GET / POST | key 清单 / 登记 |
|
||||
| 8 | `/api/admin/keys/:id` | PATCH | 启停 / 归属变更 |
|
||||
| 9 | `/api/admin/logs` | GET | 日志明细(下钻,分页) |
|
||||
| 10 | `/api/admin/payments` | GET / POST | 支付记录清单 / 登记 |
|
||||
| 11 | `/api/admin/payments/:id` | PUT / DELETE | 编辑 / 删除 |
|
||||
| 12 | `/api/admin/qpu/latest` | GET | 当前换算率(表单美元双输入用) |
|
||||
| 13 | `/api/admin/members` | GET / POST | 成员清单 / 开户(令牌明文仅一次) |
|
||||
| 14 | `/api/admin/members/:id` | PATCH | 停用 / 启用 |
|
||||
| 15 | `/api/admin/members/:id/token` | POST | 令牌重发(明文仅一次) |
|
||||
| 16 | `/api/admin/audit-logs` | GET | 审计日志(分页) |
|
||||
| 10 | `/api/admin/subscriptions` | GET / POST | 订阅权益清单 / 登记 |
|
||||
| 11 | `/api/admin/subscriptions/:id` | PUT / DELETE | 编辑 / 删除 |
|
||||
| 12 | `/api/admin/topups` | GET / POST | 充值入账清单 / 登记 |
|
||||
| 13 | `/api/admin/topups/:id` | PUT / DELETE | 编辑 / 删除 |
|
||||
| 14 | `/api/admin/qpu/latest` | GET | 当前换算率(表单美元双输入用) |
|
||||
| 15 | `/api/admin/members` | GET / POST | 成员清单 / 开户(令牌明文仅一次) |
|
||||
| 16 | `/api/admin/members/:id` | PATCH / DELETE | 档案修改(name/remark)与启停 / 空壳删除(仅 `key_count = 0`) |
|
||||
| 17 | `/api/admin/members/:id/token` | POST | 令牌重发(明文仅一次) |
|
||||
| 18 | `/api/admin/audit-logs` | GET | 审计日志(分页) |
|
||||
|
||||
## 2. 引导与会话
|
||||
|
||||
@@ -117,12 +119,13 @@
|
||||
|
||||
### GET `/api/admin/keys` → 200 `[key]`
|
||||
|
||||
全量直返(基数 10–25,不分页——§10 裁定 3)。筛选参数:`member_id`(id 或 `unassigned`)、`status`、`recent_failed=true`(引擎最近轮次失败)。排序默认数据截止降序(前端事)。
|
||||
全量直返(基数 10–25,不分页——§10 裁定 3)。筛选参数:`member_id`(id 或 `unassigned`)、`status`、`billing_domain`(值或 `none` = 未标注)、`recent_failed=true`(引擎最近轮次失败)。排序默认数据截止降序(前端事)。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 5, "token_name": "小张-01", "status": "active",
|
||||
"member_id": 1, "member_name": "小张",
|
||||
"billing_domain": "carpool",
|
||||
"registered_at": "2026-09-20T10:00:00Z",
|
||||
"log_synced_at": "2026-10-02T08:00:00Z", "usage_synced_at": "2026-10-02T08:00:00Z",
|
||||
"total_used": 600000, "total_available": 940000, "total_granted": 1540000,
|
||||
@@ -134,7 +137,7 @@
|
||||
|
||||
### POST `/api/admin/keys` → 201 `key`
|
||||
|
||||
`{ "sk_key": "sk-…", "member_id": null }`——登记四态:
|
||||
`{ "sk_key": "sk-…", "member_id": null, "billing_domain": null }`——登记四态:
|
||||
|
||||
| 结果 | 响应 |
|
||||
|------|------|
|
||||
@@ -145,13 +148,34 @@
|
||||
|
||||
### PATCH `/api/admin/keys/:id` → 200 `key`
|
||||
|
||||
`{ "status": "disabled" }`(启用 / 停用)或 `{ "member_id": 3 }`(归属变更;`null` = 归还未归属)——部分更新,单字段或并存均可,审计按实际变更字段各落一行(§9 枚举)。非法值 `422`;key 不存在 `404`;`member_id` 引用不存在成员 `422`(`fields`)。
|
||||
`{ "status": "disabled" }`(启用 / 停用)· `{ "member_id": 3 }`(归属变更;`null` = 归还未归属)或 `{ "billing_domain": "office" }`(账本域变更;`null` = 未标域)——部分更新,单字段或并存均可,审计按实际变更字段各落一行(§9 枚举)。非法值 `422`;key 不存在 `404`;`member_id` 引用不存在成员 `422`(`fields`)。
|
||||
|
||||
## 6. 日志明细(管理面下钻)
|
||||
|
||||
### GET `/api/admin/logs?from&to` → 200 `{items, total}`
|
||||
### GET `/api/admin/logs?from&to` → 200 `{items, total, summary, models}`
|
||||
|
||||
分页(`page/page_size`,公共约定 §6)。过滤:`from/to`(必填)、`api_key_id`、`member_id`(经 `api_key_id → api_keys.member_id` 联查**当前归属**——行级归属语义)、`billing_source`、`subscription_id`、`type`。默认全类型(错误行排查入口——§10 裁定 1 的另一半)。
|
||||
分页(`page/page_size`,公共约定 §6)。过滤:`from/to`(必填)、`api_key_id`、`member_id`(经 `api_key_id → api_keys.member_id` 联查**当前归属**——行级归属语义)、`model_name`、`billing_source`、`subscription_id`、`type`。默认全类型(错误行排查入口——§10 裁定 1 的另一半)。
|
||||
|
||||
**响应附加块**(汇总条与模型下拉的数据源;成员端 `/api/member/logs` 同构):
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"hit_count": 131, "usage_count": 128, "error_count": 3,
|
||||
"quota": 1250000, "quota_usd": "2.50"
|
||||
},
|
||||
"models": ["claude-sonnet-4", "gpt-5"]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 口径 |
|
||||
|------|------|
|
||||
| `hit_count` | 当前筛选命中总数(= `total`;界面文案「请求次数」) |
|
||||
| `usage_count` / `error_count` | 同筛选下 `type=2` / `type≠2` 条数 |
|
||||
| `quota` / `quota_usd` | 同筛选下 **type=2** 行合计;美元按行 QPU 微分(公共约定 §5) |
|
||||
| `models` | 时间窗 + 主体/来源/订阅筛选下去重 `model_name`(**不含** `model_name` / `type`),升序,供筛选下拉 |
|
||||
|
||||
裁定:条数跟全部筛选(含 `type` / `model_name`);用量字段恒 `type = 2`(即使请求 `type=error`)——界面标签「消费用量」。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -166,30 +190,66 @@
|
||||
|
||||
`token_name` 取**当前名**(联查 `api_keys`,与「展示名以当前为准」对齐——§10 裁定 2);`username` / `channel` / `ip` / `token_id` / `request_id` / `other` 等其余表字段暂不出 API,界面需要时再扩。
|
||||
|
||||
> **界面落点待补**:总览「行点击 → Key 管理页该 key 详情」——交互设计无成篇的「key 详情」视图,本端点先行、界面归交互补篇或实现期定(§11 遗留 1)。
|
||||
> **界面落点**:《交互设计-调用日志》——管理面独立查询页;总览/Key/成员筛选深链 + 行内回跳。
|
||||
|
||||
## 7. 支付记录
|
||||
## 7. 订阅权益与充值入账
|
||||
|
||||
### GET `/api/admin/payments` → 200 `[payment]`
|
||||
> 原 `payments` 单表已拆(`payments建模评估.md` 方案 B):**订阅 = 权益**(期间 + 额度预算 + 可选付款事实),**充值 = 入账事件**。UI 菜单仍叫「支付记录」,API 按资源分组。
|
||||
|
||||
全量直返(年十位数,不分页),时间倒序(后端排序)。
|
||||
### GET `/api/admin/subscriptions` → 200 `[subscription]`
|
||||
|
||||
全量直返(年个位数,不分页),`period_start` 倒序。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 3, "kind": "subscription", "paid_at": "2026-09-01T00:00:00Z",
|
||||
"paid_amount": 170, "remark": "九月拼车",
|
||||
"id": 3, "subscription_id": 44,
|
||||
"period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z",
|
||||
"subscription_id": 3, "quota_granted": 12500000, "quota_usd": "25.00"
|
||||
"quota_granted": 12500000, "quota_usd": "25.00",
|
||||
"paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z",
|
||||
"billing_domain": "carpool",
|
||||
"remark": "九月拼车", "created_at": "2026-09-02T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
`quota_usd` 按支付时刻(`paid_at`)QPU 换算(§10 裁定 4);`kind = topup` 行 `period_*` / `subscription_id` 为 `null`。
|
||||
- `quota_usd` 换算锚点优先级:`paid_at` → 否则 `created_at`(§10 裁定 4 修订)
|
||||
- `paid_amount` / `paid_at` 可空(付款事实未知)
|
||||
|
||||
### POST `/api/admin/payments` → 201 `payment`
|
||||
### POST `/api/admin/subscriptions` → 201 `subscription`
|
||||
|
||||
`kind` 分形态必填校验(`422`):公共 `{kind, paid_at, paid_amount, remark?}`;`subscription` 追加必填 `period_start` / `subscription_id` / `quota_granted`(`period_end` 可空 = 进行中);`topup` 追加必填 `quota_granted`。表单美元双输入为前端换算(按 `qpu/latest` 预览),落库恒原始 quota(表设计 3.6)。
|
||||
必填:`subscription_id` · `period_start` · `quota_granted`;可空:`period_end`(=进行中)· `paid_amount` · `paid_at` · `billing_domain` · `remark`。美元双输入为前端换算,落库恒原始 quota。列表筛选支持 `billing_domain`(值或 `none`)。
|
||||
|
||||
### PUT `/api/admin/payments/:id` → 200 `payment` / DELETE → 204
|
||||
### PUT `/api/admin/subscriptions/:id` / DELETE → 204
|
||||
|
||||
### GET `/api/admin/topups` → 200 `[topup]`
|
||||
|
||||
全量直返,`paid_at` 倒序。对齐上游充值单;**事件时刻单列**(上游 `complete_time`),**无 `quota_granted` 入库列**:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1, "upstream_id": 17,
|
||||
"amount": 1, "money": 2.04,
|
||||
"trade_no": "USR6NOLqTPGa1791534153",
|
||||
"payment_method": "alipay", "payment_provider": "epay",
|
||||
"status": "success",
|
||||
"paid_at": "2026-10-09T12:22:56Z",
|
||||
"billing_domain": "carpool",
|
||||
"quota_derived": 500000, "quota_usd": "1.00",
|
||||
"remark": "", "created_at": "2026-10-09T13:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
`quota_derived` / `quota_usd` 为**派生**(`amount × qpu`,锚点 `paid_at`),不入库。
|
||||
|
||||
### POST `/api/admin/topups` → 201 `topup`
|
||||
|
||||
必填:`paid_at` · `amount` · `money`;可空:`upstream_id` · `trade_no` · `payment_method` · `payment_provider` · `status` · `billing_domain` · `remark`。列表筛选支持 `billing_domain`(值或 `none`)。
|
||||
|
||||
### PUT `/api/admin/topups/:id` / DELETE → 204
|
||||
|
||||
### 平台辅助(登录代拉,密码不入库)
|
||||
|
||||
已有:`POST /api/admin/platform/login` · `POST /api/admin/platform/subscriptions`。
|
||||
**新增**:`POST /api/admin/platform/topups` body `{ access_token, page, page_size }` → `{ items, total, page, page_size }`——透传 `/api/user/topup/self` **单页**;前端传统分页、可翻至尽头、无时间参数、无页数硬上限(见交互篇 §4.1)。不做后台定时同步。
|
||||
|
||||
编辑全字段、删除有确认(前端);改错即修,均落审计。
|
||||
|
||||
@@ -223,7 +283,16 @@
|
||||
|
||||
### PATCH `/api/admin/members/:id` → 200 `member`
|
||||
|
||||
`{ "status": "disabled" }` / `active`——停用即成员视图访问立即失效(令牌校验含状态判断)。
|
||||
部分更新,单字段或并存均可,审计按实际变更字段各落一行(§9):
|
||||
|
||||
- `{ "status": "disabled" }` / `active`——停用即成员视图访问立即失效(令牌校验含状态判断)
|
||||
- `{ "name": "小张" }` / `{ "remark": "同事" }`(R16 档案修改)——`name` 冲突 `409 member_name_taken`;不接受 `token_hash` / `created_at` / `id`
|
||||
|
||||
非法值 `422`;成员不存在 `404`。
|
||||
|
||||
### DELETE `/api/admin/members/:id` → 204
|
||||
|
||||
空壳删除(R16):**仅当该成员当前 `key_count = 0`**(无任何 `api_keys.member_id` 指向)才物理删除行;否则 `409 member_has_keys`(`fields` 可附 `key_count`)。删除后名称 UNIQUE 释放、令牌一并作废。名下有 key 的清理路径 = 停用(软删除语义不变)。均落审计。
|
||||
|
||||
### POST `/api/admin/members/:id/token` → 201
|
||||
|
||||
@@ -248,8 +317,8 @@
|
||||
| 域 | action 枚举 |
|
||||
|----|------------|
|
||||
| key | `key.register` / `key.enable` / `key.disable` / `key.reassign`(归属变更) |
|
||||
| payment | `payment.create` / `payment.update` / `payment.delete` |
|
||||
| member | `member.create` / `member.enable` / `member.disable` / `member.token_issue`(开户自动签发)/ `member.token_reissue` |
|
||||
| payment | `subscription.create` / `subscription.update` / `subscription.delete` / `topup.create` / `topup.update` / `topup.delete`(UI 菜单「支付记录」) |
|
||||
| member | `member.create` / `member.update`(档案改 name/remark)/ `member.enable` / `member.disable` / `member.token_issue`(开户自动签发)/ `member.token_reissue` / `member.delete`(空壳硬删) |
|
||||
| account | `account.password_change` / `account.init`(系统,operator null) |
|
||||
|
||||
每个管理面写操作各落一行;查询类与成员侧动作不留痕。**表结构归技术稿(members / api_keys 表设计既有标注),立稿时以本文 DTO 字段为对齐锚点**(§11 遗留 2)。
|
||||
@@ -258,8 +327,8 @@
|
||||
|
||||
1. **用量聚合口径仅 `type = 2`(消费行)**——usage_logs §5 待定「type 过滤策略」落定:聚合与统计只计消费行(错误行入聚合即失真);明细查询默认全类型、可按 `type` 筛(错误排查入口);
|
||||
2. **日志明细 `token_name` 取当前名**(联查 `api_keys`;快照名存库不出 API)——表设计 2.3 待定「展示口径」落定;
|
||||
3. **有限基数清单不分页**:`keys` / `members` / `payments` 直返全量数组;公共约定 §6 的「列表类分页」指无界列表(`logs` / `audit-logs`);
|
||||
4. **支付记录 `quota_usd` 按支付时刻(`paid_at`)QPU 换算**——对齐「按日志时刻取值」的历史正确性原则;
|
||||
3. **有限基数清单不分页**:`keys` / `members` / `subscriptions` / `topups` 直返全量数组;公共约定 §6 的「列表类分页」指无界列表(`logs` / `audit-logs`);
|
||||
4. **`quota_usd` 换算锚点**:充值恒 `paid_at`(= 上游 `complete_time`);订阅优先 `paid_at`、否则 `created_at`——对齐「按日志时刻取值」的历史正确性原则;
|
||||
5. **公共约定随本稿与审阅增补四处**:状态表加 `429`(登录节流)、错误信封允许端点附机器可读附注字段(`409 key_already_registered` 附 `key_id`)、`/api` 命名空间专属接口(页面路由永不进入)、端点面向交互设计不与数据表对齐。
|
||||
|
||||
## 11. 遗留项(归后续立稿或作者裁决)
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
| `key_ciphertext` | bytea | 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 | 调上游时解密原文(唯一用途) |
|
||||
| `status` | text | 管理面启停(引擎只读) | `active` / `disabled`;轮询清单 = active;disabled 数据保留、仅移出轮询 |
|
||||
| `member_id` | bigint FK NULL | 归属登记(管理通道) | → `members.id`;成员视图与按成员统计的锚点;空 = 未归属 |
|
||||
| `billing_domain` | text NULL | 人工标注(登记 / 行内编辑) | **账本域标签**(如 `carpool` / `office`)——区分用途的自由文本,非身份、非权限;空 = 未标域;成员可见性与支付记录的 `billing_domain` 对齐 |
|
||||
| `registered_at` | timestamptz | 登记时写入 | 空态文案「数据自该 key 登记起累积」 |
|
||||
| `log_synced_at` | timestamptz NULL | 日志同步成功时写入 | **「数据截止 HH:mm」的唯一来源**;同步失败的显形信号(停滞) |
|
||||
| `usage_synced_at` | timestamptz NULL | 额度采集成功时写入 | 额度信息的新鲜度(UI 若做)、排查 |
|
||||
@@ -55,7 +56,7 @@
|
||||
|
||||
- `key_hash`(HMAC-SHA256,不可逆):只服务「命中判定」——没有它,每次查询都要把全部 key 送去上游验证
|
||||
- `key_ciphertext`(AES-256-GCM,可解密):唯一用途是调上游
|
||||
- 密钥 `SK_KEY_ENCRYPTION_SECRET` 经 env 注入(HKDF 派生 hmac / aes 子密钥);**密钥丢失 = 全部 key 需重新登记**(记入部署文档)
|
||||
- 密钥 `SK_KEY_ENCRYPTION_SECRET` 经 env 注入(HKDF 派生 hmac / aes 子密钥);**密钥丢失 = 全部 key 需重新登记**
|
||||
|
||||
> **注解:为什么是两个字段(给非密码学背景的读者)**
|
||||
>
|
||||
@@ -97,7 +98,7 @@
|
||||
|
||||
### 3.7 表名:`api_keys`
|
||||
|
||||
- 理由:与页面 `/manage/api-keys` 同词对齐;`sk-` 只是 key 的实际格式前缀(不属表名职责),`api_keys` 为领域通用词,与其余各表(`usage_logs` / `payments` / `qpu_history`)风格一致
|
||||
- 理由:与页面 `/manage/api-keys` 同词对齐;`sk-` 只是 key 的实际格式前缀(不属表名职责),`api_keys` 为领域通用词,与其余各表(`usage_logs` / `subscriptions` / `topups` / `qpu_history`)风格一致
|
||||
|
||||
### 3.8 归属列 `member_id`:外键在本表,且可空
|
||||
|
||||
|
||||
+22
-11
@@ -4,8 +4,8 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 状态 | 定稿(2026-10-01) |
|
||||
| 关联 | `docs/design/api_keys表字段设计.md` 3.9(归属列指向本表)· `docs/design/需求清单.md` §9 |
|
||||
| 状态 | 定稿(2026-10-01;R16 修订 2026-10-10) |
|
||||
| 关联 | `docs/design/api_keys表字段设计.md` 3.9(归属列指向本表)· `docs/design/需求清单.md` §9(访问主体模型)· R16(档案可改、空壳删除) |
|
||||
| 范围 | 列结构 + 语义 + 写入时机;令牌生成细节归技术稿;方言映射归数据访问层设计 |
|
||||
| 字段数 | 7 = 1 主键 + 6 业务字段 |
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
|
||||
| 列 | 类型 | 值来源 | 说明 |
|
||||
|----|------|--------|------|
|
||||
| `id` | bigserial PK | 自增 | |
|
||||
| `name` | text UNIQUE | 管理面录入 | 显示名——账目与视图中的署名 |
|
||||
| `remark` | text | 管理面录入 | 备注(联系方式、是谁等;可空) |
|
||||
| `status` | text | 管理面 | `active` / `disabled`——停用即成员视图访问立即失效(令牌校验含状态判断) |
|
||||
| `token_hash` | text | 管理面生成令牌时 | 成员视图的访问凭证(哈希存储,明文只在生成时展示一次) |
|
||||
| `token_issued_at` | timestamptz | 同上 | 当前令牌的签发时间(审计线索) |
|
||||
| `created_at` | timestamptz | 开户时 | |
|
||||
| `id` | bigserial PK | 自增 | 不可改 |
|
||||
| `name` | text UNIQUE | 管理面录入(可改) | 显示名——账目与视图中的署名;开户后可改(R16) |
|
||||
| `remark` | text | 管理面录入(可改) | 备注(联系方式、是谁等;可空);开户后可改(R16) |
|
||||
| `status` | text | 管理面启停 | `active` / `disabled`——停用即成员视图访问立即失效(令牌校验含状态判断);**不并入档案编辑** |
|
||||
| `token_hash` | text | 管理面生成令牌时 | 成员视图的访问凭证(哈希存储,明文只在生成时展示一次);仅随重发更新 |
|
||||
| `token_issued_at` | timestamptz | 同上 | 当前令牌的签发时间(审计线索);仅随重发更新 |
|
||||
| `created_at` | timestamptz | 开户时 | 不可改 |
|
||||
|
||||
## 3. 决策记录(含理由)
|
||||
|
||||
@@ -45,17 +45,28 @@
|
||||
|
||||
账目署名不可歧义。
|
||||
|
||||
### 3.4 软删除(`status`)而非物理删除
|
||||
### 3.4 软删除(`status`)为主,空壳例外硬删(R16)
|
||||
|
||||
成员名下的 key 与历史用量是账本的一部分——「退拼」是停用(保留行、停访问),不是抹掉事实。
|
||||
成员名下的 key 与历史用量是账本的一部分——「退拼」是停用(保留行、停访问),不是抹掉事实。**默认无物理删除**。
|
||||
|
||||
**空壳例外(R16)**:当前 `key_count = 0`(无任何 `api_keys.member_id` 指向本行)时允许硬删——该行不承载账本事实,删了只清掉误建/未使用的开户壳,并释放 UNIQUE 名称。名下有 key 一律拒绝删除(只能停用)。硬删与软删除不冲突:账本实体的默认语义仍是软删除,空壳删是「无账本可保」时的清理通道。
|
||||
|
||||
### 3.5 档案字段可改(`name` / `remark`)(R16)
|
||||
|
||||
- **动机**:开户后挂 key 前发现名称不对(误建、占位名)——已发出令牌时「改名」优于「删了重建」(重建会作废旧令牌)
|
||||
- **可改字段收窄**:仅 `name` / `remark`;`status` / 令牌 / `id` / `created_at` 各走各的生命周期,不并入档案编辑
|
||||
- **`name` 仍 UNIQUE**(3.3 不变);冲突 `409 member_name_taken`
|
||||
- **展示口径 = 当前名**:日志 / 总览 / 成员视图联查实时取 `members.name`(与 `api_keys.token_name` 的「当前名为准」同则)——历史署名随改名漂移,可接受
|
||||
|
||||
## 4. 写入时机
|
||||
|
||||
| 时机 | 动作 |
|
||||
|------|------|
|
||||
| 开户(管理面) | INSERT(`name` / `remark`;生成令牌 → `token_hash` + `token_issued_at`) |
|
||||
| 档案修改(R16) | UPDATE `name` 和/或 `remark` |
|
||||
| 停用 / 启用 | UPDATE `status` |
|
||||
| 重发令牌 | UPDATE `token_hash` + `token_issued_at`(旧令牌即失效) |
|
||||
| 空壳删除(R16) | DELETE(仅 `key_count = 0`;名下有 key 拒绝) |
|
||||
|
||||
以上全部为管理面写操作,均落**审计日志**(审计为独立需求,表结构归技术稿)。
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# payments 建模评估:充值与订阅要不要拆表
|
||||
|
||||
> 对「一张 `payments` 同构装下订阅 + 充值」的全面复盘。触发点:`paid_at` 语义在订阅行上更像 `created_at`,按付费时间筛会在**后补录**时丢行。**结论:方案 B 拆表已采纳**(2026-10-09)。
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 状态 | 定稿(2026-10-09,作者采纳方案 B) |
|
||||
| 关联 | `subscriptions表字段设计.md` · `topups表字段设计.md`(替代原 `payments` 单表)· `NewAPI-接口范围.md` §2.⑨ §4.3 · `需求清单.md` R7/R8/R9 · `usage_logs表字段设计.md` 3.6 |
|
||||
| 依据 | 上游订阅实体字段(钱包页 / `GET /api/subscription/self`)· 日志 `billing_source` / `subscription_id` · 现行登记与筛选交互 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 领域里其实有两种东西
|
||||
|
||||
NewAPI 侧(接口参考 §4.3、日志 `other`):
|
||||
|
||||
| 上游概念 | 本质 | 时间轴 | 与消耗的关系 |
|
||||
|----------|------|--------|--------------|
|
||||
| **订阅**(`all_subscriptions[].id`) | **权益包**:`start_time`/`end_time` 覆盖期 + `amount_total`/`amount_used` 额度预算 + `created_at` | **期间** | 日志 `billing_source=subscription` + `subscription_id` 直连 |
|
||||
| **充值**(`LogTypeTopup=1`) | **入账事件**:钱 → 钱包额度;账号级、无 token_id | **事件时刻** | 日志 `billing_source=wallet`,**无**逐笔 `subscription_id`(余额池) |
|
||||
|
||||
注意:上游**没有**「订阅付款单」——订阅的 `created_at` 是权益创建时刻,不是「作者何时把钱付给组织者」。后者只存在于社交层,正是 `payments` 要手工抄录的「支付事实」。
|
||||
|
||||
因此登记行混合了两类事实:
|
||||
|
||||
1. **权益事实**(订阅:编号、期间、总额度)——来自钱包页,客观
|
||||
2. **金钱事实**(谁付了多少)——来自支付凭证/转账,社交层,**可与权益不同日**(预付、分期、事后补账)
|
||||
|
||||
## 2. 现行单表哪里拧着
|
||||
|
||||
`payments` 九列里约一半是「订阅专用可空」:
|
||||
|
||||
| 列 | 订阅 | 充值 | 问题 |
|
||||
|----|------|------|------|
|
||||
| `kind` | `subscription` | `topup` | 判别式列(合法但胖) |
|
||||
| `paid_at` | 混用:真实付款日 **或** 登记日 | 真实充值日 | **语义分叉**(本次痛点) |
|
||||
| `paid_amount` | 套餐价(可能与期间不同日) | 充值额 | 尚可 |
|
||||
| `period_start/end` | 必填 | NULL | 可空列 |
|
||||
| `subscription_id` | 必填 | NULL | 可空列 |
|
||||
| `quota_granted` | 套餐总额度 | 充值换得额度 | 语义都是「换得的量」,可共用 |
|
||||
| `remark` | ✓ | ✓ | |
|
||||
| (无) | **登记时刻** | **登记时刻** | 补录/审计缺失 |
|
||||
|
||||
连带后果:
|
||||
|
||||
- **筛选**:按 `paid_at` 筛「这段时间的账」→ 后补录的订阅落在补录日,对不上用量窗口;按期间筛又对不上充值(无期间)。
|
||||
- **QPU 换算锚点**:契约写「按 `paid_at` 换算 `quota_usd`」——若 `paid_at` 在订阅行上是登记日,换算率锚点也跟着漂。
|
||||
- **校验**:kind 分支必填(契约已做),但类型系统层仍是「全可空 + 运行时 if」。
|
||||
|
||||
## 3. 三个方案
|
||||
|
||||
### 方案 A:单表保留,时间语义拆开(小改)
|
||||
|
||||
```
|
||||
payments
|
||||
kind, paid_amount, remark, quota_granted ← 共用
|
||||
created_at timestamptz NOT NULL ← 新增:登记入库时刻
|
||||
paid_at timestamptz? NULLABLE ← 仅「钱出手」时刻;订阅可空
|
||||
period_start/end, subscription_id ← 仅订阅
|
||||
```
|
||||
|
||||
| 筛选口径 | 规则 |
|
||||
|----------|------|
|
||||
| 登记时间 | `created_at`(后补录可查) |
|
||||
| 充值 | `paid_at ∈ [from,to)` |
|
||||
| 订阅 | 期间重叠:`period_start < to AND (period_end IS NULL OR period_end > from)` |
|
||||
|
||||
- **利**:迁移小(加一列 + 语义清洗存量 `paid_at`);API/DTO 形状基本不动。
|
||||
- **弊**:仍是判别式胖行;「订阅的付款日」与「权益期间」继续挤在同一行心智里。
|
||||
|
||||
### 方案 B:拆两张表(对齐上游)
|
||||
|
||||
```
|
||||
subscriptions topups
|
||||
id PK id PK
|
||||
subscription_id UNIQUE upstream_id? ← 上游充值单 id
|
||||
period_start NOT NULL amount NOT NULL ← 平台计费金额(充值额)
|
||||
period_end NULL money NOT NULL ← 实付
|
||||
quota_granted NOT NULL trade_no / payment_* ← 渠道事实
|
||||
paid_amount NULL paid_at NOT NULL ← = 上游 complete_time(唯一事件时刻)
|
||||
paid_at NULL status
|
||||
remark remark
|
||||
created_at NOT NULL created_at NOT NULL
|
||||
```
|
||||
|
||||
(2026-10-09 实测修正:上游充值单**无 `quota_granted`**——`amount × qpu` 派生;事件时刻**只留一条** `paid_at`=`complete_time`,`create_time` 不入库。)
|
||||
|
||||
- **利**:字段全非空(或意义明确);时间轴各归各;与 NewAPI「权益 vs 入账」同构;筛选规则按表定义,无 if-else。
|
||||
- **弊**:登记 UI 两种表单(可仍一页两 Tab);「统一支付流水」要 UNION 视图;迁移把现表按 `kind` 拆走。
|
||||
|
||||
### 方案 C:三张表(金钱流水与权益彻底解耦)——**不建议**
|
||||
|
||||
`money_events` + `subscriptions` + 关联。年登记量个位数~十位数,过重。
|
||||
|
||||
## 4. 建议
|
||||
|
||||
**采用方案 B(拆表),展示层合并流水。**
|
||||
|
||||
理由摘要:
|
||||
|
||||
1. **领域本来就是两种东西**——上游也是。单表同构是登记方便,不是概念等价。
|
||||
2. **时间轴不同**是刚性的:权益用期间、充值用事件、补录用登记时刻;塞进一列必然语义污染。
|
||||
3. 规模极小(设计稿:年十行级)——拆表成本几乎只有代码路径,没有分片/联查负担。
|
||||
4. `paid_at` 问题在 B 里消失:`topups.paid_at` 恒为事件;`subscriptions.paid_at` 明确为「套餐付款日,可空」;`created_at` 两表都有。
|
||||
|
||||
**统一入口**(交互不变感):
|
||||
|
||||
- 管理面「支付记录」页 = 两 Tab(订阅 / 充值)或一张 UNION 列表(按时间轴分别标注「期间 / 付费 / 登记」)。
|
||||
- 成员「支付参数」页同源两集,R12 弹窗仍只挂订阅。
|
||||
- 「该时段涉及」:订阅看期间重叠 ∪ 进行中;充值看 `paid_at`;补录审计看 `created_at`。
|
||||
|
||||
**字段语义钉死(两表通用原则)**:
|
||||
|
||||
| 时刻 | 含义 | 谁写 |
|
||||
|------|------|------|
|
||||
| `created_at` | 作者按下「登记」的时刻 | 系统 |
|
||||
| `paid_at` | 钱真实出手时刻(凭证上的时间) | 人工;不知则空 |
|
||||
| `period_*` | 用量覆盖期 `[start, end)` | 人工(抄钱包页) |
|
||||
|
||||
**迁移要点**(若采纳):
|
||||
|
||||
1. 存量 `kind=subscription` 行 → `subscriptions`;`kind=topup` → `topups`。
|
||||
2. 存量订阅行的 `paid_at`:若当时填的是登记日 → 迁到 `created_at`,`paid_at` 置空(或作者按凭证补真付款日)。
|
||||
3. `quota_usd` 换算锚点:优先 `paid_at`(有则),否则 `created_at` 或 `period_start`——契约里写死优先级。
|
||||
4. 日志 `subscription_id` 直连改为指向 `subscriptions.subscription_id`(语义不变)。
|
||||
5. 纯用量模式(零登记)不受影响。
|
||||
|
||||
**方案 A 的适用情形**:只想先止血筛选/补录、暂不动 CRUD 与 API 形状——则 A 是可接受的过渡;终态仍建议 B。
|
||||
|
||||
## 5. 与既有裁决的关系
|
||||
|
||||
| 裁决 | 拆表后 |
|
||||
|------|--------|
|
||||
| R7 充值与订阅同构(登记形态) | **展示/哲学同构**(只存事实、无分摊)不变;**物理模型分表** |
|
||||
| R8 `subscription_id` 直连 | 不变,指向订阅表 |
|
||||
| R9 币种无关、只存事实 | 不变 |
|
||||
| 「payments」表名 | 订阅表可名 `subscriptions`;充值表 `topups`;若保留对外「支付记录」只作 UI 名 |
|
||||
|
||||
---
|
||||
|
||||
**已采纳方案 B(2026-10-09)**:表设计见 `subscriptions表字段设计.md` / `topups表字段设计.md`;契约与交互随拆表修订。
|
||||
@@ -1,98 +0,0 @@
|
||||
# payments 表字段设计
|
||||
|
||||
> 数据模型第三张表:支付记录——每笔支付事件(订阅购买 / 充值)一行。平台只存**事实**(支付金额、换得额度、来源参数),「支付金额与额度之间的比率」是查询时的派生视图,不存储、不做任何分摊计算。本文记录字段清单、语义与决策理由。
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 状态 | 定稿(2026-10-01) |
|
||||
| 关联 | `docs/design/usage_logs表字段设计.md` 3.6(`subscription_id` 直连与本表)· `docs/design/qpu_history表字段设计.md`(quota → 额度美元的换算时间线)· `docs/references/NewAPI-接口范围.md` §2.⑨(`other.subscription_*` 字段依据) |
|
||||
| 范围 | 列结构 + 语义 + 写入时机;方言映射归数据访问层设计 |
|
||||
| 字段数 | 9 = 1 主键 + 8 人工录入(支付 4 · 期间 2 · 订阅 2) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 表定位
|
||||
|
||||
- **一行 = 一笔支付事件**(订阅购买 / 充值)——同一模型兼容两种形态(`kind` 列区分)
|
||||
- **本表是「只有作者知道」的信息的唯一落点**:支付金额、额度、订阅 ID——上游没有免 auth 接口可查,全靠作者手工抄录
|
||||
- **币种无关**:`paid_amount` 是支付金额数值,**系统不预设币种**——币种是使用者之间的约定;「金额 ↔ 额度」的比率同样是派生视图(查询时相除即得)
|
||||
- **不做分摊计算**:平台不把支付金额按用量比例分摊到日志或成员——分账是社交层问题;本表与日志的交汇只发生在**事实关联**(`subscription_id` 直连)与**参数展示**(订阅信息对使用者完全公开透明)
|
||||
- 数据规模:年个位数到十位数行——无需索引设计
|
||||
|
||||
## 2. 字段清单
|
||||
|
||||
### 2.1 主键与支付字段(5 个)
|
||||
|
||||
| 列 | 类型 | 值来源 | 说明 |
|
||||
|----|------|--------|------|
|
||||
| `id` | bigserial PK | 自增 | |
|
||||
| `kind` | text | 人工选择 | `subscription`(订阅)/ `topup`(充值) |
|
||||
| `paid_at` | timestamptz | 人工填写 | 支付时间 |
|
||||
| `paid_amount` | numeric | **人工必填** | **支付金额**(币种无关——数值本身,币种由使用者约定) |
|
||||
| `remark` | text | 人工填写 | 备注 |
|
||||
|
||||
### 2.2 期间与订阅字段(4 个)
|
||||
|
||||
| 列 | 类型 | 值来源 | 说明 |
|
||||
|----|------|--------|------|
|
||||
| `period_start` | timestamptz? | **人工录入**(钱包页面) | 仅订阅:该笔覆盖的用量期间起点;左闭 |
|
||||
| `period_end` | timestamptz? | **人工录入**(钱包页面) | 期间终点;左开;未结束可空 |
|
||||
| `subscription_id` | bigint? | **人工录入**(钱包页面「订阅 #N」) | 上游订阅实例 ID;与日志 `subscription_id` 直连(事实关联,不推算) |
|
||||
| `quota_granted` | bigint? | **人工录入**(钱包页面「总额度」/ 充值记录) | 本笔换得的额度——**单位钉死为原始 quota 整数**(如 $25 额度 = 12,500,000;见 3.6) |
|
||||
|
||||
## 3. 决策记录(含理由)
|
||||
|
||||
### 3.1 登记 = 手工抄录(来源只有两处)
|
||||
|
||||
支付记录全部由作者手工录入,来源仅两处:**支付凭证**(`kind` / `paid_at` / `paid_amount`)与**钱包页面**(订阅 ID / 期间 / 总额度)。订阅关键信息只有作者可见(没有免 auth 接口可查)——系统不做回填与推断,日志观测仅用于核对提示(见 §4);必填 / 可空的校验细节归技术稿。
|
||||
|
||||
### 3.2 只存事实,不存派生数
|
||||
|
||||
- `paid_amount` 与 `quota_granted` 是不可替代的事实;「金额 ↔ 额度」的比率是派生数——查询时相除即得,事后核对永远可用;只存比率会丢失核对依据
|
||||
- **日志同样不加任何金额 / 比率列**(`usage_logs` 零分摊字段):把支付金额按用量分摊到日志行的任何计算都不做——分摊口径是社交层问题,账本上只有客观事实
|
||||
- 关联维度(`billing_source` / `subscription_id`)已在日志侧提为独立列(见 `usage_logs表字段设计.md` 3.6),统计按事实关联
|
||||
|
||||
### 3.3 为什么额度也手工录入(不采用日志感知)
|
||||
|
||||
日志逐条自带 `subscription_total`(实测),但回填它需要「订阅 ID → 该 ID 日志 total」的关联逻辑;钱包页面已直接给出总额度——手工抄录一步到位。**系统对支付记录零回填、零推断**,观测仅用于核对(见 §4)。
|
||||
|
||||
### 3.4 币种无关(不预设人民币)
|
||||
|
||||
`paid_amount` 是纯数值。作者记账用什么货币是他的事,展示层的货币符号是界面约定——数据层与币种彻底解耦,更换支付渠道 / 币种时零 schema 变化。
|
||||
|
||||
### 3.5 命名与类型
|
||||
|
||||
- 表名 `payments`(支付记录):订阅与充值同构,`purchases`(购买)只覆盖前者,`payments` 是两者的上位词
|
||||
- 金额列 `numeric`(金额不用浮点);时间列统一 `timestamptz`
|
||||
- 期间左闭右开(`[period_start, period_end)`)——与全项目期间惯例一致
|
||||
- 订阅实例 ID 与日志同名 `subscription_id`——同一体系、同一含义(直连即用此列)
|
||||
|
||||
### 3.6 `quota_granted` 的单位:原始 quota,不是美元
|
||||
|
||||
- **存真值,不存换算快照**:上游订阅套餐的额度本体是 quota 整数,「$25」只是界面显示形态(quota ÷ `quota_per_unit`,随 `quota_display_type` 而变)——本列存本体
|
||||
- **域一致性**:日志的 `quota` 同为原始整数——`SUM(usage_logs.quota)` 与 `quota_granted` 同域直接可比,零换算;若存美元则每次比较都要过 ÷QPU,而 QPU 是时间线变量(`qpu_history`),凭空引入取值时刻问题
|
||||
- **类型精确**:quota 恒为整数;美元额度可能有小数,`bigint` 装不下
|
||||
- **录入体验归交互层**:钱包页面显示美元形态——管理面表单支持输入美元、按当时 QPU 换算落库(或展示两态),换算逻辑不进数据模型
|
||||
- 美元形态随时可派生:`quota_granted ÷ QPU`(展示层)
|
||||
- **与 qpu_history 无概念重复**:quota 是**量**,QPU 是**换算率**——记录量不等于记录单位定义(记「¥170」不涵盖「1 美元 = 6.8 人民币」)。真值域(quota)不携带任何换算,换算率只在一处(`qpu_history` 时间线),美元永远现算现用;反过来若直接存美元,每个值都是某次换算的快照、各自隐含一个换算时刻——那才是真正的冗余,且上游改 QPU 后与日志统计失去同域可比性
|
||||
|
||||
## 4. 写入时机与维护
|
||||
|
||||
| 时机 | 动作 |
|
||||
|------|------|
|
||||
| 人工登记(管理通道) | INSERT:录入支付信息(时间 / 金额)与订阅信息(ID / 期间 / 总额度,抄自钱包页面)→ 确认 |
|
||||
| 系统观测(仅核对) | 日志观测到的订阅 ID / `subscription_total` 仅作核对提示(如「观测到 #N 未对应任何支付记录」——即「未关联」分组的健康信号),不参与回填 |
|
||||
| 编辑 / 删除 | 数据为人工登记,改错即修 |
|
||||
|
||||
## 5. 展示层边界(界面设计的输入)
|
||||
|
||||
- **订阅与支付参数对使用者完全公开透明**(访问主体模型裁决):期间、额度、支付金额、订阅 ID——成员视图与管理面同源可见;不存在「管理面独占的金额信息」
|
||||
- 「按订阅维度统计」(某笔订阅的消耗占比等)数据天然支持——按 `subscription_id` 直连聚合即可
|
||||
- 成员视角的日志行携带所属订阅/充值信息的具体形态,交互设计期定(见 `docs/design/需求清单.md` R12)
|
||||
|
||||
## 6. 待定事项(关联后续环节)
|
||||
|
||||
| 事项 | 归属 |
|
||||
|------|------|
|
||||
| 核对提示(观测到的订阅 ID ↔ 已录入记录比对)的交互 | 交互设计(待立) |
|
||||
| 充值(`topup`)的 `quota_granted` 抄录来源(充值记录页)核实 | 实现期真实数据核查 |
|
||||
@@ -0,0 +1,65 @@
|
||||
# subscriptions 表字段设计
|
||||
|
||||
> 数据模型:**订阅权益登记**——一行 = 一笔上游订阅实例(覆盖期 + 额度预算 + 可选的套餐付款事实)。与 `topups`(充值入账事件)分表,对齐 NewAPI「权益 vs 入账」两分(见 `payments建模评估.md`,方案 B 已采纳)。平台只存**事实**,不做分摊计算。
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 状态 | 定稿(2026-10-12,作者认可实现) |
|
||||
| 关联 | `topups表字段设计.md`(充值事件)· `payments建模评估.md`(拆表裁决)· `usage_logs表字段设计.md` 3.6(`subscription_id` 直连)· `qpu_history表字段设计.md` · `docs/references/NewAPI-接口范围.md` §4.3 |
|
||||
| 范围 | 列结构 + 语义 + 写入时机;方言映射归数据访问层 |
|
||||
| 字段数 | 9 = 1 主键 + 1 上游编号 + 2 期间 + 1 额度 + 2 付款(可空) + 1 备注 + 1 登记时刻 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 表定位
|
||||
|
||||
- **一行 = 一笔上游订阅实例**(权益),不是「一张付款单」
|
||||
- 权益事实(`subscription_id` / 期间 / 总额度)抄自钱包页(`GET /api/subscription/self`);金钱事实(付了多少、何时付)来自支付凭证,**可空**——分期、赠送、未掌握凭证时允许不填
|
||||
- `subscription_id` 与日志 `other.subscription_id` **同体系直连**(事实关联,不推算)
|
||||
- 数据规模:年个位数行——无需独立索引设计(`subscription_id` UNIQUE 足够)
|
||||
|
||||
## 2. 字段清单
|
||||
|
||||
| 列 | 类型 | 值来源 | 说明 |
|
||||
|----|------|--------|------|
|
||||
| `id` | bigserial PK | 自增 | 本地主键 |
|
||||
| `subscription_id` | bigint UNIQUE | 人工录入(钱包页「订阅 #N」) | 上游订阅实例 ID;日志直连键 |
|
||||
| `period_start` | timestamptz | 人工录入 | 用量覆盖期起点;左闭 |
|
||||
| `period_end` | timestamptz? | 人工录入 | 期间终点;左开;未结束可空 = 进行中 |
|
||||
| `quota_granted` | bigint | 人工录入(钱包页 `amount_total`) | 套餐总额度——**原始 quota 整数**(与日志 `quota` 同域;充值侧无此列,额度由 `amount × qpu` 派生) |
|
||||
| `paid_amount` | numeric? | 人工录入(支付凭证) | 该套餐实付金额;币种无关纯数值;未知可空 |
|
||||
| `paid_at` | timestamptz? | 人工录入(支付凭证) | **钱出手时刻**(非登记时刻);未知可空 |
|
||||
| `billing_domain` | text NULL | 人工标注(登记 / 编辑) | **账本域标签**(如 `carpool` / `office`);空 = 未标域,成员不可见(涉及该订阅 `subscription_id` 的日志事实直连除外) |
|
||||
| `remark` | text | 人工填写 | 备注 |
|
||||
| `created_at` | timestamptz | 系统 | **登记入库时刻**(补录审计) |
|
||||
|
||||
## 3. 时刻语义(三时刻钉死)
|
||||
|
||||
| 时刻 | 含义 | 谁写 |
|
||||
|------|------|------|
|
||||
| `created_at` | 作者按下「登记」 | 系统 |
|
||||
| `paid_at` | 钱真实出手(凭证时间) | 人工;不知则空 |
|
||||
| `period_start` / `period_end` | 用量覆盖期 | 人工(抄钱包页) |
|
||||
|
||||
筛选约定:**用量窗口看期间重叠**;**补录审计看 `created_at`**;**付款账看 `paid_at`(有则)**。
|
||||
|
||||
## 4. 决策摘要
|
||||
|
||||
- **不与充值同表**:时间轴不同(期间 vs 事件);见 `payments建模评估.md` 方案 B
|
||||
- **只存事实、不存比率**:`paid_amount` 与 `quota_granted` 相除得比率;不落分摊
|
||||
- **额度存原始 quota**(原 payments 3.6):与日志同域可比;美元展示现算
|
||||
- **`paid_amount` / `paid_at` 可空**:社交层付款信息允许缺失;不阻塞权益登记
|
||||
- **多订阅期间可重叠**(R8):不设互斥约束
|
||||
|
||||
## 5. 写入与维护
|
||||
|
||||
| 时机 | 动作 |
|
||||
|------|------|
|
||||
| 人工登记 | INSERT(管理通道);来源:钱包页 JSON 预填 + **辅助登录选用**(按 `subscription_id` 去重,已登记灰显)+ 支付凭证 |
|
||||
| 编辑 / 删除 | 改错即修(人工数据) |
|
||||
| 日志观测 | 仅核对提示(观测到 #N 无登记),不回填 |
|
||||
|
||||
## 6. 对外边界
|
||||
|
||||
- 参数对成员**同账本域内可见**(含 `remark` / 金额)——同域透明、跨域隔离;未标域默认对成员隐藏,例外:该订阅 `subscription_id` 出现在本人名下 key 的 `type = 2` 日志中(事实直连)
|
||||
- 无「应付」、无分摊字段
|
||||
@@ -0,0 +1,78 @@
|
||||
# topups 表字段设计
|
||||
|
||||
> 数据模型:**充值入账事件**——一行 = 一次「钱 → 账号额度」的入账。与 `subscriptions`(订阅权益)分表(见 `payments建模评估.md`,方案 B)。字段对齐上游实测 `GET /api/user/topup/self`(2026-10-09 样本);**无 `quota_granted` 列**(派生),**事件时刻只存一条**(上游 `complete_time`,即平台列表展示的时间)。
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 状态 | 定稿(2026-10-12,作者认可实现) |
|
||||
| 关联 | `subscriptions表字段设计.md` · `payments建模评估.md` · `NewAPI-接口范围.md` §4.5 · `qpu_history表字段设计.md` |
|
||||
| 范围 | 列结构 + 语义 + 写入时机 |
|
||||
| 字段数 | 10 = 1 主键 + 8 上游/业务事实 + 1 登记时刻 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 表定位
|
||||
|
||||
- **一行 = 一次充值入账**(事件);时间主轴 = **`paid_at`**(上游 `complete_time`,平台展示时刻)
|
||||
- 与消耗的关系:钱包/账号余额池,**无法**逐笔对应模型日志(`billing_source=wallet` 无 `subscription_id` 是正常态)
|
||||
- 不存 `quota_granted` 真值列(上游没有;见 §3)
|
||||
|
||||
## 2. 字段清单
|
||||
|
||||
| 列 | 类型 | 上游字段 | 说明 |
|
||||
|----|------|----------|------|
|
||||
| `id` | bigserial PK | — | 本地主键 |
|
||||
| `upstream_id` | bigint? | `id` | 上游充值单 ID(对账;手工补录可空) |
|
||||
| `amount` | numeric | `amount` | **平台计费金额**(充值记账额,样本 `1`)——与实付 `money` 之间由**管理员约定汇率**换算;**不标币种/单位** |
|
||||
| `money` | numeric | `money` | **实付金额**(样本 `2.04`);同样不预设币种 |
|
||||
| `trade_no` | text? | `trade_no` | 交易号 |
|
||||
| `payment_method` | text? | `payment_method` | 如 `alipay` |
|
||||
| `payment_provider` | text? | `payment_provider` | 如 `epay` |
|
||||
| `status` | text? | `status` | 如 `success`;失败单一般不入账本 |
|
||||
| `paid_at` | timestamptz | **`complete_time`** | **入账时刻(唯一事件时间)**——平台列表展示的就是它;上游 `create_time` 不入库 |
|
||||
| `billing_domain` | text NULL | 人工标注(登记 / 编辑) | **账本域标签**(如 `carpool` / `office`);空 = 未标域,成员不可见(充值无逐笔日志连线,无法用日志证明「与我相关」) |
|
||||
| `remark` | text | — | 人工备注 |
|
||||
| `created_at` | timestamptz | — | 本系统登记时刻(补录审计) |
|
||||
|
||||
> 样本(2026-10-09):`amount: 1` · `money: 2.04` · `trade_no: "USR6NOLqTPGa1791534153"` · `alipay`/`epay` · `create_time`/`complete_time` Unix 秒 · `status: "success"`。展示与筛选一律用 `complete_time` 落入的 `paid_at`。
|
||||
|
||||
## 3. 「换得额度」是派生
|
||||
|
||||
上游无 `quota_granted`:派生 `amount × quota_per_unit`(默认 QPU 500000)——`amount` 是平台计费侧充值额,与 quota 同域量纲。
|
||||
|
||||
- **只存事实**:`amount`(平台计费)与 `money`(实付)两真值;**汇率不入库**(管理员社交约定,查询/展示可现算 `money/amount`)
|
||||
- 不在库中固化派生额度
|
||||
|
||||
## 4. 时刻语义
|
||||
|
||||
| 时刻 | 含义 |
|
||||
|------|------|
|
||||
| `paid_at` | 入账(= 上游 `complete_time`)——筛选、排序、QPU 锚点 |
|
||||
| `created_at` | 登记进本表(补录审计) |
|
||||
|
||||
## 5. 写入与维护
|
||||
|
||||
| 时机 | 动作 |
|
||||
|------|------|
|
||||
| **辅助登录选用**(与订阅选择器同模式) | 控制台登录(密码不落库)→ 分页拉 `/api/user/topup/self` → 选用预填表单 → 确认入库 |
|
||||
| 纯手工登记 | 抄充值页 / 账单;或粘贴 JSON 预填 |
|
||||
| 编辑 / 删除 | 改错即修 |
|
||||
| 后台定时同步 | **不做**(辅助登录仅代拉一次、token 会话内用) |
|
||||
|
||||
**分页请求策略**(接口仅 `p` / `page_size`,**无时间过滤**):
|
||||
|
||||
1. **传统分页 UI**:弹层底部「共 N 条 · 页码 / 上一页 / 下一页」;每次请求只取当前页(`page_size=10`)
|
||||
2. **不设硬上限**——用户可一直翻到 `total` 尽头(找旧账必须靠翻页)
|
||||
3. **不提供时间范围筛选**(上游收窄不了结果集;本地筛只会误导读不出的更早页)
|
||||
4. **已登记去重**:按 `upstream_id` 灰显,不可重复选用
|
||||
5. **401**:清会话 token → 重新登录;token 仅 sessionStorage
|
||||
|
||||
## 6. 对外边界
|
||||
|
||||
- 成员可见:`amount`、`money`、渠道、`paid_at`、备注、`billing_domain`;**仅本人名下 key 所属账本域内**;无逐笔与模型日志的连线;未标域默认不可见
|
||||
- 纯用量模式可不登记
|
||||
|
||||
## 7. 迁移说明
|
||||
|
||||
- 原 `payments.kind=topup`:`paid_at` 语义本就落在入账日 → 直接迁入本表 `paid_at`
|
||||
- 若曾手填 `quota_granted`:可反推 `amount = quota_granted / qpu` 填 `amount`,否则按实付与约定补记
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user