1 changed files with 703 additions and 0 deletions
@ -0,0 +1,703 @@ |
|||||
|
# 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 常量** |
||||
|
|
||||
|
```js |
||||
|
// 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 附近)** |
||||
|
|
||||
|
```js |
||||
|
{ path: 'traffic-ops', name: 'traffic-ops', component: () => import('../views/TrafficOpsView/TrafficOpsView.vue'), meta: { admin: true } }, |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 3: SideMenu 在「固件管理」后增加菜单项** |
||||
|
|
||||
|
```vue |
||||
|
<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 增加** |
||||
|
|
||||
|
```js |
||||
|
'traffic-ops': '流量运营', |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 5: 创建骨架页 `TrafficOpsView.vue`** |
||||
|
|
||||
|
```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** |
||||
|
|
||||
|
```bash |
||||
|
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: 增加共享工具函数与套餐状态** |
||||
|
|
||||
|
```js |
||||
|
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** |
||||
|
|
||||
|
```js |
||||
|
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 选择 |
||||
|
|
||||
|
关键表单片段: |
||||
|
|
||||
|
```vue |
||||
|
<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 加载** |
||||
|
|
||||
|
```js |
||||
|
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** |
||||
|
|
||||
|
```bash |
||||
|
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 状态与加载** |
||||
|
|
||||
|
```js |
||||
|
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** |
||||
|
|
||||
|
```js |
||||
|
watch(tab, (v) => { |
||||
|
if (v === 'packages') loadPackages() |
||||
|
if (v === 'purchases') loadPurchasesTab() |
||||
|
}) |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 4: 冒烟** |
||||
|
|
||||
|
1. pool 不存在时卡片空态,不整页挂掉 |
||||
|
2. 录入 1GB 采购 → pool 出现/可用增加 → 列表有记录 |
||||
|
|
||||
|
- [ ] **Step 5: Commit** |
||||
|
|
||||
|
```bash |
||||
|
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: 状态与加载** |
||||
|
|
||||
|
```js |
||||
|
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** |
||||
|
|
||||
|
```js |
||||
|
if (v === 'ledger') loadLedger() |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 4: 冒烟** |
||||
|
|
||||
|
切换到流水 Tab 能分页;筛选 `platform` / `user` 不报错。 |
||||
|
|
||||
|
- [ ] **Step 5: Commit** |
||||
|
|
||||
|
```bash |
||||
|
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(若有样式/文案微调)** |
||||
|
|
||||
|
```bash |
||||
|
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;若产品强需求,另开后端任务,不在本计划偷偷改契约。 |
||||
Loading…
Reference in new issue