fix: 修复 /api/v1/school/courses 接口 gradeTags 前端显示数据丢失

- 后端: 增强 SchoolCourseController.parseJsonArray 兼容多种 JSON 格式
- 后端: 新增 SchoolCourseResponse,gradeTags/domainTags 规范为 String[]
- 前端: 学校端课程列表/详情统一使用 parseGradeLevels 解析 gradeTags
- 前端: 兼容 grade_tags/domain_tags snake_case 字段

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-19 14:05:28 +08:00
parent efedb37cae
commit 81dd74662e
16 changed files with 254 additions and 79 deletions

View File

@ -375,16 +375,20 @@ export interface Course {
description?: string; description?: string;
coverUrl?: string; coverUrl?: string;
coverImagePath?: string; coverImagePath?: string;
pictureBookName?: string;
category?: string; category?: string;
ageRange?: string; ageRange?: string;
difficultyLevel?: string; difficultyLevel?: string;
durationMinutes?: number; durationMinutes?: number;
duration?: number;
objectives?: string; objectives?: string;
status: string; status: string;
isSystem: number; isSystem: number;
version?: string; version?: string;
usageCount?: number; usageCount?: number;
teacherCount?: number; teacherCount?: number;
gradeTags?: string[];
domainTags?: string[];
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
publishedAt?: string; publishedAt?: string;

View File

@ -517,22 +517,26 @@ const previewModalVisible = ref(false);
const previewFileUrl = ref(''); const previewFileUrl = ref('');
const previewFileName = ref(''); const previewFileName = ref('');
// // gradeTags String[]
const grades = computed(() => { const grades = computed(() => {
if (!course.value.gradeTags) return []; const val = course.value.gradeTags;
if (!val) return [];
if (Array.isArray(val)) return val;
try { try {
const tags = JSON.parse(course.value.gradeTags); const tags = JSON.parse(val);
return tags; return Array.isArray(tags) ? tags : [];
} catch { } catch {
return []; return [];
} }
}); });
// // domainTags String[]
const domainTags = computed(() => { const domainTags = computed(() => {
if (!course.value.domainTags) return []; const val = course.value.domainTags;
if (!val) return [];
if (Array.isArray(val)) return translateDomainTags(val);
try { try {
const tags = JSON.parse(course.value.domainTags); const tags = JSON.parse(val);
return translateDomainTags(Array.isArray(tags) ? tags : []); return translateDomainTags(Array.isArray(tags) ? tags : []);
} catch { } catch {
return []; return [];

View File

@ -228,7 +228,7 @@ const fetchCourseDetail = async () => {
// //
formData.basic.name = course.name; formData.basic.name = course.name;
formData.basic.themeId = course.themeId; formData.basic.themeId = course.themeId;
formData.basic.grades = 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.duration || 25;

View File

@ -485,11 +485,12 @@ const iterateCourse = (id: number) => {
router.push(`/admin/packages/${id}/iterate`); router.push(`/admin/packages/${id}/iterate`);
}; };
const parseGradeTags = (gradeTags: string) => { const parseGradeTags = (gradeTags: string | string[] | undefined): string[] => {
if (!gradeTags) return []; if (!gradeTags) return [];
if (Array.isArray(gradeTags)) return gradeTags.map((t) => translateGradeTag(t));
try { try {
const tags = JSON.parse(gradeTags); const tags = JSON.parse(gradeTags);
return tags.map((t: string) => translateGradeTag(t)); return Array.isArray(tags) ? tags.map((t: string) => translateGradeTag(t)) : [];
} catch { } catch {
return []; return [];
} }

View File

@ -311,11 +311,12 @@ const viewRejectReason = (record: any) => {
rejectReasonVisible.value = true; rejectReasonVisible.value = true;
}; };
const parseGradeTags = (gradeTags: string) => { const parseGradeTags = (gradeTags: string | string[] | undefined): string[] => {
if (!gradeTags) return []; if (!gradeTags) return [];
if (Array.isArray(gradeTags)) return gradeTags.map((t) => translateGradeTag(t));
try { try {
const tags = JSON.parse(gradeTags); const tags = JSON.parse(gradeTags);
return tags.map((t: string) => translateGradeTag(t)); return Array.isArray(tags) ? tags.map((t: string) => translateGradeTag(t)) : [];
} catch { } catch {
return []; return [];
} }

View File

@ -458,6 +458,7 @@ import {
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import * as schoolApi from '@/api/school'; import * as schoolApi from '@/api/school';
import { translateDomainTags } from '@/utils/tagMaps'; import { translateDomainTags } from '@/utils/tagMaps';
import { parseGradeLevels } from '@/api/collections';
import FilePreviewModal from '@/components/FilePreviewModal.vue'; import FilePreviewModal from '@/components/FilePreviewModal.vue';
const router = useRouter(); const router = useRouter();
@ -528,29 +529,15 @@ const previewModalVisible = ref(false);
const previewFileUrl = ref(''); const previewFileUrl = ref('');
const previewFileName = ref(''); const previewFileName = ref('');
// // 使 parseGradeLevels
const grades = computed(() => { const grades = computed(() =>
if (!course.value.gradeTags) return []; parseGradeLevels(course.value.gradeTags ?? course.value.grade_tags)
try { );
const tags = JSON.parse(course.value.gradeTags);
return Array.isArray(tags) ? tags : [];
} catch {
//
return Array.isArray(course.value.gradeTags) ? course.value.gradeTags : [];
}
});
// //
const domainTags = computed(() => { const domainTags = computed(() =>
if (!course.value.domainTags) return []; translateDomainTags(parseGradeLevels(course.value.domainTags ?? course.value.domain_tags))
try { );
const tags = JSON.parse(course.value.domainTags);
const arr = Array.isArray(tags) ? tags : [];
return translateDomainTags(arr);
} catch {
return Array.isArray(course.value.domainTags) ? translateDomainTags(course.value.domainTags) : [];
}
});
// //
const hasIntroContent = computed(() => { const hasIntroContent = computed(() => {

View File

@ -69,11 +69,11 @@
<p class="course-book">{{ course.pictureBookName }}</p> <p class="course-book">{{ course.pictureBookName }}</p>
<div class="course-tags"> <div class="course-tags">
<span v-for="tag in (course.gradeTags || []).slice(0, 2)" :key="tag" class="tag grade" <span v-for="tag in (course.gradeTags || [])" :key="tag" class="tag grade"
:style="getGradeTagStyle(translateGradeTag(tag))"> :style="getGradeTagStyle(translateGradeTag(tag))">
{{ translateGradeTag(tag) }} {{ translateGradeTag(tag) }}
</span> </span>
<span v-for="tag in (course.domainTags || []).slice(0, 2)" :key="tag" class="tag domain" <span v-for="tag in (course.domainTags || [])" :key="tag" class="tag domain"
:style="getDomainTagStyle(translateDomainTag(tag))"> :style="getDomainTagStyle(translateDomainTag(tag))">
{{ translateDomainTag(tag) }} {{ translateDomainTag(tag) }}
</span> </span>
@ -139,7 +139,8 @@
</template> </template>
<div class="auth-content"> <div class="auth-content">
<div class="auth-search"> <div class="auth-search">
<a-input-search v-model:value="searchKeyword" placeholder="输入课程名称搜索..." @search="searchCourses" size="large" /> <a-input-search v-model:value="searchKeyword" placeholder="输入课程名称搜索..." @search="searchCourses"
size="large" />
</div> </div>
<div class="available-courses" v-if="!authLoading && availableCourses.length > 0"> <div class="available-courses" v-if="!authLoading && availableCourses.length > 0">
@ -154,9 +155,9 @@
</div> </div>
<div class="course-info"> <div class="course-info">
<div class="course-name-small">{{ course.name }}</div> <div class="course-name-small">{{ course.name }}</div>
<div class="course-book-small">{{ course.pictureBookName }}</div> <div class="course-book-small" v-if="course.pictureBookName">{{ course.pictureBookName }}</div>
<div class="course-tags-small"> <div class="course-tags-small">
<span v-for="tag in course.gradeTags.slice(0, 2)" :key="tag" class="tag-small"> <span v-for="tag in (course.gradeTags || [])" :key="tag" class="tag-small">
{{ tag }} {{ tag }}
</span> </span>
</div> </div>
@ -204,6 +205,7 @@ import {
getGradeTagStyle, getGradeTagStyle,
getDomainTagStyle, getDomainTagStyle,
} from '@/utils/tagMaps'; } from '@/utils/tagMaps';
import { parseGradeLevels } from '@/api/collections';
import * as schoolApi from '@/api/school'; import * as schoolApi from '@/api/school';
const router = useRouter(); const router = useRouter();
@ -214,21 +216,6 @@ const searchKeyword = ref('');
const selectedCourseIds = ref<number[]>([]); const selectedCourseIds = ref<number[]>([]);
const selectedGrade = ref(''); // const selectedGrade = ref(''); //
// JSON
const parseTags = (val: any): string[] => {
if (!val) return [];
if (Array.isArray(val)) return val;
if (typeof val === 'string') {
try {
const parsed = JSON.parse(val);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
};
// //
const gradeOptions = [ const gradeOptions = [
{ label: '全部', value: '' }, { label: '全部', value: '' },
@ -290,8 +277,6 @@ const loadCourses = async () => {
const data = await schoolApi.getSchoolCourses(params); const data = await schoolApi.getSchoolCourses(params);
courses.value = (data || []).map((course: any) => ({ courses.value = (data || []).map((course: any) => ({
...course, ...course,
gradeTags: parseTags(course.gradeTags),
domainTags: parseTags(course.domainTags),
duration: course.duration ?? course.durationMinutes ?? 0, duration: course.duration ?? course.durationMinutes ?? 0,
pictureUrl: course.pictureUrl ?? course.coverImagePath ?? course.coverUrl, pictureUrl: course.pictureUrl ?? course.coverImagePath ?? course.coverUrl,
authorized: course.authorized ?? true, authorized: course.authorized ?? true,

View File

@ -539,27 +539,29 @@ const previewModalVisible = ref(false);
const previewFileUrl = ref(''); const previewFileUrl = ref('');
const previewFileName = ref(''); const previewFileName = ref('');
// // gradeTags String[]
const grades = computed(() => { const grades = computed(() => {
if (!course.value.gradeTags) return []; const val = course.value.gradeTags;
if (!val) return [];
if (Array.isArray(val)) return translateGradeTags(val);
try { try {
const tags = JSON.parse(course.value.gradeTags); const tags = JSON.parse(val);
const translated = Array.isArray(tags) ? tags : []; return translateGradeTags(Array.isArray(tags) ? tags : []);
return translateGradeTags(translated);
} catch { } catch {
return Array.isArray(course.value.gradeTags) ? translateGradeTags(course.value.gradeTags) : []; return [];
} }
}); });
// // domainTags String[]
const domainTags = computed(() => { const domainTags = computed(() => {
if (!course.value.domainTags) return []; const val = course.value.domainTags;
if (!val) return [];
if (Array.isArray(val)) return translateDomainTags(val);
try { try {
const tags = JSON.parse(course.value.domainTags); const tags = JSON.parse(val);
const arr = Array.isArray(tags) ? tags : []; return translateDomainTags(Array.isArray(tags) ? tags : []);
return translateDomainTags(arr);
} catch { } catch {
return Array.isArray(course.value.domainTags) ? translateDomainTags(course.value.domainTags) : []; return [];
} }
}); });

View File

@ -232,10 +232,20 @@ const domainMap: Record<string, string> = {
MATH: '数学', math: '数学', MATH: '数学', math: '数学',
}; };
// JSON // parseGradeLevels
const parseTags = (val: any): string[] => { const parseTags = (val: any): string[] => {
if (!val) return []; if (!val) return [];
if (Array.isArray(val)) return val; if (Array.isArray(val)) {
if (val.length === 0) return [];
if (val[0]?.toString().startsWith('[')) {
try {
return JSON.parse(val.join(''));
} catch {
return [];
}
}
return val;
}
if (typeof val === 'string') { if (typeof val === 'string') {
try { try {
const parsed = JSON.parse(val); const parsed = JSON.parse(val);

View File

@ -237,7 +237,7 @@ const loadCourseData = async () => {
pictureBookName: data.sourceCourse?.name || '', pictureBookName: data.sourceCourse?.name || '',
theme: data.themeId ? { id: data.themeId, name: '' } : null, theme: data.themeId ? { id: data.themeId, name: '' } : null,
coverImagePath: data.coverImagePath, coverImagePath: data.coverImagePath,
gradeTags: data.gradeTags ? JSON.parse(data.gradeTags) : [], gradeTags: Array.isArray(data.gradeTags) ? data.gradeTags : (data.gradeTags ? JSON.parse(data.gradeTags) : []),
domainTags: data.domainTags ? JSON.parse(data.domainTags) : [], domainTags: data.domainTags ? JSON.parse(data.domainTags) : [],
duration: data.duration || 25, duration: data.duration || 25,
coreContent: data.coreContent || '', coreContent: data.coreContent || '',

View File

@ -265,7 +265,7 @@ const fetchDetail = async () => {
// //
formData.basic.name = data.name || ''; formData.basic.name = data.name || '';
formData.basic.themeId = data.themeId; formData.basic.themeId = data.themeId;
formData.basic.grades = data.gradeTags ? JSON.parse(data.gradeTags) : []; formData.basic.grades = Array.isArray(data.gradeTags) ? data.gradeTags : (data.gradeTags ? JSON.parse(data.gradeTags) : []);
formData.basic.pictureBookName = ''; formData.basic.pictureBookName = '';
formData.basic.coreContent = data.coreContent || data.core_content || ''; formData.basic.coreContent = data.coreContent || data.core_content || '';
formData.basic.duration = data.duration || 25; formData.basic.duration = data.duration || 25;

View File

@ -3,12 +3,14 @@ package com.reading.platform.common.mapper;
import com.reading.platform.dto.response.CourseResponse; import com.reading.platform.dto.response.CourseResponse;
import com.reading.platform.entity.CoursePackage; import com.reading.platform.entity.CoursePackage;
import org.mapstruct.Mapper; import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers; import org.mapstruct.factory.Mappers;
import java.util.List; import java.util.List;
/** /**
* Course Entity Mapper * Course Entity Mapper
* gradeTags/domainTags 规范为 String[]与套餐管理适用年级对齐
*/ */
@Mapper(componentModel = "spring") @Mapper(componentModel = "spring")
public interface CoursePackageMapper { public interface CoursePackageMapper {
@ -18,6 +20,8 @@ public interface CoursePackageMapper {
/** /**
* Entity Response * Entity Response
*/ */
@Mapping(target = "gradeTags", expression = "java(com.reading.platform.common.util.JsonUtils.parseStringArray(entity.getGradeTags()))")
@Mapping(target = "domainTags", expression = "java(com.reading.platform.common.util.JsonUtils.parseStringArray(entity.getDomainTags()))")
CourseResponse toVO(CoursePackage entity); CourseResponse toVO(CoursePackage entity);
/** /**
@ -28,5 +32,7 @@ public interface CoursePackageMapper {
/** /**
* Response Entity用于创建/更新时 * Response Entity用于创建/更新时
*/ */
@Mapping(target = "gradeTags", expression = "java(vo.getGradeTags() != null ? com.reading.platform.common.util.JsonUtils.toJson(vo.getGradeTags()) : null)")
@Mapping(target = "domainTags", expression = "java(vo.getDomainTags() != null ? com.reading.platform.common.util.JsonUtils.toJson(vo.getDomainTags()) : null)")
CoursePackage toEntity(CourseResponse vo); CoursePackage toEntity(CourseResponse vo);
} }

View File

@ -1,9 +1,11 @@
package com.reading.platform.common.util; package com.reading.platform.common.util;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.alibaba.fastjson2.TypeReference; import com.alibaba.fastjson2.TypeReference;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
@ -223,6 +225,25 @@ public class JsonUtils {
} }
} }
/**
* 解析 JSON 数组为 String[]与套餐管理 gradeLevels 对齐
* 支持 ["小班","中班"] 小班,中班 格式
*/
public static String[] parseStringArray(String json) {
if (!StringUtils.hasText(json)) {
return new String[0];
}
try {
if (json.trim().startsWith("[")) {
return JSON.parseArray(json, String.class).toArray(new String[0]);
}
return json.split(",");
} catch (Exception e) {
log.warn("解析 JSON 数组失败: {}", json, e);
return new String[0];
}
}
/** /**
* 创建空的 JSONObject * 创建空的 JSONObject
* *

View File

@ -1,13 +1,16 @@
package com.reading.platform.controller.school; package com.reading.platform.controller.school;
import com.alibaba.fastjson2.JSON;
import com.reading.platform.common.response.Result; 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.response.SchoolCourseResponse;
import com.reading.platform.entity.CoursePackage; import com.reading.platform.entity.CoursePackage;
import com.reading.platform.service.CoursePackageService; import com.reading.platform.service.CoursePackageService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@ -15,6 +18,7 @@ import java.util.stream.Collectors;
/** /**
* 课程管理控制器学校端 * 课程管理控制器学校端
* gradeTags/domainTags 规范为 String[]与套餐管理适用年级对齐
*/ */
@Slf4j @Slf4j
@RestController @RestController
@ -27,21 +31,103 @@ public class SchoolCourseController {
@GetMapping @GetMapping
@Operation(summary = "获取学校课程包列表") @Operation(summary = "获取学校课程包列表")
public Result<List<CoursePackage>> getSchoolCourses( public Result<List<SchoolCourseResponse>> getSchoolCourses(
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@RequestParam(required = false) String grade) { @RequestParam(required = false) String grade) {
log.info("获取学校课程包列表keyword={}, grade={}", keyword, grade); log.info("获取学校课程包列表keyword={}, grade={}", keyword, grade);
Long tenantId = SecurityUtils.getCurrentTenantId(); Long tenantId = SecurityUtils.getCurrentTenantId();
List<CoursePackage> courses = courseService.getTenantPackageCourses(tenantId, keyword, grade); List<CoursePackage> courses = courseService.getTenantPackageCourses(tenantId, keyword, grade);
return Result.success(courses); List<SchoolCourseResponse> list = courses.stream()
.map(this::toSchoolCourseResponse)
.collect(Collectors.toList());
return Result.success(list);
} }
@GetMapping("/{id}") @GetMapping("/{id}")
@Operation(summary = "获取课程详情") @Operation(summary = "获取课程详情")
public Result<CoursePackage> getSchoolCourse(@PathVariable Long id) { public Result<SchoolCourseResponse> getSchoolCourse(@PathVariable Long id) {
log.info("获取课程详情id={}", id); log.info("获取课程详情id={}", id);
Long tenantId = SecurityUtils.getCurrentTenantId(); Long tenantId = SecurityUtils.getCurrentTenantId();
CoursePackage course = courseService.getCourseByIdWithTenantCheck(id, tenantId); CoursePackage course = courseService.getCourseByIdWithTenantCheck(id, tenantId);
return Result.success(course); return Result.success(toSchoolCourseResponse(course));
}
/**
* 转换为学校端课程响应gradeTags/domainTags 规范为 String[]
*/
private SchoolCourseResponse toSchoolCourseResponse(CoursePackage pkg) {
return SchoolCourseResponse.builder()
.id(pkg.getId())
.tenantId(pkg.getTenantId())
.name(pkg.getName())
.code(pkg.getCode())
.description(pkg.getDescription())
.pictureBookName(pkg.getPictureBookName())
.coverImagePath(pkg.getCoverImagePath())
.coverUrl(pkg.getCoverUrl())
.gradeTags(parseJsonArray(pkg.getGradeTags()))
.domainTags(parseJsonArray(pkg.getDomainTags()))
.duration(pkg.getDurationMinutes())
.usageCount(pkg.getUsageCount())
.teacherCount(pkg.getTeacherCount())
.avgRating(pkg.getAvgRating())
.status(pkg.getStatus())
.createdAt(pkg.getCreatedAt())
.updatedAt(pkg.getUpdatedAt())
.build();
}
/**
* 解析 JSON 数组为 String[]兼容多种格式
* - 标准 JSON: ["小班","中班"]
* - 逗号分隔: 小班,中班
* - 错误格式split 导致: ["[\"小班\"", " \"中班\""] -> 提取有效值
*/
private String[] parseJsonArray(String json) {
if (!StringUtils.hasText(json)) {
return new String[0];
}
String s = json.trim();
try {
if (s.startsWith("[")) {
var list = JSON.parseArray(s, String.class);
if (list != null && !list.isEmpty()) {
// 检查是否为被错误 split 的格式 ["[\"小班\"", " \"中班\""]
String first = list.get(0);
if (first != null && first.startsWith("[\"") && !first.contains(",")) {
return list.stream()
.map(String::trim)
.map(this::extractQuotedValue)
.filter(v -> v != null && !v.isEmpty())
.toArray(String[]::new);
}
return list.stream()
.map(v -> v != null ? v.trim() : "")
.filter(v -> !v.isEmpty())
.toArray(String[]::new);
}
return new String[0];
}
return java.util.Arrays.stream(s.split(","))
.map(String::trim)
.filter(v -> !v.isEmpty())
.toArray(String[]::new);
} catch (Exception e) {
log.warn("解析 JSON 数组失败: {}", json, e);
return new String[0];
}
}
private String extractQuotedValue(String s) {
if (s == null) return null;
s = s.trim();
int start = s.indexOf('"');
if (start >= 0) {
int end = s.indexOf('"', start + 1);
if (end > start) {
return s.substring(start + 1, end).trim();
}
}
return s.replaceAll("^\\[\"|\"\\]?$", "").trim();
} }
} }

View File

@ -138,11 +138,11 @@ public class CourseResponse {
@Schema(description = "评估数据") @Schema(description = "评估数据")
private String assessmentData; private String assessmentData;
@Schema(description = "年级标签") @Schema(description = "年级标签(规范为数组,与套餐管理适用年级对齐)")
private String gradeTags; private String[] gradeTags;
@Schema(description = "领域标签") @Schema(description = "领域标签(规范为数组)")
private String domainTags; private String[] domainTags;
@Schema(description = "是否有集体课") @Schema(description = "是否有集体课")
private Integer hasCollectiveLesson; private Integer hasCollectiveLesson;

View File

@ -0,0 +1,68 @@
package com.reading.platform.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 学校端课程响应gradeTags/domainTags 规范为 String[]与套餐管理适用年级对齐
*/
@Data
@Builder
@Schema(description = "学校端课程响应")
public class SchoolCourseResponse {
@Schema(description = "ID")
private Long id;
@Schema(description = "租户 ID")
private Long tenantId;
@Schema(description = "课程名称")
private String name;
@Schema(description = "课程编码")
private String code;
@Schema(description = "描述")
private String description;
@Schema(description = "绘本名称")
private String pictureBookName;
@Schema(description = "封面图片路径")
private String coverImagePath;
@Schema(description = "封面 URL")
private String coverUrl;
@Schema(description = "年级标签(规范为数组)")
private String[] gradeTags;
@Schema(description = "领域标签(规范为数组)")
private String[] domainTags;
@Schema(description = "课程时长(分钟)")
private Integer duration;
@Schema(description = "使用次数")
private Integer usageCount;
@Schema(description = "教师数量")
private Integer teacherCount;
@Schema(description = "平均评分")
private BigDecimal avgRating;
@Schema(description = "状态")
private String status;
@Schema(description = "创建时间")
private LocalDateTime createdAt;
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
}