Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
b715c9a31c
@ -230,6 +230,17 @@ export function formatPrice(price: number | null | undefined): string {
|
||||
return `¥${(price / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
// 优惠类型映射(与套餐列表、租户选择保持一致)
|
||||
export const DISCOUNT_TYPE_MAP: Record<string, string> = {
|
||||
PERCENTAGE: '折扣',
|
||||
FIXED: '立减',
|
||||
};
|
||||
|
||||
export function getDiscountTypeText(type?: string): string {
|
||||
if (!type) return '-';
|
||||
return DISCOUNT_TYPE_MAP[type] || type;
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
export function formatDate(date: string): string {
|
||||
return new Date(date).toLocaleString('zh-CN');
|
||||
|
||||
@ -52,10 +52,18 @@ export interface LessonTypeOption {
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 筛选元数据 - 课程包主题选项 */
|
||||
export interface ThemeOption {
|
||||
themeId: number;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 筛选元数据响应 */
|
||||
export interface FilterMetaResponse {
|
||||
grades: GradeOption[];
|
||||
lessonTypes: LessonTypeOption[];
|
||||
themes?: ThemeOption[];
|
||||
}
|
||||
|
||||
// ============= API 接口 =============
|
||||
@ -75,6 +83,7 @@ export function getPackages(
|
||||
params?: {
|
||||
grade?: string;
|
||||
lessonType?: string;
|
||||
themeId?: number;
|
||||
keyword?: string;
|
||||
}
|
||||
): Promise<CoursePackage[]> {
|
||||
|
||||
@ -48,6 +48,7 @@ export interface Course {
|
||||
// 新增字段
|
||||
themeId?: number;
|
||||
theme?: { id: number; name: string };
|
||||
themeName?: string;
|
||||
coreContent?: string;
|
||||
coverImagePath?: string;
|
||||
domainTags?: string[];
|
||||
|
||||
@ -211,11 +211,18 @@ export const fileApi = {
|
||||
|
||||
/**
|
||||
* 获取文件URL
|
||||
* 支持:完整 OSS URL、以 / 开头的路径、相对路径
|
||||
*/
|
||||
getFileUrl: (filePath: string): string => {
|
||||
// filePath 格式: /uploads/courses/covers/xxx.png
|
||||
// 直接返回相对路径,由 nginx 或后端静态服务处理
|
||||
getFileUrl: (filePath: string | null | undefined): string => {
|
||||
if (!filePath) return '';
|
||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
||||
return filePath;
|
||||
}
|
||||
const SERVER_BASE = import.meta.env.VITE_SERVER_BASE_URL || '/api';
|
||||
if (filePath.startsWith('/')) {
|
||||
return `${SERVER_BASE}${filePath}`;
|
||||
}
|
||||
return `${SERVER_BASE}/uploads/${filePath}`;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -62,6 +62,8 @@ export interface CourseResponse {
|
||||
environmentConstruction?: string;
|
||||
/** 主题 ID */
|
||||
themeId?: number;
|
||||
/** 主题名称 */
|
||||
themeName?: string;
|
||||
/** 绘本名称 */
|
||||
pictureBookName?: string;
|
||||
/** 封面图片路径 */
|
||||
|
||||
@ -105,8 +105,8 @@ export function createLesson(courseId: number, data: CreateLessonData) {
|
||||
}
|
||||
|
||||
// 更新课程
|
||||
export function updateLesson(lessonId: number, data: Partial<CreateLessonData>) {
|
||||
return http.put(`/v1/admin/courses/0/lessons/${lessonId}`, data);
|
||||
export function updateLesson(courseId: number, lessonId: number, data: Partial<CreateLessonData>) {
|
||||
return http.put(`/v1/admin/courses/${courseId}/lessons/${lessonId}`, data);
|
||||
}
|
||||
|
||||
// 删除课程
|
||||
@ -132,8 +132,8 @@ export function createStep(courseId: number, lessonId: number, data: CreateStepD
|
||||
}
|
||||
|
||||
// 更新环节
|
||||
export function updateStep(stepId: number, data: Partial<CreateStepData>) {
|
||||
return http.put(`/v1/admin/courses/0/lessons/steps/${stepId}`, data);
|
||||
export function updateStep(courseId: number, stepId: number, data: Partial<CreateStepData>) {
|
||||
return http.put(`/v1/admin/courses/${courseId}/lessons/steps/${stepId}`, data);
|
||||
}
|
||||
|
||||
// 删除环节
|
||||
|
||||
@ -126,7 +126,7 @@ import { ref, reactive, watch, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { getThemeList } from '@/api/theme';
|
||||
import { uploadFile } from '@/api/file';
|
||||
import { uploadFile, getFileUrl } from '@/api/file';
|
||||
import type { Theme } from '@/api/theme';
|
||||
|
||||
interface BasicInfoData {
|
||||
@ -206,19 +206,16 @@ watch(
|
||||
if (newVal) {
|
||||
Object.assign(formData, newVal);
|
||||
|
||||
// 处理封面图片
|
||||
if (newVal.coverImagePath && coverImages.value.length === 0) {
|
||||
// 构建正确的图片URL
|
||||
let imageUrl = newVal.coverImagePath;
|
||||
if (!imageUrl.startsWith('http') && !imageUrl.startsWith('/uploads') && !imageUrl.includes('/uploads/')) {
|
||||
imageUrl = `/uploads/${imageUrl}`;
|
||||
}
|
||||
// 处理封面图片回显
|
||||
if (newVal.coverImagePath) {
|
||||
coverImages.value = [{
|
||||
uid: '-1',
|
||||
name: 'cover',
|
||||
status: 'done',
|
||||
url: imageUrl,
|
||||
url: getFileUrl(newVal.coverImagePath),
|
||||
}];
|
||||
} else {
|
||||
coverImages.value = [];
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -255,16 +252,11 @@ const beforeCoverUpload = async (file: any) => {
|
||||
try {
|
||||
const result = await uploadFile(file, 'cover');
|
||||
formData.coverImagePath = result.filePath;
|
||||
// 构建正确的图片URL - 后端返回的filePath已经包含完整路径
|
||||
let imageUrl = result.filePath;
|
||||
if (!imageUrl.startsWith('http') && !imageUrl.startsWith('/uploads') && !imageUrl.includes('/uploads/')) {
|
||||
imageUrl = `/uploads/${imageUrl}`;
|
||||
}
|
||||
coverImages.value = [{
|
||||
uid: file.uid,
|
||||
name: file.name,
|
||||
status: 'done',
|
||||
url: imageUrl,
|
||||
url: getFileUrl(result.filePath),
|
||||
}];
|
||||
handleChange();
|
||||
message.success('封面上传成功');
|
||||
|
||||
@ -133,10 +133,14 @@ const tableData = ref<ScheduleRow[]>([]);
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
if (!newVal || typeof newVal !== 'string') {
|
||||
tableData.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
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,
|
||||
key: row.key || `row_${index}`,
|
||||
}));
|
||||
@ -144,7 +148,6 @@ watch(
|
||||
console.error('解析排课数据失败', e);
|
||||
tableData.value = [];
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
@ -60,6 +60,7 @@ import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import LessonConfigPanel from '@/components/course/LessonConfigPanel.vue';
|
||||
import type { LessonData } from '@/components/course/LessonConfigPanel.vue';
|
||||
import { getLessonByType, createLesson, updateLesson, deleteLesson as deleteLessonApi } from '@/api/lesson';
|
||||
import { parseAssessmentDataForDisplay } from '@/utils/assessmentData';
|
||||
|
||||
interface Props {
|
||||
courseId: number;
|
||||
@ -100,7 +101,7 @@ const fetchLesson = async () => {
|
||||
preparation: lesson.preparation || '',
|
||||
extension: lesson.extension || '',
|
||||
reflection: lesson.reflection || '',
|
||||
assessmentData: lesson.assessmentData || '',
|
||||
assessmentData: parseAssessmentDataForDisplay(lesson.assessmentData),
|
||||
useTemplate: lesson.useTemplate || false,
|
||||
steps: lesson.steps || [],
|
||||
isNew: false,
|
||||
@ -159,10 +160,10 @@ const handleLessonChange = () => {
|
||||
emit('change');
|
||||
};
|
||||
|
||||
// 验证:若配置了导入课,则通过 formRules 校验
|
||||
// 验证:导入课为必填,至少配置一条
|
||||
const validate = async () => {
|
||||
if (!lessonData.value) {
|
||||
return { valid: true, errors: [] as string[], warnings: ['未配置导入课'] };
|
||||
return { valid: false, errors: ['请配置导入课(至少一条)'] };
|
||||
}
|
||||
return configPanelRef.value?.validate() ?? { valid: true, errors: [] as string[] };
|
||||
};
|
||||
|
||||
@ -60,6 +60,7 @@ import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import LessonConfigPanel from '@/components/course/LessonConfigPanel.vue';
|
||||
import type { LessonData } from '@/components/course/LessonConfigPanel.vue';
|
||||
import { getLessonByType, createLesson as createLessonApi, updateLesson, deleteLesson as deleteLessonApi } from '@/api/lesson';
|
||||
import { parseAssessmentDataForDisplay } from '@/utils/assessmentData';
|
||||
|
||||
interface Props {
|
||||
courseId: number;
|
||||
@ -101,7 +102,7 @@ const fetchLesson = async () => {
|
||||
preparation: lesson.preparation || '',
|
||||
extension: lesson.extension || '',
|
||||
reflection: lesson.reflection || '',
|
||||
assessmentData: lesson.assessmentData || '',
|
||||
assessmentData: parseAssessmentDataForDisplay(lesson.assessmentData),
|
||||
useTemplate: lesson.useTemplate || false,
|
||||
steps: lesson.steps || [],
|
||||
isNew: false,
|
||||
@ -160,10 +161,10 @@ const handleLessonChange = () => {
|
||||
emit('change');
|
||||
};
|
||||
|
||||
// 验证:若配置了集体课,则通过 formRules 校验
|
||||
// 验证:集体课为必填,至少配置一条
|
||||
const validate = async () => {
|
||||
if (!lessonData.value) {
|
||||
return { valid: true, errors: [] as string[], warnings: ['未配置集体课'] };
|
||||
return { valid: false, errors: ['请配置集体课(至少一条)'] };
|
||||
}
|
||||
return configPanelRef.value?.validate() ?? { valid: true, errors: [] as string[] };
|
||||
};
|
||||
|
||||
@ -93,6 +93,7 @@ import {
|
||||
import LessonConfigPanel from '@/components/course/LessonConfigPanel.vue';
|
||||
import type { LessonData } from '@/components/course/LessonConfigPanel.vue';
|
||||
import { getLessonList, createLesson, updateLesson, deleteLesson } from '@/api/lesson';
|
||||
import { parseAssessmentDataForDisplay } from '@/utils/assessmentData';
|
||||
|
||||
interface DomainConfig {
|
||||
type: string;
|
||||
@ -204,7 +205,7 @@ const fetchLessons = async () => {
|
||||
preparation: lesson.preparation || '',
|
||||
extension: lesson.extension || '',
|
||||
reflection: lesson.reflection || '',
|
||||
assessmentData: lesson.assessmentData || '',
|
||||
assessmentData: parseAssessmentDataForDisplay(lesson.assessmentData),
|
||||
useTemplate: lesson.useTemplate || false,
|
||||
steps: lesson.steps || [],
|
||||
isNew: false,
|
||||
@ -266,8 +267,13 @@ const handleLessonChange = () => {
|
||||
emit('change');
|
||||
};
|
||||
|
||||
// 验证:若启用某领域,则通过 formRules 校验各领域
|
||||
// 验证:领域课为必填,至少配置一条,且已启用的领域需通过 formRules 校验
|
||||
const validate = async () => {
|
||||
const saveData = getSaveData();
|
||||
if (!saveData || saveData.length === 0) {
|
||||
return { valid: false, errors: ['请配置领域课(至少一条)'] };
|
||||
}
|
||||
|
||||
const enabledDomains = domains.filter((d) => d.enabled);
|
||||
const allErrors: string[] = [];
|
||||
|
||||
|
||||
19
reading-platform-frontend/src/utils/assessmentData.ts
Normal file
19
reading-platform-frontend/src/utils/assessmentData.ts
Normal 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;
|
||||
}
|
||||
@ -208,14 +208,7 @@ const getStatusText = (status: string) => {
|
||||
return collectionsApi.getCollectionStatusInfo(status).label;
|
||||
};
|
||||
|
||||
const getDiscountTypeText = (type?: string) => {
|
||||
if (!type) return '-';
|
||||
const typeMap: Record<string, string> = {
|
||||
PERCENTAGE: '折扣',
|
||||
FIXED: '立减',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
const getDiscountTypeText = collectionsApi.getDiscountTypeText;
|
||||
|
||||
// 删除套餐
|
||||
const handleDelete = async () => {
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
v-model:open="showPackageSelector"
|
||||
title="选择课程包"
|
||||
width="800px"
|
||||
:confirm-loading="addingPackages"
|
||||
@ok="handleAddPackages"
|
||||
>
|
||||
<a-table
|
||||
@ -109,6 +110,18 @@
|
||||
row-key="id"
|
||||
size="small"
|
||||
:loading="loadingPackages"
|
||||
:pagination="{
|
||||
current: selectorPagination.current,
|
||||
pageSize: selectorPagination.pageSize,
|
||||
total: selectorPagination.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
onChange: (page: number, size: number) => {
|
||||
selectorPagination.current = page;
|
||||
selectorPagination.pageSize = size;
|
||||
fetchAvailablePackages();
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'gradeTags'">
|
||||
@ -121,13 +134,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import type { FormInstance } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { getCollectionDetail, createCollection, updateCollection, setCollectionPackages } from '@/api/package';
|
||||
import { getCoursePackageList } from '@/api/package';
|
||||
import { getCollectionDetail, createCollection, updateCollection, setCollectionPackages, getCoursePackageList, getCoursePackage } from '@/api/package';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@ -141,6 +153,8 @@ const loadingPackages = ref(false);
|
||||
const showPackageSelector = ref(false);
|
||||
const availablePackages = ref<any[]>([]);
|
||||
const selectedRowKeys = ref<(number | string)[]>([]);
|
||||
const selectorPagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
const addingPackages = ref(false);
|
||||
|
||||
const formState = ref({
|
||||
name: '',
|
||||
@ -268,9 +282,13 @@ const fetchCollectionDetail = async () => {
|
||||
const fetchAvailablePackages = async () => {
|
||||
loadingPackages.value = true;
|
||||
try {
|
||||
// 获取已发布的课程包列表
|
||||
const res = await getCoursePackageList({ pageNum: 1, pageSize: 100, status: 'PUBLISHED' });
|
||||
const res = await getCoursePackageList({
|
||||
pageNum: selectorPagination.current,
|
||||
pageSize: selectorPagination.pageSize,
|
||||
status: 'PUBLISHED',
|
||||
});
|
||||
availablePackages.value = res.list || [];
|
||||
selectorPagination.total = res.total || 0;
|
||||
} catch (error) {
|
||||
console.error('获取课程包列表失败', error);
|
||||
} finally {
|
||||
@ -278,18 +296,50 @@ const fetchAvailablePackages = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPackages = () => {
|
||||
// 打开选择弹窗时:回显已选课程包,加载第一页数据
|
||||
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 newPackages = availablePackages.value
|
||||
.filter((p) => selectedRowKeys.value.includes(p.id) && !existingIds.has(p.id))
|
||||
.map((p) => ({
|
||||
packageId: p.id,
|
||||
packageName: p.name,
|
||||
gradeLevel: parseGradeTags(p.gradeTags)?.[0] || '小班',
|
||||
sortOrder: selectedPackages.value.length,
|
||||
}));
|
||||
const idsToAdd = selectedRowKeys.value.filter((id) => !existingIds.has(id));
|
||||
if (idsToAdd.length === 0) {
|
||||
showPackageSelector.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
addingPackages.value = true;
|
||||
try {
|
||||
const pkgMap = new Map(availablePackages.value.map((p) => [p.id, p]));
|
||||
const newPackages: { packageId: number | string; packageName: string; gradeLevel: string; sortOrder: number }[] = [];
|
||||
let sortOrder = selectedPackages.value.length;
|
||||
|
||||
for (const id of idsToAdd) {
|
||||
let pkg = pkgMap.get(id);
|
||||
if (!pkg) {
|
||||
try {
|
||||
pkg = await getCoursePackage(id);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
newPackages.push({
|
||||
packageId: pkg.id,
|
||||
packageName: pkg.name,
|
||||
gradeLevel: parseGradeTags(pkg.gradeTags)?.[0] || '小班',
|
||||
sortOrder: sortOrder++,
|
||||
});
|
||||
}
|
||||
|
||||
selectedPackages.value.push(...newPackages);
|
||||
} finally {
|
||||
addingPackages.value = false;
|
||||
}
|
||||
selectedRowKeys.value = [];
|
||||
showPackageSelector.value = false;
|
||||
};
|
||||
@ -374,7 +424,6 @@ const handleCancel = () => {
|
||||
|
||||
onMounted(() => {
|
||||
fetchCollectionDetail();
|
||||
fetchAvailablePackages();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -148,15 +148,7 @@ const columns = [
|
||||
const getStatusColor = (status: string) => collectionsApi.getCollectionStatusInfo(status).color;
|
||||
const getStatusText = (status: string) => collectionsApi.getCollectionStatusInfo(status).label;
|
||||
const formatPrice = (price: number | null | undefined) => collectionsApi.formatPrice(price);
|
||||
|
||||
const getDiscountTypeText = (type?: string) => {
|
||||
if (!type) return '-';
|
||||
const typeMap: Record<string, string> = {
|
||||
PERCENTAGE: '折扣',
|
||||
FIXED: '立减',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
const getDiscountTypeText = collectionsApi.getDiscountTypeText;
|
||||
|
||||
const parseGradeLevels = (gradeLevels: string | string[]) => {
|
||||
return collectionsApi.parseGradeLevels(gradeLevels);
|
||||
|
||||
@ -1,36 +1,27 @@
|
||||
<template>
|
||||
<div class="course-detail-view">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="detail-header">
|
||||
<div class="header-left">
|
||||
<a-button type="text" @click="router.back()">
|
||||
<ArrowLeftOutlined />
|
||||
</a-button>
|
||||
<div class="course-title">
|
||||
<h2>{{ course.name || '课程包详情' }}</h2>
|
||||
<a-tag :style="getStatusStyle(course.status)" style="margin-left: 12px;">
|
||||
{{ translateStatus(course.status) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<a-page-header title="课程包详情" :sub-title="course.name || ''" @back="() => router.back()">
|
||||
<template #extra>
|
||||
<div class="header-actions">
|
||||
<a-button v-if="course.status !== 'PUBLISHED'" @click="editCourse">
|
||||
<a-button v-if="canEdit" @click="editCourse">
|
||||
<EditOutlined /> 编辑
|
||||
</a-button>
|
||||
<a-button @click="viewStats">
|
||||
<BarChartOutlined /> 数据
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-if="course.status === 'DRAFT' || course.status === 'ARCHIVED'"
|
||||
title="确定删除此课程包吗?"
|
||||
@confirm="deleteCourse"
|
||||
>
|
||||
<a-popconfirm v-if="course.status === 'DRAFT' || course.status === 'ARCHIVED'" title="确定删除此课程包吗?"
|
||||
@confirm="deleteCourse">
|
||||
<a-button danger>
|
||||
<DeleteOutlined /> 删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #tags>
|
||||
<a-tag :style="getStatusStyle(course.status)">{{ translateStatus(course.status) }}</a-tag>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="detail-content">
|
||||
@ -223,12 +214,8 @@
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="lesson-cards">
|
||||
<div
|
||||
v-for="lesson in courseLessons"
|
||||
:key="lesson.id"
|
||||
class="lesson-card"
|
||||
:class="'lesson-type-' + lesson.lessonType?.toLowerCase()"
|
||||
>
|
||||
<div v-for="lesson in courseLessons" :key="lesson.id" class="lesson-card"
|
||||
:class="'lesson-type-' + lesson.lessonType?.toLowerCase()">
|
||||
<div class="lesson-header">
|
||||
<div class="lesson-type-badge" :style="{ background: getLessonTypeBgColor(lesson.lessonType) }">
|
||||
{{ translateLessonType(lesson.lessonType) }}
|
||||
@ -254,29 +241,20 @@
|
||||
<div class="lesson-section" v-if="hasLessonResources(lesson)">
|
||||
<div class="lesson-section-title">核心资源</div>
|
||||
<div class="resource-grid">
|
||||
<div
|
||||
v-if="lesson.videoPath"
|
||||
class="resource-item"
|
||||
@click="previewFile(lesson.videoPath, lesson.videoName || '绘本动画')"
|
||||
>
|
||||
<div v-if="lesson.videoPath" class="resource-item"
|
||||
@click="previewFile(lesson.videoPath, lesson.videoName || '绘本动画')">
|
||||
<VideoCameraOutlined class="resource-icon video" />
|
||||
<span class="resource-name">{{ lesson.videoName || '绘本动画' }}</span>
|
||||
<EyeOutlined class="resource-action" />
|
||||
</div>
|
||||
<div
|
||||
v-if="lesson.pptPath"
|
||||
class="resource-item"
|
||||
@click="previewFile(lesson.pptPath, lesson.pptName || '教学课件')"
|
||||
>
|
||||
<div v-if="lesson.pptPath" class="resource-item"
|
||||
@click="previewFile(lesson.pptPath, lesson.pptName || '教学课件')">
|
||||
<FilePptOutlined class="resource-icon ppt" />
|
||||
<span class="resource-name">{{ lesson.pptName || '教学课件' }}</span>
|
||||
<EyeOutlined class="resource-action" />
|
||||
</div>
|
||||
<div
|
||||
v-if="lesson.pdfPath"
|
||||
class="resource-item"
|
||||
@click="previewFile(lesson.pdfPath, lesson.pdfName || '电子绘本')"
|
||||
>
|
||||
<div v-if="lesson.pdfPath" class="resource-item"
|
||||
@click="previewFile(lesson.pdfPath, lesson.pdfName || '电子绘本')">
|
||||
<FilePdfOutlined class="resource-icon pdf" />
|
||||
<span class="resource-name">{{ lesson.pdfName || '电子绘本' }}</span>
|
||||
<EyeOutlined class="resource-action" />
|
||||
@ -333,12 +311,8 @@
|
||||
<VideoCameraOutlined style="color: #722ed1;" /> 视频资源
|
||||
</div>
|
||||
<div class="resource-list">
|
||||
<div
|
||||
v-for="(item, index) in allVideos"
|
||||
:key="'video-' + index"
|
||||
class="resource-item-card"
|
||||
@click="previewFile(item.path, item.name)"
|
||||
>
|
||||
<div v-for="(item, index) in allVideos" :key="'video-' + index" class="resource-item-card"
|
||||
@click="previewFile(item.path, item.name)">
|
||||
<VideoCameraOutlined class="item-icon" style="color: #722ed1;" />
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
<PlayCircleOutlined class="item-action" />
|
||||
@ -352,12 +326,8 @@
|
||||
<AudioOutlined style="color: #52c41a;" /> 音频资源
|
||||
</div>
|
||||
<div class="resource-list">
|
||||
<div
|
||||
v-for="(item, index) in allAudios"
|
||||
:key="'audio-' + index"
|
||||
class="resource-item-card"
|
||||
@click="previewFile(item.path, item.name)"
|
||||
>
|
||||
<div v-for="(item, index) in allAudios" :key="'audio-' + index" class="resource-item-card"
|
||||
@click="previewFile(item.path, item.name)">
|
||||
<AudioOutlined class="item-icon" style="color: #52c41a;" />
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
<PlayCircleOutlined class="item-action" />
|
||||
@ -371,12 +341,8 @@
|
||||
<FileTextOutlined style="color: #1890ff;" /> 文档资源
|
||||
</div>
|
||||
<div class="resource-list">
|
||||
<div
|
||||
v-for="(item, index) in allDocuments"
|
||||
:key="'doc-' + index"
|
||||
class="resource-item-card"
|
||||
@click="previewFile(item.path, item.name)"
|
||||
>
|
||||
<div v-for="(item, index) in allDocuments" :key="'doc-' + index" class="resource-item-card"
|
||||
@click="previewFile(item.path, item.name)">
|
||||
<FilePdfOutlined v-if="item.type === 'pdf'" class="item-icon" style="color: #f5222d;" />
|
||||
<FilePptOutlined v-else-if="item.type === 'ppt'" class="item-icon" style="color: #fa8c16;" />
|
||||
<FileTextOutlined v-else class="item-icon" style="color: #1890ff;" />
|
||||
@ -392,14 +358,8 @@
|
||||
<PictureOutlined style="color: #13c2c2;" /> 图片资源
|
||||
</div>
|
||||
<div class="image-grid">
|
||||
<img
|
||||
v-for="(item, index) in allImages"
|
||||
:key="'img-' + index"
|
||||
:src="getFileUrl(item.path)"
|
||||
:alt="item.name"
|
||||
class="image-thumbnail"
|
||||
@click="previewImage(getFileUrl(item.path))"
|
||||
/>
|
||||
<img v-for="(item, index) in allImages" :key="'img-' + index" :src="getFileUrl(item.path)"
|
||||
:alt="item.name" class="image-thumbnail" @click="previewImage(getFileUrl(item.path))" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -414,11 +374,7 @@
|
||||
</a-modal>
|
||||
|
||||
<!-- 文件预览弹窗 -->
|
||||
<FilePreviewModal
|
||||
v-model:open="previewModalVisible"
|
||||
:file-url="previewFileUrl"
|
||||
:file-name="previewFileName"
|
||||
/>
|
||||
<FilePreviewModal v-model:open="previewModalVisible" :file-url="previewFileUrl" :file-name="previewFileName" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -543,6 +499,12 @@ const domainTags = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// 审核中(待审核)不可编辑,仅草稿/已驳回/已下架可编辑
|
||||
const canEdit = computed(() => {
|
||||
const s = course.value.status;
|
||||
return s === 'DRAFT' || s === 'REJECTED' || s === 'ARCHIVED';
|
||||
});
|
||||
|
||||
// 是否有课程介绍内容
|
||||
const hasIntroContent = computed(() => {
|
||||
return course.value.introSummary || course.value.introHighlights ||
|
||||
@ -823,39 +785,10 @@ const fetchCourseDetail = async () => {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
padding: 24px;
|
||||
@ -1124,9 +1057,17 @@ const fetchCourseDetail = async () => {
|
||||
font-size: 18px;
|
||||
margin-right: 8px;
|
||||
|
||||
&.video { color: #722ed1; }
|
||||
&.ppt { color: #fa8c16; }
|
||||
&.pdf { color: #f5222d; }
|
||||
&.video {
|
||||
color: #722ed1;
|
||||
}
|
||||
|
||||
&.ppt {
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
&.pdf {
|
||||
color: #f5222d;
|
||||
}
|
||||
}
|
||||
|
||||
.resource-name {
|
||||
|
||||
@ -1,21 +1,13 @@
|
||||
<template>
|
||||
<div class="course-edit-view">
|
||||
<div class="sticky-header">
|
||||
<a-page-header
|
||||
:title="isEdit ? '编辑课程包' : '创建课程包'"
|
||||
@back="() => router.back()"
|
||||
>
|
||||
<a-page-header :title="isEdit ? '编辑课程包' : '创建课程包'" @back="() => router.back()">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="handleSaveDraft" :loading="saving">保存草稿</a-button>
|
||||
<a-button v-if="currentStep > 0" @click="prevStep">上一步</a-button>
|
||||
<a-button v-if="currentStep < 6" type="primary" @click="nextStep">下一步</a-button>
|
||||
<a-button
|
||||
v-if="currentStep === 6"
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
<a-button v-if="currentStep === 6" type="primary" :loading="saving" @click="() => handleSave(false)">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</a-button>
|
||||
<a-button v-else-if="isEdit" type="primary" @click="handleSaveAndSubmit" :loading="saving">
|
||||
@ -48,62 +40,32 @@
|
||||
<!-- 步骤内容 -->
|
||||
<div class="step-content">
|
||||
<!-- 步骤1:基本信息 -->
|
||||
<Step1BasicInfo
|
||||
v-show="currentStep === 0"
|
||||
ref="step1Ref"
|
||||
v-model="formData.basic"
|
||||
@change="handleDataChange"
|
||||
/>
|
||||
<Step1BasicInfo v-show="currentStep === 0" ref="step1Ref" v-model="formData.basic"
|
||||
@change="handleDataChange" />
|
||||
|
||||
<!-- 步骤2:课程介绍 -->
|
||||
<Step2CourseIntro
|
||||
v-show="currentStep === 1"
|
||||
ref="step2Ref"
|
||||
v-model="formData.intro"
|
||||
@change="handleDataChange"
|
||||
/>
|
||||
<Step2CourseIntro v-show="currentStep === 1" ref="step2Ref" v-model="formData.intro"
|
||||
@change="handleDataChange" />
|
||||
|
||||
<!-- 步骤3:排课参考 -->
|
||||
<Step3ScheduleRef
|
||||
v-show="currentStep === 2"
|
||||
ref="step3Ref"
|
||||
v-model="formData.scheduleRefData"
|
||||
@change="handleDataChange"
|
||||
/>
|
||||
<Step3ScheduleRef v-show="currentStep === 2" ref="step3Ref" v-model="formData.scheduleRefData"
|
||||
@change="handleDataChange" />
|
||||
|
||||
<!-- 步骤4:导入课 -->
|
||||
<Step4IntroLesson
|
||||
v-show="currentStep === 3"
|
||||
ref="step4Ref"
|
||||
:course-id="courseId"
|
||||
@change="handleDataChange"
|
||||
/>
|
||||
<Step4IntroLesson v-show="currentStep === 3" ref="step4Ref" :course-id="courseId"
|
||||
@change="handleDataChange" />
|
||||
|
||||
<!-- 步骤5:集体课 -->
|
||||
<Step5CollectiveLesson
|
||||
v-show="currentStep === 4"
|
||||
ref="step5Ref"
|
||||
:course-id="courseId"
|
||||
:course-name="formData.basic.name"
|
||||
@change="handleDataChange"
|
||||
/>
|
||||
<Step5CollectiveLesson v-show="currentStep === 4" ref="step5Ref" :course-id="courseId"
|
||||
:course-name="formData.basic.name" @change="handleDataChange" />
|
||||
|
||||
<!-- 步骤6:领域课 -->
|
||||
<Step6DomainLessons
|
||||
v-show="currentStep === 5"
|
||||
ref="step6Ref"
|
||||
:course-id="courseId"
|
||||
:course-name="formData.basic.name"
|
||||
@change="handleDataChange"
|
||||
/>
|
||||
<Step6DomainLessons v-show="currentStep === 5" ref="step6Ref" :course-id="courseId"
|
||||
:course-name="formData.basic.name" @change="handleDataChange" />
|
||||
|
||||
<!-- 步骤7:环创建设 -->
|
||||
<Step7Environment
|
||||
v-show="currentStep === 6"
|
||||
ref="step7Ref"
|
||||
v-model="formData.environmentConstruction"
|
||||
@change="handleDataChange"
|
||||
/>
|
||||
<Step7Environment v-show="currentStep === 6" ref="step7Ref" v-model="formData.environmentConstruction"
|
||||
@change="handleDataChange" />
|
||||
</div>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
@ -224,6 +186,12 @@ const fetchCourseDetail = async () => {
|
||||
router.push(`/admin/packages/${courseId.value}`);
|
||||
return;
|
||||
}
|
||||
// 审核中(待审核)的课程包不允许编辑
|
||||
if (course?.status === 'PENDING') {
|
||||
message.warning('审核中的课程包不允许编辑,请等待审核完成或先撤销审核');
|
||||
router.push(`/admin/packages/${courseId.value}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 基本信息
|
||||
formData.basic.name = course.name;
|
||||
@ -231,8 +199,8 @@ const fetchCourseDetail = async () => {
|
||||
formData.basic.grades = Array.isArray(course.gradeTags) ? course.gradeTags : (course.gradeTags ? JSON.parse(course.gradeTags) : []);
|
||||
formData.basic.pictureBookName = course.pictureBookName || '';
|
||||
formData.basic.coreContent = course.coreContent || '';
|
||||
formData.basic.duration = course.duration || 25;
|
||||
formData.basic.domainTags = course.domainTags ? JSON.parse(course.domainTags) : [];
|
||||
formData.basic.duration = course.durationMinutes ?? course.duration ?? 25;
|
||||
formData.basic.domainTags = Array.isArray(course.domainTags) ? course.domainTags : (course.domainTags ? JSON.parse(course.domainTags || '[]') : []);
|
||||
formData.basic.coverImagePath = course.coverImagePath || '';
|
||||
|
||||
// 课程介绍
|
||||
@ -280,18 +248,26 @@ const nextStep = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 校验:导入课、集体课、领域课至少配置一种
|
||||
const validateAtLeastOneLesson = (): boolean => {
|
||||
// 校验:导入课、集体课、领域课各必填一条
|
||||
const validateAllThreeLessons = (): boolean => {
|
||||
const hasIntro = !!step4Ref.value?.lessonData;
|
||||
const hasCollective = !!step5Ref.value?.lessonData;
|
||||
const domainData = step6Ref.value?.getSaveData?.() || [];
|
||||
const hasDomain = Array.isArray(domainData) && domainData.length > 0;
|
||||
|
||||
if (hasIntro || hasCollective || hasDomain) {
|
||||
return true;
|
||||
}
|
||||
message.warning('请至少配置一种课程:导入课、集体课或领域课(至少完成一个领域)');
|
||||
if (!hasIntro) {
|
||||
message.warning('请配置导入课(至少一节)');
|
||||
return false;
|
||||
}
|
||||
if (!hasCollective) {
|
||||
message.warning('请配置集体课(至少一节)');
|
||||
return false;
|
||||
}
|
||||
if (!hasDomain) {
|
||||
message.warning('请配置领域课(至少一节)');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 验证当前步骤(覆盖全部 7 步)
|
||||
@ -308,9 +284,9 @@ const validateCurrentStep = async (): Promise<boolean> => {
|
||||
];
|
||||
const ref = stepRefs[step]?.value;
|
||||
if (!ref?.validate) {
|
||||
// 步骤 5(领域课)、步骤 6(环创建设)需额外校验「至少一种课程」
|
||||
// 步骤 5(领域课)、步骤 6(环创建设)需额外校验「导入课、集体课、领域课各必填一条」
|
||||
if (step === 5 || step === 6) {
|
||||
return validateAtLeastOneLesson();
|
||||
return validateAllThreeLessons();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@ -321,9 +297,9 @@ const validateCurrentStep = async (): Promise<boolean> => {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 步骤 5(领域课)、步骤 6(环创建设)需额外校验「至少一种课程」
|
||||
// 步骤 5(领域课)、步骤 6(环创建设)需额外校验「导入课、集体课、领域课各必填一条」
|
||||
if (step === 5 || step === 6) {
|
||||
return validateAtLeastOneLesson();
|
||||
return validateAllThreeLessons();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@ -384,13 +360,7 @@ const handleSave = async (isDraft = false) => {
|
||||
console.log('Course updated successfully');
|
||||
} else {
|
||||
const res = await createCourse(courseData) as any;
|
||||
console.log('🔍 创建课程返回结果:', JSON.stringify(res, null, 2));
|
||||
savedCourseId = res?.id || res?.data?.id; // 响应拦截器已返回 data.data,但也兼容直接返回完整响应
|
||||
console.log('Course created with ID:', savedCourseId);
|
||||
// 更新路由以支持后续保存
|
||||
if (savedCourseId) {
|
||||
router.replace(`/admin/packages/${savedCourseId}/edit`);
|
||||
}
|
||||
savedCourseId = res?.id ?? res?.data?.id; // 响应拦截器已返回 data.data,但也兼容直接返回完整响应
|
||||
}
|
||||
|
||||
if (!savedCourseId) {
|
||||
@ -433,18 +403,10 @@ const handleSave = async (isDraft = false) => {
|
||||
// 继续保存其他内容,不中断
|
||||
}
|
||||
|
||||
console.log('✅ 所有课程数据保存完成,准备显示成功提示...');
|
||||
message.success(isDraft ? '草稿保存成功' : (isEdit.value ? '保存成功' : '创建成功'));
|
||||
console.log('✅ 成功提示已显示,准备跳转...');
|
||||
|
||||
if (!isDraft) {
|
||||
console.log('🚀 准备跳转到课程列表页面...');
|
||||
console.log('🚀 isDraft =', isDraft, ', isEdit =', isEdit.value);
|
||||
// 确保所有异步操作完成后再跳转
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
console.log('🚀 即将执行 router.push 跳转...');
|
||||
await router.push('/admin/packages');
|
||||
console.log('✅ 已执行 router.push 跳转');
|
||||
await router.replace('/admin/packages');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Save failed:', error);
|
||||
@ -457,7 +419,8 @@ const handleSave = async (isDraft = false) => {
|
||||
};
|
||||
|
||||
// 保存单个课程
|
||||
const saveLesson = async (courseId: number, lessonData: any, lessonType: string) => {
|
||||
const saveLesson = async (courseId: number | string, lessonData: any, lessonType: string) => {
|
||||
const cid = Number(courseId);
|
||||
if (!lessonData) {
|
||||
console.log('No lesson data to save for type:', lessonType);
|
||||
return;
|
||||
@ -486,15 +449,10 @@ const saveLesson = async (courseId: number, lessonData: any, lessonType: string)
|
||||
|
||||
try {
|
||||
if (lessonData.isNew || !lessonData.id) {
|
||||
// 创建新课程
|
||||
console.log('Creating new lesson:', lessonType);
|
||||
const res = await createLesson(courseId, lessonPayload) as any;
|
||||
const res = await createLesson(cid, lessonPayload) as any;
|
||||
lessonId = res.data?.id || res.id;
|
||||
console.log('Lesson created with ID:', lessonId);
|
||||
} else {
|
||||
// 更新现有课程
|
||||
console.log('Updating lesson:', lessonId);
|
||||
await updateLesson(lessonData.id, lessonPayload);
|
||||
await updateLesson(cid, lessonData.id, lessonPayload);
|
||||
}
|
||||
|
||||
// 保存教学环节
|
||||
@ -509,9 +467,9 @@ const saveLesson = async (courseId: number, lessonData: any, lessonType: string)
|
||||
|
||||
try {
|
||||
if (step.isNew || !step.id) {
|
||||
await createStep(courseId, lessonId, stepPayload);
|
||||
await createStep(cid, lessonId, stepPayload);
|
||||
} else {
|
||||
await updateStep(step.id, stepPayload);
|
||||
await updateStep(cid, step.id, stepPayload);
|
||||
}
|
||||
} catch (stepError: any) {
|
||||
console.error('Failed to save step:', stepError);
|
||||
|
||||
@ -53,6 +53,10 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'theme'">
|
||||
<span>{{ record.themeName || record.theme?.name || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'pictureBook'">
|
||||
{{ record.pictureBookName }}
|
||||
</template>
|
||||
@ -144,11 +148,11 @@
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<!-- 已下架状态 -->
|
||||
<!-- 已下架状态:需重新提交审核后才能发布 -->
|
||||
<template v-else-if="record.status === 'ARCHIVED'">
|
||||
<a-button size="small" @click="viewCourse(record.id)">查看</a-button>
|
||||
<a-button size="small" @click="viewStats(record.id)">数据</a-button>
|
||||
<a-button type="primary" size="small" @click="republishCourse(record.id)">重新发布</a-button>
|
||||
<a-button type="primary" size="small" @click="submitForReview(record)">提交审核</a-button>
|
||||
<a-popconfirm title="确定删除此课程包吗?删除后无法恢复" @confirm="deleteCourseHandler(record.id)">
|
||||
<a-button size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
@ -239,6 +243,7 @@ const pagination = reactive({
|
||||
|
||||
const columns = [
|
||||
{ title: '课程包名称', key: 'name', width: 250 },
|
||||
{ title: '课程主题', key: 'theme', width: 120 },
|
||||
{ title: '关联绘本', key: 'pictureBook', width: 120 },
|
||||
{ title: '课程配置', key: 'lessonConfig', width: 200 },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
@ -489,23 +494,6 @@ const unpublishCourse = async (id: number) => {
|
||||
});
|
||||
};
|
||||
|
||||
// 重新发布
|
||||
const republishCourse = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认重新发布',
|
||||
content: '重新发布后所有活跃租户将可以查看并使用此课程包,确认继续?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await courseApi.republishCourse(id);
|
||||
message.success('重新发布成功');
|
||||
fetchCourses();
|
||||
} catch (error) {
|
||||
message.error('重新发布失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const iterateCourse = (id: number) => {
|
||||
router.push(`/admin/packages/${id}/iterate`);
|
||||
};
|
||||
|
||||
@ -84,7 +84,7 @@
|
||||
</template>
|
||||
|
||||
<a-spin :spinning="loadingDetail">
|
||||
<div v-if="currentCourse" class="review-content">
|
||||
<a-form v-if="currentCourse" ref="reviewFormRef" :model="formState" layout="vertical" class="review-content">
|
||||
<!-- 自动检查项 -->
|
||||
<a-alert v-if="validationResult" :type="validationResult.valid ? 'success' : 'warning'"
|
||||
:message="validationResult.valid ? '自动检查通过' : '自动检查有警告'" style="margin-bottom: 16px;">
|
||||
@ -119,8 +119,11 @@
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 人工审核检查项 -->
|
||||
<a-card title="人工审核项" size="small" style="margin-bottom: 16px;">
|
||||
<a-checkbox-group v-model:value="reviewChecklist" style="width: 100%;">
|
||||
<a-form-item name="reviewChecklist" :rules="formRules.reviewChecklist" style="margin-bottom: 16px;">
|
||||
<template #label>人工审核项
|
||||
</template>
|
||||
<a-card size="small">
|
||||
<a-checkbox-group v-model:value="formState.reviewChecklist" style="width: 100%;">
|
||||
<a-row>
|
||||
<a-col :span="24" style="margin-bottom: 8px;">
|
||||
<a-checkbox value="teaching">教学科学性符合要求</a-checkbox>
|
||||
@ -137,15 +140,16 @@
|
||||
</a-row>
|
||||
</a-checkbox-group>
|
||||
</a-card>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 审核意见 -->
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="审核意见" required>
|
||||
<a-textarea v-model:value="reviewComment" placeholder="请输入审核意见(驳回时必填,通过时可选)"
|
||||
<a-form-item name="reviewComment" :rules="formRules.reviewComment">
|
||||
<template #label> 审核意见
|
||||
</template>
|
||||
<a-textarea v-model:value="formState.reviewComment" placeholder="请输入审核意见(驳回时必填,通过时可选)"
|
||||
:auto-size="{ minRows: 3, maxRows: 6 }" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
|
||||
@ -159,6 +163,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import type { FormInstance } from 'ant-design-vue';
|
||||
import { ReloadOutlined, CheckOutlined, WarningOutlined } from '@ant-design/icons-vue';
|
||||
import * as courseApi from '@/api/course';
|
||||
import {
|
||||
@ -207,8 +212,21 @@ const reviewModalVisible = ref(false);
|
||||
const reviewing = ref(false);
|
||||
const currentCourse = ref<any>(null);
|
||||
const validationResult = ref<courseApi.ValidationResult | null>(null);
|
||||
const reviewChecklist = ref<string[]>([]);
|
||||
const reviewComment = ref('');
|
||||
const reviewFormRef = ref<FormInstance>();
|
||||
const formState = reactive({
|
||||
reviewChecklist: [] as string[],
|
||||
reviewComment: '',
|
||||
});
|
||||
|
||||
// 表单校验规则
|
||||
const formRules: Record<string, object[]> = {
|
||||
reviewChecklist: [
|
||||
{ required: true, type: 'array', min: 4, message: '请完成所有审核检查项' },
|
||||
],
|
||||
reviewComment: [
|
||||
{ required: true, message: '请填写驳回原因' },
|
||||
],
|
||||
};
|
||||
|
||||
// 驳回原因
|
||||
const rejectReasonVisible = ref(false);
|
||||
@ -249,8 +267,8 @@ const handleTableChange = (pag: any) => {
|
||||
|
||||
const showReviewModal = async (record: any) => {
|
||||
currentCourse.value = record;
|
||||
reviewChecklist.value = [];
|
||||
reviewComment.value = '';
|
||||
formState.reviewChecklist = [];
|
||||
formState.reviewComment = '';
|
||||
validationResult.value = null;
|
||||
reviewModalVisible.value = true;
|
||||
|
||||
@ -272,16 +290,17 @@ const closeReviewModal = () => {
|
||||
};
|
||||
|
||||
const approveCourse = async () => {
|
||||
if (reviewChecklist.value.length < 4) {
|
||||
message.warning('请完成所有审核检查项');
|
||||
try {
|
||||
await reviewFormRef.value?.validateFields(['reviewChecklist']);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
reviewing.value = true;
|
||||
try {
|
||||
await courseApi.approveCourse(currentCourse.value.id, {
|
||||
checklist: reviewChecklist.value,
|
||||
comment: reviewComment.value || '审核通过',
|
||||
checklist: formState.reviewChecklist,
|
||||
comment: formState.reviewComment || '审核通过',
|
||||
});
|
||||
message.success('审核通过,课程已发布');
|
||||
closeReviewModal();
|
||||
@ -294,16 +313,16 @@ const approveCourse = async () => {
|
||||
};
|
||||
|
||||
const rejectCourse = async () => {
|
||||
if (!reviewComment.value.trim()) {
|
||||
message.warning('请填写驳回原因');
|
||||
try {
|
||||
await reviewFormRef.value?.validateFields(['reviewComment']);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
reviewing.value = true;
|
||||
try {
|
||||
await courseApi.rejectCourse(currentCourse.value.id, {
|
||||
checklist: reviewChecklist.value,
|
||||
comment: reviewComment.value,
|
||||
comment: formState.reviewComment,
|
||||
});
|
||||
message.success('已驳回');
|
||||
closeReviewModal();
|
||||
@ -365,5 +384,9 @@ const formatDate = (date: string | Date) => {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.required-asterisk {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -94,6 +94,7 @@ import { useRouter, useRoute } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { getCollectionDetail, submitCollection, publishCollection, archiveCollection } from '@/api/package';
|
||||
import type { CourseCollection } from '@/api/package';
|
||||
import * as collectionsApi from '@/api/collections';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@ -128,15 +129,7 @@ const statusTexts: Record<string, string> = {
|
||||
const getStatusColor = (status: string) => statusColors[status] || 'default';
|
||||
const getStatusText = (status: string) => statusTexts[status] || status;
|
||||
|
||||
const discountTypeTexts: Record<string, string> = {
|
||||
PERCENTAGE: '折扣',
|
||||
FIXED: '立减',
|
||||
};
|
||||
|
||||
const getDiscountTypeText = (type?: string) => {
|
||||
if (!type) return '-';
|
||||
return discountTypeTexts[type] || type;
|
||||
};
|
||||
const getDiscountTypeText = collectionsApi.getDiscountTypeText;
|
||||
|
||||
const formatDate = (date?: string) => {
|
||||
if (!date) return '-';
|
||||
|
||||
@ -103,6 +103,7 @@
|
||||
v-model:open="showPackageSelector"
|
||||
title="选择课程包"
|
||||
width="800px"
|
||||
:confirm-loading="addingPackages"
|
||||
@ok="handleAddPackages"
|
||||
>
|
||||
<a-table
|
||||
@ -112,6 +113,18 @@
|
||||
row-key="id"
|
||||
size="small"
|
||||
:loading="loadingPackages"
|
||||
:pagination="{
|
||||
current: selectorPagination.current,
|
||||
pageSize: selectorPagination.pageSize,
|
||||
total: selectorPagination.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
onChange: (page: number, size: number) => {
|
||||
selectorPagination.current = page;
|
||||
selectorPagination.pageSize = size;
|
||||
fetchAvailablePackages();
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'gradeTags'">
|
||||
@ -129,8 +142,7 @@ import { useRouter, useRoute } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import type { FormInstance } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { getCollectionDetail, createCollection, updateCollection, setCollectionPackages } from '@/api/package';
|
||||
import { getCoursePackageList } from '@/api/package';
|
||||
import { getCollectionDetail, createCollection, updateCollection, setCollectionPackages, getCoursePackageList, getCoursePackage } from '@/api/package';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@ -144,6 +156,8 @@ const loadingPackages = ref(false);
|
||||
const showPackageSelector = ref(false);
|
||||
const availablePackages = ref<any[]>([]);
|
||||
const selectedRowKeys = ref<(number | string)[]>([]);
|
||||
const selectorPagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
const addingPackages = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
@ -280,9 +294,13 @@ const fetchPackageDetail = async () => {
|
||||
const fetchAvailablePackages = async () => {
|
||||
loadingPackages.value = true;
|
||||
try {
|
||||
// 获取已发布的课程包列表
|
||||
const res = await getCoursePackageList({ pageNum: 1, pageSize: 100, status: 'PUBLISHED' });
|
||||
const res = await getCoursePackageList({
|
||||
pageNum: selectorPagination.current,
|
||||
pageSize: selectorPagination.pageSize,
|
||||
status: 'PUBLISHED',
|
||||
});
|
||||
availablePackages.value = res.list || [];
|
||||
selectorPagination.total = res.total || 0;
|
||||
} catch (error) {
|
||||
console.error('获取课程包列表失败', error);
|
||||
} finally {
|
||||
@ -290,21 +308,51 @@ const fetchAvailablePackages = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPackages = () => {
|
||||
const existingIds = new Set(selectedPackages.value.map((p) => p.packageId));
|
||||
const newPackages = availablePackages.value
|
||||
.filter((p) => selectedRowKeys.value.includes(p.id) && !existingIds.has(p.id))
|
||||
.map((p) => {
|
||||
const tags = parseGradeTags(p.gradeTags);
|
||||
return {
|
||||
packageId: p.id,
|
||||
packageName: p.name,
|
||||
gradeLevels: Array.isArray(tags) && tags.length > 0 ? tags : ['小班'],
|
||||
sortOrder: selectedPackages.value.length,
|
||||
};
|
||||
// 打开选择弹窗时:回显已选课程包,加载第一页数据
|
||||
watch(showPackageSelector, (visible) => {
|
||||
if (visible) {
|
||||
selectedRowKeys.value = selectedPackages.value.map((p) => p.packageId);
|
||||
selectorPagination.current = 1;
|
||||
fetchAvailablePackages();
|
||||
}
|
||||
});
|
||||
|
||||
const handleAddPackages = async () => {
|
||||
const existingIds = new Set(selectedPackages.value.map((p) => p.packageId));
|
||||
const idsToAdd = selectedRowKeys.value.filter((id) => !existingIds.has(id));
|
||||
if (idsToAdd.length === 0) {
|
||||
showPackageSelector.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
addingPackages.value = true;
|
||||
try {
|
||||
const pkgMap = new Map(availablePackages.value.map((p) => [p.id, p]));
|
||||
const newPackages: { packageId: number | string; packageName: string; gradeLevels: string[]; sortOrder: number }[] = [];
|
||||
let sortOrder = selectedPackages.value.length;
|
||||
|
||||
for (const id of idsToAdd) {
|
||||
let pkg = pkgMap.get(id);
|
||||
if (!pkg) {
|
||||
try {
|
||||
pkg = await getCoursePackage(id);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const tags = parseGradeTags(pkg.gradeTags);
|
||||
newPackages.push({
|
||||
packageId: pkg.id,
|
||||
packageName: pkg.name,
|
||||
gradeLevels: Array.isArray(tags) && tags.length > 0 ? tags : ['小班'],
|
||||
sortOrder: sortOrder++,
|
||||
});
|
||||
}
|
||||
|
||||
selectedPackages.value.push(...newPackages);
|
||||
} finally {
|
||||
addingPackages.value = false;
|
||||
}
|
||||
selectedRowKeys.value = [];
|
||||
showPackageSelector.value = false;
|
||||
};
|
||||
@ -384,7 +432,6 @@ const handleSave = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
fetchPackageDetail();
|
||||
fetchAvailablePackages();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -143,6 +143,7 @@ import { message } from 'ant-design-vue';
|
||||
import { PlusOutlined, AuditOutlined } from '@ant-design/icons-vue';
|
||||
import { getCollectionList, deleteCollection, submitCollection, publishCollection, archiveCollection } from '@/api/package';
|
||||
import type { CourseCollection } from '@/api/package';
|
||||
import * as collectionsApi from '@/api/collections';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@ -192,14 +193,7 @@ const statusTexts: Record<string, string> = {
|
||||
const getStatusColor = (status: string) => statusColors[status] || 'default';
|
||||
const getStatusText = (status: string) => statusTexts[status] || status;
|
||||
|
||||
const getDiscountTypeText = (type?: string) => {
|
||||
if (!type) return '-';
|
||||
const typeMap: Record<string, string> = {
|
||||
PERCENTAGE: '折扣',
|
||||
FIXED: '立减',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
const getDiscountTypeText = collectionsApi.getDiscountTypeText;
|
||||
|
||||
const parseGradeLevels = (gradeLevels: string | string[] | undefined): string[] => {
|
||||
if (!gradeLevels) return [];
|
||||
|
||||
@ -151,7 +151,7 @@
|
||||
:key="pkg.id"
|
||||
:value="pkg.id"
|
||||
>
|
||||
{{ pkg.name }} ({{ formatPackagePrice(pkg.discountPrice || pkg.price) }}元)
|
||||
{{ pkg.name }} ({{ formatPackagePrice(pkg.discountPrice || pkg.price) }}元{{ pkg.discountType ? ' ' + getDiscountTypeText(pkg.discountType) : '' }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
@ -185,7 +185,7 @@
|
||||
:key="pkg.id"
|
||||
:value="pkg.id"
|
||||
>
|
||||
{{ pkg.name }} ({{ formatPackagePrice(pkg.discountPrice || pkg.price) }}元)
|
||||
{{ pkg.name }} ({{ formatPackagePrice(pkg.discountPrice || pkg.price) }}元{{ pkg.discountType ? ' ' + getDiscountTypeText(pkg.discountType) : '' }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
@ -308,7 +308,7 @@
|
||||
</a-drawer>
|
||||
|
||||
<!-- 重置密码确认模态框 -->
|
||||
<a-modal v-model:open="resetPasswordVisible" @ok="confirmResetPassword" :confirm-loading="resetting" :width="400">
|
||||
<a-modal v-model:open="resetPasswordVisible" @ok="confirmResetPassword" :confirm-loading="resetting" :width="400" okText="重置">
|
||||
<template #title>
|
||||
<span class="modal-title">
|
||||
<KeyOutlined class="modal-title-icon" />
|
||||
@ -360,6 +360,7 @@ import {
|
||||
type UpdateTenantDto,
|
||||
type CourseCollectionResponse,
|
||||
} from '@/api/admin';
|
||||
import { getDiscountTypeText } from '@/api/collections';
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
@ -871,6 +872,15 @@ onMounted(() => {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.password-display :deep(.ant-typography),
|
||||
.password-display :deep(.ant-typography-copy) {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.password-display :deep(.ant-typography-copy-icon) {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* Modal title styling */
|
||||
.modal-title {
|
||||
display: flex;
|
||||
|
||||
@ -59,19 +59,31 @@
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<!-- 课程包主题筛选 -->
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">课程包主题:</span>
|
||||
<a-select v-model:value="selectedThemeId" placeholder="全部课程包主题" style="width: 180px" allowClear
|
||||
@change="loadPackages">
|
||||
<a-select-option :value="undefined">全部课程包主题</a-select-option>
|
||||
<a-select-option v-for="opt in (filterMeta.themes || [])" :key="opt.themeId" :value="opt.themeId">
|
||||
{{ opt.name }} ({{ opt.count }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<!-- 课程配置筛选 -->
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">课程配置:</span>
|
||||
<a-select v-model:value="selectedLessonType" placeholder="全部课程配置" style="width: 180px" allowClear
|
||||
@change="loadPackages">
|
||||
<a-select-option :value="undefined">全部课程配置</a-select-option>
|
||||
<a-select-option v-for="opt in (filterMeta.lessonTypes || [])" :key="opt.lessonType"
|
||||
<a-select-option v-for="opt in filteredLessonTypes" :key="opt.lessonType"
|
||||
:value="opt.lessonType">
|
||||
{{ opt.name }} ({{ opt.count }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 搜索 -->
|
||||
<div class="filter-group search-group">
|
||||
<a-input-search v-model:value="searchKeyword" placeholder="搜索课程包..." style="width: 220px" allowClear
|
||||
@ -137,7 +149,14 @@ const selectedCollection = computed(() =>
|
||||
);
|
||||
|
||||
// 筛选元数据
|
||||
const filterMeta = ref<FilterMetaResponse>({ grades: [], themes: [] });
|
||||
const filterMeta = ref<FilterMetaResponse>({ grades: [], lessonTypes: [], themes: [] });
|
||||
|
||||
// 课程配置筛选选项(过滤导入课、集体课)
|
||||
const EXCLUDED_LESSON_TYPES = new Set(['INTRODUCTION', 'INTRO', 'COLLECTIVE']);
|
||||
const filteredLessonTypes = computed(() => {
|
||||
const list = filterMeta.value.lessonTypes || [];
|
||||
return list.filter(opt => !EXCLUDED_LESSON_TYPES.has((opt.lessonType || '').toUpperCase()));
|
||||
});
|
||||
|
||||
// 课程包列表
|
||||
const packages = ref<CoursePackage[]>([]);
|
||||
@ -176,6 +195,7 @@ const selectCollection = async (collection: CourseCollection) => {
|
||||
// 重置筛选条件
|
||||
selectedGrade.value = '';
|
||||
selectedLessonType.value = undefined;
|
||||
selectedThemeId.value = undefined;
|
||||
searchKeyword.value = '';
|
||||
descExpanded.value = false;
|
||||
|
||||
@ -205,7 +225,7 @@ const loadFilterMeta = async () => {
|
||||
filterMeta.value = await getFilterMeta(selectedCollectionId.value);
|
||||
} catch (error) {
|
||||
console.error('获取筛选元数据失败', error);
|
||||
filterMeta.value = { grades: [], lessonTypes: [] };
|
||||
filterMeta.value = { grades: [], lessonTypes: [], themes: [] };
|
||||
}
|
||||
};
|
||||
|
||||
@ -217,6 +237,7 @@ const loadPackages = async () => {
|
||||
packages.value = await getPackages(selectedCollectionId.value, {
|
||||
grade: selectedGrade.value || undefined,
|
||||
lessonType: selectedLessonType.value,
|
||||
themeId: selectedThemeId.value,
|
||||
keyword: searchKeyword.value || undefined,
|
||||
});
|
||||
} catch (error: any) {
|
||||
@ -227,8 +248,8 @@ const loadPackages = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 监听年级、课程配置变化
|
||||
watch([selectedGrade, selectedLessonType], () => {
|
||||
// 监听年级、课程配置、主题变化
|
||||
watch([selectedGrade, selectedLessonType, selectedThemeId], () => {
|
||||
loadPackages();
|
||||
});
|
||||
|
||||
|
||||
@ -88,13 +88,14 @@ const gradeText = computed(() => {
|
||||
return grades.join(' · ');
|
||||
});
|
||||
|
||||
// 从 courses 提取课程类型列表(去重,与管理端一致)
|
||||
// 从 courses 提取课程类型列表(去重,过滤导入课、集体课)
|
||||
const EXCLUDED_LESSON_TYPES = new Set(['INTRODUCTION', 'INTRO', 'COLLECTIVE']);
|
||||
const lessonTypes = computed(() => {
|
||||
const courses = props.pkg.courses || [];
|
||||
const types = new Set<string>();
|
||||
for (const c of courses) {
|
||||
const t = c.lessonType;
|
||||
if (t) types.add(t);
|
||||
if (t && !EXCLUDED_LESSON_TYPES.has(t.toUpperCase())) types.add(t);
|
||||
}
|
||||
return Array.from(types);
|
||||
});
|
||||
|
||||
@ -275,7 +275,7 @@
|
||||
</a-modal>
|
||||
|
||||
<!-- 重置密码确认模态框 -->
|
||||
<a-modal v-model:open="resetPasswordVisible" @ok="confirmResetPassword" :confirm-loading="resetting" :width="400">
|
||||
<a-modal v-model:open="resetPasswordVisible" @ok="confirmResetPassword" :confirm-loading="resetting" :width="400" okText="重置">
|
||||
<template #title>
|
||||
<span class="modal-title">
|
||||
<KeyOutlined class="modal-title-icon" />
|
||||
@ -1027,6 +1027,15 @@ onMounted(() => {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.password-display :deep(.ant-typography),
|
||||
.password-display :deep(.ant-typography-copy) {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.password-display :deep(.ant-typography-copy-icon) {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* Modal title styling */
|
||||
.modal-title {
|
||||
display: flex;
|
||||
|
||||
@ -492,6 +492,10 @@ const resetForm = () => {
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false;
|
||||
resetForm();
|
||||
// 若当前筛选了班级,预填班级选择
|
||||
if (selectedClassId.value) {
|
||||
formState.classId = selectedClassId.value;
|
||||
}
|
||||
modalVisible.value = true;
|
||||
};
|
||||
|
||||
|
||||
@ -176,7 +176,7 @@
|
||||
</a-modal>
|
||||
|
||||
<!-- 重置密码确认模态框 -->
|
||||
<a-modal v-model:open="resetPasswordVisible" @ok="confirmResetPassword" :confirm-loading="resetting" :width="400">
|
||||
<a-modal v-model:open="resetPasswordVisible" @ok="confirmResetPassword" :confirm-loading="resetting" :width="400" okText="重置">
|
||||
<template #title>
|
||||
<span class="modal-title">
|
||||
<KeyOutlined class="modal-title-icon" />
|
||||
@ -778,6 +778,15 @@ onMounted(() => {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.password-display :deep(.ant-typography),
|
||||
.password-display :deep(.ant-typography-copy) {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.password-display :deep(.ant-typography-copy-icon) {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* Modal title styling */
|
||||
.modal-title {
|
||||
display: flex;
|
||||
|
||||
@ -7,12 +7,9 @@
|
||||
</div>
|
||||
<a-spin :spinning="loadingCollections">
|
||||
<div class="collection-list">
|
||||
<div
|
||||
v-for="collection in collections"
|
||||
:key="collection.id"
|
||||
<div v-for="collection in collections" :key="collection.id"
|
||||
:class="['collection-item', { active: selectedCollectionId === collection.id }]"
|
||||
@click="selectCollection(collection)"
|
||||
>
|
||||
@click="selectCollection(collection)">
|
||||
<div class="collection-name">{{ collection.name }}</div>
|
||||
<div class="collection-count">{{ collection.packageCount || 0 }}个课程包</div>
|
||||
</div>
|
||||
@ -34,11 +31,7 @@
|
||||
<div ref="descRef" :class="['desc-text', { expanded: descExpanded }]">
|
||||
{{ selectedCollection.description }}
|
||||
</div>
|
||||
<button
|
||||
v-if="showExpandBtn"
|
||||
class="expand-btn"
|
||||
@click="descExpanded = !descExpanded"
|
||||
>
|
||||
<button v-if="showExpandBtn" class="expand-btn" @click="descExpanded = !descExpanded">
|
||||
{{ descExpanded ? '收起' : '展开更多' }}
|
||||
<DownOutlined :class="{ rotated: descExpanded }" />
|
||||
</button>
|
||||
@ -52,18 +45,12 @@
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">年级:</span>
|
||||
<div class="grade-tags">
|
||||
<span
|
||||
:class="['grade-tag', { active: !selectedGrade }]"
|
||||
@click="selectedGrade = ''"
|
||||
>
|
||||
<span :class="['grade-tag', { active: !selectedGrade }]" @click="selectedGrade = ''">
|
||||
全部
|
||||
</span>
|
||||
<span
|
||||
v-for="grade in filterMeta.grades"
|
||||
:key="grade.label"
|
||||
<span v-for="grade in filterMeta.grades" :key="grade.label"
|
||||
:class="['grade-tag', { active: selectedGrade === grade.label }]"
|
||||
@click="selectedGrade = grade.label"
|
||||
>
|
||||
@click="selectedGrade = grade.label">
|
||||
{{ grade.label }}
|
||||
<span class="count">({{ grade.count }})</span>
|
||||
</span>
|
||||
@ -71,37 +58,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<div class="filter-row"><!-- 课程包主题筛选 -->
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">课程包主题:</span>
|
||||
<a-select v-model:value="selectedThemeId" placeholder="全部课程包主题" style="width: 180px" allowClear
|
||||
@change="loadPackages">
|
||||
<a-select-option :value="undefined">全部课程包主题</a-select-option>
|
||||
<a-select-option v-for="opt in (filterMeta.themes || [])" :key="opt.themeId" :value="opt.themeId">
|
||||
{{ opt.name }} ({{ opt.count }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<!-- 课程配置筛选 -->
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">课程配置:</span>
|
||||
<a-select
|
||||
v-model:value="selectedLessonType"
|
||||
placeholder="全部课程配置"
|
||||
style="width: 180px"
|
||||
allowClear
|
||||
@change="loadPackages"
|
||||
>
|
||||
<a-select v-model:value="selectedLessonType" placeholder="全部课程配置" style="width: 180px" allowClear
|
||||
@change="loadPackages">
|
||||
<a-select-option :value="undefined">全部课程配置</a-select-option>
|
||||
<a-select-option
|
||||
v-for="opt in (filterMeta.lessonTypes || [])"
|
||||
:key="opt.lessonType"
|
||||
:value="opt.lessonType"
|
||||
>
|
||||
<a-select-option v-for="opt in filteredLessonTypes" :key="opt.lessonType"
|
||||
:value="opt.lessonType">
|
||||
{{ opt.name }} ({{ opt.count }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- 搜索 -->
|
||||
<div class="filter-group search-group">
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索课程包..."
|
||||
style="width: 220px"
|
||||
allowClear
|
||||
@search="loadPackages"
|
||||
/>
|
||||
<a-input-search v-model:value="searchKeyword" placeholder="搜索课程包..." style="width: 220px" allowClear
|
||||
@search="loadPackages" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -110,13 +96,8 @@
|
||||
<section class="packages-section">
|
||||
<a-spin :spinning="loadingPackages">
|
||||
<div v-if="packages.length > 0" class="packages-grid">
|
||||
<CoursePackageCard
|
||||
v-for="pkg in packages"
|
||||
:key="pkg.id"
|
||||
:pkg="pkg"
|
||||
@click="handlePackageClick"
|
||||
@prepare="handlePrepare"
|
||||
/>
|
||||
<CoursePackageCard v-for="pkg in packages" :key="pkg.id" :pkg="pkg" @click="handlePackageClick"
|
||||
@prepare="handlePrepare" />
|
||||
</div>
|
||||
<div v-else class="empty-packages">
|
||||
<InboxOutlined class="empty-icon" />
|
||||
@ -166,7 +147,14 @@ const selectedCollection = computed(() =>
|
||||
);
|
||||
|
||||
// 筛选元数据
|
||||
const filterMeta = ref<FilterMetaResponse>({ grades: [], lessonTypes: [] });
|
||||
const filterMeta = ref<FilterMetaResponse>({ grades: [], lessonTypes: [], themes: [] });
|
||||
|
||||
// 课程配置筛选选项(过滤导入课、集体课)
|
||||
const EXCLUDED_LESSON_TYPES = new Set(['INTRODUCTION', 'INTRO', 'COLLECTIVE']);
|
||||
const filteredLessonTypes = computed(() => {
|
||||
const list = filterMeta.value.lessonTypes || [];
|
||||
return list.filter(opt => !EXCLUDED_LESSON_TYPES.has((opt.lessonType || '').toUpperCase()));
|
||||
});
|
||||
|
||||
// 课程包列表
|
||||
const packages = ref<CoursePackage[]>([]);
|
||||
@ -175,6 +163,7 @@ const loadingPackages = ref(false);
|
||||
// 筛选条件
|
||||
const selectedGrade = ref('');
|
||||
const selectedLessonType = ref<string | undefined>(undefined);
|
||||
const selectedThemeId = ref<number | undefined>(undefined);
|
||||
const searchKeyword = ref('');
|
||||
|
||||
// 描述展开
|
||||
@ -205,6 +194,7 @@ const selectCollection = async (collection: CourseCollection) => {
|
||||
// 重置筛选条件
|
||||
selectedGrade.value = '';
|
||||
selectedLessonType.value = undefined;
|
||||
selectedThemeId.value = undefined;
|
||||
searchKeyword.value = '';
|
||||
descExpanded.value = false;
|
||||
|
||||
@ -234,7 +224,7 @@ const loadFilterMeta = async () => {
|
||||
filterMeta.value = await getFilterMeta(selectedCollectionId.value);
|
||||
} catch (error) {
|
||||
console.error('获取筛选元数据失败', error);
|
||||
filterMeta.value = { grades: [], lessonTypes: [] };
|
||||
filterMeta.value = { grades: [], lessonTypes: [], themes: [] };
|
||||
}
|
||||
};
|
||||
|
||||
@ -246,6 +236,7 @@ const loadPackages = async () => {
|
||||
packages.value = await getPackages(selectedCollectionId.value, {
|
||||
grade: selectedGrade.value || undefined,
|
||||
lessonType: selectedLessonType.value,
|
||||
themeId: selectedThemeId.value,
|
||||
keyword: searchKeyword.value || undefined,
|
||||
});
|
||||
} catch (error: any) {
|
||||
@ -266,8 +257,8 @@ const handlePrepare = (pkg: CoursePackage) => {
|
||||
router.push(`/teacher/courses/${pkg.id}/prepare`);
|
||||
};
|
||||
|
||||
// 监听年级、课程配置变化
|
||||
watch([selectedGrade, selectedLessonType], () => {
|
||||
// 监听年级、课程配置、主题变化
|
||||
watch([selectedGrade, selectedLessonType, selectedThemeId], () => {
|
||||
loadPackages();
|
||||
});
|
||||
|
||||
|
||||
@ -88,13 +88,14 @@ const gradeText = computed(() => {
|
||||
return grades.join(' · ');
|
||||
});
|
||||
|
||||
// 从 courses 提取课程类型列表(去重,与管理端一致)
|
||||
// 从 courses 提取课程类型列表(去重,过滤导入课、集体课)
|
||||
const EXCLUDED_LESSON_TYPES = new Set(['INTRODUCTION', 'INTRO', 'COLLECTIVE']);
|
||||
const lessonTypes = computed(() => {
|
||||
const courses = props.pkg.courses || [];
|
||||
const types = new Set<string>();
|
||||
for (const c of courses) {
|
||||
const t = c.lessonType;
|
||||
if (t) types.add(t);
|
||||
if (t && !EXCLUDED_LESSON_TYPES.has(t.toUpperCase())) types.add(t);
|
||||
}
|
||||
return Array.from(types);
|
||||
});
|
||||
|
||||
@ -10,6 +10,7 @@ import com.reading.platform.common.response.PageResult;
|
||||
import com.reading.platform.common.response.Result;
|
||||
import com.reading.platform.dto.request.TenantCreateRequest;
|
||||
import com.reading.platform.dto.request.TenantUpdateRequest;
|
||||
import com.reading.platform.dto.request.UpdateTenantQuotaRequest;
|
||||
import com.reading.platform.dto.response.TenantResponse;
|
||||
import com.reading.platform.entity.Tenant;
|
||||
import com.reading.platform.entity.TenantPackage;
|
||||
@ -112,9 +113,14 @@ public class AdminTenantController {
|
||||
@Operation(summary = "更新租户配额")
|
||||
@Log(module = LogModule.TENANT, type = LogOperationType.UPDATE, description = "更新租户配额")
|
||||
@PutMapping("/{id}/quota")
|
||||
public Result<TenantResponse> updateTenantQuota(@PathVariable Long id, @RequestBody Map<String, Object> quota) {
|
||||
// TODO: 实现更新租户配额逻辑
|
||||
Tenant tenant = tenantService.getTenantById(id);
|
||||
public Result<TenantResponse> updateTenantQuota(
|
||||
@PathVariable Long id,
|
||||
@RequestBody UpdateTenantQuotaRequest request) {
|
||||
TenantUpdateRequest updateRequest = new TenantUpdateRequest();
|
||||
updateRequest.setCollectionIds(request.getCollectionIds());
|
||||
updateRequest.setTeacherQuota(request.getTeacherQuota());
|
||||
updateRequest.setStudentQuota(request.getStudentQuota());
|
||||
Tenant tenant = tenantService.updateTenant(id, updateRequest);
|
||||
return Result.success(toResponse(tenant));
|
||||
}
|
||||
|
||||
|
||||
@ -99,7 +99,7 @@ public class SchoolClassController {
|
||||
new LambdaQueryWrapper<ClassTeacher>().eq(ClassTeacher::getClassId, vo.getId()));
|
||||
List<ClassTeacherResponse> teacherList = new ArrayList<>();
|
||||
for (ClassTeacher ct : classTeachers) {
|
||||
Teacher t = teacherService.getTeacherById(ct.getTeacherId());
|
||||
Teacher t = teacherService.findTeacherById(ct.getTeacherId());
|
||||
teacherList.add(ClassTeacherResponse.builder()
|
||||
.id(ct.getId())
|
||||
.classId(ct.getClassId())
|
||||
@ -165,7 +165,7 @@ public class SchoolClassController {
|
||||
new LambdaQueryWrapper<ClassTeacher>().eq(ClassTeacher::getClassId, id));
|
||||
List<ClassTeacherResponse> teacherList = new ArrayList<>();
|
||||
for (ClassTeacher ct : classTeachers) {
|
||||
Teacher t = teacherService.getTeacherById(ct.getTeacherId());
|
||||
Teacher t = teacherService.findTeacherById(ct.getTeacherId());
|
||||
teacherList.add(ClassTeacherResponse.builder()
|
||||
.id(ct.getId())
|
||||
.classId(ct.getClassId())
|
||||
|
||||
@ -54,8 +54,9 @@ public class SchoolPackageController {
|
||||
@PathVariable Long collectionId,
|
||||
@RequestParam(required = false) String grade,
|
||||
@RequestParam(required = false) String lessonType,
|
||||
@RequestParam(required = false) Long themeId,
|
||||
@RequestParam(required = false) String keyword) {
|
||||
return Result.success(collectionService.getPackagesByCollection(collectionId, grade, lessonType, keyword));
|
||||
return Result.success(collectionService.getPackagesByCollection(collectionId, grade, lessonType, themeId, keyword));
|
||||
}
|
||||
|
||||
@GetMapping("/{collectionId}/filter-meta")
|
||||
|
||||
@ -95,10 +95,10 @@ public class SchoolParentController {
|
||||
@Operation(summary = "Reset parent password")
|
||||
@Log(module = LogModule.PARENT, type = LogOperationType.UPDATE, description = "重置家长密码")
|
||||
@PostMapping("/{id}/reset-password")
|
||||
public Result<Void> resetPassword(@PathVariable Long id, @RequestParam String newPassword) {
|
||||
public Result<java.util.Map<String, String>> resetPassword(@PathVariable Long id) {
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
parentService.resetPasswordWithTenantCheck(id, tenantId, newPassword);
|
||||
return Result.success();
|
||||
String tempPassword = parentService.resetPasswordAndReturnTemp(id, tenantId);
|
||||
return Result.success(java.util.Map.of("tempPassword", tempPassword));
|
||||
}
|
||||
|
||||
@Operation(summary = "Bind student to parent")
|
||||
|
||||
@ -10,7 +10,9 @@ import com.reading.platform.common.response.Result;
|
||||
import com.reading.platform.common.security.SecurityUtils;
|
||||
import com.reading.platform.dto.request.StudentCreateRequest;
|
||||
import com.reading.platform.dto.request.StudentUpdateRequest;
|
||||
import com.reading.platform.dto.request.TransferStudentRequest;
|
||||
import com.reading.platform.dto.response.StudentResponse;
|
||||
import com.reading.platform.dto.response.StudentTransferHistoryItemResponse;
|
||||
import com.reading.platform.entity.Student;
|
||||
import com.reading.platform.service.ClassService;
|
||||
import com.reading.platform.service.StudentService;
|
||||
@ -21,6 +23,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Tag(name = "School - Student", description = "Student Management APIs for School")
|
||||
@RestController
|
||||
@ -38,7 +41,9 @@ public class SchoolStudentController {
|
||||
public Result<StudentResponse> createStudent(@Valid @RequestBody StudentCreateRequest request) {
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
Student student = studentService.createStudent(tenantId, request);
|
||||
return Result.success(studentMapper.toVO(student));
|
||||
StudentResponse vo = studentMapper.toVO(student);
|
||||
vo.setClassId(request.getClassId());
|
||||
return Result.success(vo);
|
||||
}
|
||||
|
||||
@Operation(summary = "Update student")
|
||||
@ -80,6 +85,17 @@ public class SchoolStudentController {
|
||||
return Result.success(PageResult.of(voList, page.getTotal(), page.getCurrent(), page.getSize()));
|
||||
}
|
||||
|
||||
@Operation(summary = "Transfer student to another class")
|
||||
@PostMapping("/{id}/transfer")
|
||||
public Result<Map<String, String>> transferStudent(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody TransferStudentRequest request) {
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
studentService.getStudentByIdWithTenantCheck(id, tenantId);
|
||||
classService.assignStudentToClass(id, request.getToClassId(), tenantId);
|
||||
return Result.success(Map.of("message", "调班成功"));
|
||||
}
|
||||
|
||||
@Operation(summary = "Delete student")
|
||||
@Log(module = LogModule.STUDENT, type = LogOperationType.DELETE, description = "删除学生")
|
||||
@DeleteMapping("/{id}")
|
||||
@ -89,4 +105,13 @@ public class SchoolStudentController {
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@Operation(summary = "Get student class transfer history")
|
||||
@GetMapping("/{id}/history")
|
||||
public Result<List<StudentTransferHistoryItemResponse>> getStudentClassHistory(@PathVariable Long id) {
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
studentService.getStudentByIdWithTenantCheck(id, tenantId);
|
||||
List<StudentTransferHistoryItemResponse> history = classService.getStudentClassHistory(id, tenantId);
|
||||
return Result.success(history);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -35,4 +35,7 @@ public class StudentCreateRequest {
|
||||
@Schema(description = "备注")
|
||||
private String notes;
|
||||
|
||||
@Schema(description = "所在班级 ID,创建后分配到该班级")
|
||||
private Long classId;
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -96,6 +96,9 @@ public class CourseResponse {
|
||||
@Schema(description = "主题 ID")
|
||||
private Long themeId;
|
||||
|
||||
@Schema(description = "主题名称")
|
||||
private String themeName;
|
||||
|
||||
@Schema(description = "绘本名称")
|
||||
private String pictureBookName;
|
||||
|
||||
|
||||
@ -25,6 +25,9 @@ public class PackageFilterMetaResponse {
|
||||
@Schema(description = "课程配置选项列表(导入课、集体课、健康、科学等)")
|
||||
private List<LessonTypeOption> lessonTypes;
|
||||
|
||||
@Schema(description = "课程包主题选项列表")
|
||||
private List<ThemeOption> themes;
|
||||
|
||||
/**
|
||||
* 年级选项
|
||||
*/
|
||||
@ -59,4 +62,23 @@ public class PackageFilterMetaResponse {
|
||||
@Schema(description = "包含该类型环节的课程包数量")
|
||||
private Integer count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 课程包主题选项
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "课程包主题选项")
|
||||
public static class ThemeOption {
|
||||
@Schema(description = "主题ID")
|
||||
private Long themeId;
|
||||
|
||||
@Schema(description = "主题名称")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "该主题下的课程包数量")
|
||||
private Integer count;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
* 学生调班历史单条记录响应
|
||||
* 前端期望格式:fromClass、toClass 包含班级信息
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ package com.reading.platform.service;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.reading.platform.dto.request.ClassCreateRequest;
|
||||
import com.reading.platform.dto.request.ClassUpdateRequest;
|
||||
import com.reading.platform.dto.response.StudentTransferHistoryItemResponse;
|
||||
import com.reading.platform.entity.Clazz;
|
||||
|
||||
import java.util.List;
|
||||
@ -102,4 +103,9 @@ public interface ClassService extends com.baomidou.mybatisplus.extension.service
|
||||
*/
|
||||
Clazz getPrimaryClassByStudentId(Long studentId);
|
||||
|
||||
/**
|
||||
* 获取学生调班历史(带租户验证)
|
||||
*/
|
||||
List<StudentTransferHistoryItemResponse> getStudentClassHistory(Long studentId, Long tenantId);
|
||||
|
||||
}
|
||||
|
||||
@ -40,10 +40,11 @@ public interface CourseCollectionService extends IService<CourseCollection> {
|
||||
* @param collectionId 套餐ID
|
||||
* @param grade 年级筛选
|
||||
* @param lessonType 课程配置筛选(INTRODUCTION、COLLECTIVE、HEALTH、LANGUAGE、SCIENCE、SOCIAL、ART)
|
||||
* @param themeId 课程包主题筛选
|
||||
* @param keyword 关键词搜索
|
||||
* @return 课程包列表
|
||||
*/
|
||||
List<CoursePackageResponse> getPackagesByCollection(Long collectionId, String grade, String lessonType, String keyword);
|
||||
List<CoursePackageResponse> getPackagesByCollection(Long collectionId, String grade, String lessonType, Long themeId, String keyword);
|
||||
|
||||
/**
|
||||
* 获取套餐的筛选元数据(年级、课程配置选项)
|
||||
|
||||
@ -62,6 +62,11 @@ public interface ParentService extends com.baomidou.mybatisplus.extension.servic
|
||||
*/
|
||||
void resetPasswordWithTenantCheck(Long id, Long tenantId, String newPassword);
|
||||
|
||||
/**
|
||||
* 重置密码并返回临时密码(带租户验证)
|
||||
*/
|
||||
String resetPasswordAndReturnTemp(Long id, Long tenantId);
|
||||
|
||||
/**
|
||||
* 绑定学生
|
||||
*/
|
||||
|
||||
@ -34,6 +34,11 @@ public interface TeacherService extends IService<Teacher> {
|
||||
*/
|
||||
Teacher getTeacherById(Long id);
|
||||
|
||||
/**
|
||||
* 根据 ID 查询教师,不存在时返回 null(不抛异常)
|
||||
*/
|
||||
Teacher findTeacherById(Long id);
|
||||
|
||||
/**
|
||||
* 根据 ID 查询教师(带租户验证)
|
||||
*/
|
||||
|
||||
@ -7,6 +7,7 @@ import com.reading.platform.common.enums.ErrorCode;
|
||||
import com.reading.platform.common.exception.BusinessException;
|
||||
import com.reading.platform.dto.request.ClassCreateRequest;
|
||||
import com.reading.platform.dto.request.ClassUpdateRequest;
|
||||
import com.reading.platform.dto.response.StudentTransferHistoryItemResponse;
|
||||
import com.reading.platform.entity.ClassTeacher;
|
||||
import com.reading.platform.entity.Clazz;
|
||||
import com.reading.platform.entity.StudentClassHistory;
|
||||
@ -22,6 +23,7 @@ import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@ -337,4 +339,54 @@ public class ClassServiceImpl extends com.baomidou.mybatisplus.extension.service
|
||||
return clazzMapper.selectById(history.getClassId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<StudentTransferHistoryItemResponse> getStudentClassHistory(Long studentId, Long tenantId) {
|
||||
log.debug("获取学生调班历史,学生 ID: {}, 租户 ID: {}", studentId, tenantId);
|
||||
|
||||
List<StudentClassHistory> histories = studentClassHistoryMapper.selectList(
|
||||
new LambdaQueryWrapper<StudentClassHistory>()
|
||||
.eq(StudentClassHistory::getStudentId, studentId)
|
||||
.orderByAsc(StudentClassHistory::getStartDate)
|
||||
);
|
||||
|
||||
List<StudentTransferHistoryItemResponse> result = new ArrayList<>();
|
||||
Clazz prevClass = null;
|
||||
|
||||
for (StudentClassHistory h : histories) {
|
||||
Clazz toClazz = getClassById(h.getClassId());
|
||||
if (toClazz == null || !tenantId.equals(toClazz.getTenantId())) {
|
||||
log.warn("调班历史引用的班级不存在或无权访问,跳过。historyId: {}, classId: {}", h.getId(), h.getClassId());
|
||||
continue;
|
||||
}
|
||||
|
||||
StudentTransferHistoryItemResponse.ClassBasicInfo fromClassInfo = prevClass == null ? null
|
||||
: StudentTransferHistoryItemResponse.ClassBasicInfo.builder()
|
||||
.id(prevClass.getId())
|
||||
.name(prevClass.getName())
|
||||
.grade(prevClass.getGrade() != null ? prevClass.getGrade() : "")
|
||||
.build();
|
||||
|
||||
StudentTransferHistoryItemResponse.ClassBasicInfo toClassInfo =
|
||||
StudentTransferHistoryItemResponse.ClassBasicInfo.builder()
|
||||
.id(toClazz.getId())
|
||||
.name(toClazz.getName())
|
||||
.grade(toClazz.getGrade() != null ? toClazz.getGrade() : "")
|
||||
.build();
|
||||
|
||||
result.add(StudentTransferHistoryItemResponse.builder()
|
||||
.id(h.getId())
|
||||
.fromClass(fromClassInfo)
|
||||
.toClass(toClassInfo)
|
||||
.reason(null)
|
||||
.operatedBy(null)
|
||||
.createdAt(h.getCreatedAt())
|
||||
.build());
|
||||
|
||||
prevClass = toClazz;
|
||||
}
|
||||
|
||||
Collections.reverse(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -213,8 +213,8 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
|
||||
* 获取课程套餐下的课程包列表(支持筛选)
|
||||
*/
|
||||
@Override
|
||||
public List<CoursePackageResponse> getPackagesByCollection(Long collectionId, String grade, String lessonType, String keyword) {
|
||||
log.info("获取课程套餐的课程包列表(筛选),collectionId={}, grade={}, lessonType={}, keyword={}", collectionId, grade, lessonType, keyword);
|
||||
public List<CoursePackageResponse> getPackagesByCollection(Long collectionId, String grade, String lessonType, Long themeId, String keyword) {
|
||||
log.info("获取课程套餐的课程包列表(筛选),collectionId={}, grade={}, lessonType={}, themeId={}, keyword={}", collectionId, grade, lessonType, themeId, keyword);
|
||||
|
||||
// 查询关联关系
|
||||
List<CourseCollectionPackage> associations = collectionPackageMapper.selectList(
|
||||
@ -252,6 +252,11 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
|
||||
wrapper.apply("JSON_CONTAINS(grade_tags, {0})", "\"" + grade + "\"");
|
||||
}
|
||||
|
||||
// 课程包主题筛选
|
||||
if (themeId != null) {
|
||||
wrapper.eq(CoursePackage::getThemeId, themeId);
|
||||
}
|
||||
|
||||
// 关键词搜索
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
wrapper.and(w -> w
|
||||
@ -315,6 +320,7 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
|
||||
return PackageFilterMetaResponse.builder()
|
||||
.grades(new ArrayList<>())
|
||||
.lessonTypes(new ArrayList<>())
|
||||
.themes(new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
|
||||
@ -373,9 +379,32 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 统计课程包主题分布
|
||||
Map<Long, Integer> themeCountMap = new HashMap<>();
|
||||
Map<Long, String> themeNameMap = new HashMap<>();
|
||||
for (CoursePackage pkg : packages) {
|
||||
Long tid = pkg.getThemeId();
|
||||
if (tid != null) {
|
||||
themeCountMap.merge(tid, 1, Integer::sum);
|
||||
if (!themeNameMap.containsKey(tid)) {
|
||||
Theme theme = themeMapper.selectById(tid);
|
||||
themeNameMap.put(tid, theme != null ? theme.getName() : "主题" + tid);
|
||||
}
|
||||
}
|
||||
}
|
||||
List<PackageFilterMetaResponse.ThemeOption> themes = themeCountMap.entrySet().stream()
|
||||
.map(e -> PackageFilterMetaResponse.ThemeOption.builder()
|
||||
.themeId(e.getKey())
|
||||
.name(themeNameMap.getOrDefault(e.getKey(), ""))
|
||||
.count(e.getValue())
|
||||
.build())
|
||||
.sorted((a, b) -> themeNameMap.getOrDefault(a.getThemeId(), "").compareTo(themeNameMap.getOrDefault(b.getThemeId(), "")))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return PackageFilterMetaResponse.builder()
|
||||
.grades(grades)
|
||||
.lessonTypes(lessonTypes)
|
||||
.themes(themes)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@ -16,9 +16,11 @@ import com.reading.platform.entity.CourseLesson;
|
||||
import com.reading.platform.entity.CoursePackage;
|
||||
import com.reading.platform.entity.LessonStep;
|
||||
import com.reading.platform.entity.TenantPackage;
|
||||
import com.reading.platform.entity.Theme;
|
||||
import com.reading.platform.mapper.CourseCollectionPackageMapper;
|
||||
import com.reading.platform.mapper.CoursePackageMapper;
|
||||
import com.reading.platform.mapper.TenantPackageMapper;
|
||||
import com.reading.platform.mapper.ThemeMapper;
|
||||
import com.reading.platform.service.CourseLessonService;
|
||||
import com.reading.platform.service.CoursePackageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -47,6 +49,7 @@ public class CoursePackageServiceImpl extends ServiceImpl<CoursePackageMapper, C
|
||||
private final com.reading.platform.common.mapper.CoursePackageMapper coursePackageVoMapper;
|
||||
private final CourseCollectionPackageMapper collectionPackageMapper;
|
||||
private final TenantPackageMapper tenantPackageMapper;
|
||||
private final ThemeMapper themeMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@ -109,6 +112,13 @@ public class CoursePackageServiceImpl extends ServiceImpl<CoursePackageMapper, C
|
||||
public CourseResponse getCourseByIdWithLessons(Long id) {
|
||||
CoursePackage entity = getCourseById(id);
|
||||
CourseResponse response = coursePackageVoMapper.toVO(entity);
|
||||
// 填充主题名称
|
||||
if (entity.getThemeId() != null) {
|
||||
Theme theme = themeMapper.selectById(entity.getThemeId());
|
||||
if (theme != null) {
|
||||
response.setThemeName(theme.getName());
|
||||
}
|
||||
}
|
||||
List<CourseLesson> lessons = courseLessonService.findByCourseId(id);
|
||||
List<CourseLessonResponse> lessonResponses = lessons.stream()
|
||||
.map(this::toLessonResponse)
|
||||
|
||||
@ -19,6 +19,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 家长服务实现类
|
||||
*/
|
||||
@ -190,6 +192,18 @@ public class ParentServiceImpl extends com.baomidou.mybatisplus.extension.servic
|
||||
resetPassword(id, newPassword);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public String resetPasswordAndReturnTemp(Long id, Long tenantId) {
|
||||
log.info("开始重置密码并返回临时密码,ID: {}, tenantId: {}", id, tenantId);
|
||||
Parent parent = getParentByIdWithTenantCheck(id, tenantId);
|
||||
String tempPassword = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
|
||||
parent.setPassword(passwordEncoder.encode(tempPassword));
|
||||
parentMapper.updateById(parent);
|
||||
log.info("家长密码重置成功,ID: {}", id);
|
||||
return tempPassword;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void bindStudentWithTenantCheck(Long parentId, Long studentId, Long tenantId, String relationship, Boolean isPrimary) {
|
||||
|
||||
@ -58,6 +58,10 @@ public class StudentServiceImpl extends com.baomidou.mybatisplus.extension.servi
|
||||
|
||||
studentMapper.insert(student);
|
||||
|
||||
if (request.getClassId() != null) {
|
||||
classService.assignStudentToClass(student.getId(), request.getClassId(), tenantId);
|
||||
}
|
||||
|
||||
log.info("学生创建成功,ID: {}", student.getId());
|
||||
return student;
|
||||
}
|
||||
|
||||
@ -160,6 +160,12 @@ public class TeacherServiceImpl extends com.baomidou.mybatisplus.extension.servi
|
||||
return teacher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Teacher findTeacherById(Long id) {
|
||||
if (id == null) return null;
|
||||
return teacherMapper.selectById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Teacher getTeacherByIdWithTenantCheck(Long id, Long tenantId) {
|
||||
log.debug("查询教师(带租户验证),ID: {}, tenantId: {}", id, tenantId);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user