kindergarten_java/reading-platform-frontend/src/views/admin/courses/CourseReviewView.vue
En 55343ead0b fix(前端): 修复 ID 类型和分页 total 类型不匹配问题
- 将 Props 中 ID 字段从 number 改为 number | string,兼容后端 Long 序列化为 String
- 修复分页组件 total 字段类型,使用 Number() 转换避免 Vue warn
- 影响组件: PrepareNavigation, LessonCard, SelectLessonsModal 等
- 影响视图: StudentListView, TeacherListView, ParentListView 等
2026-03-25 10:47:19 +08:00

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>