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:
parent
193bbe90ae
commit
6af88225c9
@ -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);
|
||||
};
|
||||
|
||||
// ==================== 课程套餐 ====================
|
||||
|
||||
|
||||
25
reading-platform-frontend/src/components.d.ts
vendored
25
reading-platform-frontend/src/components.d.ts
vendored
@ -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']
|
||||
|
||||
@ -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="暂无数据" />
|
||||
|
||||
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user