Browse Source

docs: add admin traffic test-grant implementation plan

Break the TrafficOps test-grant dialog into URL, UI, and submit
tasks for subagent or inline execution.
main
xiaosi 2 weeks ago
parent
commit
d895cf725b
  1. 381
      docs/superpowers/plans/2026-09-02-admin-traffic-test-grant.md

381
docs/superpowers/plans/2026-09-02-admin-traffic-test-grant.md

@ -0,0 +1,381 @@
# Admin Traffic Test Grant Tool Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在管理员「流量运营」页增加「测试发放」对话框,调用 `POST /v1/admin/traffic/test-grants` 给普通用户直接加测试流量,绕过下单支付链路。
**Architecture:** 只改前端。`urls.js` 增加常量;`TrafficOpsView` 页头加按钮与对话框。用户可通过 `/v1/users` 搜索选择,或手填 `userId`。成功后 toast,并在对话框展示最新余额。
**Tech Stack:** Vue 3、tdesign-vue-next、Pinia `uiStore`、axios `src/utils/http.js`、既有 `TrafficOpsView` / `FirmwaresView` 页头按钮模式
**Spec:** `docs/superpowers/specs/2026-09-02-admin-traffic-test-grant-design.md`
---
## File map
| 文件 | 职责 |
|---|---|
| `src/config/urls.js` | 新增 `ADMIN_TRAFFIC_TEST_GRANTS` |
| `src/views/TrafficOpsView/TrafficOpsView.vue` | 页头按钮、发放对话框、搜索用户、提交与余额展示 |
辅助约定:
- 请求:`import request from '@/utils/http'`(成功时已解包 `res.data`
- Toast:`useUiStore().toast(...)`
- GB:沿用页内 `GB_BYTES = 1024 ** 3`、`bytesToGbText`
- 本仓库无前端单测惯例:用 `npm run build` + 静态核对验收;不新增测试文件
---
### Task 1: URL 常量
**Files:**
- Modify: `src/config/urls.js`
- [ ] **Step 1: 在 Admin traffic ops 段落后追加常量**
在现有:
```js
export const ADMIN_TRAFFIC_LEDGER = '/v1/admin/traffic/ledger'
```
后追加:
```js
export const ADMIN_TRAFFIC_TEST_GRANTS = '/v1/admin/traffic/test-grants'
```
- [ ] **Step 2: Commit**
```bash
git add src/config/urls.js
git commit -m "feat: add admin traffic test-grants URL"
```
---
### Task 2: TrafficOps 页头按钮 + 发放对话框 UI
**Files:**
- Modify: `src/views/TrafficOpsView/TrafficOpsView.vue`
- [ ] **Step 1: 页头增加「测试发放」按钮**
把 header 从:
```vue
<header class="operation-page-header">
<div>
<h1>流量运营</h1>
<span>管理云媒体流量套餐、平台采购入池与账务流水</span>
</div>
</header>
```
改为(对齐 `FirmwaresView``page-actions`):
```vue
<header class="operation-page-header">
<div>
<h1>流量运营</h1>
<span>管理云媒体流量套餐、平台采购入池与账务流水</span>
</div>
<div class="page-actions">
<t-button theme="primary" @click="openGrant">
<svg><use href="#i-plus" /></svg>测试发放
</t-button>
</div>
</header>
```
- [ ] **Step 2: 在现有 purchase dialog 后追加发放对话框**
放在第二个 `</t-dialog>`(采购录入)之后、`</section>` 之前:
```vue
<t-dialog
v-model:visible="grantModalVisible"
header="测试发放流量"
width="520px"
destroy-on-close
dialog-class-name="add-dock-dialog"
:close-on-overlay-click="false"
placement="center"
@closed="resetGrantForm"
>
<form novalidate @submit.prevent="submitGrant">
<label>
<span>搜索用户</span>
<t-select
v-model="grantSelectedUserId"
filterable
clearable
placeholder="输入姓名 / 手机号 / 邮箱搜索"
:options="grantUserOptions"
:loading="grantUserSearching"
style="width: 100%"
@search="searchGrantUsers"
@change="onGrantUserPicked"
@clear="onGrantUserCleared"
/>
</label>
<label>
<span>用户 ID<b>*</b></span>
<t-input
v-model="grantForm.userId"
clearable
placeholder="可手填,或从上方搜索选中"
/>
</label>
<label>
<span>额度(GB)<b>*</b></span>
<t-input-number
v-model="grantForm.amountGb"
theme="normal"
:min="1"
:step="1"
:decimal-places="0"
style="width: 100%"
/>
</label>
<label>
<span>原因<b>*</b></span>
<t-input v-model="grantForm.reason" clearable placeholder="如 live test" />
</label>
<p v-if="grantFormError" class="form-error">{{ grantFormError }}</p>
<div v-if="grantResultBalanceText" class="grant-result">
<span>最新余额</span>
<strong>{{ grantResultBalanceText }}</strong>
</div>
</form>
<template #footer>
<t-button variant="outline" @click="grantModalVisible = false">取消</t-button>
<t-button theme="primary" :loading="grantSubmitting" @click="submitGrant">确认发放</t-button>
</template>
</t-dialog>
```
若页面尚无 `.form-error` / `.grant-result` 样式,在本文件 `<style scoped>` 末尾追加:
```css
.form-error {
margin: 8px 0 0;
color: #d54941;
font-size: 12px;
}
.grant-result {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid #d7e4ee;
border-radius: 4px;
background: #f5f9fc;
}
.grant-result span {
display: block;
color: #6c7d88;
font-size: 11px;
}
.grant-result strong {
display: block;
margin-top: 4px;
color: #246fae;
font-size: 18px;
}
```
(若已有同名 `.form-error`,不要重复定义。)
- [ ] **Step 3: Commit UI scaffold(可与 Task 3 合并提交;若分提交则本步只含模板/样式)**
```bash
git add src/views/TrafficOpsView/TrafficOpsView.vue
git commit -m "feat: add traffic test-grant dialog shell"
```
---
### Task 3: 搜索用户 + 提交发放逻辑
**Files:**
- Modify: `src/views/TrafficOpsView/TrafficOpsView.vue`
- [ ] **Step 1: 在 script 中追加 grant 状态与辅助函数**
放在现有 `tab` / pool / ledger 状态附近(采购相关 state 之后亦可),追加:
```js
const grantModalVisible = ref(false)
const grantSubmitting = ref(false)
const grantUserSearching = ref(false)
const grantFormError = ref('')
const grantResultBalanceText = ref('')
const grantSelectedUserId = ref('')
const grantUserOptions = ref([])
const grantForm = reactive({
userId: '',
amountGb: 10,
reason: 'live test'
})
function resetGrantForm() {
grantForm.userId = ''
grantForm.amountGb = 10
grantForm.reason = 'live test'
grantSelectedUserId.value = ''
grantUserOptions.value = []
grantFormError.value = ''
grantResultBalanceText.value = ''
grantSubmitting.value = false
grantUserSearching.value = false
}
function openGrant() {
resetGrantForm()
grantModalVisible.value = true
}
function onGrantUserPicked(val) {
if (val == null || val === '') return
grantForm.userId = String(val)
}
function onGrantUserCleared() {
// 清空搜索选中时不强制清 ID,允许继续手填
grantSelectedUserId.value = ''
}
let grantSearchSeq = 0
async function searchGrantUsers(keyword) {
const q = String(keyword || '').trim()
if (!q) {
grantUserOptions.value = []
return
}
const seq = ++grantSearchSeq
grantUserSearching.value = true
try {
const page = await request.get(urls.USERS, {
params: { keyword: q, role: 'user', pageNum: 1, pageSize: 20 }
})
if (seq !== grantSearchSeq) return
grantUserOptions.value = (page?.records || []).map((u) => ({
label: `${u.name || '--'} · ${u.phone || '--'} · ${u.id}`,
value: String(u.id)
}))
} catch (e) {
if (seq !== grantSearchSeq) return
grantUserOptions.value = []
// http 拦截器已 toast;此处不重复
} finally {
if (seq === grantSearchSeq) grantUserSearching.value = false
}
}
function parseGrantBalanceText(data) {
if (data == null) return '已发放(未返回余额字段)'
if (typeof data === 'number' || (typeof data === 'string' && data.trim() !== '' && !Number.isNaN(Number(data)))) {
const n = Number(data)
if (!Number.isFinite(n)) return '已发放(未返回余额字段)'
// 很大则按 bytes,否则按 GB
return n >= GB_BYTES ? bytesToGbText(n) : `${n.toFixed(2)} GB`
}
if (typeof data !== 'object') return '已发放(未返回余额字段)'
if (data.trafficBalanceBytes != null) return bytesToGbText(data.trafficBalanceBytes)
if (data.balanceBytes != null) return bytesToGbText(data.balanceBytes)
if (data.trafficBalance != null) {
const n = Number(data.trafficBalance)
if (!Number.isFinite(n)) return '已发放(未返回余额字段)'
return n >= GB_BYTES ? bytesToGbText(n) : `${n.toFixed(2)} GB`
}
if (data.balance != null) {
const n = Number(data.balance)
if (!Number.isFinite(n)) return '已发放(未返回余额字段)'
return n >= GB_BYTES ? bytesToGbText(n) : `${n.toFixed(2)} GB`
}
return '已发放(未返回余额字段)'
}
async function submitGrant() {
grantFormError.value = ''
const userIdNum = Number(String(grantForm.userId || '').trim())
const amountGb = Number(grantForm.amountGb)
const reason = String(grantForm.reason || '').trim()
if (!Number.isInteger(userIdNum) || userIdNum <= 0) {
grantFormError.value = '请填写有效的用户 ID'
return
}
if (!Number.isFinite(amountGb) || amountGb < 1 || !Number.isInteger(amountGb)) {
grantFormError.value = '额度须为不小于 1 的整数 GB'
return
}
if (!reason) {
grantFormError.value = '请填写原因'
return
}
grantSubmitting.value = true
try {
const data = await request.post(urls.ADMIN_TRAFFIC_TEST_GRANTS, {
userId: userIdNum,
amountGb,
reason
})
grantResultBalanceText.value = parseGrantBalanceText(data)
ui.toast('测试流量已发放')
} catch (e) {
// 拦截器已 toast;保留对话框便于改参重试
if (!e?.message) ui.toast('发放失败')
} finally {
grantSubmitting.value = false
}
}
```
注意:
- `reactive` / `ref` 已在文件顶部 import;若没有 `reactive`,补上
- 不要重复定义 `GB_BYTES` / `bytesToGbText`
- [ ] **Step 2: 构建验证**
```bash
npm run build
```
Expected: Vite build 成功(可有 chunk size warning)。
- [ ] **Step 3: 静态核对**
```bash
rg -n "ADMIN_TRAFFIC_TEST_GRANTS|openGrant|submitGrant|测试发放" src/config/urls.js src/views/TrafficOpsView/TrafficOpsView.vue
```
Expected: urls 常量存在;TrafficOps 含按钮、对话框与提交函数。
- [ ] **Step 4: Commit**
```bash
git add src/views/TrafficOpsView/TrafficOpsView.vue
git commit -m "feat: grant test traffic from TrafficOps dialog"
```
---
## Spec coverage checklist
| Spec 要求 | Task |
|---|---|
| `/traffic-ops` 页头「测试发放」 | Task 2 |
| 搜用户 + 手填 userId | Task 3 |
| amountGb / reason 表单 | Task 2–3 |
| POST test-grants | Task 1 + 3 |
| 成功 toast + 展示最新余额 | Task 3 |
| 仅改 urls + TrafficOpsView | 全部 |
| 不改后端 | 全部 |
## Risks
- 后端接口若仍 404:按钮可用,提交失败 toast;不隐藏入口(spec 明确可接受)
- 返回余额字段名不确定:`parseGrantBalanceText` 兼容多种字段
- `t-select` `@search` 在不同 TDesign 版本行为略有差异;若远程搜索不触发,改为 `t-input` 回车搜索 + 结果 `t-select`(实现时按实际组件行为微调,保持同一提交契约)
Loading…
Cancel
Save