feat(web): 08 后台四个页面落地(内容治理 / 订单与权益 / 创作者审核 / 数据看板)

为什么:08 运营后台的接口在前几轮已经实现,但运营在界面上点不到——
评论审核、举报工单、敏感词维护、订单补发/退款、权益回收、金币补发、对账、
创作者入驻与内容提交审核、运营大盘,都只能靠 curl。本轮把这四个页面补齐,
08 才算真正闭环。

- 内容治理:评论审核(通过/驳回/删除)、举报工单处理(可联动封禁)、敏感词库增删改查
- 订单与权益:订单检索与流水、补发、退款、权益检索/人工补发/回收、金币流水与人工补币、对账运行与历史
- 创作者审核:入驻审核(通过/驳回原因码/冻结)+ 资质抽屉;内容提交审核(通过/驳回)
- 数据看板:overview 五组指标 + transcode 队列与失败任务,只读;取不到的指标显示「暂无数据源」而不是 0
- 新增 console.css(工具条/抽屉/看板卡片,含深色模式),四个页面共用;时间列统一 yyyy-MM-dd HH:mm:ss
- 按钮按权限点显隐(没有 *:update / *:audit 的角色看到的是只读态),表格列与后端字段一一对应
This commit is contained in:
Vicky committed 2026-10-15 13:28:46 +08:00
1 parent 44c2c46a68
commit f037d243cd
13 files changed
+2680

No files matched your search

+679
View File
@@ -0,0 +1,679 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import PageHeading from '@/views/components/PageHeading.vue'
import { useRbac } from '@/stores/rbac'
import { dateCell } from '@/utils/format'
import {
adjustCoinsApi,
compensateOrderApi,
grantEntitlementApi,
listOrderEventsApi,
listWalletLedgerApi,
pageEntitlementsApi,
pageOrdersApi,
pageReconcileApi,
refundOrderApi,
revokeEntitlementApi,
runReconcileApi
} from '@/api/admin/commerce'
const { can } = useRbac()
const canOrderUpdate = can('order:update')
const canEntitlementUpdate = can('entitlement:update')
const canWalletUpdate = can('wallet:update')
const canReconcile = can('reconcile:run')
const ORDER_STATUS = [
{ value: 'CREATED', label: '待支付', tag: 'info' },
{ value: 'PAID', label: '已支付', tag: 'warning' },
{ value: 'DELIVERED', label: '已交付', tag: 'success' },
{ value: 'REFUNDED', label: '已退款', tag: 'danger' },
{ value: 'CLOSED', label: '已关闭', tag: 'info' }
]
const ENTITLEMENT_STATUS = [
{ value: 'ACTIVE', label: '有效', tag: 'success' },
{ value: 'REVOKED', label: '已回收', tag: 'danger' },
{ value: 'EXPIRED', label: '已过期', tag: 'info' }
]
function meta(list, value) {
return list.find((item) => item.value === value) || { label: value || '-', tag: 'info' }
}
const activeTab = ref('orders')
const orders = reactive({
rows: [],
total: 0,
loading: false,
page: 1,
size: 10,
status: undefined,
orderNo: '',
userId: undefined
})
async function loadOrders() {
orders.loading = true
try {
const res = await pageOrdersApi({
pageIndex: orders.page,
pageSize: orders.size,
status: orders.status || undefined,
orderNo: orders.orderNo.trim() || undefined,
userId: orders.userId || undefined
})
orders.rows = res.data?.list || []
orders.total = res.data?.total || 0
} finally {
orders.loading = false
}
}
function queryOrders() {
orders.page = 1
loadOrders()
}
async function compensate(row) {
try {
await ElMessageBox.confirm(
`对订单 ${row.orderNo} 执行补发?已交付的订单会幂等返回。`,
'补发订单',
{
confirmButtonText: '确认补发',
cancelButtonText: '取消',
type: 'warning'
}
)
} catch {
return
}
await compensateOrderApi(row.orderNo)
ElMessage.success('补发完成')
await loadOrders()
}
async function refund(row) {
let reason = ''
try {
const { value } = await ElMessageBox.prompt(
'退款会回收权益并扣回金币,请填写原因',
'订单退款',
{
confirmButtonText: '确认退款',
cancelButtonText: '取消',
inputValidator: (val) => (val && val.trim() ? true : '退款原因不能为空')
}
)
reason = value
} catch {
return
}
await refundOrderApi(row.orderNo, reason.trim())
ElMessage.success('已退款并回收权益')
await loadOrders()
}
const eventsVisible = ref(false)
const eventsLoading = ref(false)
const events = ref([])
const eventsOrderNo = ref('')
async function openEvents(row) {
eventsVisible.value = true
eventsOrderNo.value = row.orderNo
eventsLoading.value = true
events.value = []
try {
events.value = (await listOrderEventsApi(row.orderNo)).data || []
} finally {
eventsLoading.value = false
}
}
const entitlements = reactive({
rows: [],
total: 0,
loading: false,
page: 1,
size: 10,
userId: undefined,
status: 'ACTIVE'
})
async function loadEntitlements() {
entitlements.loading = true
try {
const res = await pageEntitlementsApi({
pageIndex: entitlements.page,
pageSize: entitlements.size,
userId: entitlements.userId || undefined,
status: entitlements.status || undefined
})
entitlements.rows = res.data?.list || []
entitlements.total = res.data?.total || 0
} finally {
entitlements.loading = false
}
}
function queryEntitlements() {
entitlements.page = 1
loadEntitlements()
}
async function revokeEntitlement(row) {
try {
await ElMessageBox.confirm(
'回收后该用户立即失去这一集的播放权限(已下载的授权不受影响),确认?',
'回收权益',
{
confirmButtonText: '确认回收',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
}
)
} catch {
return
}
await revokeEntitlementApi(row.id, '后台回收')
ElMessage.success('权益已回收')
await loadEntitlements()
}
const grantVisible = ref(false)
const grantSaving = ref(false)
const grantForm = reactive({
userId: undefined,
dramaId: undefined,
episodeId: undefined,
type: 'EPISODE',
reason: ''
})
function openGrant() {
Object.assign(grantForm, {
userId: undefined,
dramaId: undefined,
episodeId: undefined,
type: 'EPISODE',
reason: ''
})
grantVisible.value = true
}
async function confirmGrant() {
if (!grantForm.userId || !grantForm.dramaId) return ElMessage.warning('请填写用户 ID 与短剧 ID')
grantSaving.value = true
try {
await grantEntitlementApi({
userId: grantForm.userId,
dramaId: grantForm.dramaId,
episodeId: grantForm.episodeId || 0,
type: grantForm.type
})
ElMessage.success('权益已补发')
grantVisible.value = false
await loadEntitlements()
} finally {
grantSaving.value = false
}
}
const ledger = reactive({ rows: [], loading: false, userId: undefined })
async function loadLedger() {
if (!ledger.userId) return ElMessage.warning('请先填写用户 ID')
ledger.loading = true
try {
ledger.rows = (await listWalletLedgerApi(ledger.userId, 50)).data || []
} finally {
ledger.loading = false
}
}
const coinVisible = ref(false)
const coinSaving = ref(false)
const coinForm = reactive({ userId: undefined, amount: 100, remark: '', idempotentKey: '' })
function openCoin() {
Object.assign(coinForm, { userId: ledger.userId, amount: 100, remark: '', idempotentKey: '' })
coinVisible.value = true
}
async function confirmCoin() {
if (!coinForm.userId || !coinForm.amount) return ElMessage.warning('请填写用户 ID 与变动金额')
coinSaving.value = true
try {
await adjustCoinsApi({
userId: coinForm.userId,
amount: coinForm.amount,
remark: coinForm.remark.trim() || '人工调整',
idempotentKey: coinForm.idempotentKey.trim() || undefined
})
ElMessage.success('已入流水,重复提交同一幂等键不会重复加币')
coinVisible.value = false
await loadLedger()
} finally {
coinSaving.value = false
}
}
const reconcile = reactive({ rows: [], total: 0, loading: false, page: 1, size: 10 })
async function loadReconcile() {
reconcile.loading = true
try {
const res = await pageReconcileApi({ pageIndex: reconcile.page, pageSize: reconcile.size })
reconcile.rows = res.data?.list || []
reconcile.total = res.data?.total || 0
} finally {
reconcile.loading = false
}
}
async function runReconcile() {
try {
await ElMessageBox.confirm(
'默认对账前一天;商店未接入时差异属预期,接入后必须归零。',
'手动对账',
{
confirmButtonText: '开始对账',
cancelButtonText: '取消'
}
)
} catch {
return
}
const res = await runReconcileApi({})
const data = res.data
ElMessage.success(`对账完成:差异 ${data?.diffCount ?? 0} 笔`)
await loadReconcile()
}
const tabLoaded = {}
onMounted(() => {
loadOrders()
tabLoaded.orders = true
})
function onTabChange(name) {
if (tabLoaded[name]) return
tabLoaded[name] = true
if (name === 'entitlements') loadEntitlements()
if (name === 'reconcile') loadReconcile()
}
</script>
<template>
<PageHeading
eyebrow="订单与权益"
title="订单管理"
description="订单补发与退款、权益回收与补发、金币流水与对账。"
/>
<section class="panel">
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane label="订单" name="orders">
<div class="console-toolbar">
<el-select
v-model="orders.status"
class="console-select"
clearable
placeholder="全部状态"
@change="queryOrders"
>
<el-option
v-for="item in ORDER_STATUS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
v-model="orders.orderNo"
class="console-keyword"
clearable
placeholder="订单号"
@keyup.enter="queryOrders"
/>
<el-input
v-model="orders.userId"
class="console-user"
clearable
placeholder="用户ID"
@keyup.enter="queryOrders"
/>
<el-button type="primary" @click="queryOrders">查询</el-button>
<span class="console-count">共 {{ orders.total }} 笔订单</span>
</div>
<el-table
v-loading="orders.loading"
:data="orders.rows"
row-key="id"
border
class="drama-el-table"
empty-text="没有符合条件的订单"
>
<el-table-column prop="orderNo" label="订单号" min-width="200" show-overflow-tooltip />
<el-table-column prop="userId" label="用户ID" width="90" />
<el-table-column prop="productName" label="商品" min-width="140" show-overflow-tooltip />
<el-table-column prop="platform" label="渠道" width="90" />
<el-table-column prop="amount" label="金额" width="100" />
<el-table-column prop="coinAmount" label="金币" width="90" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="meta(ORDER_STATUS, row.status).tag" effect="light">{{
meta(ORDER_STATUS, row.status).label
}}</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createdAt"
label="创建时间"
min-width="165"
show-overflow-tooltip
:formatter="dateCell"
/>
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button size="small" @click="openEvents(row)">流水</el-button>
<el-button
v-if="canOrderUpdate && row.status === 'PAID'"
type="primary"
size="small"
@click="compensate(row)"
>补发</el-button
>
<el-button
v-if="canOrderUpdate && (row.status === 'PAID' || row.status === 'DELIVERED')"
size="small"
class="danger-action"
@click="refund(row)"
>退款</el-button
>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="orders.page"
v-model:page-size="orders.size"
:page-sizes="[10, 20, 50]"
:total="orders.total"
layout="total, prev, pager, next, sizes"
background
@current-change="loadOrders"
@size-change="queryOrders"
/>
</div>
</el-tab-pane>
<el-tab-pane label="权益" name="entitlements">
<div class="console-toolbar">
<el-input
v-model="entitlements.userId"
class="console-user"
clearable
placeholder="用户ID"
@keyup.enter="queryEntitlements"
/>
<el-select
v-model="entitlements.status"
class="console-select"
clearable
placeholder="全部状态"
@change="queryEntitlements"
>
<el-option
v-for="item in ENTITLEMENT_STATUS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button type="primary" @click="queryEntitlements">查询</el-button>
<el-button v-if="canEntitlementUpdate" type="primary" @click="openGrant"
>人工补发权益</el-button
>
<span class="console-count">共 {{ entitlements.total }} 条权益</span>
</div>
<el-table
v-loading="entitlements.loading"
:data="entitlements.rows"
row-key="id"
border
class="drama-el-table"
empty-text="没有符合条件的权益"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="userId" label="用户ID" width="90" />
<el-table-column prop="dramaId" label="短剧ID" width="90" />
<el-table-column prop="episodeId" label="剧集ID" width="90" />
<el-table-column prop="type" label="类型" width="100" />
<el-table-column prop="source" label="来源" width="100" />
<el-table-column prop="orderNo" label="关联订单" min-width="160" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="meta(ENTITLEMENT_STATUS, row.status).tag" effect="light">{{
meta(ENTITLEMENT_STATUS, row.status).label
}}</el-tag>
</template>
</el-table-column>
<el-table-column
prop="startAt"
label="生效时间"
min-width="165"
show-overflow-tooltip
:formatter="dateCell"
/>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button
v-if="canEntitlementUpdate && row.status === 'ACTIVE'"
size="small"
class="danger-action"
@click="revokeEntitlement(row)"
>回收</el-button
>
<span v-else class="console-muted">—</span>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="entitlements.page"
v-model:page-size="entitlements.size"
:page-sizes="[10, 20, 50]"
:total="entitlements.total"
layout="total, prev, pager, next, sizes"
background
@current-change="loadEntitlements"
@size-change="queryEntitlements"
/>
</div>
</el-tab-pane>
<el-tab-pane label="金币流水" name="ledger">
<div class="console-toolbar">
<el-input
v-model="ledger.userId"
class="console-user"
clearable
placeholder="用户ID"
@keyup.enter="loadLedger"
/>
<el-button type="primary" @click="loadLedger">查询流水</el-button>
<el-button v-if="canWalletUpdate" type="primary" @click="openCoin">人工补币</el-button>
<span class="console-count">{{
ledger.rows.length ? `最近 ${ledger.rows.length} 条` : '按用户查询'
}}</span>
</div>
<el-table
v-loading="ledger.loading"
:data="ledger.rows"
row-key="id"
border
class="drama-el-table"
empty-text="输入用户 ID 后查询金币流水"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="变动" width="110">
<template #default="{ row }">
<span :class="row.changeAmount >= 0 ? 'console-credit' : 'console-debit'">
{{ row.changeAmount > 0 ? '+' : '' }}{{ row.changeAmount }}
</span>
</template>
</el-table-column>
<el-table-column prop="balanceAfter" label="变动后余额" width="120" />
<el-table-column prop="bizType" label="业务类型" width="120" />
<el-table-column prop="bizId" label="业务单号" min-width="160" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column prop="operatorId" label="操作人" width="90" />
<el-table-column
prop="createdAt"
label="时间"
min-width="165"
show-overflow-tooltip
:formatter="dateCell"
/>
</el-table>
</el-tab-pane>
<el-tab-pane label="对账" name="reconcile">
<div class="console-toolbar">
<el-button v-if="canReconcile" type="primary" @click="runReconcile">手动对账</el-button>
<el-button @click="loadReconcile">刷新</el-button>
<span class="console-count">共 {{ reconcile.total }} 次对账</span>
</div>
<el-alert
class="console-hint"
type="info"
:closable="false"
show-icon
title="默认核对前一天:本地订单与商店票据逐笔比对,差异必须归零;商店未接入前差异属预期。"
/>
<el-table
v-loading="reconcile.loading"
:data="reconcile.rows"
row-key="id"
border
class="drama-el-table"
empty-text="还没有对账记录"
>
<el-table-column prop="bizDate" label="对账日期" width="130" />
<el-table-column prop="platform" label="渠道" width="100" />
<el-table-column prop="totalCount" label="核对笔数" width="100" />
<el-table-column prop="matchedCount" label="一致笔数" width="100" />
<el-table-column label="差异笔数" width="100">
<template #default="{ row }">
<span :class="row.diffCount > 0 ? 'console-debit' : 'console-credit'">{{
row.diffCount
}}</span>
</template>
</el-table-column>
<el-table-column prop="amountSum" label="金额合计" width="110" />
<el-table-column prop="status" label="状态" width="100" />
<el-table-column prop="detail" label="明细" min-width="240" show-overflow-tooltip />
<el-table-column
prop="finishedAt"
label="完成时间"
min-width="165"
show-overflow-tooltip
:formatter="dateCell"
/>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="reconcile.page"
v-model:page-size="reconcile.size"
:page-sizes="[10, 20, 50]"
:total="reconcile.total"
layout="total, prev, pager, next, sizes"
background
@current-change="loadReconcile"
@size-change="loadReconcile"
/>
</div>
</el-tab-pane>
</el-tabs>
</section>
<el-drawer
v-model="eventsVisible"
:title="`订单流水 · ${eventsOrderNo}`"
size="640px"
class="console-drawer"
>
<el-table
v-loading="eventsLoading"
:data="events"
size="small"
border
empty-text="该订单暂无状态流转记录"
>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="event" label="事件" width="130" />
<el-table-column label="状态流转" width="190">
<template #default="{ row }"
>{{ row.fromStatus || '-' }} → {{ row.toStatus || '-' }}</template
>
</el-table-column>
<el-table-column prop="source" label="来源" width="100" />
<el-table-column prop="operatorId" label="操作人" width="90" />
<el-table-column prop="createdAt" label="时间" width="165" :formatter="dateCell" />
<el-table-column prop="payload" label="载荷" min-width="220" show-overflow-tooltip />
</el-table>
</el-drawer>
<el-dialog v-model="coinVisible" title="人工补币" width="480px">
<el-form label-position="top">
<el-form-item label="用户 ID" required>
<el-input v-model="coinForm.userId" placeholder="仅支持已存在的 App 用户" />
</el-form-item>
<el-form-item label="变动金额" required>
<el-input-number v-model="coinForm.amount" :step="10" class="console-number" />
<span class="console-muted">正数补发、负数扣回,单位为金币</span>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="coinForm.remark" maxlength="60" placeholder="例如:客服补偿" />
</el-form-item>
<el-form-item label="幂等键">
<el-input
v-model="coinForm.idempotentKey"
maxlength="64"
placeholder="可留空;同一工单重复提交请填同一个键"
/>
</el-form-item>
<el-alert
type="info"
:closable="false"
show-icon
title="补币会写入金币流水,请注意重复提交同一幂等键不会重复加币"
/>
</el-form>
<template #footer>
<el-button @click="coinVisible = false">取消</el-button>
<el-button type="primary" :loading="coinSaving" @click="confirmCoin">提交</el-button>
</template>
</el-dialog>
<el-dialog v-model="grantVisible" title="人工补发权益" width="480px">
<el-form label-position="top">
<el-form-item label="用户 ID" required>
<el-input v-model="grantForm.userId" />
</el-form-item>
<el-form-item label="短剧 ID" required>
<el-input v-model="grantForm.dramaId" />
</el-form-item>
<el-form-item label="剧集 ID">
<el-input v-model="grantForm.episodeId" placeholder="留空或填 0 表示整剧权益" />
</el-form-item>
<el-form-item label="权益类型">
<el-radio-group v-model="grantForm.type">
<el-radio value="EPISODE">单集</el-radio>
<el-radio value="DRAMA">整剧</el-radio>
</el-radio-group>
</el-form-item>
<el-alert
type="warning"
:closable="false"
show-icon
title="权益补发即时生效,用户下次取播放票据即可直接观看;补发动作与操作人记入后台操作日志"
/>
</el-form>
<template #footer>
<el-button @click="grantVisible = false">取消</el-button>
<el-button type="primary" :loading="grantSaving" @click="confirmGrant">提交</el-button>
</template>
</el-dialog>
</template>