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

@@ -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>