Files
ReelShortWeb/src/views/commerce/OrderManagementView.vue
T

683 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- Author: fruit.com.cn -->
<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: '取消',
confirmButtonClass: 'el-button--danger',
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')"
type="danger"
size="small"
@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'"
type="danger"
size="small"
@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 label="EPISODE">单集</el-radio>
<el-radio label="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>