fix: 数据看板前后端接口数据结构差异修复

- admin.ts: 新增后端响应类型定义和数据映射函数
  - AdminStatsResponse, StatsTrendResponse, ActiveTenantResponse, PopularCourseResponse
  - mapStatsData, mapTrendData, mapActiveTenants, mapPopularCourses
- admin/DashboardView: 活跃租户列表显示调整
- teacher/DashboardView: 数据加载添加字段映射和默认值处理
- school/DashboardView: 图表初始化添加空数据处理和数据校验

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
En 2026-03-17 16:21:51 +08:00
parent 193bbe90ae
commit 6af88225c9
5 changed files with 178 additions and 59 deletions

View File

@ -97,6 +97,17 @@ export interface UpdateTenantQuotaDto {
studentQuota?: number;
}
// 后端返回的统计数据结构
export interface AdminStatsResponse {
totalTenants: number;
activeTenants: number;
totalCourses: number;
totalStudents: number;
totalTeachers: number;
totalLessons: number;
}
// 前端使用的统计数据结构
export interface AdminStats {
tenantCount: number;
activeTenantCount: number;
@ -108,6 +119,15 @@ export interface AdminStats {
monthlyLessons: number;
}
// 后端返回的趋势数据结构(分离数组格式)
export interface StatsTrendResponse {
dates: string[];
newStudents: number[];
newTeachers: number[];
newCourses: number[];
}
// 前端使用的趋势数据结构
export interface TrendData {
month: string;
tenantCount: number;
@ -115,14 +135,32 @@ export interface TrendData {
studentCount: number;
}
// 后端返回的活跃租户结构
export interface ActiveTenantResponse {
tenantId: number;
tenantName: string;
activeUsers: number;
courseCount: number;
}
// 前端使用的活跃租户结构
export interface ActiveTenant {
id: number;
name: string;
lessonCount: number;
teacherCount: number;
studentCount: number;
teacherCount: number | string;
studentCount: number | string;
}
// 后端返回的热门课程结构
export interface PopularCourseResponse {
courseId: number;
courseName: string;
usageCount: number;
teacherCount: number;
}
// 前端使用的热门课程结构
export interface PopularCourse {
id: number;
name: string;
@ -211,17 +249,71 @@ export const deleteTenant = (id: number) =>
// ==================== 统计数据 ====================
export const getAdminStats = () =>
http.get<AdminStats>('/v1/admin/stats');
// 数据映射函数:后端 -> 前端
const mapStatsData = (data: AdminStatsResponse): AdminStats => ({
tenantCount: data.totalTenants || 0,
activeTenantCount: data.activeTenants || 0,
courseCount: data.totalCourses || 0,
studentCount: data.totalStudents || 0,
teacherCount: data.totalTeachers || 0,
lessonCount: data.totalLessons || 0,
publishedCourseCount: 0, // 后端暂无此数据
monthlyLessons: 0, // 后端暂无此数据
});
export const getTrendData = () =>
http.get<TrendData[]>('/v1/admin/stats/trend');
// 趋势数据映射:分离数组 -> 对象数组
const mapTrendData = (data: StatsTrendResponse): TrendData[] => {
if (!data || !data.dates || data.dates.length === 0) return [];
return data.dates.map((date, index) => ({
month: date,
tenantCount: 0, // 后端无此数据
lessonCount: data.newCourses?.[index] || 0,
studentCount: data.newStudents?.[index] || 0,
}));
};
export const getActiveTenants = (limit?: number) =>
http.get<ActiveTenant[]>('/v1/admin/stats/tenants/active', { params: { limit } });
// 活跃租户数据映射
const mapActiveTenants = (data: ActiveTenantResponse[]): ActiveTenant[] => {
if (!data || data.length === 0) return [];
return data.map(item => ({
id: item.tenantId,
name: item.tenantName,
teacherCount: '-', // 后端无单独字段
studentCount: '-', // 后端无单独字段
lessonCount: item.courseCount, // 使用 courseCount 替代
}));
};
export const getPopularCourses = (limit?: number) =>
http.get<PopularCourse[]>('/v1/admin/stats/courses/popular', { params: { limit } });
// 热门课程数据映射
const mapPopularCourses = (data: PopularCourseResponse[]): PopularCourse[] => {
if (!data || data.length === 0) return [];
return data.map(item => ({
id: item.courseId,
name: item.courseName,
usageCount: item.usageCount,
teacherCount: item.teacherCount,
}));
};
export const getAdminStats = async () => {
const data = await http.get<AdminStatsResponse>('/v1/admin/stats');
return mapStatsData(data);
};
export const getTrendData = async () => {
const data = await http.get<StatsTrendResponse>('/v1/admin/stats/trend');
return mapTrendData(data);
};
export const getActiveTenants = async (limit?: number) => {
const data = await http.get<ActiveTenantResponse[]>('/v1/admin/stats/tenants/active', { params: { limit } });
return mapActiveTenants(data);
};
export const getPopularCourses = async (limit?: number) => {
const data = await http.get<PopularCourseResponse[]>('/v1/admin/stats/courses/popular', { params: { limit } });
return mapPopularCourses(data);
};
// ==================== 课程套餐 ====================

View File

@ -7,16 +7,11 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AAlert: typeof import('ant-design-vue/es')['Alert']
AAvatar: typeof import('ant-design-vue/es')['Avatar']
ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button']
AButtonGroup: typeof import('ant-design-vue/es')['ButtonGroup']
ACard: typeof import('ant-design-vue/es')['Card']
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup']
ACol: typeof import('ant-design-vue/es')['Col']
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
ADivider: typeof import('ant-design-vue/es')['Divider']
@ -26,7 +21,6 @@ declare module 'vue' {
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AImage: typeof import('ant-design-vue/es')['Image']
AImagePreviewGroup: typeof import('ant-design-vue/es')['ImagePreviewGroup']
AInput: typeof import('ant-design-vue/es')['Input']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
@ -35,41 +29,22 @@ declare module 'vue' {
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
AList: typeof import('ant-design-vue/es')['List']
AListItem: typeof import('ant-design-vue/es')['ListItem']
AListItemMeta: typeof import('ant-design-vue/es')['ListItemMeta']
AMenu: typeof import('ant-design-vue/es')['Menu']
AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
AModal: typeof import('ant-design-vue/es')['Modal']
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
APagination: typeof import('ant-design-vue/es')['Pagination']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
AProgress: typeof import('ant-design-vue/es')['Progress']
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
ARate: typeof import('ant-design-vue/es')['Rate']
ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOptGroup: typeof import('ant-design-vue/es')['SelectOptGroup']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
ASpace: typeof import('ant-design-vue/es')['Space']
ASpin: typeof import('ant-design-vue/es')['Spin']
AStatistic: typeof import('ant-design-vue/es')['Statistic']
AStep: typeof import('ant-design-vue/es')['Step']
ASteps: typeof import('ant-design-vue/es')['Steps']
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
ASwitch: typeof import('ant-design-vue/es')['Switch']
ATable: typeof import('ant-design-vue/es')['Table']
ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimeRangePicker: typeof import('ant-design-vue/es')['TimeRangePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATypographyText: typeof import('ant-design-vue/es')['TypographyText']
AUpload: typeof import('ant-design-vue/es')['Upload']
FilePreviewModal: typeof import('./components/FilePreviewModal.vue')['default']
FileUploader: typeof import('./components/course/FileUploader.vue')['default']

View File

@ -82,9 +82,9 @@
<div class="rank-number" :class="'rank-' + (index + 1)">{{ index + 1 }}</div>
<div class="rank-content">
<span class="rank-name">{{ item.name }}</span>
<span class="rank-desc">教师: {{ item.teacherCount }} | 学生: {{ item.studentCount }}</span>
<span class="rank-desc">活跃用户: {{ item.lessonCount }}</span>
</div>
<a-tag color="blue">{{ item.lessonCount }} </a-tag>
<a-tag color="blue">{{ item.lessonCount }} 课程</a-tag>
</div>
</div>
<a-empty v-else description="暂无数据" />

View File

@ -392,6 +392,13 @@ const initTrendChart = (data: LessonTrendItem[]) => {
return;
}
//
const validData = data.map(d => ({
month: d.month || '',
lessonCount: d.lessonCount || 0,
studentCount: d.studentCount || 0,
}));
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'axis',
@ -419,7 +426,7 @@ const initTrendChart = (data: LessonTrendItem[]) => {
},
xAxis: {
type: 'category',
data: data.map((d) => d.month),
data: validData.map((d) => d.month),
axisLine: {
lineStyle: {
color: '#E5E7EB',
@ -450,7 +457,7 @@ const initTrendChart = (data: LessonTrendItem[]) => {
{
name: '授课次数',
type: 'bar',
data: data.map((d) => d.lessonCount),
data: validData.map((d) => d.lessonCount),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#FF8C42' },
@ -463,7 +470,7 @@ const initTrendChart = (data: LessonTrendItem[]) => {
name: '学生数',
type: 'line',
yAxisIndex: 1,
data: data.map((d) => d.studentCount),
data: validData.map((d) => d.studentCount),
smooth: true,
itemStyle: {
color: '#667eea',
@ -491,6 +498,40 @@ const initDistributionChart = (data: CourseDistributionItem[]) => {
distributionChart = echarts.init(distributionChartRef.value);
//
if (!data || data.length === 0) {
distributionChart.setOption({
title: {
text: '暂无数据',
left: 'center',
top: 'center',
textStyle: {
color: '#999',
fontSize: 14,
},
},
});
return;
}
//
const validData = data.map((item, index) => ({
name: item.name || `课程${index + 1}`,
value: item.value || 0,
itemStyle: {
color: [
'#FF8C42',
'#667eea',
'#f093fb',
'#4facfe',
'#43e97b',
'#fa709a',
'#fee140',
'#30cfd0',
][index % 8],
},
}));
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'item',
@ -526,21 +567,7 @@ const initDistributionChart = (data: CourseDistributionItem[]) => {
labelLine: {
show: false,
},
data: data.map((item, index) => ({
...item,
itemStyle: {
color: [
'#FF8C42',
'#667eea',
'#f093fb',
'#4facfe',
'#43e97b',
'#fa709a',
'#fee140',
'#30cfd0',
][index % 8],
},
})),
data: validData,
},
],
};

View File

@ -200,7 +200,7 @@
<div v-else class="cover-placeholder">
<BookFilled />
</div>
<div class="duration-tag">{{ course.duration }}分钟</div>
<div class="duration-tag">{{ course.duration || 30 }}分钟</div>
</div>
<div class="recommend-info">
<div class="recommend-name">{{ course.name }}</div>
@ -637,9 +637,34 @@ const loadDashboard = async () => {
loading.value = true;
try {
const data = await getTeacherDashboard();
stats.value = data.stats;
todayLessons.value = data.todayLessons || [];
recommendedCourses.value = data.recommendedCourses || [];
stats.value = data.stats || { classCount: 0, studentCount: 0, lessonCount: 0, courseCount: 0 };
//
todayLessons.value = (data.todayLessons || []).map((lesson: any) => ({
id: lesson.id,
courseId: lesson.courseId,
courseName: lesson.courseName || lesson.course?.name || '未命名课程',
pictureBookName: lesson.pictureBookName,
classId: lesson.classId,
className: lesson.className || lesson.class?.name || '未命名班级',
plannedDatetime: lesson.plannedDatetime || lesson.startDatetime || lesson.lessonDate,
status: lesson.status,
duration: lesson.duration || lesson.actualDuration || 30, // 30
}));
//
recommendedCourses.value = (data.recommendedCourses || []).map((course: any) => ({
id: course.id,
name: course.name || '未命名课程',
pictureBookName: course.pictureBookName,
coverImagePath: course.coverImagePath,
duration: course.duration || 30, // 30
usageCount: course.usageCount || 0,
avgRating: course.avgRating || 0, // 0
gradeTags: course.gradeTags || [],
}));
//
recentActivities.value = (data.recentActivities || []).map((item: RecentActivity) => ({
...item,
time: formatActivityTime(item.time),