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;
coverUrl?: string;
coverImagePath?: string;
pictureBookName?: string;
category?: string;
ageRange?: string;
difficultyLevel?: string;
durationMinutes?: number;
duration?: number;
objectives?: string;
status: string;
isSystem: number;
version?: string;
usageCount?: number;
teacherCount?: number;
gradeTags?: string[];
domainTags?: string[];
createdAt?: string;
updatedAt?: string;
publishedAt?: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -69,11 +69,11 @@
<p class="course-book">{{ course.pictureBookName }}</p>
<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))">
{{ translateGradeTag(tag) }}
</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))">
{{ translateDomainTag(tag) }}
</span>
@ -139,7 +139,8 @@
</template>
<div class="auth-content">
<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 class="available-courses" v-if="!authLoading && availableCourses.length > 0">
@ -154,9 +155,9 @@
</div>
<div class="course-info">
<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">
<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 }}
</span>
</div>
@ -204,6 +205,7 @@ import {
getGradeTagStyle,
getDomainTagStyle,
} from '@/utils/tagMaps';
import { parseGradeLevels } from '@/api/collections';
import * as schoolApi from '@/api/school';
const router = useRouter();
@ -214,21 +216,6 @@ const searchKeyword = ref('');
const selectedCourseIds = ref<number[]>([]);
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 = [
{ label: '全部', value: '' },
@ -290,8 +277,6 @@ const loadCourses = async () => {
const data = await schoolApi.getSchoolCourses(params);
courses.value = (data || []).map((course: any) => ({
...course,
gradeTags: parseTags(course.gradeTags),
domainTags: parseTags(course.domainTags),
duration: course.duration ?? course.durationMinutes ?? 0,
pictureUrl: course.pictureUrl ?? course.coverImagePath ?? course.coverUrl,
authorized: course.authorized ?? true,

View File

@ -539,27 +539,29 @@ const previewModalVisible = ref(false);
const previewFileUrl = ref('');
const previewFileName = ref('');
//
// gradeTags String[]
const grades = computed(() => {
if (!course.value.gradeTags) return [];
const val = course.value.gradeTags;
if (!val) return [];
if (Array.isArray(val)) return translateGradeTags(val);
try {
const tags = JSON.parse(course.value.gradeTags);
const translated = Array.isArray(tags) ? tags : [];
return translateGradeTags(translated);
const tags = JSON.parse(val);
return translateGradeTags(Array.isArray(tags) ? tags : []);
} catch {
return Array.isArray(course.value.gradeTags) ? translateGradeTags(course.value.gradeTags) : [];
return [];
}
});
//
// domainTags String[]
const domainTags = computed(() => {
if (!course.value.domainTags) return [];
const val = course.value.domainTags;
if (!val) return [];
if (Array.isArray(val)) return translateDomainTags(val);
try {
const tags = JSON.parse(course.value.domainTags);
const arr = Array.isArray(tags) ? tags : [];
return translateDomainTags(arr);
const tags = JSON.parse(val);
return translateDomainTags(Array.isArray(tags) ? tags : []);
} 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: '数学',
};
// JSON
// parseGradeLevels
const parseTags = (val: any): string[] => {
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') {
try {
const parsed = JSON.parse(val);

View File

@ -237,7 +237,7 @@ const loadCourseData = async () => {
pictureBookName: data.sourceCourse?.name || '',
theme: data.themeId ? { id: data.themeId, name: '' } : null,
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) : [],
duration: data.duration || 25,
coreContent: data.coreContent || '',

View File

@ -265,7 +265,7 @@ const fetchDetail = async () => {
//
formData.basic.name = data.name || '';
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.coreContent = data.coreContent || data.core_content || '';
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.entity.CoursePackage;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;
import java.util.List;
/**
* Course Entity Mapper
* gradeTags/domainTags 规范为 String[]与套餐管理适用年级对齐
*/
@Mapper(componentModel = "spring")
public interface CoursePackageMapper {
@ -18,6 +20,8 @@ public interface CoursePackageMapper {
/**
* 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);
/**
@ -28,5 +32,7 @@ public interface CoursePackageMapper {
/**
* 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);
}

View File

@ -1,9 +1,11 @@
package com.reading.platform.common.util;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.alibaba.fastjson2.TypeReference;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
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
*

View File

@ -1,13 +1,16 @@
package com.reading.platform.controller.school;
import com.alibaba.fastjson2.JSON;
import com.reading.platform.common.response.Result;
import com.reading.platform.common.security.SecurityUtils;
import com.reading.platform.dto.response.SchoolCourseResponse;
import com.reading.platform.entity.CoursePackage;
import com.reading.platform.service.CoursePackageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@ -15,6 +18,7 @@ import java.util.stream.Collectors;
/**
* 课程管理控制器学校端
* gradeTags/domainTags 规范为 String[]与套餐管理适用年级对齐
*/
@Slf4j
@RestController
@ -27,21 +31,103 @@ public class SchoolCourseController {
@GetMapping
@Operation(summary = "获取学校课程包列表")
public Result<List<CoursePackage>> getSchoolCourses(
public Result<List<SchoolCourseResponse>> getSchoolCourses(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String grade) {
log.info("获取学校课程包列表keyword={}, grade={}", keyword, grade);
Long tenantId = SecurityUtils.getCurrentTenantId();
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}")
@Operation(summary = "获取课程详情")
public Result<CoursePackage> getSchoolCourse(@PathVariable Long id) {
public Result<SchoolCourseResponse> getSchoolCourse(@PathVariable Long id) {
log.info("获取课程详情id={}", id);
Long tenantId = SecurityUtils.getCurrentTenantId();
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 = "评估数据")
private String assessmentData;
@Schema(description = "年级标签")
private String gradeTags;
@Schema(description = "年级标签(规范为数组,与套餐管理适用年级对齐)")
private String[] gradeTags;
@Schema(description = "领域标签")
private String domainTags;
@Schema(description = "领域标签(规范为数组)")
private String[] domainTags;
@Schema(description = "是否有集体课")
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;
}