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