1293 lines
30 KiB
Plaintext
1293 lines
30 KiB
Plaintext
<template>
|
|
<scroll-view direction="vertical" class="favorite-exercises-page" :scroll-y="true" :enable-back-to-top="true">
|
|
<!-- 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="showSearch = !showSearch" class="search-btn">
|
|
<simple-icon type="search" :size="16" color="#FFFFFF" />
|
|
<text>搜索</text>
|
|
</button>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- Search Bar -->
|
|
<view v-if="showSearch" class="search-bar">
|
|
<input :value="searchQuery" class="search-input"
|
|
placeholder="搜索运动项目..." @input="onSearchInput" />
|
|
<button v-if="searchQuery" @click="clearSearch" class="clear-btn">
|
|
<simple-icon type="x" :size="16" color="#999" />
|
|
</button>
|
|
</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' }">
|
|
<!-- User Preferences Summary -->
|
|
<view class="summary-section">
|
|
<view class="section-title">我的运动偏好</view>
|
|
<view class="summary-stats">
|
|
<view class="stat-item">
|
|
<text class="stat-number">{{ favoriteCount }}</text>
|
|
<text class="stat-label">喜爱运动</text>
|
|
</view>
|
|
<view class="stat-item">
|
|
<text class="stat-number">{{ totalWeeklyTime }}</text>
|
|
<text class="stat-label">周计划时长(分钟)</text>
|
|
</view>
|
|
<view class="stat-item">
|
|
<text class="stat-number">{{ averageIntensity }}</text>
|
|
<text class="stat-label">平均强度</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- Category Tabs -->
|
|
<view class="category-section">
|
|
<view class="section-title">运动分类</view>
|
|
<scroll-view class="category-tabs" scroll-x="true">
|
|
<view v-for="category in categories" :key="category.key"
|
|
class="category-tab"
|
|
:class="{ 'active': selectedCategory == category.key }"
|
|
@click="selectCategory(category.key.toString())">
|
|
<text class="category-emoji">{{ category.icon }}</text>
|
|
<text class="category-name">{{ category.name }}</text>
|
|
</view>
|
|
</scroll-view>
|
|
</view>
|
|
|
|
<!-- Sports List -->
|
|
<view class="sports-section">
|
|
<view class="section-title">{{ getCategoryName(selectedCategory) }}</view>
|
|
<view v-if="filteredSports.length === 0" class="empty-state">
|
|
<simple-icon type="search" :size="48" color="#BDC3C7" />
|
|
<text class="empty-text">没有找到相关运动</text>
|
|
<text class="empty-desc">试试其他搜索词或选择不同分类</text>
|
|
</view>
|
|
<view v-else class="sports-list">
|
|
<view v-for="sport in (filteredSports as UTSJSONObject[])" :key="sport.id"
|
|
class="sport-item" @click="toggleSportPreference(sport)">
|
|
<view class="sport-header">
|
|
<view class="sport-icon">
|
|
<text class="sport-emoji">{{ getSportIcon(sport) }}</text>
|
|
</view>
|
|
<view class="sport-info">
|
|
<text class="sport-name">{{ sport.name }}</text>
|
|
<text class="sport-desc">{{ (sport.description ?? '暂无描述') as string }}</text>
|
|
<view class="sport-meta">
|
|
<text class="meta-item">难度: {{ getDifficultyText((sport.difficulty_level as number) ?? 1) }}</text>
|
|
<text class="meta-item">{{ (sport.getNumber("calorie_rate") as number) ?? 0 }}卡/分钟</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<view v-if="getUserPreference(sport.id as string)" class="preference-info">
|
|
<view class="preference-row">
|
|
<text class="preference-label">喜好程度:</text>
|
|
<view class="rating-stars">
|
|
<text v-for="star in 5" :key="star"
|
|
class="star"
|
|
:class="{ 'filled': star <= ((getUserPreference(sport.id as string)?.preference_level as number) ?? 0) }"
|
|
@click="updatePreferenceLevel(sport.id as string, star)">
|
|
★
|
|
</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="preference-row">
|
|
<text class="preference-label">每周{{ (getUserPreference(sport.id as string)?.frequency_per_week ?? '').toString() }}次</text>
|
|
<text class="preference-label">每次{{ (getUserPreference(sport.id as string)?.duration_minutes ?? '').toString() }}分钟</text>
|
|
</view>
|
|
|
|
<view class="preference-actions">
|
|
<button @click="editPreference(sport)" class="edit-pref-btn">设置详情</button>
|
|
<button v-if="getUserPreference(sport.id as string)?.is_favorite === true"
|
|
@click="toggleFavorite(sport.id as string)"
|
|
class="favorite-btn active">
|
|
<simple-icon type="heart" :size="12" color="#FF4757" />
|
|
<text>最爱</text>
|
|
</button>
|
|
<button v-else @click="toggleFavorite(sport.id as string)" class="favorite-btn">
|
|
<simple-icon type="heart" :size="12" color="#999" />
|
|
<text>设为最爱</text>
|
|
</button>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- Recommended Sports -->
|
|
<view v-if="recommendedSports.length > 0" class="recommendations-section">
|
|
<view class="section-title">推荐运动</view>
|
|
<text class="section-desc">根据你的偏好为你推荐</text>
|
|
<view class="recommended-list">
|
|
<view v-for="sport in recommendedSports" :key="sport.id"
|
|
class="recommended-item" @click="addRecommendedSport(sport)">
|
|
<view class="recommended-icon">
|
|
<text class="recommended-emoji">{{ getSportIcon(sport) }}</text>
|
|
</view>
|
|
<view class="recommended-info">
|
|
<text class="recommended-name">{{ sport.name }}</text>
|
|
<text class="recommended-reason">{{ sport.recommendation_reason }}</text>
|
|
</view>
|
|
<simple-icon type="plus-circle" :size="20" color="#4CAF50" />
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
|
|
<!-- Edit Preference Modal -->
|
|
<view v-if="showPreferenceModal" class="modal-overlay" @click="closePreferenceModal">
|
|
<view class="modal-content" @click.stop>
|
|
<view class="modal-header">
|
|
<text class="modal-title">设置运动偏好</text>
|
|
<button @click="closePreferenceModal" class="modal-close">
|
|
<simple-icon type="x" :size="20" color="#666" />
|
|
</button>
|
|
</view>
|
|
|
|
<view class="modal-body">
|
|
<view class="sport-preview">
|
|
<view class="preview-icon">
|
|
<text class="preview-emoji">{{ getSportIcon(editingSport) }}</text>
|
|
</view>
|
|
<view class="preview-info">
|
|
<text class="preview-name">{{ editingSport?.name }}</text>
|
|
<text class="preview-category">{{ editingSport?.category }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="form-group">
|
|
<text class="form-label">喜好程度</text>
|
|
<view class="rating-selector">
|
|
<text v-for="level in 5" :key="level"
|
|
class="rating-star"
|
|
:class="{ 'selected': level <= (preferenceForm['preference_level'] as number) }"
|
|
@click="preferenceForm['preference_level'] = level as number">
|
|
★
|
|
</text>
|
|
</view>
|
|
<text class="rating-text">{{ getRatingText(preferenceForm['preference_level'] as number) }}</text>
|
|
</view>
|
|
|
|
<view class="form-group">
|
|
<text class="form-label">每周频次: {{ preferenceForm['frequency_per_week'] as number }}次</text>
|
|
<slider :value="preferenceForm['frequency_per_week'] as number"
|
|
min="1" max="7"
|
|
@change="onFrequencyChange"
|
|
activeColor="#4CAF50" />
|
|
</view>
|
|
|
|
<view class="form-group">
|
|
<text class="form-label">每次时长: {{ preferenceForm['duration_minutes'] as number }}分钟</text>
|
|
<slider :value="preferenceForm['duration_minutes'] as number"
|
|
min="10" max="120" step="5"
|
|
@change="onDurationChange"
|
|
activeColor="#4CAF50" />
|
|
</view>
|
|
|
|
<view class="form-group">
|
|
<text class="form-label">强度等级: {{ getIntensityText(preferenceForm['intensity_level'] as number) }}</text>
|
|
<slider :value="preferenceForm['intensity_level'] as number"
|
|
min="1" max="5"
|
|
@change="onIntensityChange"
|
|
activeColor="#4CAF50" />
|
|
</view>
|
|
|
|
<view class="form-group">
|
|
<text class="form-label">备注</text>
|
|
<textarea :value="preferenceForm['notes'] as string" class="form-textarea"
|
|
placeholder="记录你对这项运动的想法..." maxlength="200"></textarea>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="modal-footer">
|
|
<button @click="removePreference" class="remove-btn">移除偏好</button>
|
|
<button @click="closePreferenceModal" class="cancel-btn">取消</button>
|
|
<button @click="savePreference" class="save-btn">保存</button>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
</template>
|
|
|
|
<script setup lang="uts"> import { ref, onMounted, computed } from 'vue'
|
|
import { onResize, onLoad } from '@dcloudio/uni-app'
|
|
import { getCurrentUserId } from '@/utils/store'
|
|
import supaClient from '@/components/supadb/aksupainstance.uts'
|
|
|
|
// 响应式数据
|
|
const loading = ref(true)
|
|
const showSearch = ref(false)
|
|
const searchQuery = ref('')
|
|
const selectedCategory = ref('all')
|
|
const sports = ref<UTSJSONObject[]>([])
|
|
const userPreferences = ref<UTSJSONObject[]>([])
|
|
const recommendedSports = ref<UTSJSONObject[]>([])
|
|
const contentHeight = ref(0)
|
|
|
|
// 新增:全局 userId 响应式变量
|
|
const userId = ref<string>('')
|
|
|
|
// 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 showPreferenceModal = ref(false)
|
|
const editingSport = ref<UTSJSONObject | null>(null)
|
|
const preferenceForm = ref({
|
|
preference_level: 3,
|
|
frequency_per_week: 2,
|
|
duration_minutes: 30,
|
|
intensity_level: 2,
|
|
notes: ''
|
|
})
|
|
|
|
// 分类数据
|
|
const categories = [
|
|
{ key: 'all', name: '全部', icon: '🏃' },
|
|
{ key: '有氧运动', name: '有氧运动', icon: '💨' },
|
|
{ key: '力量训练', name: '力量训练', icon: '💪' },
|
|
{ key: '球类运动', name: '球类运动', icon: '⚽' },
|
|
{ key: '柔韧训练', name: '柔韧训练', icon: '🤸' },
|
|
{ key: '水上运动', name: '水上运动', icon: '🏊' },
|
|
{ key: '户外运动', name: '户外运动', icon: '🚴' }
|
|
]
|
|
|
|
// 计算属性
|
|
const filteredSports = computed(() => {
|
|
let result = sports.value
|
|
// 按分类过滤
|
|
if (selectedCategory.value !== 'all') {
|
|
result = result.filter(sport => sport['category'] === selectedCategory.value)
|
|
}
|
|
// 按搜索词过滤
|
|
if (searchQuery.value !== '') {
|
|
const query = searchQuery.value.toString().toLowerCase()
|
|
result = result.filter(sport => {
|
|
const name = (sport['name'] ?? '').toString().toLowerCase()
|
|
const description = (sport['description'] ?? '').toString().toLowerCase()
|
|
return name.includes(query) || description.includes(query)
|
|
})
|
|
}
|
|
return result
|
|
})
|
|
|
|
const favoriteCount = computed(() => {
|
|
return userPreferences.value.filter(pref => pref['is_favorite'] === true).length.toString()
|
|
})
|
|
|
|
const totalWeeklyTime = computed(() => {
|
|
return userPreferences.value.reduce((total, pref) => {
|
|
const frequency = pref['frequency_per_week'] as number ?? 0
|
|
const duration = pref['duration_minutes'] as number ?? 0
|
|
return total + (frequency * duration)
|
|
}, 0 as number)
|
|
})
|
|
|
|
const averageIntensity = computed(() => {
|
|
if (userPreferences.value.length === 0) return 0
|
|
const totalIntensity = userPreferences.value.reduce((total, pref) => {
|
|
const raw = pref['intensity_level']
|
|
const intensity = raw != null ? (raw as number) : 1
|
|
return total + intensity
|
|
}, 0 as number)
|
|
return Math.round(totalIntensity / userPreferences.value.length * 10) / 10
|
|
})
|
|
|
|
|
|
// 计算内容高度
|
|
const calculateContentHeight = () => {
|
|
const systemInfo = uni.getSystemInfoSync()
|
|
const windowHeight = systemInfo.windowHeight
|
|
const headerHeight = 60
|
|
const searchHeight = showSearch.value ? 50 : 0
|
|
contentHeight.value = windowHeight - headerHeight - searchHeight
|
|
}
|
|
|
|
|
|
|
|
// 加载运动类型
|
|
const loadSportTypes = async () => {
|
|
const result = await supaClient
|
|
.from('ak_sport_types')
|
|
.select('*',{count:'exact'})
|
|
.eq('is_active', true)
|
|
.order('category', { ascending: true })
|
|
.execute()
|
|
|
|
if (result.data!=null) {
|
|
sports.value = result.data as UTSJSONObject[]
|
|
}
|
|
}
|
|
|
|
// 加载用户偏好
|
|
const loadUserPreferences = async () => {
|
|
if (userId.value == null || userId.value === '') return
|
|
|
|
const result = await supaClient
|
|
.from('ak_user_sport_preferences')
|
|
.select(`
|
|
*,
|
|
sport_type:ak_sport_types(*)
|
|
`,{count:'exact'})
|
|
.eq('user_id', userId.value)
|
|
.execute()
|
|
|
|
if (result.data != null) {
|
|
userPreferences.value = result.data as UTSJSONObject[]
|
|
}
|
|
}
|
|
|
|
|
|
// 返回上一页
|
|
const goBack = () => {
|
|
uni.navigateBack()
|
|
}
|
|
|
|
// 搜索相关
|
|
const onSearchInput = () => {
|
|
// 实时搜索,这里可以添加防抖逻辑
|
|
}
|
|
|
|
const clearSearch = () => {
|
|
searchQuery.value = ''
|
|
}
|
|
|
|
// 分类选择
|
|
const selectCategory = (category: string) => {
|
|
selectedCategory.value = category
|
|
}
|
|
|
|
|
|
// 创建默认偏好
|
|
const createDefaultPreference = async (sport: UTSJSONObject) => {
|
|
try {
|
|
if (userId.value == null || userId.value === '') return
|
|
const preferenceData = {
|
|
user_id: userId.value,
|
|
sport_type_id: (sport['id'] ?? ''),
|
|
preference_level: 3,
|
|
frequency_per_week: 2,
|
|
duration_minutes: 30,
|
|
intensity_level: 2,
|
|
is_favorite: false
|
|
}
|
|
const result = await supaClient
|
|
.from('ak_user_sport_preferences')
|
|
.insert(preferenceData)
|
|
.execute()
|
|
const err = result.error
|
|
if (err != null) {
|
|
uni.showToast({
|
|
title: err.message,
|
|
icon: 'none'
|
|
})
|
|
return
|
|
}
|
|
uni.showToast({
|
|
title: '已添加到偏好',
|
|
icon: 'success'
|
|
})
|
|
loadUserPreferences()
|
|
} catch (error) {
|
|
console.error('创建偏好失败:', error)
|
|
uni.showToast({
|
|
title: '操作失败',
|
|
icon: 'none'
|
|
})
|
|
}
|
|
}
|
|
|
|
// 工具函数
|
|
const getUserPreference = (sportId: string): UTSJSONObject | null => {
|
|
const found = userPreferences.value.find(pref => {
|
|
return pref['sport_type_id'] === sportId
|
|
})
|
|
return found != null ? found as UTSJSONObject : null
|
|
}
|
|
|
|
const getSportIcon = (sport: UTSJSONObject | null): string => {
|
|
if (sport == null) return '🏃'
|
|
let category = ''
|
|
if (sport != null) {
|
|
const cat = sport['category']
|
|
category = typeof cat === 'string' ? cat : ''
|
|
}
|
|
const icons = {
|
|
'有氧运动': '🏃',
|
|
'力量训练': '🏋️',
|
|
'球类运动': '⚽',
|
|
'柔韧训练': '🤸',
|
|
'水上运动': '🏊',
|
|
'户外运动': '🚴'
|
|
}
|
|
const icon = icons[category]
|
|
return typeof icon === 'string' ? icon : '🏃'
|
|
}
|
|
|
|
const getCategoryName = (key: string): string => {
|
|
const category = categories.find(c => c.key === key)
|
|
return category != null ? (category['name'] as string) : '全部'
|
|
}
|
|
|
|
const getDifficultyText = (level: number): string => {
|
|
const texts = ['', '入门', '初级', '中级', '高级', '专业']
|
|
const text = texts[level]
|
|
return typeof text === 'string' ? text : '未知'
|
|
}
|
|
|
|
const getRatingText = (level: number): string => {
|
|
const texts = ['', '不太喜欢', '一般', '喜欢', '很喜欢', '非常喜欢']
|
|
const text = texts[level]
|
|
return typeof text === 'string' ? text : '一般'
|
|
}
|
|
|
|
const getIntensityText = (level: number): string => {
|
|
const texts = ['', '很轻松', '轻松', '中等', '较高', '很高']
|
|
const text = texts[level]
|
|
return typeof text === 'string' ? text : '中等'
|
|
}
|
|
|
|
const getRecommendationReason = (rec: any): string => {
|
|
const reasons = [
|
|
'适合你的健身水平',
|
|
'与你的喜好相匹配',
|
|
'帮助达成训练目标',
|
|
'平衡你的运动类型',
|
|
'新手友好的选择'
|
|
]
|
|
return reasons[Math.floor(Math.random() * reasons.length)]
|
|
}
|
|
|
|
// 编辑偏好
|
|
const editPreference = (sport: UTSJSONObject) => {
|
|
editingSport.value = sport
|
|
const preference = getUserPreference(sport['id'] as string)
|
|
if (preference != null) {
|
|
preferenceForm.value = {
|
|
preference_level: preference['preference_level'] as number ?? 3,
|
|
frequency_per_week: preference['frequency_per_week'] as number ?? 2,
|
|
duration_minutes: preference['duration_minutes'] as number ?? 30,
|
|
intensity_level: preference['intensity_level'] as number ?? 2,
|
|
notes: preference['notes'] as string ?? ''
|
|
}
|
|
} else {
|
|
preferenceForm.value = {
|
|
preference_level: 3,
|
|
frequency_per_week: 2,
|
|
duration_minutes: 30,
|
|
intensity_level: 2,
|
|
notes: ''
|
|
}
|
|
}
|
|
|
|
showPreferenceModal.value = true
|
|
}
|
|
|
|
// 关闭偏好模态框
|
|
const closePreferenceModal = () => {
|
|
showPreferenceModal.value = false
|
|
editingSport.value = null
|
|
}
|
|
|
|
// 保存偏好
|
|
const savePreference = async () => {
|
|
try {
|
|
if (userId.value == null || userId.value === '' || editingSport.value == null) return
|
|
const editing = editingSport.value
|
|
const sportId = (editing?.['id'] ?? '') as string
|
|
const existing = getUserPreference(sportId)
|
|
const preferenceData = {
|
|
user_id: userId.value,
|
|
sport_type_id: sportId,
|
|
...preferenceForm.value
|
|
}
|
|
if (existing != null) {
|
|
const result = await supaClient
|
|
.from('ak_user_sport_preferences')
|
|
.update(preferenceData)
|
|
.eq('id', existing?.['id'] ?? '')
|
|
.execute()
|
|
const reserr = result.error
|
|
if (reserr != null) {
|
|
uni.showToast({
|
|
title: reserr.message,
|
|
icon: 'none'
|
|
})
|
|
return
|
|
}
|
|
} else {
|
|
const result = await supaClient
|
|
.from('ak_user_sport_preferences')
|
|
.insert(preferenceData)
|
|
.execute()
|
|
const reserr = result.error
|
|
if (reserr != null) {
|
|
uni.showToast({
|
|
title: reserr.message,
|
|
icon: 'none'
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
uni.showToast({
|
|
title: '保存成功',
|
|
icon: 'success'
|
|
})
|
|
closePreferenceModal()
|
|
loadUserPreferences()
|
|
} catch (error) {
|
|
console.error('保存偏好失败:', error)
|
|
uni.showToast({
|
|
title: '保存失败',
|
|
icon: 'none'
|
|
})
|
|
}
|
|
}
|
|
|
|
// 移除偏好
|
|
const removePreference = () => {
|
|
uni.showModal({
|
|
title: '移除偏好',
|
|
content: '确定要移除这个运动偏好吗?',
|
|
success: function(res) {
|
|
(async () => {
|
|
if (res.confirm) {
|
|
try {
|
|
const sportId = editingSport.value?.['id'] ?? ''
|
|
const existing = getUserPreference(sportId as string)
|
|
if (existing!= null) {
|
|
const result = await supaClient
|
|
.from('ak_user_sport_preferences')
|
|
.delete()
|
|
.eq('id', existing?.['id'] ?? '')
|
|
.execute()
|
|
const reserr = result.error
|
|
if (reserr != null) {
|
|
uni.showToast({
|
|
title: reserr.message,
|
|
icon: 'none'
|
|
})
|
|
return
|
|
}
|
|
uni.showToast({
|
|
title: '已移除',
|
|
icon: 'success'
|
|
})
|
|
closePreferenceModal()
|
|
loadUserPreferences()
|
|
}
|
|
} catch (error) {
|
|
console.error('移除偏好失败:', error)
|
|
uni.showToast({
|
|
title: '操作失败',
|
|
icon: 'none'
|
|
})
|
|
}
|
|
}
|
|
})()
|
|
}
|
|
})
|
|
}
|
|
|
|
// 切换最爱
|
|
const toggleFavorite = async (sportId: string) => {
|
|
try {
|
|
const preference = getUserPreference(sportId)
|
|
if (preference == null) return
|
|
const newFavoriteStatus = ((preference?.['is_favorite'] ?? false) as boolean) ? false : true
|
|
const result = await supaClient
|
|
.from('ak_user_sport_preferences')
|
|
.update({ is_favorite: newFavoriteStatus })
|
|
.eq('id', preference?.['id'] ?? '')
|
|
.execute()
|
|
const reserr = result.error
|
|
if (reserr != null) {
|
|
uni.showToast({
|
|
title: reserr.message,
|
|
icon: 'none'
|
|
})
|
|
return
|
|
}
|
|
uni.showToast({
|
|
title: newFavoriteStatus ? '已设为最爱' : '已取消最爱',
|
|
icon: 'success'
|
|
})
|
|
loadUserPreferences()
|
|
} catch (error) {
|
|
console.error('更新最爱状态失败:', error)
|
|
uni.showToast({
|
|
title: '操作失败',
|
|
icon: 'none'
|
|
})
|
|
}
|
|
}
|
|
|
|
// 更新偏好等级
|
|
const updatePreferenceLevel = async (sportId: string, level: number) => {
|
|
try {
|
|
const preference = getUserPreference(sportId)
|
|
if (preference == null) return
|
|
const result = await supaClient
|
|
.from('ak_user_sport_preferences')
|
|
.update({ preference_level: level })
|
|
.eq('id', preference?.['id'] ?? '')
|
|
.execute()
|
|
const reserr = result.error
|
|
if (reserr != null) {
|
|
uni.showToast({
|
|
title: reserr.message,
|
|
icon: 'none'
|
|
})
|
|
return
|
|
}
|
|
loadUserPreferences()
|
|
} catch (error) {
|
|
console.error('更新偏好等级失败:', error)
|
|
}
|
|
}
|
|
|
|
// 添加推荐运动
|
|
const addRecommendedSport = (sport: UTSJSONObject) => {
|
|
createDefaultPreference(sport)
|
|
}
|
|
|
|
// 表单事件处理
|
|
const onFrequencyChange = (e:UniSliderChangeEvent) => {
|
|
preferenceForm.value['frequency_per_week'] = e.detail.value
|
|
}
|
|
|
|
const onDurationChange = (e:UniSliderChangeEvent) => {
|
|
preferenceForm.value['duration_minutes'] = e.detail.value
|
|
}
|
|
|
|
const onIntensityChange = (e:UniSliderChangeEvent) => {
|
|
preferenceForm.value['intensity_level'] = e.detail.value
|
|
}
|
|
|
|
// 加载推荐
|
|
const loadRecommendations = async () => {
|
|
if (userId.value == null || userId.value === '') return
|
|
|
|
try {
|
|
const result = await supaClient.rpc('get_training_recommendations', {
|
|
p_user_id: userId.value
|
|
})
|
|
if (result.data!=null) {
|
|
const recommendations = Array.isArray(result.data) ? result.data : [result.data]
|
|
const recList: UTSJSONObject[] = []
|
|
for (let i = 0; i < recommendations.length; i++) {
|
|
const rec = recommendations[i] as UTSJSONObject
|
|
|
|
recList.push({
|
|
...rec,
|
|
recommendation_reason: getRecommendationReason(rec)
|
|
})
|
|
}
|
|
recommendedSports.value = recList
|
|
}
|
|
else
|
|
{
|
|
console.log('get_training_recommendations null')
|
|
}
|
|
} catch (error) {
|
|
console.log('推荐功能暂不可用')
|
|
}
|
|
}
|
|
|
|
// 加载数据
|
|
const loadData = async () => {
|
|
try {
|
|
loading.value = true
|
|
await Promise.all([
|
|
loadSportTypes(),
|
|
loadUserPreferences(),
|
|
loadRecommendations()
|
|
])
|
|
} catch (error) {
|
|
console.error('加载数据失败:', error)
|
|
uni.showToast({
|
|
title: '加载失败',
|
|
icon: 'none'
|
|
})
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
// 生命周期
|
|
onMounted(() => {
|
|
calculateContentHeight()
|
|
loadData()
|
|
}) // Lifecycle hooks
|
|
onMounted(() => {
|
|
screenWidth.value = uni.getSystemInfoSync().windowWidth
|
|
})
|
|
|
|
onResize((size) => {
|
|
screenWidth.value = size.size.windowWidth
|
|
})
|
|
|
|
onLoad((options: OnLoadOptions) => {
|
|
userId.value = options['id'] ?? getCurrentUserId();
|
|
})
|
|
|
|
// 工具函数下方新增
|
|
const toggleSportPreference = (sport: UTSJSONObject) => {
|
|
const pref = getUserPreference(sport['id'] as string)
|
|
if (pref == null) {
|
|
createDefaultPreference(sport)
|
|
} else {
|
|
editPreference(sport)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.favorite-exercises-page {
|
|
display: flex;
|
|
flex:1;
|
|
background-color: #f5f5f5;
|
|
padding-bottom: 40rpx;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.header {
|
|
height: 60px; background-image: linear-gradient(to top right, #4CAF50, #45a049);
|
|
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, .search-btn {
|
|
background-color: rgba(255, 255, 255, 0.2);
|
|
border: none;
|
|
border-radius: 20px;
|
|
padding: 8px 12px;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
}
|
|
|
|
.back-btn simple-icon,
|
|
.search-btn simple-icon {
|
|
margin-right: 4px;
|
|
}
|
|
|
|
.back-btn {
|
|
margin-right: 12px;
|
|
}
|
|
|
|
.back-btn text, .search-btn text {
|
|
color: #FFFFFF;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.title {
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
color: #FFFFFF;
|
|
}
|
|
|
|
.search-bar {
|
|
background-color: #FFFFFF;
|
|
padding: 12px 16px;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.search-input {
|
|
flex: 1;
|
|
padding: 8px 12px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 20px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.clear-btn {
|
|
background: transparent;
|
|
border: none;
|
|
padding: 8px;
|
|
margin-left: 8px;
|
|
}
|
|
|
|
.content {
|
|
flex: 1;
|
|
padding: 16px;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
color: #333;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.section-desc {
|
|
font-size: 14px;
|
|
color: #666;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.summary-section {
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.summary-stats {
|
|
flex-direction: row;
|
|
gap: 12px;
|
|
}
|
|
|
|
.stat-item {
|
|
background-color: #FFFFFF;
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
flex: 1;
|
|
align-items: center;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-number {
|
|
font-size: 24px;
|
|
font-weight: bold;
|
|
color: #4CAF50;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 12px;
|
|
color: #666;
|
|
}
|
|
|
|
.category-section {
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.category-tabs {
|
|
flex:1;
|
|
flex-direction: row;
|
|
|
|
gap: 8px;
|
|
height: 80px;
|
|
}
|
|
|
|
.category-tab {
|
|
background-color: #FFFFFF;
|
|
border-radius: 12px;
|
|
padding: 12px 16px;
|
|
align-items: center;
|
|
text-align: center;
|
|
min-width: 80px;
|
|
border: 2px solid transparent;
|
|
}
|
|
|
|
.category-tab.active {
|
|
border-color: #4CAF50;
|
|
background-color: #E8F5E8;
|
|
}
|
|
|
|
.category-emoji {
|
|
font-size: 20px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.category-name {
|
|
font-size: 12px;
|
|
color: #666;
|
|
}
|
|
|
|
.category-tab.active .category-name {
|
|
color: #4CAF50;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.sports-section {
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.empty-state {
|
|
background-color: #FFFFFF;
|
|
border-radius: 12px;
|
|
padding: 40px 20px;
|
|
align-items: center;
|
|
text-align: center;
|
|
}
|
|
|
|
.empty-text {
|
|
font-size: 16px;
|
|
color: #666;
|
|
margin: 12px 0 4px;
|
|
}
|
|
|
|
.empty-desc {
|
|
font-size: 14px;
|
|
color: #999;
|
|
}
|
|
|
|
.sports-list {
|
|
gap: 12px;
|
|
}
|
|
|
|
.sport-item {
|
|
background-color: #FFFFFF;
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
|
|
background-color: #FFFFFF;
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.sport-header {
|
|
flex-direction: row;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.sport-icon {
|
|
width: 40px;
|
|
height: 40px;
|
|
background-color: #f0f0f0;
|
|
border-radius: 20px;
|
|
justify-content: center;
|
|
align-items: center;
|
|
margin-right: 12px;
|
|
}
|
|
|
|
.sport-emoji {
|
|
font-size: 20px;
|
|
}
|
|
|
|
.sport-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.sport-name {
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
color: #333;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.sport-desc {
|
|
font-size: 12px;
|
|
color: #666;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.sport-meta {
|
|
flex-direction: row;
|
|
gap: 12px;
|
|
}
|
|
|
|
.meta-item {
|
|
font-size: 11px;
|
|
color: #999;
|
|
background-color: #f8f9fa;
|
|
padding: 2px 6px;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.preference-info {
|
|
border-top: 1px solid #f0f0f0;
|
|
padding-top: 12px;
|
|
gap: 8px;
|
|
}
|
|
|
|
.preference-row {
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.preference-label {
|
|
font-size: 14px;
|
|
color: #666;
|
|
}
|
|
|
|
.rating-stars {
|
|
flex-direction: row;
|
|
gap: 2px;
|
|
}
|
|
|
|
.star {
|
|
font-size: 16px;
|
|
color: #ddd;
|
|
}
|
|
|
|
.star.filled {
|
|
color: #FFD700;
|
|
}
|
|
|
|
.preference-actions {
|
|
flex-direction: row;
|
|
gap: 8px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.edit-pref-btn, .favorite-btn {
|
|
padding: 6px 12px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
border: none;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.edit-pref-btn {
|
|
background-color: #4CAF50;
|
|
color: #FFFFFF;
|
|
}
|
|
|
|
.favorite-btn {
|
|
background-color: #f5f5f5;
|
|
color: #666;
|
|
}
|
|
|
|
.favorite-btn.active {
|
|
background-color: #FFE5E5;
|
|
color: #FF4757;
|
|
}
|
|
|
|
.recommendations-section {
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.recommended-list {
|
|
gap: 8px;
|
|
}
|
|
|
|
.recommended-item {
|
|
background-color: #FFFFFF;
|
|
border-radius: 12px;
|
|
padding: 12px;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.recommended-icon {
|
|
width: 32px;
|
|
height: 32px;
|
|
background-color: #f0f0f0;
|
|
border-radius: 16px;
|
|
justify-content: center;
|
|
align-items: center;
|
|
margin-right: 12px;
|
|
}
|
|
|
|
.recommended-emoji {
|
|
font-size: 16px;
|
|
}
|
|
|
|
.recommended-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.recommended-name {
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
color: #333;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.recommended-reason {
|
|
font-size: 12px;
|
|
color: #666;
|
|
}
|
|
|
|
/* Modal Styles */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
justify-content: center;
|
|
align-items: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.modal-content {
|
|
background-color: #FFFFFF;
|
|
border-radius: 12px;
|
|
width: 85%;
|
|
max-height: 80%;
|
|
}
|
|
|
|
.modal-header {
|
|
flex-direction: row;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 16px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.modal-title {
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
color: #333;
|
|
}
|
|
|
|
.modal-close {
|
|
background: transparent;
|
|
border: none;
|
|
padding: 4px;
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 16px;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.sport-preview {
|
|
flex-direction: row;
|
|
align-items: center;
|
|
padding: 12px;
|
|
background-color: #f8f9fa;
|
|
border-radius: 8px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.preview-icon {
|
|
width: 40px;
|
|
height: 40px;
|
|
background-color: #FFFFFF;
|
|
border-radius: 20px;
|
|
justify-content: center;
|
|
align-items: center;
|
|
margin-right: 12px;
|
|
}
|
|
|
|
.preview-emoji {
|
|
font-size: 20px;
|
|
}
|
|
|
|
.preview-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.preview-name {
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
color: #333;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.preview-category {
|
|
font-size: 12px;
|
|
color: #666;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.form-label {
|
|
font-size: 14px;
|
|
color: #333;
|
|
margin-bottom: 8px;
|
|
display: block;
|
|
}
|
|
|
|
.form-textarea {
|
|
width: 100%;
|
|
padding: 12px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
height: 80px;
|
|
text-align: start;
|
|
}
|
|
|
|
.rating-selector {
|
|
flex-direction: row;
|
|
gap: 4px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.rating-star {
|
|
font-size: 24px;
|
|
color: #ddd;
|
|
padding: 4px;
|
|
}
|
|
|
|
.rating-star.selected {
|
|
color: #FFD700;
|
|
}
|
|
|
|
.rating-text {
|
|
font-size: 12px;
|
|
color: #666;
|
|
text-align: center;
|
|
}
|
|
|
|
.modal-footer {
|
|
flex-direction: row;
|
|
justify-content: space-between;
|
|
padding: 16px;
|
|
border-top: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.remove-btn, .cancel-btn, .save-btn {
|
|
padding: 10px 20px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
border: none;
|
|
}
|
|
|
|
.remove-btn {
|
|
background-color: #FF4757;
|
|
color: #FFFFFF;
|
|
}
|
|
|
|
.cancel-btn {
|
|
background-color: #f5f5f5;
|
|
color: #666;
|
|
}
|
|
|
|
.save-btn {
|
|
background-color: #4CAF50;
|
|
color: #FFFFFF;
|
|
}
|
|
|
|
.loading-container {
|
|
flex: 1;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.loading-text {
|
|
font-size: 16px;
|
|
color: #666;
|
|
}
|
|
</style>
|