kindergarten/reading-platform-frontend/src/views/school/school-courses/SchoolCourseListView.vue
zhonghua 8bedf18f5d feat(移动端): 优化学校端页面排版
- 统一学校端各管理页的头部排版、背景和外边距,在移动端左对齐标题并增加合理留白

- 优化筛选条、搜索框和操作按钮在小屏下的栅格布局,确保控件整行展示且不被压缩

- 调整统计卡片、列表和空状态在手机上的排列方式,提升阅读性和交互体验

Made-with: Cursor
2026-03-04 15:15:45 +08:00

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>