Files
akmon/pages/sport/student/goal-settings.uvue
2026-01-20 08:04:15 +08:00

981 lines
23 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<scroll-view direction="vertical" class="goal-settings-page">
<!-- Header -->
<view class="header">
<view class="header-left">
<button @click="goBack" class="back-btn">
<simple-icon type="arrow-left" :size="16" />
<text>返回</text>
</button>
<text class="title">训练目标</text>
</view>
<view class="header-actions">
<button @click="addGoal" class="add-btn">
<simple-icon type="plus" :size="16" />
<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' }">
<!-- Current Goals -->
<view class="goals-section">
<view class="section-title">当前目标</view>
<view v-if="goals.length === 0" class="empty-state">
<simple-icon type="target" :size="48" color="#BDC3C7" />
<text class="empty-text">还没有设置训练目标</text>
<text class="empty-desc">设置目标让训练更有动力</text>
<button @click="addGoal" class="add-goal-btn">设置第一个目标</button>
</view>
<view v-else class="goals-list">
<view v-for="(goal,index) in goals" :key="goal.id" class="goal-item" @click="editGoal(goal)">
<view class="goal-header">
<view class="goal-icon">
<text class="goal-emoji">{{ goal["goal_type"] }}</text>
</view>
<view class="goal-info">
<text class="goal-name">{{ goal["goal_type"] }}</text>
<text class="goal-desc">{{ goal.getString("description") }}</text>
</view>
<view class="goal-status" :class="getGoalStatusClass(goal.getString('status')??'')">
<text class="status-text">{{ getGoalStatusText(goal.getString('status')??"") }}</text>
</view>
</view>
<view class="goal-progress">
<view class="progress-info">
<text class="progress-text">
{{ goal.current_value ?? 0 }} / {{ goal.target_value }} {{ goal.unit ?? '' }}
</text>
<text class="progress-percent">{{ getProgressPercent(goal) }}%</text>
</view>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: getProgressPercent(goal) + '%' }"></view>
</view>
</view>
<view class="goal-meta">
<text class="goal-date">目标日期: {{ getGoalTargetDate(goal) }}</text>
<text class="goal-priority">优先级: {{ getGoalPriorityText(goal) }}</text>
</view>
</view>
</view>
</view>
<!-- Goal Templates -->
<view class="templates-section">
<view class="section-title">目标模板</view>
<view class="templates-grid">
<view v-for="template in goalTemplates" :key="template.type"
class="template-item" @click="createFromTemplate(template)">
<view class="template-icon">
<text class="template-emoji">{{ template.icon }}</text>
</view>
<text class="template-name">{{ template.name }}</text>
<text class="template-desc">{{ template.description }}</text>
</view>
</view>
</view>
<!-- Statistics -->
<view class="stats-section">
<view class="section-title">目标统计</view>
<view class="stats-grid">
<view class="stat-item">
<text class="stat-number">{{ completedGoals }}</text>
<text class="stat-label">已完成</text>
</view>
<view class="stat-item">
<text class="stat-number">{{ activeGoals }}</text>
<text class="stat-label">进行中</text>
</view>
<view class="stat-item">
<text class="stat-number">{{ getAverageProgress() }}%</text>
<text class="stat-label">平均进度</text>
</view>
</view>
</view>
</scroll-view>
<!-- Add/Edit Goal Modal -->
<view v-if="showGoalModal" class="modal-overlay" @click="closeGoalModal">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ editingGoal != null ? '编辑目标' : '添加目标' }}</text>
<button @click="closeGoalModal" class="modal-close" type="button">
<simple-icon type="x" :size="20" color="#666" />
</button>
</view>
<view class="modal-body">
<view class="form-group">
<text class="form-label">目标类型</text>
<view @click="showGoalTypePicker = true" class="picker-input">
<text>{{ goalTypeOptions[goalTypeIndex] ?? '请选择目标类型' }}</text>
<simple-icon type="chevron-down" :size="16" />
</view>
<view v-if="showGoalTypePicker" class="picker-view-modal">
<picker-view :value="[goalTypeIndex]" :indicator-style="'height: 40px;'" @change="onGoalTypePickerChange">
<picker-view-column>
<view v-for="(item, idx) in goalTypeOptions" :key="idx" class="picker-view-item">{{ item }}</view>
</picker-view-column>
</picker-view>
<view class="picker-view-actions">
<button @click="showGoalTypePicker = false">取消</button>
<button @click="confirmGoalTypePicker">确定</button>
</view>
</view>
</view>
<view class="form-group">
<text class="form-label">目标数值</text>
<input :value="goalForm.target_value" type="number"
class="form-input" placeholder="请输入目标数值" />
</view>
<view class="form-group">
<text class="form-label">单位</text>
<input :value="goalForm.unit" type="text"
class="form-input" placeholder="如: kg, 次, 分钟" />
</view>
<view class="form-group">
<text class="form-label">目标日期</text>
<input :value="goalForm.target_date" type="date" class="form-input" placeholder="请选择目标日期" />
</view>
<view class="form-group">
<text class="form-label">优先级</text>
<view @click="showPriorityPicker = true" class="picker-input">
<text>{{ priorityOptions[priorityIndex] ?? '请选择优先级' }}</text>
<simple-icon type="chevron-down" :size="16" color="#999" />
</view>
<view v-if="showPriorityPicker" class="picker-view-modal">
<picker-view :value="[priorityIndex]" :indicator-style="'height: 40px;'" @change="onPriorityPickerChange">
<picker-view-column>
<view v-for="(item, idx) in priorityOptions" :key="idx" class="picker-view-item">{{ item }}</view>
</picker-view-column>
</picker-view>
<view class="picker-view-actions">
<button @click="showPriorityPicker = false">取消</button>
<button @click="confirmPriorityPicker">确定</button>
</view>
</view>
</view>
<view class="form-group">
<text class="form-label">描述</text>
<textarea :value="goalForm.description" class="form-textarea"
placeholder="描述你的目标..." maxlength="200"></textarea>
</view>
</view>
<view class="modal-footer">
<button @click="closeGoalModal" class="cancel-btn">取消</button>
<button @click="saveGoal" class="save-btn" :disabled="!isFormValid">保存</button>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import { onLoad,onResize } from '@dcloudio/uni-app'
import { formatDate } from '../types.uts'
import { getCurrentUserId } from '@/utils/store.uts'
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 goals = ref<Array<UTSJSONObject>>([])
const showGoalModal = ref(false)
const editingGoal = ref<UTSJSONObject | null>(null)
const contentHeight = ref(0)
// 表单数据
const goalForm = ref<UTSJSONObject>({
goal_type: '',
target_value: '',
unit: '',
target_date: '',
priority: 1,
description: ''
})
// 选择器数据
const goalTypeIndex = ref(0)
const priorityIndex = ref(0)
// Add missing picker-view state refs for Android compatibility
const tempGoalTypeIndex = ref(0)
const tempPriorityIndex = ref(0)
const showGoalTypePicker = ref(false)
const showPriorityPicker = ref(false)
const goalTypeOptions = ['减肥', '增肌', '耐力提升', '柔韧性', '力量增强', '技能提升']
const goalTypes = ['weight_loss', 'muscle_gain', 'endurance', 'flexibility', 'strength', 'skill']
const priorityOptions = ['低', '一般', '中等', '较高', '最高']
// 目标模板
const goalTemplates = ref<Array<UTSJSONObject>>([
{
type: 'weight_loss',
name: '减重目标',
description: '设定理想体重目标',
icon: '⚖️',
defaultValue: 5,
unit: 'kg'
},
{
type: 'muscle_gain',
name: '增肌目标',
description: '增加肌肉量',
icon: '',
defaultValue: 3,
unit: 'kg'
},
{
type: 'endurance',
name: '耐力提升',
description: '提高有氧耐力',
icon: '',
defaultValue: 30,
unit: '分钟'
},
{
type: 'strength',
name: '力量增强',
description: '提升最大力量',
icon: '',
defaultValue: 20,
unit: 'kg'
}
])
// 计算属性
const completedGoals = computed(() => {
return goals.value.filter(goal => goal.get('status') === 'completed').length
})
const activeGoals = computed(() => {
return goals.value.filter(goal => goal.get('status') === 'active').length
})
const isFormValid = computed(() => {
return (goalForm.value['goal_type'] as string) !== '' &&
(goalForm.value['target_value'] as string) !== '' &&
(goalForm.value['unit'] as string) !== '' &&
(goalForm.value['target_date'] as string) !== ''
})
// 计算内容高度
const calculateContentHeight = () => {
const systemInfo = uni.getSystemInfoSync()
const windowHeight = systemInfo.windowHeight
const headerHeight = 60
contentHeight.value = windowHeight - headerHeight
}
// 加载目标数据
const loadGoals = async () => {
try {
loading.value = true
if ((userId.value as string) === '') {
uni.showToast({
title: '请先登录',
icon: 'none'
})
return
}
const result = await supaClient
.from('ak_user_training_goals')
.select('*', {})
.eq('user_id', userId.value)
.order('created_at', { ascending: false })
.execute()
if (result.error == null && result.data != null) {
goals.value = result.data as UTSJSONObject[]
}
} catch (error) {
console.error('加载目标失败:', error)
uni.showToast({
title: '加载失败',
icon: 'none'
})
} finally {
loading.value = false
}
}
// 生命周期
onMounted(() => {
calculateContentHeight()
loadGoals()
})
onLoad((options: OnLoadOptions) => {
userId.value = options['id'] ?? getCurrentUserId()
loadGoals()
})
// 返回上一页
const goBack = () => {
uni.navigateBack()
}
// 重置表单
function resetForm() {
goalForm.value = {
goal_type: '',
target_value: '',
unit: '',
target_date: '',
priority: 1,
description: ''
}
goalTypeIndex.value = 0
priorityIndex.value = 0
}
// 填充表单
function populateForm(goal: UTSJSONObject) {
goalForm.value = {
goal_type: goal.get('goal_type') as string,
target_value: goal.get('target_value') as string,
unit: goal.get('unit') as string,
target_date: goal.get('target_date') as string,
priority: goal.get('priority') != null ? goal.get('priority') as number : 1,
description: goal.get('description') as string
}
const form = goalForm.value
const typeIndex = goalTypes.indexOf(form['goal_type'])
goalTypeIndex.value = typeIndex >= 0 ? typeIndex : 0
const priorityNum = parseInt(form['priority'] != null ? form['priority'].toString() : '1')
priorityIndex.value = isNaN(priorityNum) ? 0 : priorityNum - 1
}
// 添加目标
const addGoal = () => {
editingGoal.value = null
resetForm()
showGoalModal.value = true
}
// 编辑目标
const editGoal = (goal: UTSJSONObject) => {
editingGoal.value = goal
populateForm(goal)
showGoalModal.value = true
}
// 关闭模态框
const closeGoalModal = () => {
showGoalModal.value = false
editingGoal.value = null
}
// 保存目标
const saveGoal = async () => {
try {
const goalData = {
user_id: userId.value,
goal_type: goalTypes[goalTypeIndex.value],
target_value: parseFloat(goalForm.value['target_value'] != null ? goalForm.value['target_value'].toString() : '0'),
unit: goalForm.value['unit'],
target_date: goalForm.value['target_date'],
priority: priorityIndex.value + 1,
description: goalForm.value['description'],
status: 'active'
}
let result: any
const currentEditingGoal = editingGoal.value
if (currentEditingGoal != null) {
// 更新
result = await supaClient
.from('ak_user_training_goals')
.update(goalData)
.eq('id', (currentEditingGoal.get('id') ?? '').toString())
.execute()
} else {
// 创建
result = await supaClient
.from('ak_user_training_goals')
.insert(goalData)
.execute()
}
if (result.error != null) {
throw new Error(result.error?.message ?? '未知错误')
}
uni.showToast({
title: editingGoal.value != null ? '更新成功' : '创建成功',
icon: 'success'
})
closeGoalModal()
loadGoals()
} catch (error) {
console.error('保存目标失败:', error)
uni.showToast({
title: '保存失败',
icon: 'none'
})
}
}
// 从模板创建目标
const createFromTemplate = (template: UTSJSONObject) => {
editingGoal.value = null
resetForm()
// Use bracket notation for UTS compatibility
goalForm.value['goal_type'] = template.get('type') as string
goalForm.value['target_value'] = template.get('defaultValue') as string
goalForm.value['unit'] = template.get('unit') as string
const typeIndex = goalTypes.indexOf(goalForm.value['goal_type'])
goalTypeIndex.value = typeIndex >= 0 ? typeIndex : 0
// 设置默认目标日期为3个月后
const targetDate = new Date()
targetDate.setMonth(targetDate.getMonth() + 3)
goalForm.value['target_date'] = targetDate.toISOString().split('T')[0]
showGoalModal.value = true
}
// 事件处理
const onGoalTypePickerChange = (e: UniPickerViewChangeEvent) => {
tempGoalTypeIndex.value = e.detail.value[0]
}
const confirmGoalTypePicker = () => {
goalTypeIndex.value = tempGoalTypeIndex.value
goalForm.value['goal_type'] = goalTypes[goalTypeIndex.value]
showGoalTypePicker.value = false
}
const onPriorityPickerChange = (e: UniPickerViewChangeEvent) => {
tempPriorityIndex.value = e.detail.value[0]
}
const confirmPriorityPicker = () => {
priorityIndex.value = tempPriorityIndex.value
goalForm.value['priority'] = priorityIndex.value + 1
showPriorityPicker.value = false
}
// 工具函数
const getGoalIcon = (goalType: string): string => {
const icons = {
'weight_loss': '⚖️',
'muscle_gain': '',
'endurance': '',
'flexibility': '',
'strength': '',
'skill': ''
}
return icons[goalType] ?? ''
}
const getGoalTypeName = (goalType: string): string => {
const names = {
'weight_loss': '减肥目标',
'muscle_gain': '增肌目标',
'endurance': '耐力提升',
'flexibility': '柔韧性',
'strength': '力量增强',
'skill': '技能提升'
}
return names[goalType] ?? '未知目标'
}
const getGoalStatusText = (status: string): string => {
const statusTexts = {
'active': '进行中',
'paused': '已暂停',
'completed': '已完成',
'cancelled': '已取消'
}
const result =statusTexts[status]
return result!=null ? result.toString() :'未知'
}
const getGoalStatusClass = (status: string): string => {
return `status-${status}`
}
const getPriorityText = (priority: number): string => {
const priorities = ['低', '一般', '中等', '较高', '最高']
return priorities[priority - 1] ?? '一般'
}
const getProgressPercent = (goal: UTSJSONObject): number => {
const current = goal.getNumber('current_value') ?? 0
const target = goal.getNumber('target_value') ?? 1
return Math.min(Math.round((current / target) * 100), 100)
}
const getAverageProgress = (): number => {
if (goals.value.length === 0) return 0
const totalProgress = goals.value.reduce((sum, goal) => sum + getProgressPercent(goal), 0)
return Math.round(totalProgress / goals.value.length)
}
// Add a helper to safely get description as string
function getGoalDescription(desc: string): string {
if (desc == null) return '暂无描述'
if (typeof desc === 'string') {
const trimmed = desc.trim()
return trimmed !== '' ? trimmed : '暂无描述'
}
return '暂无描述'
}
// Helper to safely get priority text from goal object
function getGoalPriorityText(goal: UTSJSONObject): string {
const raw = goal.get('priority')
let num = 1
if (typeof raw === 'number') {
num = raw
} else if (typeof raw === 'string') {
const parsed = parseInt(raw)
num = isNaN(parsed) ? 1 : parsed
}
return getPriorityText(num)
}
// Helper to safely get formatted date from goal object
function getGoalTargetDate(goal: UTSJSONObject): string {
const raw = goal.get('target_date')
const dateStr = (raw != null) ? raw.toString() : ''
// UTS: formatDate is always a function, call directly
return formatDate(dateStr, 'YYYY-MM-DD')
}
// Lifecycle hooks
onMounted(() => {
screenWidth.value = uni.getSystemInfoSync().windowWidth
userId.value = getCurrentUserId()
loadGoals()
})
onResize((size) => {
screenWidth.value = size.size.windowWidth
})
</script>
<style scoped>
.goal-settings-page {
flex: 1;
background-color: #f5f5f5;
}
.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, .add-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, .add-btn text {
color: #FFFFFF;
font-size: 14px;
}
.title {
font-size: 18px;
font-weight: bold;
color: #FFFFFF;
}
.content {
flex: 1;
padding: 16px;
}
.section-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 12px;
}
.goals-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;
margin-bottom: 20px;
}
.add-goal-btn {
background-color: #4CAF50;
color: #FFFFFF;
border: none;
border-radius: 20px;
padding: 12px 24px;
font-size: 14px;
}
.goals-list {
gap: 12px;
}
.goal-item {
background-color: #FFFFFF;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
}
.goal-header {
flex-direction: row;
align-items: center;
margin-bottom: 12px;
}
.goal-icon {
width: 40px;
height: 40px;
background-color: #f0f0f0;
border-radius: 20px;
justify-content: center;
align-items: center;
margin-right: 12px;
}
.goal-emoji {
font-size: 20px;
}
.goal-info {
flex: 1;
}
.goal-name {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.goal-desc {
font-size: 12px;
color: #666;
}
.goal-status {
padding: 4px 8px;
border-radius: 12px;
align-items: center;
}
.status-active {
background-color: #E8F5E8;
}
.status-completed {
background-color: #E3F2FD;
}
.status-paused {
background-color: #FFF3E0;
}
.status-cancelled {
background-color: #FFEBEE;
}
.status-text {
font-size: 12px;
color: #333;
}
.goal-progress {
margin-bottom: 12px;
}
.progress-info {
flex-direction: row;
justify-content: space-between;
margin-bottom: 8px;
}
.progress-text {
font-size: 14px;
color: #666;
}
.progress-percent {
font-size: 14px;
font-weight: bold;
color: #4CAF50;
}
.progress-bar {
height: 8px;
background-color: #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: #4CAF50;
border-radius: 4px;
}
.goal-meta {
flex-direction: row;
justify-content: space-between;
}
.goal-date, .goal-priority {
font-size: 12px;
color: #999;
}
.templates-section {
margin-bottom: 24px;
}
.templates-grid {
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
}
.template-item {
background-color: #FFFFFF;
border-radius: 12px;
padding: 16px;
width: calc(50% - 6px);
align-items: center;
text-align: center;
}
.template-icon {
width: 40px;
height: 40px;
justify-content: center;
align-items: center;
margin-bottom: 8px;
}
.template-emoji {
font-size: 24px;
}
.template-name {
font-size: 14px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.template-desc {
font-size: 12px;
color: #666;
}
.stats-section {
margin-bottom: 24px;
}
.stats-grid {
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;
}
/* 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: 80%;
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;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
font-size: 14px;
color: #333;
margin-bottom: 8px;
display: block;
}
.form-input, .form-textarea {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
}
.form-textarea {
height: 80px;
text-align: start;
}
.picker-input {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #FFFFFF;
}
.modal-footer {
flex-direction: row;
justify-content: flex-end;
gap: 12px;
padding: 16px;
border-top: 1px solid #f0f0f0;
}
.cancel-btn, .save-btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
border: none;
}
.cancel-btn {
background-color: #f5f5f5;
color: #666;
}
.save-btn:disabled {
background-color: #cccccc;
color: #999999;
}
.loading-container {
flex: 1;
justify-content: center;
align-items: center;
}
.loading-text {
font-size: 16px;
color: #666;
}
</style>