feat(theme): 主题字典颜色、课程主题 Tag 展示与列表数据规范化

- 后端:theme 表增加 color 字段;主题创建/更新/课程响应返回 themeColor
- 前端:主题管理页颜色选择器与列表;管理端课程列表与详情主题 Tag
- 课程中心/课程包卡片展示主题 Tag,course-center 规范化接口字段
- 隐藏管理端课程配置列与筛选;课程详情关联主题使用 themeName/color

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-24 15:11:29 +08:00
parent 4376a4c238
commit 40782a8905
26 changed files with 457 additions and 138 deletions

View File

@ -33,6 +33,8 @@ export interface CoursePackage {
courses?: CoursePackageCourseItem[]; // 用于课程配置展示
themeId?: number;
themeName?: string;
/** 主题颜色(hex),来自主题字典 */
themeColor?: string;
durationMinutes?: number;
usageCount?: number;
avgRating?: number;
@ -66,6 +68,48 @@ export interface FilterMetaResponse {
themes?: ThemeOption[];
}
// ============= 响应规范化(学校端/教师端共用同一接口,兼容字段形态) =============
function normalizeGradeTags(raw: unknown): string[] {
if (raw == null) return [];
if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
if (typeof raw === 'string' && raw.trim()) {
const s = raw.trim();
if (s.startsWith('[')) {
try {
const j = JSON.parse(s);
return Array.isArray(j) ? j.map(String).filter(Boolean) : [];
} catch {
/* fallthrough */
}
}
return s.split(',').map((x) => x.trim()).filter(Boolean);
}
return [];
}
/** 统一课程包卡片所需字段,避免学校端/代理层返回 snake_case 或嵌套 theme 时主题不显示 */
export function normalizeCoursePackage(raw: any): CoursePackage {
if (!raw || typeof raw !== 'object') {
return raw as CoursePackage;
}
const nested =
raw.theme && typeof raw.theme === 'object' ? (raw.theme as Record<string, unknown>) : null;
const themeName = String(
raw.themeName ?? raw.theme_name ?? nested?.name ?? ''
).trim();
const themeColor = (raw.themeColor ?? raw.theme_color ?? nested?.color) as string | undefined;
const themeId = (raw.themeId ?? raw.theme_id ?? nested?.id) as number | undefined;
return {
...raw,
themeId: themeId ?? raw.themeId,
themeName: themeName || undefined,
themeColor: themeColor || undefined,
gradeTags: normalizeGradeTags(raw.gradeTags ?? raw.grade_tags),
};
}
// ============= API 接口 =============
/**
@ -87,9 +131,13 @@ export function getPackages(
keyword?: string;
}
): Promise<CoursePackage[]> {
return http.get<CoursePackage[]>(`/v1/school/packages/${collectionId}/packages`, {
return http
.get<any[]>(`/v1/school/packages/${collectionId}/packages`, {
params,
});
})
.then((list) =>
Array.isArray(list) ? list.map((item) => normalizeCoursePackage(item)) : []
);
}
/**

View File

@ -49,6 +49,8 @@ export interface Course {
themeId?: number;
theme?: { id: number; name: string };
themeName?: string;
/** 主题颜色(hex),来自主题字典 */
themeColor?: string;
coreContent?: string;
coverImagePath?: string;
domainTags?: string[];

View File

@ -4,6 +4,7 @@ export interface Theme {
id: number;
name: string;
description?: string;
color?: string;
sortOrder: number;
status: string;
createdAt: string;
@ -17,12 +18,14 @@ export interface Theme {
export interface CreateThemeData {
name: string;
description?: string;
color?: string;
sortOrder?: number;
}
export interface UpdateThemeData {
name?: string;
description?: string;
color?: string;
sortOrder?: number;
status?: string;
}

View File

@ -0,0 +1,50 @@
/**
*
*
*/
function hexToRgb(hex: string): [number, number, number] {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return [r, g, b];
}
function colorDistance(a: [number, number, number], b: [number, number, number]): number {
return Math.sqrt(
(a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2
);
}
/** 保留与已选颜色距离均大于该值的颜色,约 55 可去除明显相近色 */
const SIMILARITY_THRESHOLD = 55;
const RAW_COLORS = [
'#0780cf', '#73c0de', '#009db2', '#3ba272', '#024b51', '#0e2c82',
'#5470c6', '#91cc75', '#ee6666', '#9a60b4', '#fc8452', '#ea7ccc',
'#2196F3', '#08C9C9', '#00C345', '#F52222', '#FAD714', '#D3D3D3',
'#FF9F7F', '#FAC858', '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
'#9966FF', '#FF9F40', '#8AC24A', '#EA5F89', '#7CDDDD', '#F77825',
'#8FD3E8', '#A4DE6C', '#D0ED57', '#F7CAC9', '#92A8D1', '#88B04B',
'#E15D44', '#F3D6E4', '#B565A7', '#009B77',
'#FFD1DC', '#E1F7D5', '#C4C1E0', '#FFDAC1', '#B5EAD7', '#F8B195',
'#FF9AA2', '#FFB7B2', '#E2F0CB', '#C7CEEA', '#F67280', '#C06C84',
'#6C7A89', '#95A5A6', '#BDC3C7', '#2E86AB', '#A23B72', '#F18F01',
'#04A777', '#59114D',
'#4ECDC4', '#45B7D1', '#96CEB4', '#FF6B6B', '#FFEAA7', '#DDA0DD',
'#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9', '#F8C471', '#82E0AA',
'#58D68D', '#5DADE2', '#52BE80', '#7DCEA0', '#76D7C4',
];
export const THEME_COLOR_OPTIONS: string[] = (() => {
const result: string[] = [];
for (const hex of RAW_COLORS) {
const rgb = hexToRgb(hex);
const tooClose = result.some((kept) => {
const d = colorDistance(rgb, hexToRgb(kept));
return d < SIMILARITY_THRESHOLD;
});
if (!tooClose) result.push(hex);
}
return result;
})();

View File

@ -819,3 +819,42 @@ export function getUserRoleStyle(role: string): {
border: "none",
};
}
const THEME_HEX = /^#[0-9A-Fa-f]{6}$/;
function themeHexToRgb(hex: string): [number, number, number] {
const h = hex.startsWith("#") ? hex : `#${hex}`;
if (!THEME_HEX.test(h)) {
return [89, 89, 89];
}
return [
parseInt(h.slice(1, 3), 16),
parseInt(h.slice(3, 5), 16),
parseInt(h.slice(5, 7), 16),
];
}
/**
* Tag + /
*/
export function getThemeTagStyle(themeColor?: string | null): {
background: string;
color: string;
border: string;
} {
const raw = (themeColor || "").trim();
const hex = raw.startsWith("#") ? raw : raw ? `#${raw}` : "";
if (!hex || !THEME_HEX.test(hex)) {
return {
background: "#fafafa",
color: "#595959",
border: "1px solid #d9d9d9",
};
}
const [r, g, b] = themeHexToRgb(hex);
return {
background: `rgba(${r},${g},${b},0.12)`,
color: hex,
border: `1px solid rgba(${r},${g},${b},0.35)`,
};
}

View File

@ -43,7 +43,7 @@
<div class="info-row">
<span class="info-label">关联主题</span>
<span class="info-value">
<a-tag v-if="course.theme" color="blue">{{ course.theme.name }}</a-tag>
<a-tag v-if="themeDisplayName" :style="themeTagStyle">{{ themeDisplayName }}</a-tag>
<span v-else class="empty-text">未设置</span>
</span>
</div>
@ -404,7 +404,7 @@ import {
EnvironmentOutlined,
} from '@ant-design/icons-vue';
import * as courseApi from '@/api/course';
import { translateDomainTags } from '@/utils/tagMaps';
import { translateDomainTags, getThemeTagStyle } from '@/utils/tagMaps';
import FilePreviewModal from '@/components/FilePreviewModal.vue';
const router = useRouter();
@ -499,6 +499,13 @@ const domainTags = computed(() => {
}
});
const themeDisplayName = computed(() =>
(course.value.theme?.name || course.value.themeName || '').trim()
);
const themeTagStyle = computed(() =>
getThemeTagStyle(course.value.theme?.color || course.value.themeColor)
);
// 稿//
const canEdit = computed(() => {
const s = course.value.status;

View File

@ -54,22 +54,19 @@
</template>
<template v-else-if="column.key === 'theme'">
<span>{{ record.themeName || record.theme?.name || '-' }}</span>
<a-tag
v-if="record.themeName || record.theme?.name"
:style="getThemeTagStyle(record.themeColor)"
>
{{ record.themeName || record.theme?.name }}
</a-tag>
<span v-else class="empty-text">-</span>
</template>
<template v-else-if="column.key === 'pictureBook'">
{{ record.pictureBookName }}
</template>
<template v-else-if="column.key === 'lessonConfig'">
<div class="lesson-config-tags">
<a-tag v-for="lt in getLessonTypesFromRecord(record)" :key="lt" size="small" :style="getLessonTagStyle(lt)">
{{ getLessonTypeName(lt) }}
</a-tag>
<span v-if="!getLessonTypesFromRecord(record).length" class="empty-text">-</span>
</div>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :style="getCourseStatusStyle(record.status)">
{{ translateCourseStatus(record.status) }}
@ -203,18 +200,7 @@ import {useRouter} from 'vue-router';
import {message, Modal} from 'ant-design-vue';
import {AuditOutlined, DownOutlined, PlusOutlined} from '@ant-design/icons-vue';
import * as courseApi from '@/api/course';
import {getCourseStatusStyle, getGradeTagStyle, getLessonTagStyle, getLessonTypeName, translateCourseStatus, translateGradeTag,} from '@/utils/tagMaps';
//
const getLessonTypesFromRecord = (record: any): string[] => {
const lessons = record.courseLessons || record.lessons || [];
const types = new Set<string>();
for (const l of lessons) {
const t = l.lessonType;
if (t) types.add(t);
}
return Array.from(types);
};
import {getCourseStatusStyle, getGradeTagStyle, getThemeTagStyle, translateCourseStatus, translateGradeTag,} from '@/utils/tagMaps';
const router = useRouter();
@ -238,7 +224,6 @@ 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 },
{ title: '版本', key: 'version', width: 70 },
{ title: '数据统计', key: 'stats', width: 130 },

View File

@ -19,7 +19,11 @@
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<template v-if="column.key === 'color'">
<span v-if="record.color" class="color-swatch" :style="{ backgroundColor: record.color }" :title="record.color" />
<span v-else class="color-empty">-</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'ACTIVE' ? 'success' : 'default'">
{{ record.status === 'ACTIVE' ? '启用' : '归档' }}
</a-tag>
@ -50,6 +54,39 @@
<a-form-item label="描述">
<a-textarea v-model:value="form.description" placeholder="请输入主题描述" :rows="3" />
</a-form-item>
<a-form-item label="颜色">
<div class="color-input-row">
<a-input
v-model:value="form.color"
placeholder="#0780cf"
allow-clear
class="color-hex-input"
/>
<input
type="color"
class="native-color-input"
:value="nativePickerValue"
title="取色器"
@input="onNativeColorInput"
/>
</div>
<div class="color-picker">
<span
v-for="c in THEME_COLOR_OPTIONS"
:key="c"
class="color-option"
:class="{ selected: form.color === c }"
:style="{ backgroundColor: c }"
:title="c"
@click="form.color = c"
/>
<span
v-if="form.color"
class="color-clear"
@click="form.color = ''"
>清除</span>
</div>
</a-form-item>
<a-form-item label="排序">
<a-input-number v-model:value="form.sortOrder" :min="1" />
</a-form-item>
@ -59,7 +96,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ref, reactive, computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import {
@ -69,6 +106,7 @@ import {
deleteTheme,
} from '@/api/theme';
import type { Theme } from '@/api/theme';
import { THEME_COLOR_OPTIONS } from '@/constants/themeColors';
const loading = ref(false);
const dataSource = ref<Theme[]>([]);
@ -79,13 +117,26 @@ const editingId = ref<number | null>(null);
const form = reactive({
name: '',
description: '',
color: '' as string,
sortOrder: 1,
});
const HEX6 = /^#[0-9A-Fa-f]{6}$/;
/** 原生取色器需要合法 #RRGGBB否则用中性灰避免控件报错 */
const nativePickerValue = computed(() => {
const v = (form.color || '').trim();
return HEX6.test(v) ? v : '#cccccc';
});
function onNativeColorInput(e: Event) {
form.color = (e.target as HTMLInputElement).value;
}
const columns = [
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 80 },
{ title: '主题名称', dataIndex: 'name', key: 'name' },
{ title: '描述', dataIndex: 'description', key: 'description' },
{ title: '颜色', key: 'color', width: 80 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '操作', key: 'action', width: 150 },
];
@ -105,6 +156,7 @@ const fetchData = async () => {
const resetForm = () => {
form.name = '';
form.description = '';
form.color = '';
form.sortOrder = 1;
};
@ -136,6 +188,7 @@ const handleSave = async () => {
await updateTheme(editingId.value, {
name: form.name,
description: form.description,
color: form.color || undefined,
sortOrder: form.sortOrder,
});
message.success('更新成功');
@ -143,6 +196,7 @@ const handleSave = async () => {
await createTheme({
name: form.name,
description: form.description,
color: form.color || undefined,
sortOrder: form.sortOrder,
});
message.success('创建成功');
@ -173,4 +227,75 @@ onMounted(() => {
.theme-list-page {
padding: 24px;
}
.color-input-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.color-hex-input {
max-width: 180px;
}
.native-color-input {
width: 36px;
height: 32px;
padding: 0;
border: 1px solid #d9d9d9;
border-radius: 6px;
cursor: pointer;
vertical-align: middle;
}
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.color-option {
width: 28px;
height: 28px;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s;
}
.color-option:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.color-option.selected {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.color-clear {
font-size: 12px;
color: #999;
cursor: pointer;
padding: 4px 8px;
}
.color-clear:hover {
color: #1890ff;
}
.color-swatch {
display: inline-block;
width: 20px;
height: 20px;
border-radius: 4px;
vertical-align: middle;
border: 1px solid #e8e8e8;
}
.color-empty {
color: #bfbfbf;
}
</style>

View File

@ -70,19 +70,6 @@
</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 filteredLessonTypes" :key="opt.lessonType"
:value="opt.lessonType">
{{ opt.name }} ({{ opt.count }})
</a-select-option>
</a-select>
</div>
<!-- 搜索 -->
<div class="filter-group search-group">
@ -151,13 +138,6 @@ const selectedCollection = computed(() =>
//
const filterMeta = ref<FilterMetaResponse>({ grades: [], lessonTypes: [], themes: [] });
//
const EXCLUDED_LESSON_TYPES = new Set(['INTRODUCTION', 'INTRO', 'COLLECTIVE']);
const filteredLessonTypes = computed(() => {
const list = filterMeta.value.lessonTypes || [];
return list.filter(opt => !EXCLUDED_LESSON_TYPES.has((opt.lessonType || '').toUpperCase()));
});
//
const packages = ref<CoursePackage[]>([]);
const loadingPackages = ref(false);

View File

@ -21,24 +21,16 @@
<BookOutlined /> {{ pkg.pictureBookName }}
</p>
<!-- 年级标签行 -->
<div class="tag-row grade-row">
<span class="grade-tag">
<!-- 年级 + 课程主题与教师端一致主题色来自主题字典 -->
<div v-if="gradeText || themeDisplayName" class="tag-row grade-row">
<span v-if="gradeText" class="grade-tag">
{{ gradeText }}
</span>
<a-tag v-if="themeDisplayName" :style="themeTagStyle" class="theme-tag">
{{ themeDisplayName }}
</a-tag>
</div>
<!-- 课程配置标签行参考管理端 -->
<div v-if="lessonTypes.length > 0" class="tag-row config-row">
<span
v-for="lt in lessonTypes"
:key="lt"
class="config-tag"
:style="getLessonTagStyle(lt)"
>
{{ getLessonTypeName(lt) }}
</span>
</div>
<!-- 统计信息 -->
<div class="meta-row">
@ -71,7 +63,7 @@ import {
EyeOutlined,
} from '@ant-design/icons-vue';
import type { CoursePackage } from '@/api/course-center';
import { getLessonTypeName, getLessonTagStyle } from '@/utils/tagMaps';
import { getThemeTagStyle } from '@/utils/tagMaps';
const props = defineProps<{
pkg: CoursePackage;
@ -88,16 +80,20 @@ const gradeText = computed(() => {
return grades.join(' · ');
});
// 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 && !EXCLUDED_LESSON_TYPES.has(t.toUpperCase())) types.add(t);
}
return Array.from(types);
/** 与教师端一致:兼容 normalizeCoursePackage 及旧字段 */
const themeDisplayName = computed(() => {
const p = props.pkg as CoursePackage & {
theme_name?: string;
theme?: { name?: string; color?: string };
};
return (p.themeName || p.theme_name || p.theme?.name || '').trim();
});
const themeTagStyle = computed(() => {
const p = props.pkg as CoursePackage & {
theme_color?: string;
theme?: { name?: string; color?: string };
};
return getThemeTagStyle(p.themeColor || p.theme_color || p.theme?.color);
});
// URL
@ -207,6 +203,13 @@ const handleView = () => {
margin-bottom: 6px;
}
.grade-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.grade-tag {
display: inline-block;
padding: 2px 10px;
@ -217,6 +220,14 @@ const handleView = () => {
border: 1px solid #91d5ff;
}
.theme-tag {
margin: 0 !important;
font-size: 12px;
line-height: 1.4;
padding: 0 8px;
border-radius: 4px;
}
.config-row {
display: flex;
flex-wrap: wrap;

View File

@ -39,7 +39,7 @@
<div class="info-row">
<span class="info-label">关联主题</span>
<span class="info-value">
<a-tag v-if="course.theme" color="blue">{{ course.theme.name }}</a-tag>
<a-tag v-if="themeDisplayName" :style="themeTagStyle">{{ themeDisplayName }}</a-tag>
<span v-else class="empty-text">未设置</span>
</span>
</div>
@ -457,7 +457,7 @@ import {
EnvironmentOutlined,
} from '@ant-design/icons-vue';
import * as schoolApi from '@/api/school';
import { translateDomainTags } from '@/utils/tagMaps';
import { translateDomainTags, getThemeTagStyle } from '@/utils/tagMaps';
import { parseGradeLevels } from '@/api/collections';
import FilePreviewModal from '@/components/FilePreviewModal.vue';
@ -539,6 +539,14 @@ const domainTags = computed(() =>
translateDomainTags(parseGradeLevels(course.value.domainTags ?? course.value.domain_tags))
);
/** 后端返回 themeName/themeColor兼容旧字段 course.theme */
const themeDisplayName = computed(() =>
(course.value.theme?.name || course.value.themeName || '').trim()
);
const themeTagStyle = computed(() =>
getThemeTagStyle(course.value.theme?.color || course.value.themeColor)
);
//
const hasIntroContent = computed(() => {
return course.value.introSummary || course.value.introHighlights ||

View File

@ -69,20 +69,6 @@
</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 filteredLessonTypes" :key="opt.lessonType"
:value="opt.lessonType">
{{ opt.name }} ({{ opt.count }})
</a-select-option>
</a-select>
</div>
<!-- 搜索 -->
<div class="filter-group search-group">
@ -149,13 +135,6 @@ const selectedCollection = computed(() =>
//
const filterMeta = ref<FilterMetaResponse>({ grades: [], lessonTypes: [], themes: [] });
//
const EXCLUDED_LESSON_TYPES = new Set(['INTRODUCTION', 'INTRO', 'COLLECTIVE']);
const filteredLessonTypes = computed(() => {
const list = filterMeta.value.lessonTypes || [];
return list.filter(opt => !EXCLUDED_LESSON_TYPES.has((opt.lessonType || '').toUpperCase()));
});
//
const packages = ref<CoursePackage[]>([]);
const loadingPackages = ref(false);

View File

@ -21,23 +21,14 @@
<BookOutlined /> {{ pkg.pictureBookName }}
</p>
<!-- 年级标签行 -->
<div class="tag-row grade-row">
<span class="grade-tag">
<!-- 年级 + 课程主题 -->
<div v-if="gradeText || themeDisplayName" class="tag-row grade-row">
<span v-if="gradeText" class="grade-tag">
{{ gradeText }}
</span>
</div>
<!-- 课程配置标签行参考管理端 -->
<div v-if="lessonTypes.length > 0" class="tag-row config-row">
<span
v-for="lt in lessonTypes"
:key="lt"
class="config-tag"
:style="getLessonTagStyle(lt)"
>
{{ getLessonTypeName(lt) }}
</span>
<a-tag v-if="themeDisplayName" :style="themeTagStyle" class="theme-tag">
{{ themeDisplayName }}
</a-tag>
</div>
<!-- 统计信息 -->
@ -71,7 +62,7 @@ import {
EditOutlined,
} from '@ant-design/icons-vue';
import type { CoursePackage } from '@/api/course-center';
import { getLessonTypeName, getLessonTagStyle } from '@/utils/tagMaps';
import { getThemeTagStyle } from '@/utils/tagMaps';
const props = defineProps<{
pkg: CoursePackage;
@ -88,16 +79,19 @@ const gradeText = computed(() => {
return grades.join(' · ');
});
// 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 && !EXCLUDED_LESSON_TYPES.has(t.toUpperCase())) types.add(t);
}
return Array.from(types);
const themeDisplayName = computed(() => {
const p = props.pkg as CoursePackage & {
theme_name?: string;
theme?: { name?: string; color?: string };
};
return (p.themeName || p.theme_name || p.theme?.name || '').trim();
});
const themeTagStyle = computed(() => {
const p = props.pkg as CoursePackage & {
theme_color?: string;
theme?: { name?: string; color?: string };
};
return getThemeTagStyle(p.themeColor || p.theme_color || p.theme?.color);
});
// URL
@ -208,7 +202,18 @@ const handlePrepare = () => {
}
.grade-row {
/* 年级标签样式 */
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.theme-tag {
margin: 0 !important;
font-size: 12px;
line-height: 1.4;
padding: 0 8px;
border-radius: 4px;
}
.grade-tag {

View File

@ -45,7 +45,7 @@
<div class="info-row">
<span class="info-label">关联主题</span>
<span class="info-value">
<a-tag v-if="course.theme" color="blue">{{ course.theme.name }}</a-tag>
<a-tag v-if="themeDisplayName" :style="themeTagStyle">{{ themeDisplayName }}</a-tag>
<span v-else class="empty-text">未设置</span>
</span>
</div>
@ -467,7 +467,7 @@ import {
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import * as teacherApi from '@/api/teacher';
import { translateGradeTags, translateDomainTags } from '@/utils/tagMaps';
import { translateGradeTags, translateDomainTags, getThemeTagStyle } from '@/utils/tagMaps';
import FilePreviewModal from '@/components/FilePreviewModal.vue';
const router = useRouter();
@ -565,6 +565,13 @@ const domainTags = computed(() => {
}
});
const themeDisplayName = computed(() =>
(course.value.theme?.name || course.value.themeName || '').trim()
);
const themeTagStyle = computed(() =>
getThemeTagStyle(course.value.theme?.color || course.value.themeColor)
);
//
const hasIntroContent = computed(() => {
return course.value.introSummary || course.value.introHighlights ||

View File

@ -12,7 +12,8 @@
{{ course.pictureBookName || '-' }}
</a-descriptions-item>
<a-descriptions-item label="课程主题">
{{ course.theme?.name || '-' }}
<a-tag v-if="themeDisplayName" :style="themeTagStyle">{{ themeDisplayName }}</a-tag>
<span v-else>-</span>
</a-descriptions-item>
<a-descriptions-item label="预计时长">
{{ totalDuration }} 分钟
@ -77,6 +78,13 @@ const translatedGradeTags = computed(() => {
return translateGradeTags(props.course.gradeTags || []);
});
const themeDisplayName = computed(() =>
(props.course.theme?.name || props.course.themeName || '').trim()
);
const themeTagStyle = computed(() =>
getThemeTagStyle(props.course.theme?.color || props.course.themeColor)
);
const totalDuration = computed(() => {
return props.course.courseLessons?.reduce((sum: number, l: any) => sum + (l.duration || 0), 0) || 0;
});

View File

@ -55,6 +55,7 @@ public class AdminThemeController {
Theme theme = themeService.create(
request.getName(),
request.getDescription(),
request.getColor(),
request.getSortOrder()
);
return Result.success(toResponse(theme));
@ -71,6 +72,7 @@ public class AdminThemeController {
id,
request.getName(),
request.getDescription(),
request.getColor(),
request.getSortOrder(),
null
);
@ -103,6 +105,7 @@ public class AdminThemeController {
.id(theme.getId())
.name(theme.getName())
.description(theme.getDescription())
.color(theme.getColor())
.sortOrder(theme.getSortOrder())
.status(theme.getStatus())
.createdAt(theme.getCreatedAt())

View File

@ -18,6 +18,9 @@ public class ThemeCreateRequest {
@Schema(description = "主题描述")
private String description;
@Schema(description = "主题颜色(hex)")
private String color;
@Schema(description = "排序号")
private Integer sortOrder;
}

View File

@ -87,6 +87,9 @@ public class CoursePackageResponse {
@Schema(description = "主题名称")
private String themeName;
@Schema(description = "主题颜色(hex),来自主题字典")
private String themeColor;
@Schema(description = "绘本名称")
private String pictureBookName;

View File

@ -99,6 +99,9 @@ public class CourseResponse {
@Schema(description = "主题名称")
private String themeName;
@Schema(description = "主题颜色(hex),来自主题字典")
private String themeColor;
@Schema(description = "绘本名称")
private String pictureBookName;

View File

@ -28,6 +28,9 @@ public class ThemeResponse {
@Schema(description = "主题描述")
private String description;
@Schema(description = "主题颜色(hex)")
private String color;
@Schema(description = "排序号")
private Integer sortOrder;

View File

@ -20,6 +20,9 @@ public class Theme extends BaseEntity {
@Schema(description = "主题描述")
private String description;
@Schema(description = "主题颜色(hex)")
private String color;
@Schema(description = "排序号")
private Integer sortOrder;

View File

@ -23,12 +23,12 @@ public interface ThemeService extends IService<Theme> {
/**
* 创建主题
*/
Theme create(String name, String description, Integer sortOrder);
Theme create(String name, String description, String color, Integer sortOrder);
/**
* 更新主题
*/
Theme update(Long id, String name, String description, Integer sortOrder, String status);
Theme update(Long id, String name, String description, String color, Integer sortOrder, String status);
/**
* 删除主题

View File

@ -194,6 +194,8 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
})
.collect(Collectors.toList());
enrichCoursePackageThemeFields(packages, result);
log.info("查询到{}个课程包", result.size());
return result;
}
@ -262,22 +264,26 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
.map(CoursePackage::getThemeId)
.filter(id -> id != null)
.collect(Collectors.toSet());
Map<Long, String> themeNameMap = new HashMap<>();
Map<Long, Theme> themeEntityMap = new HashMap<>();
if (!themeIds.isEmpty()) {
List<Theme> themes = themeMapper.selectBatchIds(themeIds);
themeNameMap = themes.stream()
.collect(Collectors.toMap(Theme::getId, Theme::getName));
themeEntityMap = themes.stream()
.collect(Collectors.toMap(Theme::getId, t -> t));
}
// 转换为响应对象
final Map<Long, String> finalThemeNameMap = themeNameMap;
final Map<Long, Theme> finalThemeMap = themeEntityMap;
List<CoursePackageResponse> result = packages.stream()
.map(pkg -> {
CoursePackageResponse response = toPackageResponse(pkg);
// 设置主题名称
// 设置主题名称与颜色
if (pkg.getThemeId() != null) {
response.setThemeId(pkg.getThemeId());
response.setThemeName(finalThemeNameMap.get(pkg.getThemeId()));
Theme t = finalThemeMap.get(pkg.getThemeId());
if (t != null) {
response.setThemeName(t.getName());
response.setThemeColor(t.getColor());
}
}
// 设置排序号
associations.stream()
@ -887,6 +893,37 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
.build();
}
/**
* 批量填充课程包的主题名称颜色 theme 表一致
*/
private void enrichCoursePackageThemeFields(List<CoursePackage> packages, List<CoursePackageResponse> responses) {
if (packages == null || responses == null || packages.size() != responses.size()) {
return;
}
Set<Long> themeIds = packages.stream()
.map(CoursePackage::getThemeId)
.filter(id -> id != null)
.collect(Collectors.toSet());
if (themeIds.isEmpty()) {
return;
}
List<Theme> themes = themeMapper.selectBatchIds(themeIds);
Map<Long, Theme> themeMap = themes.stream()
.collect(Collectors.toMap(Theme::getId, t -> t));
for (int i = 0; i < packages.size(); i++) {
CoursePackage pkg = packages.get(i);
CoursePackageResponse resp = responses.get(i);
if (pkg.getThemeId() == null) {
continue;
}
Theme t = themeMap.get(pkg.getThemeId());
if (t != null) {
resp.setThemeName(t.getName());
resp.setThemeColor(t.getColor());
}
}
}
/**
* 转换为课程包响应对象包含课程列表和排课计划参考
*/

View File

@ -111,6 +111,7 @@ public class CoursePackageServiceImpl extends ServiceImpl<CoursePackageMapper, C
Theme theme = themeMapper.selectById(entity.getThemeId());
if (theme != null) {
response.setThemeName(theme.getName());
response.setThemeColor(theme.getColor());
}
}
List<CourseLesson> lessons = courseLessonService.findByCourseId(id);

View File

@ -59,7 +59,7 @@ public class ThemeServiceImpl extends ServiceImpl<ThemeMapper, Theme> implements
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Theme create(String name, String description, Integer sortOrder) {
public Theme create(String name, String description, String color, Integer sortOrder) {
log.info("创建主题name={}, sortOrder={}", name, sortOrder);
// 获取最大排序号
Integer maxSortOrder = themeMapper.selectList(null)
@ -71,6 +71,7 @@ public class ThemeServiceImpl extends ServiceImpl<ThemeMapper, Theme> implements
Theme theme = new Theme();
theme.setName(name);
theme.setDescription(description);
theme.setColor(color);
theme.setSortOrder(sortOrder != null ? sortOrder : maxSortOrder + 1);
theme.setStatus(GenericStatus.ACTIVE.getCode());
themeMapper.insert(theme);
@ -84,7 +85,7 @@ public class ThemeServiceImpl extends ServiceImpl<ThemeMapper, Theme> implements
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Theme update(Long id, String name, String description, Integer sortOrder, String status) {
public Theme update(Long id, String name, String description, String color, Integer sortOrder, String status) {
log.info("更新主题id={}, name={}, status={}", id, name, status);
Theme theme = themeMapper.selectById(id);
if (theme == null) {
@ -98,6 +99,9 @@ public class ThemeServiceImpl extends ServiceImpl<ThemeMapper, Theme> implements
if (description != null) {
theme.setDescription(description);
}
if (color != null) {
theme.setColor(color);
}
if (sortOrder != null) {
theme.setSortOrder(sortOrder);
}

View File

@ -0,0 +1,2 @@
-- 主题字典表添加颜色字段
ALTER TABLE theme ADD COLUMN color VARCHAR(20) DEFAULT NULL COMMENT '主题颜色(hex)' AFTER description;