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

+122
View File
@@ -0,0 +1,122 @@
import request from '@/axios'
import type { PageResult } from './types'
export interface OrderItem {
id: number
orderNo: string
userId: number
productCode: string
productName: string
type: string
platform: string
amount: number
currency: string
coinAmount: number
/** CREATED / PAID / DELIVERED / REFUNDED / CLOSED */
status: string
paidAt?: string
deliveredAt?: string
refundReason: string
failReason: string
createdAt?: string
}
export interface OrderEventItem {
id: number
orderNo: string
event: string
fromStatus: string
toStatus: string
source: string
payload: string
operatorId: number
createdAt?: string
}
export interface EntitlementItem {
id: number
userId: number
dramaId: number
episodeId: number
/** EPISODE / DRAMA */
type: string
/** ORDER / ADMIN / ACTIVITY */
source: string
/** ACTIVE / REVOKED / EXPIRED */
status: string
orderNo: string
startAt?: string
revokeReason: string
}
export interface LedgerItem {
id: number
userId: number
changeAmount: number
balanceAfter: number
bizType: string
bizId: string
remark: string
operatorId: number
createdAt?: string
}
export interface ReconcileItem {
id: number
bizDate: string
platform: string
totalCount: number
matchedCount: number
diffCount: number
amountSum: number
status: string
detail: string
finishedAt?: string
}
export const pageOrdersApi = (params: Record<string, unknown>) => {
return request.get<PageResult<OrderItem>>({ url: '/admin/orders/page', params })
}
export const listOrderEventsApi = (orderNo: string) => {
return request.get<OrderEventItem[]>({ url: `/admin/orders/${orderNo}/events` })
}
export const compensateOrderApi = (orderNo: string) => {
return request.post<OrderItem>({ url: `/admin/orders/${orderNo}/compensate` })
}
export const refundOrderApi = (orderNo: string, reason: string) => {
return request.post<OrderItem>({ url: `/admin/orders/${orderNo}/refund`, data: { reason } })
}
export const pageEntitlementsApi = (params: Record<string, unknown>) => {
return request.get<PageResult<EntitlementItem>>({ url: '/admin/entitlements/page', params })
}
export const grantEntitlementApi = (data: Record<string, unknown>) => {
return request.post<EntitlementItem>({ url: '/admin/entitlements', data })
}
export const revokeEntitlementApi = (id: number, reason: string) => {
return request.put<void>({ url: `/admin/entitlements/${id}/revoke`, params: { reason } })
}
export const listWalletLedgerApi = (userId: number, limit = 20) => {
return request.get<LedgerItem[]>({
url: `/admin/wallets/${userId}/ledger`,
params: { limit }
})
}
export const adjustCoinsApi = (data: Record<string, unknown>) => {
return request.post<LedgerItem>({ url: '/admin/wallets/coins', data })
}
export const pageReconcileApi = (params: Record<string, unknown>) => {
return request.get<PageResult<ReconcileItem>>({ url: '/admin/reconcile/page', params })
}
export const runReconcileApi = (params: Record<string, unknown>) => {
return request.post<ReconcileItem>({ url: '/admin/reconcile/run', params })
}
+72
View File
@@ -0,0 +1,72 @@
import request from '@/axios'
import type { PageResult } from './types'
export interface CreatorItem {
id: number
userId: number
code: string
name: string
/** INDIVIDUAL / COMPANY */
type: string
contactName: string
contactMobile: string
contactEmail: string
countryCode: string
/** pending / approved / rejected / frozen */
status: string
auditNote: string
auditBy: number
auditAt?: string
settlementCurrency: string
createdAt?: string
}
export interface CreatorDocumentItem {
id: number
creatorId: number
/** ID_CARD / BUSINESS_LICENSE / CONTRACT */
type: string
fileUrl: string
status: string
auditNote: string
createdAt?: string
}
export interface SubmissionItem {
id: number
creatorId: number
dramaId: number
/** CREATE / UPDATE / PUBLISH / OFFLINE */
type: string
/** DRAFT / SUBMITTED / REVIEWING / APPROVED / REJECTED / WITHDRAWN */
status: string
version: number
submitAt?: string
reviewerId: number
reviewAt?: string
reviewNote: string
rejectCode: string
createdAt?: string
}
export const pageCreatorsApi = (params: Record<string, unknown>) => {
return request.get<PageResult<CreatorItem>>({ url: '/admin/creators/page', params })
}
export const listCreatorDocumentsApi = (id: number) => {
return request.get<CreatorDocumentItem[]>({ url: `/admin/creators/${id}/documents` })
}
/** action: APPROVE / REJECT / FREEZE */
export const auditCreatorApi = (id: number, data: Record<string, unknown>) => {
return request.put<CreatorItem>({ url: `/admin/creators/${id}/audit`, data })
}
export const pageSubmissionsApi = (params: Record<string, unknown>) => {
return request.get<PageResult<SubmissionItem>>({ url: '/admin/submissions/page', params })
}
/** action: APPROVE / REJECT / WITHDRAW */
export const auditSubmissionApi = (id: number, data: Record<string, unknown>) => {
return request.put<SubmissionItem>({ url: `/admin/submissions/${id}/audit`, data })
}
+34
View File
@@ -0,0 +1,34 @@
import request from '@/axios'
/** 指标口径见后端 DashboardService 注释与 docs/api-phase1.md「2.8 数据看板」 */
export interface DashboardOverview {
generatedAt: string
user: Record<string, number>
content: Record<string, number>
playback: Record<string, number>
revenue: Record<string, number>
cost: Record<string, number | null>
notes: string[]
}
export interface TranscodeBoard {
generatedAt: string
queue: Record<string, number>
failedTasks: {
id: number
episodeId: number
status: string
retryCount: number
errorMsg: string
createdAt?: string
}[]
notes: string[]
}
export const getDashboardOverviewApi = () => {
return request.get<DashboardOverview>({ url: '/admin/dashboard/overview' })
}
export const getTranscodeBoardApi = () => {
return request.get<TranscodeBoard>({ url: '/admin/dashboard/transcode' })
}
+49
View File
@@ -0,0 +1,49 @@
import request from '@/axios'
import type { PageResult, CommentItem, ReportItem, SensitiveWordItem } from './types'
export const pageCommentsApi = (params: Record<string, unknown>) => {
return request.get<PageResult<CommentItem>>({ url: '/admin/comments/page', params })
}
export const listCommentAuditRecordsApi = (id: number) => {
return request.get<Record<string, unknown>[]>({
url: `/admin/comments/${id}/audit-records`
})
}
/** result: PASS / REJECT */
export const auditCommentApi = (id: number, data: Record<string, unknown>) => {
return request.put<void>({ url: `/admin/comments/${id}/audit`, data })
}
export const deleteCommentApi = (id: number) => {
return request.delete<void>({ url: `/admin/comments/${id}` })
}
export const pageReportsApi = (params: Record<string, unknown>) => {
return request.get<PageResult<ReportItem>>({ url: '/admin/reports/page', params })
}
/** status: handled / rejected;banTarget=true 时联动封禁被举报用户 */
export const handleReportApi = (id: number, data: Record<string, unknown>) => {
return request.put<void>({ url: `/admin/reports/${id}/handle`, data })
}
export const pageSensitiveWordsApi = (params: Record<string, unknown>) => {
return request.get<PageResult<SensitiveWordItem>>({
url: '/admin/sensitive-words/page',
params
})
}
export const createSensitiveWordApi = (data: Record<string, unknown>) => {
return request.post<number>({ url: '/admin/sensitive-words', data })
}
export const updateSensitiveWordApi = (id: number, data: Record<string, unknown>) => {
return request.put<void>({ url: `/admin/sensitive-words/${id}`, data })
}
export const deleteSensitiveWordApi = (id: number) => {
return request.delete<void>({ url: `/admin/sensitive-words/${id}` })
}
+171
View File
@@ -0,0 +1,171 @@
/**
* 运营台通用版式:内容治理 / 订单与权益 / 创作者审核 / 数据看板四个页面共用。
*
* 两条底线和 App 用户页一致:
* 1. 筛选控件窄窗口下换行,而不是被挤出可视区;
* 2. 卡片固定尺寸,指标位数变化时栅格不跳动。
*/
.console-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.console-toolbar .console-keyword {
flex: 1 1 240px;
min-width: 200px;
max-width: 320px;
}
.console-toolbar .console-select {
width: 160px;
flex: 0 0 160px;
}
.console-toolbar .console-user,
.console-toolbar .console-drama {
width: 150px;
flex: 0 0 150px;
}
.console-count {
margin-left: auto;
color: #858b95;
font-size: 13px;
white-space: nowrap;
}
.console-hint {
margin: 0 0 12px;
color: #858b95;
font-size: 13px;
line-height: 1.6;
}
/* el-alert 复用同一类名时,保留弹窗内的间距而不是当成段落文本 */
section .console-hint.el-alert {
margin-bottom: 14px;
}
.console-muted {
color: #858b95;
font-size: 13px;
}
.console-full {
width: 100%;
}
.console-number {
width: 180px;
}
/* 金币变动:正负一眼可辨,等宽数字让列对齐 */
.console-credit,
.console-debit {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.console-credit {
color: #4d866a;
}
.console-debit {
color: #b54848;
}
.console-drawer .el-drawer__header {
margin-bottom: 8px;
}
.console-drawer .el-table {
font-size: 12px;
}
.console-drawer .el-table .el-table__cell {
padding: 6px 0;
}
/* ---------- 数据看板 ---------- */
.board-group + .board-group {
margin-top: 20px;
}
.board-title {
margin: 0 0 10px;
color: #302d44;
font-size: 14px;
font-weight: 650;
}
.board-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.board-card {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 74px;
padding: 12px 14px;
border: 1px solid #e6e8eb;
border-radius: 6px;
background: #fff;
}
.board-label {
color: #727983;
font-size: 12px;
}
.board-value {
color: #303640;
font-size: 20px;
font-variant-numeric: tabular-nums;
line-height: 1.2;
overflow-wrap: anywhere;
}
.board-value-empty {
color: #9a97a8;
font-size: 13px;
font-weight: 400;
}
.board-notes {
margin-top: 18px;
}
.board-notes ul {
margin: 0;
padding-left: 18px;
line-height: 1.7;
}
.board-transcode {
margin-top: 16px;
}
html.dark .console-count,
html.dark .console-hint,
html.dark .console-muted,
html.dark .board-label {
color: var(--el-text-color-secondary);
}
html.dark .board-title,
html.dark .board-value {
color: var(--el-text-color-primary);
}
html.dark .board-card {
border-color: var(--el-border-color-light);
background: var(--el-bg-color-overlay);
}
+5
View File
@@ -2,4 +2,9 @@
@import './theme.css';
@import './typography.css';
@import './drama-management.css';
@import './banner-management.css';
@import './operation-log.css';
@import './app-user-management.css';
@import './console.css';
@import './layout.css';
@import './dark.css';
+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>
+234
View File
@@ -0,0 +1,234 @@
<script setup>
import { Search } from '@element-plus/icons-vue'
import PageHeading from '@/views/components/PageHeading.vue'
import { useRbac } from '@/stores/rbac'
import { dateCell } from '@/utils/format'
import {
CREATOR_STATUS,
CREATOR_TYPE,
creatorStatusMeta,
creatorTypeText,
useCreatorAudit
} from './composables/useCreatorAudit'
const { can } = useRbac()
const canAudit = can('creator:audit')
const {
rows,
total,
loading,
keyword,
statusFilter,
typeFilter,
currentPage,
pageSize,
auditVisible,
auditSaving,
auditTarget,
auditForm,
documentsVisible,
documentsLoading,
documents,
documentsTarget,
load,
query,
reset,
handleSizeChange,
openDocuments,
openAudit,
confirmAudit,
freeze
} = useCreatorAudit()
</script>
<template>
<PageHeading
eyebrow="创作者管理"
title="创作者入驻"
description="审核创作者主体与资质,通过后创作者才能提交内容。"
/>
<section class="panel">
<div class="console-toolbar">
<el-input
v-model="keyword"
class="console-keyword"
:prefix-icon="Search"
clearable
placeholder="搜索名称 / 编号 / 联系人 / 联系方式"
@keyup.enter="query"
@clear="query"
/>
<el-select
v-model="statusFilter"
class="console-select"
clearable
placeholder="全部状态"
@change="query"
>
<el-option
v-for="item in CREATOR_STATUS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="typeFilter"
class="console-select"
clearable
placeholder="全部类型"
@change="query"
>
<el-option
v-for="item in CREATOR_TYPE"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button type="primary" @click="query">查询</el-button>
<el-button @click="reset">重置</el-button>
<span class="console-count">共 {{ total }} 位创作者</span>
</div>
<el-table
v-loading="loading"
:data="rows"
row-key="id"
border
class="drama-el-table"
empty-text="没有符合条件的创作者"
>
<el-table-column
type="index"
label="序号"
width="70"
:index="(currentPage - 1) * pageSize + 1"
/>
<el-table-column prop="code" label="编号" min-width="120" show-overflow-tooltip />
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ creatorTypeText(row.type) }}</template>
</el-table-column>
<el-table-column prop="contactName" label="联系人" min-width="110" show-overflow-tooltip />
<el-table-column
prop="contactMobile"
label="联系方式"
min-width="140"
show-overflow-tooltip
/>
<el-table-column prop="countryCode" label="国家" width="80" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="creatorStatusMeta(row.status).tag" effect="light">
{{ creatorStatusMeta(row.status).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="auditAt"
label="审核时间"
min-width="165"
show-overflow-tooltip
:formatter="dateCell"
/>
<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 type="primary" size="small" @click="openDocuments(row)">资质</el-button>
<el-button v-if="canAudit" size="small" @click="openAudit(row, 'APPROVE')"
>审核</el-button
>
<el-button
v-if="canAudit && row.status === 'approved'"
size="small"
class="danger-action"
@click="freeze(row)"
>冻结</el-button
>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, prev, pager, next, sizes, jumper"
background
@current-change="load"
@size-change="handleSizeChange"
/>
</div>
</section>
<el-drawer v-model="documentsVisible" title="创作者资质" size="520px" class="console-drawer">
<p class="console-hint">
{{ documentsTarget ? `${documentsTarget.name}(${documentsTarget.code})` : '' }}
</p>
<el-table
v-loading="documentsLoading"
:data="documents"
size="small"
border
empty-text="没有上传资质"
>
<el-table-column prop="type" label="证件类型" width="140" />
<el-table-column label="文件" min-width="200">
<template #default="{ row }">
<el-link type="primary" :href="row.fileUrl" target="_blank">{{ row.fileUrl }}</el-link>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" />
</el-table>
</el-drawer>
<el-dialog v-model="auditVisible" title="创作者入驻审核" width="480px">
<el-form label-position="top">
<el-form-item label="目标创作者">
<el-input
:model-value="auditTarget ? `${auditTarget.name}(${auditTarget.code})` : ''"
disabled
/>
</el-form-item>
<el-form-item label="审核动作">
<el-radio-group v-model="auditForm.action">
<el-radio value="APPROVE">通过</el-radio>
<el-radio value="REJECT">驳回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="auditForm.action === 'REJECT'" label="驳回原因码">
<el-input
v-model="auditForm.rejectCode"
placeholder="例如:DOC_INCOMPLETE"
maxlength="32"
/>
</el-form-item>
<el-form-item :label="auditForm.action === 'REJECT' ? '驳回原因(必填)' : '备注(可选)'">
<el-input
v-model="auditForm.reason"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="auditVisible = false">取消</el-button>
<el-button type="primary" :loading="auditSaving" @click="confirmAudit">提交</el-button>
</template>
</el-dialog>
</template>
+201
View File
@@ -0,0 +1,201 @@
<script setup>
import PageHeading from '@/views/components/PageHeading.vue'
import { useRbac } from '@/stores/rbac'
import { dateCell } from '@/utils/format'
import {
SUBMISSION_STATUS,
SUBMISSION_TYPE,
submissionStatusMeta,
submissionTypeText,
useSubmissionAudit
} from './composables/useSubmissionAudit'
const { can } = useRbac()
const canAudit = can('submission:audit')
const {
rows,
total,
loading,
statusFilter,
typeFilter,
dramaId,
currentPage,
pageSize,
auditVisible,
auditSaving,
auditTarget,
auditForm,
load,
query,
reset,
handleSizeChange,
openAudit,
confirmAudit
} = useSubmissionAudit()
</script>
<template>
<PageHeading
eyebrow="创作者管理"
title="内容审核"
description="审核创作者提交的内容;通过后按提交类型落到短剧状态机(上线 / 下线)。"
/>
<section class="panel">
<div class="console-toolbar">
<el-select
v-model="statusFilter"
class="console-select"
clearable
placeholder="全部状态"
@change="query"
>
<el-option
v-for="item in SUBMISSION_STATUS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="typeFilter"
class="console-select"
clearable
placeholder="全部类型"
@change="query"
>
<el-option
v-for="item in SUBMISSION_TYPE"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
v-model="dramaId"
class="console-drama"
placeholder="按短剧 ID 过滤"
clearable
@keyup.enter="query"
/>
<el-button type="primary" @click="query">查询</el-button>
<el-button @click="reset">重置</el-button>
<span class="console-count">共 {{ total }} 条提交</span>
</div>
<el-table
v-loading="loading"
:data="rows"
row-key="id"
border
class="drama-el-table"
empty-text="没有符合条件的提交记录"
>
<el-table-column prop="id" label="提交ID" width="90" />
<el-table-column prop="dramaId" label="短剧ID" width="90" />
<el-table-column prop="creatorId" label="创作者ID" width="100" />
<el-table-column label="提交类型" width="110">
<template #default="{ row }">{{ submissionTypeText(row.type) }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="submissionStatusMeta(row.status).tag" effect="light">
{{ submissionStatusMeta(row.status).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="version" label="版本" width="80" />
<el-table-column
prop="submitAt"
label="提交时间"
min-width="165"
show-overflow-tooltip
:formatter="dateCell"
/>
<el-table-column
prop="reviewAt"
label="审核时间"
min-width="165"
show-overflow-tooltip
:formatter="dateCell"
/>
<el-table-column prop="reviewNote" label="审核说明" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button
v-if="canAudit"
type="primary"
size="small"
@click="openAudit(row, 'APPROVE')"
>审核</el-button
>
<el-button v-else size="small" disabled>只读</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, prev, pager, next, sizes, jumper"
background
@current-change="load"
@size-change="handleSizeChange"
/>
</div>
</section>
<el-dialog v-model="auditVisible" title="内容提交审核" width="480px">
<el-form label-position="top">
<el-form-item label="提交信息">
<el-input
:model-value="
auditTarget
? `提交 #${auditTarget.id} · 短剧 ${auditTarget.dramaId} · ${submissionTypeText(auditTarget.type)}`
: ''
"
disabled
/>
</el-form-item>
<el-form-item label="审核结果">
<el-radio-group v-model="auditForm.action">
<el-radio value="APPROVE">通过</el-radio>
<el-radio value="REJECT">驳回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="auditForm.action === 'REJECT'" label="驳回原因码">
<el-input
v-model="auditForm.rejectCode"
placeholder="例如:CONTENT_VIOLATION"
maxlength="32"
/>
</el-form-item>
<el-form-item :label="auditForm.action === 'REJECT' ? '驳回原因(必填)' : '备注(可选)'">
<el-input
v-model="auditForm.reason"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-alert
v-if="auditForm.action === 'APPROVE'"
type="info"
:closable="false"
show-icon
title="通过后按提交类型落到短剧状态机:申请上线会顺推至已上线,申请下线会下线"
/>
</el-form>
<template #footer>
<el-button @click="auditVisible = false">取消</el-button>
<el-button type="primary" :loading="auditSaving" @click="confirmAudit">提交</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,167 @@
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { auditCreatorApi, listCreatorDocumentsApi, pageCreatorsApi } from '@/api/admin/creator'
/** 与后端 CreatorService 的状态取值保持一致 */
export const CREATOR_STATUS = [
{ value: 'pending', label: '待审核', tag: 'warning' },
{ value: 'approved', label: '已通过', tag: 'success' },
{ value: 'rejected', label: '已驳回', tag: 'danger' },
{ value: 'frozen', label: '已冻结', tag: 'info' }
]
export const CREATOR_TYPE = [
{ value: 'INDIVIDUAL', label: '个人' },
{ value: 'COMPANY', label: '机构' }
]
export function creatorStatusMeta(status) {
return (
CREATOR_STATUS.find((item) => item.value === status) || { label: status || '-', tag: 'info' }
)
}
export function creatorTypeText(type) {
return CREATOR_TYPE.find((item) => item.value === type)?.label || type || '-'
}
export function useCreatorAudit() {
const rows = ref([])
const total = ref(0)
const loading = ref(false)
const keyword = ref('')
const statusFilter = ref(undefined)
const typeFilter = ref(undefined)
const currentPage = ref(1)
const pageSize = ref(10)
const auditVisible = ref(false)
const auditSaving = ref(false)
const auditTarget = ref(null)
const auditForm = reactive({ action: 'APPROVE', reason: '', rejectCode: '' })
const documentsVisible = ref(false)
const documentsLoading = ref(false)
const documents = ref([])
const documentsTarget = ref(null)
async function load() {
loading.value = true
try {
const res = await pageCreatorsApi({
pageIndex: currentPage.value,
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
status: statusFilter.value,
type: typeFilter.value
})
rows.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
function query() {
currentPage.value = 1
load()
}
function reset() {
keyword.value = ''
statusFilter.value = undefined
typeFilter.value = undefined
query()
}
function handleSizeChange() {
currentPage.value = 1
load()
}
async function openDocuments(row) {
documentsTarget.value = row
documentsVisible.value = true
documentsLoading.value = true
documents.value = []
try {
documents.value = (await listCreatorDocumentsApi(row.id)).data || []
} finally {
documentsLoading.value = false
}
}
function openAudit(row, action = 'APPROVE') {
auditTarget.value = row
auditForm.action = action
auditForm.reason = ''
auditForm.rejectCode = ''
auditVisible.value = true
}
async function confirmAudit() {
const row = auditTarget.value
if (!row) return
if (auditForm.action === 'REJECT' && !auditForm.reason.trim()) {
return ElMessage.warning('驳回必须填写原因')
}
auditSaving.value = true
try {
await auditCreatorApi(row.id, {
action: auditForm.action,
reason: auditForm.reason.trim(),
rejectCode: auditForm.rejectCode.trim()
})
ElMessage.success('审核已提交')
auditVisible.value = false
await load()
} finally {
auditSaving.value = false
}
}
/** 冻结是不可逆的运营动作,单独二次确认 */
async function freeze(row) {
try {
await ElMessageBox.confirm(`冻结后「${row.name}」将无法提交内容,确认冻结?`, '冻结创作者', {
confirmButtonText: '确认冻结',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
await auditCreatorApi(row.id, { action: 'FREEZE', reason: '运营冻结' })
ElMessage.success('已冻结')
await load()
}
onMounted(load)
return {
rows,
total,
loading,
keyword,
statusFilter,
typeFilter,
currentPage,
pageSize,
auditVisible,
auditSaving,
auditTarget,
auditForm,
documentsVisible,
documentsLoading,
documents,
documentsTarget,
load,
query,
reset,
handleSizeChange,
openDocuments,
openAudit,
confirmAudit,
freeze
}
}
@@ -0,0 +1,136 @@
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { auditSubmissionApi, pageSubmissionsApi } from '@/api/admin/creator'
export const SUBMISSION_STATUS = [
{ value: 'DRAFT', label: '草稿', tag: 'info' },
{ value: 'SUBMITTED', label: '已提交', tag: 'warning' },
{ value: 'REVIEWING', label: '审核中', tag: 'warning' },
{ value: 'APPROVED', label: '已通过', tag: 'success' },
{ value: 'REJECTED', label: '已驳回', tag: 'danger' },
{ value: 'WITHDRAWN', label: '已撤回', tag: 'info' }
]
export const SUBMISSION_TYPE = [
{ value: 'CREATE', label: '新建' },
{ value: 'UPDATE', label: '更新' },
{ value: 'PUBLISH', label: '申请上线' },
{ value: 'OFFLINE', label: '申请下线' }
]
export function submissionStatusMeta(status) {
return (
SUBMISSION_STATUS.find((item) => item.value === status) || {
label: status || '-',
tag: 'info'
}
)
}
export function submissionTypeText(type) {
return SUBMISSION_TYPE.find((item) => item.value === type)?.label || type || '-'
}
export function useSubmissionAudit() {
const rows = ref([])
const total = ref(0)
const loading = ref(false)
const statusFilter = ref('SUBMITTED')
const typeFilter = ref(undefined)
const dramaId = ref(undefined)
const currentPage = ref(1)
const pageSize = ref(10)
const auditVisible = ref(false)
const auditSaving = ref(false)
const auditTarget = ref(null)
const auditForm = reactive({ action: 'APPROVE', reason: '', rejectCode: '' })
async function load() {
loading.value = true
try {
const res = await pageSubmissionsApi({
pageIndex: currentPage.value,
pageSize: pageSize.value,
status: statusFilter.value,
type: typeFilter.value,
dramaId: dramaId.value || undefined
})
rows.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
function query() {
currentPage.value = 1
load()
}
function reset() {
statusFilter.value = 'SUBMITTED'
typeFilter.value = undefined
dramaId.value = undefined
query()
}
function handleSizeChange() {
currentPage.value = 1
load()
}
function openAudit(row, action = 'APPROVE') {
auditTarget.value = row
auditForm.action = action
auditForm.reason = ''
auditForm.rejectCode = ''
auditVisible.value = true
}
async function confirmAudit() {
const row = auditTarget.value
if (!row) return
if (auditForm.action === 'REJECT' && !auditForm.reason.trim()) {
return ElMessage.warning('驳回必须填写原因')
}
auditSaving.value = true
try {
await auditSubmissionApi(row.id, {
action: auditForm.action,
reason: auditForm.reason.trim(),
rejectCode: auditForm.rejectCode.trim()
})
ElMessage.success(
auditForm.action === 'APPROVE' ? '审核通过,已按类型落到短剧状态' : '审核结果已保存'
)
auditVisible.value = false
await load()
} finally {
auditSaving.value = false
}
}
onMounted(load)
return {
rows,
total,
loading,
statusFilter,
typeFilter,
dramaId,
currentPage,
pageSize,
auditVisible,
auditSaving,
auditTarget,
auditForm,
load,
query,
reset,
handleSizeChange,
openAudit,
confirmAudit
}
}
@@ -0,0 +1,180 @@
<script setup>
import { onMounted, ref } from 'vue'
import PageHeading from '@/views/components/PageHeading.vue'
import { getDashboardOverviewApi, getTranscodeBoardApi } from '@/api/admin/dashboard'
import { dateCell } from '@/utils/format'
/**
* 指标展示配置:key 与后端 /admin/dashboard/overview 返回的字段一一对应,
* 不在前端编字段;口径说明见后端 DashboardService 注释与 docs/api-phase1.md「2.8」。
*/
const GROUPS = [
{
key: 'user',
title: '用户',
metrics: [
{ key: 'total', label: '总用户数' },
{ key: 'newToday', label: '今日新增' },
{ key: 'new7d', label: '近 7 天新增' },
{ key: 'active7d', label: '近 7 天活跃' },
{ key: 'banned', label: '封禁' },
{ key: 'limited', label: '受限' },
{ key: 'deactivated', label: '已注销' }
]
},
{
key: 'content',
title: '内容',
metrics: [
{ key: 'dramaTotal', label: '短剧总数' },
{ key: 'dramaPublished', label: '已上线短剧' },
{ key: 'dramaReviewing', label: '待审短剧' },
{ key: 'dramaDraft', label: '草稿短剧' },
{ key: 'dramaOffline', label: '已下线短剧' },
{ key: 'episodeTotal', label: '剧集总数' },
{ key: 'episodePublished', label: '已上线剧集' },
{ key: 'episodeWithMedia', label: '有片源剧集' },
{ key: 'commentPending', label: '待审评论' },
{ key: 'reportPending', label: '待处理举报' }
]
},
{
key: 'playback',
title: '播放',
metrics: [
{ key: 'viewTotal', label: '累计观看' },
{ key: 'playTotal', label: '累计播放' },
{ key: 'watchedDramas', label: '看过的剧(去重)' },
{ key: 'finishedDramas', label: '看过且完播' },
{ key: 'finishRate', label: '完播率' },
{ key: 'playEvents7d', label: '近 7 天播放流水' },
{ key: 'playEventsTotal', label: '播放流水总量' }
]
},
{
key: 'revenue',
title: '收入',
metrics: [
{ key: 'orderTotal', label: '订单总数' },
{ key: 'orderDelivered', label: '已交付订单' },
{ key: 'orderRefunded', label: '已退款订单' },
{ key: 'paidAmount', label: '已支付金额' },
{ key: 'coinRecharged', label: '金币充值' },
{ key: 'coinConsumed', label: '金币消耗' },
{ key: 'coinBalance', label: '金币余额' },
{ key: 'entitlementActive', label: '有效权益' }
]
},
{
key: 'cost',
title: '成本',
metrics: [
{ key: 'transcodeQueued', label: '转码排队' },
{ key: 'transcodeRunning', label: '转码中' },
{ key: 'transcodeFailed', label: '转码失败' },
{ key: 'transcodeSuccess', label: '转码成功' },
{ key: 'renditions', label: '多码率产物' },
{ key: 'otpSent7d', label: '近 7 天验证码' },
{ key: 'cdnTrafficBytes', label: 'CDN 流量' }
]
}
]
const loading = ref(false)
const overview = ref(null)
const transcode = ref(null)
const lastUpdated = ref('')
async function load() {
loading.value = true
try {
const [board, queue] = await Promise.all([getDashboardOverviewApi(), getTranscodeBoardApi()])
overview.value = board.data || null
transcode.value = queue.data || null
lastUpdated.value = new Date().toLocaleString()
} finally {
loading.value = false
}
}
/** 取不到的指标后端返回 null,这里显示「暂无数据源」而不是 0 */
function display(value) {
if (value === null || value === undefined) return '暂无数据源'
if (typeof value === 'number' && !Number.isInteger(value)) return value.toFixed(3)
return value
}
function groupValue(groupKey, metricKey) {
return overview.value?.[groupKey]?.[metricKey]
}
onMounted(load)
</script>
<template>
<PageHeading
eyebrow="数据看板"
title="运营大盘"
description="用户、内容、播放、收入与成本总览,以及转码队列。"
>
<el-button :loading="loading" @click="load">刷新</el-button>
</PageHeading>
<section v-loading="loading" class="panel">
<p class="console-hint"
>数据时间:{{ lastUpdated }}(口径见每组的后端定义,缺数据源显示「暂无数据源」)</p
>
<div v-for="group in GROUPS" :key="group.key" class="board-group">
<h3 class="board-title">{{ group.title }}</h3>
<div class="board-grid">
<div v-for="metric in group.metrics" :key="metric.key" class="board-card">
<span class="board-label">{{ metric.label }}</span>
<span
class="board-value"
:class="{ 'board-value-empty': groupValue(group.key, metric.key) === null }"
>
{{ display(groupValue(group.key, metric.key)) }}
</span>
</div>
</div>
</div>
<el-alert
v-if="overview?.notes?.length"
class="board-notes"
type="info"
:closable="false"
show-icon
title="口径与缺失数据源说明"
>
<ul>
<li v-for="note in overview.notes" :key="note">{{ note }}</li>
</ul>
</el-alert>
</section>
<section class="panel board-transcode">
<h3 class="board-title">转码看板</h3>
<p class="console-hint">
{{ transcode?.notes?.join(';') || '' }}
</p>
<el-table
:data="transcode?.failedTasks || []"
border
class="drama-el-table"
empty-text="当前没有失败的转码任务"
>
<el-table-column prop="id" label="任务ID" width="90" />
<el-table-column prop="episodeId" label="剧集ID" width="90" />
<el-table-column prop="status" label="状态" width="110" />
<el-table-column prop="retryCount" label="重试次数" width="100" />
<el-table-column prop="errorMsg" label="失败原因" min-width="240" show-overflow-tooltip />
<el-table-column
prop="createdAt"
label="创建时间"
min-width="165"
show-overflow-tooltip
:formatter="dateCell"
/>
</el-table>
</section>
</template>
@@ -0,0 +1,630 @@
<script setup>
import { computed, 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 {
auditCommentApi,
createSensitiveWordApi,
deleteCommentApi,
deleteSensitiveWordApi,
handleReportApi,
pageCommentsApi,
pageReportsApi,
pageSensitiveWordsApi,
updateSensitiveWordApi
} from '@/api/admin/governance'
const { can } = useRbac()
const canCommentAudit = can('comment:audit')
const canReportHandle = can('report:handle')
const canWordUpdate = can('sensitive-word:update')
const activeTab = ref('comments')
const COMMENT_STATUS = [
{ value: 'pending', label: '待审核', tag: 'warning' },
{ value: 'published', label: '已发布', tag: 'success' },
{ value: 'rejected', label: '已驳回', tag: 'danger' },
{ value: 'deleted', label: '已删除', tag: 'info' }
]
const REPORT_STATUS = [
{ value: 'pending', label: '待处理', tag: 'warning' },
{ value: 'handled', label: '已处理', tag: 'success' },
{ value: 'rejected', label: '已驳回', tag: 'info' }
]
const WORD_LEVELS = [
{ value: 'BLOCK', label: '禁止发布' },
{ value: 'REVIEW', label: '转人工审核' },
{ value: 'REPLACE', label: '替换后放行' }
]
function meta(list, value) {
return list.find((item) => item.value === value) || { label: value || '-', tag: 'info' }
}
function levelText(level) {
return WORD_LEVELS.find((item) => item.value === level)?.label || level
}
const comments = reactive({
rows: [],
total: 0,
loading: false,
page: 1,
size: 10,
status: 'pending',
keyword: ''
})
async function loadComments() {
comments.loading = true
try {
const res = await pageCommentsApi({
pageIndex: comments.page,
pageSize: comments.size,
status: comments.status || undefined,
keyword: comments.keyword.trim() || undefined
})
comments.rows = res.data?.list || []
comments.total = res.data?.total || 0
} finally {
comments.loading = false
}
}
function queryComments() {
comments.page = 1
loadComments()
}
async function auditComment(row, result) {
if (result === 'REJECT') {
let reason = ''
try {
const { value } = await ElMessageBox.prompt('请填写驳回原因(会进入审核留证)', '驳回评论', {
confirmButtonText: '确认驳回',
cancelButtonText: '取消',
inputValidator: (val) => (val && val.trim() ? true : '驳回原因不能为空')
})
reason = value
} catch {
return
}
await auditCommentApi(row.id, {
result: 'REJECT',
reasonText: reason.trim(),
reasonCode: 'VIOLATION'
})
} else {
await auditCommentApi(row.id, { result: 'PASS' })
}
ElMessage.success(result === 'PASS' ? '评论已通过' : '评论已驳回')
await loadComments()
}
async function removeComment(row) {
try {
await ElMessageBox.confirm('删除后该评论对用户不可见,确认删除?', '删除评论', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
})
} catch {
return
}
await deleteCommentApi(row.id)
ElMessage.success('评论已删除')
await loadComments()
}
const reports = reactive({
rows: [],
total: 0,
loading: false,
page: 1,
size: 10,
status: 'pending'
})
async function loadReports() {
reports.loading = true
try {
const res = await pageReportsApi({
pageIndex: reports.page,
pageSize: reports.size,
status: reports.status || undefined
})
reports.rows = res.data?.list || []
reports.total = res.data?.total || 0
} finally {
reports.loading = false
}
}
function queryReports() {
reports.page = 1
loadReports()
}
const handleVisible = ref(false)
const handleSaving = ref(false)
const handleTarget = ref(null)
const handleForm = reactive({ status: 'handled', note: '', banTarget: false })
function openHandle(row) {
handleTarget.value = row
handleForm.status = 'handled'
handleForm.note = ''
handleForm.banTarget = false
handleVisible.value = true
}
async function confirmHandle() {
const row = handleTarget.value
if (!row) return
if (handleForm.status === 'handled' && !handleForm.note.trim()) {
return ElMessage.warning('结论成立时请填写处理说明')
}
handleSaving.value = true
try {
await handleReportApi(row.id, {
status: handleForm.status,
note: handleForm.note.trim(),
banTarget: handleForm.banTarget,
banReason: handleForm.note.trim()
})
ElMessage.success(handleForm.status === 'handled' ? '工单已处理' : '工单已驳回')
handleVisible.value = false
await loadReports()
} finally {
handleSaving.value = false
}
}
const words = reactive({
rows: [],
total: 0,
loading: false,
page: 1,
size: 10,
keyword: '',
level: undefined
})
async function loadWords() {
words.loading = true
try {
const res = await pageSensitiveWordsApi({
pageIndex: words.page,
pageSize: words.size,
keyword: words.keyword.trim() || undefined,
level: words.level || undefined
})
words.rows = res.data?.list || []
words.total = res.data?.total || 0
} finally {
words.loading = false
}
}
function queryWords() {
words.page = 1
loadWords()
}
const wordVisible = ref(false)
const wordSaving = ref(false)
const wordEditingId = ref(null)
const wordForm = reactive({
word: '',
category: '',
level: 'REVIEW',
replaceText: '*',
language: 'id-ID',
status: 1
})
function openWord(row) {
wordEditingId.value = row?.id ?? null
Object.assign(wordForm, {
word: row?.word ?? '',
category: row?.category ?? '',
level: row?.level ?? 'REVIEW',
replaceText: row?.replaceText ?? '*',
language: row?.language ?? 'id-ID',
status: row?.status ?? 1
})
wordVisible.value = true
}
async function saveWord() {
if (!wordForm.word.trim()) return ElMessage.warning('请输入敏感词')
wordSaving.value = true
try {
const payload = { ...wordForm, word: wordForm.word.trim() }
if (wordEditingId.value) {
await updateSensitiveWordApi(wordEditingId.value, payload)
} else {
await createSensitiveWordApi(payload)
}
ElMessage.success('已保存,词库即时生效')
wordVisible.value = false
await loadWords()
} finally {
wordSaving.value = false
}
}
async function removeWord(row) {
try {
await ElMessageBox.confirm(`确认从词库删除「${row.word}」?`, '删除敏感词', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
})
} catch {
return
}
await deleteSensitiveWordApi(row.id)
ElMessage.success('已删除')
await loadWords()
}
const tabLoaded = {}
onMounted(() => {
loadComments()
tabLoaded.comments = true
})
function onTabChange(name) {
if (tabLoaded[name]) return
tabLoaded[name] = true
if (name === 'reports') loadReports()
if (name === 'words') loadWords()
}
const handleTargetText = computed(() => {
const row = handleTarget.value
return row ? `${row.targetType} #${row.targetId}` : ''
})
</script>
<template>
<PageHeading
eyebrow="内容治理"
title="评论与举报"
description="审核评论、处理举报工单、维护敏感词库。"
/>
<section class="panel">
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane label="评论审核" name="comments">
<div class="console-toolbar">
<el-input
v-model="comments.keyword"
class="console-keyword"
clearable
placeholder="搜索评论内容"
@keyup.enter="queryComments"
@clear="queryComments"
/>
<el-select
v-model="comments.status"
class="console-select"
clearable
placeholder="全部状态"
@change="queryComments"
>
<el-option
v-for="item in COMMENT_STATUS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button type="primary" @click="queryComments">查询</el-button>
<span class="console-count">共 {{ comments.total }} 条</span>
</div>
<el-table
v-loading="comments.loading"
:data="comments.rows"
row-key="id"
border
class="drama-el-table"
empty-text="没有符合条件的评论"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="nickname" label="用户" min-width="120" show-overflow-tooltip />
<el-table-column prop="content" label="内容" min-width="220" show-overflow-tooltip />
<el-table-column prop="dramaId" label="短剧ID" width="90" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="meta(COMMENT_STATUS, row.status).tag" effect="light">{{
meta(COMMENT_STATUS, row.status).label
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="likeCount" label="点赞" width="80" />
<el-table-column prop="replyCount" label="回复" width="80" />
<el-table-column prop="reportCount" label="举报" width="80" />
<el-table-column
prop="createdAt"
label="时间"
min-width="165"
show-overflow-tooltip
:formatter="dateCell"
/>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button
v-if="canCommentAudit"
type="primary"
size="small"
@click="auditComment(row, 'PASS')"
>通过</el-button
>
<el-button v-if="canCommentAudit" size="small" @click="auditComment(row, 'REJECT')"
>驳回</el-button
>
<el-button
v-if="canCommentAudit"
size="small"
class="danger-action"
@click="removeComment(row)"
>删除</el-button
>
<el-button v-if="!canCommentAudit" size="small" disabled>只读</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="comments.page"
v-model:page-size="comments.size"
:page-sizes="[10, 20, 50]"
:total="comments.total"
layout="total, prev, pager, next, sizes"
background
@current-change="loadComments"
@size-change="queryComments"
/>
</div>
</el-tab-pane>
<el-tab-pane label="举报工单" name="reports">
<div class="console-toolbar">
<el-select
v-model="reports.status"
class="console-select"
clearable
placeholder="全部状态"
@change="queryReports"
>
<el-option
v-for="item in REPORT_STATUS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button type="primary" @click="queryReports">查询</el-button>
<span class="console-count">共 {{ reports.total }} 条</span>
</div>
<el-table
v-loading="reports.loading"
:data="reports.rows"
row-key="id"
border
class="drama-el-table"
empty-text="没有符合条件的工单"
>
<el-table-column prop="id" label="工单ID" width="90" />
<el-table-column prop="targetType" label="对象" width="100" />
<el-table-column prop="targetId" label="对象ID" width="90" />
<el-table-column prop="reasonCode" label="原因码" width="120" />
<el-table-column
prop="description"
label="补充说明"
min-width="180"
show-overflow-tooltip
/>
<el-table-column prop="reporterId" label="举报人" width="90" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="meta(REPORT_STATUS, row.status).tag" effect="light">{{
meta(REPORT_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="130" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button
v-if="canReportHandle && row.status === 'pending'"
type="primary"
size="small"
@click="openHandle(row)"
>处理</el-button
>
<span v-else class="console-muted">{{
row.status === 'pending' ? '只读' : '已处理'
}}</span>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="reports.page"
v-model:page-size="reports.size"
:page-sizes="[10, 20, 50]"
:total="reports.total"
layout="total, prev, pager, next, sizes"
background
@current-change="loadReports"
@size-change="queryReports"
/>
</div>
</el-tab-pane>
<el-tab-pane label="敏感词库" name="words">
<div class="console-toolbar">
<el-input
v-model="words.keyword"
class="console-keyword"
clearable
placeholder="搜索敏感词"
@keyup.enter="queryWords"
@clear="queryWords"
/>
<el-select
v-model="words.level"
class="console-select"
clearable
placeholder="全部级别"
@change="queryWords"
>
<el-option
v-for="item in WORD_LEVELS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button type="primary" @click="queryWords">查询</el-button>
<el-button v-if="canWordUpdate" type="primary" @click="openWord(null)"
>新增敏感词</el-button
>
<span class="console-count">共 {{ words.total }} 个词</span>
</div>
<el-table
v-loading="words.loading"
:data="words.rows"
row-key="id"
border
class="drama-el-table"
empty-text="词库为空"
>
<el-table-column prop="word" label="敏感词" min-width="160" show-overflow-tooltip />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column label="处置级别" width="140">
<template #default="{ row }">{{ levelText(row.level) }}</template>
</el-table-column>
<el-table-column prop="replaceText" label="替换为" width="100" />
<el-table-column prop="language" label="语言" width="100" />
<el-table-column label="启用" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" effect="light">{{
row.status === 1 ? '启用' : '停用'
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button v-if="canWordUpdate" type="primary" size="small" @click="openWord(row)"
>编辑</el-button
>
<el-button
v-if="canWordUpdate"
size="small"
class="danger-action"
@click="removeWord(row)"
>删除</el-button
>
<span v-if="!canWordUpdate" class="console-muted">只读</span>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="words.page"
v-model:page-size="words.size"
:page-sizes="[10, 20, 50]"
:total="words.total"
layout="total, prev, pager, next, sizes"
background
@current-change="loadWords"
@size-change="queryWords"
/>
</div>
</el-tab-pane>
</el-tabs>
</section>
<el-dialog v-model="handleVisible" title="处理举报工单" width="480px">
<el-form label-position="top">
<el-form-item label="工单对象">
<el-input :model-value="handleTargetText" disabled />
</el-form-item>
<el-form-item label="处理结论">
<el-radio-group v-model="handleForm.status">
<el-radio value="handled">举报成立</el-radio>
<el-radio value="rejected">举报不成立</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="处理说明">
<el-input
v-model="handleForm.note"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item v-if="handleForm.status === 'handled'">
<el-checkbox v-model="handleForm.banTarget">同时封禁被举报用户</el-checkbox>
</el-form-item>
<el-alert
v-if="handleForm.status === 'handled'"
type="warning"
:closable="false"
show-icon
title="结论成立会联动驳回被举报评论;勾选封禁会立即使该用户所有会话失效"
/>
</el-form>
<template #footer>
<el-button @click="handleVisible = false">取消</el-button>
<el-button type="primary" :loading="handleSaving" @click="confirmHandle">提交</el-button>
</template>
</el-dialog>
<el-dialog
v-model="wordVisible"
:title="wordEditingId ? '编辑敏感词' : '新增敏感词'"
width="460px"
>
<el-form label-position="top">
<el-form-item label="敏感词" required>
<el-input v-model="wordForm.word" maxlength="64" placeholder="例如:赌博" />
</el-form-item>
<el-form-item label="分类">
<el-input
v-model="wordForm.category"
maxlength="32"
placeholder="例如:AD / PORN / ABUSE"
/>
</el-form-item>
<el-form-item label="处置级别">
<el-select v-model="wordForm.level" class="console-full">
<el-option
v-for="item in WORD_LEVELS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="wordForm.level === 'REPLACE'" label="替换为">
<el-input v-model="wordForm.replaceText" maxlength="16" />
</el-form-item>
<el-form-item label="语言">
<el-input v-model="wordForm.language" maxlength="16" placeholder="id-ID" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="wordForm.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="wordVisible = false">取消</el-button>
<el-button type="primary" :loading="wordSaving" @click="saveWord">保存</el-button>
</template>
</el-dialog>
</template>