Browse Source
Break the TrafficOps test-grant dialog into URL, UI, and submit tasks for subagent or inline execution.main
1 changed files with 381 additions and 0 deletions
@ -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…
Reference in new issue