Compare commits

...
10 Commits
30 changed files with 2169 additions and 46 deletions
@@ -0,0 +1,343 @@
---
name: antdv-next-cli-create-pr
description: Create pull requests for antdv-next/cli using the repository's PR conventions. Use this skill when the user asks to create/open a PR, draft PR title/body, summarize branch changes for a PR, or otherwise prepare PR content. Judge by intent rather than fixed phrases; short colloquial requests still count if they are about creating a PR rather than discussing PR concepts.
---
# Antdv Next CLI PR 创建规范
## 目标
一、基于当前分支相对基线分支的全部改动生成 PR,不只看最后一个 commit。
二、优先使用仓库自带 PR 模板;当前没有模板时,使用本 skill 约定的 `Summary` / `Testing` 结构。
三、PR 正文默认使用英文,用户明确要求中文时可使用中文;PR 标题始终使用英文,并遵循本文档约定的命名格式。
四、真正执行 `gh pr create` 之前,必须先把 `base`、`title`、`body` 给用户确认,确认后才能创建 PR。
## 基本规则
### 一、按意图触发,不按短语触发
只要能判断用户是在请求创建 PR,或为创建 PR 做准备,就应使用本 skill。
不要把触发限制成固定说法。即使用户表达很短、很口语,或要求不完整,只要不是在单纯讨论 PR 概念,也应进入本 skill 的工作流。
### 二、正文结构以仓库约定为准
当前仓库没有 `.github/PULL_REQUEST_TEMPLATE*`。默认使用:
- `## Summary`
- `## Testing`
如果仓库之后新增 PR 模板,必须先读取并使用模板,不要自行改 section 名称或删除主结构。
### 三、正文默认英文,但标题固定英文
现有 PR 正文以英文为主,因此默认使用英文正文。用户明确要求中文时可以使用中文正文。
无论正文使用哪种语言:
- `PR title` 都必须是英文
- `PR title` 必须符合 Conventional Commits 格式
- 标题 subject 必须以小写字符开头,以通过 `.github/workflows/validate-pr-title.yml`
### 四、先分析分支,再写 PR
创建 PR 前,必须先看:
- 当前分支名
- 基线分支
- 当前分支相对基线分支的 commit 列表
- `base...HEAD` 的完整 diff
不要只根据工作区未提交内容写 PR,也不要只根据最近一个 commit 写 PR。
### 五、先给草稿,后创建 PR
无论用户是否说“直接帮我创建 PR”,都要先完成以下步骤:
1. 生成 `base`、`title`、`body` 草稿
2. 明确告诉用户:这是准备提交的 PR 内容
3. 让用户确认是否继续创建,或先修改
4. 只有用户明确确认后,才能真正执行 `gh pr create`
若用户中途要求修改标题、类型、测试说明、目标分支等,应先更新草稿,再次确认。
### 六、标题和正文要分工明确
- PR 标题:用英文一句话概括本分支最主要的变动
- PR 正文:说明主要结果、关键方案、关联 issue 和实际验证结果
正文不是逐文件流水账。要归纳“为什么改”和“改完后对开发者/用户有什么影响”。
### 七、信息不足时不要硬写
若以下内容缺失且无法从分支改动中可靠推断:
- 基线分支
- 关联 issue
- 变动性质
- 测试或验证方式
可以先给出草稿,并把无法确认的地方保留为待补充项;若用户要求直接创建 PR,也必须先说明缺失项并等待确认。
## 执行步骤
### 1. 检查仓库和 PR 环境
建议先确认:
```bash
git status --short
git branch --show-current
git remote -v
gh auth status
```
若 `gh` 不可用、未登录、当前不在 git 仓库、或当前分支不适合提 PR,应先说明问题,不要继续伪造结果。
### 2. 确定基线分支
不要未经分析就直接使用 `main`。按以下顺序判断:
1. 用户明确指定了 `base branch` -> 直接使用
2. 若当前分支存在可用的“来源线索”,优先根据真实 Git 信息推断:
- `git branch -vv` 查看 tracking / upstream
- `git reflog show <current-branch>` 查看是否能看出“从哪条分支 checkout 出来”
- 必要时结合 `git merge-base HEAD <candidate-branch>` 比较分叉点
3. 若能较可靠判断“当前分支是从某条分支切出来的”,优先使用该分支作为 `base`
4. 若无法可靠推断,再退回远端默认分支或仓库默认分支
建议查看:
```bash
git branch --show-current
git branch -vv
git reflog show --date=local $(git branch --show-current)
git remote show origin
```
注意:
- tracking / upstream 只能作为线索,不等于绝对正确的“父分支”
- `reflog` 若已清理,可能无法得到结果
- 若推断结果不够确定,要在草稿中明确标注为“推断值”
#### 新功能分支说明
本仓库的新功能 PR 通常也提交到 `main`。只有用户明确要求 stacked PR,或 Git 历史能可靠证明当前分支基于另一功能分支时,才使用非 `main` 的 base;不要仅因类型为 `feat` 擅自改 base。
### 3. 收集本分支全部改动
至少查看:
```bash
git log --oneline <base>..HEAD
git diff --stat <base>...HEAD
git diff <base>...HEAD
```
必要时再看:
```bash
git diff --name-only <base>...HEAD
```
归纳时要覆盖该分支会进入 PR 的全部提交,而不是只写最后一次改动。
### 4. 确定正文结构和语言
先检查是否存在 `.github/PULL_REQUEST_TEMPLATE*`:
- 存在:读取并使用仓库模板
- 不存在:使用 `Summary` / `Testing` 结构
正文默认使用英文;用户明确要求中文时再使用中文。
### 5. 判断 PR 类型
必须根据整条分支的“主结果”判断,不要被单个文件或 commit 干扰。
优先判断:
1. 新增 CLI、Agent、MCP 或集成方可用能力 -> `feat`
2. 修复错误行为 -> `fix`
3. 只修改文档或 skill -> `docs`
4. 主要修改构建、打包配置 -> `build`
5. 主要修改 workflow / CI -> `ci`
6. 主要修改测试 -> `test`
7. 无外部行为变化的代码整理 -> `refactor`
8. 性能优化 -> `perf`
9. 依赖或内部维护 -> `chore`
10. 版本发布 -> `release`
例如:
- 新增 MCP tool -> `feat(mcp)`
- 修复 changelog 版本解析 -> `fix(changelog)`
- 更新 agent skill 说明 -> `docs(skills)`
- 调整 tsdown 打包配置 -> `build(config)`
- 修改 PR 校验 workflow -> `ci`
### 6. 归纳 PR 的核心信息
至少整理出:
- 主要结果:本分支解决什么问题或新增什么能力
- Related Issues:有可靠关联时填写 issue 链接或 `Closes #xxxx` / `Fixes #xxxx`
- Summary:归纳用户影响、关键方案和重要约束
- Testing:只列出本次实际执行并通过的命令
- 兼容性:说明是否有 CLI 参数、输出格式、配置、MCP API 或打包产物变化
### 7. 处理 Testing
根据改动范围选择验证:
- 所有 PR:`pnpm lint`
- 修改 `src/**` 或 `test/**`:`pnpm build` 和 `pnpm test`
- 涉及 CLI 执行、MCP、setup、打包或跨模块集成:考虑 `pnpm test:all`
- 通用补充检查:`git diff --check`
只写实际执行且通过的命令。未执行时写 `Not run (reason)`;失败时如实说明失败命令,不要写成通过。
### 8. 生成 PR 标题
标题要求:
- 按下方“写法要求 -> 标题”生成
- 覆盖整条分支的主要目标
- 不要照搬单个 commit message
- `type` 要与第 5 步判断一致
### 9. 按约定产出 PR 正文草稿
没有仓库模板时使用:
```markdown
## Summary
- <主要结果或用户影响>
- <关键方案或重要约束>
## Testing
- `<实际通过的命令>`
```
填写时遵守:
- Summary 通常使用 2 至 4 个要点
- Testing 只列实际结果
- 有可靠关联 issue 时加入 `Closes #xxxx`、`Fixes #xxxx` 或链接
- 涉及命令输出或交互变化时,提醒补充必要的终端输出、截图或示例
- 有破坏性变化、迁移要求或公开行为变化时明确写出
- 信息尚未确认时显式标注,不要假装确定
### 10. 先给用户确认
输出时至少包含:
- `Base branch`
- `PR title`
- `PR body`
- 实际执行的测试及结果
- 需要用户补充或确认的点
明确询问用户是否:
- 直接创建 PR
- 先修改后再创建
没有明确确认前,不得执行 `gh pr create`。
### 11. 创建 PR
只有在用户明确确认后,才执行。
执行前再次检查:
```bash
git status --short
git branch -vv
git remote -v
gh repo view --json nameWithOwner,defaultBranchRef
```
要求:
1. 确认当前分支的 tracking remote 和远端分支正确
2. 确认 PR 的目标仓库是 `antdv-next/cli`,不要依赖 `gh` 默认推断
3. 若 tracking remote 缺失、指向不明确、或不是预期 fork,先向用户确认,不要默认推送
4. 只有在推送目标 remote 明确无误时,才推送当前分支
5. 使用已确认过的标题和正文执行 `gh pr create`
6. 只有用户明确要求 Draft PR 时才使用 `--draft`
7. 相同 head/base 已存在 PR 时,返回现有 PR,不要重复创建
若需要推送,优先使用明确的远端与分支名:
```bash
git push -u <remote> HEAD
```
正文较长时写入安全的临时文件,并使用:
```bash
gh pr create \
--repo antdv-next/cli \
--base <base> \
--head <owner>:<branch> \
--title "<title>" \
--body-file <body-file>
```
同仓库分支可以只传 `<branch>`;fork 分支使用 `<owner>:<branch>`。创建成功后返回 PR 链接。
## 写法要求
### 标题
- 必须是英文
- 默认先判断 `type`,再决定是否需要 `scope`
- 优先使用 `type: subject` 或 `type(scope): subject`
- 优先写结果,不写过程
- 避免 `update`, `fix issues`, `misc changes` 这类空话
- 若分支包含多类小改动,提炼一个更高层概括
常用 `type` 参考:
- `feat`:新增能力
- `fix`:修复问题
- `docs`:文档或 skill
- `refactor`:重构
- `test`:测试改动
- `build`:构建或打包
- `ci`:CI 或 workflow
- `chore`:杂项维护
- `perf`:性能优化
- `release`:版本发布
`scope` 使用规则:
- 改动集中在单个功能边界时再加,如 `refactor(changelog): ...`
- 常见 scope 包括 `cli`、`mcp`、`changelog`、`version`、`bug`、`setup`、`skills`、`package`
- 若没有明显聚焦对象,就不要硬加 `scope`
- 不要把目录名机械塞进 `scope`
### Summary
- 先写本分支解决的问题或新增的能力
- 再写关键方案和重要约束
- 若涉及 CLI 参数、输出格式、配置、MCP API 或打包产物变化,点明外部可感知差异
- 不要写逐文件流水账
### Testing
- 只列本次实际执行并通过的命令
- 未运行时写 `Not run (reason)`
- 失败时如实说明,不要伪造通过结果
- 根据改动范围参考 `pnpm lint`、`pnpm build`、`pnpm test`、`pnpm test:all` 和 `git diff --check`
## 参考
更多类型判断、基线分支建议、确认话术与标题示例见 `references/template-notes-and-examples.md`。
@@ -0,0 +1,174 @@
## PR 类型建议
优先选择最贴近整条分支主结果的一个 type:
- 新增 CLI、Agent、MCP 或集成能力:`feat`
- 修复错误行为:`fix`
- 只修改文档或 skill:`docs`
- 主要修改测试:`test`
- 无外部行为变化的整理:`refactor`
- 性能优化:`perf`
- 构建或打包配置:`build`
- GitHub Actions / CI:`ci`
- 依赖或内部维护:`chore`
- 版本发布:`release`
## 类型判断补充说明
不要因为 diff 里包含逻辑代码就直接判成 `fix`,先看最终结果。
优先判断:
1. `feat` 只用于对外新增能力,不是“代码变多了”
2. `fix` 用于修复真实错误行为
3. 文档、skill、构建和 CI 改动优先使用各自 type
4. 以用户、CLI 调用方或维护者最终感知到的结果为准
示例:
- 新增 MCP 查询工具 -> `feat(mcp): ...`
- 修复版本 fallback 逻辑 -> `fix(version): ...`
- 更新 agent skill -> `docs(skills): ...`
- 调整 tsdown 配置 -> `build(config): ...`
- 修改 PR title workflow -> `ci: ...`
## Related Issues 写法
有明确 issue 时:
- `Closes #123`
- `Fixes #123`
- `Refs #123`
没有可靠关联时可以不写,或写 `None`。不要编造 issue 编号。
## Summary 写法
推荐使用 2 至 4 个要点,回答:
1. 原先有什么问题,或为什么需要新能力
2. 这次改变了什么
3. 是否影响 CLI 参数、输出格式、配置、MCP API 或包内容
4. 是否有破坏性变化或迁移要求
英文示例:
```markdown
## Summary
- keep changelog version resolution inside the diff utility
- simplify the command layer to pass resolved versions
- preserve existing CLI output with no public API changes
```
中文示例:
```markdown
## Summary
- 将 changelog 版本解析收敛到 diff 工具内部
- 简化命令层,只传递已解析版本
- 保持现有 CLI 输出,不涉及公开 API 变化
```
## Testing 写法
Testing 只记录实际执行并通过的命令,不要根据代码改动猜测结果。
常见验证:
- 所有 PR:`pnpm lint`
- 修改 `src/**` 或 `test/**`:`pnpm build`、`pnpm test`
- CLI、MCP、setup、打包或跨模块集成:考虑 `pnpm test:all`
- 通用检查:`git diff --check`
示例:
```markdown
## Testing
- `pnpm lint`
- `pnpm build`
- `pnpm test`
- `git diff --check`
```
未执行时:
```markdown
## Testing
- Not run (documentation-only change)
```
若测试失败,要写明失败命令和原因,不能写成通过。
## 基线分支判断建议
目标是尽量推断“当前分支实际从哪里切出来”,而不是拍脑袋默认 `main`。
建议顺序:
1. 用户明确指定了 `base branch` -> 直接使用
2. 查看当前分支是否能从 `reflog` 看出 checkout 来源
3. 查看 `git branch -vv` 的 tracking / upstream 作为辅助线索
4. 必要时结合 `merge-base` 比较候选分支
5. 若仍无法确定,再退回远端默认分支或仓库默认分支
建议命令:
```bash
git branch --show-current
git branch -vv
git reflog show --date=local $(git branch --show-current)
git remote show origin
git merge-base HEAD <candidate-branch>
```
注意:
- upstream 不是绝对父分支,只是候选线索
- `reflog` 最接近真实答案,但不一定一直存在
- 不确定时要明确告诉用户“这是推断值”
## 创建 PR 前确认话术建议
在真正执行 `git push` 或 `gh pr create` 前,先给用户确认版草稿:
```markdown
我先整理了一版待提交的 PR 草稿,请你确认:
- Target repository: `antdv-next/cli`
- Base branch: `main`
- Head branch: `refactor/changelog-version-loading`
- PR title: `refactor(changelog): move version loading into diff utility`
- Testing: `pnpm lint`, `pnpm test`
如果没问题,我再继续推送并创建 PR;如果你想改 title、base、正文或 Draft 状态,我先更新草稿。
```
## PR 标题示例
`antdv-next/cli` 的 PR 标题使用英文,并遵循:
- `<type>: <subject>`
- `<type>(<scope>): <subject>`
subject 必须以小写字符开头,以通过 `.github/workflows/validate-pr-title.yml`。
推荐示例:
- `feat(mcp): add component changelog query tool`
- `fix(version): resolve missing patch versions with fallback`
- `docs(skills): clarify CLI usage for agents`
- `refactor(changelog): move metadata loading into diff utility`
- `build(config): optimize tsdown bundle output`
- `ci: validate pull request title format`
- `chore(package): include skills in published files`
不要这样写:
- `修复版本解析问题`
- `Update version resolver`
- `fix issues`
- `some improvements`
+247
View File
@@ -0,0 +1,247 @@
---
name: antdv
description: >
Use when the user's task involves antdv-next — writing antdv-next components,
debugging antdv-next issues, querying antdv-next props/events/slot/tokens/demos, migrating between
antdv-next versions, or analyzing antdv-next usage in a project. Triggers on antdv-next-related
code, imports from 'antdv-next', or explicit antdv-next questions.
allowed-tools:
- Bash(antdv *)
- Bash(antdv bug*)
- Bash(antdv bug-cli*)
- Bash(npm install -g @antdv-next/cli*)
- Bash(which antdv)
---
# Ant Design CLI
You can visit `@antdv-next/cli` - a local cli tool that contains metadata about the current, latest version of antdv-next. Use it to query component knowledge, analyze projects, and guide migrations. All data is offline and does not require a network.
## Setup
Before first use, check if the CLI is installed. If not, install it automatically:
```bash
which antdv || npm install -g @antdv-next/cli
```
After running any command, if the output contains an "Update available" notice, run `antdv upgrade` to update before continuing.
**Always use `--format json` for structured output you can parse programmatically.**
## Scenarios
### 1. Writing antd component code
Before writing any antd component code, look up its API first — don't rely on memory.
```bash
# Check what props are available
antdv info Button --format json
# Get a working demo as starting point
antdv demo Button basic --format json
# Check semantic classNames/styles for custom styling
antdv semantic Button --format json
# Check component-level design tokens for theming
antdv token Button --format json
# Get the overall design language (design.md): colors, typography, spacing, radius + principles
antdv design.md --format json
```
**Workflow:** `antdv info` → understand props/event/slot → `antdv demo` → grab a working example → write code.
### 2. Looking up full documentation
When you need comprehensive component docs (not just props):
```bash
antdv doc Table --format json # full markdown docs for Table
antdv doc Table --lang zh # Chinese docs
```
### 3. Debugging antd issues
When code isn't working as expected or the user reports an antdv bug:
```bash
# Collect full environment snapshot (system, deps, browsers, build tools)
antdv env --format json
# Check if the prop exists for the user's antd version
antdv info Select --version 5.12.0 --format json
# Check if the prop is deprecated
antdv lint ./src/components/MyForm.tsx --format json
# Diagnose project-level configuration issues
antdv doctor --format json
```
**Workflow:** `antdv env` → capture full environment → `antdv doctor` → check configuration → `antdv info --ver X` → verify API against the user's exact version → `antdv lint` → find deprecated or incorrect usage.
### 4. Analyzing project antd usage
When the user wants to understand how antd is used in their project:
```bash
# Scan component usage statistics
antdv usage ./src --format json
# Filter to a specific component
antdv usage ./src --filter Form --format json
# Lint for best practice violations
antdv lint ./src --format json
# Check only specific rule categories
antdv lint ./src --only deprecated --format json
antdv lint ./src --only a11y --format json
antdv lint ./src --only performance --format json
```
### 5. Comparing component APIs between versions
When the user asks what component APIs changed between versions:
```bash
# Compare props, events, slots, and methods for all components
antdv changelog 1.2.2 1.5.2 --format json
# Limit the comparison to one component
antdv changelog 1.2.2 1.5.2 Select --format json
```
### 6. Exploring available components
When the user is choosing which component to use:
```bash
# List all components with categories
antdv list --format json
# List components for a specific antd version
antdv list --version 5.0.0 --format json
```
### 7. Collecting environment info
When you need to understand the project's antdv-next setup, or prepare info for a bug report:
```bash
# Full environment snapshot (text — paste into GitHub Issues)
antdv env
# Structured JSON for programmatic use
anvtd env --format json
# Scan a specific project directory
antdv env ./my-project --format json
```
Collects: OS, Node, package managers (npm/pnpm/yarn/bun/utoo), npm registry, browsers, core deps (antdv-next/vue/dayjs), all `@antdv-next/*` packages, and build tools (vite/typescript/etc.).
### 8. Reporting antd bugs
When the user asks you to report an antdv bug:
```bash
# Step 0: Collect environment info for reference (optional — antd bug already embeds basic env)
# Use the output to cross-check versions or attach extra details to the bug report
antdv env --format json
# Step 1: Preview for user review
antdv bug --title "DatePicker crashes when selecting date" \
--reproduction "https://codesandbox.io/s/xxx" \
--steps "1. Open DatePicker 2. Click a date" \
--expected "Date is selected" \
--actual "Component crashes with error" \
--format json
# Step 2: Show to user, ask for confirmation
# Step 3: Submit after user confirms
antdv bug --title "DatePicker crashes when selecting date" \
--reproduction "https://codesandbox.io/s/xxx" \
--steps "1. Open DatePicker 2. Click a date" \
--expected "Date is selected" \
--actual "Component crashes with error" \
--submit
```
### 9. Reporting CLI issues
When the user asks you to report a CLI bug, or explicitly requests help filing one:
```bash
# Preview the bug report for the user
antdv bug-cli --title "antdv info Button returns wrong props for v1.5.1" \
--description "When querying Button info for version 1.5.1, the output includes props that don't exist in that version" \
--steps "1. Run: antdv info Button --ver 1.5.1 --format json" \
--expected "Props matching antdv-next 1.5.0 Button API" \
--actual "Props include 'classes' which was added in 1.5.1" \
--format json
```
Show the report to the user and confirm before submitting:
```bash
antdv bug-cli --title "antdv info Button returns wrong props for v1.5.1" \
--description "..." \
--steps "..." \
--expected "..." \
--actual "..." \
--submit
```
**Opt-out:** If the environment variable `ANTDV_NO_AUTO_REPORT=1` is set, skip all bug-reporting suggestions entirely — do not suggest `antdv bug` or `antdv bug-cli` unless the user directly asks.
### 10. Upgrading the CLI
When the user wants to update `@antdv-next/cli` to the latest version, or when an "Update available" notice appears:
```bash
# Upgrade to the latest version (auto-detects package manager)
antdv upgrade
```
The command detects which package manager installed the CLI (npm, yarn, pnpm, bun) and runs the appropriate upgrade command. If detection fails, it suggests the manual command.
### 11. Using as MCP server
If working in an IDE that supports MCP (Claude Desktop, Cursor, etc.), the CLI can also run as an MCP server, exposing all knowledge-query tools directly:
```json
{
"mcpServers": {
"antdv": {
"command": "antdv",
"args": ["mcp", "--version", "1.5.0"]
}
}
}
```
This provides 8 tools (`antdv_list`, `antdv_info`, `antdv_doc`, `antdv_demo`, `antdv_token`, `antdv_design_md`, `antdv_semantic`) and 2 prompts (`antdv-expert`, `antdv-page-generator`) via MCP protocol.
## Global Flags
| Flag | Purpose |
|---------------------|----------------------------------------------------------------------------|
| `--format <format>` | Output format: `json`, `text`, or `markdown` (agents should prefer `json`) |
| `--ver` | Target a specific antd version (e.g. `1.5.0`) |
| `--detail` | Include extra fields (description, since, deprecated, FAQ) |
| `-V, --version` | Print CLI version and exit |
## Key Rules
1. **Always query before writing** — Don't guess antd APIs from memory. Run `antdv info` first.
2. **Match the user's version** — Knowledge queries (`list/info/doc/demo/token/semantic/changelog`) support antdv-next v1.x.
3. **Use `--format json`** — Every command supports it. Parse the JSON output rather than regex-matching text output.
4. **Check before suggesting migration** — Run `antdv changelog <v1> <v2>` and `antdv migrate` before advising on version upgrades.
5. **Lint after changes** — After writing or modifying antd code, run `antdv lint` on the changed files to catch deprecated or problematic usage.
6. **Report antdv-next bugs** — When the user asks to report an antd bug, use `antdv bug`. Always preview first, get user confirmation, then submit.
7. **Report CLI issues** — When the user asks about a CLI problem, use `antdv bug-cli` to help them file a report. Always preview first, get user confirmation, then submit.
+4
View File
@@ -23,3 +23,7 @@ build/
# 测试与工具
coverage/
*.tsbuildinfo
# Agent 本地接线(skills 本体只保留 .agents/skills)
.claude/
agent/
+1
View File
@@ -34,6 +34,7 @@
| [`docs/design/交互设计-总览与共性.md`](docs/design/交互设计-总览与共性.md) | 交互设计总纲:访问拓扑、导航、跨页组件、状态与数值呈现约定 | 定稿(2026-10-01) |
| [`docs/design/交互设计-登录与会话.md`](docs/design/交互设计-登录与会话.md) | 管理面登录页、引导页、改密、成员登录壳 | 同上 |
| [`docs/design/交互设计-全员总览.md`](docs/design/交互设计-全员总览.md) | 管理面首页:健康带 + 按成员 / key 汇总 | 同上 |
| [`docs/design/交互设计-调用日志.md`](docs/design/交互设计-调用日志.md) | 管理面调用日志:筛选、明细表、与 key/成员/总览的跳转链 | 草案(待审阅) |
| [`docs/design/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 同上 |
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 订阅/充值同构表单、核对提示 | 同上 |
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、令牌签发/重发(明文仅一次)、停用 | 同上 |
+7 -1
View File
@@ -21,7 +21,7 @@ export function adminAuditRoutes() {
method: "get",
path: "/audit-logs",
summary: "审计日志",
description: "分页、时间倒序;可按 domain 过滤(key/payment/member/account)",
description: "分页、时间倒序;可按 domain 与 [from,to) 过滤",
tags: ["审计"],
security: [{ adminSession: [] }],
request: {
@@ -31,6 +31,8 @@ export function adminAuditRoutes() {
domain: z
.enum(["key", "payment", "member", "account"])
.optional(),
from: z.string().optional(),
to: z.string().optional(),
}),
},
responses: {
@@ -49,8 +51,12 @@ export function adminAuditRoutes() {
200,
Math.max(1, Number(q.page_size ?? 50) || 50),
);
const from = q.from ? new Date(q.from) : undefined;
const to = q.to ? new Date(q.to) : undefined;
const { rows, total } = await c.get("repos").audit.listPage({
domain: q.domain,
from: from && !Number.isNaN(from.getTime()) ? from : undefined,
to: to && !Number.isNaN(to.getTime()) ? to : undefined,
page,
page_size,
});
+3 -3
View File
@@ -129,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));
}
@@ -274,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");
+18 -5
View File
@@ -4,6 +4,7 @@ import {
resolveQpuTimeline,
sumUsage,
toUnixSec,
usageRowPrecise,
type LogItem,
} from "@new-api-key-usage/core";
import {
@@ -317,17 +318,29 @@ export function adminUsageRoutes() {
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"
: 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,
type:
q.type === "error"
? "error"
: q.type != null && q.type !== ""
? Number(q.type)
: undefined,
});
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 {
+4 -2
View File
@@ -3,6 +3,7 @@ import {
ERR,
resolveQpuTimeline,
sumUsage,
usageRowPrecise,
toUnixSec,
type LogItem,
type PaymentItem,
@@ -265,8 +266,9 @@ export function memberViewRoutes() {
page_size,
});
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 {
+2
View File
@@ -5,6 +5,8 @@ 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,
+14 -2
View File
@@ -1,11 +1,23 @@
import type { LogItem, Paginated } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type AdminLogFilters = {
from: string;
to: string;
page: number;
page_size: number;
api_key_id?: number;
member_id?: number | "unassigned";
billing_source?: string;
subscription_id?: number;
type?: number | "error";
};
export async function fetchLogs(
range: { from: string; to: string; page: number; page_size: number },
params: AdminLogFilters,
): Promise<Paginated<LogItem>> {
const { data } = await adminHttp.get<Paginated<LogItem>>("/logs", {
params: range,
params,
});
return data;
}
+16 -4
View File
@@ -26,13 +26,25 @@ export const qk = {
overview: (range: TimeRange) =>
["admin", "overview", range.from, range.to] as const,
keys: (filters: KeyFilters) => ["admin", "keys", filters] as const,
logs: (range: TimeRange & PageQuery) =>
["admin", "logs", range.from, range.to, range.page, range.page_size] as const,
logs: (range: TimeRange & PageQuery & Record<string, unknown>) =>
[
"admin",
"logs",
range.from,
range.to,
range.page,
range.page_size,
range.api_key_id ?? "",
range.member_id ?? "",
range.billing_source ?? "",
range.subscription_id ?? "",
range.type ?? "",
] as const,
payments: () => ["admin", "payments"] as const,
qpuLatest: () => ["admin", "qpu-latest"] as const,
members: () => ["admin", "members"] as const,
auditLogs: (q: PageQuery & { domain?: string }) =>
["admin", "audit-logs", q.page, q.page_size, q.domain ?? ""] 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,
+3 -1
View File
@@ -6,6 +6,7 @@ import {
AuditOutlined,
DashboardOutlined,
DownOutlined,
FileTextOutlined,
KeyOutlined,
LogoutOutlined,
MenuFoldOutlined,
@@ -42,9 +43,10 @@ const username = computed(
/** 收起时仅剩图标——每项必须带 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/members", icon: TeamOutlined, label: "成员管理" },
{ key: "/admin/logs", icon: FileTextOutlined, label: "调用日志" },
{ key: "/admin/audit", icon: AuditOutlined, label: "审计日志" },
{ key: "/admin/settings", icon: SettingOutlined, label: "账号设置" },
];
+4 -2
View File
@@ -85,7 +85,9 @@ export function usePatchKey() {
});
}
export function useAdminLogs(params: () => TimeRange & PageQuery) {
export function useAdminLogs(
params: () => Parameters<typeof fetchLogs>[0],
) {
return useQuery({
queryKey: () => qk.admin.logs(params()),
queryFn: () => fetchLogs(params()),
@@ -176,7 +178,7 @@ export function useIssueMemberToken() {
}
export function useAuditLogs(
params: () => PageQuery & { domain?: string },
params: () => PageQuery & { domain?: string; from?: string; to?: string },
) {
return useQuery({
queryKey: () => qk.admin.auditLogs(params()),
+5
View File
@@ -26,6 +26,11 @@ export const adminRoutes: RouteRecordRaw[] = [
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",
+155 -5
View File
@@ -1,13 +1,163 @@
<script setup lang="ts">
/**
* 审计日志(交互设计-审计日志):只读时间线 + 域筛选 + 分页
* 表体内部滚动:高度按容器实测,避免内容区被撑出视口
*/
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import PageHeader from "@/components/common/page-header.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import { useAuditLogs } from "@/queries/admin.ts";
import { formatDateTime, recentDaysRange } from "@/utils/time.ts";
const domain = ref<string | null>(null);
const page = ref(1);
const pageSize = ref(50);
const range = ref(recentDaysRange(30));
const boxRef = ref<HTMLElement | null>(null);
/** 表头 + 分页大约占掉的高度 */
const CHROME = 96;
const bodyY = ref(480);
const params = computed(() => ({
page: page.value,
page_size: pageSize.value,
domain: domain.value ?? undefined,
from: range.value.from,
to: range.value.to,
}));
const auditQuery = useAuditLogs(() => params.value);
const rows = computed(() => auditQuery.data.value?.items ?? []);
const total = computed(() => auditQuery.data.value?.total ?? 0);
const domainOptions = [
{ value: null, label: "全部域" },
{ value: "key", label: "Key" },
{ value: "payment", label: "支付" },
{ value: "member", label: "成员" },
{ value: "account", label: "账号" },
];
const columns = [
{ title: "时间", key: "created_at", width: 160 },
{ title: "操作者", key: "operator", width: 100 },
{ title: "域", key: "domain", width: 80 },
{ title: "操作", key: "action", width: 140 },
{ title: "摘要", key: "summary", ellipsis: true },
];
watch([domain, range], () => {
page.value = 1;
});
let ro: ResizeObserver | null = null;
function measure() {
const h = boxRef.value?.clientHeight ?? 0;
if (h > 0) bodyY.value = Math.max(200, h - CHROME);
}
onMounted(() => {
measure();
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
ro = new ResizeObserver(() => measure());
ro.observe(boxRef.value);
}
window.addEventListener("resize", measure);
});
onUnmounted(() => {
ro?.disconnect();
window.removeEventListener("resize", measure);
});
</script>
<template>
<section>
<PageHeader
title="审计日志"
subtitle="管理操作留痕(信息结构见交互设计-审计日志)"
<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
/>
<a-skeleton active :paragraph="{ rows: 4 }" />
</template>
</PageHeader>
<div ref="boxRef" class="audit__box">
<a-table
:columns="columns"
:data-source="rows"
:row-key="(r: { id: number }) => r.id"
:loading="auditQuery.isPending.value || auditQuery.isFetching.value"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (t: number) => `共 ${t} 条`,
onChange: (p: number, ps: number) => {
page = p;
pageSize = ps;
},
}"
size="middle"
:scroll="{ y: bodyY }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'operator'">
{{ record.operator || "系统" }}
</template>
<template v-else-if="column.key === 'domain'">
<a-tag>{{ record.domain }}</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<span class="audit-action">{{ record.action }}</span>
</template>
<template v-else-if="column.key === 'summary'">
{{ record.summary }}
</template>
</template>
</a-table>
</div>
</section>
</template>
<style scoped>
/*
* 固定壳:视口高 − 顶栏 64 − 内容区 padding 24×2 − 内容面 padding 24×2
* 表体高度由 ResizeObserver 量出,内容区不随表格增高
*/
.audit {
height: calc(100vh - 64px - 48px - 48px);
display: flex;
flex-direction: column;
min-height: 0;
}
.audit__box {
flex: 1;
min-height: 0;
}
.audit-action {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
monospace;
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
}
.audit-range-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
line-height: 32px;
}
</style>
+8
View File
@@ -297,6 +297,13 @@ function onRowClass(record: KeyItem) {
{{ 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="停用后移出轮询清单(数据保留)"
@@ -310,6 +317,7 @@ function onRowClass(record: KeyItem) {
<a-button v-else size="small" @click="() => onToggleStatus(record)">
启用
</a-button>
</a-space>
</template>
</template>
</a-table>
+340
View File
@@ -0,0 +1,340 @@
<script setup lang="ts">
/**
* 管理面调用日志(交互设计-调用日志):筛选 + 明细表 + 与 key/成员跳转
*/
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import type { LogItem } from "@new-api-key-usage/core";
import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import { useLiveTimeRange } from "@/composables/use-live-time-range.ts";
import { useAdminLogs, useKeys, useMembers } from "@/queries/admin.ts";
import { formatDateTime, recentDaysRange } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
const { range, setRange } = useLiveTimeRange("recent-30");
const memberId = ref<number | "unassigned" | null>(null);
const apiKeyId = ref<number | null>(null);
const billingSource = ref<string | null>(null);
const subscriptionId = ref<number | null>(null);
const typeFilter = ref<number | "error" | null>(null);
const page = ref(1);
const pageSize = ref(50);
const membersQuery = useMembers();
const keysQuery = useKeys(() => ({}));
const keyById = computed(() => {
const m = new Map<number, { token_name: string; member_id: number | null; member_name: string | null }>();
for (const k of keysQuery.data.value ?? []) {
m.set(k.id, {
token_name: k.token_name,
member_id: k.member_id,
member_name: k.member_name,
});
}
return m;
});
const params = computed(() => ({
...range.value,
page: page.value,
page_size: pageSize.value,
api_key_id: apiKeyId.value ?? undefined,
member_id: memberId.value ?? undefined,
billing_source: billingSource.value ?? undefined,
subscription_id: subscriptionId.value ?? undefined,
type: typeFilter.value ?? undefined,
}));
const logsQuery = useAdminLogs(() => params.value);
const rows = computed(() => logsQuery.data.value?.items ?? []);
const total = computed(() => logsQuery.data.value?.total ?? 0);
const memberOptions = computed(() => [
{ value: null, label: "全部成员" },
{ value: "unassigned", label: "未归属" },
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
]);
const keyOptions = computed(() => [
{ value: null, label: "全部 Key" },
...(keysQuery.data.value ?? []).map((k) => ({
value: k.id,
label: k.token_name,
})),
]);
const columns = [
{ title: "时间", key: "created_at", width: 150 },
{ title: "Key", key: "token_name", width: 120 },
{ title: "归属", key: "member_name", width: 110 },
{ title: "模型", key: "model_name", dataIndex: "model_name", width: 110 },
{ title: "类型", key: "type", width: 80 },
{ title: "来源", key: "source", width: 110 },
{ title: "用量", key: "usage", width: 140 },
{ title: "Tokens", key: "tokens", width: 110 },
];
function syncFromRoute() {
const q = route.query;
const m = q.member_id;
if (m === "unassigned") memberId.value = "unassigned";
else if (typeof m === "string" && m) memberId.value = Number(m);
const k = q.api_key_id ?? q.key_id;
if (typeof k === "string" && k) apiKeyId.value = Number(k);
if (typeof q.billing_source === "string" && q.billing_source) {
billingSource.value = q.billing_source;
}
const s = q.subscription_id;
if (typeof s === "string" && s) subscriptionId.value = Number(s);
const t = q.type;
if (t === "2") typeFilter.value = 2;
else if (t === "error") typeFilter.value = "error";
else typeFilter.value = null;
const p = q.page;
if (typeof p === "string" && p) page.value = Number(p) || 1;
const from = q.from;
const to = q.to;
if (typeof from === "string" && from && typeof to === "string" && to) {
setRange({ from, to }, "fixed");
}
}
function pushQuery() {
const query: Record<string, string> = {
from: range.value.from,
to: range.value.to,
};
if (memberId.value === "unassigned") query.member_id = "unassigned";
else if (memberId.value != null) query.member_id = String(memberId.value);
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
if (billingSource.value) query.billing_source = billingSource.value;
if (subscriptionId.value != null) {
query.subscription_id = String(subscriptionId.value);
}
if (typeFilter.value) query.type = String(typeFilter.value);
if (page.value > 1) query.page = String(page.value);
void router.replace({ path: "/admin/logs", query });
}
syncFromRoute();
watch(
() => route.query,
() => syncFromRoute(),
);
watch(
[memberId, apiKeyId, billingSource, subscriptionId, typeFilter],
() => {
page.value = 1;
pushQuery();
},
);
watch(page, () => pushQuery());
function sourceBadge(record: LogItem) {
if (record.billing_source === "subscription" && record.subscription_id != null) {
return { text: `订阅 #${record.subscription_id}`, color: "blue" as const };
}
if (record.billing_source === "wallet") {
return { text: "充值", color: "gold" as const };
}
return { text: "未关联", color: undefined };
}
function goKey(keyId: number) {
void router.push({ path: "/admin/keys", query: { key_id: String(keyId) } });
}
function goMember(memberId: number | null) {
if (memberId == null) return;
void router.push({
path: "/admin/members",
query: { member_id: String(memberId) },
});
}
let ro: ResizeObserver | null = null;
const boxRef = ref<HTMLElement | null>(null);
const bodyY = ref(480);
const CHROME = 96;
function measure() {
const h = boxRef.value?.clientHeight ?? 0;
if (h > 0) bodyY.value = Math.max(200, h - CHROME);
}
onMounted(() => {
measure();
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
ro = new ResizeObserver(() => measure());
ro.observe(boxRef.value);
}
window.addEventListener("resize", measure);
});
onUnmounted(() => {
ro?.disconnect();
window.removeEventListener("resize", measure);
});
</script>
<template>
<section class="call-logs">
<PageHeader title="调用日志" subtitle="全池模型请求查询与筛选">
<template #actions>
<span class="call-logs__label">时间范围</span>
<TimeRangePicker
:model-value="range"
@change="
(v, k) => {
setRange(v, k);
page = 1;
pushQuery();
}
"
/>
</template>
</PageHeader>
<div class="call-logs__filters">
<a-select
v-model:value="memberId"
:options="memberOptions"
style="width: 140px"
allow-clear
placeholder="成员"
/>
<a-select
v-model:value="apiKeyId"
:options="keyOptions"
style="width: 160px"
allow-clear
placeholder="Key"
show-search
option-filter-prop="label"
/>
<a-select
v-model:value="billingSource"
:options="[
{ value: null, label: '全部来源' },
{ value: 'subscription', label: '订阅' },
{ value: 'wallet', label: '充值' },
{ value: 'none', label: '未关联' },
]"
style="width: 120px"
allow-clear
placeholder="来源"
/>
<a-input-number
v-model:value="subscriptionId"
:min="1"
:precision="0"
style="width: 120px"
placeholder="订阅 ID"
/>
<a-select
v-model:value="typeFilter"
:options="[
{ value: null, label: '全部类型' },
{ value: 2, label: '消费' },
{ value: 'error', label: '错误' },
]"
style="width: 120px"
placeholder="类型"
/>
</div>
<a-empty
v-if="!logsQuery.isPending.value && rows.length === 0"
description="该时段内无调用日志"
/>
<div v-else ref="boxRef" class="call-logs__box">
<a-table
:columns="columns"
:data-source="rows"
:row-key="(r: LogItem) => r.id"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (t: number) => `共 ${t} 条`,
onChange: (p: number, ps: number) => {
page = p;
pageSize = ps;
},
}"
size="middle"
:scroll="{ y: bodyY }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'token_name'">
<a @click="goKey(record.api_key_id)">{{ record.token_name }}</a>
</template>
<template v-else-if="column.key === 'member_name'">
<a
v-if="keyById.get(record.api_key_id)?.member_id != null"
@click="goMember(keyById.get(record.api_key_id)!.member_id)"
>
{{ keyById.get(record.api_key_id)?.member_name ?? "成员" }}
</a>
<span v-else>未归属</span>
</template>
<template v-else-if="column.key === 'type'">
<a-tag :color="record.type === 2 ? 'blue' : 'red'">
{{ record.type === 2 ? "消费" : "错误" }}
</a-tag>
</template>
<template v-else-if="column.key === 'source'">
<a-tag :color="sourceBadge(record).color">
{{ sourceBadge(record).text }}
</a-tag>
</template>
<template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.quota" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'tokens'">
{{ formatInt(record.prompt_tokens) }} / {{ formatInt(record.completion_tokens) }}
</template>
</template>
</a-table>
</div>
</section>
</template>
<style scoped>
.call-logs {
height: calc(100vh - 64px - 48px - 48px);
display: flex;
flex-direction: column;
min-height: 0;
}
.call-logs__label {
font-size: 13px;
line-height: 32px;
color: rgba(0, 0, 0, 0.65);
}
.call-logs__filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.call-logs__box {
flex: 1;
min-height: 0;
}
</style>
+19 -1
View File
@@ -5,6 +5,10 @@
import { computed, h, reactive, ref } from "vue";
import { Tooltip, message } from "antdv-next";
import { QuestionCircleOutlined } from "@antdv-next/icons";
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
import type { MemberItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import PageHeader from "@/components/common/page-header.vue";
@@ -58,7 +62,15 @@ const columns = [
{ title: "操作", key: "actions", width: 200 },
];
const rows = computed(() => membersQuery.data.value ?? []);
const rows = computed(() => {
const list = membersQuery.data.value ?? [];
const mid = route.query.member_id;
if (typeof mid === "string" && mid) {
const n = Number(mid);
return list.filter((m) => m.id === n);
}
return list;
});
async function onCreate() {
createError.value = "";
@@ -156,6 +168,12 @@ async function onToggle(record: MemberItem) {
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button
size="small"
@click="() => router.push({ path: '/admin/logs', query: { member_id: String(record.id) } })"
>
查调用
</a-button>
<a-popconfirm
title="重发后旧令牌立即失效"
description="该成员正在用的访问将中断"
+25 -1
View File
@@ -77,7 +77,22 @@ function onMemberClick(memberId: number | null) {
}
function onKeyClick(keyId: number) {
void router.push({ path: "/admin/keys", query: { key_id: String(keyId) } });
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() {
@@ -131,6 +146,7 @@ const memberColumns = [
),
key: "data_cutoff",
},
{ title: "操作", key: "actions", width: 100 },
];
const keyColumns = [
@@ -238,6 +254,14 @@ const keyColumns = [
<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>
+124 -3
View File
@@ -1,10 +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>
<PageHeader title="账号设置" subtitle="修改密码" />
<a-skeleton active :paragraph="{ rows: 3 }" />
<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>
+515 -4
View File
@@ -1,13 +1,524 @@
<script setup lang="ts">
/**
* 成员视图单页(交互设计-成员视图):聚合 · 支付参数 · 我的 key · 日志徽章展开
*/
import { computed, h, onMounted, onUnmounted, ref, watch } from "vue";
import type { LogItem, PaymentItem } from "@new-api-key-usage/core";
import { Tooltip } from "antdv-next";
import { QuestionCircleOutlined } from "@antdv-next/icons";
import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import SourceBar from "@/components/common/source-bar.vue";
import { useLiveTimeRange } from "@/composables/use-live-time-range.ts";
import { useMemberLogs, useMemberOverview, useMemberMe } from "@/queries/member.ts";
import { formatDateTime, formatMinute, hoursSince } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
const { range, setRange } = useLiveTimeRange("recent-30");
const meQuery = useMemberMe();
const overviewQuery = useMemberOverview(() => range.value);
const page = ref(1);
const pageSize = ref(50);
const logsQuery = useMemberLogs(() => ({
...range.value,
page: page.value,
page_size: pageSize.value,
}));
const overview = computed(() => overviewQuery.data.value);
const me = computed(() => overview.value?.member ?? meQuery.data.value ?? null);
const usage = computed(() => overview.value?.usage);
const bySource = computed(() => overview.value?.by_source ?? []);
const keys = computed(() => overview.value?.keys ?? []);
const payments = computed(() => overview.value?.payments ?? []);
const extraPresets = computed(() =>
payments.value
.filter((p) => p.kind === "subscription" && p.period_start != null)
.map((p) => ({
label: `订阅 #${p.subscription_id ?? p.id} 期间`,
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
to: new Date(
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
).toISOString(),
}))
.slice(0, 4),
);
const paymentBySub = computed(() => {
const m = new Map<number, PaymentItem>();
for (const p of payments.value) {
if (p.subscription_id != null) m.set(p.subscription_id, p);
}
return m;
});
const logs = computed(() => logsQuery.data.value?.items ?? []);
const logTotal = computed(() => logsQuery.data.value?.total ?? 0);
const expandedRowKeys = ref<number[]>([]);
function tipTitle(label: string, tip: string) {
return h("span", { class: "mv-col-title" }, [
label,
h(
Tooltip,
{ title: tip },
() =>
h(QuestionCircleOutlined, {
class: "mv-col-title__icon",
"aria-hidden": "true",
}),
),
]);
}
const logColumns = [
{ title: "时间", key: "created_at", width: 150 },
{ title: "Key", key: "token_name", dataIndex: "token_name", width: 120 },
{
title: "模型",
key: "model_name",
dataIndex: "model_name",
width: 120,
},
{ title: "来源", key: "source", width: 120 },
{ title: "用量", key: "usage", width: 150 },
{
title: tipTitle("Tokens", "提示 tokens / 补全 tokens"),
key: "tokens",
width: 110,
},
];
function syncFromRoute() {
const from = route.query.from;
const to = route.query.to;
if (typeof from === "string" && from && typeof to === "string" && to) {
setRange({ from, to }, "fixed");
}
}
function pushRangeQuery() {
void router.replace({
path: "/member",
query: { from: range.value.from, to: range.value.to },
});
}
syncFromRoute();
watch(
() => route.query,
() => syncFromRoute(),
);
function onRangeChange(
v: { from: string; to: string },
kind: "recent-7" | "recent-30" | "fixed",
) {
setRange(v, kind);
page.value = 1;
expandedRowKeys.value = [];
pushRangeQuery();
}
function isStalled(at: number | null | undefined): boolean {
const h = hoursSince(at);
return h != null && h > 2;
}
function sourceBadge(record: LogItem) {
if (record.billing_source === "subscription" && record.subscription_id != null) {
return {
text: `订阅 #${record.subscription_id}`,
kind: "sub" as const,
id: record.subscription_id,
};
}
if (record.billing_source === "wallet") {
return { text: "充值", kind: "wallet" as const, id: null };
}
return { text: "未关联", kind: "none" as const, id: null };
}
function onBadge(record: LogItem) {
const b = sourceBadge(record);
if (b.kind !== "sub") return;
const i = expandedRowKeys.value.indexOf(record.id);
if (i >= 0) expandedRowKeys.value.splice(i, 1);
else expandedRowKeys.value.push(record.id);
}
function onExpand(expanded: boolean, record: LogItem) {
const i = expandedRowKeys.value.indexOf(record.id);
if (expanded && i < 0) expandedRowKeys.value.push(record.id);
if (!expanded && i >= 0) expandedRowKeys.value.splice(i, 1);
}
/** 表体滚动,避免整页被明细撑高 */
const boxRef = ref<HTMLElement | null>(null);
const bodyY = ref(420);
const CHROME = 120;
function measure() {
const h = boxRef.value?.clientHeight ?? 0;
if (h > 0) bodyY.value = Math.max(220, h - CHROME);
}
let ro: ResizeObserver | null = null;
onMounted(() => {
measure();
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
ro = new ResizeObserver(() => measure());
ro.observe(boxRef.value);
}
window.addEventListener("resize", measure);
});
onUnmounted(() => {
ro?.disconnect();
window.removeEventListener("resize", measure);
});
function periodText(p: PaymentItem): string {
if (p.kind !== "subscription") return formatDateTime(p.paid_at);
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${formatDateTime(p.period_start)} ~ ${end}`;
}
function expandPayment(record: LogItem): PaymentItem | undefined {
return paymentBySub.value.get(sourceBadge(record).id ?? -1);
}
</script>
<template>
<section>
<section class="mv">
<PageHeader
title="我的用量"
subtitle="聚合 · 来源徽章 · 明细(信息结构见交互设计-成员视图)"
:title="me?.name ? me.name : '我的用量'"
subtitle="你在本池的名下用量"
>
<template #actions>
<span class="mv-range-label">时间范围</span>
<TimeRangePicker
:model-value="range"
:extra-presets="extraPresets"
@change="onRangeChange"
/>
<a-skeleton active :paragraph="{ rows: 4 }" />
</template>
</PageHeader>
<a-skeleton
v-if="overviewQuery.isPending.value"
active
:paragraph="{ rows: 4 }"
/>
<a-empty
v-else-if="keys.length === 0"
description="作者尚未为你分配 key"
/>
<div v-else class="mv-body">
<section class="mv-block">
<h2 class="mv-block__title">总用量</h2>
<template v-if="usage && usage.count > 0">
<div class="mv-agg">
<span>调用 {{ formatInt(usage.count) }} 次</span>
<QuotaText :quota="usage.quota" :quota-usd="usage.quota_usd" />
<span class="mv-note">按当时换算率</span>
</div>
<SourceBar :rows="bySource" />
</template>
<div v-else class="mv-empty">该时段内无调用</div>
</section>
<section class="mv-block">
<h2 class="mv-block__title">支付参数</h2>
<div v-if="payments.length === 0" class="mv-empty">暂无支付记录信息</div>
<template v-else>
<div v-for="p in payments" :key="p.id" class="mv-pay">
<div class="mv-pay__head">
<a-tag :color="p.kind === 'subscription' ? 'blue' : 'gold'">
{{ p.kind === "subscription" ? "订阅" : "充值" }}
</a-tag>
<strong v-if="p.kind === 'subscription'">
订阅 #{{ p.subscription_id }}
</strong>
</div>
<div class="mv-pay__grid">
<div class="mv-pay__row">
<span class="mv-pay__k">{{ p.kind === "subscription" ? "期间" : "支付时间" }}</span>
<span>{{ periodText(p) }}</span>
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">额度</span>
<QuotaText :quota="p.quota_granted" :quota-usd="p.quota_usd" />
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">金额</span>
<span class="mv-num">{{ p.paid_amount }}</span>
</div>
<div v-if="p.remark" class="mv-pay__row">
<span class="mv-pay__k">备注</span>
<span>{{ p.remark }}</span>
</div>
</div>
</div>
<div class="mv-note mv-foot">
支付参数对成员完全公开;币种由成员与作者约定
</div>
</template>
</section>
<section class="mv-block">
<h2 class="mv-block__title">我的 key</h2>
<div class="mv-keys">
<div v-for="k in keys" :key="k.key_id" class="mv-key">
<strong>{{ k.token_name }}</strong>
<span v-if="isStalled(k.log_synced_at)" class="mv-stall">
同步截止 {{ formatMinute(k.log_synced_at) }} · 停滞
</span>
<span v-else class="mv-note">
同步截止 {{ formatMinute(k.log_synced_at) }}
</span>
<span>调用 {{ formatInt(k.usage.count) }} 次</span>
<QuotaText :quota="k.usage.quota" :quota-usd="k.usage.quota_usd" />
</div>
</div>
</section>
<section class="mv-block mv-block--logs">
<h2 class="mv-block__title">日志明细</h2>
<div ref="boxRef" class="mv-logs">
<a-table
:columns="logColumns"
:data-source="logs"
:row-key="(r: LogItem) => r.id"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
:pagination="{
current: page,
pageSize,
total: logTotal,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100'],
onChange: (p: number, ps: number) => {
page = p;
pageSize = ps;
},
}"
size="middle"
:scroll="{ x: 800, y: bodyY }"
:expanded-row-keys="expandedRowKeys"
:expandable="{
showExpandColumn: false,
rowExpandable: (r: LogItem) => sourceBadge(r).kind === 'sub',
}"
@expand="onExpand"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.quota" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'tokens'">
{{ formatInt(record.prompt_tokens) }} / {{ formatInt(record.completion_tokens) }}
</template>
<template v-else-if="column.key === 'source'">
<a-tooltip
v-if="sourceBadge(record).kind === 'sub'"
title="点击查看该订阅支付参数"
>
<a-tag color="blue" class="mv-badge" @click="onBadge(record)">
{{ sourceBadge(record).text }}
</a-tag>
</a-tooltip>
<a-tag v-else-if="sourceBadge(record).kind === 'wallet'" color="gold">
充值
</a-tag>
<a-tag v-else>未关联</a-tag>
</template>
</template>
<template #expandedRowRender="{ record }">
<div class="mv-expand">
<template v-if="expandPayment(record)">
<div class="mv-expand__title">订阅支付参数</div>
<div class="mv-pay__grid">
<div class="mv-pay__row">
<span class="mv-pay__k">期间</span>
<span>{{ periodText(expandPayment(record)!) }}</span>
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">额度</span>
<QuotaText
:quota="expandPayment(record)!.quota_granted"
:quota-usd="expandPayment(record)!.quota_usd"
/>
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">金额</span>
<span class="mv-num">
{{ expandPayment(record)!.paid_amount }}
</span>
</div>
<div v-if="expandPayment(record)!.remark" class="mv-pay__row">
<span class="mv-pay__k">备注</span>
<span>{{ expandPayment(record)!.remark }}</span>
</div>
</div>
</template>
<div v-else class="mv-empty">该订阅无登记信息</div>
</div>
</template>
</a-table>
</div>
</section>
</div>
</section>
</template>
<style scoped>
.mv {
min-height: 0;
}
.mv-range-label {
font-size: 13px;
line-height: 32px;
color: rgba(0, 0, 0, 0.65);
}
.mv-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.mv-block {
margin-bottom: 16px;
}
.mv-block--logs {
flex: 1;
min-height: 280px;
display: flex;
flex-direction: column;
}
.mv-logs {
flex: 1;
min-height: 0;
}
.mv-block__title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
line-height: 22px;
}
.mv-agg {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 16px;
margin-bottom: 12px;
font-variant-numeric: tabular-nums;
}
.mv-note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-foot {
margin-top: 8px;
}
.mv-empty {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
.mv-num {
font-variant-numeric: tabular-nums;
}
.mv-pay {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.mv-pay__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.mv-pay__grid {
display: grid;
gap: 4px;
font-size: 13px;
}
.mv-pay__row {
display: flex;
gap: 12px;
}
.mv-pay__k {
width: 56px;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.45);
}
.mv-keys {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.mv-key {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
font-size: 13px;
}
.mv-stall {
color: #d48806;
font-weight: 600;
}
.mv-badge {
cursor: pointer;
}
.mv-expand {
padding: 8px 12px;
font-size: 13px;
}
.mv-expand__title {
font-weight: 600;
margin-bottom: 8px;
}
.mv-col-title {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.mv-col-title__icon {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
cursor: help;
}
</style>
+1 -1
View File
@@ -166,7 +166,7 @@
`token_name` 取**当前名**(联查 `api_keys`,与「展示名以当前为准」对齐——§10 裁定 2);`username` / `channel` / `ip` / `token_id` / `request_id` / `other` 等其余表字段暂不出 API,界面需要时再扩。
> **界面落点待补**:总览「行点击 → Key 管理页该 key 详情」——交互设计无成篇的「key 详情」视图,本端点先行、界面归交互补篇或实现期定(§11 遗留 1)。
> **界面落点**:《交互设计-调用日志》——管理面独立查询页;总览/Key/成员筛选深链 + 行内回跳。
## 7. 支付记录
+4 -1
View File
@@ -28,12 +28,15 @@
| 菜单 | 页面 | 职责一句话 |
|------|------|-----------|
| 总览 | 全员总览 | 健康与用量全景(首页,登录后落地) |
| 成员管理 | 成员清单 | 开户、令牌、停用 |
| Key 管理 | key 清单 | 登记、启停、归属 |
| 支付记录 | 记录清单 | 订阅/充值登记、核对 |
| 成员管理 | 成员清单 | 开户、令牌、停用 |
| 调用日志 | 请求明细 | 全池模型请求查询与筛选 |
| 审计日志 | 操作留痕 | 管理动作时间线 |
| 账号设置 | 改密 | 会话体系基本配套 |
顺序逻辑:先看全景 → 立对象(成员 → Key → 支付)→ 查事实(调用日志)→ 系统(审计 / 账号)。
- 侧边栏底部:当前用户名 + 登出(登出即删会话行 → 回登录页)
- 成员视图**无侧边栏**(单页,页头导航)
+71
View File
@@ -0,0 +1,71 @@
# 交互设计 · 调用日志
> 管理面的模型请求日志查询页——`GET /api/admin/logs` 的界面落点。回答「这段时间谁、哪把 key、哪个模型、花了多少」;与 Key 管理、成员管理、全员总览构成筛选跳转闭环。**只读事实**,无改写。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-08) |
| 关联 | 《API契约-管理面》§6(过滤与 DTO)·《交互设计-总览与共性》(时间范围、数值呈现、状态模式)·《交互设计-key管理》《交互设计-成员管理》《交互设计-全员总览》(双向跳转)· `usage_logs表字段设计.md` |
| 数据 | `usage_logs` 明细(分页;默认全类型,可筛 `type`) |
---
## 1. 页面定位
- 侧边栏项「**调用日志**」(排在「支付记录」之后:对象链之后的事实查询)
- 纯查询页:无登记、无启停、无删除;排查与核对入口
- 与成员视图的日志表**列语义同构**,但范围是**全池**且可筛错误行
## 2. 筛选区(自上而下一行或两行)
| 筛选 | 形态 | 对应 API |
|------|------|----------|
| 时间范围 | 共性组件,默认最近 30 天 | `from` / `to`(必填) |
| 成员 | Select,含「未归属」 | `member_id` |
| Key | Select(依赖已选成员可收窄;或全量) | `api_key_id` |
| 计费来源 | 全部 / 订阅 / 充值 / 未关联 | `billing_source` |
| 订阅 ID | 数字(可空) | `subscription_id` |
| 类型 | 全部 / 消费 / 错误 | `type`(2 / 非 2) |
- 切换任一筛选 → 回第 1 页
- URL 查询同步:`from/to` 可选省略(用默认区间);`member_id`、`api_key_id`、`billing_source`、`subscription_id`、`type`、`page`——便于总览/管理页深链
## 3. 日志表(页面主体)
| 列 | 内容 | 备注 |
|----|------|------|
| 时间 | 到秒(北京时间) | 倒序由后端返回 |
| Key | `token_name` | **可点** → Key 管理定位该 key |
| 归属 | 成员名 / 未归属 | **可点** → 成员管理;未归属不跳 |
| 模型 | `model_name` | |
| 类型 | 消费 / 错误徽章 | 错误行 `type ≠ 2` |
| 来源 | 订阅 #N / 充值 / 未关联徽章 | 与成员视图同构 |
| 用量 | quota + $ 双单位 | 错误行可显示 `—` 或 0 |
| Tokens | prompt / completion | |
- 分页:默认 50,档位 20 / 50 / 100 / 200(无界列表)
- **行内不展开支付参数**(管理面支付另页;此处保持扫读密度)——需要看订阅登记时从来源徽章旁的链接或工具提示指向支付记录筛选(实现期可只做 tooltip 文案)
- 空态:「该时段内无调用日志」
- 窄屏:表格横向滚动(共性 §3.5)
## 4. 交互链路(与其它页)
| 从 | 到 | 携带 |
|----|-----|------|
| 全员总览 · 按 key 行 / 成员行 | 本页 | `api_key_id` 或 `member_id` + 当前时间范围 |
| Key 管理 · 行操作「查调用」 | 本页 | `api_key_id` |
| 成员管理 · 行操作「查调用」 | 本页 | `member_id` |
| 本页 · Key 列 | Key 管理 | `key_id`(定位/高亮) |
| 本页 · 归属列 | 成员管理 | `member_id`(筛选) |
| 健康带失败数(总览) | 仍去 Key 管理失败筛选 | 不变 |
## 5. 状态与边界
- 加载:表 loading;错误:顶部错误条 + 重试(共性 §3.4)
- 401 → 管理面登录(既有分流)
- 不展示 `request_id` / `ip` / `channel` 等表字段(契约 §6 暂不 API);将来需要时列可扩
## 6. 与既有文档的衔接
- 《API契约-管理面》§6「界面落点待补」→ **本篇即落点**;「跳 Key 管理详情」改为「跳本页带筛选 + Key 管理定位」并行
- 侧边栏六项 → **七项**(《交互设计-总览与共性》§2 同步修订)
+9
View File
@@ -18,6 +18,15 @@ export function microUsdToQuotaUsdStr(micro: number): string {
return `${dollars}.${String(rem).padStart(2, "0")}`;
}
/** 明细行等小额场景:保留更多小数位(默认 4 位,仍是字符串两位小数契约的展示增强) */
export function microUsdToQuotaUsdStrPrecise(
micro: number,
fractionDigits = 4,
): string {
const usd = micro / 1_000_000;
return usd.toFixed(fractionDigits);
}
export function quotaToUsdStr(quota: number, qpu: number): string {
return microUsdToQuotaUsdStr(quotaToMicroUsd(quota, qpu));
}
+2 -2
View File
@@ -20,7 +20,7 @@ export const usernameBody = z.object({ username: z.string() });
export const setupBody = z.object({
username: z.string().min(1).max(64),
password: z.string().min(8).max(128),
password: z.string().min(1).max(128),
});
export const loginBody = z.object({
@@ -30,7 +30,7 @@ export const loginBody = z.object({
export const changePasswordBody = z.object({
old_password: z.string().min(1),
new_password: z.string().min(8).max(128),
new_password: z.string().min(1).max(128),
});
export const timeRangeQuery = z.object({
+16
View File
@@ -3,6 +3,7 @@
*/
import {
microSumToUsdStr,
microUsdToQuotaUsdStrPrecise,
qpuAt,
quotaToMicroUsd,
quotaToUsdStr,
@@ -54,6 +55,21 @@ export function usageAt(
return { count: 1, quota, quota_usd: quotaToUsdStr(quota, qpu) };
}
/** 明细行:按当时 QPU 换算,展示保留 6 位小数(微美元级,小额不被抹成 0) */
export function usageRowPrecise(
quota: number,
created_at: Date | number,
timeline: readonly QpuPoint[],
): UsageSum {
const qpu = qpuAt(created_at, timeline);
const micro = quotaToMicroUsd(quota, qpu);
return {
count: 1,
quota,
quota_usd: microUsdToQuotaUsdStrPrecise(micro, 6),
};
}
export function addUsage(a: UsageSum, b: UsageSum): UsageSum {
// 合并已成形的两段时,quota_usd 需重算——调用方应尽量走 sumUsage
return {
+17 -7
View File
@@ -18,7 +18,7 @@ import {
type UsageLogRow,
type UserRow,
} from "./schema.ts";
import { and, asc, count, desc, eq, gt, gte, lt, or, sql } from "drizzle-orm";
import { and, asc, count, desc, eq, gt, gte, isNull, lt, ne, or, sql } from "drizzle-orm";
export interface SessionUser {
user_id: number;
@@ -323,10 +323,14 @@ export function createRepos(db: Db) {
},
async listPage(params: {
domain?: string;
from?: Date;
to?: Date;
page: number;
page_size: number;
}): Promise<{ rows: AuditLogRow[]; total: number }> {
const conds = params.domain ? [eq(auditLogs.domain, params.domain)] : [];
if (params.from) conds.push(gte(auditLogs.created_at, params.from));
if (params.to) conds.push(lt(auditLogs.created_at, params.to));
const totalRows = await db
.select({ n: count() })
.from(auditLogs)
@@ -452,10 +456,10 @@ export function createRepos(db: Db) {
from: Date;
to: Date;
api_key_id?: number;
member_id?: number;
billing_source?: string;
member_id?: number | "unassigned";
billing_source?: string | "none";
subscription_id?: number;
type?: number;
type?: number | "error";
page: number;
page_size: number;
}): Promise<{ rows: Array<UsageLogRow & { token_name: string }>; total: number }> {
@@ -466,16 +470,22 @@ export function createRepos(db: Db) {
if (params.api_key_id != null) {
conds.push(eq(usageLogs.api_key_id, params.api_key_id));
}
if (params.billing_source) {
if (params.billing_source === "none") {
conds.push(isNull(usageLogs.billing_source));
} else if (params.billing_source) {
conds.push(eq(usageLogs.billing_source, params.billing_source));
}
if (params.subscription_id != null) {
conds.push(eq(usageLogs.subscription_id, params.subscription_id));
}
if (params.type != null) {
if (params.type === "error") {
conds.push(ne(usageLogs.type, 2));
} else if (typeof params.type === "number") {
conds.push(eq(usageLogs.type, params.type));
}
if (params.member_id != null) {
if (params.member_id === "unassigned") {
conds.push(isNull(apiKeys.member_id));
} else if (params.member_id != null) {
conds.push(eq(apiKeys.member_id, params.member_id));
}
+17
View File
@@ -0,0 +1,17 @@
{
"version": 1,
"skills": {
"antdv": {
"source": "antdv-next/cli",
"sourceType": "github",
"skillPath": "skills/antdv/SKILL.md",
"computedHash": "03eb1af94f9501f6b9ff2c4a3eb8010840e2492015e31c50402762964be7273e"
},
"antdv-next-cli-create-pr": {
"source": "antdv-next/cli",
"sourceType": "github",
"skillPath": ".agents/skills/create-pr/SKILL.md",
"computedHash": "ed62187839cd00787865cab43b1987d40b71f8cfdaa6ba862a0b130ea3243ac5"
}
}
}