458 lines
16 KiB
Vue
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>
|