feat(theme): 主题字典颜色、课程主题 Tag 展示与列表数据规范化
- 后端:theme 表增加 color 字段;主题创建/更新/课程响应返回 themeColor - 前端:主题管理页颜色选择器与列表;管理端课程列表与详情主题 Tag - 课程中心/课程包卡片展示主题 Tag,course-center 规范化接口字段 - 隐藏管理端课程配置列与筛选;课程详情关联主题使用 themeName/color Made-with: Cursor
This commit is contained in:
parent
4376a4c238
commit
40782a8905
@ -33,6 +33,8 @@ export interface CoursePackage {
|
|||||||
courses?: CoursePackageCourseItem[]; // 用于课程配置展示
|
courses?: CoursePackageCourseItem[]; // 用于课程配置展示
|
||||||
themeId?: number;
|
themeId?: number;
|
||||||
themeName?: string;
|
themeName?: string;
|
||||||
|
/** 主题颜色(hex),来自主题字典 */
|
||||||
|
themeColor?: string;
|
||||||
durationMinutes?: number;
|
durationMinutes?: number;
|
||||||
usageCount?: number;
|
usageCount?: number;
|
||||||
avgRating?: number;
|
avgRating?: number;
|
||||||
@ -66,6 +68,48 @@ export interface FilterMetaResponse {
|
|||||||
themes?: ThemeOption[];
|
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 接口 =============
|
// ============= API 接口 =============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -87,9 +131,13 @@ export function getPackages(
|
|||||||
keyword?: string;
|
keyword?: string;
|
||||||
}
|
}
|
||||||
): Promise<CoursePackage[]> {
|
): Promise<CoursePackage[]> {
|
||||||
return http.get<CoursePackage[]>(`/v1/school/packages/${collectionId}/packages`, {
|
return http
|
||||||
params,
|
.get<any[]>(`/v1/school/packages/${collectionId}/packages`, {
|
||||||
});
|
params,
|
||||||
|
})
|
||||||
|
.then((list) =>
|
||||||
|
Array.isArray(list) ? list.map((item) => normalizeCoursePackage(item)) : []
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -49,6 +49,8 @@ export interface Course {
|
|||||||
themeId?: number;
|
themeId?: number;
|
||||||
theme?: { id: number; name: string };
|
theme?: { id: number; name: string };
|
||||||
themeName?: string;
|
themeName?: string;
|
||||||
|
/** 主题颜色(hex),来自主题字典 */
|
||||||
|
themeColor?: string;
|
||||||
coreContent?: string;
|
coreContent?: string;
|
||||||
coverImagePath?: string;
|
coverImagePath?: string;
|
||||||
domainTags?: string[];
|
domainTags?: string[];
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export interface Theme {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
color?: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
status: string;
|
status: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@ -17,12 +18,14 @@ export interface Theme {
|
|||||||
export interface CreateThemeData {
|
export interface CreateThemeData {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
color?: string;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateThemeData {
|
export interface UpdateThemeData {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
color?: string;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
50
reading-platform-frontend/src/constants/themeColors.ts
Normal file
50
reading-platform-frontend/src/constants/themeColors.ts
Normal 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;
|
||||||
|
})();
|
||||||
@ -819,3 +819,42 @@ export function getUserRoleStyle(role: string): {
|
|||||||
border: "none",
|
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)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -43,7 +43,7 @@
|
|||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">关联主题</span>
|
<span class="info-label">关联主题</span>
|
||||||
<span class="info-value">
|
<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 v-else class="empty-text">未设置</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -404,7 +404,7 @@ import {
|
|||||||
EnvironmentOutlined,
|
EnvironmentOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import * as courseApi from '@/api/course';
|
import * as courseApi from '@/api/course';
|
||||||
import { translateDomainTags } from '@/utils/tagMaps';
|
import { translateDomainTags, getThemeTagStyle } from '@/utils/tagMaps';
|
||||||
import FilePreviewModal from '@/components/FilePreviewModal.vue';
|
import FilePreviewModal from '@/components/FilePreviewModal.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
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 canEdit = computed(() => {
|
||||||
const s = course.value.status;
|
const s = course.value.status;
|
||||||
|
|||||||
@ -54,22 +54,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'theme'">
|
<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>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'pictureBook'">
|
<template v-else-if="column.key === 'pictureBook'">
|
||||||
{{ record.pictureBookName }}
|
{{ record.pictureBookName }}
|
||||||
</template>
|
</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'">
|
<template v-else-if="column.key === 'status'">
|
||||||
<a-tag :style="getCourseStatusStyle(record.status)">
|
<a-tag :style="getCourseStatusStyle(record.status)">
|
||||||
{{ translateCourseStatus(record.status) }}
|
{{ translateCourseStatus(record.status) }}
|
||||||
@ -203,18 +200,7 @@ import {useRouter} from 'vue-router';
|
|||||||
import {message, Modal} from 'ant-design-vue';
|
import {message, Modal} from 'ant-design-vue';
|
||||||
import {AuditOutlined, DownOutlined, PlusOutlined} from '@ant-design/icons-vue';
|
import {AuditOutlined, DownOutlined, PlusOutlined} from '@ant-design/icons-vue';
|
||||||
import * as courseApi from '@/api/course';
|
import * as courseApi from '@/api/course';
|
||||||
import {getCourseStatusStyle, getGradeTagStyle, getLessonTagStyle, getLessonTypeName, translateCourseStatus, translateGradeTag,} from '@/utils/tagMaps';
|
import {getCourseStatusStyle, getGradeTagStyle, getThemeTagStyle, 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);
|
|
||||||
};
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -238,7 +224,6 @@ const columns = [
|
|||||||
{ title: '课程包名称', key: 'name', width: 250 },
|
{ title: '课程包名称', key: 'name', width: 250 },
|
||||||
{ title: '课程主题', key: 'theme', width: 120 },
|
{ title: '课程主题', key: 'theme', width: 120 },
|
||||||
{ title: '关联绘本', key: 'pictureBook', width: 120 },
|
{ title: '关联绘本', key: 'pictureBook', width: 120 },
|
||||||
{ title: '课程配置', key: 'lessonConfig', width: 200 },
|
|
||||||
{ title: '状态', key: 'status', width: 90 },
|
{ title: '状态', key: 'status', width: 90 },
|
||||||
{ title: '版本', key: 'version', width: 70 },
|
{ title: '版本', key: 'version', width: 70 },
|
||||||
{ title: '数据统计', key: 'stats', width: 130 },
|
{ title: '数据统计', key: 'stats', width: 130 },
|
||||||
|
|||||||
@ -19,7 +19,11 @@
|
|||||||
row-key="id"
|
row-key="id"
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<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'">
|
<a-tag :color="record.status === 'ACTIVE' ? 'success' : 'default'">
|
||||||
{{ record.status === 'ACTIVE' ? '启用' : '归档' }}
|
{{ record.status === 'ACTIVE' ? '启用' : '归档' }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
@ -50,6 +54,39 @@
|
|||||||
<a-form-item label="描述">
|
<a-form-item label="描述">
|
||||||
<a-textarea v-model:value="form.description" placeholder="请输入主题描述" :rows="3" />
|
<a-textarea v-model:value="form.description" placeholder="请输入主题描述" :rows="3" />
|
||||||
</a-form-item>
|
</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-form-item label="排序">
|
||||||
<a-input-number v-model:value="form.sortOrder" :min="1" />
|
<a-input-number v-model:value="form.sortOrder" :min="1" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@ -59,7 +96,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue';
|
import { ref, reactive, computed, onMounted } from 'vue';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||||
import {
|
import {
|
||||||
@ -69,6 +106,7 @@ import {
|
|||||||
deleteTheme,
|
deleteTheme,
|
||||||
} from '@/api/theme';
|
} from '@/api/theme';
|
||||||
import type { Theme } from '@/api/theme';
|
import type { Theme } from '@/api/theme';
|
||||||
|
import { THEME_COLOR_OPTIONS } from '@/constants/themeColors';
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const dataSource = ref<Theme[]>([]);
|
const dataSource = ref<Theme[]>([]);
|
||||||
@ -79,13 +117,26 @@ const editingId = ref<number | null>(null);
|
|||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
color: '' as string,
|
||||||
sortOrder: 1,
|
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 = [
|
const columns = [
|
||||||
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 80 },
|
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 80 },
|
||||||
{ title: '主题名称', dataIndex: 'name', key: 'name' },
|
{ title: '主题名称', dataIndex: 'name', key: 'name' },
|
||||||
{ title: '描述', dataIndex: 'description', key: 'description' },
|
{ title: '描述', dataIndex: 'description', key: 'description' },
|
||||||
|
{ title: '颜色', key: 'color', width: 80 },
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||||
{ title: '操作', key: 'action', width: 150 },
|
{ title: '操作', key: 'action', width: 150 },
|
||||||
];
|
];
|
||||||
@ -105,6 +156,7 @@ const fetchData = async () => {
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
form.name = '';
|
form.name = '';
|
||||||
form.description = '';
|
form.description = '';
|
||||||
|
form.color = '';
|
||||||
form.sortOrder = 1;
|
form.sortOrder = 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -136,6 +188,7 @@ const handleSave = async () => {
|
|||||||
await updateTheme(editingId.value, {
|
await updateTheme(editingId.value, {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
|
color: form.color || undefined,
|
||||||
sortOrder: form.sortOrder,
|
sortOrder: form.sortOrder,
|
||||||
});
|
});
|
||||||
message.success('更新成功');
|
message.success('更新成功');
|
||||||
@ -143,6 +196,7 @@ const handleSave = async () => {
|
|||||||
await createTheme({
|
await createTheme({
|
||||||
name: form.name,
|
name: form.name,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
|
color: form.color || undefined,
|
||||||
sortOrder: form.sortOrder,
|
sortOrder: form.sortOrder,
|
||||||
});
|
});
|
||||||
message.success('创建成功');
|
message.success('创建成功');
|
||||||
@ -173,4 +227,75 @@ onMounted(() => {
|
|||||||
.theme-list-page {
|
.theme-list-page {
|
||||||
padding: 24px;
|
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>
|
</style>
|
||||||
|
|||||||
@ -70,19 +70,6 @@
|
|||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</div>
|
</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">
|
<div class="filter-group search-group">
|
||||||
@ -151,13 +138,6 @@ const selectedCollection = computed(() =>
|
|||||||
// 筛选元数据
|
// 筛选元数据
|
||||||
const filterMeta = ref<FilterMetaResponse>({ grades: [], lessonTypes: [], themes: [] });
|
const filterMeta = ref<FilterMetaResponse>({ grades: [], lessonTypes: [], themes: [] });
|
||||||
|
|
||||||
// 课程配置筛选选项(过滤导入课、集体课)
|
|
||||||
const EXCLUDED_LESSON_TYPES = new Set(['INTRODUCTION', 'INTRO', 'COLLECTIVE']);
|
|
||||||
const filteredLessonTypes = computed(() => {
|
|
||||||
const list = filterMeta.value.lessonTypes || [];
|
|
||||||
return list.filter(opt => !EXCLUDED_LESSON_TYPES.has((opt.lessonType || '').toUpperCase()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 课程包列表
|
// 课程包列表
|
||||||
const packages = ref<CoursePackage[]>([]);
|
const packages = ref<CoursePackage[]>([]);
|
||||||
const loadingPackages = ref(false);
|
const loadingPackages = ref(false);
|
||||||
|
|||||||
@ -21,24 +21,16 @@
|
|||||||
<BookOutlined /> {{ pkg.pictureBookName }}
|
<BookOutlined /> {{ pkg.pictureBookName }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- 年级标签行 -->
|
<!-- 年级 + 课程主题(与教师端一致,主题色来自主题字典) -->
|
||||||
<div class="tag-row grade-row">
|
<div v-if="gradeText || themeDisplayName" class="tag-row grade-row">
|
||||||
<span class="grade-tag">
|
<span v-if="gradeText" class="grade-tag">
|
||||||
{{ gradeText }}
|
{{ gradeText }}
|
||||||
</span>
|
</span>
|
||||||
|
<a-tag v-if="themeDisplayName" :style="themeTagStyle" class="theme-tag">
|
||||||
|
{{ themeDisplayName }}
|
||||||
|
</a-tag>
|
||||||
</div>
|
</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">
|
<div class="meta-row">
|
||||||
@ -71,7 +63,7 @@ import {
|
|||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import type { CoursePackage } from '@/api/course-center';
|
import type { CoursePackage } from '@/api/course-center';
|
||||||
import { getLessonTypeName, getLessonTagStyle } from '@/utils/tagMaps';
|
import { getThemeTagStyle } from '@/utils/tagMaps';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
pkg: CoursePackage;
|
pkg: CoursePackage;
|
||||||
@ -88,16 +80,20 @@ const gradeText = computed(() => {
|
|||||||
return grades.join(' · ');
|
return grades.join(' · ');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 从 courses 提取课程类型列表(去重,过滤导入课、集体课)
|
/** 与教师端一致:兼容 normalizeCoursePackage 及旧字段 */
|
||||||
const EXCLUDED_LESSON_TYPES = new Set(['INTRODUCTION', 'INTRO', 'COLLECTIVE']);
|
const themeDisplayName = computed(() => {
|
||||||
const lessonTypes = computed(() => {
|
const p = props.pkg as CoursePackage & {
|
||||||
const courses = props.pkg.courses || [];
|
theme_name?: string;
|
||||||
const types = new Set<string>();
|
theme?: { name?: string; color?: string };
|
||||||
for (const c of courses) {
|
};
|
||||||
const t = c.lessonType;
|
return (p.themeName || p.theme_name || p.theme?.name || '').trim();
|
||||||
if (t && !EXCLUDED_LESSON_TYPES.has(t.toUpperCase())) types.add(t);
|
});
|
||||||
}
|
const themeTagStyle = computed(() => {
|
||||||
return Array.from(types);
|
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
|
// 获取图片完整 URL
|
||||||
@ -207,6 +203,13 @@ const handleView = () => {
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grade-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.grade-tag {
|
.grade-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
@ -217,6 +220,14 @@ const handleView = () => {
|
|||||||
border: 1px solid #91d5ff;
|
border: 1px solid #91d5ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-tag {
|
||||||
|
margin: 0 !important;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.config-row {
|
.config-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@ -39,7 +39,7 @@
|
|||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">关联主题</span>
|
<span class="info-label">关联主题</span>
|
||||||
<span class="info-value">
|
<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 v-else class="empty-text">未设置</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -457,7 +457,7 @@ import {
|
|||||||
EnvironmentOutlined,
|
EnvironmentOutlined,
|
||||||
} 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, getThemeTagStyle } from '@/utils/tagMaps';
|
||||||
import { parseGradeLevels } from '@/api/collections';
|
import { parseGradeLevels } from '@/api/collections';
|
||||||
import FilePreviewModal from '@/components/FilePreviewModal.vue';
|
import FilePreviewModal from '@/components/FilePreviewModal.vue';
|
||||||
|
|
||||||
@ -539,6 +539,14 @@ const domainTags = computed(() =>
|
|||||||
translateDomainTags(parseGradeLevels(course.value.domainTags ?? course.value.domain_tags))
|
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(() => {
|
const hasIntroContent = computed(() => {
|
||||||
return course.value.introSummary || course.value.introHighlights ||
|
return course.value.introSummary || course.value.introHighlights ||
|
||||||
|
|||||||
@ -69,20 +69,6 @@
|
|||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</div>
|
</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">
|
<div class="filter-group search-group">
|
||||||
@ -149,13 +135,6 @@ const selectedCollection = computed(() =>
|
|||||||
// 筛选元数据
|
// 筛选元数据
|
||||||
const filterMeta = ref<FilterMetaResponse>({ grades: [], lessonTypes: [], themes: [] });
|
const filterMeta = ref<FilterMetaResponse>({ grades: [], lessonTypes: [], themes: [] });
|
||||||
|
|
||||||
// 课程配置筛选选项(过滤导入课、集体课)
|
|
||||||
const EXCLUDED_LESSON_TYPES = new Set(['INTRODUCTION', 'INTRO', 'COLLECTIVE']);
|
|
||||||
const filteredLessonTypes = computed(() => {
|
|
||||||
const list = filterMeta.value.lessonTypes || [];
|
|
||||||
return list.filter(opt => !EXCLUDED_LESSON_TYPES.has((opt.lessonType || '').toUpperCase()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 课程包列表
|
// 课程包列表
|
||||||
const packages = ref<CoursePackage[]>([]);
|
const packages = ref<CoursePackage[]>([]);
|
||||||
const loadingPackages = ref(false);
|
const loadingPackages = ref(false);
|
||||||
|
|||||||
@ -21,23 +21,14 @@
|
|||||||
<BookOutlined /> {{ pkg.pictureBookName }}
|
<BookOutlined /> {{ pkg.pictureBookName }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- 年级标签行 -->
|
<!-- 年级 + 课程主题 -->
|
||||||
<div class="tag-row grade-row">
|
<div v-if="gradeText || themeDisplayName" class="tag-row grade-row">
|
||||||
<span class="grade-tag">
|
<span v-if="gradeText" class="grade-tag">
|
||||||
{{ gradeText }}
|
{{ gradeText }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<a-tag v-if="themeDisplayName" :style="themeTagStyle" class="theme-tag">
|
||||||
|
{{ themeDisplayName }}
|
||||||
<!-- 课程配置标签行(参考管理端) -->
|
</a-tag>
|
||||||
<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>
|
||||||
|
|
||||||
<!-- 统计信息 -->
|
<!-- 统计信息 -->
|
||||||
@ -71,7 +62,7 @@ import {
|
|||||||
EditOutlined,
|
EditOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import type { CoursePackage } from '@/api/course-center';
|
import type { CoursePackage } from '@/api/course-center';
|
||||||
import { getLessonTypeName, getLessonTagStyle } from '@/utils/tagMaps';
|
import { getThemeTagStyle } from '@/utils/tagMaps';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
pkg: CoursePackage;
|
pkg: CoursePackage;
|
||||||
@ -88,16 +79,19 @@ const gradeText = computed(() => {
|
|||||||
return grades.join(' · ');
|
return grades.join(' · ');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 从 courses 提取课程类型列表(去重,过滤导入课、集体课)
|
const themeDisplayName = computed(() => {
|
||||||
const EXCLUDED_LESSON_TYPES = new Set(['INTRODUCTION', 'INTRO', 'COLLECTIVE']);
|
const p = props.pkg as CoursePackage & {
|
||||||
const lessonTypes = computed(() => {
|
theme_name?: string;
|
||||||
const courses = props.pkg.courses || [];
|
theme?: { name?: string; color?: string };
|
||||||
const types = new Set<string>();
|
};
|
||||||
for (const c of courses) {
|
return (p.themeName || p.theme_name || p.theme?.name || '').trim();
|
||||||
const t = c.lessonType;
|
});
|
||||||
if (t && !EXCLUDED_LESSON_TYPES.has(t.toUpperCase())) types.add(t);
|
const themeTagStyle = computed(() => {
|
||||||
}
|
const p = props.pkg as CoursePackage & {
|
||||||
return Array.from(types);
|
theme_color?: string;
|
||||||
|
theme?: { name?: string; color?: string };
|
||||||
|
};
|
||||||
|
return getThemeTagStyle(p.themeColor || p.theme_color || p.theme?.color);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取图片完整 URL
|
// 获取图片完整 URL
|
||||||
@ -208,7 +202,18 @@ const handlePrepare = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grade-row {
|
.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 {
|
.grade-tag {
|
||||||
|
|||||||
@ -45,7 +45,7 @@
|
|||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">关联主题</span>
|
<span class="info-label">关联主题</span>
|
||||||
<span class="info-value">
|
<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 v-else class="empty-text">未设置</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -467,7 +467,7 @@ import {
|
|||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import * as teacherApi from '@/api/teacher';
|
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';
|
import FilePreviewModal from '@/components/FilePreviewModal.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
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(() => {
|
const hasIntroContent = computed(() => {
|
||||||
return course.value.introSummary || course.value.introHighlights ||
|
return course.value.introSummary || course.value.introHighlights ||
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
{{ course.pictureBookName || '-' }}
|
{{ course.pictureBookName || '-' }}
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
<a-descriptions-item label="课程主题">
|
<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>
|
||||||
<a-descriptions-item label="预计时长">
|
<a-descriptions-item label="预计时长">
|
||||||
{{ totalDuration }} 分钟
|
{{ totalDuration }} 分钟
|
||||||
@ -77,6 +78,13 @@ const translatedGradeTags = computed(() => {
|
|||||||
return translateGradeTags(props.course.gradeTags || []);
|
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(() => {
|
const totalDuration = computed(() => {
|
||||||
return props.course.courseLessons?.reduce((sum: number, l: any) => sum + (l.duration || 0), 0) || 0;
|
return props.course.courseLessons?.reduce((sum: number, l: any) => sum + (l.duration || 0), 0) || 0;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -55,6 +55,7 @@ public class AdminThemeController {
|
|||||||
Theme theme = themeService.create(
|
Theme theme = themeService.create(
|
||||||
request.getName(),
|
request.getName(),
|
||||||
request.getDescription(),
|
request.getDescription(),
|
||||||
|
request.getColor(),
|
||||||
request.getSortOrder()
|
request.getSortOrder()
|
||||||
);
|
);
|
||||||
return Result.success(toResponse(theme));
|
return Result.success(toResponse(theme));
|
||||||
@ -71,6 +72,7 @@ public class AdminThemeController {
|
|||||||
id,
|
id,
|
||||||
request.getName(),
|
request.getName(),
|
||||||
request.getDescription(),
|
request.getDescription(),
|
||||||
|
request.getColor(),
|
||||||
request.getSortOrder(),
|
request.getSortOrder(),
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@ -103,6 +105,7 @@ public class AdminThemeController {
|
|||||||
.id(theme.getId())
|
.id(theme.getId())
|
||||||
.name(theme.getName())
|
.name(theme.getName())
|
||||||
.description(theme.getDescription())
|
.description(theme.getDescription())
|
||||||
|
.color(theme.getColor())
|
||||||
.sortOrder(theme.getSortOrder())
|
.sortOrder(theme.getSortOrder())
|
||||||
.status(theme.getStatus())
|
.status(theme.getStatus())
|
||||||
.createdAt(theme.getCreatedAt())
|
.createdAt(theme.getCreatedAt())
|
||||||
|
|||||||
@ -18,6 +18,9 @@ public class ThemeCreateRequest {
|
|||||||
@Schema(description = "主题描述")
|
@Schema(description = "主题描述")
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
|
@Schema(description = "主题颜色(hex)")
|
||||||
|
private String color;
|
||||||
|
|
||||||
@Schema(description = "排序号")
|
@Schema(description = "排序号")
|
||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,6 +87,9 @@ public class CoursePackageResponse {
|
|||||||
@Schema(description = "主题名称")
|
@Schema(description = "主题名称")
|
||||||
private String themeName;
|
private String themeName;
|
||||||
|
|
||||||
|
@Schema(description = "主题颜色(hex),来自主题字典")
|
||||||
|
private String themeColor;
|
||||||
|
|
||||||
@Schema(description = "绘本名称")
|
@Schema(description = "绘本名称")
|
||||||
private String pictureBookName;
|
private String pictureBookName;
|
||||||
|
|
||||||
|
|||||||
@ -99,6 +99,9 @@ public class CourseResponse {
|
|||||||
@Schema(description = "主题名称")
|
@Schema(description = "主题名称")
|
||||||
private String themeName;
|
private String themeName;
|
||||||
|
|
||||||
|
@Schema(description = "主题颜色(hex),来自主题字典")
|
||||||
|
private String themeColor;
|
||||||
|
|
||||||
@Schema(description = "绘本名称")
|
@Schema(description = "绘本名称")
|
||||||
private String pictureBookName;
|
private String pictureBookName;
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,9 @@ public class ThemeResponse {
|
|||||||
@Schema(description = "主题描述")
|
@Schema(description = "主题描述")
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
|
@Schema(description = "主题颜色(hex)")
|
||||||
|
private String color;
|
||||||
|
|
||||||
@Schema(description = "排序号")
|
@Schema(description = "排序号")
|
||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,9 @@ public class Theme extends BaseEntity {
|
|||||||
@Schema(description = "主题描述")
|
@Schema(description = "主题描述")
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
|
@Schema(description = "主题颜色(hex)")
|
||||||
|
private String color;
|
||||||
|
|
||||||
@Schema(description = "排序号")
|
@Schema(description = "排序号")
|
||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除主题
|
* 删除主题
|
||||||
|
|||||||
@ -194,6 +194,8 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
|
|||||||
})
|
})
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
enrichCoursePackageThemeFields(packages, result);
|
||||||
|
|
||||||
log.info("查询到{}个课程包", result.size());
|
log.info("查询到{}个课程包", result.size());
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@ -262,22 +264,26 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
|
|||||||
.map(CoursePackage::getThemeId)
|
.map(CoursePackage::getThemeId)
|
||||||
.filter(id -> id != null)
|
.filter(id -> id != null)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
Map<Long, String> themeNameMap = new HashMap<>();
|
Map<Long, Theme> themeEntityMap = new HashMap<>();
|
||||||
if (!themeIds.isEmpty()) {
|
if (!themeIds.isEmpty()) {
|
||||||
List<Theme> themes = themeMapper.selectBatchIds(themeIds);
|
List<Theme> themes = themeMapper.selectBatchIds(themeIds);
|
||||||
themeNameMap = themes.stream()
|
themeEntityMap = themes.stream()
|
||||||
.collect(Collectors.toMap(Theme::getId, Theme::getName));
|
.collect(Collectors.toMap(Theme::getId, t -> t));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应对象
|
// 转换为响应对象
|
||||||
final Map<Long, String> finalThemeNameMap = themeNameMap;
|
final Map<Long, Theme> finalThemeMap = themeEntityMap;
|
||||||
List<CoursePackageResponse> result = packages.stream()
|
List<CoursePackageResponse> result = packages.stream()
|
||||||
.map(pkg -> {
|
.map(pkg -> {
|
||||||
CoursePackageResponse response = toPackageResponse(pkg);
|
CoursePackageResponse response = toPackageResponse(pkg);
|
||||||
// 设置主题名称
|
// 设置主题名称与颜色
|
||||||
if (pkg.getThemeId() != null) {
|
if (pkg.getThemeId() != null) {
|
||||||
response.setThemeId(pkg.getThemeId());
|
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()
|
associations.stream()
|
||||||
@ -887,6 +893,37 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
|
|||||||
.build();
|
.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转换为课程包响应对象(包含课程列表和排课计划参考)
|
* 转换为课程包响应对象(包含课程列表和排课计划参考)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -111,6 +111,7 @@ public class CoursePackageServiceImpl extends ServiceImpl<CoursePackageMapper, C
|
|||||||
Theme theme = themeMapper.selectById(entity.getThemeId());
|
Theme theme = themeMapper.selectById(entity.getThemeId());
|
||||||
if (theme != null) {
|
if (theme != null) {
|
||||||
response.setThemeName(theme.getName());
|
response.setThemeName(theme.getName());
|
||||||
|
response.setThemeColor(theme.getColor());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
List<CourseLesson> lessons = courseLessonService.findByCourseId(id);
|
List<CourseLesson> lessons = courseLessonService.findByCourseId(id);
|
||||||
|
|||||||
@ -59,7 +59,7 @@ public class ThemeServiceImpl extends ServiceImpl<ThemeMapper, Theme> implements
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@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);
|
log.info("创建主题,name={}, sortOrder={}", name, sortOrder);
|
||||||
// 获取最大排序号
|
// 获取最大排序号
|
||||||
Integer maxSortOrder = themeMapper.selectList(null)
|
Integer maxSortOrder = themeMapper.selectList(null)
|
||||||
@ -71,6 +71,7 @@ public class ThemeServiceImpl extends ServiceImpl<ThemeMapper, Theme> implements
|
|||||||
Theme theme = new Theme();
|
Theme theme = new Theme();
|
||||||
theme.setName(name);
|
theme.setName(name);
|
||||||
theme.setDescription(description);
|
theme.setDescription(description);
|
||||||
|
theme.setColor(color);
|
||||||
theme.setSortOrder(sortOrder != null ? sortOrder : maxSortOrder + 1);
|
theme.setSortOrder(sortOrder != null ? sortOrder : maxSortOrder + 1);
|
||||||
theme.setStatus(GenericStatus.ACTIVE.getCode());
|
theme.setStatus(GenericStatus.ACTIVE.getCode());
|
||||||
themeMapper.insert(theme);
|
themeMapper.insert(theme);
|
||||||
@ -84,7 +85,7 @@ public class ThemeServiceImpl extends ServiceImpl<ThemeMapper, Theme> implements
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@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);
|
log.info("更新主题,id={}, name={}, status={}", id, name, status);
|
||||||
Theme theme = themeMapper.selectById(id);
|
Theme theme = themeMapper.selectById(id);
|
||||||
if (theme == null) {
|
if (theme == null) {
|
||||||
@ -98,6 +99,9 @@ public class ThemeServiceImpl extends ServiceImpl<ThemeMapper, Theme> implements
|
|||||||
if (description != null) {
|
if (description != null) {
|
||||||
theme.setDescription(description);
|
theme.setDescription(description);
|
||||||
}
|
}
|
||||||
|
if (color != null) {
|
||||||
|
theme.setColor(color);
|
||||||
|
}
|
||||||
if (sortOrder != null) {
|
if (sortOrder != null) {
|
||||||
theme.setSortOrder(sortOrder);
|
theme.setSortOrder(sortOrder);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- 主题字典表添加颜色字段
|
||||||
|
ALTER TABLE theme ADD COLUMN color VARCHAR(20) DEFAULT NULL COMMENT '主题颜色(hex)' AFTER description;
|
||||||
Loading…
Reference in New Issue
Block a user