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.
781 lines
24 KiB
781 lines
24 KiB
<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'" class="packages-panel">
|
|
<div class="asset-toolbar">
|
|
<div class="toolbar-group">
|
|
<t-select
|
|
v-model="pkgStatusFilter"
|
|
:options="pkgStatusFilterOptions"
|
|
placeholder="全部状态"
|
|
style="width: 140px"
|
|
@change="reloadPackages"
|
|
/>
|
|
</div>
|
|
<div class="page-actions">
|
|
<t-button shape="square" variant="outline" title="刷新" @click="loadPackages">
|
|
<svg><use href="#i-refresh" /></svg>
|
|
</t-button>
|
|
<t-button theme="primary" @click="openPkgAdd">
|
|
<svg><use href="#i-plus" /></svg>新建套餐
|
|
</t-button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="asset-table-wrap">
|
|
<t-table
|
|
row-key="id"
|
|
:data="pkgList"
|
|
:columns="pkgColumns"
|
|
empty="暂无流量套餐"
|
|
>
|
|
<template #status="{ row }">
|
|
<span class="table-status" :class="row.statusClass"><i></i>{{ row.statusName }}</span>
|
|
</template>
|
|
<template #actions="{ row }">
|
|
<div class="row-actions">
|
|
<t-button variant="text" theme="primary" @click="openPkgEdit(row)">编辑</t-button>
|
|
<t-button
|
|
v-if="row.status === 'active'"
|
|
variant="text"
|
|
theme="danger"
|
|
@click="disablePackage(row)"
|
|
>下架</t-button>
|
|
</div>
|
|
</template>
|
|
</t-table>
|
|
</div>
|
|
|
|
<div class="table-footer">
|
|
<t-pagination
|
|
v-model:current="pkgPageNum"
|
|
:total="pkgTotal"
|
|
:page-size="pkgPageSize"
|
|
@current-change="loadPackages"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-show="tab === 'purchases'" class="purchases-panel">
|
|
<div v-if="poolMissing" class="pool-empty">
|
|
尚未建立资源池,首次录入采购后自动创建
|
|
</div>
|
|
<div v-else-if="pool" class="business-metric-band pool-metrics">
|
|
<div>
|
|
<span>资源池总量</span>
|
|
<strong>{{ bytesToGbText(pool.totalBytes) }}</strong>
|
|
<small>{{ pool.totalBytes ?? 0 }} bytes</small>
|
|
</div>
|
|
<div>
|
|
<span>可用流量</span>
|
|
<strong>{{ bytesToGbText(pool.availableBytes) }}</strong>
|
|
<small>{{ pool.availableBytes ?? 0 }} bytes</small>
|
|
</div>
|
|
<div>
|
|
<span>池状态</span>
|
|
<strong>{{ pool.status === 'active' ? '有效' : (pool.status === 'inactive' ? '失效' : (pool.status || '—')) }}</strong>
|
|
<small>{{ pool.status === 'active' ? '有效' : (pool.status === 'inactive' ? '失效' : (pool.status || '—')) }}</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="asset-toolbar">
|
|
<div class="asset-search">
|
|
<t-input
|
|
v-model="purchaseProvider"
|
|
clearable
|
|
placeholder="按供应商筛选"
|
|
@keyup.enter="reloadPurchases"
|
|
@clear="reloadPurchases"
|
|
>
|
|
<template #prefix-icon><t-icon name="search" /></template>
|
|
</t-input>
|
|
</div>
|
|
<div class="toolbar-group">
|
|
<t-select
|
|
v-model="purchaseStatus"
|
|
:options="purchaseStatusOptions"
|
|
placeholder="全部状态"
|
|
style="width: 140px"
|
|
@change="reloadPurchases"
|
|
/>
|
|
</div>
|
|
<div class="page-actions">
|
|
<t-button shape="square" variant="outline" title="刷新" @click="loadPurchasesTab">
|
|
<svg><use href="#i-refresh" /></svg>
|
|
</t-button>
|
|
<t-button theme="primary" @click="openPurchaseAdd">
|
|
<svg><use href="#i-plus" /></svg>录入采购
|
|
</t-button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="asset-table-wrap">
|
|
<t-table
|
|
row-key="id"
|
|
:data="purchaseList"
|
|
:columns="purchaseColumns"
|
|
empty="暂无采购记录"
|
|
/>
|
|
</div>
|
|
|
|
<div class="table-footer">
|
|
<t-pagination
|
|
v-model:current="purchasePageNum"
|
|
:total="purchaseTotal"
|
|
:page-size="purchasePageSize"
|
|
@current-change="loadPurchases"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-show="tab === 'ledger'" class="ledger-panel">
|
|
<div class="asset-toolbar">
|
|
<div class="toolbar-group">
|
|
<t-select
|
|
v-model="ledgerAccountType"
|
|
:options="ledgerAccountTypeOptions"
|
|
placeholder="全部账户"
|
|
style="width: 140px"
|
|
@change="reloadLedger"
|
|
/>
|
|
</div>
|
|
<div class="asset-search">
|
|
<t-input
|
|
v-model="ledgerAccountId"
|
|
clearable
|
|
placeholder="按账户ID筛选"
|
|
@keyup.enter="reloadLedger"
|
|
@clear="reloadLedger"
|
|
>
|
|
<template #prefix-icon><t-icon name="search" /></template>
|
|
</t-input>
|
|
</div>
|
|
<div class="page-actions">
|
|
<t-button shape="square" variant="outline" title="刷新" @click="loadLedger">
|
|
<svg><use href="#i-refresh" /></svg>
|
|
</t-button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="asset-table-wrap">
|
|
<t-table
|
|
row-key="id"
|
|
:data="ledgerList"
|
|
:columns="ledgerColumns"
|
|
empty="暂无流水"
|
|
/>
|
|
</div>
|
|
|
|
<div class="table-footer">
|
|
<t-pagination
|
|
v-model:current="ledgerPageNum"
|
|
:total="ledgerTotal"
|
|
:page-size="ledgerPageSize"
|
|
@current-change="loadLedger"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<t-dialog
|
|
v-model:visible="pkgModalVisible"
|
|
:header="pkgEditingId ? '编辑套餐' : '新建套餐'"
|
|
width="520px"
|
|
destroy-on-close
|
|
dialog-class-name="add-dock-dialog"
|
|
:close-on-overlay-click="false"
|
|
placement="center"
|
|
>
|
|
<form novalidate @submit.prevent="submitPackage">
|
|
<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 v-if="!pkgEditingId" class="form-hint">有效期填 0 表示长期有效</div>
|
|
<div class="form-error">{{ pkgFormError }}</div>
|
|
</form>
|
|
<template #footer>
|
|
<t-button variant="outline" @click="pkgModalVisible = false">取消</t-button>
|
|
<t-button theme="primary" @click="submitPackage">
|
|
{{ pkgEditingId ? '保存' : '确认新建' }}
|
|
</t-button>
|
|
</template>
|
|
</t-dialog>
|
|
|
|
<t-dialog
|
|
v-model:visible="purchaseModalVisible"
|
|
header="录入采购"
|
|
width="520px"
|
|
destroy-on-close
|
|
dialog-class-name="add-dock-dialog"
|
|
:close-on-overlay-click="false"
|
|
placement="center"
|
|
>
|
|
<form novalidate @submit.prevent="submitPurchase">
|
|
<label>
|
|
<span>批次号<b>*</b></span>
|
|
<t-input v-model="purchaseForm.batchNo" placeholder="唯一批次号" />
|
|
</label>
|
|
<label>
|
|
<span>供应商<b>*</b></span>
|
|
<t-input v-model="purchaseForm.provider" placeholder="供应商名称" />
|
|
</label>
|
|
<div class="form-row">
|
|
<label>
|
|
<span>总量(GB)<b>*</b></span>
|
|
<t-input-number
|
|
v-model="purchaseForm.totalGb"
|
|
theme="normal"
|
|
:min="0.01"
|
|
:step="1"
|
|
style="width: 100%"
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>单价</span>
|
|
<t-input-number
|
|
v-model="purchaseForm.unitCost"
|
|
theme="normal"
|
|
:min="0"
|
|
:step="0.01"
|
|
style="width: 100%"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div class="form-row">
|
|
<label>
|
|
<span>总成本</span>
|
|
<t-input-number
|
|
v-model="purchaseForm.totalCost"
|
|
theme="normal"
|
|
:min="0"
|
|
:step="0.01"
|
|
style="width: 100%"
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>到期时间</span>
|
|
<t-input
|
|
v-model="purchaseForm.expiresAt"
|
|
placeholder="YYYY-MM-DDTHH:mm:ss"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label>
|
|
<span>备注</span>
|
|
<t-input v-model="purchaseForm.remark" placeholder="可选备注" />
|
|
</label>
|
|
<div class="form-error">{{ purchaseFormError }}</div>
|
|
</form>
|
|
<template #footer>
|
|
<t-button variant="outline" @click="purchaseModalVisible = false">取消</t-button>
|
|
<t-button theme="primary" @click="submitPurchase">确认录入</t-button>
|
|
</template>
|
|
</t-dialog>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup>
|
|
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')
|
|
|
|
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%' }
|
|
]
|
|
|
|
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 || '下架失败')
|
|
}
|
|
}
|
|
|
|
const pool = ref(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, { skipErrorToast: true })
|
|
} 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 === 'inactive' ? '失效' : (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 reloadPurchases() {
|
|
purchasePageNum.value = 1
|
|
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) {
|
|
const expires = new Date(purchaseForm.expiresAt)
|
|
if (Number.isNaN(expires.getTime())) {
|
|
purchaseFormError.value = '到期时间格式无效'
|
|
return
|
|
}
|
|
payload.expiresAt = expires.toISOString()
|
|
}
|
|
try {
|
|
await request.post(urls.ADMIN_TRAFFIC_PURCHASES, payload)
|
|
ui.toast('采购已录入')
|
|
purchaseModalVisible.value = false
|
|
await loadPurchasesTab()
|
|
} catch (e) {
|
|
purchaseFormError.value = e.message || '录入失败'
|
|
}
|
|
}
|
|
|
|
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
|
|
const rawId = String(ledgerAccountId.value || '').trim()
|
|
if (rawId) {
|
|
const id = Number(rawId)
|
|
if (!Number.isFinite(id) || id <= 0 || !Number.isInteger(id)) {
|
|
ui.toast('账户ID须为正整数')
|
|
return
|
|
}
|
|
params.accountId = id
|
|
}
|
|
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 === 'user' ? '用户' : (r.accountType === 'platform' ? '平台' : (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()
|
|
}
|
|
|
|
|
|
|
|
watch(tab, (v) => {
|
|
if (v === 'packages') loadPackages()
|
|
if (v === 'purchases') loadPurchasesTab()
|
|
if (v === 'ledger') loadLedger()
|
|
})
|
|
|
|
onMounted(() => {
|
|
if (tab.value === 'packages') loadPackages()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.packages-panel,
|
|
.purchases-panel,
|
|
.ledger-panel {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
.pool-metrics {
|
|
margin: 12px 16px 0;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
}
|
|
.pool-empty {
|
|
margin: 12px 16px 0;
|
|
padding: 14px 16px;
|
|
border: 1px dashed var(--line);
|
|
border-radius: 6px;
|
|
background: #fafbfc;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
}
|
|
.asset-search {
|
|
border: 0;
|
|
padding: 0;
|
|
height: auto;
|
|
}
|
|
.asset-search :deep(.t-input) {
|
|
width: 100%;
|
|
}
|
|
.toolbar-group :deep(.t-select) {
|
|
flex-shrink: 0;
|
|
}
|
|
.form-hint {
|
|
margin-top: 4px;
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
.add-dock-dialog .t-input,
|
|
.add-dock-dialog .t-input-number,
|
|
.add-dock-dialog .t-select {
|
|
width: 100%;
|
|
}
|
|
</style>
|
|
|