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.
 
 
 
 

21 KiB

Traffic Ops Admin Page 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: 为管理员新增独立「流量运营」页:套餐 CRUD、采购入池、账务流水只读。

Architecture: 新建 TrafficOpsView,页内三 Tab;路由 /traffic-ops + meta.admin;侧栏/顶栏挂入口;对接既有 /v1/admin/traffic/*,不改后端。UI/交互对齐 FirmwaresView(operation-page、asset-toolbar、t-table、t-dialog、ui.confirm)。

Tech Stack: Vue 3、tdesign-vue-next、Pinia uiStore、axios src/utils/http.js、现有 urls/router/layout 模式

Spec: docs/superpowers/specs/2026-09-01-traffic-ops-admin-design.md


File map

文件 职责
src/config/urls.js 新增 admin traffic URL 常量
src/router/index.js 注册 /traffic-ops
src/layout/components/SideMenu.vue 管理员菜单「流量运营」
src/layout/components/TopBar.vue 标题映射
src/views/TrafficOpsView/TrafficOpsView.vue 页面本体(三 Tab)

辅助约定:

  • 请求:import request from '@/utils/http'
  • Toast/Confirm:useUiStore() from @/stores/modules/uiStore
  • GB ↔ bytes:GB_BYTES = 1024 ** 3;展示用 bytesToGbText(n)
  • pool 404:拦截器会 toast + reject;采购 Tab 需 catch err.status === 404 / msg 含「资源不存在」,把 pool 置空并抑制二次致命提示(可吞掉已知 404)

Task 1: URL + 路由 + 菜单骨架

Files:

  • Modify: src/config/urls.js

  • Modify: src/router/index.js

  • Modify: src/layout/components/SideMenu.vue

  • Modify: src/layout/components/TopBar.vue

  • Create: src/views/TrafficOpsView/TrafficOpsView.vue(骨架)

  • Step 1: 在 urls.js Account 段落后追加 Admin Traffic 常量

// Admin traffic ops
export const ADMIN_TRAFFIC_POOL = '/v1/admin/traffic/pool'
export const ADMIN_TRAFFIC_PURCHASES = '/v1/admin/traffic/purchases'
export const ADMIN_TRAFFIC_PACKAGES = '/v1/admin/traffic/packages'
export const ADMIN_TRAFFIC_PACKAGE = (id) => `/v1/admin/traffic/packages/${id}`
export const ADMIN_TRAFFIC_LEDGER = '/v1/admin/traffic/ledger'
  • Step 2: 路由增加 admin 子路由(放在 firmwares 附近)
{ path: 'traffic-ops', name: 'traffic-ops', component: () => import('../views/TrafficOpsView/TrafficOpsView.vue'), meta: { admin: true } },
  • Step 3: SideMenu 在「固件管理」后增加菜单项
<t-menu-item v-if="isAdmin" value="traffic-ops">
  <template #icon>
    <svg :class="s.icon"><use href="#i-wallet" /></svg>
  </template>
  流量运营
</t-menu-item>

(若 #i-wallet 语义冲突,可改用已有 #i-box / #i-download;不要新增 icon symbol 除非必要。)

  • Step 4: TopBar titles 增加
'traffic-ops': '流量运营',
  • Step 5: 创建骨架页 TrafficOpsView.vue
<template>
  <section class="operation-page">
    <header class="operation-page-header">
      <div>
        <h1>流量运营</h1>
        <span>管理云媒体流量套餐平台采购入池与账务流水</span>
      </div>
    </header>

    <div class="business-workspace">
      <t-tabs v-model="tab" class="business-tdesign-tabs">
        <t-tab-panel value="packages" label="套餐管理" :destroy-on-hide="false" />
        <t-tab-panel value="purchases" label="采购入池" :destroy-on-hide="false" />
        <t-tab-panel value="ledger" label="账务流水" :destroy-on-hide="false" />
      </t-tabs>

      <div v-show="tab === 'packages'">套餐管理占位</div>
      <div v-show="tab === 'purchases'">采购入池占位</div>
      <div v-show="tab === 'ledger'">账务流水占位</div>
    </div>
  </section>
</template>

<script setup>
import { ref, watch } from 'vue'

const tab = ref('packages')

watch(tab, (v) => {
  // 后续各 Tab 按需懒加载
  void v
})
</script>
  • Step 6: 冒烟

Run(本地已有 Vite 时):管理员登录 → 侧栏见「流量运营」→ 进入页见三 Tab;普通用户侧栏无此项,手动访问 /traffic-ops 应回 /monitor

  • Step 7: Commit
git add src/config/urls.js src/router/index.js src/layout/components/SideMenu.vue src/layout/components/TopBar.vue src/views/TrafficOpsView/TrafficOpsView.vue
git commit -m "$(cat <<'EOF'
feat: scaffold admin traffic ops page

Add /traffic-ops route, menu entry, urls, and tabbed page shell.
EOF
)"

Task 2: 套餐管理 Tab CRUD

Files:

  • Modify: src/views/TrafficOpsView/TrafficOpsView.vue

  • Step 1: 增加共享工具函数与套餐状态

import { onMounted, reactive, ref, watch } from 'vue'
import { useUiStore } from '@/stores/modules/uiStore'
import request from '@/utils/http'
import * as urls from '@/config/urls'

const ui = useUiStore()
const GB_BYTES = 1024 ** 3

function bytesToGb(n) {
  const v = Number(n || 0) / GB_BYTES
  return Number.isFinite(v) ? v : 0
}
function bytesToGbText(n) {
  return `${bytesToGb(n).toFixed(2)} GB`
}
function fmtTime(t) {
  if (!t) return '--'
  const d = new Date(t)
  if (Number.isNaN(d.getTime())) return '--'
  return d.toLocaleString('zh-CN', { hour12: false })
}

const tab = ref('packages')

// packages
const pkgList = ref([])
const pkgTotal = ref(0)
const pkgPageNum = ref(1)
const pkgPageSize = ref(10)
const pkgStatusFilter = ref('')
const pkgModalVisible = ref(false)
const pkgEditingId = ref(null)
const pkgFormError = ref('')
const pkgForm = reactive({
  code: '',
  name: '',
  amountGb: 1,
  price: 0,
  validityDays: 0,
  sort: 0,
  status: 'active'
})

const pkgStatusFilterOptions = [
  { label: '全部状态', value: '' },
  { label: '上架', value: 'active' },
  { label: '下架', value: 'inactive' }
]
const pkgStatusOptions = [
  { label: '上架', value: 'active' },
  { label: '下架', value: 'inactive' }
]
const pkgColumns = [
  { colKey: 'code', title: '编码', width: '12%' },
  { colKey: 'name', title: '名称', width: '14%' },
  { colKey: 'amountText', title: '额度', width: '10%' },
  { colKey: 'priceText', title: '价格', width: '8%' },
  { colKey: 'validityText', title: '有效期', width: '10%' },
  { colKey: 'status', title: '状态', width: '8%' },
  { colKey: 'sort', title: '排序', width: '6%' },
  { colKey: 'updatedAt', title: '更新时间', width: '16%' },
  { colKey: 'actions', title: '操作', width: '12%' }
]
  • Step 2: 实现 loadPackages / open / submit / disable
async function loadPackages() {
  try {
    const params = { pageNum: pkgPageNum.value, pageSize: pkgPageSize.value }
    if (pkgStatusFilter.value) params.status = pkgStatusFilter.value
    const page = await request.get(urls.ADMIN_TRAFFIC_PACKAGES, { params })
    pkgTotal.value = page?.total || 0
    pkgList.value = (page?.records || []).map((p) => ({
      id: String(p.id),
      code: p.code,
      name: p.name,
      amountText: bytesToGbText(p.amountBytes),
      priceText: ${Number(p.price || 0).toFixed(2)}`,
      validityText: Number(p.validityDays) > 0 ? `${p.validityDays} 天` : '长期有效',
      status: p.status,
      statusName: p.status === 'active' ? '上架' : '下架',
      statusClass: p.status === 'active' ? 'online' : 'offline',
      sort: p.sort ?? 0,
      updatedAt: fmtTime(p.updatedAt),
      raw: p
    }))
  } catch (e) {
    ui.toast(e.message || '加载套餐失败')
  }
}

function reloadPackages() {
  pkgPageNum.value = 1
  loadPackages()
}

function openPkgAdd() {
  Object.assign(pkgForm, { code: '', name: '', amountGb: 1, price: 0, validityDays: 0, sort: 0, status: 'active' })
  pkgEditingId.value = null
  pkgFormError.value = ''
  pkgModalVisible.value = true
}

function openPkgEdit(row) {
  const r = row.raw
  Object.assign(pkgForm, {
    code: r.code,
    name: r.name,
    amountGb: Number(bytesToGb(r.amountBytes).toFixed(4)) || 1,
    price: Number(r.price || 0),
    validityDays: Number(r.validityDays || 0),
    sort: Number(r.sort || 0),
    status: r.status || 'active'
  })
  pkgEditingId.value = row.id
  pkgFormError.value = ''
  pkgModalVisible.value = true
}

async function submitPackage() {
  pkgFormError.value = ''
  if (!pkgForm.name || (!pkgEditingId.value && !pkgForm.code)) {
    pkgFormError.value = '请填写编码和名称'
    return
  }
  if (!(Number(pkgForm.amountGb) > 0)) {
    pkgFormError.value = '额度必须大于 0 GB'
    return
  }
  const amountBytes = Math.round(Number(pkgForm.amountGb) * GB_BYTES)
  try {
    if (pkgEditingId.value) {
      // 后端 Update: price/validityDays 仅 >0 才写入;编辑时若用户未改就原样提交正值
      const payload = {
        name: pkgForm.name,
        amountBytes,
        sort: Number(pkgForm.sort || 0),
        status: pkgForm.status
      }
      if (Number(pkgForm.price) > 0) payload.price = Number(pkgForm.price)
      if (Number(pkgForm.validityDays) > 0) payload.validityDays = Number(pkgForm.validityDays)
      // 长期有效(0)无法通过 Update 写入;保持原值即可。若必须把有效期改成长期,需后端后续支持。
      await request.put(urls.ADMIN_TRAFFIC_PACKAGE(pkgEditingId.value), payload)
      ui.toast('套餐已更新')
    } else {
      await request.post(urls.ADMIN_TRAFFIC_PACKAGES, {
        code: pkgForm.code.trim(),
        name: pkgForm.name.trim(),
        amountBytes,
        price: Number(pkgForm.price || 0),
        validityDays: Number(pkgForm.validityDays || 0),
        sort: Number(pkgForm.sort || 0)
      })
      ui.toast('套餐已创建')
    }
    pkgModalVisible.value = false
    await loadPackages()
  } catch (e) {
    pkgFormError.value = e.message || '保存失败'
  }
}

async function disablePackage(row) {
  const ok = await ui.confirm(
    `确认下架套餐 ${row.name}?`,
    '下架后用户侧购买区不可见,之后可再编辑上架。'
  )
  if (!ok) return
  try {
    await request.delete(urls.ADMIN_TRAFFIC_PACKAGE(row.id))
    ui.toast('套餐已下架')
    await loadPackages()
  } catch (e) {
    ui.toast(e.message || '下架失败')
  }
}
  • Step 3: 模板替换 packages 占位

结构对齐 FirmwaresView:

  • asset-toolbar:状态筛选 @change="reloadPackages"
  • header page-actions:刷新 loadPackages、新建 openPkgAdd
  • t-table + #status / #actions(编辑、下架)
  • t-pagination 绑定 pkgPageNum / pkgTotal / pkgPageSize
  • t-dialog destroy-on-close:新建/编辑表单;编辑时 code disabled;编辑显示 status 选择

关键表单片段:

<label><span>编码<b>*</b></span>
  <t-input v-model="pkgForm.code" :disabled="!!pkgEditingId" placeholder="唯一编码,如 MEDIA_10G" />
</label>
<label><span>名称<b>*</b></span><t-input v-model="pkgForm.name" placeholder="套餐名称" /></label>
<div class="form-row">
  <label><span>额度(GB)<b>*</b></span>
    <t-input-number v-model="pkgForm.amountGb" theme="normal" :min="0.01" :step="1" style="width:100%" />
  </label>
  <label><span>价格(元)</span>
    <t-input-number v-model="pkgForm.price" theme="normal" :min="0" :step="0.01" style="width:100%" />
  </label>
</div>
<div class="form-row">
  <label><span>有效期(天)</span>
    <t-input-number v-model="pkgForm.validityDays" theme="normal" :min="0" style="width:100%" />
  </label>
  <label><span>排序</span>
    <t-input-number v-model="pkgForm.sort" theme="normal" :min="0" style="width:100%" />
  </label>
</div>
<label v-if="pkgEditingId"><span>状态</span>
  <t-select v-model="pkgForm.status" :options="pkgStatusOptions" style="width:100%" />
</label>
<div class="form-hint" v-if="!pkgEditingId">有效期填 0 表示长期有效</div>
  • Step 4: Tab 切换 / onMounted 加载
watch(tab, (v) => {
  if (v === 'packages') loadPackages()
})
onMounted(() => {
  if (tab.value === 'packages') loadPackages()
})
  • Step 5: 冒烟

管理员进入套餐 Tab:

  1. 列表能出已有套餐
  2. 新建一套餐 → 列表出现 → 用户账户购买区可见
  3. 编辑改名/价格 → 生效
  4. 下架 → 用户侧消失;再编辑 status=active 可上架
  • Step 6: Commit
git add src/views/TrafficOpsView/TrafficOpsView.vue
git commit -m "$(cat <<'EOF'
feat: add traffic package CRUD tab

Admin can create, edit, and disable cloud media traffic packages.
EOF
)"

Task 3: 采购入池 Tab

Files:

  • Modify: src/views/TrafficOpsView/TrafficOpsView.vue

  • Step 1: 增加 pool / purchases 状态与加载

const pool = ref(null) // null = 未建池
const poolMissing = ref(false)
const purchaseList = ref([])
const purchaseTotal = ref(0)
const purchasePageNum = ref(1)
const purchasePageSize = ref(10)
const purchaseProvider = ref('')
const purchaseStatus = ref('')
const purchaseModalVisible = ref(false)
const purchaseFormError = ref('')
const purchaseForm = reactive({
  batchNo: '',
  provider: '',
  totalGb: 1,
  unitCost: 0,
  totalCost: 0,
  expiresAt: '',
  remark: ''
})

const purchaseStatusOptions = [
  { label: '全部状态', value: '' },
  { label: '有效', value: 'active' },
  { label: '失效', value: 'inactive' }
]
const purchaseColumns = [
  { colKey: 'batchNo', title: '批次号', width: '12%' },
  { colKey: 'provider', title: '供应商', width: '10%' },
  { colKey: 'totalText', title: '总量', width: '10%' },
  { colKey: 'costText', title: '成本', width: '10%' },
  { colKey: 'expiresAt', title: '到期', width: '14%' },
  { colKey: 'statusName', title: '状态', width: '8%' },
  { colKey: 'remark', title: '备注', width: '14%', ellipsis: true },
  { colKey: 'createdAt', title: '创建时间', width: '16%' }
]

async function loadPool() {
  poolMissing.value = false
  try {
    pool.value = await request.get(urls.ADMIN_TRAFFIC_POOL)
  } catch (e) {
    if (e.status === 404 || /资源不存在|not found/i.test(e.message || '')) {
      pool.value = null
      poolMissing.value = true
      return
    }
    ui.toast(e.message || '加载资源池失败')
  }
}

async function loadPurchases() {
  try {
    const params = { pageNum: purchasePageNum.value, pageSize: purchasePageSize.value }
    if (purchaseProvider.value) params.provider = purchaseProvider.value
    if (purchaseStatus.value) params.status = purchaseStatus.value
    const page = await request.get(urls.ADMIN_TRAFFIC_PURCHASES, { params })
    purchaseTotal.value = page?.total || 0
    purchaseList.value = (page?.records || []).map((p) => ({
      id: String(p.id),
      batchNo: p.batchNo || '—',
      provider: p.provider || '—',
      totalText: bytesToGbText(p.totalBytes),
      costText: ${Number(p.totalCost || 0).toFixed(2)}`,
      expiresAt: fmtTime(p.expiresAt),
      statusName: p.status === 'active' ? '有效' : (p.status || '—'),
      remark: p.remark || '',
      createdAt: fmtTime(p.createdAt),
      raw: p
    }))
  } catch (e) {
    ui.toast(e.message || '加载采购列表失败')
  }
}

async function loadPurchasesTab() {
  await Promise.all([loadPool(), loadPurchases()])
}

function openPurchaseAdd() {
  Object.assign(purchaseForm, {
    batchNo: '', provider: '', totalGb: 1, unitCost: 0, totalCost: 0, expiresAt: '', remark: ''
  })
  purchaseFormError.value = ''
  purchaseModalVisible.value = true
}

async function submitPurchase() {
  purchaseFormError.value = ''
  if (!purchaseForm.batchNo.trim() || !purchaseForm.provider.trim()) {
    purchaseFormError.value = '请填写批次号和供应商'
    return
  }
  if (!(Number(purchaseForm.totalGb) > 0)) {
    purchaseFormError.value = '采购总量必须大于 0 GB'
    return
  }
  const payload = {
    batchNo: purchaseForm.batchNo.trim(),
    provider: purchaseForm.provider.trim(),
    totalBytes: Math.round(Number(purchaseForm.totalGb) * GB_BYTES),
    unitCost: Number(purchaseForm.unitCost || 0),
    totalCost: Number(purchaseForm.totalCost || 0),
    remark: purchaseForm.remark || ''
  }
  if (purchaseForm.expiresAt) {
    // t-date-picker 若返回字符串,转 ISO;后端 *time.Time
    payload.expiresAt = new Date(purchaseForm.expiresAt).toISOString()
  }
  try {
    await request.post(urls.ADMIN_TRAFFIC_PURCHASES, payload)
    ui.toast('采购已录入')
    purchaseModalVisible.value = false
    await loadPurchasesTab()
  } catch (e) {
    purchaseFormError.value = e.message || '录入失败'
  }
}
  • Step 2: 模板采购区
  1. 资源池卡片(poolMissing 时空态文案:「尚未建立资源池,首次录入采购后自动创建」)
  2. 工具栏:provider 输入 / status 筛选 / 刷新 / 录入采购
  3. 采购表 + 分页
  4. 新建弹窗字段对齐 CreateReq
  • Step 3: watch tab 时加载 purchases
watch(tab, (v) => {
  if (v === 'packages') loadPackages()
  if (v === 'purchases') loadPurchasesTab()
})
  • Step 4: 冒烟
  1. pool 不存在时卡片空态,不整页挂掉
  2. 录入 1GB 采购 → pool 出现/可用增加 → 列表有记录
  • Step 5: Commit
git add src/views/TrafficOpsView/TrafficOpsView.vue
git commit -m "$(cat <<'EOF'
feat: add traffic purchase intake tab

Show platform pool status and allow creating purchase batches.
EOF
)"

Task 4: 账务流水 Tab(只读)

Files:

  • Modify: src/views/TrafficOpsView/TrafficOpsView.vue

  • Step 1: 状态与加载

const ledgerList = ref([])
const ledgerTotal = ref(0)
const ledgerPageNum = ref(1)
const ledgerPageSize = ref(10)
const ledgerAccountType = ref('')
const ledgerAccountId = ref('')

const ledgerAccountTypeOptions = [
  { label: '全部账户', value: '' },
  { label: '用户', value: 'user' },
  { label: '平台', value: 'platform' }
]
const ledgerColumns = [
  { colKey: 'createdAt', title: '时间', width: '14%' },
  { colKey: 'accountType', title: '账户类型', width: '8%' },
  { colKey: 'accountId', title: '账户ID', width: '10%' },
  { colKey: 'direction', title: '方向', width: '7%' },
  { colKey: 'amountText', title: '金额', width: '12%' },
  { colKey: 'balanceText', title: '余额前→后', width: '14%' },
  { colKey: 'sourceType', title: '来源', width: '10%' },
  { colKey: 'sourceId', title: '来源ID', width: '10%', ellipsis: true },
  { colKey: 'idempotencyKey', title: '幂等键', width: '12%', ellipsis: true }
]

async function loadLedger() {
  try {
    const params = { pageNum: ledgerPageNum.value, pageSize: ledgerPageSize.value }
    if (ledgerAccountType.value) params.accountType = ledgerAccountType.value
    if (ledgerAccountId.value) params.accountId = Number(ledgerAccountId.value)
    const page = await request.get(urls.ADMIN_TRAFFIC_LEDGER, { params })
    ledgerTotal.value = page?.total || 0
    ledgerList.value = (page?.records || []).map((r) => ({
      id: String(r.id),
      createdAt: fmtTime(r.createdAt),
      accountType: r.accountType,
      accountId: String(r.accountId),
      direction: r.direction === 'credit' ? '入账' : (r.direction === 'debit' ? '扣减' : r.direction),
      amountText: `${bytesToGbText(r.amountBytes)} (${r.amountBytes})`,
      balanceText: `${bytesToGbText(r.balanceBefore)}${bytesToGbText(r.balanceAfter)}`,
      sourceType: r.sourceType || '—',
      sourceId: r.sourceId || '—',
      idempotencyKey: r.idempotencyKey || '—'
    }))
  } catch (e) {
    ui.toast(e.message || '加载流水失败')
  }
}

function reloadLedger() {
  ledgerPageNum.value = 1
  loadLedger()
}
  • Step 2: 模板

筛选栏 + 只读表 + 分页;empty="暂无流水";无写按钮。

  • Step 3: watch tab 加载 ledger
if (v === 'ledger') loadLedger()
  • Step 4: 冒烟

切换到流水 Tab 能分页;筛选 platform / user 不报错。

  • Step 5: Commit
git add src/views/TrafficOpsView/TrafficOpsView.vue
git commit -m "$(cat <<'EOF'
feat: add read-only traffic ledger tab

Allow admins to filter and page platform/user traffic ledger entries.
EOF
)"

Task 5: 端到端验收与收口

Files: 视冒烟结果微调 TrafficOpsView.vue / 样式

  • Step 1: 权限验收

  • 管理员:侧栏可见,三 Tab 可用

  • 普通用户:侧栏不可见;访问 /traffic-ops/monitor

  • Step 2: 业务闭环验收

  1. 新建 active 套餐 → 账户中心购买区出现
  2. 下架 → 购买区消失
  3. 录入采购 → pool 可用增加
  4. (若有支付流水)ledger 可见对应记录;没有也至少空态正常
  • Step 3: 已知后端约束确认

  • 编辑套餐时:price=0 / validityDays=0 不会被 Update 写入(payload 已按 >0 省略)

  • 创建时 validityDays=0 表示长期有效(OK)

  • pool 404 不炸页

  • Step 4: 最终 commit(若有样式/文案微调)

git add src/views/TrafficOpsView/TrafficOpsView.vue
git commit -m "$(cat <<'EOF'
fix: polish traffic ops admin page

Tighten empty states, filters, and GB display after smoke checks.
EOF
)"

(无改动则跳过。)


Self-review

Spec coverage

Spec 项 Task
独立 /traffic-ops + 菜单 + 顶栏 Task 1
套餐 CRUD / 下架 confirm / GB 输入 Task 2
采购入池 + pool 404 空态 Task 3
流水只读筛选分页 Task 4
权限双控与用户侧可见性回归 Task 5
不改后端 / 不做采购编辑删除 / 不做流水写入 全任务遵守

Placeholder scan: 无 TBD;关键 payload/模板已写出。

一致性: URL 常量名、Tab value、字段名与后端 JSON(camelCase)一致。

风险记入实现: Update 无法把 validityDays 改回 0;若产品强需求,另开后端任务,不在本计划偷偷改契约。