From 5d192c05413245d68512ac0722578bdcf378494c Mon Sep 17 00:00:00 2001
From: xiaosi <2652281683@qq.com>
Date: Tue, 1 Sep 2026 17:54:40 +0800
Subject: [PATCH] docs: add traffic ops admin implementation plan
Bite-sized tasks for TrafficOpsView package CRUD, purchase intake, and ledger.
---
.../plans/2026-09-01-traffic-ops-admin.md | 703 ++++++++++++++++++
1 file changed, 703 insertions(+)
create mode 100644 docs/superpowers/plans/2026-09-01-traffic-ops-admin.md
diff --git a/docs/superpowers/plans/2026-09-01-traffic-ops-admin.md b/docs/superpowers/plans/2026-09-01-traffic-ops-admin.md
new file mode 100644
index 0000000..c2cbe07
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-01-traffic-ops-admin.md
@@ -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
+
+
+
+
+ 流量运营
+
+```
+
+(若 `#i-wallet` 语义冲突,可改用已有 `#i-box` / `#i-download`;不要新增 icon symbol 除非必要。)
+
+- [ ] **Step 4: TopBar titles 增加**
+
+```js
+'traffic-ops': '流量运营',
+```
+
+- [ ] **Step 5: 创建骨架页 `TrafficOpsView.vue`**
+
+```vue
+
+
+
+
+
+
+
+
+
+
+
+
套餐管理占位
+
采购入池占位
+
账务流水占位
+
+
+
+
+
+```
+
+- [ ] **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
+
+
+
+
+
+
+
+
+
+
+
+有效期填 0 表示长期有效
+```
+
+- [ ] **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;若产品强需求,另开后端任务,不在本计划偷偷改契约。