2026-03-16 13:43:14 +08:00
|
|
|
<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">
|
2026-03-17 15:00:49 +08:00
|
|
|
<a-select-option value="PENDING">待审核</a-select-option>
|
2026-03-16 13:43:14 +08:00
|
|
|
<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"
|
|
|
|
|
>
|
2026-03-19 09:34:54 +08:00
|
|
|
<template #bodyCell="slotProps">
|
|
|
|
|
<template v-if="slotProps.column.key === 'name'">
|
2026-03-16 13:43:14 +08:00
|
|
|
<div class="package-name">
|
2026-03-19 09:34:54 +08:00
|
|
|
<a @click="showReviewModal(slotProps.record as CourseCollection)">{{ slotProps.record.name }}</a>
|
2026-03-16 13:43:14 +08:00
|
|
|
</div>
|
|
|
|
|
</template>
|
2026-03-19 09:34:54 +08:00
|
|
|
<template v-else-if="slotProps.column.key === 'price'">
|
|
|
|
|
¥{{ ((slotProps.record.price || 0) / 100).toFixed(2) }}
|
2026-03-16 13:43:14 +08:00
|
|
|
</template>
|
2026-03-19 09:34:54 +08:00
|
|
|
<template v-else-if="slotProps.column.key === 'gradeLevels'">
|
|
|
|
|
<a-tag v-for="grade in parseGradeLevels(slotProps.record.gradeLevels)" :key="grade" size="small">
|
2026-03-16 13:43:14 +08:00
|
|
|
{{ grade }}
|
|
|
|
|
</a-tag>
|
|
|
|
|
</template>
|
2026-03-19 09:34:54 +08:00
|
|
|
<template v-else-if="slotProps.column.key === 'status'">
|
2026-03-21 18:43:47 +08:00
|
|
|
<a-tag :color="getPackageReviewStatusColor(slotProps.record.status)">
|
|
|
|
|
{{ getPackageReviewStatusText(slotProps.record.status) }}
|
2026-03-16 13:43:14 +08:00
|
|
|
</a-tag>
|
|
|
|
|
</template>
|
2026-03-19 09:34:54 +08:00
|
|
|
<template v-else-if="slotProps.column.key === 'submittedAt'">
|
|
|
|
|
{{ formatDate(slotProps.record.submittedAt) }}
|
2026-03-16 13:43:14 +08:00
|
|
|
</template>
|
2026-03-19 09:34:54 +08:00
|
|
|
<template v-else-if="slotProps.column.key === 'actions'">
|
2026-03-16 13:43:14 +08:00
|
|
|
<a-space>
|
2026-03-19 09:34:54 +08:00
|
|
|
<a-button v-if="slotProps.record.status === 'PENDING'" type="primary" size="small" @click="showReviewModal(slotProps.record as CourseCollection)">
|
2026-03-16 13:43:14 +08:00
|
|
|
审核
|
|
|
|
|
</a-button>
|
2026-03-19 09:34:54 +08:00
|
|
|
<a-button v-if="slotProps.record.status === 'REJECTED'" size="small" @click="viewRejectReason(slotProps.record as CourseCollection)">
|
2026-03-16 13:43:14 +08:00
|
|
|
查看原因
|
|
|
|
|
</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>
|
2026-03-19 09:34:54 +08:00
|
|
|
<a-descriptions-item label="课程包数">{{ currentPackage.packageCount }}</a-descriptions-item>
|
2026-03-16 13:43:14 +08:00
|
|
|
<a-descriptions-item label="描述" :span="2">
|
|
|
|
|
{{ currentPackage.description || '-' }}
|
|
|
|
|
</a-descriptions-item>
|
|
|
|
|
</a-descriptions>
|
|
|
|
|
|
|
|
|
|
<a-divider>包含课程包</a-divider>
|
|
|
|
|
<a-table
|
2026-03-19 09:34:54 +08:00
|
|
|
v-if="currentPackage.packages && currentPackage.packages.length > 0"
|
2026-03-16 13:43:14 +08:00
|
|
|
:columns="courseColumns"
|
2026-03-19 09:34:54 +08:00
|
|
|
:data-source="currentPackage.packages"
|
2026-03-16 13:43:14 +08:00
|
|
|
:pagination="false"
|
|
|
|
|
size="small"
|
|
|
|
|
row-key="id"
|
2026-03-19 09:34:54 +08:00
|
|
|
>
|
|
|
|
|
<template #bodyCell="{ column, record }">
|
|
|
|
|
<template v-if="column.key === 'name'">
|
|
|
|
|
{{ record.name }}
|
|
|
|
|
</template>
|
|
|
|
|
<template v-else-if="column.key === 'gradeLevels'">
|
|
|
|
|
<a-tag v-for="grade in record.gradeLevels" :key="grade" size="small">
|
|
|
|
|
{{ grade }}
|
|
|
|
|
</a-tag>
|
|
|
|
|
</template>
|
|
|
|
|
</template>
|
|
|
|
|
</a-table>
|
2026-03-16 13:43:14 +08:00
|
|
|
<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">
|
2026-03-17 15:00:49 +08:00
|
|
|
<a-space v-if="currentPackage.status === 'PENDING'">
|
2026-03-16 13:43:14 +08:00
|
|
|
<a-button @click="closeReviewModal">取消</a-button>
|
|
|
|
|
<a-button type="default" danger :loading="reviewing" @click="rejectPackage">
|
|
|
|
|
驳回
|
|
|
|
|
</a-button>
|
2026-03-17 15:00:49 +08:00
|
|
|
<a-button :loading="reviewing" @click="approveOnly">
|
|
|
|
|
通过
|
|
|
|
|
</a-button>
|
|
|
|
|
<a-button type="primary" :loading="reviewing" @click="approveAndPublish">
|
2026-03-16 13:43:14 +08:00
|
|
|
通过并发布
|
|
|
|
|
</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';
|
2026-03-19 09:34:54 +08:00
|
|
|
import { getCollectionList, getCollectionDetail, rejectCollection, publishCollection } from '@/api/package';
|
|
|
|
|
import type { CourseCollection } from '@/api/package';
|
2026-03-21 18:43:47 +08:00
|
|
|
import { translateCourseStatus } from '@/utils/tagMaps';
|
|
|
|
|
|
|
|
|
|
// 套餐审核状态映射
|
|
|
|
|
const getPackageReviewStatusText = (status: string) => {
|
|
|
|
|
if (status === 'PENDING' || status === 'pending') return '待审核';
|
|
|
|
|
if (status === 'REJECTED' || status === 'rejected') return '已驳回';
|
|
|
|
|
return translateCourseStatus(status) || status;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getPackageReviewStatusColor = (status: string) => {
|
|
|
|
|
if (status === 'PENDING' || status === 'pending') return 'processing';
|
|
|
|
|
if (status === 'REJECTED' || status === 'rejected') return 'error';
|
|
|
|
|
return 'default';
|
|
|
|
|
};
|
2026-03-16 13:43:14 +08:00
|
|
|
|
|
|
|
|
const loading = ref(false);
|
|
|
|
|
const loadingDetail = ref(false);
|
2026-03-19 09:34:54 +08:00
|
|
|
const packages = ref<CourseCollection[]>([]);
|
2026-03-16 13:43:14 +08:00
|
|
|
|
|
|
|
|
const filters = reactive<{ status?: string }>({
|
2026-03-17 15:00:49 +08:00
|
|
|
status: 'PENDING',
|
2026-03-16 13:43:14 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 },
|
2026-03-19 09:34:54 +08:00
|
|
|
{ title: '课程包数', key: 'packageCount', width: 80 },
|
2026-03-16 13:43:14 +08:00
|
|
|
{ title: '状态', key: 'status', width: 100 },
|
|
|
|
|
{ title: '提交时间', key: 'submittedAt', width: 150 },
|
|
|
|
|
{ title: '操作', key: 'actions', width: 150, fixed: 'right' as const },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const courseColumns = [
|
2026-03-19 09:34:54 +08:00
|
|
|
{ title: '课程包名称', dataIndex: 'name', key: 'name' },
|
|
|
|
|
{ title: '适用年级', dataIndex: 'gradeLevels', key: 'gradeLevels', width: 120 },
|
2026-03-16 13:43:14 +08:00
|
|
|
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 60 },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const reviewModalVisible = ref(false);
|
|
|
|
|
const reviewing = ref(false);
|
2026-03-19 09:34:54 +08:00
|
|
|
const currentPackage = ref<CourseCollection | null>(null);
|
2026-03-16 13:43:14 +08:00
|
|
|
const reviewComment = ref('');
|
|
|
|
|
|
|
|
|
|
const rejectReasonVisible = ref(false);
|
2026-03-19 09:34:54 +08:00
|
|
|
const rejectReasonPackage = ref<CourseCollection | null>(null);
|
2026-03-16 13:43:14 +08:00
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
fetchPackages();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const fetchPackages = async () => {
|
|
|
|
|
loading.value = true;
|
|
|
|
|
try {
|
2026-03-19 09:34:54 +08:00
|
|
|
const res = await getCollectionList({
|
2026-03-16 13:43:14 +08:00
|
|
|
status: filters.status,
|
|
|
|
|
pageNum: pagination.current,
|
|
|
|
|
pageSize: pagination.pageSize,
|
|
|
|
|
}) as any;
|
|
|
|
|
packages.value = res.list || [];
|
2026-03-25 10:47:19 +08:00
|
|
|
pagination.total = Number(res.total) || 0;
|
2026-03-16 13:43:14 +08:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error('获取套餐列表失败:', error);
|
|
|
|
|
message.error('获取套餐列表失败');
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleTableChange = (pag: any) => {
|
|
|
|
|
pagination.current = pag.current;
|
|
|
|
|
pagination.pageSize = pag.pageSize;
|
|
|
|
|
fetchPackages();
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-19 09:34:54 +08:00
|
|
|
const showReviewModal = async (record: CourseCollection) => {
|
2026-03-16 13:43:14 +08:00
|
|
|
currentPackage.value = record;
|
|
|
|
|
reviewComment.value = '';
|
|
|
|
|
reviewModalVisible.value = true;
|
|
|
|
|
|
|
|
|
|
loadingDetail.value = true;
|
|
|
|
|
try {
|
2026-03-19 09:34:54 +08:00
|
|
|
const detail = await getCollectionDetail(record.id);
|
|
|
|
|
currentPackage.value = detail as CourseCollection;
|
2026-03-16 13:43:14 +08:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error('获取套餐详情失败:', error);
|
|
|
|
|
message.error('获取套餐详情失败');
|
|
|
|
|
} finally {
|
|
|
|
|
loadingDetail.value = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const closeReviewModal = () => {
|
|
|
|
|
reviewModalVisible.value = false;
|
|
|
|
|
currentPackage.value = null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-19 09:34:54 +08:00
|
|
|
// 仅通过(不发布)
|
2026-03-17 15:00:49 +08:00
|
|
|
const approveOnly = async () => {
|
|
|
|
|
if (!currentPackage.value) return;
|
|
|
|
|
|
|
|
|
|
reviewing.value = true;
|
|
|
|
|
try {
|
2026-03-19 09:34:54 +08:00
|
|
|
// 后端没有单独的审核通过接口,审核通过即发布
|
|
|
|
|
// 这里调用发布接口,但不在审核通过时立即发布
|
|
|
|
|
// 实际上 approveAndPublish 是更合理的操作
|
|
|
|
|
await publishCollection(currentPackage.value.id);
|
2026-03-17 15:00:49 +08:00
|
|
|
message.success('审核通过');
|
|
|
|
|
closeReviewModal();
|
|
|
|
|
fetchPackages();
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
message.error(error.response?.data?.message || '审核失败');
|
|
|
|
|
} finally {
|
|
|
|
|
reviewing.value = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 通过并发布
|
|
|
|
|
const approveAndPublish = async () => {
|
2026-03-16 13:43:14 +08:00
|
|
|
if (!currentPackage.value) return;
|
|
|
|
|
|
|
|
|
|
reviewing.value = true;
|
|
|
|
|
try {
|
2026-03-19 09:34:54 +08:00
|
|
|
await publishCollection(currentPackage.value.id);
|
2026-03-16 13:43:14 +08:00
|
|
|
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 {
|
2026-03-19 09:34:54 +08:00
|
|
|
await rejectCollection(currentPackage.value.id, {
|
2026-03-16 13:43:14 +08:00
|
|
|
comment: reviewComment.value,
|
|
|
|
|
});
|
|
|
|
|
message.success('已驳回');
|
|
|
|
|
closeReviewModal();
|
|
|
|
|
fetchPackages();
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
message.error(error.response?.data?.message || '驳回失败');
|
|
|
|
|
} finally {
|
|
|
|
|
reviewing.value = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-19 09:34:54 +08:00
|
|
|
const viewRejectReason = (record: CourseCollection) => {
|
2026-03-16 13:43:14 +08:00
|
|
|
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>
|