feat: 套餐审核管理 - 审核页面、驳回可重新编辑提交、审核页已驳回不允许审核

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-16 13:43:14 +08:00
parent f5de4e613d
commit 87899886d1
7 changed files with 386 additions and 16 deletions

View File

@ -57,7 +57,7 @@ request.interceptors.response.use(
message.error('请求的资源不存在');
break;
case 500:
message.error('服务器错误');
message.error(data?.message || '服务器错误');
break;
default:
message.error(data?.message || '请求失败');

View File

@ -17,6 +17,7 @@ export interface CoursePackage {
publishedAt?: string;
submittedAt?: string;
reviewedAt?: string;
reviewComment?: string;
updatedAt?: string;
courses?: PackageCourse[];
}

View File

@ -82,6 +82,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/admin/packages/PackageListView.vue'),
meta: { title: '套餐管理' },
},
{
path: 'packages/review',
name: 'AdminPackageReview',
component: () => import('@/views/admin/packages/PackageReviewView.vue'),
meta: { title: '套餐审核管理' },
},
{
path: 'packages/create',
name: 'AdminPackageCreate',

View File

@ -8,7 +8,12 @@
<a-space>
<a-button @click="router.back()">返回</a-button>
<a-button v-if="pkg?.status === 'DRAFT'" type="primary" @click="handleEdit">编辑</a-button>
<a-button v-if="pkg?.status === 'DRAFT'" @click="handleSubmit">提交审核</a-button>
<a-tooltip v-if="pkg?.status === 'DRAFT' && (pkg?.courseCount || 0) === 0" title="请先添加至少一个课程包">
<span>
<a-button disabled>提交审核</a-button>
</span>
</a-tooltip>
<a-button v-else-if="pkg?.status === 'DRAFT'" @click="handleSubmit">提交审核</a-button>
<a-button v-if="pkg?.status === 'APPROVED'" type="primary" @click="handlePublish">发布</a-button>
</a-space>
</template>
@ -121,12 +126,16 @@ const handleEdit = () => {
};
const handleSubmit = async () => {
if ((pkg.value?.courseCount || 0) === 0) {
message.warning('套餐必须包含至少一个课程包,请先编辑添加');
return;
}
try {
await submitPackage(route.params.id as string);
message.success('提交成功');
fetchData();
} catch (error) {
message.error('提交失败');
} catch (error: any) {
message.error(error.response?.data?.message || '提交失败');
}
};

View File

@ -5,10 +5,16 @@
<span>课程套餐管理</span>
</template>
<template #extra>
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
新建套餐
</a-button>
<a-space>
<a-button @click="$router.push('/admin/packages/review')">
<AuditOutlined /> 审核管理
<a-badge v-if="pendingCount > 0" :count="pendingCount" :offset="[10, 0]" />
</a-button>
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
新建套餐
</a-button>
</a-space>
</template>
<!-- 筛选 -->
@ -54,19 +60,32 @@
<a-button
type="link"
size="small"
v-if="record.status === 'DRAFT'"
v-if="record.status === 'DRAFT' || record.status === 'REJECTED'"
@click="handleEdit(record)"
>
编辑
</a-button>
<a-tooltip v-if="(record.status === 'DRAFT' || record.status === 'REJECTED') && (record.courseCount || 0) === 0" title="请先添加至少一个课程包">
<span>
<a-button type="link" size="small" disabled>提交</a-button>
</span>
</a-tooltip>
<a-button
v-else-if="record.status === 'DRAFT' || record.status === 'REJECTED'"
type="link"
size="small"
v-if="record.status === 'DRAFT'"
@click="handleSubmit(record)"
>
提交
</a-button>
<a-button
type="link"
size="small"
v-if="record.status === 'PENDING_REVIEW'"
@click="handleReview(record)"
>
审核
</a-button>
<a-button
type="link"
size="small"
@ -94,7 +113,7 @@
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { PlusOutlined, AuditOutlined } from '@ant-design/icons-vue';
import { getPackageList, deletePackage, submitPackage, publishPackage } from '@/api/package';
import type { CoursePackage } from '@/api/package';
@ -102,6 +121,7 @@ const router = useRouter();
const loading = ref(false);
const dataSource = ref<CoursePackage[]>([]);
const pendingCount = ref(0);
const filters = reactive({
status: undefined as string | undefined,
});
@ -159,9 +179,15 @@ const fetchData = async () => {
pageNum: pagination.current,
pageSize: pagination.pageSize,
}) as any;
// PageResult { list, total, pageNum, pageSize, pages }
dataSource.value = res.list || [];
pagination.total = res.total || 0;
//
try {
const pendingRes = await getPackageList({ status: 'PENDING_REVIEW', pageNum: 1, pageSize: 1 }) as any;
pendingCount.value = pendingRes.total || 0;
} catch {
pendingCount.value = 0;
}
} catch (error) {
console.error('获取套餐列表失败', error);
} finally {
@ -187,13 +213,21 @@ const handleEdit = (record: any) => {
router.push(`/admin/packages/${record.id}/edit`);
};
const handleReview = (record: any) => {
router.push('/admin/packages/review');
};
const handleSubmit = async (record: any) => {
if ((record.courseCount || 0) === 0) {
message.warning('套餐必须包含至少一个课程包,请先编辑添加');
return;
}
try {
await submitPackage(record.id);
message.success('提交成功');
fetchData();
} catch (error) {
message.error('提交失败');
} catch (error: any) {
message.error(error.response?.data?.message || '提交失败');
}
};

View File

@ -0,0 +1,319 @@
<template>
<div class="package-review">
<div class="page-header">
<a-page-header title="套餐审核管理" sub-title="审核待发布的课程套餐" @back="$router.back()">
<template #extra>
<a-space>
<a-select v-model:value="filters.status" placeholder="全部状态" style="width: 120px" @change="fetchPackages">
<a-select-option value="PENDING_REVIEW">待审核</a-select-option>
<a-select-option value="REJECTED">已驳回</a-select-option>
</a-select>
<a-button @click="fetchPackages">
<ReloadOutlined /> 刷新
</a-button>
</a-space>
</template>
</a-page-header>
</div>
<a-table
:columns="columns"
:data-source="packages"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="package-name">
<a @click="showReviewModal(record)">{{ record.name }}</a>
</div>
</template>
<template v-else-if="column.key === 'price'">
¥{{ ((record.price || 0) / 100).toFixed(2) }}
</template>
<template v-else-if="column.key === 'gradeLevels'">
<a-tag v-for="grade in parseGradeLevels(record.gradeLevels)" :key="grade" size="small">
{{ grade }}
</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'PENDING_REVIEW' ? 'processing' : 'error'">
{{ record.status === 'PENDING_REVIEW' ? '待审核' : '已驳回' }}
</a-tag>
</template>
<template v-else-if="column.key === 'submittedAt'">
{{ formatDate(record.submittedAt) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button v-if="record.status === 'PENDING_REVIEW'" type="primary" size="small" @click="showReviewModal(record)">
审核
</a-button>
<a-button v-if="record.status === 'REJECTED'" size="small" @click="viewRejectReason(record)">
查看原因
</a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 审核弹窗 -->
<a-modal
v-model:open="reviewModalVisible"
:title="`审核: ${currentPackage?.name || ''}`"
width="700px"
:footer="null"
@cancel="closeReviewModal"
>
<a-spin :spinning="loadingDetail">
<div v-if="currentPackage" class="review-content">
<a-descriptions title="套餐信息" bordered size="small" :column="2" style="margin-bottom: 16px">
<a-descriptions-item label="套餐名称">{{ currentPackage.name }}</a-descriptions-item>
<a-descriptions-item label="价格">
¥{{ ((currentPackage.price || 0) / 100).toFixed(2) }}
</a-descriptions-item>
<a-descriptions-item label="适用年级">
<a-tag v-for="grade in parseGradeLevels(currentPackage.gradeLevels)" :key="grade" size="small">
{{ grade }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="课程数">{{ currentPackage.courseCount }}</a-descriptions-item>
<a-descriptions-item label="描述" :span="2">
{{ currentPackage.description || '-' }}
</a-descriptions-item>
</a-descriptions>
<a-divider>包含课程包</a-divider>
<a-table
v-if="currentPackage.courses && currentPackage.courses.length > 0"
:columns="courseColumns"
:data-source="currentPackage.courses"
:pagination="false"
size="small"
row-key="id"
/>
<a-empty v-else description="暂无课程包" />
<a-form layout="vertical" style="margin-top: 16px">
<a-form-item label="审核意见">
<a-textarea
v-model:value="reviewComment"
placeholder="驳回时必填,通过时可选"
:auto-size="{ minRows: 3, maxRows: 6 }"
/>
</a-form-item>
</a-form>
<div class="modal-footer">
<a-space v-if="currentPackage.status === 'PENDING_REVIEW'">
<a-button @click="closeReviewModal">取消</a-button>
<a-button type="default" danger :loading="reviewing" @click="rejectPackage">
驳回
</a-button>
<a-button type="primary" :loading="reviewing" @click="approvePackage">
通过并发布
</a-button>
</a-space>
<template v-else>
<a-alert type="info" message="已驳回的套餐请到套餐管理页面重新编辑并提交" show-icon style="flex: 1" />
<a-button @click="closeReviewModal">关闭</a-button>
</template>
</div>
</div>
</a-spin>
</a-modal>
<!-- 查看驳回原因弹窗 -->
<a-modal v-model:open="rejectReasonVisible" title="驳回原因" :footer="null">
<a-alert type="error" :message="rejectReasonPackage?.reviewComment" show-icon />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { ReloadOutlined } from '@ant-design/icons-vue';
import { getPackageList, getPackageDetail, reviewPackage } from '@/api/package';
import type { CoursePackage } from '@/api/package';
const loading = ref(false);
const loadingDetail = ref(false);
const packages = ref<CoursePackage[]>([]);
const filters = reactive<{ status?: string }>({
status: 'PENDING_REVIEW',
});
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
});
const columns = [
{ title: '套餐名称', key: 'name', width: 200 },
{ title: '价格', key: 'price', width: 100 },
{ title: '适用年级', key: 'gradeLevels', width: 150 },
{ title: '课程数', key: 'courseCount', width: 80 },
{ title: '状态', key: 'status', width: 100 },
{ title: '提交时间', key: 'submittedAt', width: 150 },
{ title: '操作', key: 'actions', width: 150, fixed: 'right' as const },
];
const courseColumns = [
{ title: '课程名称', dataIndex: 'name', key: 'name' },
{ title: '年级', dataIndex: 'gradeLevel', key: 'gradeLevel', width: 80 },
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 60 },
];
const reviewModalVisible = ref(false);
const reviewing = ref(false);
const currentPackage = ref<CoursePackage | null>(null);
const reviewComment = ref('');
const rejectReasonVisible = ref(false);
const rejectReasonPackage = ref<CoursePackage | null>(null);
onMounted(() => {
fetchPackages();
});
const fetchPackages = async () => {
loading.value = true;
try {
const res = await getPackageList({
status: filters.status,
pageNum: pagination.current,
pageSize: pagination.pageSize,
}) as any;
packages.value = res.list || [];
pagination.total = res.total || 0;
} catch (error) {
console.error('获取套餐列表失败:', error);
message.error('获取套餐列表失败');
} finally {
loading.value = false;
}
};
const handleTableChange = (pag: any) => {
pagination.current = pag.current;
pagination.pageSize = pag.pageSize;
fetchPackages();
};
const showReviewModal = async (record: CoursePackage) => {
currentPackage.value = record;
reviewComment.value = '';
reviewModalVisible.value = true;
loadingDetail.value = true;
try {
const detail = await getPackageDetail(record.id);
currentPackage.value = detail as CoursePackage;
} catch (error) {
console.error('获取套餐详情失败:', error);
message.error('获取套餐详情失败');
} finally {
loadingDetail.value = false;
}
};
const closeReviewModal = () => {
reviewModalVisible.value = false;
currentPackage.value = null;
};
const approvePackage = async () => {
if (!currentPackage.value) return;
reviewing.value = true;
try {
await reviewPackage(currentPackage.value.id, {
approved: true,
comment: reviewComment.value || '审核通过',
});
message.success('审核通过,套餐已发布');
closeReviewModal();
fetchPackages();
} catch (error: any) {
message.error(error.response?.data?.message || '审核失败');
} finally {
reviewing.value = false;
}
};
const rejectPackage = async () => {
if (!currentPackage.value) return;
if (!reviewComment.value.trim()) {
message.warning('请填写驳回原因');
return;
}
reviewing.value = true;
try {
await reviewPackage(currentPackage.value.id, {
approved: false,
comment: reviewComment.value,
});
message.success('已驳回');
closeReviewModal();
fetchPackages();
} catch (error: any) {
message.error(error.response?.data?.message || '驳回失败');
} finally {
reviewing.value = false;
}
};
const viewRejectReason = (record: CoursePackage) => {
rejectReasonPackage.value = record;
rejectReasonVisible.value = true;
};
const parseGradeLevels = (gradeLevels: string | string[]) => {
if (Array.isArray(gradeLevels)) return gradeLevels;
try {
return JSON.parse(gradeLevels || '[]');
} catch {
return [];
}
};
const formatDate = (date?: string) => {
if (!date) return '-';
return new Date(date).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
</script>
<style scoped lang="scss">
.package-review {
.page-header {
margin-bottom: 16px;
}
.package-name a {
font-weight: 500;
}
.review-content {
max-height: 70vh;
overflow-y: auto;
}
.modal-footer {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
}
</style>

View File

@ -3,6 +3,7 @@ package com.reading.platform.controller.admin;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.reading.platform.common.annotation.RequireRole;
import com.reading.platform.common.enums.UserRole;
import com.reading.platform.common.security.SecurityUtils;
import com.reading.platform.common.response.PageResult;
import com.reading.platform.common.response.Result;
import com.reading.platform.dto.request.PackageCreateRequest;
@ -98,7 +99,7 @@ public class AdminPackageController {
@Operation(summary = "提交审核")
@RequireRole(UserRole.ADMIN)
public Result<Void> submit(@PathVariable Long id) {
packageService.submitPackage(id, 1L); // TODO: 从token获取userId
packageService.submitPackage(id, SecurityUtils.getCurrentUserId());
return Result.success();
}
@ -108,7 +109,7 @@ public class AdminPackageController {
public Result<Void> review(
@PathVariable Long id,
@RequestBody ReviewRequest request) {
packageService.reviewPackage(id, 1L, request.getApproved(), request.getComment());
packageService.reviewPackage(id, SecurityUtils.getCurrentUserId(), request.getApproved(), request.getComment());
return Result.success();
}