feat: add content report governance

This commit is contained in:
2026-01-16 11:36:04 +08:00
parent 3af3c854c9
commit 609ca7b980
18 changed files with 2480 additions and 101 deletions

View File

@@ -190,5 +190,80 @@ export const ContentService = {
method: 'POST',
body: { reason }
});
},
async listContentReports({
page,
limit,
id,
tenant_id,
tenant_code,
tenant_name,
content_id,
content_title,
reporter_id,
reporter_name,
handled_by,
handled_by_name,
reason,
keyword,
status,
created_at_from,
created_at_to,
handled_at_from,
handled_at_to,
sortField,
sortOrder
} = {}) {
const iso = (d) => {
if (!d) return undefined;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
};
const query = {
page,
limit,
id,
tenant_id,
tenant_code,
tenant_name,
content_id,
content_title,
reporter_id,
reporter_name,
handled_by,
handled_by_name,
reason,
keyword,
status,
created_at_from: iso(created_at_from),
created_at_to: iso(created_at_to),
handled_at_from: iso(handled_at_from),
handled_at_to: iso(handled_at_to)
};
if (sortField && sortOrder) {
if (sortOrder === 1) query.asc = sortField;
if (sortOrder === -1) query.desc = sortField;
}
const data = await requestJson('/super/v1/content-reports', { query });
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
},
async processContentReport(id, { action, content_action, reason } = {}) {
if (!id) throw new Error('id is required');
return requestJson(`/super/v1/content-reports/${id}/process`, {
method: 'POST',
body: {
action,
content_action,
reason
}
});
}
};

View File

@@ -55,11 +55,43 @@ const commentCreatedAtTo = ref(null);
const commentSortField = ref('created_at');
const commentSortOrder = ref(-1);
const reportLoading = ref(false);
const reports = ref([]);
const reportTotalRecords = ref(0);
const reportPage = ref(1);
const reportRows = ref(10);
const reportID = ref(null);
const reportTenantID = ref(null);
const reportTenantCode = ref('');
const reportTenantName = ref('');
const reportContentID = ref(null);
const reportContentTitle = ref('');
const reportReporterID = ref(null);
const reportReporterName = ref('');
const reportHandledBy = ref(null);
const reportHandledByName = ref('');
const reportReason = ref('');
const reportKeyword = ref('');
const reportStatus = ref('pending');
const reportCreatedAtFrom = ref(null);
const reportCreatedAtTo = ref(null);
const reportHandledAtFrom = ref(null);
const reportHandledAtTo = ref(null);
const reportSortField = ref('created_at');
const reportSortOrder = ref(-1);
const commentDeleteDialogVisible = ref(false);
const commentDeleteLoading = ref(false);
const commentDeleteReason = ref('');
const commentDeleteTarget = ref(null);
const reportProcessDialogVisible = ref(false);
const reportProcessSubmitting = ref(false);
const reportProcessAction = ref('approve');
const reportProcessContentAction = ref('unpublish');
const reportProcessReason = ref('');
const reportProcessTarget = ref(null);
const reviewDialogVisible = ref(false);
const reviewSubmitting = ref(false);
const reviewAction = ref('approve');
@@ -92,6 +124,24 @@ const commentStatusOptions = [
{ label: '已删除', value: 'deleted' }
];
const reportStatusOptions = [
{ label: '全部', value: 'all' },
{ label: '待处理', value: 'pending' },
{ label: '已成立', value: 'approved' },
{ label: '已驳回', value: 'rejected' }
];
const reportActionOptions = [
{ label: '通过举报', value: 'approve' },
{ label: '驳回举报', value: 'reject' }
];
const reportContentActionOptions = [
{ label: '不处理内容', value: 'ignore' },
{ label: '下架内容', value: 'unpublish' },
{ label: '封禁内容', value: 'block' }
];
function getQueryValue(value) {
if (Array.isArray(value)) return value[0];
return value ?? null;
@@ -162,6 +212,30 @@ function resetCommentFilters() {
commentRows.value = 10;
}
function resetReportFilters() {
reportID.value = null;
reportTenantID.value = null;
reportTenantCode.value = '';
reportTenantName.value = '';
reportContentID.value = null;
reportContentTitle.value = '';
reportReporterID.value = null;
reportReporterName.value = '';
reportHandledBy.value = null;
reportHandledByName.value = '';
reportReason.value = '';
reportKeyword.value = '';
reportStatus.value = 'pending';
reportCreatedAtFrom.value = null;
reportCreatedAtTo.value = null;
reportHandledAtFrom.value = null;
reportHandledAtTo.value = null;
reportSortField.value = 'created_at';
reportSortOrder.value = -1;
reportPage.value = 1;
reportRows.value = 10;
}
function applyRouteQuery(query) {
resetFilters();
@@ -241,8 +315,51 @@ function formatCommentContent(value) {
return `${text.slice(0, 60)}...`;
}
function getReportStatusSeverity(value) {
switch (value) {
case 'approved':
return 'success';
case 'rejected':
return 'secondary';
case 'pending':
default:
return 'warn';
}
}
function getReportStatusLabel(value) {
switch (value) {
case 'approved':
return '已成立';
case 'rejected':
return '已驳回';
case 'pending':
default:
return '待处理';
}
}
function formatReportDetail(value) {
const text = String(value || '');
if (text.length <= 80) return text || '-';
return `${text.slice(0, 80)}...`;
}
function getReportActionLabel(value) {
switch (value) {
case 'block':
return '封禁内容';
case 'unpublish':
return '下架内容';
case 'ignore':
default:
return '不处理内容';
}
}
const selectedCount = computed(() => selectedContents.value.length);
const reviewTargetCount = computed(() => reviewTargetIDs.value.length);
const reportProcessNeedsContentAction = computed(() => reportProcessAction.value === 'approve');
async function loadContents() {
loading.value = true;
@@ -430,6 +547,100 @@ async function confirmCommentDelete() {
}
}
async function loadReports() {
reportLoading.value = true;
try {
const result = await ContentService.listContentReports({
page: reportPage.value,
limit: reportRows.value,
id: reportID.value || undefined,
tenant_id: reportTenantID.value || undefined,
tenant_code: reportTenantCode.value,
tenant_name: reportTenantName.value,
content_id: reportContentID.value || undefined,
content_title: reportContentTitle.value,
reporter_id: reportReporterID.value || undefined,
reporter_name: reportReporterName.value,
handled_by: reportHandledBy.value || undefined,
handled_by_name: reportHandledByName.value,
reason: reportReason.value,
keyword: reportKeyword.value,
status: reportStatus.value || undefined,
created_at_from: reportCreatedAtFrom.value || undefined,
created_at_to: reportCreatedAtTo.value || undefined,
handled_at_from: reportHandledAtFrom.value || undefined,
handled_at_to: reportHandledAtTo.value || undefined,
sortField: reportSortField.value,
sortOrder: reportSortOrder.value
});
reports.value = result.items;
reportTotalRecords.value = result.total;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载举报列表', life: 4000 });
} finally {
reportLoading.value = false;
}
}
function onReportSearch() {
reportPage.value = 1;
loadReports();
}
function onReportReset() {
resetReportFilters();
loadReports();
}
function onReportPage(event) {
reportPage.value = (event.page ?? 0) + 1;
reportRows.value = event.rows ?? reportRows.value;
loadReports();
}
function onReportSort(event) {
reportSortField.value = event.sortField ?? reportSortField.value;
reportSortOrder.value = event.sortOrder ?? reportSortOrder.value;
loadReports();
}
function openReportProcessDialog(report) {
reportProcessTarget.value = report;
reportProcessAction.value = 'approve';
reportProcessContentAction.value = 'unpublish';
reportProcessReason.value = '';
reportProcessDialogVisible.value = true;
}
async function confirmReportProcess() {
const id = Number(reportProcessTarget.value?.id ?? 0);
if (!id) return;
const action = reportProcessAction.value;
const contentAction = action === 'approve' ? reportProcessContentAction.value : 'ignore';
const reason = reportProcessReason.value.trim();
if (action === 'reject' && !reason) {
toast.add({ severity: 'warn', summary: '请输入原因', detail: '驳回举报时需填写原因', life: 3000 });
return;
}
reportProcessSubmitting.value = true;
try {
await ContentService.processContentReport(id, {
action,
content_action: contentAction,
reason: reason || undefined
});
toast.add({ severity: 'success', summary: '已处理', detail: `举报ID: ${id}`, life: 3000 });
reportProcessDialogVisible.value = false;
await loadReports();
} catch (error) {
toast.add({ severity: 'error', summary: '处理失败', detail: error?.message || '无法处理举报', life: 4000 });
} finally {
reportProcessSubmitting.value = false;
}
}
const unpublishDialogVisible = ref(false);
const unpublishLoading = ref(false);
const unpublishItem = ref(null);
@@ -465,6 +676,8 @@ watch(
(value) => {
if (value === 'comments') {
loadComments();
} else if (value === 'reports') {
loadReports();
} else if (value === 'contents') {
loadContents();
}
@@ -497,6 +710,7 @@ watch(
<TabList>
<Tab value="contents">内容列表</Tab>
<Tab value="comments">评论治理</Tab>
<Tab value="reports">举报治理</Tab>
</TabList>
<TabPanels>
<TabPanel value="contents">
@@ -792,6 +1006,167 @@ watch(
</DataTable>
</div>
</TabPanel>
<TabPanel value="reports">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<span class="font-medium">举报列表</span>
<span class="text-muted-color">跨租户内容举报处理</span>
</div>
<Button label="刷新" icon="pi pi-refresh" severity="secondary" @click="loadReports" :disabled="reportLoading" />
</div>
<div class="flex flex-col gap-4">
<SearchPanel :loading="reportLoading" @search="onReportSearch" @reset="onReportReset">
<SearchField label="ReportID">
<InputNumber v-model="reportID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="TenantID">
<InputNumber v-model="reportTenantID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="TenantCode">
<InputText v-model="reportTenantCode" placeholder="模糊匹配" class="w-full" @keyup.enter="onReportSearch" />
</SearchField>
<SearchField label="TenantName">
<InputText v-model="reportTenantName" placeholder="模糊匹配" class="w-full" @keyup.enter="onReportSearch" />
</SearchField>
<SearchField label="ContentID">
<InputNumber v-model="reportContentID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="ContentTitle">
<InputText v-model="reportContentTitle" placeholder="内容标题" class="w-full" @keyup.enter="onReportSearch" />
</SearchField>
<SearchField label="ReporterID">
<InputNumber v-model="reportReporterID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="ReporterName">
<InputText v-model="reportReporterName" placeholder="模糊匹配" class="w-full" @keyup.enter="onReportSearch" />
</SearchField>
<SearchField label="处理人ID">
<InputNumber v-model="reportHandledBy" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="处理人">
<InputText v-model="reportHandledByName" placeholder="模糊匹配" class="w-full" @keyup.enter="onReportSearch" />
</SearchField>
<SearchField label="原因">
<InputText v-model="reportReason" placeholder="举报原因" class="w-full" @keyup.enter="onReportSearch" />
</SearchField>
<SearchField label="关键字">
<InputText v-model="reportKeyword" placeholder="举报描述关键字" class="w-full" @keyup.enter="onReportSearch" />
</SearchField>
<SearchField label="状态">
<Select v-model="reportStatus" :options="reportStatusOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
</SearchField>
<SearchField label="举报时间 From">
<DatePicker v-model="reportCreatedAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="举报时间 To">
<DatePicker v-model="reportCreatedAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
<SearchField label="处理时间 From">
<DatePicker v-model="reportHandledAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="处理时间 To">
<DatePicker v-model="reportHandledAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
</SearchPanel>
<DataTable
:value="reports"
dataKey="id"
:loading="reportLoading"
lazy
:paginator="true"
:rows="reportRows"
:totalRecords="reportTotalRecords"
:first="(reportPage - 1) * reportRows"
:rowsPerPageOptions="[10, 20, 50, 100]"
sortMode="single"
:sortField="reportSortField"
:sortOrder="reportSortOrder"
@page="onReportPage"
@sort="onReportSort"
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
scrollable
scrollHeight="640px"
responsiveLayout="scroll"
>
<Column field="id" header="举报ID" sortable style="min-width: 8rem" />
<Column header="内容" style="min-width: 20rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium truncate max-w-[260px]">{{ data?.content_title || '-' }}</span>
<span class="text-xs text-muted-color">ContentID: {{ data?.content_id ?? '-' }}</span>
<Tag v-if="data?.content_status" :value="data.content_status" :severity="getContentStatusSeverity(data?.content_status)" class="mt-1 w-fit" />
</div>
</template>
</Column>
<Column header="租户" style="min-width: 16rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data?.tenant_name || '-' }}</span>
<span class="text-xs text-muted-color">Code: {{ data?.tenant_code || '-' }} / ID: {{ data?.tenant_id ?? '-' }}</span>
</div>
</template>
</Column>
<Column header="作者" style="min-width: 14rem">
<template #body="{ data }">
<router-link v-if="(data?.content_owner_id ?? 0) > 0" class="inline-flex items-center gap-1 font-medium text-primary hover:underline" :to="`/superadmin/users/${data.content_owner_id}`">
<span class="truncate max-w-[200px]">{{ data?.content_owner_name || `ID:${data?.content_owner_id ?? '-'}` }}</span>
<i class="pi pi-external-link text-xs" />
</router-link>
<span v-else class="font-medium text-muted-color">-</span>
<div class="text-xs text-muted-color">ID: {{ data?.content_owner_id ?? '-' }}</div>
</template>
</Column>
<Column header="举报人" style="min-width: 14rem">
<template #body="{ data }">
<router-link v-if="(data?.reporter_id ?? 0) > 0" class="inline-flex items-center gap-1 font-medium text-primary hover:underline" :to="`/superadmin/users/${data.reporter_id}`">
<span class="truncate max-w-[200px]">{{ data?.reporter_name || `ID:${data?.reporter_id ?? '-'}` }}</span>
<i class="pi pi-external-link text-xs" />
</router-link>
<span v-else class="font-medium text-muted-color">-</span>
<div class="text-xs text-muted-color">ID: {{ data?.reporter_id ?? '-' }}</div>
</template>
</Column>
<Column header="原因" style="min-width: 12rem">
<template #body="{ data }">
<span class="text-sm">{{ data?.reason || '-' }}</span>
</template>
</Column>
<Column header="描述" style="min-width: 22rem">
<template #body="{ data }">
<span class="text-sm">{{ formatReportDetail(data?.detail) }}</span>
</template>
</Column>
<Column field="status" header="状态" sortable style="min-width: 10rem">
<template #body="{ data }">
<Tag :value="getReportStatusLabel(data?.status)" :severity="getReportStatusSeverity(data?.status)" />
</template>
</Column>
<Column field="created_at" header="举报时间" sortable style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data?.created_at) }}
</template>
</Column>
<Column header="处理结果" style="min-width: 20rem">
<template #body="{ data }">
<div class="flex flex-col gap-1">
<span class="text-sm">{{ data?.handled_action ? getReportActionLabel(data?.handled_action) : '-' }}</span>
<span class="text-xs text-muted-color">处理人: {{ data?.handled_by_name || '-' }}</span>
<span class="text-xs text-muted-color">处理时间: {{ formatDate(data?.handled_at) }}</span>
<span v-if="data?.handled_reason" class="text-xs text-muted-color truncate max-w-[260px]">说明: {{ data?.handled_reason }}</span>
</div>
</template>
</Column>
<Column header="操作" style="min-width: 10rem">
<template #body="{ data }">
<Button label="处理" icon="pi pi-shield" text size="small" class="p-0" :disabled="data?.status !== 'pending'" @click="openReportProcessDialog(data)" />
</template>
</Column>
</DataTable>
</div>
</TabPanel>
</TabPanels>
</Tabs>
</div>
@@ -838,6 +1213,35 @@ watch(
</template>
</Dialog>
<Dialog v-model:visible="reportProcessDialogVisible" :modal="true" :style="{ width: '560px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">处理举报</span>
<span class="text-muted-color truncate max-w-[280px]">{{ reportProcessTarget?.content_title ?? '-' }}</span>
</div>
</template>
<div class="flex flex-col gap-4">
<div class="text-sm text-muted-color">举报人{{ reportProcessTarget?.reporter_name || '-' }} / 原因{{ reportProcessTarget?.reason || '-' }}</div>
<div>
<label class="block font-medium mb-2">处理动作</label>
<Select v-model="reportProcessAction" :options="reportActionOptions" optionLabel="label" optionValue="value" class="w-full" />
</div>
<div v-if="reportProcessNeedsContentAction">
<label class="block font-medium mb-2">内容处置</label>
<Select v-model="reportProcessContentAction" :options="reportContentActionOptions" optionLabel="label" optionValue="value" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">处理说明</label>
<InputText v-model="reportProcessReason" placeholder="可选,便于审计与通知" class="w-full" />
</div>
<div class="text-sm text-muted-color">处理后会记录审计并视情况通知作者</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="reportProcessDialogVisible = false" :disabled="reportProcessSubmitting" />
<Button label="确认处理" icon="pi pi-check" severity="success" @click="confirmReportProcess" :loading="reportProcessSubmitting" :disabled="reportProcessSubmitting || (reportProcessAction === 'reject' && !reportProcessReason.trim())" />
</template>
</Dialog>
<Dialog v-model:visible="commentDeleteDialogVisible" :modal="true" :style="{ width: '520px' }">
<template #header>
<div class="flex items-center gap-2">