- 统一学校端各管理页的头部排版、背景和外边距,在移动端左对齐标题并增加合理留白 - 优化筛选条、搜索框和操作按钮在小屏下的栅格布局,确保控件整行展示且不被压缩 - 调整统计卡片、列表和空状态在手机上的排列方式,提升阅读性和交互体验 Made-with: Cursor
574 lines
18 KiB
Vue
574 lines
18 KiB
Vue
<template>
|
|
<div class="min-h-100vh bg-[linear-gradient(180deg,#FFF8F0_0%,#FFFFFF_100%)] px-4 py-4 md:px-6 md:py-6">
|
|
<!-- 页面头部 -->
|
|
<div class="mb-6 flex justify-between items-center gap-4 max-md:flex-col max-md:items-start">
|
|
<div class="flex items-center gap-4">
|
|
<div class="w-14 h-14 flex items-center justify-center bg-[linear-gradient(135deg,#667eea_0%,#764ba2_100%)] rounded-[14px] shadow-[0_4px_12px_rgba(102,126,234,0.3)]">
|
|
<AppstoreOutlined class="text-[28px] text-white" />
|
|
</div>
|
|
<div>
|
|
<h2 class="m-0 text-2xl font-700 text-[#333]">校本课程包</h2>
|
|
<p class="text-[#666] text-sm mt-1 mb-0">管理本校教师创建的校本课程包</p>
|
|
</div>
|
|
</div>
|
|
<a-button
|
|
type="primary"
|
|
class="w-full md:w-auto"
|
|
@click="handleCreate"
|
|
>
|
|
<PlusOutlined /> 创建校本课程包
|
|
</a-button>
|
|
</div>
|
|
|
|
<!-- 统计概览 -->
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
|
<div class="bg-white rounded-xl p-5 flex items-center gap-4 shadow-[0_2px_8px_rgba(0,0,0,0.06)]">
|
|
<div class="w-12 h-12 rounded-xl flex items-center justify-center text-2xl bg-[linear-gradient(135deg,#667eea_0%,#764ba2_100%)] text-white">
|
|
<AppstoreOutlined />
|
|
</div>
|
|
<div>
|
|
<div class="text-[28px] font-700 text-[#333]">{{ dataSource.length }}</div>
|
|
<div class="text-[13px] text-[#666]">校本课程包总数</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-white rounded-xl p-5 flex items-center gap-4 shadow-[0_2px_8px_rgba(0,0,0,0.06)]">
|
|
<div class="w-12 h-12 rounded-xl flex items-center justify-center text-2xl bg-[linear-gradient(135deg,#43e97b_0%,#38f9d7_100%)] text-white">
|
|
<BarChartOutlined />
|
|
</div>
|
|
<div>
|
|
<div class="text-[28px] font-700 text-[#333]">{{ totalUsage }}</div>
|
|
<div class="text-[13px] text-[#666]">本周使用次数</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-white rounded-xl p-5 flex items-center gap-4 shadow-[0_2px_8px_rgba(0,0,0,0.06)]">
|
|
<div class="w-12 h-12 rounded-xl flex items-center justify-center text-2xl bg-[linear-gradient(135deg,#4facfe_0%,#00f2fe_100%)] text-white">
|
|
<CalendarOutlined />
|
|
</div>
|
|
<div>
|
|
<div class="text-[28px] font-700 text-[#333]">{{ pendingReservations }}</div>
|
|
<div class="text-[13px] text-[#666]">待上课预约</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 套餐列表 -->
|
|
<a-card :bordered="false" class="rounded-xl list-card">
|
|
<template #title>
|
|
<span>校本课程包列表</span>
|
|
</template>
|
|
<template #extra>
|
|
<div class="search-box">
|
|
<a-input-search
|
|
v-model:value="searchKeyword"
|
|
placeholder="搜索课程包名称"
|
|
class="w-[220px]"
|
|
@search="handleSearch"
|
|
allow-clear
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<a-table
|
|
:columns="columns"
|
|
:data-source="filteredData"
|
|
:loading="loading"
|
|
row-key="id"
|
|
:pagination="{ pageSize: 10 }"
|
|
:scroll="{ x: true }"
|
|
>
|
|
<template #bodyCell="{ column, record }">
|
|
<template v-if="column.key === 'name'">
|
|
<div class="flex flex-col">
|
|
<span class="font-500">{{ record.name }}</span>
|
|
<span v-if="record.changesSummary" class="text-xs text-[#999] mt-1">
|
|
{{ record.changesSummary }}
|
|
</span>
|
|
</div>
|
|
</template>
|
|
<template v-else-if="column.key === 'sourceCourse'">
|
|
<div class="flex items-center gap-2">
|
|
<img
|
|
v-if="record.sourceCourse?.coverImagePath"
|
|
:src="getFileUrl(record.sourceCourse.coverImagePath)"
|
|
class="w-10 h-10 object-cover rounded"
|
|
/>
|
|
<div v-else class="w-10 h-10 bg-[#f0f0f0] rounded flex items-center justify-center text-[#999]">
|
|
<BookOutlined />
|
|
</div>
|
|
<span>{{ record.sourceCourse?.name || '-' }}</span>
|
|
</div>
|
|
</template>
|
|
<template v-else-if="column.key === 'createdBy'">
|
|
<span>{{ record.creator?.name || '-' }}</span>
|
|
</template>
|
|
<template v-else-if="column.key === 'status'">
|
|
<a-tag :color="record.status === 'ACTIVE' ? 'success' : 'default'">
|
|
{{ record.status === 'ACTIVE' ? '启用' : '禁用' }}
|
|
</a-tag>
|
|
</template>
|
|
<template v-else-if="column.key === 'usageCount'">
|
|
<a-badge :count="record.usageCount || 0" :number-style="{ backgroundColor: '#52c41a' }" />
|
|
</template>
|
|
<template v-else-if="column.key === 'action'">
|
|
<a-space>
|
|
<a-tooltip title="查看详情">
|
|
<a-button type="link" size="small" @click="handleView(record)">
|
|
<EyeOutlined />
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-tooltip title="预约">
|
|
<a-button type="link" size="small" @click="showReserveModal(record)">
|
|
<CalendarOutlined />
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-tooltip title="排课">
|
|
<a-button type="link" size="small" @click="showScheduleModal(record)">
|
|
<ScheduleOutlined />
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-dropdown>
|
|
<a-button type="link" size="small">
|
|
<MoreOutlined />
|
|
</a-button>
|
|
<template #overlay>
|
|
<a-menu>
|
|
<a-menu-item @click="handleEdit(record)">
|
|
<EditOutlined /> 编辑
|
|
</a-menu-item>
|
|
<a-menu-divider />
|
|
<a-menu-item danger @click="confirmDelete(record)">
|
|
<DeleteOutlined /> 删除
|
|
</a-menu-item>
|
|
</a-menu>
|
|
</template>
|
|
</a-dropdown>
|
|
</a-space>
|
|
</template>
|
|
</template>
|
|
</a-table>
|
|
</a-card>
|
|
|
|
<!-- 预约弹窗 -->
|
|
<a-modal
|
|
v-model:open="reserveModalVisible"
|
|
title="预约校本课程包"
|
|
width="500px"
|
|
@ok="handleReserve"
|
|
:confirmLoading="reserveLoading"
|
|
>
|
|
<div v-if="selectedCourse">
|
|
<div class="bg-[#f9f9f9] rounded-lg py-3 px-4 flex gap-2">
|
|
<span class="text-[#666]">课程包名称</span>
|
|
<span class="font-500">{{ selectedCourse.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>
|
|
|
|
<a-alert
|
|
v-if="conflictInfo"
|
|
:type="conflictInfo.hasConflict ? 'error' : 'success'"
|
|
:message="conflictInfo.message"
|
|
show-icon
|
|
/>
|
|
</div>
|
|
</a-modal>
|
|
|
|
<!-- 排课弹窗 -->
|
|
<a-modal
|
|
v-model:open="scheduleModalVisible"
|
|
title="排课管理"
|
|
width="800px"
|
|
:footer="null"
|
|
>
|
|
<div v-if="selectedCourse">
|
|
<div class="flex justify-between items-center mb-4 py-3 px-4 bg-[#f9f9f9] rounded-lg">
|
|
<span>课程包:{{ selectedCourse.name }}</span>
|
|
<a-button type="primary" size="small" @click="showReserveModal(selectedCourse)">
|
|
<PlusOutlined /> 新增排课
|
|
</a-button>
|
|
</div>
|
|
|
|
<a-tabs v-model:activeKey="scheduleTab">
|
|
<a-tab-pane key="upcoming" tab="即将上课">
|
|
<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 === '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" @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="历史记录">
|
|
<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 === 'status'">
|
|
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
|
|
</template>
|
|
</template>
|
|
</a-table>
|
|
</a-tab-pane>
|
|
</a-tabs>
|
|
</div>
|
|
</a-modal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import { message, Modal } from 'ant-design-vue';
|
|
import {
|
|
PlusOutlined,
|
|
AppstoreOutlined,
|
|
BarChartOutlined,
|
|
CalendarOutlined,
|
|
EyeOutlined,
|
|
ScheduleOutlined,
|
|
MoreOutlined,
|
|
EditOutlined,
|
|
DeleteOutlined,
|
|
BookOutlined,
|
|
} from '@ant-design/icons-vue';
|
|
import {
|
|
getSchoolCourseList,
|
|
deleteSchoolCourse,
|
|
getReservations,
|
|
createReservation,
|
|
cancelReservation,
|
|
type SchoolCourse,
|
|
type CreateReservationData,
|
|
} from '@/api/school-course';
|
|
import { getTeachers, getClasses } from '@/api/school';
|
|
import type { Teacher, ClassInfo } from '@/api/school';
|
|
import dayjs from 'dayjs';
|
|
|
|
const router = useRouter();
|
|
|
|
const loading = ref(false);
|
|
const dataSource = ref<SchoolCourse[]>([]);
|
|
const searchKeyword = ref('');
|
|
|
|
// 预约相关
|
|
const reserveModalVisible = ref(false);
|
|
const scheduleModalVisible = ref(false);
|
|
const selectedCourse = ref<SchoolCourse | null>(null);
|
|
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 scheduleTab = ref('upcoming');
|
|
const conflictInfo = ref<{ hasConflict: boolean; message: string } | null>(null);
|
|
|
|
// 获取完整的文件 URL
|
|
const getFileUrl = (filePath: string | null | undefined): string => {
|
|
if (!filePath) return '';
|
|
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
|
return filePath;
|
|
}
|
|
const SERVER_BASE = import.meta.env.VITE_SERVER_BASE_URL || 'http://localhost:3000';
|
|
return `${SERVER_BASE}${filePath.startsWith('/') ? '' : '/'}${filePath}`;
|
|
};
|
|
|
|
const columns = [
|
|
{ title: 'ID', dataIndex: 'id', key: 'id', minWidth: 60 },
|
|
{ title: '校本课程包名称', key: 'name', minWidth: 200 },
|
|
{ title: '基于课程包', key: 'sourceCourse', minWidth: 200 },
|
|
{ title: '创建者', key: 'createdBy', minWidth: 100 },
|
|
{ title: '使用次数', dataIndex: 'usageCount', key: 'usageCount', minWidth: 100 },
|
|
{ title: '状态', dataIndex: 'status', key: 'status', minWidth: 80 },
|
|
{ title: '操作', key: 'action', minWidth: 200, fixed: 'right' as const },
|
|
];
|
|
|
|
const reservationColumns = [
|
|
{ title: '预约时间', dataIndex: 'scheduledDate', key: 'scheduledDate', minWidth: 160 },
|
|
{ title: '教师', dataIndex: ['teacher', 'name'], key: 'teacherName', minWidth: 100 },
|
|
{ title: '班级', dataIndex: ['class', 'name'], key: 'className', minWidth: 100 },
|
|
{ title: '状态', key: 'status', minWidth: 80 },
|
|
{ title: '操作', key: 'action', minWidth: 80, fixed: 'right' as const },
|
|
];
|
|
|
|
// 计算属性
|
|
const filteredData = computed(() => {
|
|
if (!searchKeyword.value) return dataSource.value;
|
|
const keyword = searchKeyword.value.toLowerCase();
|
|
return dataSource.value.filter(item =>
|
|
item.name.toLowerCase().includes(keyword) ||
|
|
item.sourceCourse?.name?.toLowerCase().includes(keyword)
|
|
);
|
|
});
|
|
|
|
const totalUsage = computed(() => {
|
|
return dataSource.value.reduce((sum, item) => sum + (item.usageCount || 0), 0);
|
|
});
|
|
|
|
const pendingReservations = computed(() => {
|
|
return reservations.value.filter(r => r.status === 'PENDING').length;
|
|
});
|
|
|
|
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 fetchData = async () => {
|
|
loading.value = true;
|
|
try {
|
|
const res = await getSchoolCourseList() as any;
|
|
dataSource.value = res || [];
|
|
} catch (error) {
|
|
console.error('获取校本课程包列表失败', 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 handleSearch = () => {
|
|
// 搜索已通过 computed 实现
|
|
};
|
|
|
|
const handleCreate = () => {
|
|
router.push('/school/school-courses/create');
|
|
};
|
|
|
|
const handleView = (record: any) => {
|
|
router.push(`/school/school-courses/${record.id}`);
|
|
};
|
|
|
|
const handleEdit = (record: any) => {
|
|
router.push(`/school/school-courses/${record.id}/edit`);
|
|
};
|
|
|
|
const confirmDelete = (record: any) => {
|
|
Modal.confirm({
|
|
title: '确认删除',
|
|
content: `确定要删除校本课程包"${record.name}"吗?`,
|
|
okText: '删除',
|
|
okType: 'danger',
|
|
onOk: () => handleDelete(record),
|
|
});
|
|
};
|
|
|
|
const handleDelete = async (record: any) => {
|
|
try {
|
|
await deleteSchoolCourse(record.id);
|
|
message.success('删除成功');
|
|
fetchData();
|
|
} catch (error) {
|
|
message.error('删除失败');
|
|
}
|
|
};
|
|
|
|
// 显示预约弹窗
|
|
const showReserveModal = (record: SchoolCourse) => {
|
|
selectedCourse.value = record;
|
|
reserveForm.value = {
|
|
teacherId: undefined as any,
|
|
classId: undefined as any,
|
|
scheduledDate: undefined as any,
|
|
note: '',
|
|
};
|
|
conflictInfo.value = null;
|
|
reserveModalVisible.value = true;
|
|
};
|
|
|
|
// 显示排课弹窗
|
|
const showScheduleModal = async (record: SchoolCourse) => {
|
|
selectedCourse.value = record;
|
|
scheduleTab.value = 'upcoming';
|
|
scheduleModalVisible.value = true;
|
|
await loadReservations(record.id);
|
|
};
|
|
|
|
// 加载预约列表
|
|
const loadReservations = async (schoolCourseId: number) => {
|
|
reservationLoading.value = true;
|
|
try {
|
|
const res = await getReservations(schoolCourseId);
|
|
reservations.value = (res as any) || [];
|
|
} catch (error) {
|
|
console.error('获取预约列表失败', error);
|
|
} finally {
|
|
reservationLoading.value = false;
|
|
}
|
|
};
|
|
|
|
// 处理预约
|
|
const handleReserve = async () => {
|
|
if (!selectedCourse.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(selectedCourse.value.id, data);
|
|
message.success('预约成功');
|
|
reserveModalVisible.value = false;
|
|
await loadReservations(selectedCourse.value.id);
|
|
} catch (error) {
|
|
console.error('预约失败', error);
|
|
message.error('预约失败');
|
|
} finally {
|
|
reserveLoading.value = false;
|
|
}
|
|
};
|
|
|
|
// 取消预约
|
|
const cancelReserve = async (record: any) => {
|
|
try {
|
|
await cancelReservation(record.id);
|
|
message.success('取消成功');
|
|
if (selectedCourse.value) {
|
|
await loadReservations(selectedCourse.value.id);
|
|
}
|
|
} catch (error) {
|
|
message.error('取消失败');
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
fetchData();
|
|
fetchBaseData();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.list-card :deep(.ant-card-head) {
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
|
|
:deep(.ant-table-thead > tr > th) {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.search-box :deep(.ant-input-affix-wrapper) {
|
|
border-radius: 12px;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.list-card :deep(.ant-card-head-wrapper) {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
}
|
|
|
|
.list-card :deep(.ant-card-extra) {
|
|
width: 100%;
|
|
}
|
|
|
|
.search-box :deep(.ant-input-search) {
|
|
width: 100% !important;
|
|
}
|
|
}
|
|
</style>
|