- 将 Props 中 ID 字段从 number 改为 number | string,兼容后端 Long 序列化为 String - 修复分页组件 total 字段类型,使用 Number() 转换避免 Vue warn - 影响组件: PrepareNavigation, LessonCard, SelectLessonsModal 等 - 影响视图: StudentListView, TeacherListView, ParentListView 等
393 lines
13 KiB
Vue
393 lines
13 KiB
Vue
<template>
|
|
<div class="course-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="fetchCourses">
|
|
<a-select-option value="PENDING">待审核</a-select-option>
|
|
<a-select-option value="REJECTED">已驳回</a-select-option>
|
|
</a-select>
|
|
<a-button @click="fetchCourses">
|
|
<ReloadOutlined /> 刷新
|
|
</a-button>
|
|
</a-space>
|
|
</template>
|
|
</a-page-header>
|
|
</div>
|
|
|
|
<a-table :columns="columns" :data-source="courses" :loading="loading" :pagination="pagination"
|
|
@change="handleTableChange" row-key="id">
|
|
<template #bodyCell="{ column, record }">
|
|
<template v-if="column.key === 'name'">
|
|
<div class="course-name">
|
|
<a @click="showReviewModal(record)">{{ record.name }}</a>
|
|
<div class="course-tags">
|
|
<a-tag v-for="grade in parseGradeTags(record.gradeTags)" :key="grade" size="small"
|
|
:style="getGradeTagStyle(grade)">
|
|
{{ grade }}
|
|
</a-tag>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="column.key === 'status'">
|
|
<a-tag :color="getCourseReviewStatusColor(record.status)">
|
|
{{ getCourseReviewStatusText(record.status) }}
|
|
</a-tag>
|
|
</template>
|
|
|
|
<template v-else-if="column.key === 'submittedAt'">
|
|
{{ formatDate(record.submittedAt) }}
|
|
</template>
|
|
|
|
<template v-else-if="column.key === 'autoCheck'">
|
|
<a-space>
|
|
<a-tag v-if="record.status === 'REJECTED'" color="default">
|
|
已驳回
|
|
</a-tag>
|
|
<a-tag v-else-if="record.validationPassed" color="success">
|
|
<CheckOutlined /> 通过
|
|
</a-tag>
|
|
<a-tag v-else color="warning">
|
|
<WarningOutlined /> 有警告
|
|
</a-tag>
|
|
</a-space>
|
|
</template>
|
|
|
|
<template v-else-if="column.key === 'actions'">
|
|
<a-space>
|
|
<a-button 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="`审核: ${currentCourse?.name || ''}`" width="800px"
|
|
:confirmLoading="reviewing" @ok="submitReview" @cancel="closeReviewModal">
|
|
<template #footer>
|
|
<a-space>
|
|
<a-button @click="closeReviewModal">取消</a-button>
|
|
<a-button type="default" danger :loading="reviewing" @click="rejectCourse">
|
|
驳回
|
|
</a-button>
|
|
<a-button type="primary" :loading="reviewing" @click="approveCourse">
|
|
通过并发布
|
|
</a-button>
|
|
</a-space>
|
|
</template>
|
|
|
|
<a-spin :spinning="loadingDetail">
|
|
<a-form v-if="currentCourse" ref="reviewFormRef" :model="formState" layout="vertical" class="review-content">
|
|
<!-- 自动检查项 -->
|
|
<a-alert v-if="validationResult" :type="validationResult.valid ? 'success' : 'warning'"
|
|
:message="validationResult.valid ? '自动检查通过' : '自动检查有警告'" style="margin-bottom: 16px;">
|
|
<template #description>
|
|
<div v-if="validationResult.errors.length > 0">
|
|
<strong>错误:</strong>
|
|
<ul style="margin: 4px 0; padding-left: 20px;">
|
|
<li v-for="error in validationResult.errors" :key="error.code">{{ error.message }}</li>
|
|
</ul>
|
|
</div>
|
|
<div v-if="validationResult.warnings.length > 0">
|
|
<strong>建议:</strong>
|
|
<ul style="margin: 4px 0; padding-left: 20px;">
|
|
<li v-for="warning in validationResult.warnings" :key="warning.code">{{ warning.message }}</li>
|
|
</ul>
|
|
</div>
|
|
</template>
|
|
</a-alert>
|
|
|
|
<!-- 课程基本信息 -->
|
|
<a-descriptions title="课程信息" bordered size="small" :column="2" style="margin-bottom: 16px;">
|
|
<a-descriptions-item label="课程名称">{{ currentCourse.name }}</a-descriptions-item>
|
|
<a-descriptions-item label="关联绘本">{{ currentCourse.pictureBookName || '无' }}</a-descriptions-item>
|
|
<a-descriptions-item label="适用年级">
|
|
<a-tag v-for="grade in parseGradeTags(currentCourse.gradeTags)" :key="grade" color="blue" size="small">
|
|
{{ grade }}
|
|
</a-tag>
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="课程时长">{{ currentCourse.duration }} 分钟</a-descriptions-item>
|
|
<a-descriptions-item label="提交人">{{ currentCourse.submitter?.name || '未知' }}</a-descriptions-item>
|
|
<a-descriptions-item label="提交时间">{{ formatDate(currentCourse.submittedAt) }}</a-descriptions-item>
|
|
</a-descriptions>
|
|
|
|
<!-- 人工审核检查项 -->
|
|
<a-form-item name="reviewChecklist" :rules="formRules.reviewChecklist" style="margin-bottom: 16px;">
|
|
<template #label>人工审核项
|
|
</template>
|
|
<a-card size="small">
|
|
<a-checkbox-group v-model:value="formState.reviewChecklist" style="width: 100%;">
|
|
<a-row>
|
|
<a-col :span="24" style="margin-bottom: 8px;">
|
|
<a-checkbox value="teaching">教学科学性符合要求</a-checkbox>
|
|
</a-col>
|
|
<a-col :span="24" style="margin-bottom: 8px;">
|
|
<a-checkbox value="quality">素材质量达标</a-checkbox>
|
|
</a-col>
|
|
<a-col :span="24" style="margin-bottom: 8px;">
|
|
<a-checkbox value="tags">标签分类准确</a-checkbox>
|
|
</a-col>
|
|
<a-col :span="24" style="margin-bottom: 8px;">
|
|
<a-checkbox value="copyright">版权合规</a-checkbox>
|
|
</a-col>
|
|
</a-row>
|
|
</a-checkbox-group>
|
|
</a-card>
|
|
</a-form-item>
|
|
|
|
<!-- 审核意见 -->
|
|
<a-form-item name="reviewComment" :rules="formRules.reviewComment">
|
|
<template #label> 审核意见
|
|
</template>
|
|
<a-textarea v-model:value="formState.reviewComment" placeholder="请输入审核意见(驳回时必填,通过时可选)"
|
|
:auto-size="{ minRows: 3, maxRows: 6 }" />
|
|
</a-form-item>
|
|
</a-form>
|
|
</a-spin>
|
|
</a-modal>
|
|
|
|
<!-- 查看驳回原因弹窗 -->
|
|
<a-modal v-model:open="rejectReasonVisible" title="驳回原因" :footer="null">
|
|
<a-alert type="error" :message="rejectReasonCourse?.reviewComment" show-icon />
|
|
</a-modal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, onMounted } from 'vue';
|
|
import { message } from 'ant-design-vue';
|
|
import type { FormInstance } from 'ant-design-vue';
|
|
import { ReloadOutlined, CheckOutlined, WarningOutlined } from '@ant-design/icons-vue';
|
|
import * as courseApi from '@/api/course';
|
|
import {
|
|
translateGradeTag,
|
|
getGradeTagStyle,
|
|
translateCourseStatus,
|
|
} from '@/utils/tagMaps';
|
|
|
|
// 课程审核状态映射
|
|
const getCourseReviewStatusText = (status: string) => {
|
|
if (status === 'PENDING' || status === 'pending') return '待审核';
|
|
if (status === 'REJECTED' || status === 'rejected') return '已驳回';
|
|
return translateCourseStatus(status) || status;
|
|
};
|
|
|
|
const getCourseReviewStatusColor = (status: string) => {
|
|
if (status === 'PENDING' || status === 'pending') return 'processing';
|
|
if (status === 'REJECTED' || status === 'rejected') return 'error';
|
|
return 'default';
|
|
};
|
|
|
|
const loading = ref(false);
|
|
const loadingDetail = ref(false);
|
|
const courses = ref<any[]>([]);
|
|
|
|
const filters = reactive<{ status?: string }>({
|
|
status: 'PENDING', // 默认只显示待审核
|
|
});
|
|
|
|
const pagination = reactive({
|
|
current: 1,
|
|
pageSize: 10,
|
|
total: 0,
|
|
});
|
|
|
|
const columns = [
|
|
{ title: '课程包名称', key: 'name', width: 250 },
|
|
{ title: '状态', key: 'status', width: 100 },
|
|
{ title: '提交时间', key: 'submittedAt', width: 150 },
|
|
{ title: '自动检查', key: 'autoCheck', width: 120 },
|
|
{ title: '操作', key: 'actions', width: 150, fixed: 'right' as const },
|
|
];
|
|
|
|
// 审核相关
|
|
const reviewModalVisible = ref(false);
|
|
const reviewing = ref(false);
|
|
const currentCourse = ref<any>(null);
|
|
const validationResult = ref<courseApi.ValidationResult | null>(null);
|
|
const reviewFormRef = ref<FormInstance>();
|
|
const formState = reactive({
|
|
reviewChecklist: [] as string[],
|
|
reviewComment: '',
|
|
});
|
|
|
|
// 表单校验规则
|
|
const formRules: Record<string, object[]> = {
|
|
reviewChecklist: [
|
|
{ required: true, type: 'array', min: 4, message: '请完成所有审核检查项' },
|
|
],
|
|
reviewComment: [
|
|
{ required: true, message: '请填写驳回原因' },
|
|
],
|
|
};
|
|
|
|
// 驳回原因
|
|
const rejectReasonVisible = ref(false);
|
|
const rejectReasonCourse = ref<any>(null);
|
|
|
|
onMounted(() => {
|
|
fetchCourses();
|
|
});
|
|
|
|
const fetchCourses = async () => {
|
|
loading.value = true;
|
|
try {
|
|
const data = await courseApi.getReviewList({
|
|
pageNum: pagination.current,
|
|
pageSize: pagination.pageSize,
|
|
...filters,
|
|
});
|
|
|
|
courses.value = (data.items || []).map((item: any) => ({
|
|
...item,
|
|
// 已驳回的课程不显示「通过」,待审核的默认通过(能提交的已过基本验证)
|
|
validationPassed: item.status !== 'REJECTED',
|
|
}));
|
|
pagination.total = Number(data.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;
|
|
fetchCourses();
|
|
};
|
|
|
|
const showReviewModal = async (record: any) => {
|
|
currentCourse.value = record;
|
|
formState.reviewChecklist = [];
|
|
formState.reviewComment = '';
|
|
validationResult.value = null;
|
|
reviewModalVisible.value = true;
|
|
|
|
// 加载验证结果
|
|
loadingDetail.value = true;
|
|
try {
|
|
validationResult.value = await courseApi.validateCourse(record.id);
|
|
} catch (error) {
|
|
console.error('验证失败:', error);
|
|
} finally {
|
|
loadingDetail.value = false;
|
|
}
|
|
};
|
|
|
|
const closeReviewModal = () => {
|
|
reviewModalVisible.value = false;
|
|
currentCourse.value = null;
|
|
validationResult.value = null;
|
|
};
|
|
|
|
const approveCourse = async () => {
|
|
try {
|
|
await reviewFormRef.value?.validateFields(['reviewChecklist']);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
reviewing.value = true;
|
|
try {
|
|
await courseApi.approveCourse(currentCourse.value.id, {
|
|
checklist: formState.reviewChecklist,
|
|
comment: formState.reviewComment || '审核通过',
|
|
});
|
|
message.success('审核通过,课程已发布');
|
|
closeReviewModal();
|
|
fetchCourses();
|
|
} catch (error: any) {
|
|
message.error(error.response?.data?.message || '审核失败');
|
|
} finally {
|
|
reviewing.value = false;
|
|
}
|
|
};
|
|
|
|
const rejectCourse = async () => {
|
|
try {
|
|
await reviewFormRef.value?.validateFields(['reviewComment']);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
reviewing.value = true;
|
|
try {
|
|
await courseApi.rejectCourse(currentCourse.value.id, {
|
|
comment: formState.reviewComment,
|
|
});
|
|
message.success('已驳回');
|
|
closeReviewModal();
|
|
fetchCourses();
|
|
} catch (error: any) {
|
|
message.error(error.response?.data?.message || '驳回失败');
|
|
} finally {
|
|
reviewing.value = false;
|
|
}
|
|
};
|
|
|
|
const submitReview = () => {
|
|
// 默认点击确定是通过
|
|
approveCourse();
|
|
};
|
|
|
|
const viewRejectReason = (record: any) => {
|
|
rejectReasonCourse.value = record;
|
|
rejectReasonVisible.value = true;
|
|
};
|
|
|
|
const parseGradeTags = (gradeTags: string | string[] | undefined): string[] => {
|
|
if (!gradeTags) return [];
|
|
if (Array.isArray(gradeTags)) return gradeTags.map((t) => translateGradeTag(t));
|
|
try {
|
|
const tags = JSON.parse(gradeTags);
|
|
return Array.isArray(tags) ? tags.map((t: string) => translateGradeTag(t)) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const formatDate = (date: string | Date) => {
|
|
if (!date) return '-';
|
|
const d = new Date(date);
|
|
return d.toLocaleString('zh-CN', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
};
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.course-review {
|
|
.page-header {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.course-name {
|
|
.course-tags {
|
|
margin-top: 4px;
|
|
}
|
|
}
|
|
|
|
.review-content {
|
|
max-height: 60vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.required-asterisk {
|
|
color: #ff4d4f;
|
|
}
|
|
}
|
|
</style>
|