feat: 套餐审核管理 - 审核页面、驳回可重新编辑提交、审核页已驳回不允许审核
Made-with: Cursor
This commit is contained in:
parent
f5de4e613d
commit
87899886d1
@ -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 || '请求失败');
|
||||
|
||||
@ -17,6 +17,7 @@ export interface CoursePackage {
|
||||
publishedAt?: string;
|
||||
submittedAt?: string;
|
||||
reviewedAt?: string;
|
||||
reviewComment?: string;
|
||||
updatedAt?: string;
|
||||
courses?: PackageCourse[];
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 || '提交失败');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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 || '提交失败');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user