feat(表格): 优化 a-table 移动端显示与布局

统一启用横向滚动和 minWidth 列宽,固定操作列到右侧,并为表头添加 white-space: nowrap 及排课页面移动端布局优化。

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-03 17:38:29 +08:00
parent 7b1260afd3
commit 822cfc3945
24 changed files with 758 additions and 1363 deletions

View File

@ -3,7 +3,12 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<!-- 设置移动端浏览器顶部状态栏背景色,避免显示为默认黑色 -->
<meta name="theme-color" content="#ffffff" />
<title>幼儿阅读教学服务平台</title>
</head>
<body>

View File

@ -24,6 +24,10 @@ body {
padding-right: env(safe-area-inset-right);
}
#app .ant-layout-header {
background-color: #ffffff !important;
}
#app {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',

View File

@ -20,11 +20,8 @@
<a-button @click="viewStats">
<BarChartOutlined /> 数据
</a-button>
<a-popconfirm
v-if="course.status === 'DRAFT' || course.status === 'ARCHIVED'"
title="确定删除此课程包吗?"
@confirm="deleteCourse"
>
<a-popconfirm v-if="course.status === 'DRAFT' || course.status === 'ARCHIVED'" title="确定删除此课程包吗?"
@confirm="deleteCourse">
<a-button danger>
<DeleteOutlined /> 删除
</a-button>
@ -223,12 +220,8 @@
</div>
<div class="section-body">
<div class="lesson-cards">
<div
v-for="lesson in courseLessons"
:key="lesson.id"
class="lesson-card"
:class="'lesson-type-' + lesson.lessonType?.toLowerCase()"
>
<div v-for="lesson in courseLessons" :key="lesson.id" class="lesson-card"
:class="'lesson-type-' + lesson.lessonType?.toLowerCase()">
<div class="lesson-header">
<div class="lesson-type-badge" :style="{ background: getLessonTypeBgColor(lesson.lessonType) }">
{{ translateLessonType(lesson.lessonType) }}
@ -254,29 +247,20 @@
<div class="lesson-section" v-if="hasLessonResources(lesson)">
<div class="lesson-section-title">核心资源</div>
<div class="resource-grid">
<div
v-if="lesson.videoPath"
class="resource-item"
@click="previewFile(lesson.videoPath, lesson.videoName || '绘本动画')"
>
<div v-if="lesson.videoPath" class="resource-item"
@click="previewFile(lesson.videoPath, lesson.videoName || '绘本动画')">
<VideoCameraOutlined class="resource-icon video" />
<span class="resource-name">{{ lesson.videoName || '绘本动画' }}</span>
<EyeOutlined class="resource-action" />
</div>
<div
v-if="lesson.pptPath"
class="resource-item"
@click="previewFile(lesson.pptPath, lesson.pptName || '教学课件')"
>
<div v-if="lesson.pptPath" class="resource-item"
@click="previewFile(lesson.pptPath, lesson.pptName || '教学课件')">
<FilePptOutlined class="resource-icon ppt" />
<span class="resource-name">{{ lesson.pptName || '教学课件' }}</span>
<EyeOutlined class="resource-action" />
</div>
<div
v-if="lesson.pdfPath"
class="resource-item"
@click="previewFile(lesson.pdfPath, lesson.pdfName || '电子绘本')"
>
<div v-if="lesson.pdfPath" class="resource-item"
@click="previewFile(lesson.pdfPath, lesson.pdfName || '电子绘本')">
<FilePdfOutlined class="resource-icon pdf" />
<span class="resource-name">{{ lesson.pdfName || '电子绘本' }}</span>
<EyeOutlined class="resource-action" />
@ -333,12 +317,8 @@
<VideoCameraOutlined style="color: #722ed1;" /> 视频资源
</div>
<div class="resource-list">
<div
v-for="(item, index) in allVideos"
:key="'video-' + index"
class="resource-item-card"
@click="previewFile(item.path, item.name)"
>
<div v-for="(item, index) in allVideos" :key="'video-' + index" class="resource-item-card"
@click="previewFile(item.path, item.name)">
<VideoCameraOutlined class="item-icon" style="color: #722ed1;" />
<span class="item-name">{{ item.name }}</span>
<PlayCircleOutlined class="item-action" />
@ -352,12 +332,8 @@
<AudioOutlined style="color: #52c41a;" /> 音频资源
</div>
<div class="resource-list">
<div
v-for="(item, index) in allAudios"
:key="'audio-' + index"
class="resource-item-card"
@click="previewFile(item.path, item.name)"
>
<div v-for="(item, index) in allAudios" :key="'audio-' + index" class="resource-item-card"
@click="previewFile(item.path, item.name)">
<AudioOutlined class="item-icon" style="color: #52c41a;" />
<span class="item-name">{{ item.name }}</span>
<PlayCircleOutlined class="item-action" />
@ -371,12 +347,8 @@
<FileTextOutlined style="color: #1890ff;" /> 文档资源
</div>
<div class="resource-list">
<div
v-for="(item, index) in allDocuments"
:key="'doc-' + index"
class="resource-item-card"
@click="previewFile(item.path, item.name)"
>
<div v-for="(item, index) in allDocuments" :key="'doc-' + index" class="resource-item-card"
@click="previewFile(item.path, item.name)">
<FilePdfOutlined v-if="item.type === 'pdf'" class="item-icon" style="color: #f5222d;" />
<FilePptOutlined v-else-if="item.type === 'ppt'" class="item-icon" style="color: #fa8c16;" />
<FileTextOutlined v-else class="item-icon" style="color: #1890ff;" />
@ -392,14 +364,8 @@
<PictureOutlined style="color: #13c2c2;" /> 图片资源
</div>
<div class="image-grid">
<img
v-for="(item, index) in allImages"
:key="'img-' + index"
:src="getFileUrl(item.path)"
:alt="item.name"
class="image-thumbnail"
@click="previewImage(getFileUrl(item.path))"
/>
<img v-for="(item, index) in allImages" :key="'img-' + index" :src="getFileUrl(item.path)"
:alt="item.name" class="image-thumbnail" @click="previewImage(getFileUrl(item.path))" />
</div>
</div>
</div>
@ -414,11 +380,7 @@
</a-modal>
<!-- 文件预览弹窗 -->
<FilePreviewModal
v-model:open="previewModalVisible"
:file-url="previewFileUrl"
:file-name="previewFileName"
/>
<FilePreviewModal v-model:open="previewModalVisible" :file-url="previewFileUrl" :file-name="previewFileName" />
</div>
</template>
@ -541,9 +503,9 @@ const domainTags = computed(() => {
//
const hasIntroContent = computed(() => {
return course.value.introSummary || course.value.introHighlights ||
course.value.introGoals || course.value.introSchedule ||
course.value.introKeyPoints || course.value.introMethods ||
course.value.introEvaluation || course.value.introNotes;
course.value.introGoals || course.value.introSchedule ||
course.value.introKeyPoints || course.value.introMethods ||
course.value.introEvaluation || course.value.introNotes;
});
//
@ -582,7 +544,7 @@ const allVideos = computed(() => {
paths.forEach((item: any) => {
videos.push({ path: item.path, name: item.name || '视频', source: '资源库' });
});
} catch {}
} catch { }
}
return videos;
});
@ -596,7 +558,7 @@ const allAudios = computed(() => {
paths.forEach((item: any) => {
audios.push({ path: item.path, name: item.name || '音频', source: '资源库' });
});
} catch {}
} catch { }
}
return audios;
});
@ -623,7 +585,7 @@ const allDocuments = computed(() => {
paths.forEach((item: any) => {
docs.push({ path: item.path, name: item.name || '电子绘本', type: 'pdf', source: '资源库' });
});
} catch {}
} catch { }
}
return docs;
});
@ -637,16 +599,16 @@ const allImages = computed(() => {
paths.forEach((item: any) => {
images.push({ path: item.path, name: item.name || '挂图' });
});
} catch {}
} catch { }
}
return images;
});
const hasAnyResources = computed(() => {
return allVideos.value.length > 0 ||
allAudios.value.length > 0 ||
allDocuments.value.length > 0 ||
allImages.value.length > 0;
allAudios.value.length > 0 ||
allDocuments.value.length > 0 ||
allImages.value.length > 0;
});
const totalResourcesCount = computed(() => {
@ -1119,9 +1081,17 @@ const fetchCourseDetail = async () => {
font-size: 18px;
margin-right: 8px;
&.video { color: #722ed1; }
&.ppt { color: #fa8c16; }
&.pdf { color: #f5222d; }
&.video {
color: #722ed1;
}
&.ppt {
color: #fa8c16;
}
&.pdf {
color: #f5222d;
}
}
.resource-name {

View File

@ -4,25 +4,15 @@
<a-row :gutter="16" align="middle">
<a-col :span="16">
<a-space>
<a-select
v-model:value="filters.grade"
placeholder="年级"
style="width: 120px"
allow-clear
@change="fetchCourses"
>
<a-select v-model:value="filters.grade" placeholder="年级" style="width: 120px" allow-clear
@change="fetchCourses">
<a-select-option value="small">小班</a-select-option>
<a-select-option value="middle">中班</a-select-option>
<a-select-option value="big">大班</a-select-option>
</a-select>
<a-select
v-model:value="filters.status"
placeholder="状态"
style="width: 120px"
allow-clear
@change="fetchCourses"
>
<a-select v-model:value="filters.status" placeholder="状态" style="width: 120px" allow-clear
@change="fetchCourses">
<a-select-option value="DRAFT">草稿</a-select-option>
<a-select-option value="PENDING">审核中</a-select-option>
<a-select-option value="REJECTED">已驳回</a-select-option>
@ -30,13 +20,8 @@
<a-select-option value="ARCHIVED">已下架</a-select-option>
</a-select>
<a-input-search
v-model:value="filters.keyword"
placeholder="搜索课程包名称"
style="width: 250px"
@search="fetchCourses"
@blur="fetchCourses"
/>
<a-input-search v-model:value="filters.keyword" placeholder="搜索课程包名称" style="width: 250px"
@search="fetchCourses" @blur="fetchCourses" />
</a-space>
</a-col>
<a-col :span="8" style="text-align: right;">
@ -53,25 +38,15 @@
</a-row>
</div>
<a-table
:columns="columns"
:data-source="courses"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
row-key="id"
>
<a-table :columns="columns" :data-source="courses" :loading="loading" :pagination="pagination"
:scroll="{ x: true }" @change="handleTableChange" row-key="id">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="course-name">
<a @click="viewCourse(record.id)">{{ record.name }}</a>
<div class="course-tags">
<a-tag
v-for="grade in parseGradeTags(record.gradeTags)"
:key="grade"
size="small"
:style="getGradeTagStyle(grade)"
>
<a-tag v-for="grade in parseGradeTags(record.gradeTags)" :key="grade" size="small"
:style="getGradeTagStyle(grade)">
{{ grade }}
</a-tag>
</div>
@ -111,7 +86,8 @@
<a-button size="small" @click="editCourse(record.id)">编辑</a-button>
<a-dropdown>
<a-button type="primary" size="small">
发布 <DownOutlined />
发布
<DownOutlined />
</a-button>
<template #overlay>
<a-menu @click="({ key }) => handlePublishAction(String(key), record)">
@ -120,10 +96,7 @@
</a-menu>
</template>
</a-dropdown>
<a-popconfirm
title="确定删除此课程包吗?"
@confirm="deleteCourseHandler(record.id)"
>
<a-popconfirm title="确定删除此课程包吗?" @confirm="deleteCourseHandler(record.id)">
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</template>
@ -150,7 +123,8 @@
<a-button size="small" @click="viewStats(record.id)">数据</a-button>
<a-dropdown>
<a-button size="small">
更多 <DownOutlined />
更多
<DownOutlined />
</a-button>
<template #overlay>
<a-menu @click="({ key }) => handleMoreAction(String(key), record)">
@ -166,10 +140,7 @@
<a-button size="small" @click="viewCourse(record.id)">查看</a-button>
<a-button size="small" @click="viewStats(record.id)">数据</a-button>
<a-button type="primary" size="small" @click="republishCourse(record.id)">重新发布</a-button>
<a-popconfirm
title="确定删除此课程包吗?删除后无法恢复"
@confirm="deleteCourseHandler(record.id)"
>
<a-popconfirm title="确定删除此课程包吗?删除后无法恢复" @confirm="deleteCourseHandler(record.id)">
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</template>
@ -179,18 +150,9 @@
</a-table>
<!-- 提交审核弹窗 -->
<a-modal
v-model:open="submitModalVisible"
title="提交审核"
:confirmLoading="submitting"
@ok="confirmSubmit"
>
<a-alert
v-if="validationResult && !validationResult.valid"
type="error"
:message="`发现 ${validationResult.errors.length} 个问题需要修复`"
style="margin-bottom: 16px;"
>
<a-modal v-model:open="submitModalVisible" title="提交审核" :confirmLoading="submitting" @ok="confirmSubmit">
<a-alert v-if="validationResult && !validationResult.valid" type="error"
:message="`发现 ${validationResult.errors.length} 个问题需要修复`" style="margin-bottom: 16px;">
<template #description>
<ul style="margin: 0; padding-left: 20px;">
<li v-for="error in validationResult?.errors" :key="error.code">
@ -200,12 +162,8 @@
</template>
</a-alert>
<a-alert
v-if="validationResult && validationResult.warnings.length > 0"
type="warning"
:message="`有 ${validationResult.warnings.length} 条建议`"
style="margin-bottom: 16px;"
>
<a-alert v-if="validationResult && validationResult.warnings.length > 0" type="warning"
:message="`有 ${validationResult.warnings.length} 条建议`" style="margin-bottom: 16px;">
<template #description>
<ul style="margin: 0; padding-left: 20px;">
<li v-for="warning in validationResult?.warnings" :key="warning.code">
@ -258,13 +216,13 @@ const pagination = reactive({
});
const columns = [
{ title: '课程包名称', key: 'name', width: 250 },
{ title: '关联绘本', key: 'pictureBook', width: 120 },
{ title: '状态', key: 'status', width: 90 },
{ title: '版本', key: 'version', width: 70 },
{ title: '数据统计', key: 'stats', width: 130 },
{ title: '最近更新', key: 'updatedAt', width: 120 },
{ title: '操作', key: 'actions', width: 200, fixed: 'right' as const },
{ title: '课程包名称', key: 'name', minWidth: 220 },
{ title: '关联绘本', key: 'pictureBook', minWidth: 120 },
{ title: '状态', key: 'status', minWidth: 90 },
{ title: '版本', key: 'version', minWidth: 70 },
{ title: '数据统计', key: 'stats', minWidth: 130 },
{ title: '最近更新', key: 'updatedAt', minWidth: 120 },
{ title: '操作', key: 'actions', minWidth: 220, fixed: 'right' as const },
];
//
@ -547,6 +505,10 @@ const formatDate = (dateStr: string) => {
<style scoped lang="scss">
.course-list {
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
.page-header {
margin-bottom: 16px;
}

View File

@ -1,20 +1,11 @@
<template>
<div class="course-review">
<div class="page-header">
<a-page-header
title="审核管理"
sub-title="审核待发布的课程包"
@back="$router.back()"
>
<a-page-header title="审核管理" sub-title="审核待发布的课程包" @back="$router.back()">
<template #extra>
<a-space>
<a-select
v-model:value="filters.status"
placeholder="状态筛选"
style="width: 120px"
allow-clear
@change="fetchCourses"
>
<a-select v-model:value="filters.status" placeholder="状态筛选" style="width: 120px" allow-clear
@change="fetchCourses">
<a-select-option value="PENDING">待审核</a-select-option>
<a-select-option value="REJECTED">已驳回</a-select-option>
</a-select>
@ -26,25 +17,15 @@
</a-page-header>
</div>
<a-table
:columns="columns"
:data-source="courses"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
row-key="id"
>
<a-table :columns="columns" :data-source="courses" :loading="loading" :pagination="pagination"
@change="handleTableChange" row-key="id" :scroll="{ x: true }">
<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)"
>
<a-tag v-for="grade in parseGradeTags(record.gradeTags)" :key="grade" size="small"
:style="getGradeTagStyle(grade)">
{{ grade }}
</a-tag>
</div>
@ -86,14 +67,8 @@
</a-table>
<!-- 审核弹窗 -->
<a-modal
v-model:open="reviewModalVisible"
:title="`审核: ${currentCourse?.name || ''}`"
width="800px"
:confirmLoading="reviewing"
@ok="submitReview"
@cancel="closeReviewModal"
>
<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>
@ -109,12 +84,8 @@
<a-spin :spinning="loadingDetail">
<div v-if="currentCourse" class="review-content">
<!-- 自动检查项 -->
<a-alert
v-if="validationResult"
:type="validationResult.valid ? 'success' : 'warning'"
:message="validationResult.valid ? '自动检查通过' : '自动检查有警告'"
style="margin-bottom: 16px;"
>
<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>
@ -168,11 +139,8 @@
<!-- 审核意见 -->
<a-form layout="vertical">
<a-form-item label="审核意见" required>
<a-textarea
v-model:value="reviewComment"
placeholder="请输入审核意见(驳回时必填,通过时可选)"
:auto-size="{ minRows: 3, maxRows: 6 }"
/>
<a-textarea v-model:value="reviewComment" placeholder="请输入审核意见(驳回时必填,通过时可选)"
:auto-size="{ minRows: 3, maxRows: 6 }" />
</a-form-item>
</a-form>
</div>
@ -180,11 +148,7 @@
</a-modal>
<!-- 查看驳回原因弹窗 -->
<a-modal
v-model:open="rejectReasonVisible"
title="驳回原因"
:footer="null"
>
<a-modal v-model:open="rejectReasonVisible" title="驳回原因" :footer="null">
<a-alert type="error" :message="rejectReasonCourse?.reviewComment" show-icon />
</a-modal>
</div>
@ -215,11 +179,11 @@ const pagination = reactive({
});
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 },
{ title: '课程包名称', key: 'name', minWidth: 250 },
{ title: '状态', key: 'status', minWidth: 100 },
{ title: '提交时间', key: 'submittedAt', minWidth: 150 },
{ title: '自动检查', key: 'autoCheck', minWidth: 120 },
{ title: '操作', key: 'actions', minWidth: 150, fixed: 'right' as const },
];
//
@ -383,5 +347,9 @@ const formatDate = (date: string | Date) => {
max-height: 60vh;
overflow-y: auto;
}
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
}
</style>

View File

@ -1,9 +1,6 @@
<template>
<div class="course-stats">
<a-page-header
:title="courseName ? `${courseName} - 数据统计` : '数据统计'"
@back="() => router.back()"
/>
<a-page-header :title="courseName ? `${courseName} - 数据统计` : '数据统计'" @back="() => router.back()" />
<a-spin :spinning="loading">
<div style="margin-top: 16px;">
@ -11,40 +8,22 @@
<a-row :gutter="16" style="margin-bottom: 24px;">
<a-col :span="6">
<a-card>
<a-statistic
title="总授课次数"
:value="stats.totalLessons || 0"
prefix="📚"
/>
<a-statistic title="总授课次数" :value="stats.totalLessons || 0" prefix="📚" />
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic
title="参与教师数"
:value="stats.totalTeachers || 0"
prefix="👨‍🏫"
/>
<a-statistic title="参与教师数" :value="stats.totalTeachers || 0" prefix="👨‍🏫" />
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic
title="参与学生数"
:value="stats.totalStudents || 0"
prefix="👦"
/>
<a-statistic title="参与学生数" :value="stats.totalStudents || 0" prefix="👦" />
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic
title="平均评分"
:value="stats.avgRating || 0"
:precision="1"
prefix="⭐"
suffix="/ 5"
/>
<a-statistic title="平均评分" :value="stats.avgRating || 0" :precision="1" prefix="⭐" suffix="/ 5" />
</a-card>
</a-col>
</a-row>
@ -56,11 +35,7 @@
<a-card title="授课记录趋势" :bordered="false">
<div v-if="stats.lessonTrend && stats.lessonTrend.length > 0" style="height: 200px;">
<a-row :gutter="[8, 16]">
<a-col
v-for="(item, index) in stats.lessonTrend"
:key="index"
:span="24 / stats.lessonTrend.length"
>
<a-col v-for="(item, index) in stats.lessonTrend" :key="index" :span="24 / stats.lessonTrend.length">
<div style="text-align: center;">
<div style="font-size: 12px; color: #666;">{{ item.date }}</div>
<div style="font-size: 20px; font-weight: bold; color: #1890ff; margin-top: 4px;">
@ -80,50 +55,25 @@
<div v-if="stats.feedbackDistribution">
<a-row :gutter="[16, 16]">
<a-col :span="12">
<a-statistic
title="设计质量"
:value="stats.feedbackDistribution.designQuality || 0"
:precision="1"
suffix="/ 5"
/>
<a-progress
:percent="(stats.feedbackDistribution.designQuality || 0) * 20"
status="active"
size="small"
/>
<a-statistic title="设计质量" :value="stats.feedbackDistribution.designQuality || 0" :precision="1"
suffix="/ 5" />
<a-progress :percent="(stats.feedbackDistribution.designQuality || 0) * 20" status="active"
size="small" />
</a-col>
<a-col :span="12">
<a-statistic
title="学生参与度"
:value="stats.feedbackDistribution.participation || 0"
:precision="1"
suffix="/ 5"
/>
<a-progress
:percent="(stats.feedbackDistribution.participation || 0) * 20"
status="active"
size="small"
/>
<a-statistic title="学生参与度" :value="stats.feedbackDistribution.participation || 0" :precision="1"
suffix="/ 5" />
<a-progress :percent="(stats.feedbackDistribution.participation || 0) * 20" status="active"
size="small" />
</a-col>
<a-col :span="12">
<a-statistic
title="目标达成度"
:value="stats.feedbackDistribution.goalAchievement || 0"
:precision="1"
suffix="/ 5"
/>
<a-progress
:percent="(stats.feedbackDistribution.goalAchievement || 0) * 20"
status="active"
size="small"
/>
<a-statistic title="目标达成度" :value="stats.feedbackDistribution.goalAchievement || 0" :precision="1"
suffix="/ 5" />
<a-progress :percent="(stats.feedbackDistribution.goalAchievement || 0) * 20" status="active"
size="small" />
</a-col>
<a-col :span="12">
<a-statistic
title="反馈数量"
:value="stats.feedbackDistribution.totalFeedbacks || 0"
suffix="条"
/>
<a-statistic title="反馈数量" :value="stats.feedbackDistribution.totalFeedbacks || 0" suffix="条" />
</a-col>
</a-row>
</div>
@ -136,13 +86,8 @@
<!-- 最近授课记录 -->
<a-col :span="24">
<a-card title="最近授课记录" :bordered="false">
<a-table
:columns="lessonColumns"
:data-source="stats.recentLessons || []"
:pagination="false"
size="small"
:loading="loading"
>
<a-table :columns="lessonColumns" :data-source="stats.recentLessons || []" :pagination="false"
size="small" :loading="loading" :scroll="{ x: true }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'teacher'">
{{ record.teacher?.name || '-' }}
@ -163,7 +108,8 @@
</template>
</template>
</a-table>
<div v-if="!stats.recentLessons || stats.recentLessons.length === 0" style="padding: 40px; text-align: center;">
<div v-if="!stats.recentLessons || stats.recentLessons.length === 0"
style="padding: 40px; text-align: center;">
<a-empty description="暂无授课记录" />
</div>
</a-card>
@ -177,42 +123,26 @@
<a-row :gutter="16">
<a-col :span="6">
<a-card size="small">
<a-statistic
title="平均专注度"
:value="stats.studentPerformance?.avgFocus || 0"
:precision="1"
suffix="/ 5"
/>
<a-statistic title="平均专注度" :value="stats.studentPerformance?.avgFocus || 0" :precision="1"
suffix="/ 5" />
</a-card>
</a-col>
<a-col :span="6">
<a-card size="small">
<a-statistic
title="平均参与度"
:value="stats.studentPerformance?.avgParticipation || 0"
:precision="1"
suffix="/ 5"
/>
<a-statistic title="平均参与度" :value="stats.studentPerformance?.avgParticipation || 0" :precision="1"
suffix="/ 5" />
</a-card>
</a-col>
<a-col :span="6">
<a-card size="small">
<a-statistic
title="平均兴趣度"
:value="stats.studentPerformance?.avgInterest || 0"
:precision="1"
suffix="/ 5"
/>
<a-statistic title="平均兴趣度" :value="stats.studentPerformance?.avgInterest || 0" :precision="1"
suffix="/ 5" />
</a-card>
</a-col>
<a-col :span="6">
<a-card size="small">
<a-statistic
title="平均理解度"
:value="stats.studentPerformance?.avgUnderstanding || 0"
:precision="1"
suffix="/ 5"
/>
<a-statistic title="平均理解度" :value="stats.studentPerformance?.avgUnderstanding || 0" :precision="1"
suffix="/ 5" />
</a-card>
</a-col>
</a-row>
@ -238,11 +168,11 @@ const courseName = ref('');
const stats = ref<any>({});
const lessonColumns = [
{ title: '教师', key: 'teacher', width: 120 },
{ title: '班级', key: 'class', width: 120 },
{ title: '授课时间', key: 'date', width: 150 },
{ title: '时长', key: 'duration', width: 100 },
{ title: '状态', key: 'status', width: 100 },
{ title: '教师', key: 'teacher', minWidth: 120 },
{ title: '班级', key: 'class', minWidth: 120 },
{ title: '授课时间', key: 'date', minWidth: 150 },
{ title: '时长', key: 'duration', minWidth: 100 },
{ title: '状态', key: 'status', minWidth: 100 },
];
onMounted(async () => {
@ -306,6 +236,10 @@ const getLessonStatusText = (status: string) => {
background: #f5f5f5;
min-height: calc(100vh - 64px);
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
:deep(.ant-page-header) {
background: white;
padding: 16px 24px;

View File

@ -7,31 +7,16 @@
</a-button>
</div>
<a-alert
message="排课参考说明"
type="info"
show-icon
style="margin-bottom: 16px"
>
<a-alert message="排课参考说明" type="info" show-icon style="margin-bottom: 16px">
<template #description>
此表格为排课参考帮助教师了解课程安排建议可根据实际情况调整
</template>
</a-alert>
<a-table
:columns="columns"
:data-source="tableData"
:pagination="false"
bordered
size="middle"
>
<a-table :columns="columns" :data-source="tableData" :pagination="false" bordered size="middle">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'dayOfWeek'">
<a-select
v-model:value="record.dayOfWeek"
style="width: 100%"
@change="handleChange"
>
<a-select v-model:value="record.dayOfWeek" style="width: 100%" @change="handleChange">
<a-select-option value="周一">周一</a-select-option>
<a-select-option value="周二">周二</a-select-option>
<a-select-option value="周三">周三</a-select-option>
@ -40,11 +25,7 @@
</a-select>
</template>
<template v-else-if="column.key === 'lessonType'">
<a-select
v-model:value="record.lessonType"
style="width: 100%"
@change="handleChange"
>
<a-select v-model:value="record.lessonType" style="width: 100%" @change="handleChange">
<a-select-option value="导入课">导入课</a-select-option>
<a-select-option value="集体课">集体课</a-select-option>
<a-select-option value="健康领域">健康领域</a-select-option>
@ -56,25 +37,13 @@
</a-select>
</template>
<template v-else-if="column.key === 'lessonName'">
<a-input
v-model:value="record.lessonName"
placeholder="课程名称"
@change="handleChange"
/>
<a-input v-model:value="record.lessonName" placeholder="课程名称" @change="handleChange" />
</template>
<template v-else-if="column.key === 'activity'">
<a-input
v-model:value="record.activity"
placeholder="区域活动"
@change="handleChange"
/>
<a-input v-model:value="record.activity" placeholder="区域活动" @change="handleChange" />
</template>
<template v-else-if="column.key === 'note'">
<a-input
v-model:value="record.note"
placeholder="备注"
@change="handleChange"
/>
<a-input v-model:value="record.note" placeholder="备注" @change="handleChange" />
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" danger @click="removeRow(index)">
@ -230,6 +199,7 @@ defineExpose({
}
:deep(.ant-table) {
.ant-input,
.ant-select {
width: 100%;

View File

@ -34,20 +34,12 @@
<a-divider>包含课程包</a-divider>
<a-table
:columns="courseColumns"
:data-source="pkg?.courses || []"
row-key="courseId"
:pagination="false"
>
<a-table :columns="courseColumns" :data-source="pkg?.courses || []" row-key="courseId" :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'course'">
<div class="flex items-center gap-3">
<img
v-if="record.course?.coverImagePath"
:src="record.course.coverImagePath"
class="w-12 h-12 object-cover rounded"
/>
<img v-if="record.course?.coverImagePath" :src="record.course.coverImagePath"
class="w-12 h-12 object-cover rounded" />
<span>{{ record.course?.name }}</span>
</div>
</template>

View File

@ -8,12 +8,7 @@
<a-button @click="router.back()">返回</a-button>
</template>
<a-form
:model="form"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 16 }"
@finish="handleSave"
>
<a-form :model="form" :label-col="{ span: 4 }" :wrapper-col="{ span: 16 }" @finish="handleSave">
<a-form-item label="套餐名称" name="name" :rules="[{ required: true, message: '请输入套餐名称' }]">
<a-input v-model:value="form.name" placeholder="请输入套餐名称" />
</a-form-item>
@ -49,13 +44,8 @@
<a-form-item label="已选课程包">
<div class="course-list">
<a-table
:columns="courseColumns"
:data-source="selectedCourses"
row-key="courseId"
size="small"
:pagination="false"
>
<a-table :columns="courseColumns" :data-source="selectedCourses" row-key="courseId" size="small"
:pagination="false" :scroll="{ x: true }">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'sortOrder'">
<a-input-number v-model:value="record.sortOrder" :min="0" size="small" />
@ -73,7 +63,9 @@
</template>
</a-table>
<a-button type="dashed" block class="mt-4" @click="showCourseSelector = true">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
添加课程包
</a-button>
</div>
@ -89,20 +81,9 @@
</a-card>
<!-- 课程选择器 -->
<a-modal
v-model:open="showCourseSelector"
title="选择课程包"
width="800px"
@ok="handleAddCourses"
>
<a-table
:columns="selectorColumns"
:data-source="availableCourses"
:row-selection="rowSelection"
row-key="id"
size="small"
:loading="loadingCourses"
>
<a-modal v-model:open="showCourseSelector" title="选择课程包" width="800px" @ok="handleAddCourses">
<a-table :columns="selectorColumns" :data-source="availableCourses" :row-selection="rowSelection" row-key="id"
size="small" :loading="loadingCourses" :scroll="{ x: true }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'gradeTags'">
<a-tag v-for="tag in parseGradeTags(record.gradeTags)" :key="tag">{{ tag }}</a-tag>
@ -145,16 +126,16 @@ const form = reactive({
const selectedCourses = ref<{ courseId: number; gradeLevel: string; sortOrder: number; courseName: string }[]>([]);
const courseColumns = [
{ title: '课程包', dataIndex: 'courseName', key: 'courseName' },
{ title: '年级', dataIndex: 'gradeLevel', key: 'gradeLevel', width: 120 },
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 80 },
{ title: '操作', key: 'action', width: 80 },
{ title: '课程包', dataIndex: 'courseName', key: 'courseName', minWidth: 200 },
{ title: '年级', dataIndex: 'gradeLevel', key: 'gradeLevel', minWidth: 120, maxWidth: 140 },
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', minWidth: 80, maxWidth: 100 },
{ title: '操作', key: 'action', minWidth: 100, fixed: 'right' as const },
];
const selectorColumns = [
{ title: '课程包名称', dataIndex: 'name', key: 'name' },
{ title: '年级标签', dataIndex: 'gradeTags', key: 'gradeTags' },
{ title: '时长', dataIndex: 'duration', key: 'duration', width: 80 },
{ title: '课程包名称', dataIndex: 'name', key: 'name', minWidth: 200 },
{ title: '年级标签', dataIndex: 'gradeTags', key: 'gradeTags', minWidth: 160 },
{ title: '时长', dataIndex: 'duration', key: 'duration', minWidth: 80, maxWidth: 100 },
];
const rowSelection = computed(() => ({
@ -276,4 +257,8 @@ onMounted(() => {
<style scoped>
/* 仅保留 :deep / @keyframes / scrollbar / @media */
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
</style>

View File

@ -6,20 +6,16 @@
</template>
<template #extra>
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
新建套餐
</a-button>
</template>
<!-- 筛选 -->
<div class="mb-4">
<a-select
v-model:value="filters.status"
placeholder="状态筛选"
style="width: 150px"
allowClear
@change="fetchData"
>
<a-select v-model:value="filters.status" placeholder="状态筛选" style="width: 150px" allowClear @change="fetchData">
<a-select-option value="DRAFT">草稿</a-select-option>
<a-select-option value="PENDING_REVIEW">待审核</a-select-option>
<a-select-option value="APPROVED">已通过</a-select-option>
@ -30,14 +26,8 @@
</div>
<!-- 表格 -->
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination" row-key="id"
:scroll="{ x: true }" @change="handleTableChange">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'price'">
¥{{ (record.price / 100).toFixed(2) }}
@ -51,35 +41,16 @@
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">查看</a-button>
<a-button
type="link"
size="small"
v-if="record.status === 'DRAFT'"
@click="handleEdit(record)"
>
<a-button type="link" size="small" v-if="record.status === 'DRAFT'" @click="handleEdit(record)">
编辑
</a-button>
<a-button
type="link"
size="small"
v-if="record.status === 'DRAFT'"
@click="handleSubmit(record)"
>
<a-button type="link" size="small" v-if="record.status === 'DRAFT'" @click="handleSubmit(record)">
提交
</a-button>
<a-button
type="link"
size="small"
v-if="record.status === 'APPROVED'"
@click="handlePublish(record)"
>
<a-button type="link" size="small" v-if="record.status === 'APPROVED'" @click="handlePublish(record)">
发布
</a-button>
<a-popconfirm
v-if="record.status === 'DRAFT'"
title="确定要删除吗?"
@confirm="handleDelete(record)"
>
<a-popconfirm v-if="record.status === 'DRAFT'" title="确定要删除吗?" @confirm="handleDelete(record)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
@ -112,14 +83,14 @@ const pagination = reactive({
});
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: '套餐名称', dataIndex: 'name', key: 'name' },
{ title: '价格', dataIndex: 'price', key: 'price', width: 100 },
{ title: '适用年级', dataIndex: 'gradeLevels', key: 'gradeLevels', width: 150 },
{ title: '课程数', dataIndex: 'courseCount', key: 'courseCount', width: 80 },
{ title: '使用学校数', dataIndex: 'tenantCount', key: 'tenantCount', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '操作', key: 'action', width: 200 },
{ title: 'ID', dataIndex: 'id', key: 'id', minWidth: 80, maxWidth: 100 },
{ title: '套餐名称4', dataIndex: 'name', key: 'name', minWidth: 300, width: 300 },
{ title: '价格', dataIndex: 'price', key: 'price', minWidth: 300, width: 300 },
{ title: '适用年级', dataIndex: 'gradeLevels', key: 'gradeLevels', minWidth: 300, width: 300 },
{ title: '课程数', dataIndex: 'courseCount', key: 'courseCount', minWidth: 300, width: 300, maxWidth: 300 },
{ title: '使用学校数', dataIndex: 'tenantCount', key: 'tenantCount', minWidth: 300, width: 300, maxWidth: 300 },
{ title: '状态', dataIndex: 'status', key: 'status', minWidth: 110, maxWidth: 300, width: 300 },
{ title: '操作', key: 'action', minWidth: 220, fixed: 'right' as const },
];
const statusColors: Record<string, string> = {
@ -221,3 +192,9 @@ onMounted(() => {
fetchData();
});
</script>
<style scoped>
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
</style>

View File

@ -1,37 +1,42 @@
<template>
<div class="resource-list-view">
<a-page-header
title="资源库管理"
sub-title="管理平台数字资源"
/>
<a-page-header title="资源库管理" sub-title="管理平台数字资源" />
<!-- 统计卡片 -->
<a-row :gutter="16" style="margin-bottom: 16px;">
<a-col :span="6">
<a-card>
<a-statistic title="资源库总数" :value="stats.totalLibraries">
<template #prefix><FolderOutlined /></template>
<template #prefix>
<FolderOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic title="资源总数" :value="stats.totalItems">
<template #prefix><FileOutlined /></template>
<template #prefix>
<FileOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic title="绘本资源" :value="stats.itemsByLibraryType?.PICTURE_BOOK || 0">
<template #prefix><BookOutlined /></template>
<template #prefix>
<BookOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic title="教学材料" :value="stats.itemsByLibraryType?.MATERIAL || 0">
<template #prefix><AppstoreOutlined /></template>
<template #prefix>
<AppstoreOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
@ -41,11 +46,15 @@
<template #extra>
<a-space>
<a-button @click="showLibraryModal">
<template #icon><FolderAddOutlined /></template>
<template #icon>
<FolderAddOutlined />
</template>
新建资源库
</a-button>
<a-button type="primary" @click="showUploadModal">
<template #icon><UploadOutlined /></template>
<template #icon>
<UploadOutlined />
</template>
上传资源
</a-button>
</a-space>
@ -55,36 +64,23 @@
<div class="filter-bar" style="margin-bottom: 16px;">
<a-row :gutter="16">
<a-col :span="6">
<a-input-search
v-model:value="filters.keyword"
placeholder="搜索资源名称"
allow-clear
@search="fetchItems"
>
<template #prefix><SearchOutlined /></template>
<a-input-search v-model:value="filters.keyword" placeholder="搜索资源名称" allow-clear @search="fetchItems">
<template #prefix>
<SearchOutlined />
</template>
</a-input-search>
</a-col>
<a-col :span="4">
<a-select
v-model:value="filters.libraryId"
placeholder="选择资源库"
allow-clear
style="width: 100%"
@change="fetchItems"
>
<a-select v-model:value="filters.libraryId" placeholder="选择资源库" allow-clear style="width: 100%"
@change="fetchItems">
<a-select-option v-for="lib in libraries" :key="lib.id" :value="lib.id">
{{ lib.name }}
</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-select
v-model:value="filters.fileType"
placeholder="资源类型"
allow-clear
style="width: 100%"
@change="fetchItems"
>
<a-select v-model:value="filters.fileType" placeholder="资源类型" allow-clear style="width: 100%"
@change="fetchItems">
<a-select-option value="IMAGE">图片</a-select-option>
<a-select-option value="PDF">PDF</a-select-option>
<a-select-option value="VIDEO">视频</a-select-option>
@ -96,15 +92,8 @@
</a-row>
</div>
<a-table
:columns="columns"
:data-source="items"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
@change="handleTableChange"
row-key="id"
>
<a-table :columns="columns" :data-source="items" :loading="loading" :pagination="pagination"
:row-selection="rowSelection" @change="handleTableChange" row-key="id" :scroll="{ x: true }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'resource'">
<div class="resource-item">
@ -146,12 +135,8 @@
<a-button type="link" size="small" @click="openEditModal(record as ResourceItem)">
编辑
</a-button>
<a-button
type="link"
size="small"
:disabled="!canPreview(record as ResourceItem)"
@click="previewResource(record as ResourceItem)"
>
<a-button type="link" size="small" :disabled="!canPreview(record as ResourceItem)"
@click="previewResource(record as ResourceItem)">
预览
</a-button>
<a-button type="link" size="small" @click="downloadResource(record as ResourceItem)">
@ -180,12 +165,7 @@
</a-card>
<!-- 新建资源库弹窗 -->
<a-modal
v-model:open="libraryModalVisible"
title="新建资源库"
@ok="handleCreateLibrary"
:confirm-loading="submitting"
>
<a-modal v-model:open="libraryModalVisible" title="新建资源库" @ok="handleCreateLibrary" :confirm-loading="submitting">
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="资源库名称" required>
<a-input v-model:value="libraryForm.name" placeholder="请输入资源库名称" />
@ -204,13 +184,8 @@
</a-modal>
<!-- 上传资源弹窗 -->
<a-modal
v-model:open="uploadModalVisible"
title="上传资源"
width="600px"
@ok="handleUpload"
:confirm-loading="uploading"
>
<a-modal v-model:open="uploadModalVisible" title="上传资源" width="600px" @ok="handleUpload"
:confirm-loading="uploading">
<a-form :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-form-item label="目标资源库" required>
<a-select v-model:value="uploadForm.libraryId" placeholder="请选择资源库">
@ -232,15 +207,8 @@
</a-form-item>
<a-form-item label="选择文件" required>
<a-upload
v-model:file-list="uploadForm.files"
:action="uploadUrl"
:headers="uploadHeaders"
:data="{ type: 'resources' }"
:max-count="10"
list-type="text"
@change="handleUploadChange"
>
<a-upload v-model:file-list="uploadForm.files" :action="uploadUrl" :headers="uploadHeaders"
:data="{ type: 'resources' }" :max-count="10" list-type="text" @change="handleUploadChange">
<a-button>
<UploadOutlined /> 选择文件
</a-button>
@ -251,12 +219,7 @@
</a-form-item>
<a-form-item label="资源标签">
<a-select
v-model:value="uploadForm.tags"
mode="tags"
placeholder="输入标签,按回车添加"
style="width: 100%"
>
<a-select v-model:value="uploadForm.tags" mode="tags" placeholder="输入标签,按回车添加" style="width: 100%">
<a-select-option value="绘本阅读">绘本阅读</a-select-option>
<a-select-option value="儿歌">儿歌</a-select-option>
<a-select-option value="游戏">游戏</a-select-option>
@ -268,12 +231,7 @@
</a-modal>
<!-- 编辑资源弹窗 -->
<a-modal
v-model:open="editModalVisible"
title="编辑资源"
@ok="handleEditConfirm"
:confirm-loading="submitting"
>
<a-modal v-model:open="editModalVisible" title="编辑资源" @ok="handleEditConfirm" :confirm-loading="submitting">
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="资源名称">
<a-input v-model:value="editForm.title" placeholder="资源名称" />
@ -282,29 +240,20 @@
<a-textarea v-model:value="editForm.description" placeholder="描述" :rows="3" />
</a-form-item>
<a-form-item label="标签">
<a-select
v-model:value="editForm.tags"
mode="tags"
placeholder="输入标签"
style="width: 100%"
/>
<a-select v-model:value="editForm.tags" mode="tags" placeholder="输入标签" style="width: 100%" />
</a-form-item>
</a-form>
</a-modal>
<!-- 预览弹窗 -->
<a-modal
v-model:open="previewModalVisible"
:title="currentPreviewResource?.title"
width="800px"
:footer="null"
>
<a-modal v-model:open="previewModalVisible" :title="currentPreviewResource?.title" width="800px" :footer="null">
<div class="preview-container">
<div v-if="currentPreviewResource?.fileType === 'IMAGE'">
<img :src="getFileUrl(currentPreviewResource?.filePath)" style="max-width: 100%;" />
</div>
<div v-else-if="currentPreviewResource?.fileType === 'VIDEO'">
<video :src="getFileUrl(currentPreviewResource?.filePath)" controls style="width: 100%; max-height: 500px;"></video>
<video :src="getFileUrl(currentPreviewResource?.filePath)" controls
style="width: 100%; max-height: 500px;"></video>
</div>
<div v-else-if="currentPreviewResource?.fileType === 'AUDIO'">
<audio :src="getFileUrl(currentPreviewResource?.filePath)" controls style="width: 100%;"></audio>
@ -421,11 +370,11 @@ const uploadHeaders = computed(() => ({
}));
const columns = [
{ title: '资源', key: 'resource', width: 300 },
{ title: '所属资源库', key: 'library', width: 150 },
{ title: '标签', key: 'tags', width: 200 },
{ title: '上传时间', key: 'createdAt', width: 120 },
{ title: '操作', key: 'action', width: 200, fixed: 'right' as const },
{ title: '资源', key: 'resource', minWidth: 300 },
{ title: '所属资源库', key: 'library', minWidth: 150 },
{ title: '标签', key: 'tags', minWidth: 200 },
{ title: '上传时间', key: 'createdAt', minWidth: 120 },
{ title: '操作', key: 'action', minWidth: 200, fixed: 'right' as const },
];
const getFileTypeLabel = (type: FileTypeType) => {
@ -669,6 +618,10 @@ onMounted(() => {
background: #f5f5f5;
min-height: calc(100vh - 64px);
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
:deep(.ant-page-header) {
background: white;
padding: 16px 24px;

View File

@ -5,33 +5,18 @@
<!-- 搜索表单 -->
<a-form layout="inline" :model="searchForm" class="mb-4">
<a-form-item label="关键词">
<a-input
v-model:value="searchForm.keyword"
placeholder="学校名称/账号/联系人"
allow-clear
class="w-[200px]"
@pressEnter="handleSearch"
/>
<a-input v-model:value="searchForm.keyword" placeholder="学校名称/账号/联系人" allow-clear class="w-[200px]"
@pressEnter="handleSearch" />
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="searchForm.status"
placeholder="全部状态"
allow-clear
class="w-[120px]"
>
<a-select v-model:value="searchForm.status" placeholder="全部状态" allow-clear class="w-[120px]">
<a-select-option value="ACTIVE">生效中</a-select-option>
<a-select-option value="EXPIRED">已过期</a-select-option>
<a-select-option value="SUSPENDED">已暂停</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="套餐">
<a-select
v-model:value="searchForm.packageType"
placeholder="全部套餐"
allow-clear
class="w-[120px]"
>
<a-select v-model:value="searchForm.packageType" placeholder="全部套餐" allow-clear class="w-[120px]">
<a-select-option value="BASIC">基础版</a-select-option>
<a-select-option value="STANDARD">标准版</a-select-option>
<a-select-option value="ADVANCED">高级版</a-select-option>
@ -41,7 +26,9 @@
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
<template #icon>
<SearchOutlined />
</template>
搜索
</a-button>
<a-button @click="handleReset">重置</a-button>
@ -49,20 +36,17 @@
</a-form-item>
<a-form-item style="float: right">
<a-button type="primary" @click="showAddModal">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
添加租户
</a-button>
</a-form-item>
</a-form>
<!-- 数据表格 -->
<a-table
:columns="columns"
:data-source="tenants"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
>
<a-table :columns="columns" :data-source="tenants" :loading="loading" :pagination="pagination"
:scroll="{ x: true }" @change="handleTableChange">
<template #bodyCell="{ column, record }: any">
<template v-if="column.key === 'name'">
<a-button type="link" @click="handleViewDetail(record)">
@ -87,16 +71,14 @@
</div>
</template>
<template v-else-if="column.key === 'status'">
<a-badge
:status="getStatusType(record.status)"
:text="getStatusText(record.status)"
/>
<a-badge :status="getStatusType(record.status)" :text="getStatusText(record.status)" />
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-dropdown>
<a-button type="link" size="small">
操作 <DownOutlined />
操作
<DownOutlined />
</a-button>
<template #overlay>
<a-menu>
@ -106,16 +88,10 @@
<a-menu-item @click="handleQuota(record)">调整配额</a-menu-item>
<a-menu-item @click="handleResetPassword(record)">重置密码</a-menu-item>
<a-menu-divider />
<a-menu-item
v-if="record.status === 'ACTIVE'"
@click="handleUpdateStatus(record, 'SUSPENDED')"
>
<a-menu-item v-if="record.status === 'ACTIVE'" @click="handleUpdateStatus(record, 'SUSPENDED')">
暂停服务
</a-menu-item>
<a-menu-item
v-if="record.status === 'SUSPENDED'"
@click="handleUpdateStatus(record, 'ACTIVE')"
>
<a-menu-item v-if="record.status === 'SUSPENDED'" @click="handleUpdateStatus(record, 'ACTIVE')">
恢复服务
</a-menu-item>
<a-menu-item danger @click="handleDelete(record)">删除</a-menu-item>
@ -129,36 +105,17 @@
</a-card>
<!-- 添加/编辑租户弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="isEdit ? '编辑租户' : '添加租户'"
:confirm-loading="modalLoading"
width="600px"
@ok="handleModalOk"
@cancel="handleModalCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-modal v-model:open="modalVisible" :title="isEdit ? '编辑租户' : '添加租户'" :confirm-loading="modalLoading" width="600px"
@ok="handleModalOk" @cancel="handleModalCancel">
<a-form ref="formRef" :model="formData" :rules="formRules" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="学校名称" name="name">
<a-input v-model:value="formData.name" placeholder="请输入学校名称" />
</a-form-item>
<a-form-item v-if="!isEdit" label="登录账号" name="loginAccount">
<a-input
v-model:value="formData.loginAccount"
placeholder="字母开头4-20位"
:disabled="isEdit"
/>
<a-input v-model:value="formData.loginAccount" placeholder="字母开头4-20位" :disabled="isEdit" />
</a-form-item>
<a-form-item v-if="!isEdit" label="初始密码" name="password">
<a-input-password
v-model:value="formData.password"
placeholder="留空则默认为 123456"
/>
<a-input-password v-model:value="formData.password" placeholder="留空则默认为 123456" />
</a-form-item>
<a-form-item label="联系人" name="contactPerson">
<a-input v-model:value="formData.contactPerson" placeholder="请输入联系人姓名" />
@ -178,38 +135,19 @@
</a-select>
</a-form-item>
<a-form-item label="教师配额" name="teacherQuota">
<a-input-number
v-model:value="formData.teacherQuota"
:min="1"
:max="1000"
class="w-full"
/>
<a-input-number v-model:value="formData.teacherQuota" :min="1" :max="1000" class="w-full" />
</a-form-item>
<a-form-item label="学生配额" name="studentQuota">
<a-input-number
v-model:value="formData.studentQuota"
:min="1"
:max="10000"
class="w-full"
/>
<a-input-number v-model:value="formData.studentQuota" :min="1" :max="10000" class="w-full" />
</a-form-item>
<a-form-item label="有效期" name="dateRange">
<a-range-picker
v-model:value="dateRange"
class="w-full"
value-format="YYYY-MM-DD"
/>
<a-range-picker v-model:value="dateRange" class="w-full" value-format="YYYY-MM-DD" />
</a-form-item>
</a-form>
</a-modal>
<!-- 配额调整弹窗 -->
<a-modal
v-model:open="quotaModalVisible"
title="调整配额"
:confirm-loading="quotaModalLoading"
@ok="handleQuotaOk"
>
<a-modal v-model:open="quotaModalVisible" title="调整配额" :confirm-loading="quotaModalLoading" @ok="handleQuotaOk">
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="当前租户">
<span>{{ currentTenant?.name }}</span>
@ -223,23 +161,15 @@
</a-select>
</a-form-item>
<a-form-item label="教师配额">
<a-input-number
v-model:value="quotaForm.teacherQuota"
:min="currentTenant?.teacherCount || 1"
:max="1000"
class="w-full"
/>
<a-input-number v-model:value="quotaForm.teacherQuota" :min="currentTenant?.teacherCount || 1" :max="1000"
class="w-full" />
<div style="color: #999; font-size: 12px">
已使用: {{ currentTenant?.teacherCount || 0 }}
</div>
</a-form-item>
<a-form-item label="学生配额">
<a-input-number
v-model:value="quotaForm.studentQuota"
:min="currentTenant?.studentCount || 1"
:max="10000"
class="w-full"
/>
<a-input-number v-model:value="quotaForm.studentQuota" :min="currentTenant?.studentCount || 1" :max="10000"
class="w-full" />
<div style="color: #999; font-size: 12px">
已使用: {{ currentTenant?.studentCount || 0 }}
</div>
@ -248,12 +178,7 @@
</a-modal>
<!-- 详情抽屉 -->
<a-drawer
v-model:open="drawerVisible"
title="租户详情"
width="600"
:destroy-on-close="true"
>
<a-drawer v-model:open="drawerVisible" title="租户详情" width="600" :destroy-on-close="true">
<template v-if="detailData">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="学校名称" :span="2">
@ -263,10 +188,7 @@
{{ detailData.loginAccount }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge
:status="getStatusType(detailData.status)"
:text="getStatusText(detailData.status)"
/>
<a-badge :status="getStatusType(detailData.status)" :text="getStatusText(detailData.status)" />
</a-descriptions-item>
<a-descriptions-item label="联系人">
{{ detailData.contactPerson || '-' }}
@ -305,23 +227,13 @@
</a-row>
<a-divider>最近教师</a-divider>
<a-table
v-if="detailData.teachers && detailData.teachers.length > 0"
:columns="teacherColumns"
:data-source="detailData.teachers"
:pagination="false"
size="small"
/>
<a-table v-if="detailData.teachers && detailData.teachers.length > 0" :columns="teacherColumns"
:data-source="detailData.teachers" :pagination="false" size="small" />
<a-empty v-else description="暂无教师数据" />
<a-divider>最近学生</a-divider>
<a-table
v-if="detailData.students && detailData.students.length > 0"
:columns="studentColumns"
:data-source="detailData.students"
:pagination="false"
size="small"
/>
<a-table v-if="detailData.students && detailData.students.length > 0" :columns="studentColumns"
:data-source="detailData.students" :pagination="false" size="small" />
<a-empty v-else description="暂无学生数据" />
</template>
<a-skeleton v-else active />
@ -375,15 +287,15 @@ const pagination = reactive({
//
const columns = [
{ title: '学校名称', dataIndex: 'name', key: 'name', width: 150 },
{ title: '登录账号', dataIndex: 'loginAccount', key: 'loginAccount', width: 120 },
{ title: '联系人', dataIndex: 'contactPerson', key: 'contactPerson', width: 100 },
{ title: '联系电话', dataIndex: 'contactPhone', key: 'contactPhone', width: 120 },
{ title: '套餐', dataIndex: 'packageType', key: 'packageType', width: 80 },
{ title: '配额使用', key: 'quota', width: 120 },
{ title: '有效期', key: 'validity', width: 140 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
{ title: '学校名称', dataIndex: 'name', key: 'name', minWidth: 150 },
{ title: '登录账号', dataIndex: 'loginAccount', key: 'loginAccount', minWidth: 120 },
{ title: '联系人', dataIndex: 'contactPerson', key: 'contactPerson', minWidth: 100 },
{ title: '联系电话', dataIndex: 'contactPhone', key: 'contactPhone', minWidth: 120 },
{ title: '套餐', dataIndex: 'packageType', key: 'packageType', minWidth: 80 },
{ title: '配额使用', key: 'quota', minWidth: 140 },
{ title: '有效期', key: 'validity', minWidth: 160 },
{ title: '状态', dataIndex: 'status', key: 'status', minWidth: 90 },
{ title: '操作', key: 'action', minWidth: 120, fixed: 'right' as const },
];
//
@ -502,9 +414,9 @@ const showAddModal = () => {
studentQuota: 200,
});
dateRange.value = [
dayjs().format('YYYY-MM-DD'),
dayjs().add(1, 'year').format('YYYY-MM-DD'),
] as [string, string];
dayjs().format('YYYY-MM-DD'),
dayjs().add(1, 'year').format('YYYY-MM-DD'),
] as [string, string];
modalVisible.value = true;
};
@ -700,4 +612,8 @@ onMounted(() => {
<style scoped>
/* 仅保留 :deep / @keyframes / scrollbar / @media 等 */
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
</style>

View File

@ -6,18 +6,15 @@
</template>
<template #extra>
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
新建主题
</a-button>
</template>
<!-- 表格 -->
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
row-key="id"
>
<a-table :columns="columns" :data-source="dataSource" :loading="loading" row-key="id" :scroll="{ x: true }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 'ACTIVE' ? 'success' : 'default'">
@ -37,12 +34,8 @@
</a-card>
<!-- 编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="isEdit ? '编辑主题' : '新建主题'"
@ok="handleSave"
@cancel="modalVisible = false"
>
<a-modal v-model:open="modalVisible" :title="isEdit ? '编辑主题' : '新建主题'" @ok="handleSave"
@cancel="modalVisible = false">
<a-form :model="form" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-form-item label="主题名称" required>
<a-input v-model:value="form.name" placeholder="请输入主题名称" />
@ -83,11 +76,11 @@ const form = reactive({
});
const columns = [
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 80 },
{ title: '主题名称', dataIndex: 'name', key: 'name' },
{ title: '描述', dataIndex: 'description', key: 'description' },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '操作', key: 'action', width: 150 },
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', minWidth: 80, maxWidth: 100 },
{ title: '主题名称', dataIndex: 'name', key: 'name', minWidth: 180 },
{ title: '描述', dataIndex: 'description', key: 'description', minWidth: 220 },
{ title: '状态', dataIndex: 'status', key: 'status', minWidth: 100, maxWidth: 120 },
{ title: '操作', key: 'action', minWidth: 150, fixed: 'right' as const },
];
const fetchData = async () => {
@ -168,3 +161,9 @@ onMounted(() => {
fetchData();
});
</script>
<style scoped>
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
</style>

View File

@ -4,7 +4,8 @@
<div class="mb-6">
<div class="flex justify-between items-center">
<div class="flex items-center gap-4">
<div class="w-14 h-14 flex items-center justify-center bg-[linear-gradient(135deg,#43e97b_0%,#38f9d7_100%)] rounded-[14px] shadow-[0_4px_12px_rgba(67,233,123,0.3)]">
<div
class="w-14 h-14 flex items-center justify-center bg-[linear-gradient(135deg,#43e97b_0%,#38f9d7_100%)] rounded-[14px] shadow-[0_4px_12px_rgba(67,233,123,0.3)]">
<GiftOutlined class="text-[28px] text-white" />
</div>
<div>
@ -21,7 +22,8 @@
<!-- 统计概览 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6" v-if="!loading">
<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 text-white bg-[linear-gradient(135deg,#43e97b_0%,#38f9d7_100%)]">
<div
class="w-12 h-12 rounded-xl flex items-center justify-center text-2xl text-white bg-[linear-gradient(135deg,#43e97b_0%,#38f9d7_100%)]">
<GiftOutlined />
</div>
<div>
@ -30,7 +32,8 @@
</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 text-white bg-[linear-gradient(135deg,#667eea_0%,#764ba2_100%)]">
<div
class="w-12 h-12 rounded-xl flex items-center justify-center text-2xl text-white bg-[linear-gradient(135deg,#667eea_0%,#764ba2_100%)]">
<BookOutlined />
</div>
<div>
@ -39,14 +42,17 @@
</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 v-if="expiringCount > 0" class="w-12 h-12 rounded-xl flex items-center justify-center text-2xl text-white bg-[linear-gradient(135deg,#f093fb_0%,#f5576c_100%)]">
<div v-if="expiringCount > 0"
class="w-12 h-12 rounded-xl flex items-center justify-center text-2xl text-white bg-[linear-gradient(135deg,#f093fb_0%,#f5576c_100%)]">
<WarningOutlined />
</div>
<div v-else class="w-12 h-12 rounded-xl flex items-center justify-center text-2xl text-white bg-[linear-gradient(135deg,#4facfe_0%,#00f2fe_100%)]">
<div v-else
class="w-12 h-12 rounded-xl flex items-center justify-center text-2xl text-white bg-[linear-gradient(135deg,#4facfe_0%,#00f2fe_100%)]">
<CheckCircleOutlined />
</div>
<div>
<div class="text-[28px] font-700" :class="expiringCount > 0 ? 'text-[#f5222d]' : 'text-[#333]'">{{ expiringCount }}</div>
<div class="text-[28px] font-700" :class="expiringCount > 0 ? 'text-[#f5222d]' : 'text-[#333]'">{{
expiringCount }}</div>
<div class="text-[13px] text-[#666]">即将到期</div>
</div>
</div>
@ -60,25 +66,16 @@
<!-- 套餐列表 -->
<div v-if="!loading">
<a-alert
v-if="tenantPackages.length === 0"
type="info"
show-icon
message="暂无已授权的套餐"
description="请联系管理员申请课程套餐,以获取课程包的使用权限。"
class="mb-6"
/>
<a-alert v-if="tenantPackages.length === 0" type="info" show-icon message="暂无已授权的套餐"
description="请联系管理员申请课程套餐,以获取课程包的使用权限。" class="mb-6" />
<div class="grid gap-5 grid-cols-1 md:grid-cols-[repeat(auto-fill,minmax(360px,1fr))]">
<div
v-for="item in tenantPackages"
:key="item.id"
<div v-for="item in tenantPackages" :key="item.id"
class="bg-white rounded-2xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)] transition-all duration-300 border hover:shadow-[0_4px_16px_rgba(0,0,0,0.12)] hover:-translate-y-0.5"
:class="[
isExpiring(item.endDate) ? 'border-[#faad14] bg-[linear-gradient(to_bottom,#fffbe6,white)]' : 'border-[#e8e8e8]',
isExpired(item.endDate) ? 'border-[#ff4d4f] opacity-80' : '',
]"
>
]">
<div class="flex justify-between items-center py-4 px-5 border-b border-[#f0f0f0] bg-[#fafafa]">
<div class="text-base font-600 text-[#333]">{{ item.package.name }}</div>
<a-tag :color="getStatusColor(item.endDate)">
@ -108,7 +105,8 @@
</div>
<!-- 课程包预览 -->
<div v-if="item.package.courses && item.package.courses.length > 0" class="mt-3 pt-3 border-t border-[#f0f0f0]">
<div v-if="item.package.courses && item.package.courses.length > 0"
class="mt-3 pt-3 border-t border-[#f0f0f0]">
<div class="text-xs text-[#666] mb-2">包含课程包</div>
<div class="flex flex-wrap gap-1">
<a-tag v-for="course in item.package.courses.slice(0, 5)" :key="course.id" color="blue">
@ -125,11 +123,9 @@
<a-button type="link" @click="viewPackageDetail(item)">
<EyeOutlined /> 查看详情
</a-button>
<a-button
type="link"
<a-button type="link"
:class="(isExpiring(item.endDate) || isExpired(item.endDate)) ? '!text-[#faad14] hover:!text-[#ff7a2a]' : ''"
@click="showRenewModal(item)"
>
@click="showRenewModal(item)">
<SyncOutlined /> 续订
</a-button>
</div>
@ -138,12 +134,8 @@
</div>
<!-- 套餐详情弹窗 -->
<a-modal
v-model:open="detailModalVisible"
:title="selectedPackage?.package?.name || '套餐详情'"
width="800px"
:footer="null"
>
<a-modal v-model:open="detailModalVisible" :title="selectedPackage?.package?.name || '套餐详情'" width="800px"
:footer="null">
<div v-if="selectedPackage">
<div class="mb-6 last:mb-0">
<div class="text-[15px] font-600 text-[#333] mb-3 flex items-center gap-2 pb-2 border-b border-[#f0f0f0]">
@ -166,7 +158,8 @@
<span class="w-[100px] text-[#666] text-[13px]">到期时间</span>
<span class="flex-1 text-[13px]">
{{ formatDate(selectedPackage.endDate) }}
<a-tag v-if="!isExpired(selectedPackage.endDate)" :color="getStatusColor(selectedPackage.endDate)" class="ml-2">
<a-tag v-if="!isExpired(selectedPackage.endDate)" :color="getStatusColor(selectedPackage.endDate)"
class="ml-2">
{{ getDaysLeft(selectedPackage.endDate) }}天后到期
</a-tag>
<a-tag v-else color="red" class="ml-2">已过期</a-tag>
@ -180,14 +173,9 @@
<BookOutlined /> 包含课程包 ({{ selectedPackage.package.courses?.length || 0 }})
</div>
<div>
<a-table
v-if="selectedPackage.package.courses && selectedPackage.package.courses.length > 0"
:dataSource="selectedPackage.package.courses"
:columns="courseColumns"
:pagination="false"
size="small"
row-key="id"
>
<a-table v-if="selectedPackage.package.courses && selectedPackage.package.courses.length > 0"
:dataSource="selectedPackage.package.courses" :columns="courseColumns" :pagination="false" size="small"
row-key="id" :scroll="{ x: true }">
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'gradeLevel'">
<a-tag>{{ record.gradeLevel }}</a-tag>
@ -201,13 +189,8 @@
</a-modal>
<!-- 续订弹窗 -->
<a-modal
v-model:open="renewModalVisible"
title="续订套餐"
width="500px"
@ok="handleRenew"
:confirmLoading="renewLoading"
>
<a-modal v-model:open="renewModalVisible" title="续订套餐" width="500px" @ok="handleRenew"
:confirmLoading="renewLoading">
<div v-if="selectedPackage">
<div class="bg-[#f9f9f9] rounded-lg p-4">
<div class="flex justify-between py-2 border-b border-dashed border-[#e8e8e8]">
@ -240,33 +223,21 @@
</a-form-item>
</a-form>
<a-alert
type="info"
show-icon
message="续订说明"
description="请选择续订时长后,联系客服完成续订支付。"
/>
<a-alert type="info" show-icon message="续订说明" description="请选择续订时长后,联系客服完成续订支付。" />
</div>
</a-modal>
<!-- 申请套餐弹窗 -->
<a-modal
v-model:open="applyModalVisible"
title="申请新套餐"
width="500px"
:footer="null"
>
<a-modal v-model:open="applyModalVisible" title="申请新套餐" width="500px" :footer="null">
<div>
<a-alert
type="info"
show-icon
message="申请说明"
description="如需申请新的课程套餐,请联系平台管理员或客服进行咨询和办理。"
class="mb-4"
/>
<a-alert type="info" show-icon message="申请说明" description="如需申请新的课程套餐,请联系平台管理员或客服进行咨询和办理。" class="mb-4" />
<div class="bg-[#f9f9f9] rounded-lg p-4 mb-4">
<p class="my-2 text-[#333] flex items-center gap-2"><PhoneOutlined /> 客服电话400-XXX-XXXX</p>
<p class="my-2 text-[#333] flex items-center gap-2"><MailOutlined /> 客服邮箱service@example.com</p>
<p class="my-2 text-[#333] flex items-center gap-2">
<PhoneOutlined /> 客服电话400-XXX-XXXX
</p>
<p class="my-2 text-[#333] flex items-center gap-2">
<MailOutlined /> 客服邮箱service@example.com
</p>
</div>
<a-button type="primary" block @click="applyModalVisible = false">
我知道了
@ -310,9 +281,9 @@ const applyModalVisible = ref(false);
//
const courseColumns = [
{ title: '序号', dataIndex: 'sortOrder', key: 'sortOrder', width: 60 },
{ title: '课程包名称', dataIndex: ['course', 'name'], key: 'courseName' },
{ title: '适用年级', dataIndex: 'gradeLevel', key: 'gradeLevel', width: 100 },
{ title: '序号', dataIndex: 'sortOrder', key: 'sortOrder', minWidth: 60, maxWidth: 80 },
{ title: '课程包名称', dataIndex: ['course', 'name'], key: 'courseName', minWidth: 220 },
{ title: '适用年级', dataIndex: 'gradeLevel', key: 'gradeLevel', minWidth: 100, maxWidth: 120 },
];
//
@ -435,6 +406,7 @@ onMounted(() => {
<style scoped>
/* 仅保留无法用 UnoCSS 实现的部分 */
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
</style>

View File

@ -23,7 +23,8 @@
<div class="py-6 px-6 max-w-[1400px] mx-auto">
<!-- 封面和基本信息 -->
<div class="mb-6 text-center" v-if="course.coverImagePath">
<img :src="getFileUrl(course.coverImagePath)" alt="课程封面" class="max-w-full max-h-[400px] rounded-xl shadow-[0_4px_12px_rgba(0,0,0,0.1)] object-cover" />
<img :src="getFileUrl(course.coverImagePath)" alt="课程封面"
class="max-w-full max-h-[400px] rounded-xl shadow-[0_4px_12px_rgba(0,0,0,0.1)] object-cover" />
</div>
<!-- 信息卡片网格 -->
@ -97,7 +98,8 @@
<div class="text-[13px] text-[#666] mt-1">使用教师</div>
</div>
<div class="text-center">
<div class="text-[28px] font-600 text-[#43e97b] leading-tight">{{ course.avgRating?.toFixed(1) || '-' }}</div>
<div class="text-[28px] font-600 text-[#43e97b] leading-tight">{{ course.avgRating?.toFixed(1) || '-' }}
</div>
<div class="text-[13px] text-[#666] mt-1">平均评分</div>
</div>
</div>
@ -132,7 +134,8 @@
</div>
<!-- 课程介绍 -->
<div class="bg-white rounded-xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)] mb-6" v-if="hasIntroContent">
<div class="bg-white rounded-xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)] mb-6"
v-if="hasIntroContent">
<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">
<BookOutlined /> 课程介绍
@ -170,7 +173,8 @@
</div>
<!-- 排课参考 -->
<div class="bg-white rounded-xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)] mb-6" v-if="scheduleRef.length > 0">
<div class="bg-white rounded-xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)] mb-6"
v-if="scheduleRef.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">
<CalendarOutlined /> 排课计划参考
@ -190,19 +194,22 @@
</div>
<!-- 环创建设 -->
<div class="bg-white rounded-xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)] mb-6" v-if="course.environmentConstruction">
<div class="bg-white rounded-xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)] mb-6"
v-if="course.environmentConstruction">
<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">
<EnvironmentOutlined /> 环创建设
</span>
</div>
<div class="p-5">
<div class="leading-[1.8] text-[#333] whitespace-pre-wrap text-sm">{{ course.environmentConstruction }}</div>
<div class="leading-[1.8] text-[#333] whitespace-pre-wrap text-sm">{{ course.environmentConstruction }}
</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="courseLessons.length > 0">
<div class="bg-white rounded-xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)] mb-6"
v-if="courseLessons.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 /> 课程配置
@ -211,13 +218,11 @@
</div>
<div class="p-5">
<div class="grid gap-4">
<div
v-for="lesson in courseLessons"
:key="lesson.id"
class="border border-[#e8e8e8] rounded-xl overflow-hidden transition-all duration-300 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)]"
>
<div v-for="lesson in courseLessons" :key="lesson.id"
class="border border-[#e8e8e8] rounded-xl overflow-hidden transition-all duration-300 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)]">
<div class="flex items-center gap-3 py-4 px-5 bg-[#fafafa] border-b border-[#f0f0f0]">
<div class="py-1 px-3 rounded-[20px] text-white text-xs font-500" :style="{ background: getLessonTypeBgColor(lesson.lessonType) }">
<div class="py-1 px-3 rounded-[20px] text-white text-xs font-500"
:style="{ background: getLessonTypeBgColor(lesson.lessonType) }">
{{ translateLessonType(lesson.lessonType) }}
</div>
<div class="flex-1 font-600 text-[15px]">{{ lesson.name }}</div>
@ -234,54 +239,65 @@
<!-- 教学准备 -->
<div class="mb-4" v-if="lesson.preparation">
<div class="text-[13px] font-600 text-[#666] mb-2 pl-2 border-l-[3px] border-l-[#43e97b]">教学准备</div>
<div class="text-[13px] text-[#333] leading-[1.6] whitespace-pre-wrap">{{ lesson.preparation }}</div>
<div class="text-[13px] text-[#333] leading-[1.6] whitespace-pre-wrap">{{ lesson.preparation }}
</div>
</div>
<!-- 核心资源 -->
<div class="mb-4" v-if="hasLessonResources(lesson)">
<div class="text-[13px] font-600 text-[#666] mb-2 pl-2 border-l-[3px] border-l-[#43e97b]">核心资源</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<div
v-if="lesson.videoPath"
<div v-if="lesson.videoPath"
class="group flex items-center py-2.5 px-3 bg-[#f9f9f9] rounded-lg cursor-pointer transition-all duration-200 hover:bg-[#F0FFF4]"
@click="previewFile(lesson.videoPath, lesson.videoName || '绘本动画')"
>
@click="previewFile(lesson.videoPath, lesson.videoName || '绘本动画')">
<VideoCameraOutlined class="text-[18px] mr-2 text-[#722ed1]" />
<span class="flex-1 text-[13px] overflow-hidden text-ellipsis whitespace-nowrap">{{ lesson.videoName || '绘本动画' }}</span>
<EyeOutlined class="text-[#43e97b] opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
<span class="flex-1 text-[13px] overflow-hidden text-ellipsis whitespace-nowrap">{{
lesson.videoName ||
'绘本动画' }}</span>
<EyeOutlined
class="text-[#43e97b] opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
</div>
<div
v-if="lesson.pptPath"
<div v-if="lesson.pptPath"
class="group flex items-center py-2.5 px-3 bg-[#f9f9f9] rounded-lg cursor-pointer transition-all duration-200 hover:bg-[#F0FFF4]"
@click="previewFile(lesson.pptPath, lesson.pptName || '教学课件')"
>
@click="previewFile(lesson.pptPath, lesson.pptName || '教学课件')">
<FilePptOutlined class="text-[18px] mr-2 text-[#fa8c16]" />
<span class="flex-1 text-[13px] overflow-hidden text-ellipsis whitespace-nowrap">{{ lesson.pptName || '教学课件' }}</span>
<EyeOutlined class="text-[#43e97b] opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
<span class="flex-1 text-[13px] overflow-hidden text-ellipsis whitespace-nowrap">{{
lesson.pptName ||
'教学课件' }}</span>
<EyeOutlined
class="text-[#43e97b] opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
</div>
<div
v-if="lesson.pdfPath"
<div v-if="lesson.pdfPath"
class="group flex items-center py-2.5 px-3 bg-[#f9f9f9] rounded-lg cursor-pointer transition-all duration-200 hover:bg-[#F0FFF4]"
@click="previewFile(lesson.pdfPath, lesson.pdfName || '电子绘本')"
>
@click="previewFile(lesson.pdfPath, lesson.pdfName || '电子绘本')">
<FilePdfOutlined class="text-[18px] mr-2 text-[#f5222d]" />
<span class="flex-1 text-[13px] overflow-hidden text-ellipsis whitespace-nowrap">{{ lesson.pdfName || '电子绘本' }}</span>
<EyeOutlined class="text-[#43e97b] opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
<span class="flex-1 text-[13px] overflow-hidden text-ellipsis whitespace-nowrap">{{
lesson.pdfName ||
'电子绘本' }}</span>
<EyeOutlined
class="text-[#43e97b] opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
</div>
</div>
</div>
<!-- 教学环节 -->
<div class="mb-4" v-if="lesson.steps && lesson.steps.length > 0">
<div class="text-[13px] font-600 text-[#666] mb-2 pl-2 border-l-[3px] border-l-[#43e97b]">教学环节 ({{ lesson.steps.length }})</div>
<div class="text-[13px] font-600 text-[#666] mb-2 pl-2 border-l-[3px] border-l-[#43e97b]">教学环节 ({{
lesson.steps.length }})</div>
<div class="steps-timeline">
<div v-for="(step, index) in lesson.steps" :key="step.id || index" class="flex gap-3 py-3 border-b border-dashed border-[#f0f0f0] last:border-b-0">
<div class="w-6 h-6 rounded-full bg-[linear-gradient(135deg,#43e97b_0%,#38f9d7_100%)] text-white flex items-center justify-center text-xs font-600 shrink-0">{{ index + 1 }}</div>
<div v-for="(step, index) in lesson.steps" :key="step.id || index"
class="flex gap-3 py-3 border-b border-dashed border-[#f0f0f0] last:border-b-0">
<div
class="w-6 h-6 rounded-full bg-[linear-gradient(135deg,#43e97b_0%,#38f9d7_100%)] text-white flex items-center justify-center text-xs font-600 shrink-0">
{{ index + 1 }}</div>
<div class="flex-1">
<div class="font-500 mb-1">{{ step.name }}</div>
<div class="inline-block text-xs text-[#999] ml-2">{{ step.duration }}分钟</div>
<div class="text-xs text-[#666] mt-1 leading-[1.5]" v-if="step.objective">目标{{ step.objective }}</div>
<div class="text-xs text-[#666] mt-1 leading-[1.5]" v-if="step.content">{{ step.content }}</div>
<div class="text-xs text-[#666] mt-1 leading-[1.5]" v-if="step.objective">目标{{ step.objective
}}
</div>
<div class="text-xs text-[#666] mt-1 leading-[1.5]" v-if="step.content">{{ step.content }}
</div>
</div>
</div>
</div>
@ -305,7 +321,8 @@
</div>
<!-- 数字资源汇总 -->
<div class="bg-white rounded-xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)] mb-6" v-if="hasAnyResources">
<div class="bg-white rounded-xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)] mb-6"
v-if="hasAnyResources">
<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">
<FolderOutlined /> 数字资源
@ -320,15 +337,14 @@
<VideoCameraOutlined class="text-[#722ed1]" /> 视频资源
</div>
<div class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2">
<div
v-for="(item, index) in allVideos"
:key="'video-' + index"
<div v-for="(item, index) in allVideos" :key="'video-' + index"
class="group flex items-center py-2.5 px-3.5 bg-[#fafafa] rounded-lg cursor-pointer transition-all duration-200 hover:bg-[#F0FFF4]"
@click="previewFile(item.path, item.name)"
>
@click="previewFile(item.path, item.name)">
<VideoCameraOutlined class="text-[18px] mr-2.5 text-[#722ed1]" />
<span class="flex-1 text-[13px] overflow-hidden text-ellipsis whitespace-nowrap">{{ item.name }}</span>
<PlayCircleOutlined class="text-[#43e97b] opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
<span class="flex-1 text-[13px] overflow-hidden text-ellipsis whitespace-nowrap">{{ item.name
}}</span>
<PlayCircleOutlined
class="text-[#43e97b] opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
</div>
</div>
</div>
@ -339,15 +355,14 @@
<AudioOutlined class="text-[#52c41a]" /> 音频资源
</div>
<div class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2">
<div
v-for="(item, index) in allAudios"
:key="'audio-' + index"
<div v-for="(item, index) in allAudios" :key="'audio-' + index"
class="group flex items-center py-2.5 px-3.5 bg-[#fafafa] rounded-lg cursor-pointer transition-all duration-200 hover:bg-[#F0FFF4]"
@click="previewFile(item.path, item.name)"
>
@click="previewFile(item.path, item.name)">
<AudioOutlined class="text-[18px] mr-2.5 text-[#52c41a]" />
<span class="flex-1 text-[13px] overflow-hidden text-ellipsis whitespace-nowrap">{{ item.name }}</span>
<PlayCircleOutlined class="text-[#43e97b] opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
<span class="flex-1 text-[13px] overflow-hidden text-ellipsis whitespace-nowrap">{{ item.name
}}</span>
<PlayCircleOutlined
class="text-[#43e97b] opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
</div>
</div>
</div>
@ -358,17 +373,16 @@
<FileTextOutlined class="text-[#1890ff]" /> 文档资源
</div>
<div class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2">
<div
v-for="(item, index) in allDocuments"
:key="'doc-' + index"
<div v-for="(item, index) in allDocuments" :key="'doc-' + index"
class="group flex items-center py-2.5 px-3.5 bg-[#fafafa] rounded-lg cursor-pointer transition-all duration-200 hover:bg-[#F0FFF4]"
@click="previewFile(item.path, item.name)"
>
@click="previewFile(item.path, item.name)">
<FilePdfOutlined v-if="item.type === 'pdf'" class="text-[18px] mr-2.5 text-[#f5222d]" />
<FilePptOutlined v-else-if="item.type === 'ppt'" class="text-[18px] mr-2.5 text-[#fa8c16]" />
<FileTextOutlined v-else class="text-[18px] mr-2.5 text-[#1890ff]" />
<span class="flex-1 text-[13px] overflow-hidden text-ellipsis whitespace-nowrap">{{ item.name }}</span>
<EyeOutlined class="text-[#43e97b] opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
<span class="flex-1 text-[13px] overflow-hidden text-ellipsis whitespace-nowrap">{{ item.name
}}</span>
<EyeOutlined
class="text-[#43e97b] opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
</div>
</div>
</div>
@ -379,14 +393,10 @@
<PictureOutlined class="text-[#13c2c2]" /> 图片资源
</div>
<div class="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-2">
<img
v-for="(item, index) in allImages"
:key="'img-' + index"
:src="getFileUrl(item.path)"
<img v-for="(item, index) in allImages" :key="'img-' + index" :src="getFileUrl(item.path)"
:alt="item.name"
class="w-full h-[100px] object-cover rounded-lg cursor-pointer transition-transform duration-200 hover:scale-105"
@click="previewImage(getFileUrl(item.path))"
/>
@click="previewImage(getFileUrl(item.path))" />
</div>
</div>
</div>
@ -394,7 +404,8 @@
</div>
<!-- 教师反馈汇总 -->
<div class="bg-white rounded-xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)] mb-6" v-if="teacherFeedbacks.length > 0">
<div class="bg-white rounded-xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)] mb-6"
v-if="teacherFeedbacks.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">
<CommentOutlined /> 教师反馈
@ -403,7 +414,8 @@
</div>
<div class="p-5">
<div class="flex flex-col gap-2">
<div v-for="feedback in teacherFeedbacks" :key="feedback.id" class="p-3 bg-[#fafafa] rounded-lg mb-2 last:mb-0">
<div v-for="feedback in teacherFeedbacks" :key="feedback.id"
class="p-3 bg-[#fafafa] rounded-lg mb-2 last:mb-0">
<div class="flex items-center mb-2">
<span class="font-500 text-[#333]">{{ feedback.teacherName }}</span>
<span class="text-xs text-[#999] ml-3">{{ formatDate(feedback.createdAt) }}</span>
@ -423,11 +435,7 @@
</a-modal>
<!-- 文件预览弹窗 -->
<FilePreviewModal
v-model:open="previewModalVisible"
:file-url="previewFileUrl"
:file-name="previewFileName"
/>
<FilePreviewModal v-model:open="previewModalVisible" :file-url="previewFileUrl" :file-name="previewFileName" />
</div>
</template>
@ -552,9 +560,9 @@ const domainTags = computed(() => {
//
const hasIntroContent = computed(() => {
return course.value.introSummary || course.value.introHighlights ||
course.value.introGoals || course.value.introSchedule ||
course.value.introKeyPoints || course.value.introMethods ||
course.value.introEvaluation || course.value.introNotes;
course.value.introGoals || course.value.introSchedule ||
course.value.introKeyPoints || course.value.introMethods ||
course.value.introEvaluation || course.value.introNotes;
});
//
@ -614,7 +622,7 @@ const allVideos = computed(() => {
videos.push({ path: item.path, name: item.name || '视频', source: '资源库' });
});
}
} catch {}
} catch { }
}
return videos;
});
@ -630,7 +638,7 @@ const allAudios = computed(() => {
audios.push({ path: item.path, name: item.name || '音频', source: '资源库' });
});
}
} catch {}
} catch { }
}
return audios;
});
@ -659,7 +667,7 @@ const allDocuments = computed(() => {
docs.push({ path: item.path, name: item.name || '电子绘本', type: 'pdf', source: '资源库' });
});
}
} catch {}
} catch { }
}
return docs;
});
@ -675,16 +683,16 @@ const allImages = computed(() => {
images.push({ path: item.path, name: item.name || '挂图' });
});
}
} catch {}
} catch { }
}
return images;
});
const hasAnyResources = computed(() => {
return allVideos.value.length > 0 ||
allAudios.value.length > 0 ||
allDocuments.value.length > 0 ||
allImages.value.length > 0;
allAudios.value.length > 0 ||
allDocuments.value.length > 0 ||
allImages.value.length > 0;
});
const totalResourcesCount = computed(() => {

View File

@ -26,46 +26,42 @@
</div>
<!-- 操作栏 -->
<div class="flex justify-between items-center mb-6 py-4 px-5 bg-white rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.04)] max-md:flex-col max-md:gap-3">
<div
class="flex justify-between items-center mb-6 py-4 px-5 bg-white rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.04)] max-md:flex-col max-md:gap-3">
<div class="search-box">
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索家长姓名/手机号/账号"
class="w-[280px]"
@search="handleSearch"
allow-clear
>
<a-input-search v-model:value="searchKeyword" placeholder="搜索家长姓名/手机号/账号" class="w-[280px]"
@search="handleSearch" allow-clear>
<template #prefix>
<SearchOutlined class="text-[#B2BEC3]" />
</template>
</a-input-search>
</div>
<a-button type="primary" class="!bg-[linear-gradient(135deg,#FF8C42_0%,#FFB347_100%)] hover:!bg-[linear-gradient(135deg,#FF7A2A_0%,#FFA030_100%)] !border-0 rounded-xl h-10 px-6 font-600" @click="showAddModal">
<a-button type="primary"
class="!bg-[linear-gradient(135deg,#FF8C42_0%,#FFB347_100%)] hover:!bg-[linear-gradient(135deg,#FF7A2A_0%,#FFA030_100%)] !border-0 rounded-xl h-10 px-6 font-600"
@click="showAddModal">
<PlusOutlined class="mr-2 text-sm" />
添加家长
</a-button>
</div>
<!-- 家长卡片列表 -->
<div class="grid gap-5 mb-6 grid-cols-1 md:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]" v-if="!loading && parents.length > 0">
<div
v-for="parent in parents"
:key="parent.id"
<div class="grid gap-5 mb-6 grid-cols-1 md:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]"
v-if="!loading && parents.length > 0">
<div v-for="parent in parents" :key="parent.id"
class="bg-white rounded-2xl overflow-hidden shadow-[0_4px_12px_rgba(0,0,0,0.05)] transition-all duration-300 border-2 border-transparent hover:translate-y-[-4px] hover:shadow-[0_8px_24px_rgba(0,0,0,0.1)] hover:border-[#FF8C42]"
:class="parent.status !== 'ACTIVE' ? 'opacity-70' : ''"
>
<div class="flex items-center gap-3 py-4 px-5 bg-[linear-gradient(135deg,#F8F9FA_0%,#FFFFFF_100%)] border-b border-[#F0F0F0]">
<div class="w-12 h-12 rounded-xl flex items-center justify-center bg-[linear-gradient(135deg,#FF8C42_0%,#FFB347_100%)]">
:class="parent.status !== 'ACTIVE' ? 'opacity-70' : ''">
<div
class="flex items-center gap-3 py-4 px-5 bg-[linear-gradient(135deg,#F8F9FA_0%,#FFFFFF_100%)] border-b border-[#F0F0F0]">
<div
class="w-12 h-12 rounded-xl flex items-center justify-center bg-[linear-gradient(135deg,#FF8C42_0%,#FFB347_100%)]">
<IdcardOutlined class="text-2xl text-white" />
</div>
<div class="flex-1 min-w-0">
<div class="text-base font-600 text-[#2D3436]">{{ parent.name }}</div>
<div class="text-xs text-[#636E72] mt-0.5">@{{ parent.loginAccount }}</div>
</div>
<span
class="py-1 px-3 rounded-[20px] text-xs font-500"
:class="parent.status === 'ACTIVE' ? 'bg-[#E8F5E9] text-[#43A047]' : 'bg-[#FFEBEE] text-[#E53935]'"
>
<span class="py-1 px-3 rounded-[20px] text-xs font-500"
:class="parent.status === 'ACTIVE' ? 'bg-[#E8F5E9] text-[#43A047]' : 'bg-[#FFEBEE] text-[#E53935]'">
{{ parent.status === 'ACTIVE' ? '活跃' : '停用' }}
</span>
</div>
@ -82,26 +78,28 @@
<div class="flex items-center gap-2 text-[13px]">
<TeamOutlined class="text-sm text-[#FF8C42]" />
<span class="text-[#636E72]">
<span v-if="parent.childrenCount > 0">关联 <strong class="text-[#FF8C42]">{{ parent.childrenCount }}</strong> 个孩子</span>
<span v-if="parent.childrenCount > 0">关联 <strong class="text-[#FF8C42]">{{ parent.childrenCount
}}</strong>
个孩子</span>
<span v-else class="text-[#B2BEC3] italic">未关联孩子</span>
</span>
</div>
</div>
<div class="flex justify-end gap-1 py-3 px-4 border-t border-[#F0F0F0] bg-[#FAFAFA] card-actions">
<a-button type="link" size="small" @click="handleEdit(parent)" class="!py-1 !px-2 !h-auto !text-xs inline-flex items-center gap-1">
<a-button type="link" size="small" @click="handleEdit(parent)"
class="!py-1 !px-2 !h-auto !text-xs inline-flex items-center gap-1">
<EditOutlined /> 编辑
</a-button>
<a-button type="link" size="small" @click="handleManageChildren(parent)" class="!py-1 !px-2 !h-auto !text-xs inline-flex items-center gap-1">
<a-button type="link" size="small" @click="handleManageChildren(parent)"
class="!py-1 !px-2 !h-auto !text-xs inline-flex items-center gap-1">
<UserAddOutlined /> 孩子
</a-button>
<a-button type="link" size="small" @click="handleResetPassword(parent)" class="!py-1 !px-2 !h-auto !text-xs inline-flex items-center gap-1">
<a-button type="link" size="small" @click="handleResetPassword(parent)"
class="!py-1 !px-2 !h-auto !text-xs inline-flex items-center gap-1">
<KeyOutlined /> 重置
</a-button>
<a-popconfirm
title="确定要删除这位家长吗?"
@confirm="handleDelete(parent.id)"
>
<a-popconfirm title="确定要删除这位家长吗?" @confirm="handleDelete(parent.id)">
<a-button type="link" size="small" danger class="!py-1 !px-2 !h-auto !text-xs">
<DeleteOutlined /> 删除
</a-button>
@ -111,10 +109,12 @@
</div>
<!-- 空状态 -->
<div class="flex flex-col items-center justify-center py-20 bg-white rounded-2xl empty-state" v-if="!loading && parents.length === 0">
<div class="flex flex-col items-center justify-center py-20 bg-white rounded-2xl empty-state"
v-if="!loading && parents.length === 0">
<InboxOutlined class="text-[64px] text-[#B2BEC3] mb-4" />
<p class="text-[#636E72] text-base mb-6">暂无家长数据</p>
<a-button type="primary" class="!bg-[linear-gradient(135deg,#FF8C42_0%,#FFB347_100%)] !border-0 rounded-xl" @click="showAddModal">
<a-button type="primary" class="!bg-[linear-gradient(135deg,#FF8C42_0%,#FFB347_100%)] !border-0 rounded-xl"
@click="showAddModal">
添加第一位家长
</a-button>
</div>
@ -127,25 +127,14 @@
<!-- 分页 -->
<div class="flex justify-center py-6 bg-white rounded-2xl pagination-wrapper" v-if="parents.length > 0">
<a-pagination
v-model:current="pagination.current"
v-model:pageSize="pagination.pageSize"
:total="pagination.total"
:show-size-changer="true"
:show-total="(total: number) => `共 ${total} 条`"
@change="handlePageChange"
/>
<a-pagination v-model:current="pagination.current" v-model:pageSize="pagination.pageSize"
:total="pagination.total" :show-size-changer="true" :show-total="(total: number) => `共 ${total} 条`"
@change="handlePageChange" />
</div>
<!-- 添加/编辑家长模态框 -->
<a-modal
v-model:open="modalVisible"
:title="isEdit ? '编辑家长' : '添加家长'"
@ok="handleModalOk"
@cancel="handleModalCancel"
:confirm-loading="submitting"
:width="520"
>
<a-modal v-model:open="modalVisible" :title="isEdit ? '编辑家长' : '添加家长'" @ok="handleModalOk"
@cancel="handleModalCancel" :confirm-loading="submitting" :width="520">
<template #title>
<span class="flex items-center gap-2">
<EditOutlined v-if="isEdit" class="text-[#FF8C42]" />
@ -153,52 +142,47 @@
{{ isEdit ? '编辑家长' : '添加家长' }}
</span>
</template>
<a-form
ref="formRef"
:model="formState"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form ref="formRef" :model="formState" :rules="rules" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="姓名" name="name">
<a-input v-model:value="formState.name" placeholder="请输入家长姓名">
<template #prefix><UserOutlined class="text-[#B2BEC3]" /></template>
<template #prefix>
<UserOutlined class="text-[#B2BEC3]" />
</template>
</a-input>
</a-form-item>
<a-form-item label="手机号" name="phone">
<a-input v-model:value="formState.phone" placeholder="请输入手机号">
<template #prefix><PhoneOutlined class="text-[#B2BEC3]" /></template>
<template #prefix>
<PhoneOutlined class="text-[#B2BEC3]" />
</template>
</a-input>
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formState.email" placeholder="请输入邮箱(可选)">
<template #prefix><MailOutlined class="text-[#B2BEC3]" /></template>
<template #prefix>
<MailOutlined class="text-[#B2BEC3]" />
</template>
</a-input>
</a-form-item>
<a-form-item label="登录账号" name="loginAccount">
<a-input
v-model:value="formState.loginAccount"
placeholder="请输入登录账号"
:disabled="isEdit"
>
<template #prefix><KeyOutlined class="text-[#B2BEC3]" /></template>
<a-input v-model:value="formState.loginAccount" placeholder="请输入登录账号" :disabled="isEdit">
<template #prefix>
<KeyOutlined class="text-[#B2BEC3]" />
</template>
</a-input>
</a-form-item>
<a-form-item v-if="!isEdit" label="密码" name="password">
<a-input-password v-model:value="formState.password" placeholder="请输入密码默认123456">
<template #prefix><LockOutlined class="text-[#B2BEC3]" /></template>
<template #prefix>
<LockOutlined class="text-[#B2BEC3]" />
</template>
</a-input-password>
</a-form-item>
</a-form>
</a-modal>
<!-- 管理孩子模态框 -->
<a-modal
v-model:open="childrenModalVisible"
title="管理关联孩子"
:width="650"
:footer="null"
>
<a-modal v-model:open="childrenModalVisible" title="管理关联孩子" :width="650" :footer="null">
<template #title>
<span class="flex items-center gap-2">
<TeamOutlined class="text-[#FF8C42]" />
@ -216,11 +200,7 @@
<div class="py-3 px-4 bg-[#FAFAFA] border-b border-[#F0F0F0] font-500 text-[#2D3436] list-header">
<span>已关联孩子 ({{ parentChildren.length }})</span>
</div>
<a-list
:data-source="parentChildren"
:loading="childrenLoading"
class="children-list-ant"
>
<a-list :data-source="parentChildren" :loading="childrenLoading" class="children-list-ant">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
@ -238,10 +218,7 @@
</template>
</a-list-item-meta>
<template #actions>
<a-popconfirm
title="确定要解除关联吗?"
@confirm="handleRemoveChild(item.id)"
>
<a-popconfirm title="确定要解除关联吗?" @confirm="handleRemoveChild(item.id)">
<a-button type="link" size="small" danger>
<DisconnectOutlined /> 解除
</a-button>
@ -250,20 +227,16 @@
</a-list-item>
</template>
</a-list>
<div v-if="parentChildren.length === 0 && !childrenLoading" class="py-10 text-center text-[#B2BEC3] empty-children">
<div v-if="parentChildren.length === 0 && !childrenLoading"
class="py-10 text-center text-[#B2BEC3] empty-children">
<span>暂无关联的孩子</span>
</div>
</div>
</a-modal>
<!-- 选择学生弹窗 -->
<a-modal
v-model:open="selectStudentModalVisible"
title="选择孩子"
:width="800"
:footer="null"
class="select-student-modal"
>
<a-modal v-model:open="selectStudentModalVisible" title="选择孩子" :width="800" :footer="null"
class="select-student-modal">
<template #title>
<span class="flex items-center gap-2">
<UserAddOutlined class="text-[#FF8C42]" />
@ -272,37 +245,19 @@
</template>
<div class="flex gap-3 mb-4 select-search-bar">
<a-input-search
v-model:value="studentSearchKeyword"
placeholder="搜索学生姓名"
class="w-[240px]"
@search="handleStudentSearch"
allow-clear
/>
<a-select
v-model:value="studentClassFilter"
placeholder="按班级筛选"
class="w-[160px]"
allow-clear
@change="handleStudentSearch"
>
<a-input-search v-model:value="studentSearchKeyword" placeholder="搜索学生姓名" class="w-[240px]"
@search="handleStudentSearch" allow-clear />
<a-select v-model:value="studentClassFilter" placeholder="按班级筛选" class="w-[160px]" allow-clear
@change="handleStudentSearch">
<a-select-option v-for="cls in classOptions" :key="cls.id" :value="cls.id">
{{ cls.name }}
</a-select-option>
</a-select>
</div>
<a-table
:columns="studentTableColumns"
:data-source="studentTableData"
:loading="studentsLoading"
:pagination="studentPagination"
:row-selection="studentRowSelection"
row-key="id"
size="small"
class="mt-4 select-student-table"
@change="handleStudentTableChange"
>
<a-table :columns="studentTableColumns" :data-source="studentTableData" :loading="studentsLoading"
:pagination="studentPagination" :row-selection="studentRowSelection" row-key="id" size="small"
class="mt-4 select-student-table" @change="handleStudentTableChange">
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'gender'">
{{ record.gender === 'MALE' ? '男' : record.gender === 'FEMALE' ? '女' : '-' }}
@ -310,7 +265,8 @@
</template>
</a-table>
<div class="flex justify-between items-center mt-4 p-4 bg-[#F8F9FA] rounded-xl select-footer" v-if="selectedStudent">
<div class="flex justify-between items-center mt-4 p-4 bg-[#F8F9FA] rounded-xl select-footer"
v-if="selectedStudent">
<div class="flex items-center gap-2 selected-info">
<span>已选择</span>
<a-tag color="orange">{{ selectedStudent.name }}</a-tag>
@ -333,12 +289,7 @@
</a-modal>
<!-- 重置密码确认模态框 -->
<a-modal
v-model:open="resetPasswordVisible"
@ok="confirmResetPassword"
:confirm-loading="resetting"
:width="400"
>
<a-modal v-model:open="resetPasswordVisible" @ok="confirmResetPassword" :confirm-loading="resetting" :width="400">
<template #title>
<span class="flex items-center gap-2">
<KeyOutlined class="text-[#FF8C42]" />
@ -352,7 +303,8 @@
</div>
<div v-if="newPassword" class="new-password-box">
<p class="mb-2 text-[#636E72]">新密码</p>
<div class="p-4 rounded-xl font-bold text-2xl text-white bg-[linear-gradient(135deg,#FF8C42_0%,#FFB347_100%)] password-display">
<div
class="p-4 rounded-xl font-bold text-2xl text-white bg-[linear-gradient(135deg,#FF8C42_0%,#FFB347_100%)] password-display">
<a-typography-text copyable class="!text-white">{{ newPassword }}</a-typography-text>
</div>
</div>
@ -672,11 +624,11 @@ const loadStudentsForSelect = async () => {
studentsLoading.value = true;
try {
const result = await getStudents({
page: studentPagination.current,
pageSize: studentPagination.pageSize,
keyword: studentSearchKeyword.value || undefined,
classId: studentClassFilter.value || undefined,
});
page: studentPagination.current,
pageSize: studentPagination.pageSize,
keyword: studentSearchKeyword.value || undefined,
classId: studentClassFilter.value || undefined,
});
studentTableData.value = result.items;
studentPagination.total = result.total;
} catch (error) {
@ -744,6 +696,7 @@ onMounted(() => {
border-radius: 12px;
border: 2px solid #F0F0F0;
}
.search-box :deep(.ant-input-affix-wrapper:hover) {
border-color: #FF8C42;
}
@ -759,15 +712,18 @@ onMounted(() => {
align-items: center;
padding: 12px 16px;
}
.children-list-ant :deep(.ant-list-item-meta) {
display: flex;
align-items: center;
margin-bottom: 0;
}
.children-list-ant :deep(.ant-list-item-meta-content) {
display: flex;
align-items: center;
}
.children-list-ant :deep(.ant-list-item-meta-title) {
margin-bottom: 0;
line-height: 1;
@ -782,6 +738,7 @@ onMounted(() => {
.search-box :deep(.ant-input-search) {
width: 100% !important;
}
.card-actions {
flex-wrap: wrap;
}

View File

@ -1,11 +1,15 @@
<template>
<div>
<div class="flex justify-between items-center mb-5">
<h2 class="m-0">课程排期</h2>
<a-space>
<div
class="flex flex-wrap gap-3 md:flex-nowrap md:justify-between md:items-center items-start mb-5"
>
<h2 class="m-0 flex-shrink-0">课程排期</h2>
<a-space :wrap="true">
<a-dropdown>
<a-button>
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
新建排课
<DownOutlined />
</a-button>
@ -24,12 +28,16 @@
</template>
</a-dropdown>
<a-button @click="showTemplateModal">
<template #icon><CopyOutlined /></template>
<template #icon>
<CopyOutlined />
</template>
排课模板
</a-button>
<a-dropdown>
<a-button>
<template #icon><CalendarOutlined /></template>
<template #icon>
<CalendarOutlined />
</template>
视图切换
<DownOutlined />
</a-button>
@ -53,39 +61,20 @@
<!-- 筛选区 -->
<div class="mb-5 p-4 bg-[#fafafa] rounded-lg">
<a-space wrap>
<a-select
v-model:value="filters.classId"
placeholder="选择班级"
allowClear
class="w-[150px]"
@change="loadSchedules"
>
<a-select v-model:value="filters.classId" placeholder="选择班级" allowClear class="w-[150px]"
@change="loadSchedules">
<a-select-option v-for="cls in classes" :key="cls.id" :value="cls.id">
{{ cls.name }}
</a-select-option>
</a-select>
<a-select
v-model:value="filters.teacherId"
placeholder="选择教师"
allowClear
class="w-[150px]"
@change="loadSchedules"
>
<a-select v-model:value="filters.teacherId" placeholder="选择教师" allowClear class="w-[150px]"
@change="loadSchedules">
<a-select-option v-for="teacher in teachers" :key="teacher.id" :value="teacher.id">
{{ teacher.name }}
</a-select-option>
</a-select>
<a-range-picker
:value="dateRange"
@change="handleDateChange"
/>
<a-select
v-model:value="filters.status"
placeholder="状态"
allowClear
class="w-[120px]"
@change="loadSchedules"
>
<a-range-picker :value="dateRange" @change="handleDateChange" />
<a-select v-model:value="filters.status" placeholder="状态" allowClear class="w-[120px]" @change="loadSchedules">
<a-select-option value="ACTIVE">有效</a-select-option>
<a-select-option value="CANCELLED">已取消</a-select-option>
</a-select>
@ -99,6 +88,7 @@
:loading="loading"
:pagination="pagination"
rowKey="id"
:scroll="{ x: true }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
@ -122,11 +112,7 @@
<template v-if="column.key === 'actions'">
<a-space>
<a-button type="link" size="small" @click="showEditModal(record)">编辑</a-button>
<a-popconfirm
v-if="record.status === 'ACTIVE'"
title="确定要取消此排课吗?"
@confirm="handleCancel(record.id)"
>
<a-popconfirm v-if="record.status === 'ACTIVE'" title="确定要取消此排课吗?" @confirm="handleCancel(record.id)">
<a-button type="link" size="small" danger>取消</a-button>
</a-popconfirm>
</a-space>
@ -135,49 +121,25 @@
</a-table>
<!-- 新建/编辑排课弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="editingSchedule ? '编辑排课' : '新建排课'"
:confirm-loading="modalLoading"
@ok="handleSubmit"
@cancel="handleModalCancel"
width="600px"
>
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-modal v-model:open="modalVisible" :title="editingSchedule ? '编辑排课' : '新建排课'" :confirm-loading="modalLoading"
@ok="handleSubmit" @cancel="handleModalCancel" width="600px">
<a-form ref="formRef" :model="formState" :rules="formRules" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="班级" name="classId">
<a-select
v-model:value="formState.classId"
placeholder="选择班级"
:disabled="!!editingSchedule"
>
<a-select v-model:value="formState.classId" placeholder="选择班级" :disabled="!!editingSchedule">
<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="课程" name="courseId">
<a-select
v-model:value="formState.courseId"
placeholder="选择课程"
:disabled="!!editingSchedule"
>
<a-select v-model:value="formState.courseId" placeholder="选择课程" :disabled="!!editingSchedule">
<a-select-option v-for="course in courses" :key="course.id" :value="course.id">
{{ course.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="授课教师" name="teacherId">
<a-select
v-model:value="formState.teacherId"
placeholder="选择教师(可选)"
allowClear
>
<a-select v-model:value="formState.teacherId" placeholder="选择教师(可选)" allowClear>
<a-select-option v-for="teacher in teachers" :key="teacher.id" :value="teacher.id">
{{ teacher.name }}
</a-select-option>
@ -187,12 +149,8 @@
<a-date-picker v-model:value="formState.scheduledDate" style="width: 100%" />
</a-form-item>
<a-form-item label="时间段" name="scheduledTimeRange">
<a-time-range-picker
v-model:value="formState.scheduledTimeRange"
format="HH:mm"
style="width: 100%"
:placeholder="['开始时间', '结束时间']"
/>
<a-time-range-picker v-model:value="formState.scheduledTimeRange" format="HH:mm" style="width: 100%"
:placeholder="['开始时间', '结束时间']" />
</a-form-item>
<a-form-item label="重复方式" name="repeatType">
<a-radio-group v-model:value="formState.repeatType">
@ -211,12 +169,7 @@
</a-modal>
<!-- 排课模板管理弹窗 -->
<a-modal
v-model:open="templateModalVisible"
title="排课模板管理"
:footer="null"
width="800px"
>
<a-modal v-model:open="templateModalVisible" title="排课模板管理" :footer="null" width="800px">
<div class="mb-4 flex justify-end">
<a-button type="primary" size="small" @click="showCreateTemplateModal">
<PlusOutlined /> 新建模板
@ -229,6 +182,7 @@
size="small"
rowKey="id"
:pagination="{ pageSize: 5 }"
:scroll="{ x: true }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'weekDay'">
@ -252,18 +206,9 @@
</a-modal>
<!-- 新建/编辑模板弹窗 -->
<a-modal
v-model:open="templateFormModalVisible"
:title="editingTemplate ? '编辑模板' : '新建模板'"
:confirm-loading="templateFormLoading"
@ok="handleTemplateSubmit"
>
<a-form
:model="templateForm"
:rules="templateFormRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-modal v-model:open="templateFormModalVisible" :title="editingTemplate ? '编辑模板' : '新建模板'"
:confirm-loading="templateFormLoading" @ok="handleTemplateSubmit">
<a-form :model="templateForm" :rules="templateFormRules" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="模板名称" name="name">
<a-input v-model:value="templateForm.name" placeholder="如:周一早读" />
</a-form-item>
@ -312,19 +257,9 @@
</a-modal>
<!-- 批量排课弹窗 -->
<a-modal
v-model:open="batchModalVisible"
title="批量新建排课"
:confirm-loading="batchLoading"
@ok="handleBatchSubmit"
width="900px"
>
<a-alert
message="批量添加排课信息,点击下方按钮添加更多行"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-modal v-model:open="batchModalVisible" title="批量新建排课" :confirm-loading="batchLoading" @ok="handleBatchSubmit"
width="900px">
<a-alert message="批量添加排课信息,点击下方按钮添加更多行" type="info" show-icon style="margin-bottom: 16px" />
<div class="mb-4">
<a-button type="dashed" @click="addBatchItem">
<PlusOutlined /> 添加排课
@ -336,6 +271,7 @@
size="small"
rowKey="key"
:pagination="false"
:scroll="{ x: true }"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'classId'">
@ -375,12 +311,8 @@
</a-modal>
<!-- 从模板创建弹窗 -->
<a-modal
v-model:open="templateSelectModalVisible"
title="从模板创建排课"
:confirm-loading="templateSelectLoading"
@ok="handleTemplateSelectSubmit"
>
<a-modal v-model:open="templateSelectModalVisible" title="从模板创建排课" :confirm-loading="templateSelectLoading"
@ok="handleTemplateSelectSubmit">
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="选择模板">
<a-select v-model:value="selectedTemplateId" placeholder="选择排课模板" style="width: 100%">
@ -464,14 +396,14 @@ const pagination = reactive({
//
const columns = [
{ title: '班级', dataIndex: 'className', key: 'className' },
{ title: '课程', dataIndex: 'courseName', key: 'courseName' },
{ title: '授课教师', dataIndex: 'teacherName', key: 'teacherName' },
{ title: '排课时间', key: 'scheduledDate' },
{ title: '重复', dataIndex: 'repeatType', key: 'repeatType' },
{ title: '来源', dataIndex: 'source', key: 'source' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '操作', key: 'actions', width: 150 },
{ title: '班级', dataIndex: 'className', key: 'className', minWidth: 100 },
{ title: '课程', dataIndex: 'courseName', key: 'courseName', minWidth: 160 },
{ title: '授课教师', dataIndex: 'teacherName', key: 'teacherName', minWidth: 120 },
{ title: '排课时间', key: 'scheduledDate', minWidth: 180 },
{ title: '重复', dataIndex: 'repeatType', key: 'repeatType', minWidth: 80, maxWidth: 100 },
{ title: '来源', dataIndex: 'source', key: 'source', minWidth: 90, maxWidth: 110 },
{ title: '状态', dataIndex: 'status', key: 'status', minWidth: 80, maxWidth: 100 },
{ title: '操作', key: 'actions', minWidth: 150, fixed: 'right' as const },
];
//
@ -681,14 +613,14 @@ const templateModalVisible = ref(false);
const templateLoading = ref(false);
const templates = ref<ScheduleTemplate[]>([]);
const templateColumns = [
{ title: '模板名称', dataIndex: 'name', key: 'name' },
{ title: '课程', dataIndex: 'courseName', key: 'courseName' },
{ title: '班级', dataIndex: 'className', key: 'className' },
{ title: '教师', dataIndex: 'teacherName', key: 'teacherName' },
{ title: '时间', dataIndex: 'scheduledTime', key: 'scheduledTime' },
{ title: '周几', key: 'weekDay' },
{ title: '默认', key: 'isDefault' },
{ title: '操作', key: 'actions', width: 180 },
{ title: '模板名称', dataIndex: 'name', key: 'name', minWidth: 160 },
{ title: '课程', dataIndex: 'courseName', key: 'courseName', minWidth: 140 },
{ title: '班级', dataIndex: 'className', key: 'className', minWidth: 120 },
{ title: '教师', dataIndex: 'teacherName', key: 'teacherName', minWidth: 120 },
{ title: '时间', dataIndex: 'scheduledTime', key: 'scheduledTime', minWidth: 130, maxWidth: 150 },
{ title: '周几', key: 'weekDay', minWidth: 80, maxWidth: 90 },
{ title: '默认', key: 'isDefault', minWidth: 80, maxWidth: 90 },
{ title: '操作', key: 'actions', minWidth: 180, fixed: 'right' as const },
];
const weekDayNames: Record<number, string> = {
@ -842,12 +774,12 @@ const batchItems = ref<any[]>([]);
let batchKey = 0;
const batchColumns = [
{ title: '班级', key: 'classId', width: 120 },
{ title: '课程', key: 'courseId', width: 150 },
{ title: '教师', key: 'teacherId', width: 100 },
{ title: '日期', key: 'scheduledDate', width: 140 },
{ title: '时间', key: 'scheduledTime', width: 120 },
{ title: '', key: 'actions', width: 50 },
{ title: '班级', key: 'classId', minWidth: 140, maxWidth: 160 },
{ title: '课程', key: 'courseId', minWidth: 170, maxWidth: 190 },
{ title: '教师', key: 'teacherId', minWidth: 120, maxWidth: 140 },
{ title: '日期', key: 'scheduledDate', minWidth: 150, maxWidth: 170 },
{ title: '时间', key: 'scheduledTime', minWidth: 130, maxWidth: 150 },
{ title: '操作', key: 'actions', minWidth: 70, maxWidth: 80, fixed: 'right' as const },
];
const showBatchModal = () => {
@ -927,4 +859,8 @@ onMounted(() => {
<style scoped>
/* 仅保留无法用 UnoCSS 实现的部分 */
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
</style>

View File

@ -5,7 +5,9 @@
<a-space>
<a-dropdown>
<a-button>
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
新建排课
<DownOutlined />
</a-button>
@ -24,12 +26,16 @@
</template>
</a-dropdown>
<a-button @click="showTemplateModal">
<template #icon><CopyOutlined /></template>
<template #icon>
<CopyOutlined />
</template>
排课模板
</a-button>
<a-dropdown>
<a-button>
<template #icon><CalendarOutlined /></template>
<template #icon>
<CalendarOutlined />
</template>
视图切换
<DownOutlined />
</a-button>
@ -49,12 +55,16 @@
</a-dropdown>
<a-divider type="vertical" />
<a-button @click="goToPrevWeek">
<template #icon><LeftOutlined /></template>
<template #icon>
<LeftOutlined />
</template>
上一周
</a-button>
<a-button @click="goToCurrentWeek">本周</a-button>
<a-button @click="goToNextWeek">
<template #icon><RightOutlined /></template>
<template #icon>
<RightOutlined />
</template>
下一周
</a-button>
</a-space>
@ -65,24 +75,14 @@
<a-space>
<span>周次{{ weekRangeText }}</span>
<a-divider type="vertical" />
<a-select
v-model:value="filters.classId"
placeholder="选择班级"
allowClear
class="w-[150px]"
@change="loadTimetable"
>
<a-select v-model:value="filters.classId" placeholder="选择班级" allowClear class="w-[150px]"
@change="loadTimetable">
<a-select-option v-for="cls in classes" :key="cls.id" :value="cls.id">
{{ cls.name }}
</a-select-option>
</a-select>
<a-select
v-model:value="filters.teacherId"
placeholder="选择教师"
allowClear
class="w-[150px]"
@change="loadTimetable"
>
<a-select v-model:value="filters.teacherId" placeholder="选择教师" allowClear class="w-[150px]"
@change="loadTimetable">
<a-select-option v-for="teacher in teachers" :key="teacher.id" :value="teacher.id">
{{ teacher.name }}
</a-select-option>
@ -93,12 +93,8 @@
<!-- 课表 -->
<div class="border border-[#e8e8e8] rounded-lg overflow-hidden">
<div class="grid grid-cols-7 bg-[linear-gradient(135deg,#FF8C42_0%,#E67635_100%)] text-white timetable-header">
<div
v-for="day in weekDays"
:key="day.date"
class="p-3 text-center border-r border-white/20 last:border-r-0"
:class="day.isToday ? 'bg-white/20' : ''"
>
<div v-for="day in weekDays" :key="day.date" class="p-3 text-center border-r border-white/20 last:border-r-0"
:class="day.isToday ? 'bg-white/20' : ''">
<div class="font-500">{{ day.dayName }}</div>
<div class="text-xs opacity-80">{{ day.dateDisplay }}</div>
</div>
@ -107,25 +103,19 @@
<div class="min-h-[400px]">
<a-spin :spinning="loading">
<div class="grid grid-cols-7 timetable-grid">
<div
v-for="day in weekDays"
:key="day.date"
<div v-for="day in weekDays" :key="day.date"
class="min-h-[300px] p-2 border-r border-[#e8e8e8] bg-[#fafafa] last:border-r-0 align-top"
:class="day.isToday ? 'bg-[#fff4ec]' : ''"
>
<div
v-for="schedule in day.schedules"
:key="schedule.id"
:class="day.isToday ? 'bg-[#fff4ec]' : ''">
<div v-for="schedule in day.schedules" :key="schedule.id"
class="bg-white rounded-lg p-2.5 mb-2 shadow-[0_1px_3px_rgba(0,0,0,0.1)] cursor-pointer transition-all duration-300 border-l-[3px] hover:shadow-[0_3px_8px_rgba(0,0,0,0.15)] hover:-translate-y-0.5"
:class="[
schedule.source === 'SCHOOL' ? 'border-l-[#FF8C42]' : '',
schedule.source === 'TEACHER' ? 'border-l-[#722ed1]' : '',
schedule.status === 'CANCELLED' ? 'opacity-50 border-l-[#999]' : '',
]"
@click="showScheduleDetail(schedule)"
>
]" @click="showScheduleDetail(schedule)">
<div class="text-xs text-[#666] mb-1">{{ schedule.scheduledTime || '待定' }}</div>
<div class="font-500 text-[#333] mb-1 overflow-hidden text-ellipsis whitespace-nowrap">{{ schedule.courseName }}</div>
<div class="font-500 text-[#333] mb-1 overflow-hidden text-ellipsis whitespace-nowrap">{{
schedule.courseName }}</div>
<div class="text-xs text-[#666]">{{ schedule.className }}</div>
<div v-if="schedule.teacherName" class="text-xs text-[#999] mt-1">
{{ schedule.teacherName }}
@ -142,12 +132,7 @@
</div>
<!-- 排课详情弹窗 -->
<a-modal
v-model:open="detailVisible"
title="排课详情"
:footer="null"
width="500px"
>
<a-modal v-model:open="detailVisible" title="排课详情" :footer="null" width="500px">
<template v-if="selectedSchedule">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="班级">{{ selectedSchedule.className }}</a-descriptions-item>
@ -174,30 +159,19 @@
</a-modal>
<!-- 排课模板管理弹窗 -->
<a-modal
v-model:open="templateModalVisible"
title="排课模板管理"
:footer="null"
width="800px"
>
<a-modal v-model:open="templateModalVisible" title="排课模板管理" :footer="null" width="800px">
<div class="mb-4 text-right">
<a-button type="primary" size="small" @click="router.push('/school/schedule')">
<PlusOutlined /> 新建模板
</a-button>
</div>
<a-table
:columns="[
{ title: '模板名称', dataIndex: 'name' },
{ title: '课程', dataIndex: 'courseName' },
{ title: '时间', dataIndex: 'scheduledTime' },
{ title: '操作', key: 'actions', width: 100 },
]"
:data-source="templates"
:loading="templateLoading"
size="small"
rowKey="id"
:pagination="{ pageSize: 5 }"
>
<a-table :columns="[
{ title: '模板名称', dataIndex: 'name', key: 'name', minWidth: 160 },
{ title: '课程', dataIndex: 'courseName', key: 'courseName', minWidth: 160 },
{ title: '时间', dataIndex: 'scheduledTime', key: 'scheduledTime', minWidth: 140, maxWidth: 160 },
{ title: '操作', key: 'actions', minWidth: 100, fixed: 'right' as const },
]" :data-source="templates" :loading="templateLoading" size="small" rowKey="id" :pagination="{ pageSize: 5 }"
:scroll="{ x: true }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'actions'">
<a-button type="link" size="small" @click="applyTemplate(record as any)">应用</a-button>
@ -207,33 +181,22 @@
</a-modal>
<!-- 批量排课弹窗 -->
<a-modal
v-model:open="batchModalVisible"
title="批量新建排课"
:confirm-loading="batchLoading"
@ok="handleBatchSubmit"
width="900px"
>
<a-modal v-model:open="batchModalVisible" title="批量新建排课" :confirm-loading="batchLoading" @ok="handleBatchSubmit"
width="900px">
<a-alert message="批量添加排课信息" type="info" show-icon class="mb-4" />
<div class="mb-4">
<a-button type="dashed" @click="addBatchItem">
<PlusOutlined /> 添加排课
</a-button>
</div>
<a-table
:columns="[
{ title: '班级', key: 'classId', width: 120 },
{ title: '课程', key: 'courseId', width: 150 },
{ title: '教师', key: 'teacherId', width: 100 },
{ title: '日期', key: 'scheduledDate', width: 140 },
{ title: '时间', key: 'scheduledTime', width: 120 },
{ title: '', key: 'actions', width: 50 },
]"
:data-source="batchItems"
size="small"
rowKey="key"
:pagination="false"
>
<a-table :columns="[
{ title: '班级', key: 'classId', minWidth: 140, maxWidth: 160 },
{ title: '课程', key: 'courseId', minWidth: 180, maxWidth: 200 },
{ title: '教师', key: 'teacherId', minWidth: 120, maxWidth: 140 },
{ title: '日期', key: 'scheduledDate', minWidth: 150, maxWidth: 170 },
{ title: '时间', key: 'scheduledTime', minWidth: 130, maxWidth: 150 },
{ title: '操作', key: 'actions', minWidth: 70, fixed: 'right' as const },
]" :data-source="batchItems" size="small" rowKey="key" :pagination="false" :scroll="{ x: true }">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'classId'">
<a-select v-model:value="record.classId" placeholder="班级" class="w-full">
@ -272,12 +235,8 @@
</a-modal>
<!-- 从模板创建弹窗 -->
<a-modal
v-model:open="templateSelectModalVisible"
title="从模板创建排课"
:confirm-loading="templateSelectLoading"
@ok="handleTemplateSelectSubmit"
>
<a-modal v-model:open="templateSelectModalVisible" title="从模板创建排课" :confirm-loading="templateSelectLoading"
@ok="handleTemplateSelectSubmit">
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="选择模板">
<a-select v-model:value="selectedTemplateId" placeholder="选择排课模板" class="w-full">
@ -594,4 +553,8 @@ onMounted(() => {
<style scoped>
/* 仅保留无法用 UnoCSS 实现的部分 */
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
</style>

View File

@ -67,6 +67,7 @@
:loading="loading"
row-key="id"
:pagination="{ pageSize: 10 }"
:scroll="{ x: true }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
@ -322,21 +323,21 @@ const getFileUrl = (filePath: string | null | undefined): string => {
};
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
{ title: '校本课程包名称', key: 'name' },
{ title: '基于课程包', key: 'sourceCourse' },
{ title: '创建者', key: 'createdBy', width: 100 },
{ title: '使用次数', dataIndex: 'usageCount', key: 'usageCount', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
{ title: '操作', key: 'action', width: 180 },
{ 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', 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 },
{ 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 },
];
//
@ -538,4 +539,8 @@ onMounted(() => {
.list-card :deep(.ant-card-head) {
border-bottom: 1px solid #f0f0f0;
}
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
</style>

View File

@ -7,34 +7,21 @@
<!-- 筛选区 -->
<div class="mb-5 p-4 bg-[#fafafa] rounded-lg filter-section">
<a-space wrap>
<a-select
v-model:value="filters.module"
placeholder="选择模块"
allowClear
class="w-[150px]"
@change="loadLogs"
>
<a-select v-model:value="filters.module" placeholder="选择模块" allowClear class="w-[150px]" @change="loadLogs">
<a-select-option v-for="module in modules" :key="module" :value="module">
{{ module }}
</a-select-option>
</a-select>
<a-select
v-model:value="filters.action"
placeholder="选择操作"
allowClear
class="w-[150px]"
@change="loadLogs"
>
<a-select v-model:value="filters.action" placeholder="选择操作" allowClear class="w-[150px]" @change="loadLogs">
<a-select-option v-for="action in actions" :key="action" :value="action">
{{ action }}
</a-select-option>
</a-select>
<a-range-picker
:value="dateRange"
@change="handleDateChange"
/>
<a-range-picker :value="dateRange" @change="handleDateChange" />
<a-button @click="loadLogs">
<template #icon><SearchOutlined /></template>
<template #icon>
<SearchOutlined />
</template>
查询
</a-button>
</a-space>
@ -62,14 +49,8 @@
</div>
<!-- 日志列表 -->
<a-table
:columns="columns"
:data-source="logs"
:loading="loading"
:pagination="pagination"
rowKey="id"
@change="handleTableChange"
>
<a-table :columns="columns" :data-source="logs" :loading="loading" :pagination="pagination" rowKey="id"
:scroll="{ x: true }" @change="handleTableChange">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'userType'">
<a-tag :color="getUserTypeColor(record.userType)">{{ record.userType }}</a-tag>
@ -90,12 +71,7 @@
</a-table>
<!-- 详情弹窗 -->
<a-modal
v-model:open="detailModalVisible"
title="操作日志详情"
:footer="null"
width="700px"
>
<a-modal v-model:open="detailModalVisible" title="操作日志详情" :footer="null" width="700px">
<a-descriptions :column="2" bordered size="small" v-if="selectedLog">
<a-descriptions-item label="操作用户">
{{ selectedLog.userType }} (ID: {{ selectedLog.userId }})
@ -119,10 +95,12 @@
{{ selectedLog.description }}
</a-descriptions-item>
<a-descriptions-item label="变更前数据" :span="2">
<pre class="max-h-[200px] overflow-auto bg-[#f5f5f5] p-2 rounded text-xs m-0 whitespace-pre-wrap break-all">{{ formatJson(selectedLog.oldValue) }}</pre>
<pre class="max-h-[200px] overflow-auto bg-[#f5f5f5] p-2 rounded text-xs m-0 whitespace-pre-wrap break-all">{{
formatJson(selectedLog.oldValue) }}</pre>
</a-descriptions-item>
<a-descriptions-item label="变更后数据" :span="2">
<pre class="max-h-[200px] overflow-auto bg-[#f5f5f5] p-2 rounded text-xs m-0 whitespace-pre-wrap break-all">{{ formatJson(selectedLog.newValue) }}</pre>
<pre class="max-h-[200px] overflow-auto bg-[#f5f5f5] p-2 rounded text-xs m-0 whitespace-pre-wrap break-all">{{
formatJson(selectedLog.newValue) }}</pre>
</a-descriptions-item>
</a-descriptions>
</a-modal>
@ -184,13 +162,13 @@ const pagination = reactive({
//
const columns = [
{ title: '用户类型', key: 'userType', width: 100 },
{ title: '模块', key: 'module', width: 120 },
{ title: '操作', key: 'action', width: 120 },
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{ title: 'IP地址', dataIndex: 'ipAddress', key: 'ipAddress', width: 130 },
{ title: '时间', key: 'createdAt', width: 180 },
{ title: '操作', key: 'actions', width: 80 },
{ title: '用户类型', key: 'userType', minWidth: 100, maxWidth: 120 },
{ title: '模块', key: 'module', minWidth: 120, maxWidth: 140 },
{ title: '操作', key: 'action', minWidth: 120, maxWidth: 140 },
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true, minWidth: 260 },
{ title: 'IP地址', dataIndex: 'ipAddress', key: 'ipAddress', minWidth: 140, maxWidth: 160 },
{ title: '时间', key: 'createdAt', minWidth: 180, maxWidth: 200 },
{ title: '操作', key: 'actions', minWidth: 100, fixed: 'right' as const },
];
//
@ -282,3 +260,9 @@ onMounted(() => {
loadStats();
});
</script>
<style scoped>
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
</style>

View File

@ -46,41 +46,25 @@
<!-- 筛选区域 -->
<div class="flex gap-3 mb-6 filter-bar">
<a-select
v-model:value="filters.status"
placeholder="任务状态"
class="w-[120px]"
allowClear
@change="loadTasks"
>
<a-select v-model:value="filters.status" placeholder="任务状态" class="w-[120px]" allowClear @change="loadTasks">
<a-select-option value="PUBLISHED">进行中</a-select-option>
<a-select-option value="DRAFT">草稿</a-select-option>
<a-select-option value="ARCHIVED">已归档</a-select-option>
</a-select>
<a-select
v-model:value="filters.taskType"
placeholder="任务类型"
class="w-[120px]"
allowClear
@change="loadTasks"
>
<a-select v-model:value="filters.taskType" placeholder="任务类型" class="w-[120px]" allowClear @change="loadTasks">
<a-select-option value="READING">阅读</a-select-option>
<a-select-option value="ACTIVITY">活动</a-select-option>
<a-select-option value="HOMEWORK">作业</a-select-option>
</a-select>
<a-input-search
v-model:value="filters.keyword"
placeholder="搜索任务标题"
class="w-[200px]"
@search="loadTasks"
allow-clear
/>
<a-input-search v-model:value="filters.keyword" placeholder="搜索任务标题" class="w-[200px]" @search="loadTasks"
allow-clear />
</div>
<!-- 任务列表 -->
<a-spin :spinning="loading">
<div class="flex flex-col gap-4 task-list" v-if="tasks.length > 0">
<div v-for="task in tasks" :key="task.id" class="bg-white border border-[#f0f0f0] rounded-xl overflow-hidden task-card">
<div v-for="task in tasks" :key="task.id"
class="bg-white border border-[#f0f0f0] rounded-xl overflow-hidden task-card">
<div class="flex justify-between items-center py-4 px-5 bg-[#fafafa] border-b border-[#f0f0f0] card-header">
<div class="flex items-center gap-3 task-title">
<a-tag :color="getTypeColor(task.taskType)">{{ getTypeText(task.taskType) }}</a-tag>
@ -107,13 +91,10 @@
<div class="flex justify-between items-center py-3 px-5 bg-[#fafafa] border-t border-[#f0f0f0] card-footer">
<div class="flex items-center gap-3 progress-info">
<a-progress
:percent="getCompletionRate(task)"
:stroke-color="{ '0%': '#52c41a', '100%': '#73d13d' }"
size="small"
class="w-[150px]"
/>
<span class="text-[13px] text-[#666]">{{ getCompletedCount(task) }}/{{ task.completionCount || 0 }} 人完成</span>
<a-progress :percent="getCompletionRate(task)" :stroke-color="{ '0%': '#52c41a', '100%': '#73d13d' }"
size="small" class="w-[150px]" />
<span class="text-[13px] text-[#666]">{{ getCompletedCount(task) }}/{{ task.completionCount || 0 }}
人完成</span>
</div>
<div class="flex gap-2 card-actions">
<a-button type="link" size="small" @click="viewCompletionDetail(task)">
@ -142,24 +123,13 @@
<!-- 分页 -->
<div class="flex justify-center mt-6 pagination-section" v-if="total > pageSize">
<a-pagination
v-model:current="currentPage"
:total="total"
:page-size="pageSize"
@change="onPageChange"
show-quick-jumper
:show-total="(total: number) => `共 ${total} 条`"
/>
<a-pagination v-model:current="currentPage" :total="total" :page-size="pageSize" @change="onPageChange"
show-quick-jumper :show-total="(total: number) => `共 ${total} 条`" />
</div>
<!-- 创建/编辑任务弹窗 -->
<a-modal
v-model:open="createModalVisible"
:title="isEdit ? '编辑任务' : '发布任务'"
@ok="handleSubmit"
:confirm-loading="creating"
width="600px"
>
<a-modal v-model:open="createModalVisible" :title="isEdit ? '编辑任务' : '发布任务'" @ok="handleSubmit"
:confirm-loading="creating" width="600px">
<a-form :model="createForm" layout="vertical">
<a-form-item label="任务标题" required>
<a-input v-model:value="createForm.title" placeholder="请输入任务标题" />
@ -187,46 +157,28 @@
</a-col>
</a-row>
<a-form-item label="选择目标" required v-if="createForm.targetType === 'CLASS'">
<a-select
v-model:value="createForm.targetIds"
mode="multiple"
placeholder="请选择班级"
class="w-full"
>
<a-select v-model:value="createForm.targetIds" mode="multiple" placeholder="请选择班级" class="w-full">
<a-select-option v-for="cls in classes" :key="cls.id" :value="cls.id">
{{ cls.name }} ({{ cls.grade }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="任务时间" required>
<a-range-picker
v-model:value="createForm.dateRange"
class="w-full"
/>
<a-range-picker v-model:value="createForm.dateRange" class="w-full" />
</a-form-item>
</a-form>
</a-modal>
<!-- 完成情况弹窗 -->
<a-modal
v-model:open="completionModalVisible"
:title="`完成情况 - ${selectedTask?.title || ''}`"
:footer="null"
width="700px"
>
<a-modal v-model:open="completionModalVisible" :title="`完成情况 - ${selectedTask?.title || ''}`" :footer="null"
width="700px">
<div class="flex gap-2 mb-4 completion-stats">
<a-tag color="blue">{{ completionStats.pending }} 待完成</a-tag>
<a-tag color="orange">{{ completionStats.inProgress }} 进行中</a-tag>
<a-tag color="green">{{ completionStats.completed }} 已完成</a-tag>
</div>
<a-table
:dataSource="completions"
:columns="completionColumns"
:pagination="false"
:loading="loadingCompletions"
rowKey="id"
size="small"
>
<a-table :dataSource="completions" :columns="completionColumns" :pagination="false" :loading="loadingCompletions"
rowKey="id" size="small">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getCompletionStatusColor(record.status)">

View File

@ -5,7 +5,9 @@
<ArrowLeftOutlined />
</a-button>
<div class="header-info">
<h2><TeamOutlined /> 班级学生</h2>
<h2>
<TeamOutlined /> 班级学生
</h2>
<p class="page-desc" v-if="classInfo">{{ classInfo.name }} - {{ students.length }} 名学生</p>
</div>
</div>
@ -46,21 +48,11 @@
<div class="students-section">
<div class="section-header">
<h3>学生列表</h3>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索学生"
style="width: 200px;"
allow-clear
/>
<a-input-search v-model:value="searchKeyword" placeholder="搜索学生" style="width: 200px;" allow-clear />
</div>
<a-table
:columns="columns"
:data-source="filteredStudents"
:pagination="{ pageSize: 10 }"
row-key="id"
@row="handleRowClick"
>
<a-table :columns="columns" :data-source="filteredStudents" :pagination="{ pageSize: 10 }" row-key="id"
:scroll="{ x: true }" @row="handleRowClick">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="student-name-cell">
@ -78,12 +70,8 @@
<a-tag color="orange">{{ record.lessonCount || 0 }}</a-tag>
</template>
<template v-else-if="column.key === 'avgScore'">
<a-progress
:percent="record.avgScore || 0"
:stroke-color="getScoreColor(record.avgScore)"
size="small"
style="width: 80px;"
/>
<a-progress :percent="record.avgScore || 0" :stroke-color="getScoreColor(record.avgScore)" size="small"
style="width: 80px;" />
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click.stop="showStudentDetail(record)">
@ -96,12 +84,7 @@
</a-spin>
<!-- 学生详情抽屉 -->
<a-drawer
v-model:open="detailDrawerVisible"
title="学生详情"
placement="right"
:width="480"
>
<a-drawer v-model:open="detailDrawerVisible" title="学生详情" placement="right" :width="480">
<div class="student-detail" v-if="selectedStudent">
<div class="detail-header">
<a-avatar :size="64" :style="{ backgroundColor: getAvatarColor(selectedStudent.id) }">
@ -153,7 +136,8 @@
<div class="record-course">{{ record.lesson?.course?.name || '未知课程' }}</div>
<div class="record-date">{{ formatDateTime(record.lesson?.startDatetime) }}</div>
<div class="record-score">
<a-rate :value="(record.focus || 0 + record.participation || 0) / 2" disabled allow-half style="font-size: 12px;" />
<a-rate :value="(record.focus || 0 + record.participation || 0) / 2" disabled allow-half
style="font-size: 12px;" />
</div>
</div>
</div>
@ -196,11 +180,11 @@ const avatarColors = ['#FF8C42', '#667eea', '#f093fb', '#4facfe', '#43e97b', '#f
const getAvatarColor = (id: number) => avatarColors[id % avatarColors.length];
const columns = [
{ title: '姓名', key: 'name', dataIndex: 'name' },
{ title: '阅读次数', key: 'readingCount', dataIndex: 'readingCount', width: 100 },
{ title: '上课次数', key: 'lessonCount', dataIndex: 'lessonCount', width: 100 },
{ title: '平均得分', key: 'avgScore', width: 120 },
{ title: '操作', key: 'action', width: 100 },
{ title: '姓名', key: 'name', dataIndex: 'name', minWidth: 140 },
{ title: '阅读次数', key: 'readingCount', dataIndex: 'readingCount', minWidth: 100 },
{ title: '上课次数', key: 'lessonCount', dataIndex: 'lessonCount', minWidth: 100 },
{ title: '平均得分', key: 'avgScore', minWidth: 120 },
{ title: '操作', key: 'action', minWidth: 100, fixed: 'right' as const },
];
const filteredStudents = computed(() => {
@ -273,6 +257,10 @@ $primary-color: #FF8C42;
$primary-light: #FFF4EC;
.class-students-view {
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
.page-header {
display: flex;
align-items: center;

View File

@ -28,12 +28,7 @@
<a-divider>课程列表</a-divider>
<a-table
:columns="lessonColumns"
:data-source="detail?.lessons || []"
row-key="id"
:pagination="false"
>
<a-table :columns="lessonColumns" :data-source="detail?.lessons || []" row-key="id" :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'lessonType'">
<a-tag>{{ getLessonTypeName(record.lessonType) }}</a-tag>

View File

@ -6,25 +6,19 @@
</template>
<template #extra>
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
创建校本课程包
</a-button>
</template>
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
row-key="id"
>
<a-table :columns="columns" :data-source="dataSource" :loading="loading" row-key="id" :scroll="{ x: true }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'sourceCourse'">
<div class="flex items-center gap-2">
<img
v-if="record.sourceCourse?.coverImagePath"
:src="record.sourceCourse.coverImagePath"
class="w-10 h-10 object-cover rounded"
/>
<img v-if="record.sourceCourse?.coverImagePath" :src="record.sourceCourse.coverImagePath"
class="w-10 h-10 object-cover rounded" />
<span>{{ record.sourceCourse?.name }}</span>
</div>
</template>
@ -62,13 +56,13 @@ const loading = ref(false);
const dataSource = ref<SchoolCourse[]>([]);
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '源课程包', key: 'sourceCourse' },
{ title: '修改说明', dataIndex: 'changesSummary', key: 'changesSummary' },
{ title: '使用次数', dataIndex: 'usageCount', key: 'usageCount', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
{ title: '操作', key: 'action', width: 180 },
{ title: 'ID', dataIndex: 'id', key: 'id', minWidth: 80 },
{ title: '名称', dataIndex: 'name', key: 'name', minWidth: 180 },
{ title: '源课程包', key: 'sourceCourse', minWidth: 180 },
{ title: '修改说明', dataIndex: 'changesSummary', key: 'changesSummary', minWidth: 220 },
{ title: '使用次数', dataIndex: 'usageCount', key: 'usageCount', minWidth: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', minWidth: 80 },
{ title: '操作', key: 'action', minWidth: 180, fixed: 'right' as const },
];
const fetchData = async () => {
@ -109,3 +103,9 @@ onMounted(() => {
fetchData();
});
</script>
<style scoped>
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
</style>