Files
ReelShortWeb/src/views/governance/ContentGovernanceView.vue
T

678 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 {
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: '', label: '全部状态', tag: 'info' },
{ value: 'pending', label: '待审核', tag: 'warning' },
{ value: 'published', label: '已发布', tag: 'success' },
{ value: 'rejected', label: '已驳回', tag: 'danger' },
{ value: 'deleted', label: '已删除', tag: 'info' }
]
const REPORT_STATUS = [
{ value: '', label: '全部状态', tag: 'info' },
{ value: 'pending', label: '待处理', tag: 'warning' },
{ value: 'handled', label: '已处理', tag: 'success' },
{ value: 'rejected', label: '已驳回', tag: 'info' }
]
const REPORT_TARGET_TYPES = {
COMMENT: { label: '评论', tag: 'warning' },
USER: { label: '用户', tag: 'success' },
DRAMA: { 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
}
function commentEpisodeText(row) {
if (row.episodeNo) return `第 ${row.episodeNo} 集`
return row.episodeId ? `分集 #${row.episodeId}` : '未关联分集'
}
function commentContentTooltip(row) {
return `短剧 ID:${row.dramaId} / 分集 ID:${row.episodeId || '未关联'}`
}
function reportTargetMeta(type) {
return REPORT_TARGET_TYPES[type] || { label: type || '-', tag: 'info' }
}
function reportTargetSummary(row) {
return row.targetSummary || `${reportTargetMeta(row.targetType).label} #${row.targetId}`
}
function reporterText(row) {
return row.reporterNickname || row.reporterUid || `#${row.reporterId}`
}
const comments = reactive({
rows: [],
total: 0,
loading: false,
page: 1,
size: 10,
status: '',
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: ''
})
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()
}
</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 label="短剧 / 分集" min-width="190">
<template #default="{ row }">
<el-tooltip :content="commentContentTooltip(row)" placement="top">
<div class="episode-table-title">
<b>{{ row.dramaTitle || `短剧 #${row.dramaId}` }}</b>
<small>{{ commentEpisodeText(row) }}</small>
<small v-if="row.episodeTitle">{{ row.episodeTitle }}</small>
</div>
</el-tooltip>
</template>
</el-table-column>
<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="230" 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"
type="danger"
size="small"
@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 label="对象" min-width="240">
<template #default="{ row }">
<el-tooltip placement="top">
<template #content>
<div>原始对象:#{{ row.targetId }}</div>
<div v-if="row.commentContent">评论内容:{{ row.commentContent }}</div>
</template>
<div class="episode-table-title">
<el-tag :type="reportTargetMeta(row.targetType).tag" size="small" effect="light">
{{ reportTargetMeta(row.targetType).label }}
</el-tag>
<b>{{ reportTargetSummary(row) }}</b>
</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="reasonCode" label="原因码" width="120" />
<el-table-column
prop="description"
label="补充说明"
min-width="180"
show-overflow-tooltip
/>
<el-table-column label="举报人" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ reporterText(row) }}</template>
</el-table-column>
<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" type="danger" size="small" @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="工单对象">
<div v-if="handleTarget" class="episode-table-title">
<el-tag :type="reportTargetMeta(handleTarget.targetType).tag" size="small" effect="light">
{{ reportTargetMeta(handleTarget.targetType).label }}
</el-tag>
<b>{{ reportTargetSummary(handleTarget) }}</b>
</div>
</el-form-item>
<el-form-item label="处理结论">
<el-radio-group v-model="handleForm.status">
<el-radio label="handled">举报成立</el-radio>
<el-radio label="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>