You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

11 KiB

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 ** 3bytesToGbText
  • 本仓库无前端单测惯例:用 npm run build + 静态核对验收;不新增测试文件

Task 1: URL 常量

Files:

  • Modify: src/config/urls.js

  • Step 1: 在 Admin traffic ops 段落后追加常量

在现有:

export const ADMIN_TRAFFIC_LEDGER = '/v1/admin/traffic/ledger'

后追加:

export const ADMIN_TRAFFIC_TEST_GRANTS = '/v1/admin/traffic/test-grants'
  • Step 2: Commit
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 从:

<header class="operation-page-header">
  <div>
    <h1>流量运营</h1>
    <span>管理云媒体流量套餐平台采购入池与账务流水</span>
  </div>
</header>

改为(对齐 FirmwaresViewpage-actions):

<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> 之前:

<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> 末尾追加:

.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 合并提交;若分提交则本步只含模板/样式)
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 之后亦可),追加:

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: 构建验证

npm run build

Expected: Vite build 成功(可有 chunk size warning)。

  • Step 3: 静态核对
rg -n "ADMIN_TRAFFIC_TEST_GRANTS|openGrant|submitGrant|测试发放" src/config/urls.js src/views/TrafficOpsView/TrafficOpsView.vue

Expected: urls 常量存在;TrafficOps 含按钮、对话框与提交函数。

  • Step 4: Commit
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(实现时按实际组件行为微调,保持同一提交契约)