Browse Source

feat: add traffic package CRUD tab

Admin can create, edit, and disable cloud media traffic packages.
main
xiaosi 3 weeks ago
parent
commit
2e6fb76629
  1. 344
      src/views/TrafficOpsView/TrafficOpsView.vue

344
src/views/TrafficOpsView/TrafficOpsView.vue

@ -14,20 +14,356 @@
<t-tab-panel value="ledger" label="账务流水" :destroy-on-hide="false" />
</t-tabs>
<div v-show="tab === 'packages'">套餐管理占位</div>
<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'">采购入池占位</div>
<div v-show="tab === 'ledger'">账务流水占位</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>
</section>
</template>
<script setup>
import { ref, watch } from 'vue'
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 || '下架失败')
}
}
watch(tab, (v) => {
// Tab
void v
if (v === 'packages') loadPackages()
})
onMounted(() => {
if (tab.value === 'packages') loadPackages()
})
</script>
<style scoped>
.packages-panel {
flex: 1;
display: flex;
flex-direction: column;
min-height: 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>

Loading…
Cancel
Save