kindergarten/reading-platform-frontend/src/views/school/school-courses/SchoolCourseDetailView.vue
2026-03-03 13:59:02 +08:00

458 lines
16 KiB
Vue

<template>
<div class="min-h-100vh bg-[linear-gradient(135deg,#F0FFF4_0%,#FFFFFF_50%,#F0FDF4_100%)]">
<!-- 顶部导航 -->
<div class="flex justify-between items-center py-4 px-6 bg-white border-b border-[#f0f0f0] sticky top-0 z-100">
<div class="flex items-center gap-3">
<a-button type="text" @click="router.back()">
<ArrowLeftOutlined />
</a-button>
<div class="flex items-center gap-3">
<h2 class="m-0 text-lg font-600">{{ detail?.name || '校本课程包详情' }}</h2>
<a-tag :color="detail?.status === 'ACTIVE' ? 'success' : 'default'">
{{ detail?.status === 'ACTIVE' ? '启用' : '禁用' }}
</a-tag>
</div>
</div>
<div class="flex gap-2">
<a-button @click="showReserveModal">
<CalendarOutlined /> 预约
</a-button>
<a-button type="primary" @click="handleEdit">
<EditOutlined /> 编辑
</a-button>
</div>
</div>
<a-spin :spinning="loading">
<div class="p-6 max-w-[1200px] mx-auto">
<!-- 基本信息 -->
<div class="bg-white rounded-xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)] mb-6">
<div class="py-4 px-6 border-b border-[#f0f0f0] flex justify-between items-center">
<span class="text-base font-600 text-[#333] flex items-center gap-2">
<InfoCircleOutlined /> 基本信息
</span>
</div>
<div class="p-5 px-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="flex flex-col gap-1">
<span class="text-xs text-[#666]">校本课程包名称</span>
<span class="text-sm text-[#333] font-500">{{ detail?.name }}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-[#666]">基于课程包</span>
<span class="text-sm text-[#333] font-500">{{ detail?.sourceCourse?.name || '-' }}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-[#666]">创建者</span>
<span class="text-sm text-[#333] font-500">{{ detail?.creator?.name || '-' }}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-[#666]">使用次数</span>
<span class="text-sm text-[#333] font-500">
<a-badge :count="detail?.usageCount || 0" :number-style="{ backgroundColor: '#52c41a' }" />
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-[#666]">创建时间</span>
<span class="text-sm text-[#333] font-500">{{ formatDate(detail?.createdAt) }}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-[#666]">更新时间</span>
<span class="text-sm text-[#333] font-500">{{ formatDate(detail?.updatedAt) }}</span>
</div>
</div>
<div class="mt-4 pt-4 border-t border-dashed border-[#f0f0f0] flex flex-col gap-1" v-if="detail?.description">
<span class="text-xs text-[#666]">描述</span>
<span class="text-sm text-[#333] leading-[1.6] whitespace-pre-wrap">{{ detail?.description }}</span>
</div>
<div class="mt-4 pt-4 border-t border-dashed border-[#f0f0f0] flex flex-col gap-1" v-if="detail?.changesSummary">
<span class="text-xs text-[#666]">修改说明</span>
<span class="text-sm text-[#333] leading-[1.6] whitespace-pre-wrap">{{ detail?.changesSummary }}</span>
</div>
</div>
</div>
<!-- 课程配置 -->
<div class="bg-white rounded-xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)] mb-6" v-if="detail?.lessons && detail.lessons.length > 0">
<div class="py-4 px-6 border-b border-[#f0f0f0] flex justify-between items-center">
<span class="text-base font-600 text-[#333] flex items-center gap-2">
<AppstoreOutlined /> 课程配置
</span>
<a-tag>{{ detail.lessons.length }} 个课程</a-tag>
</div>
<div class="p-5 px-6">
<div class="grid gap-4">
<div
v-for="lesson in detail.lessons"
:key="lesson.id"
class="border border-[#e8e8e8] rounded-xl overflow-hidden"
>
<div class="py-3 px-4 bg-[#fafafa] border-b border-[#f0f0f0]">
<a-tag :color="getLessonTypeColor(lesson.lessonType)">
{{ getLessonTypeName(lesson.lessonType) }}
</a-tag>
</div>
<div class="p-4">
<div class="mb-3 last:mb-0" v-if="lesson.objectives">
<div class="text-xs text-[#666] mb-1">教学目标</div>
<div class="text-[13px] text-[#333] leading-[1.6] whitespace-pre-wrap">{{ lesson.objectives }}</div>
</div>
<div class="mb-3 last:mb-0" v-if="lesson.preparation">
<div class="text-xs text-[#666] mb-1">教学准备</div>
<div class="text-[13px] text-[#333] leading-[1.6] whitespace-pre-wrap">{{ lesson.preparation }}</div>
</div>
<div class="mb-3 last:mb-0" v-if="lesson.changeNote">
<div class="text-xs text-[#1890ff] mb-1">
<EditOutlined /> 修改备注
</div>
<div class="text-[13px] text-[#333] leading-[1.6] whitespace-pre-wrap bg-[#e6f7ff] py-2 px-3 rounded border-l-[3px] border-l-[#1890ff]">{{ lesson.changeNote }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 预约/排课记录 -->
<div class="bg-white rounded-xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)] mb-6">
<div class="py-4 px-6 border-b border-[#f0f0f0] flex justify-between items-center">
<span class="text-base font-600 text-[#333] flex items-center gap-2">
<CalendarOutlined /> 预约/排课记录
</span>
<a-button type="link" size="small" @click="showReserveModal">
<PlusOutlined /> 新增预约
</a-button>
</div>
<div class="p-5 px-6">
<a-tabs v-model:activeKey="reservationTab">
<a-tab-pane key="upcoming" :tab="`即将上课 (${upcomingReservations.length})`">
<a-table
:columns="reservationColumns"
:data-source="upcomingReservations"
:loading="reservationLoading"
row-key="id"
size="small"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'scheduledDate'">
{{ formatDateTime(record.scheduledDate) }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" danger @click="cancelReserve(record)">
取消
</a-button>
</a-space>
</template>
</template>
</a-table>
<div v-if="upcomingReservations.length === 0" class="text-center py-10 text-[#999]">
暂无即将上课的预约
</div>
</a-tab-pane>
<a-tab-pane key="history" :tab="`历史记录 (${historyReservations.length})`">
<a-table
:columns="reservationColumns"
:data-source="historyReservations"
:loading="reservationLoading"
row-key="id"
size="small"
:pagination="{ pageSize: 5 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'scheduledDate'">
{{ formatDateTime(record.scheduledDate) }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
</template>
</template>
</a-table>
<div v-if="historyReservations.length === 0" class="text-center py-10 text-[#999]">
暂无历史记录
</div>
</a-tab-pane>
</a-tabs>
</div>
</div>
</div>
</a-spin>
<!-- 预约弹窗 -->
<a-modal
v-model:open="reserveModalVisible"
title="预约校本课程包"
width="500px"
@ok="handleReserve"
:confirmLoading="reserveLoading"
>
<div v-if="detail">
<div class="bg-[#f9f9f9] rounded-lg py-3 px-4 flex gap-2">
<span class="text-[#666]">课程包名称</span>
<span class="font-500">{{ detail.name }}</span>
</div>
<a-divider />
<a-form layout="vertical">
<a-form-item label="授课教师" required>
<a-select v-model:value="reserveForm.teacherId" placeholder="选择授课教师" show-search>
<a-select-option v-for="teacher in teachers" :key="teacher.id" :value="teacher.id">
{{ teacher.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="授课班级" required>
<a-select v-model:value="reserveForm.classId" placeholder="选择授课班级">
<a-select-option v-for="cls in classes" :key="cls.id" :value="cls.id">
{{ cls.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="预约时间" required>
<a-date-picker
v-model:value="reserveForm.scheduledDate"
show-time
format="YYYY-MM-DD HH:mm"
placeholder="选择预约时间"
class="w-full"
/>
</a-form-item>
<a-form-item label="备注">
<a-textarea v-model:value="reserveForm.note" :rows="2" placeholder="备注信息(可选)" />
</a-form-item>
</a-form>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import {
ArrowLeftOutlined,
CalendarOutlined,
EditOutlined,
InfoCircleOutlined,
AppstoreOutlined,
PlusOutlined,
} from '@ant-design/icons-vue';
import {
getSchoolCourseDetail,
getReservations,
createReservation,
cancelReservation,
type SchoolCourse,
type CreateReservationData,
} from '@/api/school-course';
import { getTeachers, getClasses } from '@/api/school';
import type { Teacher, ClassInfo } from '@/api/school';
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const detail = ref<SchoolCourse | null>(null);
// 预约相关
const reserveModalVisible = ref(false);
const reserveLoading = ref(false);
const reserveForm = ref<CreateReservationData>({
teacherId: undefined as any,
classId: undefined as any,
scheduledDate: undefined as any,
note: '',
});
// 数据
const teachers = ref<Teacher[]>([]);
const classes = ref<ClassInfo[]>([]);
const reservations = ref<any[]>([]);
const reservationLoading = ref(false);
const reservationTab = ref('upcoming');
const reservationColumns = [
{ title: '预约时间', key: 'scheduledDate', width: 160 },
{ title: '教师', dataIndex: ['teacher', 'name'], key: 'teacherName', width: 100 },
{ title: '班级', dataIndex: ['class', 'name'], key: 'className', width: 100 },
{ title: '状态', key: 'status', width: 80 },
{ title: '操作', key: 'action', width: 80 },
];
const lessonTypeNames: Record<string, string> = {
INTRO: '导入课',
COLLECTIVE: '集体课',
DOMAIN_HEALTH: '健康领域',
DOMAIN_LANGUAGE: '语言领域',
DOMAIN_SOCIAL: '社会领域',
DOMAIN_SCIENCE: '科学领域',
DOMAIN_ART: '艺术领域',
};
const lessonTypeColors: Record<string, string> = {
INTRO: 'cyan',
COLLECTIVE: 'green',
DOMAIN_HEALTH: 'red',
DOMAIN_LANGUAGE: 'orange',
DOMAIN_SOCIAL: 'purple',
DOMAIN_SCIENCE: 'geekblue',
DOMAIN_ART: 'magenta',
};
const getLessonTypeName = (type: string) => lessonTypeNames[type] || type;
const getLessonTypeColor = (type: string) => lessonTypeColors[type] || 'default';
// 计算属性
const upcomingReservations = computed(() => {
const now = new Date();
return reservations.value.filter(r => new Date(r.scheduledDate) >= now);
});
const historyReservations = computed(() => {
const now = new Date();
return reservations.value.filter(r => new Date(r.scheduledDate) < now);
});
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
PENDING: 'blue',
COMPLETED: 'green',
CANCELLED: 'red',
};
return colors[status] || 'default';
};
const getStatusText = (status: string) => {
const texts: Record<string, string> = {
PENDING: '待上课',
COMPLETED: '已完成',
CANCELLED: '已取消',
};
return texts[status] || status;
};
const formatDate = (date?: string) => {
if (!date) return '-';
return new Date(date).toLocaleDateString('zh-CN');
};
const formatDateTime = (date?: string) => {
if (!date) return '-';
return new Date(date).toLocaleString('zh-CN');
};
const fetchData = async () => {
loading.value = true;
try {
const id = Number(route.params.id);
const res = await getSchoolCourseDetail(id);
detail.value = (res as any).data || res;
} catch (error) {
message.error('获取详情失败');
} finally {
loading.value = false;
}
};
const fetchBaseData = async () => {
try {
const [teacherRes, classRes] = await Promise.all([
getTeachers({ pageSize: 1000 }),
getClasses(),
]);
teachers.value = (teacherRes as any).items || [];
classes.value = classRes as any;
} catch (error) {
console.error('获取基础数据失败', error);
}
};
const loadReservations = async () => {
if (!detail.value) return;
reservationLoading.value = true;
try {
const res = await getReservations(detail.value.id);
reservations.value = (res as any) || [];
} catch (error) {
console.error('获取预约列表失败', error);
} finally {
reservationLoading.value = false;
}
};
const handleEdit = () => {
router.push(`/school/school-courses/${route.params.id}/edit`);
};
const showReserveModal = () => {
reserveForm.value = {
teacherId: undefined as any,
classId: undefined as any,
scheduledDate: undefined as any,
note: '',
};
reserveModalVisible.value = true;
};
const handleReserve = async () => {
if (!detail.value) return;
if (!reserveForm.value.teacherId) {
message.warning('请选择授课教师');
return;
}
if (!reserveForm.value.classId) {
message.warning('请选择授课班级');
return;
}
if (!reserveForm.value.scheduledDate) {
message.warning('请选择预约时间');
return;
}
reserveLoading.value = true;
try {
const data = {
...reserveForm.value,
scheduledDate: reserveForm.value.scheduledDate.format('YYYY-MM-DD HH:mm'),
};
await createReservation(detail.value.id, data);
message.success('预约成功');
reserveModalVisible.value = false;
await loadReservations();
} catch (error) {
console.error('预约失败', error);
message.error('预约失败');
} finally {
reserveLoading.value = false;
}
};
const cancelReserve = async (record: any) => {
try {
await cancelReservation(record.id);
message.success('取消成功');
await loadReservations();
} catch (error) {
message.error('取消失败');
}
};
onMounted(() => {
fetchData();
fetchBaseData().then(() => {
loadReservations();
});
});
</script>
<style scoped>
/* 仅保留第三方/无法用原子类实现的部分 */
</style>