Merge remote-tracking branch 'origin/master'

This commit is contained in:
En 2026-03-23 19:58:11 +08:00
commit b715c9a31c
51 changed files with 825 additions and 464 deletions

View File

@ -230,6 +230,17 @@ export function formatPrice(price: number | null | undefined): string {
return `¥${(price / 100).toFixed(2)}`;
}
// 优惠类型映射(与套餐列表、租户选择保持一致)
export const DISCOUNT_TYPE_MAP: Record<string, string> = {
PERCENTAGE: '折扣',
FIXED: '立减',
};
export function getDiscountTypeText(type?: string): string {
if (!type) return '-';
return DISCOUNT_TYPE_MAP[type] || type;
}
// 格式化日期
export function formatDate(date: string): string {
return new Date(date).toLocaleString('zh-CN');

View File

@ -52,10 +52,18 @@ export interface LessonTypeOption {
count: number;
}
/** 筛选元数据 - 课程包主题选项 */
export interface ThemeOption {
themeId: number;
name: string;
count: number;
}
/** 筛选元数据响应 */
export interface FilterMetaResponse {
grades: GradeOption[];
lessonTypes: LessonTypeOption[];
themes?: ThemeOption[];
}
// ============= API 接口 =============
@ -75,6 +83,7 @@ export function getPackages(
params?: {
grade?: string;
lessonType?: string;
themeId?: number;
keyword?: string;
}
): Promise<CoursePackage[]> {

View File

@ -48,6 +48,7 @@ export interface Course {
// 新增字段
themeId?: number;
theme?: { id: number; name: string };
themeName?: string;
coreContent?: string;
coverImagePath?: string;
domainTags?: string[];

View File

@ -211,11 +211,18 @@ export const fileApi = {
/**
* URL
* OSS URL /
*/
getFileUrl: (filePath: string): string => {
// filePath 格式: /uploads/courses/covers/xxx.png
// 直接返回相对路径,由 nginx 或后端静态服务处理
return filePath;
getFileUrl: (filePath: string | null | undefined): string => {
if (!filePath) return '';
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
return filePath;
}
const SERVER_BASE = import.meta.env.VITE_SERVER_BASE_URL || '/api';
if (filePath.startsWith('/')) {
return `${SERVER_BASE}${filePath}`;
}
return `${SERVER_BASE}/uploads/${filePath}`;
},
};

View File

@ -62,6 +62,8 @@ export interface CourseResponse {
environmentConstruction?: string;
/** 主题 ID */
themeId?: number;
/** 主题名称 */
themeName?: string;
/** 绘本名称 */
pictureBookName?: string;
/** 封面图片路径 */

View File

@ -105,8 +105,8 @@ export function createLesson(courseId: number, data: CreateLessonData) {
}
// 更新课程
export function updateLesson(lessonId: number, data: Partial<CreateLessonData>) {
return http.put(`/v1/admin/courses/0/lessons/${lessonId}`, data);
export function updateLesson(courseId: number, lessonId: number, data: Partial<CreateLessonData>) {
return http.put(`/v1/admin/courses/${courseId}/lessons/${lessonId}`, data);
}
// 删除课程
@ -132,8 +132,8 @@ export function createStep(courseId: number, lessonId: number, data: CreateStepD
}
// 更新环节
export function updateStep(stepId: number, data: Partial<CreateStepData>) {
return http.put(`/v1/admin/courses/0/lessons/steps/${stepId}`, data);
export function updateStep(courseId: number, stepId: number, data: Partial<CreateStepData>) {
return http.put(`/v1/admin/courses/${courseId}/lessons/steps/${stepId}`, data);
}
// 删除环节

View File

@ -126,7 +126,7 @@ import { ref, reactive, watch, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { getThemeList } from '@/api/theme';
import { uploadFile } from '@/api/file';
import { uploadFile, getFileUrl } from '@/api/file';
import type { Theme } from '@/api/theme';
interface BasicInfoData {
@ -206,19 +206,16 @@ watch(
if (newVal) {
Object.assign(formData, newVal);
//
if (newVal.coverImagePath && coverImages.value.length === 0) {
// URL
let imageUrl = newVal.coverImagePath;
if (!imageUrl.startsWith('http') && !imageUrl.startsWith('/uploads') && !imageUrl.includes('/uploads/')) {
imageUrl = `/uploads/${imageUrl}`;
}
//
if (newVal.coverImagePath) {
coverImages.value = [{
uid: '-1',
name: 'cover',
status: 'done',
url: imageUrl,
url: getFileUrl(newVal.coverImagePath),
}];
} else {
coverImages.value = [];
}
}
},
@ -255,16 +252,11 @@ const beforeCoverUpload = async (file: any) => {
try {
const result = await uploadFile(file, 'cover');
formData.coverImagePath = result.filePath;
// URL - filePath
let imageUrl = result.filePath;
if (!imageUrl.startsWith('http') && !imageUrl.startsWith('/uploads') && !imageUrl.includes('/uploads/')) {
imageUrl = `/uploads/${imageUrl}`;
}
coverImages.value = [{
uid: file.uid,
name: file.name,
status: 'done',
url: imageUrl,
url: getFileUrl(result.filePath),
}];
handleChange();
message.success('封面上传成功');

View File

@ -133,17 +133,20 @@ const tableData = ref<ScheduleRow[]>([]);
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
try {
const parsed = JSON.parse(newVal);
tableData.value = parsed.map((row: any, index: number) => ({
...row,
key: row.key || `row_${index}`,
}));
} catch (e) {
console.error('解析排课数据失败', e);
tableData.value = [];
}
if (!newVal || typeof newVal !== 'string') {
tableData.value = [];
return;
}
try {
const parsed = JSON.parse(newVal);
const rows = Array.isArray(parsed) ? parsed : [];
tableData.value = rows.map((row: any, index: number) => ({
...row,
key: row.key || `row_${index}`,
}));
} catch (e) {
console.error('解析排课数据失败', e);
tableData.value = [];
}
},
{ immediate: true }

View File

@ -60,6 +60,7 @@ import { PlusOutlined } from '@ant-design/icons-vue';
import LessonConfigPanel from '@/components/course/LessonConfigPanel.vue';
import type { LessonData } from '@/components/course/LessonConfigPanel.vue';
import { getLessonByType, createLesson, updateLesson, deleteLesson as deleteLessonApi } from '@/api/lesson';
import { parseAssessmentDataForDisplay } from '@/utils/assessmentData';
interface Props {
courseId: number;
@ -100,7 +101,7 @@ const fetchLesson = async () => {
preparation: lesson.preparation || '',
extension: lesson.extension || '',
reflection: lesson.reflection || '',
assessmentData: lesson.assessmentData || '',
assessmentData: parseAssessmentDataForDisplay(lesson.assessmentData),
useTemplate: lesson.useTemplate || false,
steps: lesson.steps || [],
isNew: false,
@ -159,10 +160,10 @@ const handleLessonChange = () => {
emit('change');
};
// formRules
//
const validate = async () => {
if (!lessonData.value) {
return { valid: true, errors: [] as string[], warnings: ['未配置导入课'] };
return { valid: false, errors: ['请配置导入课(至少一条)'] };
}
return configPanelRef.value?.validate() ?? { valid: true, errors: [] as string[] };
};

View File

@ -60,6 +60,7 @@ import { PlusOutlined } from '@ant-design/icons-vue';
import LessonConfigPanel from '@/components/course/LessonConfigPanel.vue';
import type { LessonData } from '@/components/course/LessonConfigPanel.vue';
import { getLessonByType, createLesson as createLessonApi, updateLesson, deleteLesson as deleteLessonApi } from '@/api/lesson';
import { parseAssessmentDataForDisplay } from '@/utils/assessmentData';
interface Props {
courseId: number;
@ -101,7 +102,7 @@ const fetchLesson = async () => {
preparation: lesson.preparation || '',
extension: lesson.extension || '',
reflection: lesson.reflection || '',
assessmentData: lesson.assessmentData || '',
assessmentData: parseAssessmentDataForDisplay(lesson.assessmentData),
useTemplate: lesson.useTemplate || false,
steps: lesson.steps || [],
isNew: false,
@ -160,10 +161,10 @@ const handleLessonChange = () => {
emit('change');
};
// formRules
//
const validate = async () => {
if (!lessonData.value) {
return { valid: true, errors: [] as string[], warnings: ['未配置集体课'] };
return { valid: false, errors: ['请配置集体课(至少一条)'] };
}
return configPanelRef.value?.validate() ?? { valid: true, errors: [] as string[] };
};

View File

@ -93,6 +93,7 @@ import {
import LessonConfigPanel from '@/components/course/LessonConfigPanel.vue';
import type { LessonData } from '@/components/course/LessonConfigPanel.vue';
import { getLessonList, createLesson, updateLesson, deleteLesson } from '@/api/lesson';
import { parseAssessmentDataForDisplay } from '@/utils/assessmentData';
interface DomainConfig {
type: string;
@ -204,7 +205,7 @@ const fetchLessons = async () => {
preparation: lesson.preparation || '',
extension: lesson.extension || '',
reflection: lesson.reflection || '',
assessmentData: lesson.assessmentData || '',
assessmentData: parseAssessmentDataForDisplay(lesson.assessmentData),
useTemplate: lesson.useTemplate || false,
steps: lesson.steps || [],
isNew: false,
@ -266,8 +267,13 @@ const handleLessonChange = () => {
emit('change');
};
// formRules
// formRules
const validate = async () => {
const saveData = getSaveData();
if (!saveData || saveData.length === 0) {
return { valid: false, errors: ['请配置领域课(至少一条)'] };
}
const enabledDomains = domains.filter((d) => d.enabled);
const allErrors: string[] = [];

View File

@ -0,0 +1,19 @@
/**
* assessmentData
* JSON "核心内容"
*/
export function parseAssessmentDataForDisplay(value: string | null | undefined): string {
if (value == null || value === '') return '';
const trimmed = value.trim();
if (!trimmed) return '';
// 若是 JSON 字符串格式(如 "核心内容"),解析后返回明文
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
try {
const parsed = JSON.parse(trimmed);
return typeof parsed === 'string' ? parsed : trimmed;
} catch {
return trimmed;
}
}
return trimmed;
}

View File

@ -208,14 +208,7 @@ const getStatusText = (status: string) => {
return collectionsApi.getCollectionStatusInfo(status).label;
};
const getDiscountTypeText = (type?: string) => {
if (!type) return '-';
const typeMap: Record<string, string> = {
PERCENTAGE: '折扣',
FIXED: '立减',
};
return typeMap[type] || type;
};
const getDiscountTypeText = collectionsApi.getDiscountTypeText;
//
const handleDelete = async () => {

View File

@ -100,6 +100,7 @@
v-model:open="showPackageSelector"
title="选择课程包"
width="800px"
:confirm-loading="addingPackages"
@ok="handleAddPackages"
>
<a-table
@ -109,6 +110,18 @@
row-key="id"
size="small"
:loading="loadingPackages"
:pagination="{
current: selectorPagination.current,
pageSize: selectorPagination.pageSize,
total: selectorPagination.total,
showSizeChanger: true,
showTotal: (t: number) => `${t}`,
onChange: (page: number, size: number) => {
selectorPagination.current = page;
selectorPagination.pageSize = size;
fetchAvailablePackages();
},
}"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'gradeTags'">
@ -121,13 +134,12 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { ref, reactive, computed, watch, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { getCollectionDetail, createCollection, updateCollection, setCollectionPackages } from '@/api/package';
import { getCoursePackageList } from '@/api/package';
import { getCollectionDetail, createCollection, updateCollection, setCollectionPackages, getCoursePackageList, getCoursePackage } from '@/api/package';
const router = useRouter();
const route = useRoute();
@ -141,6 +153,8 @@ const loadingPackages = ref(false);
const showPackageSelector = ref(false);
const availablePackages = ref<any[]>([]);
const selectedRowKeys = ref<(number | string)[]>([]);
const selectorPagination = reactive({ current: 1, pageSize: 10, total: 0 });
const addingPackages = ref(false);
const formState = ref({
name: '',
@ -268,9 +282,13 @@ const fetchCollectionDetail = async () => {
const fetchAvailablePackages = async () => {
loadingPackages.value = true;
try {
//
const res = await getCoursePackageList({ pageNum: 1, pageSize: 100, status: 'PUBLISHED' });
const res = await getCoursePackageList({
pageNum: selectorPagination.current,
pageSize: selectorPagination.pageSize,
status: 'PUBLISHED',
});
availablePackages.value = res.list || [];
selectorPagination.total = res.total || 0;
} catch (error) {
console.error('获取课程包列表失败', error);
} finally {
@ -278,18 +296,50 @@ const fetchAvailablePackages = async () => {
}
};
const handleAddPackages = () => {
const existingIds = new Set(selectedPackages.value.map((p) => p.packageId));
const newPackages = availablePackages.value
.filter((p) => selectedRowKeys.value.includes(p.id) && !existingIds.has(p.id))
.map((p) => ({
packageId: p.id,
packageName: p.name,
gradeLevel: parseGradeTags(p.gradeTags)?.[0] || '小班',
sortOrder: selectedPackages.value.length,
}));
//
watch(showPackageSelector, (visible) => {
if (visible) {
selectedRowKeys.value = selectedPackages.value.map((p) => p.packageId);
selectorPagination.current = 1;
fetchAvailablePackages();
}
});
selectedPackages.value.push(...newPackages);
const handleAddPackages = async () => {
const existingIds = new Set(selectedPackages.value.map((p) => p.packageId));
const idsToAdd = selectedRowKeys.value.filter((id) => !existingIds.has(id));
if (idsToAdd.length === 0) {
showPackageSelector.value = false;
return;
}
addingPackages.value = true;
try {
const pkgMap = new Map(availablePackages.value.map((p) => [p.id, p]));
const newPackages: { packageId: number | string; packageName: string; gradeLevel: string; sortOrder: number }[] = [];
let sortOrder = selectedPackages.value.length;
for (const id of idsToAdd) {
let pkg = pkgMap.get(id);
if (!pkg) {
try {
pkg = await getCoursePackage(id);
} catch {
continue;
}
}
newPackages.push({
packageId: pkg.id,
packageName: pkg.name,
gradeLevel: parseGradeTags(pkg.gradeTags)?.[0] || '小班',
sortOrder: sortOrder++,
});
}
selectedPackages.value.push(...newPackages);
} finally {
addingPackages.value = false;
}
selectedRowKeys.value = [];
showPackageSelector.value = false;
};
@ -374,7 +424,6 @@ const handleCancel = () => {
onMounted(() => {
fetchCollectionDetail();
fetchAvailablePackages();
});
</script>

View File

@ -148,15 +148,7 @@ const columns = [
const getStatusColor = (status: string) => collectionsApi.getCollectionStatusInfo(status).color;
const getStatusText = (status: string) => collectionsApi.getCollectionStatusInfo(status).label;
const formatPrice = (price: number | null | undefined) => collectionsApi.formatPrice(price);
const getDiscountTypeText = (type?: string) => {
if (!type) return '-';
const typeMap: Record<string, string> = {
PERCENTAGE: '折扣',
FIXED: '立减',
};
return typeMap[type] || type;
};
const getDiscountTypeText = collectionsApi.getDiscountTypeText;
const parseGradeLevels = (gradeLevels: string | string[]) => {
return collectionsApi.parseGradeLevels(gradeLevels);

View File

@ -1,36 +1,27 @@
<template>
<div class="course-detail-view">
<!-- 顶部导航 -->
<div class="detail-header">
<div class="header-left">
<a-button type="text" @click="router.back()">
<ArrowLeftOutlined />
</a-button>
<div class="course-title">
<h2>{{ course.name || '课程包详情' }}</h2>
<a-tag :style="getStatusStyle(course.status)" style="margin-left: 12px;">
{{ translateStatus(course.status) }}
</a-tag>
</div>
</div>
<div class="header-actions">
<a-button v-if="course.status !== 'PUBLISHED'" @click="editCourse">
<EditOutlined /> 编辑
</a-button>
<a-button @click="viewStats">
<BarChartOutlined /> 数据
</a-button>
<a-popconfirm
v-if="course.status === 'DRAFT' || course.status === 'ARCHIVED'"
title="确定删除此课程包吗?"
@confirm="deleteCourse"
>
<a-button danger>
<DeleteOutlined /> 删除
<a-page-header title="课程包详情" :sub-title="course.name || ''" @back="() => router.back()">
<template #extra>
<div class="header-actions">
<a-button v-if="canEdit" @click="editCourse">
<EditOutlined /> 编辑
</a-button>
</a-popconfirm>
</div>
</div>
<a-button @click="viewStats">
<BarChartOutlined /> 数据
</a-button>
<a-popconfirm v-if="course.status === 'DRAFT' || course.status === 'ARCHIVED'" title="确定删除此课程包吗?"
@confirm="deleteCourse">
<a-button danger>
<DeleteOutlined /> 删除
</a-button>
</a-popconfirm>
</div>
</template>
<template #tags>
<a-tag :style="getStatusStyle(course.status)">{{ translateStatus(course.status) }}</a-tag>
</template>
</a-page-header>
<a-spin :spinning="loading">
<div class="detail-content">
@ -223,12 +214,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 +241,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 +311,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 +326,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 +341,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 +358,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 +374,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>
@ -543,12 +499,18 @@ const domainTags = computed(() => {
}
});
// 稿//
const canEdit = computed(() => {
const s = course.value.status;
return s === 'DRAFT' || s === 'REJECTED' || s === 'ARCHIVED';
});
//
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;
});
//
@ -587,7 +549,7 @@ const allVideos = computed(() => {
paths.forEach((item: any) => {
videos.push({ path: item.path, name: item.name || '视频', source: '资源库' });
});
} catch {}
} catch { }
}
return videos;
});
@ -601,7 +563,7 @@ const allAudios = computed(() => {
paths.forEach((item: any) => {
audios.push({ path: item.path, name: item.name || '音频', source: '资源库' });
});
} catch {}
} catch { }
}
return audios;
});
@ -628,7 +590,7 @@ const allDocuments = computed(() => {
paths.forEach((item: any) => {
docs.push({ path: item.path, name: item.name || '电子绘本', type: 'pdf', source: '资源库' });
});
} catch {}
} catch { }
}
return docs;
});
@ -642,16 +604,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(() => {
@ -823,38 +785,9 @@ const fetchCourseDetail = async () => {
background: #f0f2f5;
}
.detail-header {
.header-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: white;
border-bottom: 1px solid #f0f0f0;
position: sticky;
top: 0;
z-index: 100;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.course-title {
display: flex;
align-items: center;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
}
.header-actions {
display: flex;
gap: 8px;
}
gap: 8px;
}
.detail-content {
@ -1124,9 +1057,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

@ -1,21 +1,13 @@
<template>
<div class="course-edit-view">
<div class="sticky-header">
<a-page-header
:title="isEdit ? '编辑课程包' : '创建课程包'"
@back="() => router.back()"
>
<a-page-header :title="isEdit ? '编辑课程包' : '创建课程包'" @back="() => router.back()">
<template #extra>
<a-space>
<a-button @click="handleSaveDraft" :loading="saving">保存草稿</a-button>
<a-button v-if="currentStep > 0" @click="prevStep">上一步</a-button>
<a-button v-if="currentStep < 6" type="primary" @click="nextStep">下一步</a-button>
<a-button
v-if="currentStep === 6"
type="primary"
:loading="saving"
@click="handleSave"
>
<a-button v-if="currentStep === 6" type="primary" :loading="saving" @click="() => handleSave(false)">
{{ isEdit ? '保存' : '创建' }}
</a-button>
<a-button v-else-if="isEdit" type="primary" @click="handleSaveAndSubmit" :loading="saving">
@ -48,62 +40,32 @@
<!-- 步骤内容 -->
<div class="step-content">
<!-- 步骤1基本信息 -->
<Step1BasicInfo
v-show="currentStep === 0"
ref="step1Ref"
v-model="formData.basic"
@change="handleDataChange"
/>
<Step1BasicInfo v-show="currentStep === 0" ref="step1Ref" v-model="formData.basic"
@change="handleDataChange" />
<!-- 步骤2课程介绍 -->
<Step2CourseIntro
v-show="currentStep === 1"
ref="step2Ref"
v-model="formData.intro"
@change="handleDataChange"
/>
<Step2CourseIntro v-show="currentStep === 1" ref="step2Ref" v-model="formData.intro"
@change="handleDataChange" />
<!-- 步骤3排课参考 -->
<Step3ScheduleRef
v-show="currentStep === 2"
ref="step3Ref"
v-model="formData.scheduleRefData"
@change="handleDataChange"
/>
<Step3ScheduleRef v-show="currentStep === 2" ref="step3Ref" v-model="formData.scheduleRefData"
@change="handleDataChange" />
<!-- 步骤4导入课 -->
<Step4IntroLesson
v-show="currentStep === 3"
ref="step4Ref"
:course-id="courseId"
@change="handleDataChange"
/>
<Step4IntroLesson v-show="currentStep === 3" ref="step4Ref" :course-id="courseId"
@change="handleDataChange" />
<!-- 步骤5集体课 -->
<Step5CollectiveLesson
v-show="currentStep === 4"
ref="step5Ref"
:course-id="courseId"
:course-name="formData.basic.name"
@change="handleDataChange"
/>
<Step5CollectiveLesson v-show="currentStep === 4" ref="step5Ref" :course-id="courseId"
:course-name="formData.basic.name" @change="handleDataChange" />
<!-- 步骤6领域课 -->
<Step6DomainLessons
v-show="currentStep === 5"
ref="step6Ref"
:course-id="courseId"
:course-name="formData.basic.name"
@change="handleDataChange"
/>
<Step6DomainLessons v-show="currentStep === 5" ref="step6Ref" :course-id="courseId"
:course-name="formData.basic.name" @change="handleDataChange" />
<!-- 步骤7环创建设 -->
<Step7Environment
v-show="currentStep === 6"
ref="step7Ref"
v-model="formData.environmentConstruction"
@change="handleDataChange"
/>
<Step7Environment v-show="currentStep === 6" ref="step7Ref" v-model="formData.environmentConstruction"
@change="handleDataChange" />
</div>
</a-card>
</a-spin>
@ -224,6 +186,12 @@ const fetchCourseDetail = async () => {
router.push(`/admin/packages/${courseId.value}`);
return;
}
//
if (course?.status === 'PENDING') {
message.warning('审核中的课程包不允许编辑,请等待审核完成或先撤销审核');
router.push(`/admin/packages/${courseId.value}`);
return;
}
//
formData.basic.name = course.name;
@ -231,8 +199,8 @@ const fetchCourseDetail = async () => {
formData.basic.grades = Array.isArray(course.gradeTags) ? course.gradeTags : (course.gradeTags ? JSON.parse(course.gradeTags) : []);
formData.basic.pictureBookName = course.pictureBookName || '';
formData.basic.coreContent = course.coreContent || '';
formData.basic.duration = course.duration || 25;
formData.basic.domainTags = course.domainTags ? JSON.parse(course.domainTags) : [];
formData.basic.duration = course.durationMinutes ?? course.duration ?? 25;
formData.basic.domainTags = Array.isArray(course.domainTags) ? course.domainTags : (course.domainTags ? JSON.parse(course.domainTags || '[]') : []);
formData.basic.coverImagePath = course.coverImagePath || '';
//
@ -280,18 +248,26 @@ const nextStep = async () => {
}
};
//
const validateAtLeastOneLesson = (): boolean => {
//
const validateAllThreeLessons = (): boolean => {
const hasIntro = !!step4Ref.value?.lessonData;
const hasCollective = !!step5Ref.value?.lessonData;
const domainData = step6Ref.value?.getSaveData?.() || [];
const hasDomain = Array.isArray(domainData) && domainData.length > 0;
if (hasIntro || hasCollective || hasDomain) {
return true;
if (!hasIntro) {
message.warning('请配置导入课(至少一节)');
return false;
}
message.warning('请至少配置一种课程:导入课、集体课或领域课(至少完成一个领域)');
return false;
if (!hasCollective) {
message.warning('请配置集体课(至少一节)');
return false;
}
if (!hasDomain) {
message.warning('请配置领域课(至少一节)');
return false;
}
return true;
};
// 7
@ -308,9 +284,9 @@ const validateCurrentStep = async (): Promise<boolean> => {
];
const ref = stepRefs[step]?.value;
if (!ref?.validate) {
// 5 6
// 5 6
if (step === 5 || step === 6) {
return validateAtLeastOneLesson();
return validateAllThreeLessons();
}
return true;
}
@ -321,9 +297,9 @@ const validateCurrentStep = async (): Promise<boolean> => {
return false;
}
// 5 6
// 5 6
if (step === 5 || step === 6) {
return validateAtLeastOneLesson();
return validateAllThreeLessons();
}
return true;
};
@ -384,13 +360,7 @@ const handleSave = async (isDraft = false) => {
console.log('Course updated successfully');
} else {
const res = await createCourse(courseData) as any;
console.log('🔍 创建课程返回结果:', JSON.stringify(res, null, 2));
savedCourseId = res?.id || res?.data?.id; // data.data
console.log('Course created with ID:', savedCourseId);
//
if (savedCourseId) {
router.replace(`/admin/packages/${savedCourseId}/edit`);
}
savedCourseId = res?.id ?? res?.data?.id; // data.data
}
if (!savedCourseId) {
@ -433,18 +403,10 @@ const handleSave = async (isDraft = false) => {
//
}
console.log('✅ 所有课程数据保存完成,准备显示成功提示...');
message.success(isDraft ? '草稿保存成功' : (isEdit.value ? '保存成功' : '创建成功'));
console.log('✅ 成功提示已显示,准备跳转...');
if (!isDraft) {
console.log('🚀 准备跳转到课程列表页面...');
console.log('🚀 isDraft =', isDraft, ', isEdit =', isEdit.value);
//
await new Promise(resolve => setTimeout(resolve, 500));
console.log('🚀 即将执行 router.push 跳转...');
await router.push('/admin/packages');
console.log('✅ 已执行 router.push 跳转');
await router.replace('/admin/packages');
}
} catch (error: any) {
console.error('Save failed:', error);
@ -457,7 +419,8 @@ const handleSave = async (isDraft = false) => {
};
//
const saveLesson = async (courseId: number, lessonData: any, lessonType: string) => {
const saveLesson = async (courseId: number | string, lessonData: any, lessonType: string) => {
const cid = Number(courseId);
if (!lessonData) {
console.log('No lesson data to save for type:', lessonType);
return;
@ -486,15 +449,10 @@ const saveLesson = async (courseId: number, lessonData: any, lessonType: string)
try {
if (lessonData.isNew || !lessonData.id) {
//
console.log('Creating new lesson:', lessonType);
const res = await createLesson(courseId, lessonPayload) as any;
const res = await createLesson(cid, lessonPayload) as any;
lessonId = res.data?.id || res.id;
console.log('Lesson created with ID:', lessonId);
} else {
//
console.log('Updating lesson:', lessonId);
await updateLesson(lessonData.id, lessonPayload);
await updateLesson(cid, lessonData.id, lessonPayload);
}
//
@ -509,9 +467,9 @@ const saveLesson = async (courseId: number, lessonData: any, lessonType: string)
try {
if (step.isNew || !step.id) {
await createStep(courseId, lessonId, stepPayload);
await createStep(cid, lessonId, stepPayload);
} else {
await updateStep(step.id, stepPayload);
await updateStep(cid, step.id, stepPayload);
}
} catch (stepError: any) {
console.error('Failed to save step:', stepError);

View File

@ -53,6 +53,10 @@
</div>
</template>
<template v-else-if="column.key === 'theme'">
<span>{{ record.themeName || record.theme?.name || '-' }}</span>
</template>
<template v-else-if="column.key === 'pictureBook'">
{{ record.pictureBookName }}
</template>
@ -144,11 +148,11 @@
</a-dropdown>
</template>
<!-- 已下架状态 -->
<!-- 已下架状态需重新提交审核后才能发布 -->
<template v-else-if="record.status === 'ARCHIVED'">
<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-button type="primary" size="small" @click="submitForReview(record)">提交审核</a-button>
<a-popconfirm title="确定删除此课程包吗?删除后无法恢复" @confirm="deleteCourseHandler(record.id)">
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
@ -239,6 +243,7 @@ const pagination = reactive({
const columns = [
{ title: '课程包名称', key: 'name', width: 250 },
{ title: '课程主题', key: 'theme', width: 120 },
{ title: '关联绘本', key: 'pictureBook', width: 120 },
{ title: '课程配置', key: 'lessonConfig', width: 200 },
{ title: '状态', key: 'status', width: 90 },
@ -489,23 +494,6 @@ const unpublishCourse = async (id: number) => {
});
};
//
const republishCourse = async (id: number) => {
Modal.confirm({
title: '确认重新发布',
content: '重新发布后所有活跃租户将可以查看并使用此课程包,确认继续?',
onOk: async () => {
try {
await courseApi.republishCourse(id);
message.success('重新发布成功');
fetchCourses();
} catch (error) {
message.error('重新发布失败');
}
},
});
};
const iterateCourse = (id: number) => {
router.push(`/admin/packages/${id}/iterate`);
};

View File

@ -84,7 +84,7 @@
</template>
<a-spin :spinning="loadingDetail">
<div v-if="currentCourse" class="review-content">
<a-form v-if="currentCourse" ref="reviewFormRef" :model="formState" layout="vertical" class="review-content">
<!-- 自动检查项 -->
<a-alert v-if="validationResult" :type="validationResult.valid ? 'success' : 'warning'"
:message="validationResult.valid ? '自动检查通过' : '自动检查有警告'" style="margin-bottom: 16px;">
@ -119,33 +119,37 @@
</a-descriptions>
<!-- 人工审核检查项 -->
<a-card title="人工审核项" size="small" style="margin-bottom: 16px;">
<a-checkbox-group v-model:value="reviewChecklist" style="width: 100%;">
<a-row>
<a-col :span="24" style="margin-bottom: 8px;">
<a-checkbox value="teaching">教学科学性符合要求</a-checkbox>
</a-col>
<a-col :span="24" style="margin-bottom: 8px;">
<a-checkbox value="quality">素材质量达标</a-checkbox>
</a-col>
<a-col :span="24" style="margin-bottom: 8px;">
<a-checkbox value="tags">标签分类准确</a-checkbox>
</a-col>
<a-col :span="24" style="margin-bottom: 8px;">
<a-checkbox value="copyright">版权合规</a-checkbox>
</a-col>
</a-row>
</a-checkbox-group>
</a-card>
<a-form-item name="reviewChecklist" :rules="formRules.reviewChecklist" style="margin-bottom: 16px;">
<template #label>人工审核项
</template>
<a-card size="small">
<a-checkbox-group v-model:value="formState.reviewChecklist" style="width: 100%;">
<a-row>
<a-col :span="24" style="margin-bottom: 8px;">
<a-checkbox value="teaching">教学科学性符合要求</a-checkbox>
</a-col>
<a-col :span="24" style="margin-bottom: 8px;">
<a-checkbox value="quality">素材质量达标</a-checkbox>
</a-col>
<a-col :span="24" style="margin-bottom: 8px;">
<a-checkbox value="tags">标签分类准确</a-checkbox>
</a-col>
<a-col :span="24" style="margin-bottom: 8px;">
<a-checkbox value="copyright">版权合规</a-checkbox>
</a-col>
</a-row>
</a-checkbox-group>
</a-card>
</a-form-item>
<!-- 审核意见 -->
<a-form layout="vertical">
<a-form-item label="审核意见" required>
<a-textarea v-model:value="reviewComment" placeholder="请输入审核意见(驳回时必填,通过时可选)"
:auto-size="{ minRows: 3, maxRows: 6 }" />
</a-form-item>
</a-form>
</div>
<a-form-item name="reviewComment" :rules="formRules.reviewComment">
<template #label> 审核意见
</template>
<a-textarea v-model:value="formState.reviewComment" placeholder="请输入审核意见(驳回时必填,通过时可选)"
:auto-size="{ minRows: 3, maxRows: 6 }" />
</a-form-item>
</a-form>
</a-spin>
</a-modal>
@ -159,6 +163,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue';
import { ReloadOutlined, CheckOutlined, WarningOutlined } from '@ant-design/icons-vue';
import * as courseApi from '@/api/course';
import {
@ -207,8 +212,21 @@ const reviewModalVisible = ref(false);
const reviewing = ref(false);
const currentCourse = ref<any>(null);
const validationResult = ref<courseApi.ValidationResult | null>(null);
const reviewChecklist = ref<string[]>([]);
const reviewComment = ref('');
const reviewFormRef = ref<FormInstance>();
const formState = reactive({
reviewChecklist: [] as string[],
reviewComment: '',
});
//
const formRules: Record<string, object[]> = {
reviewChecklist: [
{ required: true, type: 'array', min: 4, message: '请完成所有审核检查项' },
],
reviewComment: [
{ required: true, message: '请填写驳回原因' },
],
};
//
const rejectReasonVisible = ref(false);
@ -249,8 +267,8 @@ const handleTableChange = (pag: any) => {
const showReviewModal = async (record: any) => {
currentCourse.value = record;
reviewChecklist.value = [];
reviewComment.value = '';
formState.reviewChecklist = [];
formState.reviewComment = '';
validationResult.value = null;
reviewModalVisible.value = true;
@ -272,16 +290,17 @@ const closeReviewModal = () => {
};
const approveCourse = async () => {
if (reviewChecklist.value.length < 4) {
message.warning('请完成所有审核检查项');
try {
await reviewFormRef.value?.validateFields(['reviewChecklist']);
} catch {
return;
}
reviewing.value = true;
try {
await courseApi.approveCourse(currentCourse.value.id, {
checklist: reviewChecklist.value,
comment: reviewComment.value || '审核通过',
checklist: formState.reviewChecklist,
comment: formState.reviewComment || '审核通过',
});
message.success('审核通过,课程已发布');
closeReviewModal();
@ -294,16 +313,16 @@ const approveCourse = async () => {
};
const rejectCourse = async () => {
if (!reviewComment.value.trim()) {
message.warning('请填写驳回原因');
try {
await reviewFormRef.value?.validateFields(['reviewComment']);
} catch {
return;
}
reviewing.value = true;
try {
await courseApi.rejectCourse(currentCourse.value.id, {
checklist: reviewChecklist.value,
comment: reviewComment.value,
comment: formState.reviewComment,
});
message.success('已驳回');
closeReviewModal();
@ -365,5 +384,9 @@ const formatDate = (date: string | Date) => {
max-height: 60vh;
overflow-y: auto;
}
.required-asterisk {
color: #ff4d4f;
}
}
</style>

View File

@ -94,6 +94,7 @@ import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import { getCollectionDetail, submitCollection, publishCollection, archiveCollection } from '@/api/package';
import type { CourseCollection } from '@/api/package';
import * as collectionsApi from '@/api/collections';
const router = useRouter();
const route = useRoute();
@ -128,15 +129,7 @@ const statusTexts: Record<string, string> = {
const getStatusColor = (status: string) => statusColors[status] || 'default';
const getStatusText = (status: string) => statusTexts[status] || status;
const discountTypeTexts: Record<string, string> = {
PERCENTAGE: '折扣',
FIXED: '立减',
};
const getDiscountTypeText = (type?: string) => {
if (!type) return '-';
return discountTypeTexts[type] || type;
};
const getDiscountTypeText = collectionsApi.getDiscountTypeText;
const formatDate = (date?: string) => {
if (!date) return '-';

View File

@ -103,6 +103,7 @@
v-model:open="showPackageSelector"
title="选择课程包"
width="800px"
:confirm-loading="addingPackages"
@ok="handleAddPackages"
>
<a-table
@ -112,6 +113,18 @@
row-key="id"
size="small"
:loading="loadingPackages"
:pagination="{
current: selectorPagination.current,
pageSize: selectorPagination.pageSize,
total: selectorPagination.total,
showSizeChanger: true,
showTotal: (t: number) => `${t}`,
onChange: (page: number, size: number) => {
selectorPagination.current = page;
selectorPagination.pageSize = size;
fetchAvailablePackages();
},
}"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'gradeTags'">
@ -129,8 +142,7 @@ import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { getCollectionDetail, createCollection, updateCollection, setCollectionPackages } from '@/api/package';
import { getCoursePackageList } from '@/api/package';
import { getCollectionDetail, createCollection, updateCollection, setCollectionPackages, getCoursePackageList, getCoursePackage } from '@/api/package';
const router = useRouter();
const route = useRoute();
@ -144,6 +156,8 @@ const loadingPackages = ref(false);
const showPackageSelector = ref(false);
const availablePackages = ref<any[]>([]);
const selectedRowKeys = ref<(number | string)[]>([]);
const selectorPagination = reactive({ current: 1, pageSize: 10, total: 0 });
const addingPackages = ref(false);
const form = reactive({
name: '',
@ -280,9 +294,13 @@ const fetchPackageDetail = async () => {
const fetchAvailablePackages = async () => {
loadingPackages.value = true;
try {
//
const res = await getCoursePackageList({ pageNum: 1, pageSize: 100, status: 'PUBLISHED' });
const res = await getCoursePackageList({
pageNum: selectorPagination.current,
pageSize: selectorPagination.pageSize,
status: 'PUBLISHED',
});
availablePackages.value = res.list || [];
selectorPagination.total = res.total || 0;
} catch (error) {
console.error('获取课程包列表失败', error);
} finally {
@ -290,21 +308,51 @@ const fetchAvailablePackages = async () => {
}
};
const handleAddPackages = () => {
const existingIds = new Set(selectedPackages.value.map((p) => p.packageId));
const newPackages = availablePackages.value
.filter((p) => selectedRowKeys.value.includes(p.id) && !existingIds.has(p.id))
.map((p) => {
const tags = parseGradeTags(p.gradeTags);
return {
packageId: p.id,
packageName: p.name,
gradeLevels: Array.isArray(tags) && tags.length > 0 ? tags : ['小班'],
sortOrder: selectedPackages.value.length,
};
});
//
watch(showPackageSelector, (visible) => {
if (visible) {
selectedRowKeys.value = selectedPackages.value.map((p) => p.packageId);
selectorPagination.current = 1;
fetchAvailablePackages();
}
});
selectedPackages.value.push(...newPackages);
const handleAddPackages = async () => {
const existingIds = new Set(selectedPackages.value.map((p) => p.packageId));
const idsToAdd = selectedRowKeys.value.filter((id) => !existingIds.has(id));
if (idsToAdd.length === 0) {
showPackageSelector.value = false;
return;
}
addingPackages.value = true;
try {
const pkgMap = new Map(availablePackages.value.map((p) => [p.id, p]));
const newPackages: { packageId: number | string; packageName: string; gradeLevels: string[]; sortOrder: number }[] = [];
let sortOrder = selectedPackages.value.length;
for (const id of idsToAdd) {
let pkg = pkgMap.get(id);
if (!pkg) {
try {
pkg = await getCoursePackage(id);
} catch {
continue;
}
}
const tags = parseGradeTags(pkg.gradeTags);
newPackages.push({
packageId: pkg.id,
packageName: pkg.name,
gradeLevels: Array.isArray(tags) && tags.length > 0 ? tags : ['小班'],
sortOrder: sortOrder++,
});
}
selectedPackages.value.push(...newPackages);
} finally {
addingPackages.value = false;
}
selectedRowKeys.value = [];
showPackageSelector.value = false;
};
@ -384,7 +432,6 @@ const handleSave = async () => {
onMounted(() => {
fetchPackageDetail();
fetchAvailablePackages();
});
</script>

View File

@ -143,6 +143,7 @@ import { message } from 'ant-design-vue';
import { PlusOutlined, AuditOutlined } from '@ant-design/icons-vue';
import { getCollectionList, deleteCollection, submitCollection, publishCollection, archiveCollection } from '@/api/package';
import type { CourseCollection } from '@/api/package';
import * as collectionsApi from '@/api/collections';
const router = useRouter();
@ -192,14 +193,7 @@ const statusTexts: Record<string, string> = {
const getStatusColor = (status: string) => statusColors[status] || 'default';
const getStatusText = (status: string) => statusTexts[status] || status;
const getDiscountTypeText = (type?: string) => {
if (!type) return '-';
const typeMap: Record<string, string> = {
PERCENTAGE: '折扣',
FIXED: '立减',
};
return typeMap[type] || type;
};
const getDiscountTypeText = collectionsApi.getDiscountTypeText;
const parseGradeLevels = (gradeLevels: string | string[] | undefined): string[] => {
if (!gradeLevels) return [];

View File

@ -151,7 +151,7 @@
:key="pkg.id"
:value="pkg.id"
>
{{ pkg.name }} ({{ formatPackagePrice(pkg.discountPrice || pkg.price) }})
{{ pkg.name }} ({{ formatPackagePrice(pkg.discountPrice || pkg.price) }}{{ pkg.discountType ? ' ' + getDiscountTypeText(pkg.discountType) : '' }})
</a-select-option>
</a-select>
</a-form-item>
@ -185,7 +185,7 @@
:key="pkg.id"
:value="pkg.id"
>
{{ pkg.name }} ({{ formatPackagePrice(pkg.discountPrice || pkg.price) }})
{{ pkg.name }} ({{ formatPackagePrice(pkg.discountPrice || pkg.price) }}{{ pkg.discountType ? ' ' + getDiscountTypeText(pkg.discountType) : '' }})
</a-select-option>
</a-select>
</a-form-item>
@ -308,7 +308,7 @@
</a-drawer>
<!-- 重置密码确认模态框 -->
<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" okText="重置">
<template #title>
<span class="modal-title">
<KeyOutlined class="modal-title-icon" />
@ -360,6 +360,7 @@ import {
type UpdateTenantDto,
type CourseCollectionResponse,
} from '@/api/admin';
import { getDiscountTypeText } from '@/api/collections';
//
const searchForm = reactive({
@ -871,6 +872,15 @@ onMounted(() => {
color: white;
}
.password-display :deep(.ant-typography),
.password-display :deep(.ant-typography-copy) {
color: white !important;
}
.password-display :deep(.ant-typography-copy-icon) {
color: rgba(255, 255, 255, 0.85);
}
/* Modal title styling */
.modal-title {
display: flex;

View File

@ -59,19 +59,31 @@
</div>
<div class="filter-row">
<!-- 课程包主题筛选 -->
<div class="filter-group">
<span class="filter-label">课程包主题</span>
<a-select v-model:value="selectedThemeId" placeholder="全部课程包主题" style="width: 180px" allowClear
@change="loadPackages">
<a-select-option :value="undefined">全部课程包主题</a-select-option>
<a-select-option v-for="opt in (filterMeta.themes || [])" :key="opt.themeId" :value="opt.themeId">
{{ opt.name }} ({{ opt.count }})
</a-select-option>
</a-select>
</div>
<!-- 课程配置筛选 -->
<div class="filter-group">
<span class="filter-label">课程配置</span>
<a-select v-model:value="selectedLessonType" placeholder="全部课程配置" style="width: 180px" allowClear
@change="loadPackages">
<a-select-option :value="undefined">全部课程配置</a-select-option>
<a-select-option v-for="opt in (filterMeta.lessonTypes || [])" :key="opt.lessonType"
<a-select-option v-for="opt in filteredLessonTypes" :key="opt.lessonType"
:value="opt.lessonType">
{{ opt.name }} ({{ opt.count }})
</a-select-option>
</a-select>
</div>
<!-- 搜索 -->
<div class="filter-group search-group">
<a-input-search v-model:value="searchKeyword" placeholder="搜索课程包..." style="width: 220px" allowClear
@ -137,7 +149,14 @@ const selectedCollection = computed(() =>
);
//
const filterMeta = ref<FilterMetaResponse>({ grades: [], themes: [] });
const filterMeta = ref<FilterMetaResponse>({ grades: [], lessonTypes: [], themes: [] });
//
const EXCLUDED_LESSON_TYPES = new Set(['INTRODUCTION', 'INTRO', 'COLLECTIVE']);
const filteredLessonTypes = computed(() => {
const list = filterMeta.value.lessonTypes || [];
return list.filter(opt => !EXCLUDED_LESSON_TYPES.has((opt.lessonType || '').toUpperCase()));
});
//
const packages = ref<CoursePackage[]>([]);
@ -176,6 +195,7 @@ const selectCollection = async (collection: CourseCollection) => {
//
selectedGrade.value = '';
selectedLessonType.value = undefined;
selectedThemeId.value = undefined;
searchKeyword.value = '';
descExpanded.value = false;
@ -205,7 +225,7 @@ const loadFilterMeta = async () => {
filterMeta.value = await getFilterMeta(selectedCollectionId.value);
} catch (error) {
console.error('获取筛选元数据失败', error);
filterMeta.value = { grades: [], lessonTypes: [] };
filterMeta.value = { grades: [], lessonTypes: [], themes: [] };
}
};
@ -217,6 +237,7 @@ const loadPackages = async () => {
packages.value = await getPackages(selectedCollectionId.value, {
grade: selectedGrade.value || undefined,
lessonType: selectedLessonType.value,
themeId: selectedThemeId.value,
keyword: searchKeyword.value || undefined,
});
} catch (error: any) {
@ -227,8 +248,8 @@ const loadPackages = async () => {
}
};
//
watch([selectedGrade, selectedLessonType], () => {
//
watch([selectedGrade, selectedLessonType, selectedThemeId], () => {
loadPackages();
});

View File

@ -88,13 +88,14 @@ const gradeText = computed(() => {
return grades.join(' · ');
});
// courses
// courses
const EXCLUDED_LESSON_TYPES = new Set(['INTRODUCTION', 'INTRO', 'COLLECTIVE']);
const lessonTypes = computed(() => {
const courses = props.pkg.courses || [];
const types = new Set<string>();
for (const c of courses) {
const t = c.lessonType;
if (t) types.add(t);
if (t && !EXCLUDED_LESSON_TYPES.has(t.toUpperCase())) types.add(t);
}
return Array.from(types);
});

View File

@ -275,7 +275,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" okText="重置">
<template #title>
<span class="modal-title">
<KeyOutlined class="modal-title-icon" />
@ -1027,6 +1027,15 @@ onMounted(() => {
color: white;
}
.password-display :deep(.ant-typography),
.password-display :deep(.ant-typography-copy) {
color: white !important;
}
.password-display :deep(.ant-typography-copy-icon) {
color: rgba(255, 255, 255, 0.85);
}
/* Modal title styling */
.modal-title {
display: flex;

View File

@ -492,6 +492,10 @@ const resetForm = () => {
const showAddModal = () => {
isEdit.value = false;
resetForm();
//
if (selectedClassId.value) {
formState.classId = selectedClassId.value;
}
modalVisible.value = true;
};

View File

@ -176,7 +176,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" okText="重置">
<template #title>
<span class="modal-title">
<KeyOutlined class="modal-title-icon" />
@ -778,6 +778,15 @@ onMounted(() => {
color: white;
}
.password-display :deep(.ant-typography),
.password-display :deep(.ant-typography-copy) {
color: white !important;
}
.password-display :deep(.ant-typography-copy-icon) {
color: rgba(255, 255, 255, 0.85);
}
/* Modal title styling */
.modal-title {
display: flex;

View File

@ -7,12 +7,9 @@
</div>
<a-spin :spinning="loadingCollections">
<div class="collection-list">
<div
v-for="collection in collections"
:key="collection.id"
<div v-for="collection in collections" :key="collection.id"
:class="['collection-item', { active: selectedCollectionId === collection.id }]"
@click="selectCollection(collection)"
>
@click="selectCollection(collection)">
<div class="collection-name">{{ collection.name }}</div>
<div class="collection-count">{{ collection.packageCount || 0 }}个课程包</div>
</div>
@ -34,11 +31,7 @@
<div ref="descRef" :class="['desc-text', { expanded: descExpanded }]">
{{ selectedCollection.description }}
</div>
<button
v-if="showExpandBtn"
class="expand-btn"
@click="descExpanded = !descExpanded"
>
<button v-if="showExpandBtn" class="expand-btn" @click="descExpanded = !descExpanded">
{{ descExpanded ? '收起' : '展开更多' }}
<DownOutlined :class="{ rotated: descExpanded }" />
</button>
@ -52,18 +45,12 @@
<div class="filter-group">
<span class="filter-label">年级</span>
<div class="grade-tags">
<span
:class="['grade-tag', { active: !selectedGrade }]"
@click="selectedGrade = ''"
>
<span :class="['grade-tag', { active: !selectedGrade }]" @click="selectedGrade = ''">
全部
</span>
<span
v-for="grade in filterMeta.grades"
:key="grade.label"
<span v-for="grade in filterMeta.grades" :key="grade.label"
:class="['grade-tag', { active: selectedGrade === grade.label }]"
@click="selectedGrade = grade.label"
>
@click="selectedGrade = grade.label">
{{ grade.label }}
<span class="count">({{ grade.count }})</span>
</span>
@ -71,37 +58,36 @@
</div>
</div>
<div class="filter-row">
<div class="filter-row"><!-- 课程包主题筛选 -->
<div class="filter-group">
<span class="filter-label">课程包主题</span>
<a-select v-model:value="selectedThemeId" placeholder="全部课程包主题" style="width: 180px" allowClear
@change="loadPackages">
<a-select-option :value="undefined">全部课程包主题</a-select-option>
<a-select-option v-for="opt in (filterMeta.themes || [])" :key="opt.themeId" :value="opt.themeId">
{{ opt.name }} ({{ opt.count }})
</a-select-option>
</a-select>
</div>
<!-- 课程配置筛选 -->
<div class="filter-group">
<span class="filter-label">课程配置</span>
<a-select
v-model:value="selectedLessonType"
placeholder="全部课程配置"
style="width: 180px"
allowClear
@change="loadPackages"
>
<a-select v-model:value="selectedLessonType" placeholder="全部课程配置" style="width: 180px" allowClear
@change="loadPackages">
<a-select-option :value="undefined">全部课程配置</a-select-option>
<a-select-option
v-for="opt in (filterMeta.lessonTypes || [])"
:key="opt.lessonType"
:value="opt.lessonType"
>
<a-select-option v-for="opt in filteredLessonTypes" :key="opt.lessonType"
:value="opt.lessonType">
{{ opt.name }} ({{ opt.count }})
</a-select-option>
</a-select>
</div>
<!-- 搜索 -->
<div class="filter-group search-group">
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索课程包..."
style="width: 220px"
allowClear
@search="loadPackages"
/>
<a-input-search v-model:value="searchKeyword" placeholder="搜索课程包..." style="width: 220px" allowClear
@search="loadPackages" />
</div>
</div>
</section>
@ -110,13 +96,8 @@
<section class="packages-section">
<a-spin :spinning="loadingPackages">
<div v-if="packages.length > 0" class="packages-grid">
<CoursePackageCard
v-for="pkg in packages"
:key="pkg.id"
:pkg="pkg"
@click="handlePackageClick"
@prepare="handlePrepare"
/>
<CoursePackageCard v-for="pkg in packages" :key="pkg.id" :pkg="pkg" @click="handlePackageClick"
@prepare="handlePrepare" />
</div>
<div v-else class="empty-packages">
<InboxOutlined class="empty-icon" />
@ -166,7 +147,14 @@ const selectedCollection = computed(() =>
);
//
const filterMeta = ref<FilterMetaResponse>({ grades: [], lessonTypes: [] });
const filterMeta = ref<FilterMetaResponse>({ grades: [], lessonTypes: [], themes: [] });
//
const EXCLUDED_LESSON_TYPES = new Set(['INTRODUCTION', 'INTRO', 'COLLECTIVE']);
const filteredLessonTypes = computed(() => {
const list = filterMeta.value.lessonTypes || [];
return list.filter(opt => !EXCLUDED_LESSON_TYPES.has((opt.lessonType || '').toUpperCase()));
});
//
const packages = ref<CoursePackage[]>([]);
@ -175,6 +163,7 @@ const loadingPackages = ref(false);
//
const selectedGrade = ref('');
const selectedLessonType = ref<string | undefined>(undefined);
const selectedThemeId = ref<number | undefined>(undefined);
const searchKeyword = ref('');
//
@ -205,6 +194,7 @@ const selectCollection = async (collection: CourseCollection) => {
//
selectedGrade.value = '';
selectedLessonType.value = undefined;
selectedThemeId.value = undefined;
searchKeyword.value = '';
descExpanded.value = false;
@ -234,7 +224,7 @@ const loadFilterMeta = async () => {
filterMeta.value = await getFilterMeta(selectedCollectionId.value);
} catch (error) {
console.error('获取筛选元数据失败', error);
filterMeta.value = { grades: [], lessonTypes: [] };
filterMeta.value = { grades: [], lessonTypes: [], themes: [] };
}
};
@ -246,6 +236,7 @@ const loadPackages = async () => {
packages.value = await getPackages(selectedCollectionId.value, {
grade: selectedGrade.value || undefined,
lessonType: selectedLessonType.value,
themeId: selectedThemeId.value,
keyword: searchKeyword.value || undefined,
});
} catch (error: any) {
@ -266,8 +257,8 @@ const handlePrepare = (pkg: CoursePackage) => {
router.push(`/teacher/courses/${pkg.id}/prepare`);
};
//
watch([selectedGrade, selectedLessonType], () => {
//
watch([selectedGrade, selectedLessonType, selectedThemeId], () => {
loadPackages();
});
@ -432,7 +423,7 @@ onMounted(() => {
gap: 16px;
}
.filter-row + .filter-row {
.filter-row+.filter-row {
margin-top: 12px;
}

View File

@ -88,13 +88,14 @@ const gradeText = computed(() => {
return grades.join(' · ');
});
// courses
// courses
const EXCLUDED_LESSON_TYPES = new Set(['INTRODUCTION', 'INTRO', 'COLLECTIVE']);
const lessonTypes = computed(() => {
const courses = props.pkg.courses || [];
const types = new Set<string>();
for (const c of courses) {
const t = c.lessonType;
if (t) types.add(t);
if (t && !EXCLUDED_LESSON_TYPES.has(t.toUpperCase())) types.add(t);
}
return Array.from(types);
});

View File

@ -10,6 +10,7 @@ import com.reading.platform.common.response.PageResult;
import com.reading.platform.common.response.Result;
import com.reading.platform.dto.request.TenantCreateRequest;
import com.reading.platform.dto.request.TenantUpdateRequest;
import com.reading.platform.dto.request.UpdateTenantQuotaRequest;
import com.reading.platform.dto.response.TenantResponse;
import com.reading.platform.entity.Tenant;
import com.reading.platform.entity.TenantPackage;
@ -112,9 +113,14 @@ public class AdminTenantController {
@Operation(summary = "更新租户配额")
@Log(module = LogModule.TENANT, type = LogOperationType.UPDATE, description = "更新租户配额")
@PutMapping("/{id}/quota")
public Result<TenantResponse> updateTenantQuota(@PathVariable Long id, @RequestBody Map<String, Object> quota) {
// TODO: 实现更新租户配额逻辑
Tenant tenant = tenantService.getTenantById(id);
public Result<TenantResponse> updateTenantQuota(
@PathVariable Long id,
@RequestBody UpdateTenantQuotaRequest request) {
TenantUpdateRequest updateRequest = new TenantUpdateRequest();
updateRequest.setCollectionIds(request.getCollectionIds());
updateRequest.setTeacherQuota(request.getTeacherQuota());
updateRequest.setStudentQuota(request.getStudentQuota());
Tenant tenant = tenantService.updateTenant(id, updateRequest);
return Result.success(toResponse(tenant));
}

View File

@ -99,7 +99,7 @@ public class SchoolClassController {
new LambdaQueryWrapper<ClassTeacher>().eq(ClassTeacher::getClassId, vo.getId()));
List<ClassTeacherResponse> teacherList = new ArrayList<>();
for (ClassTeacher ct : classTeachers) {
Teacher t = teacherService.getTeacherById(ct.getTeacherId());
Teacher t = teacherService.findTeacherById(ct.getTeacherId());
teacherList.add(ClassTeacherResponse.builder()
.id(ct.getId())
.classId(ct.getClassId())
@ -165,7 +165,7 @@ public class SchoolClassController {
new LambdaQueryWrapper<ClassTeacher>().eq(ClassTeacher::getClassId, id));
List<ClassTeacherResponse> teacherList = new ArrayList<>();
for (ClassTeacher ct : classTeachers) {
Teacher t = teacherService.getTeacherById(ct.getTeacherId());
Teacher t = teacherService.findTeacherById(ct.getTeacherId());
teacherList.add(ClassTeacherResponse.builder()
.id(ct.getId())
.classId(ct.getClassId())

View File

@ -54,8 +54,9 @@ public class SchoolPackageController {
@PathVariable Long collectionId,
@RequestParam(required = false) String grade,
@RequestParam(required = false) String lessonType,
@RequestParam(required = false) Long themeId,
@RequestParam(required = false) String keyword) {
return Result.success(collectionService.getPackagesByCollection(collectionId, grade, lessonType, keyword));
return Result.success(collectionService.getPackagesByCollection(collectionId, grade, lessonType, themeId, keyword));
}
@GetMapping("/{collectionId}/filter-meta")

View File

@ -95,10 +95,10 @@ public class SchoolParentController {
@Operation(summary = "Reset parent password")
@Log(module = LogModule.PARENT, type = LogOperationType.UPDATE, description = "重置家长密码")
@PostMapping("/{id}/reset-password")
public Result<Void> resetPassword(@PathVariable Long id, @RequestParam String newPassword) {
public Result<java.util.Map<String, String>> resetPassword(@PathVariable Long id) {
Long tenantId = SecurityUtils.getCurrentTenantId();
parentService.resetPasswordWithTenantCheck(id, tenantId, newPassword);
return Result.success();
String tempPassword = parentService.resetPasswordAndReturnTemp(id, tenantId);
return Result.success(java.util.Map.of("tempPassword", tempPassword));
}
@Operation(summary = "Bind student to parent")

View File

@ -10,7 +10,9 @@ import com.reading.platform.common.response.Result;
import com.reading.platform.common.security.SecurityUtils;
import com.reading.platform.dto.request.StudentCreateRequest;
import com.reading.platform.dto.request.StudentUpdateRequest;
import com.reading.platform.dto.request.TransferStudentRequest;
import com.reading.platform.dto.response.StudentResponse;
import com.reading.platform.dto.response.StudentTransferHistoryItemResponse;
import com.reading.platform.entity.Student;
import com.reading.platform.service.ClassService;
import com.reading.platform.service.StudentService;
@ -21,6 +23,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "School - Student", description = "Student Management APIs for School")
@RestController
@ -38,7 +41,9 @@ public class SchoolStudentController {
public Result<StudentResponse> createStudent(@Valid @RequestBody StudentCreateRequest request) {
Long tenantId = SecurityUtils.getCurrentTenantId();
Student student = studentService.createStudent(tenantId, request);
return Result.success(studentMapper.toVO(student));
StudentResponse vo = studentMapper.toVO(student);
vo.setClassId(request.getClassId());
return Result.success(vo);
}
@Operation(summary = "Update student")
@ -80,6 +85,17 @@ public class SchoolStudentController {
return Result.success(PageResult.of(voList, page.getTotal(), page.getCurrent(), page.getSize()));
}
@Operation(summary = "Transfer student to another class")
@PostMapping("/{id}/transfer")
public Result<Map<String, String>> transferStudent(
@PathVariable Long id,
@Valid @RequestBody TransferStudentRequest request) {
Long tenantId = SecurityUtils.getCurrentTenantId();
studentService.getStudentByIdWithTenantCheck(id, tenantId);
classService.assignStudentToClass(id, request.getToClassId(), tenantId);
return Result.success(Map.of("message", "调班成功"));
}
@Operation(summary = "Delete student")
@Log(module = LogModule.STUDENT, type = LogOperationType.DELETE, description = "删除学生")
@DeleteMapping("/{id}")
@ -89,4 +105,13 @@ public class SchoolStudentController {
return Result.success();
}
@Operation(summary = "Get student class transfer history")
@GetMapping("/{id}/history")
public Result<List<StudentTransferHistoryItemResponse>> getStudentClassHistory(@PathVariable Long id) {
Long tenantId = SecurityUtils.getCurrentTenantId();
studentService.getStudentByIdWithTenantCheck(id, tenantId);
List<StudentTransferHistoryItemResponse> history = classService.getStudentClassHistory(id, tenantId);
return Result.success(history);
}
}

View File

@ -35,4 +35,7 @@ public class StudentCreateRequest {
@Schema(description = "备注")
private String notes;
@Schema(description = "所在班级 ID创建后分配到该班级")
private Long classId;
}

View File

@ -0,0 +1,17 @@
package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "学生调班请求")
public class TransferStudentRequest {
@NotNull(message = "目标班级 ID 不能为空")
@Schema(description = "目标班级 ID", required = true)
private Long toClassId;
@Schema(description = "调班原因")
private String reason;
}

View File

@ -0,0 +1,23 @@
package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
* 更新租户配额请求
*/
@Data
@Schema(description = "更新租户配额请求")
public class UpdateTenantQuotaRequest {
@Schema(description = "课程套餐 ID 列表collectionIds")
private List<Long> collectionIds;
@Schema(description = "教师配额")
private Integer teacherQuota;
@Schema(description = "学生配额")
private Integer studentQuota;
}

View File

@ -96,6 +96,9 @@ public class CourseResponse {
@Schema(description = "主题 ID")
private Long themeId;
@Schema(description = "主题名称")
private String themeName;
@Schema(description = "绘本名称")
private String pictureBookName;

View File

@ -25,6 +25,9 @@ public class PackageFilterMetaResponse {
@Schema(description = "课程配置选项列表(导入课、集体课、健康、科学等)")
private List<LessonTypeOption> lessonTypes;
@Schema(description = "课程包主题选项列表")
private List<ThemeOption> themes;
/**
* 年级选项
*/
@ -59,4 +62,23 @@ public class PackageFilterMetaResponse {
@Schema(description = "包含该类型环节的课程包数量")
private Integer count;
}
/**
* 课程包主题选项
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "课程包主题选项")
public static class ThemeOption {
@Schema(description = "主题ID")
private Long themeId;
@Schema(description = "主题名称")
private String name;
@Schema(description = "该主题下的课程包数量")
private Integer count;
}
}

View File

@ -0,0 +1,53 @@
package com.reading.platform.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 学生调班历史单条记录响应
* 前端期望格式fromClasstoClass 包含班级信息
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "学生调班历史单条记录")
public class StudentTransferHistoryItemResponse {
@Schema(description = "记录 ID")
private Long id;
@Schema(description = "调出班级(首次入园为 null")
private ClassBasicInfo fromClass;
@Schema(description = "调入班级")
private ClassBasicInfo toClass;
@Schema(description = "调班原因")
private String reason;
@Schema(description = "操作人 ID")
private Long operatedBy;
@Schema(description = "创建时间")
private LocalDateTime createdAt;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "班级基本信息")
public static class ClassBasicInfo {
@Schema(description = "班级 ID")
private Long id;
@Schema(description = "班级名称")
private String name;
@Schema(description = "年级")
private String grade;
}
}

View File

@ -3,6 +3,7 @@ package com.reading.platform.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.reading.platform.dto.request.ClassCreateRequest;
import com.reading.platform.dto.request.ClassUpdateRequest;
import com.reading.platform.dto.response.StudentTransferHistoryItemResponse;
import com.reading.platform.entity.Clazz;
import java.util.List;
@ -102,4 +103,9 @@ public interface ClassService extends com.baomidou.mybatisplus.extension.service
*/
Clazz getPrimaryClassByStudentId(Long studentId);
/**
* 获取学生调班历史带租户验证
*/
List<StudentTransferHistoryItemResponse> getStudentClassHistory(Long studentId, Long tenantId);
}

View File

@ -40,10 +40,11 @@ public interface CourseCollectionService extends IService<CourseCollection> {
* @param collectionId 套餐ID
* @param grade 年级筛选
* @param lessonType 课程配置筛选INTRODUCTIONCOLLECTIVEHEALTHLANGUAGESCIENCESOCIALART
* @param themeId 课程包主题筛选
* @param keyword 关键词搜索
* @return 课程包列表
*/
List<CoursePackageResponse> getPackagesByCollection(Long collectionId, String grade, String lessonType, String keyword);
List<CoursePackageResponse> getPackagesByCollection(Long collectionId, String grade, String lessonType, Long themeId, String keyword);
/**
* 获取套餐的筛选元数据年级课程配置选项

View File

@ -62,6 +62,11 @@ public interface ParentService extends com.baomidou.mybatisplus.extension.servic
*/
void resetPasswordWithTenantCheck(Long id, Long tenantId, String newPassword);
/**
* 重置密码并返回临时密码带租户验证
*/
String resetPasswordAndReturnTemp(Long id, Long tenantId);
/**
* 绑定学生
*/

View File

@ -34,6 +34,11 @@ public interface TeacherService extends IService<Teacher> {
*/
Teacher getTeacherById(Long id);
/**
* 根据 ID 查询教师不存在时返回 null不抛异常
*/
Teacher findTeacherById(Long id);
/**
* 根据 ID 查询教师带租户验证
*/

View File

@ -7,6 +7,7 @@ import com.reading.platform.common.enums.ErrorCode;
import com.reading.platform.common.exception.BusinessException;
import com.reading.platform.dto.request.ClassCreateRequest;
import com.reading.platform.dto.request.ClassUpdateRequest;
import com.reading.platform.dto.response.StudentTransferHistoryItemResponse;
import com.reading.platform.entity.ClassTeacher;
import com.reading.platform.entity.Clazz;
import com.reading.platform.entity.StudentClassHistory;
@ -22,6 +23,7 @@ import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
@ -337,4 +339,54 @@ public class ClassServiceImpl extends com.baomidou.mybatisplus.extension.service
return clazzMapper.selectById(history.getClassId());
}
@Override
public List<StudentTransferHistoryItemResponse> getStudentClassHistory(Long studentId, Long tenantId) {
log.debug("获取学生调班历史,学生 ID: {}, 租户 ID: {}", studentId, tenantId);
List<StudentClassHistory> histories = studentClassHistoryMapper.selectList(
new LambdaQueryWrapper<StudentClassHistory>()
.eq(StudentClassHistory::getStudentId, studentId)
.orderByAsc(StudentClassHistory::getStartDate)
);
List<StudentTransferHistoryItemResponse> result = new ArrayList<>();
Clazz prevClass = null;
for (StudentClassHistory h : histories) {
Clazz toClazz = getClassById(h.getClassId());
if (toClazz == null || !tenantId.equals(toClazz.getTenantId())) {
log.warn("调班历史引用的班级不存在或无权访问跳过。historyId: {}, classId: {}", h.getId(), h.getClassId());
continue;
}
StudentTransferHistoryItemResponse.ClassBasicInfo fromClassInfo = prevClass == null ? null
: StudentTransferHistoryItemResponse.ClassBasicInfo.builder()
.id(prevClass.getId())
.name(prevClass.getName())
.grade(prevClass.getGrade() != null ? prevClass.getGrade() : "")
.build();
StudentTransferHistoryItemResponse.ClassBasicInfo toClassInfo =
StudentTransferHistoryItemResponse.ClassBasicInfo.builder()
.id(toClazz.getId())
.name(toClazz.getName())
.grade(toClazz.getGrade() != null ? toClazz.getGrade() : "")
.build();
result.add(StudentTransferHistoryItemResponse.builder()
.id(h.getId())
.fromClass(fromClassInfo)
.toClass(toClassInfo)
.reason(null)
.operatedBy(null)
.createdAt(h.getCreatedAt())
.build());
prevClass = toClazz;
}
Collections.reverse(result);
return result;
}
}

View File

@ -213,8 +213,8 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
* 获取课程套餐下的课程包列表支持筛选
*/
@Override
public List<CoursePackageResponse> getPackagesByCollection(Long collectionId, String grade, String lessonType, String keyword) {
log.info("获取课程套餐的课程包列表筛选collectionId={}, grade={}, lessonType={}, keyword={}", collectionId, grade, lessonType, keyword);
public List<CoursePackageResponse> getPackagesByCollection(Long collectionId, String grade, String lessonType, Long themeId, String keyword) {
log.info("获取课程套餐的课程包列表筛选collectionId={}, grade={}, lessonType={}, themeId={}, keyword={}", collectionId, grade, lessonType, themeId, keyword);
// 查询关联关系
List<CourseCollectionPackage> associations = collectionPackageMapper.selectList(
@ -252,6 +252,11 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
wrapper.apply("JSON_CONTAINS(grade_tags, {0})", "\"" + grade + "\"");
}
// 课程包主题筛选
if (themeId != null) {
wrapper.eq(CoursePackage::getThemeId, themeId);
}
// 关键词搜索
if (StringUtils.hasText(keyword)) {
wrapper.and(w -> w
@ -315,6 +320,7 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
return PackageFilterMetaResponse.builder()
.grades(new ArrayList<>())
.lessonTypes(new ArrayList<>())
.themes(new ArrayList<>())
.build();
}
@ -373,9 +379,32 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
.build())
.collect(Collectors.toList());
// 统计课程包主题分布
Map<Long, Integer> themeCountMap = new HashMap<>();
Map<Long, String> themeNameMap = new HashMap<>();
for (CoursePackage pkg : packages) {
Long tid = pkg.getThemeId();
if (tid != null) {
themeCountMap.merge(tid, 1, Integer::sum);
if (!themeNameMap.containsKey(tid)) {
Theme theme = themeMapper.selectById(tid);
themeNameMap.put(tid, theme != null ? theme.getName() : "主题" + tid);
}
}
}
List<PackageFilterMetaResponse.ThemeOption> themes = themeCountMap.entrySet().stream()
.map(e -> PackageFilterMetaResponse.ThemeOption.builder()
.themeId(e.getKey())
.name(themeNameMap.getOrDefault(e.getKey(), ""))
.count(e.getValue())
.build())
.sorted((a, b) -> themeNameMap.getOrDefault(a.getThemeId(), "").compareTo(themeNameMap.getOrDefault(b.getThemeId(), "")))
.collect(Collectors.toList());
return PackageFilterMetaResponse.builder()
.grades(grades)
.lessonTypes(lessonTypes)
.themes(themes)
.build();
}

View File

@ -16,9 +16,11 @@ import com.reading.platform.entity.CourseLesson;
import com.reading.platform.entity.CoursePackage;
import com.reading.platform.entity.LessonStep;
import com.reading.platform.entity.TenantPackage;
import com.reading.platform.entity.Theme;
import com.reading.platform.mapper.CourseCollectionPackageMapper;
import com.reading.platform.mapper.CoursePackageMapper;
import com.reading.platform.mapper.TenantPackageMapper;
import com.reading.platform.mapper.ThemeMapper;
import com.reading.platform.service.CourseLessonService;
import com.reading.platform.service.CoursePackageService;
import lombok.RequiredArgsConstructor;
@ -47,6 +49,7 @@ public class CoursePackageServiceImpl extends ServiceImpl<CoursePackageMapper, C
private final com.reading.platform.common.mapper.CoursePackageMapper coursePackageVoMapper;
private final CourseCollectionPackageMapper collectionPackageMapper;
private final TenantPackageMapper tenantPackageMapper;
private final ThemeMapper themeMapper;
@Override
@Transactional(rollbackFor = Exception.class)
@ -109,6 +112,13 @@ public class CoursePackageServiceImpl extends ServiceImpl<CoursePackageMapper, C
public CourseResponse getCourseByIdWithLessons(Long id) {
CoursePackage entity = getCourseById(id);
CourseResponse response = coursePackageVoMapper.toVO(entity);
// 填充主题名称
if (entity.getThemeId() != null) {
Theme theme = themeMapper.selectById(entity.getThemeId());
if (theme != null) {
response.setThemeName(theme.getName());
}
}
List<CourseLesson> lessons = courseLessonService.findByCourseId(id);
List<CourseLessonResponse> lessonResponses = lessons.stream()
.map(this::toLessonResponse)

View File

@ -19,6 +19,8 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.UUID;
/**
* 家长服务实现类
*/
@ -190,6 +192,18 @@ public class ParentServiceImpl extends com.baomidou.mybatisplus.extension.servic
resetPassword(id, newPassword);
}
@Override
@Transactional
public String resetPasswordAndReturnTemp(Long id, Long tenantId) {
log.info("开始重置密码并返回临时密码ID: {}, tenantId: {}", id, tenantId);
Parent parent = getParentByIdWithTenantCheck(id, tenantId);
String tempPassword = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
parent.setPassword(passwordEncoder.encode(tempPassword));
parentMapper.updateById(parent);
log.info("家长密码重置成功ID: {}", id);
return tempPassword;
}
@Override
@Transactional
public void bindStudentWithTenantCheck(Long parentId, Long studentId, Long tenantId, String relationship, Boolean isPrimary) {

View File

@ -58,6 +58,10 @@ public class StudentServiceImpl extends com.baomidou.mybatisplus.extension.servi
studentMapper.insert(student);
if (request.getClassId() != null) {
classService.assignStudentToClass(student.getId(), request.getClassId(), tenantId);
}
log.info("学生创建成功ID: {}", student.getId());
return student;
}

View File

@ -160,6 +160,12 @@ public class TeacherServiceImpl extends com.baomidou.mybatisplus.extension.servi
return teacher;
}
@Override
public Teacher findTeacherById(Long id) {
if (id == null) return null;
return teacherMapper.selectById(id);
}
@Override
public Teacher getTeacherByIdWithTenantCheck(Long id, Long tenantId) {
log.debug("查询教师带租户验证ID: {}, tenantId: {}", id, tenantId);