1242 lines
30 KiB
Plaintext
1242 lines
30 KiB
Plaintext
<template>
|
||
<view class="preferences-analytics-page">
|
||
<!-- Header -->
|
||
<view class="header">
|
||
<view class="header-left">
|
||
<button @click="goBack" class="back-btn">
|
||
<simple-icon type="arrow-left" :size="16" color="#FFFFFF" />
|
||
<text>返回</text>
|
||
</button>
|
||
<text class="title">训练偏好分析</text>
|
||
</view>
|
||
<view class="header-actions">
|
||
<button @click="refreshData" class="refresh-btn">
|
||
<simple-icon type="refresh" :size="16" color="#FFFFFF" />
|
||
<text>刷新</text>
|
||
</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Loading State -->
|
||
<view v-if="loading" class="loading-container">
|
||
<text class="loading-text">分析数据中...</text>
|
||
</view>
|
||
|
||
<!-- Content -->
|
||
<scroll-view v-else class="content" scroll-y="true" :style="{ height: contentHeight + 'px' }">
|
||
<!-- Summary Statistics -->
|
||
<view class="summary-section">
|
||
<view class="section-title">偏好概览</view>
|
||
<view class="summary-cards">
|
||
<view class="summary-card">
|
||
<view class="card-icon">❤️</view>
|
||
<view class="card-content">
|
||
<text class="card-number">{{ analyticsData.getString("favoriteCount") }}</text>
|
||
<text class="card-label">喜爱运动</text>
|
||
</view>
|
||
</view>
|
||
<view class="summary-card">
|
||
<view class="card-icon">📅</view>
|
||
<view class="card-content">
|
||
<text class="card-number">{{ analyticsData.getString("weeklyHours") }}</text>
|
||
<text class="card-label">周计划时长(h)</text>
|
||
</view>
|
||
</view>
|
||
<view class="summary-card">
|
||
<view class="card-icon">🎯</view>
|
||
<view class="card-content">
|
||
<text class="card-number">{{ analyticsData.getString("activeGoals") }}</text>
|
||
<text class="card-label">活跃目标</text>
|
||
</view>
|
||
</view>
|
||
<view class="summary-card">
|
||
<view class="card-icon">⏰</view>
|
||
<view class="card-content">
|
||
<text class="card-number">{{ analyticsData.getString("remindersCount") }}</text>
|
||
<text class="card-label">活跃提醒</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Exercise Preferences Distribution -->
|
||
<view class="distribution-section">
|
||
<view class="section-title">运动类型分布</view>
|
||
<view class="chart-container">
|
||
<view class="chart-legend">
|
||
<view v-for="category in categoryDistribution" :key="category.name" class="legend-item">
|
||
<view class="legend-color" :style="{ backgroundColor: category.color }"></view>
|
||
<text class="legend-text">{{ category.name }}</text>
|
||
<text class="legend-value">{{ category.count }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="distribution-bars">
|
||
<view v-for="category in categoryDistribution" :key="category.name" class="distribution-bar">
|
||
<view class="bar-info">
|
||
<text class="bar-label">{{ category.name }}</text>
|
||
<text class="bar-percentage">{{ category.percentage }}%</text>
|
||
</view>
|
||
<view class="bar-track">
|
||
<view class="bar-fill" :style="{
|
||
width: category.getString('percentage') + '%',
|
||
backgroundColor: category.getString('color')
|
||
}"></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Intensity Analysis -->
|
||
<view class="intensity-section">
|
||
<view class="section-title">训练强度分析</view>
|
||
<view class="intensity-chart">
|
||
<view class="intensity-levels">
|
||
<view v-for="level in intensityAnalysis" :key="level.level" class="intensity-level">
|
||
<view class="level-header">
|
||
<text class="level-name">{{ level.name }}</text>
|
||
<text class="level-count">{{ level.count }}项</text>
|
||
</view>
|
||
<view class="level-bar">
|
||
<view class="level-fill" :style="{
|
||
width: level.getString('percentage') + '%',
|
||
backgroundColor: level.getString('color')
|
||
}"></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Weekly Schedule Analysis -->
|
||
<view class="schedule-section">
|
||
<view class="section-title">训练时间安排</view>
|
||
<view class="weekly-schedule">
|
||
<view class="time-periods">
|
||
<view v-for="period in timePeriods" :key="period.getString('name')" class="time-period">
|
||
<text class="period-name">{{ period.getString('name') }}</text>
|
||
<view class="period-days">
|
||
<view v-for="day in (period.days as Array<UTSJSONObject>)" :key="day.getString('name')"
|
||
class="day-item" :class="{ 'has-training': day.getBoolean('hasTraining') }">
|
||
<text class="day-name">{{ day.getString('name') }}</text>
|
||
<text v-if="day.getBoolean('hasTraining')"
|
||
class="day-count">{{ day.getNumber('sessionCount') }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Goals Progress -->
|
||
<view class="goals-section">
|
||
<view class="section-title">目标完成情况</view>
|
||
<view class="goals-list">
|
||
<view v-for="goal in goalsProgress" :key="goal.id" class="goal-item">
|
||
<view class="goal-header">
|
||
<text class="goal-type">{{ goal.type }}</text>
|
||
<text class="goal-progress-text">{{ goal.progressPercentage }}%</text>
|
||
</view>
|
||
<view class="goal-progress-bar">
|
||
<view class="goal-progress-fill" :style="{
|
||
width: goal.getString('progressPercentage') + '%',
|
||
backgroundColor: getProgressColor(goal.getNumber('progressPercentage')??0)
|
||
}"></view>
|
||
</view>
|
||
<view class="goal-meta">
|
||
<text class="goal-current">{{ goal.currentValue }}/{{ goal.targetValue }}
|
||
{{ goal.unit }}</text>
|
||
<text class="goal-status"
|
||
:class="getStatusClass(goal.getString('status')??'')">{{ goal.getString('status') }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Recommendations -->
|
||
<view class="recommendations-section">
|
||
<view class="section-title">智能建议</view>
|
||
<view class="recommendations-list">
|
||
<view v-for="recommendation in recommendations" :key="recommendation.id"
|
||
class="recommendation-item">
|
||
<view class="recommendation-icon">
|
||
<text class="rec-emoji">{{ recommendation.icon }}</text>
|
||
</view>
|
||
<view class="recommendation-content">
|
||
<text class="rec-title">{{ recommendation.title }}</text>
|
||
<text class="rec-description">{{ recommendation.description }}</text>
|
||
<view class="rec-actions">
|
||
<button @click="applyRecommendation(recommendation)" class="apply-btn">
|
||
<text>应用建议</text>
|
||
</button>
|
||
<button @click="dismissRecommendation(recommendation.getString('id')??'')"
|
||
class="dismiss-btn">
|
||
<text>忽略</text>
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Trends Analysis -->
|
||
<view class="trends-section">
|
||
<view class="section-title">趋势分析</view>
|
||
<view class="trends-chart">
|
||
<view class="trend-metrics">
|
||
<view v-for="metric in trendMetrics" :key="metric.name" class="trend-metric">
|
||
<view class="metric-header">
|
||
<text class="metric-name">{{ metric.name }}</text>
|
||
<text class="metric-change" :class="metric.changeClass">{{ metric.change }}</text>
|
||
</view>
|
||
<view class="metric-chart">
|
||
<view v-for="(point, index) in metric.getArray('dataPoints')" :key="index"
|
||
class="data-point" :style="{ height: (point as Number) + '%' }">
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="uts">
|
||
import { onLoad, onReady, OnLoadOptions, onResize } from '@dcloudio/uni-app'
|
||
|
||
import { ref, onMounted, computed } from 'vue'
|
||
import { getCurrentUserId } from '@/utils/store'
|
||
import supaClient from '@/components/supadb/aksupainstance.uts'
|
||
|
||
const userId = ref('')
|
||
|
||
// Responsive state - using onResize for dynamic updates
|
||
const screenWidth = ref<number>(uni.getSystemInfoSync().windowWidth)
|
||
|
||
// Computed properties for responsive design
|
||
const isLargeScreen = computed(() : boolean => {
|
||
return screenWidth.value >= 768
|
||
})
|
||
|
||
// 响应式数据
|
||
const loading = ref(true)
|
||
const contentHeight = ref(0)
|
||
|
||
// 分析数据
|
||
const analyticsData = ref({
|
||
favoriteCount: 0,
|
||
weeklyHours: 0,
|
||
activeGoals: 0,
|
||
remindersCount: 0
|
||
})
|
||
|
||
const categoryDistribution = ref<UTSJSONObject[]>([])
|
||
const intensityAnalysis = ref<UTSJSONObject[]>([])
|
||
const timePeriods = ref<UTSJSONObject[]>([])
|
||
const goalsProgress = ref<UTSJSONObject[]>([])
|
||
const recommendations = ref<UTSJSONObject[]>([])
|
||
const trendMetrics = ref<UTSJSONObject[]>([])
|
||
|
||
|
||
// 加载概览数据
|
||
const loadSummaryData = async () => {
|
||
if (userId.value == null || userId.value == '') return
|
||
try {
|
||
// 获取喜爱运动数量
|
||
const favoritesResult = await supaClient
|
||
.from('ak_user_sport_preferences')
|
||
.select('id', { count: 'exact' })
|
||
.eq('user_id', userId.value)
|
||
.eq('is_favorite', true)
|
||
.execute()
|
||
|
||
// 获取总的训练时间
|
||
const preferencesResult = await supaClient
|
||
.from('ak_user_sport_preferences')
|
||
.select('frequency_per_week, duration_minutes', {})
|
||
.eq('user_id', userId.value)
|
||
.execute()
|
||
|
||
// 获取活跃目标
|
||
const goalsResult = await supaClient
|
||
.from('ak_user_training_goals')
|
||
.select('id', {})
|
||
.eq('user_id', userId.value)
|
||
.eq('status', 'active')
|
||
.execute()
|
||
|
||
// 获取活跃提醒
|
||
const remindersResult = await supaClient
|
||
.from('ak_training_reminders')
|
||
.select('id', {})
|
||
.eq('user_id', userId.value)
|
||
.eq('is_enabled', true)
|
||
.execute()
|
||
|
||
let weeklyMinutes = 0
|
||
if (preferencesResult.data != null && Array.isArray(preferencesResult.data)) {
|
||
(preferencesResult.data as Array<UTSJSONObject>).forEach((pref : UTSJSONObject) => {
|
||
const frequency = pref.getNumber('frequency_per_week') ?? 0
|
||
const duration = pref.getNumber('duration_minutes') ?? 0
|
||
weeklyMinutes += frequency * duration
|
||
})
|
||
}
|
||
|
||
analyticsData.value = {
|
||
favoriteCount: Array.isArray(favoritesResult.data) ? favoritesResult.data.length : 0,
|
||
weeklyHours: Math.round(weeklyMinutes / 60 * 10) / 10,
|
||
activeGoals: Array.isArray(goalsResult.data) ? goalsResult.data.length : 0,
|
||
remindersCount: Array.isArray(remindersResult.data) ? remindersResult.data.length : 0
|
||
}
|
||
} catch (error) {
|
||
console.error('加载概览数据失败:', error)
|
||
}
|
||
}
|
||
|
||
// 加载分类分布
|
||
const loadCategoryDistribution = async () => {
|
||
const userId = getCurrentUserId()
|
||
if (userId == null || userId == '') return
|
||
|
||
try {
|
||
const result = await supaClient
|
||
.from('ak_user_sport_preferences')
|
||
.select(`sport_type:ak_sport_types(category)`, {})
|
||
.eq('user_id', userId)
|
||
.execute()
|
||
|
||
if (Array.isArray(result.data)) {
|
||
const colors = ['#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#F44336', '#00BCD4']
|
||
let colorIndex = 0
|
||
const categoryArr : UTSJSONObject[] = []
|
||
// 用UTSJSONObject代替plain object来统计category
|
||
const categoryMap = {} as UTSJSONObject
|
||
|
||
// 用标准for循环替换forEach
|
||
const dataArr = result.data as Array<UTSJSONObject>
|
||
for (let i = 0; i < dataArr.length; i++) {
|
||
const pref = dataArr[i]
|
||
let category = '其他'
|
||
const sportType = pref['sport_type'] as UTSJSONObject
|
||
if (sportType != null && typeof sportType.getString === 'function') {
|
||
const cat = sportType.getString('category')
|
||
if (cat != null && cat.length > 0) category = cat
|
||
}
|
||
if (!categoryMap.hasOwnProperty(category)) {
|
||
categoryMap[category] = 1
|
||
} else {
|
||
categoryMap[category] = (categoryMap[category] as number) + 1
|
||
}
|
||
}
|
||
|
||
// UTS Supabase返回的result没有total字段,直接用dataArr.length
|
||
const totalCount = dataArr.length
|
||
for (let category in categoryMap) {
|
||
if (!categoryMap.hasOwnProperty(category)) continue
|
||
const count = categoryMap[category] as number
|
||
categoryArr.push({
|
||
name: category,
|
||
count: count,
|
||
percentage: totalCount > 0 ? Math.round((count * 100.0) / totalCount) : 0,
|
||
color: colors[colorIndex % colors.length]
|
||
})
|
||
colorIndex++
|
||
}
|
||
|
||
categoryDistribution.value = categoryArr.sort((a, b) => (b['count'] as number) - (a['count'] as number))
|
||
}
|
||
} catch (error) {
|
||
console.error('加载分类分布失败:', error)
|
||
}
|
||
}
|
||
|
||
// intensityAnalysis部分修正
|
||
const loadIntensityAnalysis = async () => {
|
||
const userId = getCurrentUserId()
|
||
if (userId == null || userId == '') return
|
||
try {
|
||
const result = await supaClient
|
||
.from('ak_user_sport_preferences')
|
||
.select('intensity_level', {})
|
||
.eq('user_id', userId)
|
||
.execute()
|
||
if (Array.isArray(result.data)) {
|
||
const intensityCount = {} as UTSJSONObject
|
||
const dataArr = result.data as Array<UTSJSONObject>
|
||
for (let i = 0; i < dataArr.length; i++) {
|
||
const pref = dataArr[i]
|
||
let level = 1
|
||
if (typeof pref['intensity_level'] === 'number') {
|
||
level = pref['intensity_level'] as number
|
||
}
|
||
const levelKey = level + ''
|
||
if (!intensityCount.hasOwnProperty(levelKey)) {
|
||
intensityCount[levelKey] = 1
|
||
} else {
|
||
intensityCount[levelKey] = (intensityCount[levelKey] as number) + 1
|
||
}
|
||
}
|
||
const total = dataArr.length
|
||
const intensityNames = ['', '很轻松', '轻松', '中等', '较高', '很高']
|
||
const colors = ['', '#4CAF50', '#8BC34A', '#FFC107', '#FF9800', '#F44336']
|
||
const analysis : UTSJSONObject[] = []
|
||
for (let level = 1; level <= 5; level++) {
|
||
const levelKey = level + ''
|
||
const count = typeof intensityCount[levelKey] === 'number' ? (intensityCount[levelKey] as number) : 0
|
||
analysis.push({
|
||
level: level,
|
||
name: intensityNames[level],
|
||
count: count,
|
||
percentage: total > 0 ? Math.round((count * 100.0) / total) : 0,
|
||
color: colors[level]
|
||
})
|
||
}
|
||
intensityAnalysis.value = analysis
|
||
}
|
||
} catch (error) {
|
||
console.error('加载强度分析失败:', error)
|
||
}
|
||
}
|
||
|
||
// 加载日程分析部分修正
|
||
const loadScheduleAnalysis = async () => {
|
||
const userId = getCurrentUserId()
|
||
if (userId == null || userId == '') return
|
||
try {
|
||
const result = await supaClient
|
||
.from('ak_training_reminders')
|
||
.select('trigger_time, trigger_days', {})
|
||
.eq('user_id', userId)
|
||
.eq('is_enabled', true)
|
||
.execute()
|
||
const schedule = {} as UTSJSONObject
|
||
const timeSlots = ['早上', '上午', '下午', '晚上']
|
||
const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||
// 初始化时间表
|
||
for (let i = 0; i < timeSlots.length; i++) {
|
||
const slot = timeSlots[i]
|
||
const dayMap = {} as UTSJSONObject
|
||
for (let j = 0; j < weekDays.length; j++) {
|
||
dayMap[weekDays[j]] = 0
|
||
}
|
||
schedule[slot] = dayMap
|
||
}
|
||
if (Array.isArray(result.data)) {
|
||
|
||
for (let i = 0; i < result.data.length; i++) {
|
||
const reminder = result.data[i]
|
||
const triggerTime = (reminder as UTSJSONObject).getString('trigger_time') ?? ''
|
||
const triggerDays = (reminder as UTSJSONObject).getString('trigger_days') ?? ''
|
||
// UTS: 字符串判空需用 triggerTime != null && triggerTime.length > 0
|
||
if (triggerTime != null && triggerTime.length > 0 && triggerDays != null && triggerDays.length > 0) {
|
||
const hour = parseInt(triggerTime.split(':')[0])
|
||
let timeSlot = '早上'
|
||
if (hour >= 6 && hour < 9) timeSlot = '早上'
|
||
else if (hour >= 9 && hour < 12) timeSlot = '上午'
|
||
else if (hour >= 12 && hour < 18) timeSlot = '下午'
|
||
else timeSlot = '晚上'
|
||
for (let k = 0; k < triggerDays.length; k++) {
|
||
// UTS: triggerDays[k] 是字符,需转字符串
|
||
const dayIndex = parseInt(triggerDays.charAt(k) + '') - 1
|
||
if (dayIndex >= 0 && dayIndex < 7) {
|
||
const dayName = weekDays[dayIndex]
|
||
const slotMap = schedule[timeSlot] as UTSJSONObject
|
||
slotMap[dayName] = (slotMap[dayName] as number) + 1
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const periods : UTSJSONObject[] = []
|
||
for (let i = 0; i < timeSlots.length; i++) {
|
||
const slot = timeSlots[i]
|
||
const slotMap = schedule[slot] as UTSJSONObject
|
||
if (slotMap != null) {
|
||
const days : UTSJSONObject[] = []
|
||
for (let j = 0; j < weekDays.length; j++) {
|
||
const day = weekDays[j]
|
||
let count = slotMap.getNumber(day) ?? 0
|
||
days.push({
|
||
name: day,
|
||
hasTraining: count > 0,
|
||
sessionCount: count
|
||
})
|
||
}
|
||
periods.push({
|
||
name: slot,
|
||
days: days
|
||
})
|
||
}
|
||
}
|
||
timePeriods.value = periods
|
||
} catch (error) {
|
||
console.error('加载日程分析失败:', error)
|
||
}
|
||
}
|
||
|
||
|
||
// 加载趋势指标
|
||
const loadTrendMetrics = async () => {
|
||
// 模拟趋势数据,实际应用中从数据库获取历史数据
|
||
trendMetrics.value = [
|
||
{
|
||
name: '训练频率',
|
||
change: '+12%',
|
||
changeClass: 'positive',
|
||
dataPoints: [60, 65, 70, 68, 75, 80, 85]
|
||
},
|
||
{
|
||
name: '目标完成',
|
||
change: '+8%',
|
||
changeClass: 'positive',
|
||
dataPoints: [40, 45, 50, 55, 60, 65, 70]
|
||
},
|
||
{
|
||
name: '运动多样性',
|
||
change: '+5%',
|
||
changeClass: 'positive',
|
||
dataPoints: [30, 35, 40, 45, 50, 55, 60]
|
||
}
|
||
]
|
||
}
|
||
// 工具函数
|
||
const getGoalTypeText = (atype : string) : string => {
|
||
const types = {
|
||
'weight_loss': '减肥目标',
|
||
'muscle_gain': '增肌目标',
|
||
'endurance': '耐力提升',
|
||
'flexibility': '柔韧性',
|
||
'strength': '力量增强',
|
||
'skill': '技能提升'
|
||
}
|
||
return types.getString(atype) ?? atype
|
||
}
|
||
|
||
const getGoalStatusText = (progress : number, status : string) : string => {
|
||
if (status === 'completed') return '已完成'
|
||
if (progress >= 100) return '已达成'
|
||
if (progress >= 75) return '接近完成'
|
||
if (progress >= 50) return '进行中'
|
||
if (progress >= 25) return '刚开始'
|
||
return '待开始'
|
||
}
|
||
|
||
const getProgressColor = (percentage : number) : string => {
|
||
if (percentage >= 100) return '#4CAF50'
|
||
if (percentage >= 75) return '#8BC34A'
|
||
if (percentage >= 50) return '#FFC107'
|
||
if (percentage >= 25) return '#FF9800'
|
||
return '#F44336'
|
||
}
|
||
|
||
const getStatusClass = (status : string) : string => {
|
||
if (status === '已完成' || status === '已达成') return 'status-completed'
|
||
if (status === '接近完成' || status === '进行中') return 'status-active'
|
||
return 'status-pending'
|
||
}
|
||
// 生成智能建议
|
||
const generateRecommendations = () => {
|
||
const recs : UTSJSONObject[] = []
|
||
const favoriteCount = analyticsData.value.getNumber("favoriteCount") ?? 0
|
||
// 基于数据生成建议
|
||
if (favoriteCount < 3) {
|
||
recs.push({
|
||
id: 'more_favorites',
|
||
icon: '❤️',
|
||
title: '扩展运动偏好',
|
||
description: '尝试添加更多喜爱的运动类型,让训练更有趣',
|
||
type: 'preference',
|
||
action: 'navigate_to_exercises'
|
||
})
|
||
}
|
||
const weeklyHours = analyticsData.value.getNumber("weeklyHours") ?? 0
|
||
if (weeklyHours < 3) {
|
||
recs.push({
|
||
id: 'increase_time',
|
||
icon: '⏰',
|
||
title: '增加训练时间',
|
||
description: '建议每周至少进行3小时的体育锻炼',
|
||
type: 'time',
|
||
action: 'adjust_schedule'
|
||
})
|
||
}
|
||
const activeGoals = analyticsData.value.getNumber("activeGoals") ?? 0
|
||
if (activeGoals === 0) {
|
||
recs.push({
|
||
id: 'set_goals',
|
||
icon: '🎯',
|
||
title: '设定训练目标',
|
||
description: '设定明确的训练目标有助于保持动力',
|
||
type: 'goal',
|
||
action: 'navigate_to_goals'
|
||
})
|
||
}
|
||
// 基于强度分析的建议
|
||
const highIntensityCount = intensityAnalysis.value.filter(item =>
|
||
((item as UTSJSONObject).getNumber('level') ?? 0) >= 4 && ((item as UTSJSONObject).getNumber('count') ?? 0) > 0
|
||
).length
|
||
|
||
if (highIntensityCount === 0) {
|
||
recs.push({
|
||
id: 'add_intensity',
|
||
icon: '💪',
|
||
title: '增加训练强度',
|
||
description: '适当增加高强度训练可以提高运动效果',
|
||
type: 'intensity',
|
||
action: 'adjust_intensity'
|
||
})
|
||
}
|
||
|
||
recommendations.value = recs
|
||
}
|
||
|
||
|
||
// 加载目标进度
|
||
const loadGoalsProgress = async () => {
|
||
const userId = getCurrentUserId()
|
||
if (userId == null || userId == '') return
|
||
try {
|
||
const result = await supaClient
|
||
.from('ak_user_training_goals')
|
||
.select('*', {})
|
||
.eq('user_id', userId)
|
||
.neq('status', 'cancelled')
|
||
.execute()
|
||
if (result.data != null && Array.isArray(result.data)) {
|
||
const goals : UTSJSONObject[] = []
|
||
const dataArr = result.data as Array<UTSJSONObject>
|
||
for (let i = 0; i < dataArr.length; i++) {
|
||
const goal = dataArr[i]
|
||
const targetValue = goal.getNumber('target_value') ?? 0
|
||
const currentValue = goal.getNumber('current_value') ?? 0
|
||
const progressPercentage = targetValue > 0 ? Math.min(100, Math.round((currentValue / targetValue) * 100)) : 0
|
||
const goalType = goal.getString('goal_type') ?? ''
|
||
const unit = goal.getString('unit') ?? ''
|
||
const status = goal.getString('status') ?? ''
|
||
const id = goal.getString('id') ?? ''
|
||
// 直接用 new Object() 明确声明为普通对象,避免 UTS Map 构造器冲突
|
||
const obj : UTSJSONObject = {}
|
||
obj.id = id
|
||
obj.type = getGoalTypeText(goalType)
|
||
obj.targetValue = targetValue
|
||
obj.currentValue = currentValue
|
||
obj.unit = unit
|
||
obj.progressPercentage = progressPercentage
|
||
obj.status = getGoalStatusText(progressPercentage, status)
|
||
goals.push(obj)
|
||
}
|
||
goalsProgress.value = goals.sort(function (a, b) {
|
||
return (b.progressPercentage as number) - (a.progressPercentage as number)
|
||
})
|
||
}
|
||
} catch (error) {
|
||
console.error('加载目标进度失败:', error)
|
||
}
|
||
}
|
||
|
||
// 加载分析数据
|
||
const loadAnalyticsData = async () => {
|
||
try {
|
||
loading.value = true
|
||
await Promise.all([
|
||
loadSummaryData(),
|
||
loadCategoryDistribution(),
|
||
loadIntensityAnalysis(),
|
||
loadScheduleAnalysis(),
|
||
loadGoalsProgress(),
|
||
loadTrendMetrics()
|
||
])
|
||
generateRecommendations()
|
||
} catch (error) {
|
||
console.error('加载分析数据失败:', error)
|
||
uni.showToast({
|
||
title: '加载失败',
|
||
icon: 'none'
|
||
})
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 事件处理
|
||
const goBack = () => {
|
||
uni.navigateBack()
|
||
}
|
||
|
||
const refreshData = () => {
|
||
loadAnalyticsData()
|
||
}
|
||
const applyRecommendation = (recommendation : UTSJSONObject) => {
|
||
const action = (recommendation as UTSJSONObject).getString('action') ?? ''
|
||
|
||
switch (action) {
|
||
case 'navigate_to_exercises':
|
||
uni.navigateTo({
|
||
url: '/pages/sport/student/favorite-exercises'
|
||
})
|
||
break
|
||
case 'navigate_to_goals':
|
||
uni.navigateTo({
|
||
url: '/pages/sport/student/goal-settings'
|
||
})
|
||
break
|
||
case 'adjust_schedule':
|
||
uni.navigateTo({
|
||
url: '/pages/sport/student/reminder-settings'
|
||
})
|
||
break
|
||
default:
|
||
uni.showToast({
|
||
title: '建议已应用',
|
||
icon: 'success'
|
||
})
|
||
}
|
||
}
|
||
const dismissRecommendation = (id : string) => {
|
||
recommendations.value = recommendations.value.filter(rec =>
|
||
(rec as UTSJSONObject).getString('id') !== id
|
||
)
|
||
uni.showToast({
|
||
title: '建议已忽略',
|
||
icon: 'success'
|
||
})
|
||
}
|
||
|
||
// 生命周期
|
||
onMounted(() => {
|
||
const systemInfo = uni.getSystemInfoSync()
|
||
contentHeight.value = systemInfo.windowHeight - 120
|
||
})
|
||
// Lifecycle hooks
|
||
onMounted(() => {
|
||
screenWidth.value = uni.getSystemInfoSync().windowWidth
|
||
})
|
||
|
||
onResize((size) => {
|
||
screenWidth.value = size.size.windowWidth
|
||
})
|
||
|
||
onLoad((opt : OnLoadOptions) => {
|
||
const idParam = opt['id'] ?? ''
|
||
if (idParam !== '') {
|
||
userId.value = idParam
|
||
} else {
|
||
userId.value = getCurrentUserId()
|
||
}
|
||
loadAnalyticsData()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.preferences-analytics-page {
|
||
flex: 1;
|
||
background-color: #f5f5f5;
|
||
}
|
||
.header {
|
||
height: 60px;
|
||
background-image: linear-gradient(to top right, #667eea, #764ba2);
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 16px;
|
||
}
|
||
|
||
.header-left {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
flex: 1;
|
||
}
|
||
|
||
.back-btn,
|
||
.refresh-btn {
|
||
background-color: rgba(255, 255, 255, 0.2);
|
||
border: none;
|
||
border-radius: 20px;
|
||
padding: 8px 12px;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.back-btn {
|
||
margin-right: 12px;
|
||
}
|
||
|
||
.back-btn text,
|
||
.refresh-btn text {
|
||
color: #FFFFFF;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.title {
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
color: #FFFFFF;
|
||
}
|
||
|
||
.header-actions {
|
||
flex-direction: row;
|
||
gap: 8px;
|
||
}
|
||
|
||
.loading-container {
|
||
flex: 1;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.loading-text {
|
||
font-size: 16px;
|
||
color: #666;
|
||
}
|
||
|
||
.content {
|
||
flex: 1;
|
||
padding: 16px;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
/* Summary Section */
|
||
.summary-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.summary-cards {
|
||
flex-direction: row;
|
||
gap: 12px;
|
||
}
|
||
|
||
.summary-card {
|
||
flex: 1;
|
||
background-color: #FFFFFF;
|
||
border-radius: 12px;
|
||
padding: 16px;
|
||
align-items: center;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.card-icon {
|
||
font-size: 24px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.card-content {
|
||
align-items: center;
|
||
}
|
||
|
||
.card-number {
|
||
font-size: 20px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.card-label {
|
||
font-size: 12px;
|
||
color: #666;
|
||
text-align: center;
|
||
}
|
||
|
||
/* Distribution Section */
|
||
.distribution-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.chart-container {
|
||
background-color: #FFFFFF;
|
||
border-radius: 12px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.chart-legend {
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.legend-item {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.legend-color {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.legend-text {
|
||
font-size: 14px;
|
||
color: #333;
|
||
}
|
||
|
||
.legend-value {
|
||
font-size: 12px;
|
||
color: #666;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.distribution-bars {
|
||
gap: 12px;
|
||
}
|
||
|
||
.distribution-bar {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.bar-info {
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.bar-label {
|
||
font-size: 14px;
|
||
color: #333;
|
||
}
|
||
|
||
.bar-percentage {
|
||
font-size: 12px;
|
||
color: #666;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.bar-track {
|
||
height: 8px;
|
||
background-color: #f0f0f0;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.bar-fill {
|
||
height: 100%;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
/* Intensity Section */
|
||
.intensity-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.intensity-chart {
|
||
background-color: #FFFFFF;
|
||
border-radius: 12px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.intensity-levels {
|
||
gap: 12px;
|
||
}
|
||
|
||
.intensity-level {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.level-header {
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.level-name {
|
||
font-size: 14px;
|
||
color: #333;
|
||
}
|
||
|
||
.level-count {
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
.level-bar {
|
||
height: 6px;
|
||
background-color: #f0f0f0;
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.level-fill {
|
||
height: 100%;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
/* Schedule Section */
|
||
.schedule-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.weekly-schedule {
|
||
background-color: #FFFFFF;
|
||
border-radius: 12px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.time-periods {
|
||
gap: 16px;
|
||
}
|
||
|
||
.time-period {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.period-name {
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.period-days {
|
||
flex-direction: row;
|
||
gap: 8px;
|
||
}
|
||
|
||
.day-item {
|
||
flex: 1;
|
||
background-color: #f8f9fa;
|
||
border-radius: 8px;
|
||
padding: 8px 4px;
|
||
align-items: center;
|
||
min-height: 48px;
|
||
justify-content: center;
|
||
}
|
||
|
||
.day-item.has-training {
|
||
background-color: #E8F5E8;
|
||
border: 1px solid #4CAF50;
|
||
}
|
||
|
||
.day-name {
|
||
font-size: 12px;
|
||
color: #666;
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.day-count {
|
||
font-size: 10px;
|
||
color: #4CAF50;
|
||
font-weight: bold;
|
||
}
|
||
|
||
/* Goals Section */
|
||
.goals-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.goals-list {
|
||
gap: 12px;
|
||
}
|
||
|
||
.goal-item {
|
||
background-color: #FFFFFF;
|
||
border-radius: 12px;
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.goal-header {
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.goal-type {
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
.goal-progress-text {
|
||
font-size: 14px;
|
||
color: #4CAF50;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.goal-progress-bar {
|
||
height: 8px;
|
||
background-color: #f0f0f0;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.goal-progress-fill {
|
||
height: 100%;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.goal-meta {
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.goal-current {
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
.goal-status {
|
||
font-size: 12px;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.status-completed {
|
||
background-color: #E8F5E8;
|
||
color: #4CAF50;
|
||
}
|
||
|
||
.status-active {
|
||
background-color: #FFF3E0;
|
||
color: #FF9800;
|
||
}
|
||
|
||
.status-pending {
|
||
background-color: #FFEBEE;
|
||
color: #F44336;
|
||
}
|
||
|
||
/* Recommendations Section */
|
||
.recommendations-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.recommendations-list {
|
||
gap: 12px;
|
||
}
|
||
|
||
.recommendation-item {
|
||
background-color: #FFFFFF;
|
||
border-radius: 12px;
|
||
padding: 16px;
|
||
flex-direction: row;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.recommendation-icon {
|
||
width: 40px;
|
||
height: 40px;
|
||
background-color: #f0f0f0;
|
||
border-radius: 20px;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.rec-emoji {
|
||
font-size: 20px;
|
||
}
|
||
|
||
.recommendation-content {
|
||
flex: 1;
|
||
}
|
||
|
||
.rec-title {
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.rec-description {
|
||
font-size: 14px;
|
||
color: #666;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.rec-actions {
|
||
flex-direction: row;
|
||
gap: 8px;
|
||
}
|
||
|
||
.apply-btn,
|
||
.dismiss-btn {
|
||
padding: 6px 12px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
border: none;
|
||
}
|
||
|
||
.apply-btn {
|
||
background-color: #4CAF50;
|
||
color: #FFFFFF;
|
||
}
|
||
|
||
.dismiss-btn {
|
||
background-color: #f5f5f5;
|
||
color: #666;
|
||
}
|
||
|
||
/* Trends Section */
|
||
.trends-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.trends-chart {
|
||
background-color: #FFFFFF;
|
||
border-radius: 12px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.trend-metrics {
|
||
gap: 16px;
|
||
}
|
||
|
||
.trend-metric {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.metric-header {
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.metric-name {
|
||
font-size: 14px;
|
||
color: #333;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.metric-change {
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.metric-change.positive {
|
||
color: #4CAF50;
|
||
}
|
||
|
||
.metric-change.negative {
|
||
color: #F44336;
|
||
}
|
||
|
||
.metric-chart {
|
||
flex-direction: row;
|
||
align-items: end;
|
||
gap: 4px;
|
||
height: 40px;
|
||
}
|
||
|
||
.data-point {
|
||
flex: 1;
|
||
background-color: #4CAF50;
|
||
border-radius: 2px;
|
||
min-height: 4px;
|
||
}
|
||
</style> |