feat: 完善养成管理后台功能

This commit is contained in:
richjohnrambo
2026-09-19 17:07:47 +08:00
parent f6766d3dbd
commit c7265252d8
13 changed files with 1257 additions and 61 deletions
+27 -5
View File
@@ -1,7 +1,29 @@
import { http } from '@/utils/http/axios';
export function ContentList(params) { return http.request({ url: '/cultivate/content/list', method: 'get', params }); }
export function ContentReview(params) { return http.request({ url: '/cultivate/content/review', method: 'POST', params }); }
export function ReportList(params) { return http.request({ url: '/cultivate/reports/list', method: 'get', params }); }
export function ReportHandle(params) { return http.request({ url: '/cultivate/reports/handle', method: 'POST', params }); }
export function OperationLogList(params) { return http.request({ url: '/cultivate/logs/list', method: 'get', params }); }
export function ContentList(params) {
return http.request({ url: '/cultivate/content/list', method: 'get', params });
}
export function ContentView(params) {
return http.request({ url: '/cultivate/content/view', method: 'get', params });
}
export function ContentReview(params) {
return http.request({ url: '/cultivate/content/review', method: 'POST', params });
}
export function ContentBatchReview(params) {
return http.request({ url: '/cultivate/content/batch-review', method: 'POST', params });
}
export function ReportList(params) {
return http.request({ url: '/cultivate/reports/list', method: 'get', params });
}
export function ReportView(params) {
return http.request({ url: '/cultivate/reports/view', method: 'get', params });
}
export function ReportHandle(params) {
return http.request({ url: '/cultivate/reports/handle', method: 'POST', params });
}
export function OperationLogList(params) {
return http.request({ url: '/cultivate/logs/list', method: 'get', params });
}
export function CultivateDashboard() {
return http.request({ url: '/cultivate/dashboard', method: 'get' });
}
+223 -24
View File
@@ -1,37 +1,236 @@
<template>
<div>
<div class="n-layout-page-header"><n-card :bordered="false" title="内容审核">审核物友动态与评论,支持拒绝、下架和恢复。</n-card></div>
<div class="n-layout-page-header"
><n-card :bordered="false" title="内容审核"
>审核物友动态与评论,支持拒绝、下架和恢复。</n-card
></div
>
<n-card :bordered="false" class="proCard">
<BasicForm ref="searchFormRef" @register="register" @submit="reloadTable" @reset="reloadTable" @keyup.enter="reloadTable" />
<BasicTable ref="actionRef" :columns="columns" :request="loadDataTable" :row-key="(row) => row.id" :actionColumn="actionColumn" :scroll-x="1450" />
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="reloadTable"
@keyup.enter="reloadTable"
/>
<BasicTable
ref="actionRef"
:openChecked="true"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
:actionColumn="actionColumn"
:scroll-x="1450"
:checked-row-keys="checkedIds"
@update:checked-row-keys="onCheckedRow"
>
<template #tableTitle>
<n-button
type="primary"
:disabled="checkedIds.length === 0"
v-if="hasPermission(['/cultivate/content/batch-review'])"
@click="openBatchReview"
>批量审核({{ checkedIds.length }})</n-button
>
</template>
</BasicTable>
</n-card>
<n-modal v-model:show="showReview" preset="dialog" title="内容审核" positive-text="确认" negative-text="取消" @positive-click="submitReview">
<n-modal
v-model:show="showReview"
preset="dialog"
:title="selected ? '内容审核' : '批量内容审核'"
positive-text="确认"
negative-text="取消"
@positive-click="submitReview"
>
<n-space vertical>
<n-select v-model:value="reviewForm.action" :options="reviewOptions" placeholder="请选择审核动作" />
<n-input v-model:value="reviewForm.reason" type="textarea" placeholder="拒绝或下架时必须填写原因" />
<n-select
v-model:value="reviewForm.action"
:options="reviewOptions"
placeholder="请选择审核动作"
/>
<n-input
v-model:value="reviewForm.reason"
type="textarea"
placeholder="拒绝或下架时必须填写原因"
/>
</n-space>
</n-modal>
<n-modal v-model:show="showDetail" preset="card" title="内容详情" class="detail-modal">
<n-spin :show="detailLoading">
<n-descriptions v-if="detail" bordered :column="2" label-placement="left">
<n-descriptions-item label="内容ID">{{ detail.id }}</n-descriptions-item>
<n-descriptions-item label="作者"
>{{ detail.authorNickname || '-' }}({{ detail.authorId }})</n-descriptions-item
>
<n-descriptions-item label="状态">{{ detail.status }}</n-descriptions-item>
<n-descriptions-item label="审核状态">{{
detail.auditStatus || '-'
}}</n-descriptions-item>
<n-descriptions-item label="正文" :span="2">{{
detail.contentText || '-'
}}</n-descriptions-item>
</n-descriptions>
<n-divider title-placement="left">媒体素材</n-divider>
<n-data-table :columns="mediaColumns" :data="detail?.media || []" :pagination="false" />
<n-divider title-placement="left">审核记录</n-divider>
<n-data-table :columns="auditColumns" :data="detail?.audits || []" :pagination="false" />
<n-divider title-placement="left">关联举报</n-divider>
<n-data-table
:columns="reportColumns"
:data="detail?.relatedReports || []"
:pagination="false"
/>
</n-spin>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { h, onMounted, reactive, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form';
import { usePermission } from '@/hooks/web/usePermission';
import { ContentList, ContentReview } from '@/api/cultivate/content';
import { columns, ContentState, loadOptions, schemas } from './model';
import { h, onMounted, reactive, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form';
import { usePermission } from '@/hooks/web/usePermission';
import {
ContentBatchReview,
ContentList,
ContentReview,
ContentView,
} from '@/api/cultivate/content';
import { columns, ContentState, loadOptions, schemas } from './model';
const actionRef = ref(); const searchFormRef = ref<any>({}); const showReview = ref(false); const selected = ref<ContentState>();
const message = useMessage(); const { hasPermission } = usePermission();
const reviewForm = reactive({ action: 'approve', reason: '' });
const reviewOptions = [{ label: '审核通过', value: 'approve' }, { label: '审核拒绝', value: 'reject' }, { label: '下架', value: 'take_down' }, { label: '恢复发布', value: 'restore' }];
const [register] = useForm({ gridProps: { cols: '1 s:1 m:2 l:4 xl:4 2xl:4' }, labelWidth: 90, schemas });
const loadDataTable = (res) => ContentList({ ...searchFormRef.value?.formModel, ...res });
const reloadTable = () => actionRef.value?.reload();
function openReview(row: ContentState) { selected.value = row; reviewForm.action = row.status === 'reviewing' ? 'approve' : row.status === 'taken_down' ? 'restore' : 'take_down'; reviewForm.reason = ''; showReview.value = true; }
async function submitReview() { if (!selected.value) return false; if ((reviewForm.action === 'reject' || reviewForm.action === 'take_down') && !reviewForm.reason.trim()) { message.warning('请填写原因'); return false; } await ContentReview({ objectType: selected.value.objectType, id: selected.value.id, ...reviewForm }); message.success('处理成功'); reloadTable(); return true; }
const actionColumn = reactive({ width: 120, title: '操作', key: 'action', fixed: 'right', render(row: ContentState) { return h(TableAction as any, { style: 'button', actions: [{ label: '审核处理', onClick: () => openReview(row), auth: ['/cultivate/content/review'], disabled: !hasPermission(['/cultivate/content/review']) }] }); } });
onMounted(loadOptions);
const actionRef = ref();
const searchFormRef = ref<any>({});
const showReview = ref(false);
const selected = ref<ContentState>();
const checkedIds = ref<Array<string | number>>([]);
const message = useMessage();
const { hasPermission } = usePermission();
const reviewForm = reactive({ action: 'approve', reason: '' });
const showDetail = ref(false);
const detailLoading = ref(false);
const detail = ref<any>();
const mediaColumns = [
{ title: '类型', key: 'mediaType' },
{ title: '文件ID', key: 'fileId' },
{ title: '缩略图ID', key: 'thumbnailFileId' },
{ title: '审核状态', key: 'auditStatus' },
];
const auditColumns = [
{ title: '来源', key: 'auditSource' },
{
title: '状态变化',
key: 'statusAfter',
render: (row) => `${row.statusBefore || '-'} → ${row.statusAfter}`,
},
{ title: '结果', key: 'resultCode' },
{ title: '操作人', key: 'operatorId' },
{ title: '时间', key: 'occurredAt' },
];
const reportColumns = [
{ title: '举报ID', key: 'id' },
{ title: '原因', key: 'reasonCode' },
{ title: '状态', key: 'status' },
{ title: '时间', key: 'createdAt' },
];
const reviewOptions = [
{ label: '审核通过', value: 'approve' },
{ label: '审核拒绝', value: 'reject' },
{ label: '下架', value: 'take_down' },
{ label: '恢复发布', value: 'restore' },
];
const [register] = useForm({
gridProps: { cols: '1 s:1 m:2 l:4 xl:4 2xl:4' },
labelWidth: 90,
schemas,
});
const loadDataTable = (res) => ContentList({ ...searchFormRef.value?.formModel, ...res });
const reloadTable = () => actionRef.value?.reload();
function openReview(row: ContentState) {
selected.value = row;
reviewForm.action =
row.status === 'reviewing'
? 'approve'
: row.status === 'taken_down'
? 'restore'
: 'take_down';
reviewForm.reason = '';
showReview.value = true;
}
function onCheckedRow(rowKeys: Array<string | number>) {
checkedIds.value = rowKeys;
}
function openBatchReview() {
selected.value = undefined;
reviewForm.action = 'approve';
reviewForm.reason = '';
showReview.value = true;
}
async function openDetail(row: ContentState) {
showDetail.value = true;
detailLoading.value = true;
try {
detail.value = await ContentView({ objectType: row.objectType, id: row.id });
} finally {
detailLoading.value = false;
}
}
async function submitReview() {
if (
(reviewForm.action === 'reject' || reviewForm.action === 'take_down') &&
!reviewForm.reason.trim()
) {
message.warning('请填写原因');
return false;
}
if (selected.value) {
await ContentReview({
objectType: selected.value.objectType,
id: selected.value.id,
...reviewForm,
});
message.success('处理成功');
} else {
const result: any = await ContentBatchReview({
objectType: searchFormRef.value?.formModel?.objectType || 'feed_post',
ids: checkedIds.value.map(String),
...reviewForm,
});
if (result.failedCount) {
message.warning(`成功 ${result.successCount} 条,失败 ${result.failedCount} 条`);
} else {
message.success(`已处理 ${result.successCount} 条内容`);
}
checkedIds.value = [];
}
reloadTable();
return true;
}
const actionColumn = reactive({
width: 170,
title: '操作',
key: 'action',
fixed: 'right',
render(row: ContentState) {
return h(TableAction as any, {
style: 'button',
actions: [
{ label: '详情', onClick: () => openDetail(row), auth: ['/cultivate/content/view'] },
{
label: '审核处理',
onClick: () => openReview(row),
auth: ['/cultivate/content/review'],
disabled: !hasPermission(['/cultivate/content/review']),
},
],
});
},
});
onMounted(loadOptions);
</script>
<style scoped>
.detail-modal {
width: min(1100px, 92vw);
}
</style>
@@ -0,0 +1,51 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="养成概览"
>汇总后院养成、物友动态、内容审核与举报治理数据。</n-card
>
</div>
<n-spin :show="loading">
<n-grid cols="1 s:2 m:3 l:4" responsive="screen" :x-gap="16" :y-gap="16">
<n-grid-item v-for="item in cards" :key="item.key">
<n-card :title="item.label" size="small">
<n-statistic :value="statistics[item.key] || 0" />
</n-card>
</n-grid-item>
</n-grid>
</n-spin>
</div>
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref } from 'vue';
import { CultivateDashboard } from '@/api/cultivate/content';
const loading = ref(false);
const statistics = reactive<Record<string, number>>({});
const cards = [
{ key: 'plantCount', label: '累计种植' },
{ key: 'growingCount', label: '生长中' },
{ key: 'bloomCount', label: '已开花' },
{ key: 'harvestedCount', label: '已收获' },
{ key: 'active7d', label: '近 7 日活跃用户' },
{ key: 'active30d', label: '近 30 日活跃用户' },
{ key: 'postCount', label: '已发布动态' },
{ key: 'commentCount', label: '已发布评论' },
{ key: 'likeCount', label: '累计点赞' },
{ key: 'pendingAuditCount', label: '待审核内容' },
{ key: 'pendingReportCount', label: '待处理举报' },
{ key: 'processingReportCount', label: '处理中举报' },
];
async function loadDashboard() {
loading.value = true;
try {
Object.assign(statistics, await CultivateDashboard());
} finally {
loading.value = false;
}
}
onMounted(loadDashboard);
</script>
+38 -6
View File
@@ -1,12 +1,44 @@
import { ref } from 'vue';
import { FormSchema } from '@/components/Form';
export const schemas = ref<FormSchema[]>([
{ field: 'operatorId', component: 'NInput', label: '操作人ID', componentProps: { placeholder: '请输入操作人ID' } },
{ field: 'operationType', component: 'NInput', label: '操作类型', componentProps: { placeholder: '如 content、report、flower' } },
{ field: 'targetType', component: 'NInput', label: '对象类型', componentProps: { placeholder: '请输入对象类型' } },
{
field: 'operatorId',
component: 'NInput',
label: '操作人ID',
componentProps: { placeholder: '请输入操作人ID' },
},
{
field: 'operationType',
component: 'NInput',
label: '操作类型',
componentProps: { placeholder: '如 content、report、flower' },
},
{
field: 'targetType',
component: 'NInput',
label: '对象类型',
componentProps: { placeholder: '请输入对象类型' },
},
{
field: 'targetId',
component: 'NInput',
label: '对象ID',
componentProps: { placeholder: '请输入对象ID' },
},
{
field: 'requestId',
component: 'NInput',
label: '请求ID',
componentProps: { placeholder: '请输入请求ID' },
},
]);
export const columns = [
{ title: '日志ID', key: 'id', width: 190 }, { title: '操作人', key: 'operatorId', width: 150 }, { title: '操作类型', key: 'operationType', width: 160 },
{ title: '对象类型', key: 'targetType', width: 130 }, { title: '对象ID', key: 'targetId', width: 190 }, { title: '原因/结果', key: 'reason', width: 260, ellipsis: { tooltip: true } },
{ title: '请求ID', key: 'requestId', width: 220 }, { title: '操作时间', key: 'createdAt', width: 180 },
{ title: '日志ID', key: 'id', width: 190 },
{ title: '操作人', key: 'operatorId', width: 150 },
{ title: '操作类型', key: 'operationType', width: 160 },
{ title: '对象类型', key: 'targetType', width: 130 },
{ title: '对象ID', key: 'targetId', width: 190 },
{ title: '原因/结果', key: 'reason', width: 260, ellipsis: { tooltip: true } },
{ title: '请求ID', key: 'requestId', width: 220 },
{ title: '操作时间', key: 'createdAt', width: 180 },
];
+205 -16
View File
@@ -1,25 +1,214 @@
<template>
<div>
<div class="n-layout-page-header"><n-card :bordered="false" title="举报处理">查看物友动态、评论及用户举报并记录处理结果。</n-card></div>
<div class="n-layout-page-header"
><n-card :bordered="false" title="举报处理"
>查看物友动态、评论及用户举报并记录处理结果。</n-card
></div
>
<n-card :bordered="false" class="proCard">
<BasicForm ref="searchFormRef" @register="register" @submit="reloadTable" @reset="reloadTable" />
<BasicTable ref="actionRef" :columns="columns" :request="loadDataTable" :row-key="(row) => row.id" :actionColumn="actionColumn" :scroll-x="1650" />
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="reloadTable"
/>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
:actionColumn="actionColumn"
:scroll-x="1650"
/>
</n-card>
<n-modal v-model:show="showHandle" preset="dialog" title="处理举报" positive-text="确认" negative-text="取消" @positive-click="submitHandle">
<n-space vertical><n-select v-model:value="handleForm.action" :options="handleOptions" /><n-input v-model:value="handleForm.handleResult" type="textarea" placeholder="办结或驳回时必须填写处理结果" /></n-space>
<n-modal
v-model:show="showHandle"
preset="dialog"
title="处理举报"
positive-text="确认"
negative-text="取消"
@positive-click="submitHandle"
>
<n-space vertical>
<n-select v-model:value="handleForm.action" :options="handleOptions" />
<n-select
v-model:value="handleForm.contentAction"
:options="contentActionOptions"
:disabled="handleForm.action !== 'resolve'"
placeholder="内容联动处置"
/>
<n-select
v-model:value="handleForm.userAction"
:options="userActionOptions"
:disabled="handleForm.action !== 'resolve'"
placeholder="用户联动处置"
/>
<n-input
v-model:value="handleForm.reason"
type="textarea"
placeholder="联动处置时必须填写原因"
/>
<n-input
v-model:value="handleForm.handleResult"
type="textarea"
placeholder="办结或驳回时必须填写处理结果"
/>
</n-space>
</n-modal>
<n-modal v-model:show="showDetail" preset="card" title="举报详情" class="detail-modal">
<n-spin :show="detailLoading">
<n-descriptions v-if="detail" bordered :column="2">
<n-descriptions-item label="举报人"
>{{ detail.reporterNickname || '-' }}({{ detail.reporterId }})</n-descriptions-item
>
<n-descriptions-item label="被举报人"
>{{ detail.reportedUserNickname || '-' }}({{
detail.reportedUserId
}})</n-descriptions-item
>
<n-descriptions-item label="对象"
>{{ detail.objectType }} / {{ detail.objectId }}</n-descriptions-item
>
<n-descriptions-item label="原因">{{ detail.reasonCode }}</n-descriptions-item>
<n-descriptions-item label="说明" :span="2">{{
detail.description || '-'
}}</n-descriptions-item>
<n-descriptions-item label="证据文件" :span="2">{{
formatEvidence(detail.evidenceFileIds)
}}</n-descriptions-item>
<n-descriptions-item v-if="detail.targetContent" label="目标内容" :span="2">{{
detail.targetContent.contentText || '-'
}}</n-descriptions-item>
</n-descriptions>
<n-divider title-placement="left">该用户近期举报记录</n-divider>
<n-data-table :columns="historyColumns" :data="detail?.history || []" :pagination="false" />
</n-spin>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { h, onMounted, reactive, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table'; import { BasicForm, useForm } from '@/components/Form';
import { ReportHandle, ReportList } from '@/api/cultivate/content'; import { columns, loadOptions, ReportState, schemas } from './model';
const actionRef = ref(); const searchFormRef = ref<any>({}); const showHandle = ref(false); const selected = ref<ReportState>(); const message = useMessage();
const handleForm = reactive({ action: 'processing', handleResult: '' }); const handleOptions = [{ label: '标记处理中', value: 'processing' }, { label: '确认处理', value: 'resolve' }, { label: '驳回举报', value: 'reject' }];
const [register] = useForm({ gridProps: { cols: '1 s:1 m:2 l:4' }, labelWidth: 90, schemas }); const loadDataTable = (res) => ReportList({ ...searchFormRef.value?.formModel, ...res }); const reloadTable = () => actionRef.value?.reload();
function openHandle(row: ReportState) { selected.value = row; handleForm.action = row.status === 'submitted' ? 'processing' : 'resolve'; handleForm.handleResult = ''; showHandle.value = true; }
async function submitHandle() { if (!selected.value) return false; if (handleForm.action !== 'processing' && !handleForm.handleResult.trim()) { message.warning('请填写处理结果'); return false; } await ReportHandle({ id: selected.value.id, ...handleForm }); message.success('处理成功'); reloadTable(); return true; }
const actionColumn = reactive({ width: 110, title: '操作', key: 'action', fixed: 'right', render(row: ReportState) { return h(TableAction as any, { style: 'button', actions: [{ label: '处理', onClick: () => openHandle(row), auth: ['/cultivate/reports/handle'], disabled: row.status === 'resolved' || row.status === 'rejected' }] }); } });
onMounted(loadOptions);
import { h, onMounted, reactive, ref, watch } from 'vue';
import { useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form';
import { ReportHandle, ReportList, ReportView } from '@/api/cultivate/content';
import { columns, loadOptions, ReportState, schemas } from './model';
const actionRef = ref();
const searchFormRef = ref<any>({});
const showHandle = ref(false);
const showDetail = ref(false);
const detailLoading = ref(false);
const detail = ref<any>();
const selected = ref<ReportState>();
const message = useMessage();
const handleForm = reactive({
action: 'processing',
handleResult: '',
contentAction: 'none',
userAction: 'none',
reason: '',
});
const handleOptions = [
{ label: '标记处理中', value: 'processing' },
{ label: '确认处理', value: 'resolve' },
{ label: '驳回举报', value: 'reject' },
];
const contentActionOptions = [
{ label: '不处置内容', value: 'none' },
{ label: '下架被举报内容', value: 'take_down' },
];
const userActionOptions = [
{ label: '不处置用户', value: 'none' },
{ label: '冻结用户', value: 'freeze' },
{ label: '封禁用户', value: 'ban' },
];
const historyColumns = [
{ title: '举报ID', key: 'id' },
{ title: '对象类型', key: 'objectType' },
{ title: '原因', key: 'reasonCode' },
{ title: '状态', key: 'status' },
{ title: '提交时间', key: 'createdAt' },
];
watch(
() => handleForm.action,
(action) => {
if (action !== 'resolve') {
handleForm.contentAction = 'none';
handleForm.userAction = 'none';
handleForm.reason = '';
}
}
);
const [register] = useForm({ gridProps: { cols: '1 s:1 m:2 l:4' }, labelWidth: 90, schemas });
const loadDataTable = (res) => ReportList({ ...searchFormRef.value?.formModel, ...res });
const reloadTable = () => actionRef.value?.reload();
function openHandle(row: ReportState) {
selected.value = row;
Object.assign(handleForm, {
action: row.status === 'submitted' ? 'processing' : 'resolve',
handleResult: '',
contentAction: 'none',
userAction: 'none',
reason: '',
});
showHandle.value = true;
}
async function openDetail(row: ReportState) {
showDetail.value = true;
detailLoading.value = true;
try {
detail.value = await ReportView({ id: row.id });
} finally {
detailLoading.value = false;
}
}
function formatEvidence(value: any) {
if (!value) return '-';
if (Array.isArray(value)) return value.join('、');
return typeof value === 'string' ? value : JSON.stringify(value);
}
async function submitHandle() {
if (!selected.value) return false;
if (handleForm.action !== 'processing' && !handleForm.handleResult.trim()) {
message.warning('请填写处理结果');
return false;
}
if (
(handleForm.contentAction !== 'none' || handleForm.userAction !== 'none') &&
!handleForm.reason.trim()
) {
message.warning('请填写联动处置原因');
return false;
}
await ReportHandle({ id: selected.value.id, ...handleForm });
message.success('处理成功');
reloadTable();
return true;
}
const actionColumn = reactive({
width: 160,
title: '操作',
key: 'action',
fixed: 'right',
render(row: ReportState) {
return h(TableAction as any, {
style: 'button',
actions: [
{ label: '详情', onClick: () => openDetail(row), auth: ['/cultivate/reports/view'] },
{
label: '处理',
onClick: () => openHandle(row),
auth: ['/cultivate/reports/handle'],
disabled: row.status === 'resolved' || row.status === 'rejected',
},
],
});
},
});
onMounted(loadOptions);
</script>
<style scoped>
.detail-modal {
width: min(1000px, 92vw);
}
</style>