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)}`; 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 { export function formatDate(date: string): string {
return new Date(date).toLocaleString('zh-CN'); return new Date(date).toLocaleString('zh-CN');

View File

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

View File

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

View File

@ -211,11 +211,18 @@ export const fileApi = {
/** /**
* URL * URL
* OSS URL /
*/ */
getFileUrl: (filePath: string): string => { getFileUrl: (filePath: string | null | undefined): string => {
// filePath 格式: /uploads/courses/covers/xxx.png if (!filePath) return '';
// 直接返回相对路径,由 nginx 或后端静态服务处理 if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
return filePath; 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; environmentConstruction?: string;
/** 主题 ID */ /** 主题 ID */
themeId?: number; themeId?: number;
/** 主题名称 */
themeName?: string;
/** 绘本名称 */ /** 绘本名称 */
pictureBookName?: string; pictureBookName?: string;
/** 封面图片路径 */ /** 封面图片路径 */

View File

@ -105,8 +105,8 @@ export function createLesson(courseId: number, data: CreateLessonData) {
} }
// 更新课程 // 更新课程
export function updateLesson(lessonId: number, data: Partial<CreateLessonData>) { export function updateLesson(courseId: number, lessonId: number, data: Partial<CreateLessonData>) {
return http.put(`/v1/admin/courses/0/lessons/${lessonId}`, data); 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>) { export function updateStep(courseId: number, stepId: number, data: Partial<CreateStepData>) {
return http.put(`/v1/admin/courses/0/lessons/steps/${stepId}`, data); 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 { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue'; import { PlusOutlined } from '@ant-design/icons-vue';
import { getThemeList } from '@/api/theme'; import { getThemeList } from '@/api/theme';
import { uploadFile } from '@/api/file'; import { uploadFile, getFileUrl } from '@/api/file';
import type { Theme } from '@/api/theme'; import type { Theme } from '@/api/theme';
interface BasicInfoData { interface BasicInfoData {
@ -206,19 +206,16 @@ watch(
if (newVal) { if (newVal) {
Object.assign(formData, newVal); Object.assign(formData, newVal);
// //
if (newVal.coverImagePath && coverImages.value.length === 0) { if (newVal.coverImagePath) {
// URL
let imageUrl = newVal.coverImagePath;
if (!imageUrl.startsWith('http') && !imageUrl.startsWith('/uploads') && !imageUrl.includes('/uploads/')) {
imageUrl = `/uploads/${imageUrl}`;
}
coverImages.value = [{ coverImages.value = [{
uid: '-1', uid: '-1',
name: 'cover', name: 'cover',
status: 'done', status: 'done',
url: imageUrl, url: getFileUrl(newVal.coverImagePath),
}]; }];
} else {
coverImages.value = [];
} }
} }
}, },
@ -255,16 +252,11 @@ const beforeCoverUpload = async (file: any) => {
try { try {
const result = await uploadFile(file, 'cover'); const result = await uploadFile(file, 'cover');
formData.coverImagePath = result.filePath; 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 = [{ coverImages.value = [{
uid: file.uid, uid: file.uid,
name: file.name, name: file.name,
status: 'done', status: 'done',
url: imageUrl, url: getFileUrl(result.filePath),
}]; }];
handleChange(); handleChange();
message.success('封面上传成功'); message.success('封面上传成功');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,36 +1,27 @@
<template> <template>
<div class="course-detail-view"> <div class="course-detail-view">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<div class="detail-header"> <a-page-header title="课程包详情" :sub-title="course.name || ''" @back="() => router.back()">
<div class="header-left"> <template #extra>
<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"> <div class="header-actions">
<a-button v-if="course.status !== 'PUBLISHED'" @click="editCourse"> <a-button v-if="canEdit" @click="editCourse">
<EditOutlined /> 编辑 <EditOutlined /> 编辑
</a-button> </a-button>
<a-button @click="viewStats"> <a-button @click="viewStats">
<BarChartOutlined /> 数据 <BarChartOutlined /> 数据
</a-button> </a-button>
<a-popconfirm <a-popconfirm v-if="course.status === 'DRAFT' || course.status === 'ARCHIVED'" title="确定删除此课程包吗?"
v-if="course.status === 'DRAFT' || course.status === 'ARCHIVED'" @confirm="deleteCourse">
title="确定删除此课程包吗?"
@confirm="deleteCourse"
>
<a-button danger> <a-button danger>
<DeleteOutlined /> 删除 <DeleteOutlined /> 删除
</a-button> </a-button>
</a-popconfirm> </a-popconfirm>
</div> </div>
</div> </template>
<template #tags>
<a-tag :style="getStatusStyle(course.status)">{{ translateStatus(course.status) }}</a-tag>
</template>
</a-page-header>
<a-spin :spinning="loading"> <a-spin :spinning="loading">
<div class="detail-content"> <div class="detail-content">
@ -223,12 +214,8 @@
</div> </div>
<div class="section-body"> <div class="section-body">
<div class="lesson-cards"> <div class="lesson-cards">
<div <div v-for="lesson in courseLessons" :key="lesson.id" class="lesson-card"
v-for="lesson in courseLessons" :class="'lesson-type-' + lesson.lessonType?.toLowerCase()">
:key="lesson.id"
class="lesson-card"
:class="'lesson-type-' + lesson.lessonType?.toLowerCase()"
>
<div class="lesson-header"> <div class="lesson-header">
<div class="lesson-type-badge" :style="{ background: getLessonTypeBgColor(lesson.lessonType) }"> <div class="lesson-type-badge" :style="{ background: getLessonTypeBgColor(lesson.lessonType) }">
{{ translateLessonType(lesson.lessonType) }} {{ translateLessonType(lesson.lessonType) }}
@ -254,29 +241,20 @@
<div class="lesson-section" v-if="hasLessonResources(lesson)"> <div class="lesson-section" v-if="hasLessonResources(lesson)">
<div class="lesson-section-title">核心资源</div> <div class="lesson-section-title">核心资源</div>
<div class="resource-grid"> <div class="resource-grid">
<div <div v-if="lesson.videoPath" class="resource-item"
v-if="lesson.videoPath" @click="previewFile(lesson.videoPath, lesson.videoName || '绘本动画')">
class="resource-item"
@click="previewFile(lesson.videoPath, lesson.videoName || '绘本动画')"
>
<VideoCameraOutlined class="resource-icon video" /> <VideoCameraOutlined class="resource-icon video" />
<span class="resource-name">{{ lesson.videoName || '绘本动画' }}</span> <span class="resource-name">{{ lesson.videoName || '绘本动画' }}</span>
<EyeOutlined class="resource-action" /> <EyeOutlined class="resource-action" />
</div> </div>
<div <div v-if="lesson.pptPath" class="resource-item"
v-if="lesson.pptPath" @click="previewFile(lesson.pptPath, lesson.pptName || '教学课件')">
class="resource-item"
@click="previewFile(lesson.pptPath, lesson.pptName || '教学课件')"
>
<FilePptOutlined class="resource-icon ppt" /> <FilePptOutlined class="resource-icon ppt" />
<span class="resource-name">{{ lesson.pptName || '教学课件' }}</span> <span class="resource-name">{{ lesson.pptName || '教学课件' }}</span>
<EyeOutlined class="resource-action" /> <EyeOutlined class="resource-action" />
</div> </div>
<div <div v-if="lesson.pdfPath" class="resource-item"
v-if="lesson.pdfPath" @click="previewFile(lesson.pdfPath, lesson.pdfName || '电子绘本')">
class="resource-item"
@click="previewFile(lesson.pdfPath, lesson.pdfName || '电子绘本')"
>
<FilePdfOutlined class="resource-icon pdf" /> <FilePdfOutlined class="resource-icon pdf" />
<span class="resource-name">{{ lesson.pdfName || '电子绘本' }}</span> <span class="resource-name">{{ lesson.pdfName || '电子绘本' }}</span>
<EyeOutlined class="resource-action" /> <EyeOutlined class="resource-action" />
@ -333,12 +311,8 @@
<VideoCameraOutlined style="color: #722ed1;" /> 视频资源 <VideoCameraOutlined style="color: #722ed1;" /> 视频资源
</div> </div>
<div class="resource-list"> <div class="resource-list">
<div <div v-for="(item, index) in allVideos" :key="'video-' + index" class="resource-item-card"
v-for="(item, index) in allVideos" @click="previewFile(item.path, item.name)">
:key="'video-' + index"
class="resource-item-card"
@click="previewFile(item.path, item.name)"
>
<VideoCameraOutlined class="item-icon" style="color: #722ed1;" /> <VideoCameraOutlined class="item-icon" style="color: #722ed1;" />
<span class="item-name">{{ item.name }}</span> <span class="item-name">{{ item.name }}</span>
<PlayCircleOutlined class="item-action" /> <PlayCircleOutlined class="item-action" />
@ -352,12 +326,8 @@
<AudioOutlined style="color: #52c41a;" /> 音频资源 <AudioOutlined style="color: #52c41a;" /> 音频资源
</div> </div>
<div class="resource-list"> <div class="resource-list">
<div <div v-for="(item, index) in allAudios" :key="'audio-' + index" class="resource-item-card"
v-for="(item, index) in allAudios" @click="previewFile(item.path, item.name)">
:key="'audio-' + index"
class="resource-item-card"
@click="previewFile(item.path, item.name)"
>
<AudioOutlined class="item-icon" style="color: #52c41a;" /> <AudioOutlined class="item-icon" style="color: #52c41a;" />
<span class="item-name">{{ item.name }}</span> <span class="item-name">{{ item.name }}</span>
<PlayCircleOutlined class="item-action" /> <PlayCircleOutlined class="item-action" />
@ -371,12 +341,8 @@
<FileTextOutlined style="color: #1890ff;" /> 文档资源 <FileTextOutlined style="color: #1890ff;" /> 文档资源
</div> </div>
<div class="resource-list"> <div class="resource-list">
<div <div v-for="(item, index) in allDocuments" :key="'doc-' + index" class="resource-item-card"
v-for="(item, index) in allDocuments" @click="previewFile(item.path, item.name)">
: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;" /> <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;" /> <FilePptOutlined v-else-if="item.type === 'ppt'" class="item-icon" style="color: #fa8c16;" />
<FileTextOutlined v-else class="item-icon" style="color: #1890ff;" /> <FileTextOutlined v-else class="item-icon" style="color: #1890ff;" />
@ -392,14 +358,8 @@
<PictureOutlined style="color: #13c2c2;" /> 图片资源 <PictureOutlined style="color: #13c2c2;" /> 图片资源
</div> </div>
<div class="image-grid"> <div class="image-grid">
<img <img v-for="(item, index) in allImages" :key="'img-' + index" :src="getFileUrl(item.path)"
v-for="(item, index) in allImages" :alt="item.name" class="image-thumbnail" @click="previewImage(getFileUrl(item.path))" />
:key="'img-' + index"
:src="getFileUrl(item.path)"
:alt="item.name"
class="image-thumbnail"
@click="previewImage(getFileUrl(item.path))"
/>
</div> </div>
</div> </div>
</div> </div>
@ -414,11 +374,7 @@
</a-modal> </a-modal>
<!-- 文件预览弹窗 --> <!-- 文件预览弹窗 -->
<FilePreviewModal <FilePreviewModal v-model:open="previewModalVisible" :file-url="previewFileUrl" :file-name="previewFileName" />
v-model:open="previewModalVisible"
:file-url="previewFileUrl"
:file-name="previewFileName"
/>
</div> </div>
</template> </template>
@ -543,6 +499,12 @@ const domainTags = computed(() => {
} }
}); });
// 稿//
const canEdit = computed(() => {
const s = course.value.status;
return s === 'DRAFT' || s === 'REJECTED' || s === 'ARCHIVED';
});
// //
const hasIntroContent = computed(() => { const hasIntroContent = computed(() => {
return course.value.introSummary || course.value.introHighlights || return course.value.introSummary || course.value.introHighlights ||
@ -823,39 +785,10 @@ const fetchCourseDetail = async () => {
background: #f0f2f5; background: #f0f2f5;
} }
.detail-header {
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 { .header-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
}
.detail-content { .detail-content {
padding: 24px; padding: 24px;
@ -1124,9 +1057,17 @@ const fetchCourseDetail = async () => {
font-size: 18px; font-size: 18px;
margin-right: 8px; margin-right: 8px;
&.video { color: #722ed1; } &.video {
&.ppt { color: #fa8c16; } color: #722ed1;
&.pdf { color: #f5222d; } }
&.ppt {
color: #fa8c16;
}
&.pdf {
color: #f5222d;
}
} }
.resource-name { .resource-name {

View File

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

View File

@ -53,6 +53,10 @@
</div> </div>
</template> </template>
<template v-else-if="column.key === 'theme'">
<span>{{ record.themeName || record.theme?.name || '-' }}</span>
</template>
<template v-else-if="column.key === 'pictureBook'"> <template v-else-if="column.key === 'pictureBook'">
{{ record.pictureBookName }} {{ record.pictureBookName }}
</template> </template>
@ -144,11 +148,11 @@
</a-dropdown> </a-dropdown>
</template> </template>
<!-- 已下架状态 --> <!-- 已下架状态需重新提交审核后才能发布 -->
<template v-else-if="record.status === 'ARCHIVED'"> <template v-else-if="record.status === 'ARCHIVED'">
<a-button size="small" @click="viewCourse(record.id)">查看</a-button> <a-button size="small" @click="viewCourse(record.id)">查看</a-button>
<a-button size="small" @click="viewStats(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-popconfirm title="确定删除此课程包吗?删除后无法恢复" @confirm="deleteCourseHandler(record.id)">
<a-button size="small" danger>删除</a-button> <a-button size="small" danger>删除</a-button>
</a-popconfirm> </a-popconfirm>
@ -239,6 +243,7 @@ const pagination = reactive({
const columns = [ const columns = [
{ title: '课程包名称', key: 'name', width: 250 }, { title: '课程包名称', key: 'name', width: 250 },
{ title: '课程主题', key: 'theme', width: 120 },
{ title: '关联绘本', key: 'pictureBook', width: 120 }, { title: '关联绘本', key: 'pictureBook', width: 120 },
{ title: '课程配置', key: 'lessonConfig', width: 200 }, { title: '课程配置', key: 'lessonConfig', width: 200 },
{ title: '状态', key: 'status', width: 90 }, { 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) => { const iterateCourse = (id: number) => {
router.push(`/admin/packages/${id}/iterate`); router.push(`/admin/packages/${id}/iterate`);
}; };

View File

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

View File

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

View File

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

View File

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

View File

@ -151,7 +151,7 @@
:key="pkg.id" :key="pkg.id"
:value="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-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
@ -185,7 +185,7 @@
:key="pkg.id" :key="pkg.id"
:value="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-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
@ -308,7 +308,7 @@
</a-drawer> </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> <template #title>
<span class="modal-title"> <span class="modal-title">
<KeyOutlined class="modal-title-icon" /> <KeyOutlined class="modal-title-icon" />
@ -360,6 +360,7 @@ import {
type UpdateTenantDto, type UpdateTenantDto,
type CourseCollectionResponse, type CourseCollectionResponse,
} from '@/api/admin'; } from '@/api/admin';
import { getDiscountTypeText } from '@/api/collections';
// //
const searchForm = reactive({ const searchForm = reactive({
@ -871,6 +872,15 @@ onMounted(() => {
color: white; 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 styling */
.modal-title { .modal-title {
display: flex; display: flex;

View File

@ -59,19 +59,31 @@
</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"> <div class="filter-group">
<span class="filter-label">课程配置</span> <span class="filter-label">课程配置</span>
<a-select v-model:value="selectedLessonType" placeholder="全部课程配置" style="width: 180px" allowClear <a-select v-model:value="selectedLessonType" placeholder="全部课程配置" style="width: 180px" allowClear
@change="loadPackages"> @change="loadPackages">
<a-select-option :value="undefined">全部课程配置</a-select-option> <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"> :value="opt.lessonType">
{{ opt.name }} ({{ opt.count }}) {{ opt.name }} ({{ opt.count }})
</a-select-option> </a-select-option>
</a-select> </a-select>
</div> </div>
<!-- 搜索 --> <!-- 搜索 -->
<div class="filter-group search-group"> <div class="filter-group search-group">
<a-input-search v-model:value="searchKeyword" placeholder="搜索课程包..." style="width: 220px" allowClear <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[]>([]); const packages = ref<CoursePackage[]>([]);
@ -176,6 +195,7 @@ const selectCollection = async (collection: CourseCollection) => {
// //
selectedGrade.value = ''; selectedGrade.value = '';
selectedLessonType.value = undefined; selectedLessonType.value = undefined;
selectedThemeId.value = undefined;
searchKeyword.value = ''; searchKeyword.value = '';
descExpanded.value = false; descExpanded.value = false;
@ -205,7 +225,7 @@ const loadFilterMeta = async () => {
filterMeta.value = await getFilterMeta(selectedCollectionId.value); filterMeta.value = await getFilterMeta(selectedCollectionId.value);
} catch (error) { } catch (error) {
console.error('获取筛选元数据失败', 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, { packages.value = await getPackages(selectedCollectionId.value, {
grade: selectedGrade.value || undefined, grade: selectedGrade.value || undefined,
lessonType: selectedLessonType.value, lessonType: selectedLessonType.value,
themeId: selectedThemeId.value,
keyword: searchKeyword.value || undefined, keyword: searchKeyword.value || undefined,
}); });
} catch (error: any) { } catch (error: any) {
@ -227,8 +248,8 @@ const loadPackages = async () => {
} }
}; };
// //
watch([selectedGrade, selectedLessonType], () => { watch([selectedGrade, selectedLessonType, selectedThemeId], () => {
loadPackages(); loadPackages();
}); });

View File

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

View File

@ -275,7 +275,7 @@
</a-modal> </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> <template #title>
<span class="modal-title"> <span class="modal-title">
<KeyOutlined class="modal-title-icon" /> <KeyOutlined class="modal-title-icon" />
@ -1027,6 +1027,15 @@ onMounted(() => {
color: white; 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 styling */
.modal-title { .modal-title {
display: flex; display: flex;

View File

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

View File

@ -176,7 +176,7 @@
</a-modal> </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> <template #title>
<span class="modal-title"> <span class="modal-title">
<KeyOutlined class="modal-title-icon" /> <KeyOutlined class="modal-title-icon" />
@ -778,6 +778,15 @@ onMounted(() => {
color: white; 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 styling */
.modal-title { .modal-title {
display: flex; display: flex;

View File

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

View File

@ -88,13 +88,14 @@ const gradeText = computed(() => {
return grades.join(' · '); return grades.join(' · ');
}); });
// courses // courses
const EXCLUDED_LESSON_TYPES = new Set(['INTRODUCTION', 'INTRO', 'COLLECTIVE']);
const lessonTypes = computed(() => { const lessonTypes = computed(() => {
const courses = props.pkg.courses || []; const courses = props.pkg.courses || [];
const types = new Set<string>(); const types = new Set<string>();
for (const c of courses) { for (const c of courses) {
const t = c.lessonType; const t = c.lessonType;
if (t) types.add(t); if (t && !EXCLUDED_LESSON_TYPES.has(t.toUpperCase())) types.add(t);
} }
return Array.from(types); 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.common.response.Result;
import com.reading.platform.dto.request.TenantCreateRequest; import com.reading.platform.dto.request.TenantCreateRequest;
import com.reading.platform.dto.request.TenantUpdateRequest; 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.dto.response.TenantResponse;
import com.reading.platform.entity.Tenant; import com.reading.platform.entity.Tenant;
import com.reading.platform.entity.TenantPackage; import com.reading.platform.entity.TenantPackage;
@ -112,9 +113,14 @@ public class AdminTenantController {
@Operation(summary = "更新租户配额") @Operation(summary = "更新租户配额")
@Log(module = LogModule.TENANT, type = LogOperationType.UPDATE, description = "更新租户配额") @Log(module = LogModule.TENANT, type = LogOperationType.UPDATE, description = "更新租户配额")
@PutMapping("/{id}/quota") @PutMapping("/{id}/quota")
public Result<TenantResponse> updateTenantQuota(@PathVariable Long id, @RequestBody Map<String, Object> quota) { public Result<TenantResponse> updateTenantQuota(
// TODO: 实现更新租户配额逻辑 @PathVariable Long id,
Tenant tenant = tenantService.getTenantById(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)); return Result.success(toResponse(tenant));
} }

View File

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

View File

@ -54,8 +54,9 @@ public class SchoolPackageController {
@PathVariable Long collectionId, @PathVariable Long collectionId,
@RequestParam(required = false) String grade, @RequestParam(required = false) String grade,
@RequestParam(required = false) String lessonType, @RequestParam(required = false) String lessonType,
@RequestParam(required = false) Long themeId,
@RequestParam(required = false) String keyword) { @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") @GetMapping("/{collectionId}/filter-meta")

View File

@ -95,10 +95,10 @@ public class SchoolParentController {
@Operation(summary = "Reset parent password") @Operation(summary = "Reset parent password")
@Log(module = LogModule.PARENT, type = LogOperationType.UPDATE, description = "重置家长密码") @Log(module = LogModule.PARENT, type = LogOperationType.UPDATE, description = "重置家长密码")
@PostMapping("/{id}/reset-password") @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(); Long tenantId = SecurityUtils.getCurrentTenantId();
parentService.resetPasswordWithTenantCheck(id, tenantId, newPassword); String tempPassword = parentService.resetPasswordAndReturnTemp(id, tenantId);
return Result.success(); return Result.success(java.util.Map.of("tempPassword", tempPassword));
} }
@Operation(summary = "Bind student to parent") @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.common.security.SecurityUtils;
import com.reading.platform.dto.request.StudentCreateRequest; import com.reading.platform.dto.request.StudentCreateRequest;
import com.reading.platform.dto.request.StudentUpdateRequest; 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.StudentResponse;
import com.reading.platform.dto.response.StudentTransferHistoryItemResponse;
import com.reading.platform.entity.Student; import com.reading.platform.entity.Student;
import com.reading.platform.service.ClassService; import com.reading.platform.service.ClassService;
import com.reading.platform.service.StudentService; import com.reading.platform.service.StudentService;
@ -21,6 +23,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map;
@Tag(name = "School - Student", description = "Student Management APIs for School") @Tag(name = "School - Student", description = "Student Management APIs for School")
@RestController @RestController
@ -38,7 +41,9 @@ public class SchoolStudentController {
public Result<StudentResponse> createStudent(@Valid @RequestBody StudentCreateRequest request) { public Result<StudentResponse> createStudent(@Valid @RequestBody StudentCreateRequest request) {
Long tenantId = SecurityUtils.getCurrentTenantId(); Long tenantId = SecurityUtils.getCurrentTenantId();
Student student = studentService.createStudent(tenantId, request); 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") @Operation(summary = "Update student")
@ -80,6 +85,17 @@ public class SchoolStudentController {
return Result.success(PageResult.of(voList, page.getTotal(), page.getCurrent(), page.getSize())); 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") @Operation(summary = "Delete student")
@Log(module = LogModule.STUDENT, type = LogOperationType.DELETE, description = "删除学生") @Log(module = LogModule.STUDENT, type = LogOperationType.DELETE, description = "删除学生")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@ -89,4 +105,13 @@ public class SchoolStudentController {
return Result.success(); 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 = "备注") @Schema(description = "备注")
private String notes; 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") @Schema(description = "主题 ID")
private Long themeId; private Long themeId;
@Schema(description = "主题名称")
private String themeName;
@Schema(description = "绘本名称") @Schema(description = "绘本名称")
private String pictureBookName; private String pictureBookName;

View File

@ -25,6 +25,9 @@ public class PackageFilterMetaResponse {
@Schema(description = "课程配置选项列表(导入课、集体课、健康、科学等)") @Schema(description = "课程配置选项列表(导入课、集体课、健康、科学等)")
private List<LessonTypeOption> lessonTypes; private List<LessonTypeOption> lessonTypes;
@Schema(description = "课程包主题选项列表")
private List<ThemeOption> themes;
/** /**
* 年级选项 * 年级选项
*/ */
@ -59,4 +62,23 @@ public class PackageFilterMetaResponse {
@Schema(description = "包含该类型环节的课程包数量") @Schema(description = "包含该类型环节的课程包数量")
private Integer count; 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.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.reading.platform.dto.request.ClassCreateRequest; import com.reading.platform.dto.request.ClassCreateRequest;
import com.reading.platform.dto.request.ClassUpdateRequest; import com.reading.platform.dto.request.ClassUpdateRequest;
import com.reading.platform.dto.response.StudentTransferHistoryItemResponse;
import com.reading.platform.entity.Clazz; import com.reading.platform.entity.Clazz;
import java.util.List; import java.util.List;
@ -102,4 +103,9 @@ public interface ClassService extends com.baomidou.mybatisplus.extension.service
*/ */
Clazz getPrimaryClassByStudentId(Long studentId); 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 collectionId 套餐ID
* @param grade 年级筛选 * @param grade 年级筛选
* @param lessonType 课程配置筛选INTRODUCTIONCOLLECTIVEHEALTHLANGUAGESCIENCESOCIALART * @param lessonType 课程配置筛选INTRODUCTIONCOLLECTIVEHEALTHLANGUAGESCIENCESOCIALART
* @param themeId 课程包主题筛选
* @param keyword 关键词搜索 * @param keyword 关键词搜索
* @return 课程包列表 * @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); 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); Teacher getTeacherById(Long id);
/**
* 根据 ID 查询教师不存在时返回 null不抛异常
*/
Teacher findTeacherById(Long id);
/** /**
* 根据 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.common.exception.BusinessException;
import com.reading.platform.dto.request.ClassCreateRequest; import com.reading.platform.dto.request.ClassCreateRequest;
import com.reading.platform.dto.request.ClassUpdateRequest; 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.ClassTeacher;
import com.reading.platform.entity.Clazz; import com.reading.platform.entity.Clazz;
import com.reading.platform.entity.StudentClassHistory; import com.reading.platform.entity.StudentClassHistory;
@ -22,6 +23,7 @@ import org.springframework.util.StringUtils;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
/** /**
@ -337,4 +339,54 @@ public class ClassServiceImpl extends com.baomidou.mybatisplus.extension.service
return clazzMapper.selectById(history.getClassId()); 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 @Override
public List<CoursePackageResponse> getPackagesByCollection(Long collectionId, String grade, String lessonType, String keyword) { public List<CoursePackageResponse> getPackagesByCollection(Long collectionId, String grade, String lessonType, Long themeId, String keyword) {
log.info("获取课程套餐的课程包列表筛选collectionId={}, grade={}, lessonType={}, keyword={}", collectionId, grade, lessonType, keyword); log.info("获取课程套餐的课程包列表筛选collectionId={}, grade={}, lessonType={}, themeId={}, keyword={}", collectionId, grade, lessonType, themeId, keyword);
// 查询关联关系 // 查询关联关系
List<CourseCollectionPackage> associations = collectionPackageMapper.selectList( List<CourseCollectionPackage> associations = collectionPackageMapper.selectList(
@ -252,6 +252,11 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
wrapper.apply("JSON_CONTAINS(grade_tags, {0})", "\"" + grade + "\""); wrapper.apply("JSON_CONTAINS(grade_tags, {0})", "\"" + grade + "\"");
} }
// 课程包主题筛选
if (themeId != null) {
wrapper.eq(CoursePackage::getThemeId, themeId);
}
// 关键词搜索 // 关键词搜索
if (StringUtils.hasText(keyword)) { if (StringUtils.hasText(keyword)) {
wrapper.and(w -> w wrapper.and(w -> w
@ -315,6 +320,7 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
return PackageFilterMetaResponse.builder() return PackageFilterMetaResponse.builder()
.grades(new ArrayList<>()) .grades(new ArrayList<>())
.lessonTypes(new ArrayList<>()) .lessonTypes(new ArrayList<>())
.themes(new ArrayList<>())
.build(); .build();
} }
@ -373,9 +379,32 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
.build()) .build())
.collect(Collectors.toList()); .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() return PackageFilterMetaResponse.builder()
.grades(grades) .grades(grades)
.lessonTypes(lessonTypes) .lessonTypes(lessonTypes)
.themes(themes)
.build(); .build();
} }

View File

@ -16,9 +16,11 @@ import com.reading.platform.entity.CourseLesson;
import com.reading.platform.entity.CoursePackage; import com.reading.platform.entity.CoursePackage;
import com.reading.platform.entity.LessonStep; import com.reading.platform.entity.LessonStep;
import com.reading.platform.entity.TenantPackage; import com.reading.platform.entity.TenantPackage;
import com.reading.platform.entity.Theme;
import com.reading.platform.mapper.CourseCollectionPackageMapper; import com.reading.platform.mapper.CourseCollectionPackageMapper;
import com.reading.platform.mapper.CoursePackageMapper; import com.reading.platform.mapper.CoursePackageMapper;
import com.reading.platform.mapper.TenantPackageMapper; import com.reading.platform.mapper.TenantPackageMapper;
import com.reading.platform.mapper.ThemeMapper;
import com.reading.platform.service.CourseLessonService; import com.reading.platform.service.CourseLessonService;
import com.reading.platform.service.CoursePackageService; import com.reading.platform.service.CoursePackageService;
import lombok.RequiredArgsConstructor; 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 com.reading.platform.common.mapper.CoursePackageMapper coursePackageVoMapper;
private final CourseCollectionPackageMapper collectionPackageMapper; private final CourseCollectionPackageMapper collectionPackageMapper;
private final TenantPackageMapper tenantPackageMapper; private final TenantPackageMapper tenantPackageMapper;
private final ThemeMapper themeMapper;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@ -109,6 +112,13 @@ public class CoursePackageServiceImpl extends ServiceImpl<CoursePackageMapper, C
public CourseResponse getCourseByIdWithLessons(Long id) { public CourseResponse getCourseByIdWithLessons(Long id) {
CoursePackage entity = getCourseById(id); CoursePackage entity = getCourseById(id);
CourseResponse response = coursePackageVoMapper.toVO(entity); 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<CourseLesson> lessons = courseLessonService.findByCourseId(id);
List<CourseLessonResponse> lessonResponses = lessons.stream() List<CourseLessonResponse> lessonResponses = lessons.stream()
.map(this::toLessonResponse) .map(this::toLessonResponse)

View File

@ -19,6 +19,8 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; 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); 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 @Override
@Transactional @Transactional
public void bindStudentWithTenantCheck(Long parentId, Long studentId, Long tenantId, String relationship, Boolean isPrimary) { 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); studentMapper.insert(student);
if (request.getClassId() != null) {
classService.assignStudentToClass(student.getId(), request.getClassId(), tenantId);
}
log.info("学生创建成功ID: {}", student.getId()); log.info("学生创建成功ID: {}", student.getId());
return student; return student;
} }

View File

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