运动打卡功能优化,不再上传照片

This commit is contained in:
amos wong
2026-01-17 18:21:40 +08:00
parent a5cc393add
commit 898aa65f44
9 changed files with 296 additions and 269 deletions

View File

@@ -13,11 +13,11 @@ android {
applicationId = "com.healthflow.app"
minSdk = 26
targetSdk = 35
versionCode = 114
versionName = "3.1.4"
versionCode = 116
versionName = "3.1.6"
buildConfigField("String", "API_BASE_URL", "\"https://health.amos.us.kg/api/\"")
buildConfigField("int", "VERSION_CODE", "114")
buildConfigField("int", "VERSION_CODE", "116")
}
signingConfigs {

View File

@@ -218,7 +218,9 @@ data class ExerciseCheckin(
val id: Long = 0,
@SerialName("user_id") val userId: Long = 0,
@SerialName("checkin_date") val checkinDate: String,
@SerialName("image_url") val imageUrl: String = "",
@SerialName("exercise_type") val exerciseType: String = "", // "aerobic" 或 "anaerobic"
@SerialName("body_part") val bodyPart: String = "", // 无氧运动部位: "leg", "chest", "back", "abs"
val duration: Int = 0, // 有氧运动时长(分钟)
val note: String = "",
@SerialName("created_at") val createdAt: String = ""
)
@@ -226,7 +228,9 @@ data class ExerciseCheckin(
@Serializable
data class CreateExerciseCheckinRequest(
@SerialName("checkin_date") val checkinDate: String,
@SerialName("image_url") val imageUrl: String = "",
@SerialName("exercise_type") val exerciseType: String,
@SerialName("body_part") val bodyPart: String = "",
val duration: Int = 0,
val note: String = ""
)

View File

@@ -26,9 +26,9 @@ import com.healthflow.app.ui.theme.*
import com.healthflow.app.ui.viewmodel.EpochViewModel
sealed class Tab(val route: String, val label: String) {
data object Exercise : Tab("tab_exercise", "运动")
data object Epoch : Tab("tab_epoch", "纪元")
data object Plan : Tab("tab_plan", "计划")
data object Exercise : Tab("tab_exercise", "运动")
data object Profile : Tab("tab_profile", "我的")
}
@@ -42,7 +42,7 @@ object Routes {
fun weekPlanDetail(epochId: Long, planId: Long) = "week_plan_detail/$epochId/$planId"
}
val tabs = listOf(Tab.Epoch, Tab.Plan, Tab.Exercise, Tab.Profile)
val tabs = listOf(Tab.Exercise, Tab.Epoch, Tab.Plan, Tab.Profile)
@Composable
fun MainNavigation(
@@ -76,11 +76,23 @@ fun MainNavigation(
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = Tab.Epoch.route,
startDestination = Tab.Exercise.route,
modifier = Modifier
.fillMaxSize()
.padding(bottom = if (showBottomNav) 56.dp else 0.dp)
) {
composable(Tab.Exercise.route) {
val exerciseViewModel: com.healthflow.app.ui.viewmodel.ExerciseViewModel = viewModel()
val exerciseStats by exerciseViewModel.stats.collectAsState()
// 当运动统计更新时,同步到 EpochViewModel
LaunchedEffect(exerciseStats.thisYearDays) {
epochViewModel.updateExerciseDays(exerciseStats.thisYearDays)
}
ExerciseScreen(viewModel = exerciseViewModel)
}
composable(Tab.Epoch.route) {
EpochScreen(
epochs = epochList,
@@ -154,18 +166,6 @@ fun MainNavigation(
)
}
composable(Tab.Exercise.route) {
val exerciseViewModel: com.healthflow.app.ui.viewmodel.ExerciseViewModel = viewModel()
val exerciseStats by exerciseViewModel.stats.collectAsState()
// 当运动统计更新时,同步到 EpochViewModel
LaunchedEffect(exerciseStats.thisYearDays) {
epochViewModel.updateExerciseDays(exerciseStats.thisYearDays)
}
ExerciseScreen(viewModel = exerciseViewModel)
}
composable(Routes.CREATE_EPOCH) {
CreateEpochScreen(
isLoading = isLoading,

View File

@@ -1,8 +1,5 @@
package com.healthflow.app.ui.screen
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -27,26 +24,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.healthflow.app.data.api.ApiClient
import com.healthflow.app.data.model.ExerciseCheckin
import com.healthflow.app.ui.theme.*
import com.healthflow.app.ui.viewmodel.ExerciseViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
@@ -245,8 +230,8 @@ fun ExerciseScreen(
CheckinDialog(
isLoading = isCheckinLoading,
onDismiss = { showCheckinDialog = false },
onCheckin = { imageUrl, note ->
viewModel.checkin(imageUrl, note) {
onCheckin = { exerciseType, bodyPart, duration, note ->
viewModel.checkin(exerciseType, bodyPart, duration, note) {
showCheckinDialog = false
}
}
@@ -468,6 +453,22 @@ private fun getHeatmapColor(level: Int): Color = when (level) {
private fun CheckinItem(checkin: ExerciseCheckin, onClick: () -> Unit) {
val date = try { LocalDate.parse(checkin.checkinDate.take(10)) } catch (e: Exception) { LocalDate.now() }
val dayOfWeek = date.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.CHINESE)
// 运动类型和详情
val typeText = when (checkin.exerciseType) {
"aerobic" -> "有氧 ${checkin.duration}分钟"
"anaerobic" -> {
val partText = when (checkin.bodyPart) {
"leg" -> ""
"chest" -> ""
"back" -> ""
"abs" -> ""
else -> ""
}
"无氧 $partText"
}
else -> "运动打卡"
}
Row(
modifier = Modifier.fillMaxWidth().clickable { onClick() }.padding(vertical = 8.dp),
@@ -482,21 +483,12 @@ private fun CheckinItem(checkin: ExerciseCheckin, onClick: () -> Unit) {
modifier = Modifier.weight(1f).clip(RoundedCornerShape(12.dp)).background(Slate50).padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (checkin.imageUrl.isNotEmpty()) {
AsyncImage(
model = checkin.imageUrl,
contentDescription = null,
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(12.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (checkin.note.isNotEmpty()) checkin.note else "运动打卡 ✓",
text = if (checkin.note.isNotEmpty()) checkin.note else typeText,
fontSize = 14.sp, color = Slate700, maxLines = 1, overflow = TextOverflow.Ellipsis
)
Text(text = "${date.monthValue}${date.dayOfMonth}", fontSize = 12.sp, color = Slate400)
Text(text = "${date.monthValue}${date.dayOfMonth} · $typeText", fontSize = 12.sp, color = Slate400)
}
Box(
modifier = Modifier.size(24.dp).clip(CircleShape).background(SuccessGreen),
@@ -517,7 +509,27 @@ private fun CheckinDetailDialog(
) {
val date = try { LocalDate.parse(checkin.checkinDate.take(10)) } catch (e: Exception) { LocalDate.now() }
var showDeleteConfirm by remember { mutableStateOf(false) }
var showFullscreenImage by remember { mutableStateOf(false) }
// 运动类型和详情
val typeText = when (checkin.exerciseType) {
"aerobic" -> "有氧运动"
"anaerobic" -> "无氧运动"
else -> "运动"
}
val detailText = when (checkin.exerciseType) {
"aerobic" -> "${checkin.duration} 分钟"
"anaerobic" -> {
when (checkin.bodyPart) {
"leg" -> "腿部训练"
"chest" -> "胸部训练"
"back" -> "背部训练"
"abs" -> "腹部训练"
else -> ""
}
}
else -> ""
}
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -551,50 +563,57 @@ private fun CheckinDetailDialog(
}
}
// Image - 点击可全屏查看
if (checkin.imageUrl.isNotEmpty()) {
AsyncImage(
model = checkin.imageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(280.dp)
.clip(RoundedCornerShape(16.dp))
.clickable { showFullscreenImage = true },
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(16.dp))
}
// Note - 完整显示
if (checkin.note.isNotEmpty()) {
Text(
text = checkin.note,
fontSize = 16.sp,
color = Slate700,
lineHeight = 24.sp
)
} else if (checkin.imageUrl.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().height(120.dp).clip(RoundedCornerShape(16.dp)).background(Slate50),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Check, contentDescription = null, tint = SuccessGreen, modifier = Modifier.size(40.dp))
Spacer(modifier = Modifier.height(8.dp))
Text(text = "已完成打卡", fontSize = 16.sp, color = Slate500, fontWeight = FontWeight.Medium)
// 运动信息卡片
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Slate50)
.padding(20.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier.size(48.dp).clip(CircleShape).background(Brand500),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(text = typeText, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Slate900)
if (detailText.isNotEmpty()) {
Text(text = detailText, fontSize = 14.sp, color = Slate500)
}
}
}
}
}
}
// 全屏图片查看
if (showFullscreenImage && checkin.imageUrl.isNotEmpty()) {
FullscreenImageDialog(
imageUrl = checkin.imageUrl,
onDismiss = { showFullscreenImage = false }
)
// 备注
if (checkin.note.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Slate50)
.padding(16.dp)
) {
Text(text = "备注", fontSize = 12.sp, color = Slate500, fontWeight = FontWeight.Medium)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = checkin.note,
fontSize = 15.sp,
color = Slate700,
lineHeight = 22.sp
)
}
}
}
}
// Delete confirmation
@@ -617,86 +636,18 @@ private fun CheckinDetailDialog(
}
}
@Composable
private fun FullscreenImageDialog(
imageUrl: String,
onDismiss: () -> Unit
) {
androidx.compose.ui.window.Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.clickable { onDismiss() },
contentAlignment = Alignment.Center
) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.Fit
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CheckinDialog(
isLoading: Boolean,
onDismiss: () -> Unit,
onCheckin: (imageUrl: String, note: String) -> Unit
onCheckin: (exerciseType: String, bodyPart: String, duration: Int, note: String) -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
var uploadedImageUrl by remember { mutableStateOf("") }
var exerciseType by remember { mutableStateOf("aerobic") } // "aerobic" 或 "anaerobic"
var bodyPart by remember { mutableStateOf("") }
var duration by remember { mutableStateOf("40") }
var note by remember { mutableStateOf("") }
var isUploading by remember { mutableStateOf(false) }
var uploadError by remember { mutableStateOf<String?>(null) }
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
uri?.let {
selectedImageUri = it
uploadedImageUrl = ""
uploadError = null
scope.launch {
isUploading = true
try {
val file = withContext(Dispatchers.IO) {
val inputStream = context.contentResolver.openInputStream(uri)
val tempFile = File.createTempFile("upload", ".jpg", context.cacheDir)
tempFile.outputStream().use { output -> inputStream?.copyTo(output) }
inputStream?.close()
tempFile
}
val requestBody = file.asRequestBody("image/*".toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
val response = ApiClient.api.upload(part)
if (response.isSuccessful) {
uploadedImageUrl = response.body()?.url ?: ""
if (uploadedImageUrl.isEmpty()) {
uploadError = "上传失败"
}
} else {
uploadError = "上传失败: ${response.code()}"
}
file.delete()
} catch (e: Exception) {
e.printStackTrace()
uploadError = "上传失败: ${e.message}"
}
isUploading = false
}
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -711,74 +662,63 @@ private fun CheckinDialog(
) {
Text(text = "运动打卡", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Slate900, modifier = Modifier.padding(bottom = 20.dp))
// Image upload
Box(
modifier = Modifier.fillMaxWidth().height(180.dp).clip(RoundedCornerShape(16.dp)).background(Slate50)
.border(2.dp, when {
uploadError != null -> ErrorRed
uploadedImageUrl.isNotEmpty() -> SuccessGreen
selectedImageUri != null -> Brand500
else -> Slate200
}, RoundedCornerShape(16.dp))
.clickable { imagePickerLauncher.launch("image/*") },
contentAlignment = Alignment.Center
// 运动类型选择
Text(text = "运动类型", fontSize = 14.sp, fontWeight = FontWeight.Medium, color = Slate700, modifier = Modifier.padding(bottom = 12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (selectedImageUri != null) {
AsyncImage(
model = selectedImageUri,
contentDescription = null,
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(16.dp)),
contentScale = ContentScale.Crop
)
if (isUploading) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = Color.White, modifier = Modifier.size(40.dp), strokeWidth = 3.dp)
Spacer(modifier = Modifier.height(8.dp))
Text("上传中...", color = Color.White, fontSize = 12.sp)
}
}
} else if (uploadedImageUrl.isNotEmpty()) {
// 上传成功标记
Box(
modifier = Modifier.align(Alignment.TopStart).padding(8.dp).clip(RoundedCornerShape(4.dp))
.background(SuccessGreen).padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text("已上传", color = Color.White, fontSize = 10.sp, fontWeight = FontWeight.Medium)
}
}
if (!isUploading) {
Box(
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).size(32.dp).clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)).clickable { imagePickerLauncher.launch("image/*") },
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Add, contentDescription = "更换", tint = Color.White, modifier = Modifier.size(18.dp))
}
}
} else {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.size(48.dp).clip(CircleShape).background(Slate100), contentAlignment = Alignment.Center) {
Icon(Icons.Default.Add, contentDescription = null, tint = Slate400, modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.height(8.dp))
Text(text = "上传运动照片", color = Slate500, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
}
// 上传错误提示
if (uploadError != null) {
Text(
text = uploadError!!,
color = ErrorRed,
fontSize = 12.sp,
modifier = Modifier.padding(top = 4.dp)
TypeButton(
text = "有氧运动",
selected = exerciseType == "aerobic",
onClick = { exerciseType = "aerobic"; bodyPart = "" },
modifier = Modifier.weight(1f)
)
TypeButton(
text = "无氧运动",
selected = exerciseType == "anaerobic",
onClick = { exerciseType = "anaerobic"; duration = "" },
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(20.dp))
// 有氧运动 - 时长输入
if (exerciseType == "aerobic") {
Text(text = "运动时长", fontSize = 14.sp, fontWeight = FontWeight.Medium, color = Slate700, modifier = Modifier.padding(bottom = 12.dp))
OutlinedTextField(
value = duration,
onValueChange = { if (it.all { char -> char.isDigit() }) duration = it },
placeholder = { Text("输入运动时长(分钟)", color = Slate400, fontSize = 15.sp) },
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Brand500, unfocusedBorderColor = Slate200,
focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent
),
shape = RoundedCornerShape(12.dp),
singleLine = true,
suffix = { Text("分钟", color = Slate500, fontSize = 14.sp) }
)
}
// 无氧运动 - 部位选择
if (exerciseType == "anaerobic") {
Text(text = "训练部位", fontSize = 14.sp, fontWeight = FontWeight.Medium, color = Slate700, modifier = Modifier.padding(bottom = 12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
BodyPartButton("", "leg", bodyPart) { bodyPart = it }
BodyPartButton("", "chest", bodyPart) { bodyPart = it }
BodyPartButton("", "back", bodyPart) { bodyPart = it }
BodyPartButton("", "abs", bodyPart) { bodyPart = it }
}
}
Spacer(modifier = Modifier.height(20.dp))
// 备注
OutlinedTextField(
value = note,
onValueChange = { note = it },
@@ -794,14 +734,24 @@ private fun CheckinDialog(
Spacer(modifier = Modifier.height(20.dp))
// 打卡按钮
val canSubmit = when (exerciseType) {
"aerobic" -> duration.isNotEmpty() && duration.toIntOrNull() != null && duration.toInt() > 0
"anaerobic" -> bodyPart.isNotEmpty()
else -> false
}
Button(
onClick = { onCheckin(uploadedImageUrl, note) },
enabled = !isLoading && !isUploading,
onClick = {
val durationInt = if (exerciseType == "aerobic") duration.toIntOrNull() ?: 0 else 0
onCheckin(exerciseType, bodyPart, durationInt, note)
},
enabled = !isLoading && canSubmit,
modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.buttonColors(containerColor = Slate900, disabledContainerColor = Slate300),
shape = RoundedCornerShape(12.dp)
) {
if (isLoading || isUploading) {
if (isLoading) {
CircularProgressIndicator(color = Color.White, modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Text(text = "完成打卡", fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
@@ -810,3 +760,43 @@ private fun CheckinDialog(
}
}
}
@Composable
private fun TypeButton(
text: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = onClick,
modifier = modifier.height(48.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (selected) Brand500 else Slate100,
contentColor = if (selected) Color.White else Slate600
),
shape = RoundedCornerShape(12.dp)
) {
Text(text = text, fontSize = 15.sp, fontWeight = FontWeight.Medium)
}
}
@Composable
private fun RowScope.BodyPartButton(
text: String,
value: String,
selected: String,
onSelect: (String) -> Unit
) {
Button(
onClick = { onSelect(value) },
modifier = Modifier.weight(1f).height(48.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (selected == value) Brand500 else Slate100,
contentColor = if (selected == value) Color.White else Slate600
),
shape = RoundedCornerShape(12.dp)
) {
Text(text = text, fontSize = 15.sp, fontWeight = FontWeight.Medium)
}
}

View File

@@ -96,7 +96,7 @@ class ExerciseViewModel : ViewModel() {
_todayCheckedIn.value = _heatmapData.value[today]?.let { it > 0 } ?: false
}
fun checkin(imageUrl: String, note: String, onSuccess: () -> Unit) {
fun checkin(exerciseType: String, bodyPart: String, duration: Int, note: String, onSuccess: () -> Unit) {
viewModelScope.launch {
_isCheckinLoading.value = true
_error.value = null
@@ -104,7 +104,9 @@ class ExerciseViewModel : ViewModel() {
val today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
val request = CreateExerciseCheckinRequest(
checkinDate = today,
imageUrl = imageUrl,
exerciseType = exerciseType,
bodyPart = bodyPart,
duration = duration,
note = note
)
val response = ApiClient.api.createExerciseCheckin(request)

View File

@@ -123,7 +123,9 @@ func migrate(db *sql.DB) error {
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
checkin_date DATE NOT NULL,
image_url TEXT DEFAULT '',
exercise_type TEXT NOT NULL,
body_part TEXT DEFAULT '',
duration INTEGER DEFAULT 0,
note TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
@@ -153,21 +155,22 @@ func migrate(db *sql.DB) error {
return nil
}
// migrateExerciseCheckins 移除 exercise_checkins 表的 UNIQUE 约束
// migrateExerciseCheckins 移除 exercise_checkins 表的 UNIQUE 约束并更新表结构
func migrateExerciseCheckins(db *sql.DB) {
// 检查表是否有 UNIQUE 约束
// 检查表结构
var tableSql string
err := db.QueryRow("SELECT sql FROM sqlite_master WHERE type='table' AND name='exercise_checkins'").Scan(&tableSql)
if err != nil {
return
}
// 如果表定义中包含 UNIQUE需要重建表
if !strings.Contains(tableSql, "UNIQUE") {
// 如果表定义中包含 image_url 或 UNIQUE需要重建表
needsMigration := strings.Contains(tableSql, "image_url") || strings.Contains(tableSql, "UNIQUE")
if !needsMigration {
return
}
// 重建表以移除 UNIQUE 约束
// 重建表以更新结构
tx, err := db.Begin()
if err != nil {
return
@@ -180,13 +183,15 @@ func migrateExerciseCheckins(db *sql.DB) {
return
}
// 2. 创建新表(无 UNIQUE 约束
// 2. 创建新表(新结构
_, err = tx.Exec(`
CREATE TABLE exercise_checkins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
checkin_date DATE NOT NULL,
image_url TEXT DEFAULT '',
exercise_type TEXT NOT NULL DEFAULT 'aerobic',
body_part TEXT DEFAULT '',
duration INTEGER DEFAULT 0,
note TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
@@ -196,10 +201,10 @@ func migrateExerciseCheckins(db *sql.DB) {
return
}
// 3. 复制数据
// 3. 复制数据(旧数据默认为有氧运动)
_, err = tx.Exec(`
INSERT INTO exercise_checkins (id, user_id, checkin_date, image_url, note, created_at)
SELECT id, user_id, checkin_date, image_url, note, created_at FROM exercise_checkins_old
INSERT INTO exercise_checkins (id, user_id, checkin_date, exercise_type, note, created_at)
SELECT id, user_id, checkin_date, 'aerobic', note, created_at FROM exercise_checkins_old
`)
if err != nil {
return

View File

@@ -414,20 +414,42 @@ func (h *EpochHandler) calculateEpochDetail(epoch *model.WeightEpoch) *model.Epo
detail.EpochTotalLoss = &epochLoss
}
// 计算本年度总减重(所有今年纪元的减重总和)
var yearLoss float64
h.db.QueryRow(`
SELECT COALESCE(SUM(e.initial_weight - COALESCE(
(SELECT wp.final_weight FROM weekly_plans wp
WHERE wp.epoch_id = e.id AND wp.final_weight IS NOT NULL
ORDER BY wp.year DESC, wp.week DESC LIMIT 1),
e.initial_weight
)), 0)
FROM weight_epochs e
WHERE e.user_id = ? AND strftime('%Y', e.start_date) = ?
`, epoch.UserID, strconv.Itoa(now.Year())).Scan(&yearLoss)
if yearLoss != 0 {
detail.YearTotalLoss = &yearLoss
// 计算本年度总减重
// 策略:找到年初的体重(今年第一周或去年最后一周)- 今年最新体重
var yearStartWeight, yearLatestWeight sql.NullFloat64
// 1. 先获取今年最新体重
err = h.db.QueryRow(`
SELECT final_weight
FROM weekly_plans
WHERE user_id = ? AND year = ? AND final_weight IS NOT NULL
ORDER BY week DESC LIMIT 1
`, epoch.UserID, now.Year()).Scan(&yearLatestWeight)
if err == nil && yearLatestWeight.Valid {
// 2. 获取年初体重:优先用今年第一周,如果没有则用去年最后一周
err = h.db.QueryRow(`
SELECT final_weight
FROM weekly_plans
WHERE user_id = ? AND year = ? AND final_weight IS NOT NULL
ORDER BY week ASC LIMIT 1
`, epoch.UserID, now.Year()).Scan(&yearStartWeight)
// 如果今年第一周没有,用去年最后一周
if err != nil || !yearStartWeight.Valid {
h.db.QueryRow(`
SELECT final_weight
FROM weekly_plans
WHERE user_id = ? AND year = ? AND final_weight IS NOT NULL
ORDER BY week DESC LIMIT 1
`, epoch.UserID, now.Year()-1).Scan(&yearStartWeight)
}
// 如果有年初体重,计算减重
if yearStartWeight.Valid && yearStartWeight.Float64 > 0 {
yearLoss := yearStartWeight.Float64 - yearLatestWeight.Float64
detail.YearTotalLoss = &yearLoss
}
}
// 计算累计总减重(所有纪元的减重总和)

View File

@@ -33,9 +33,9 @@ func (h *ExerciseHandler) CreateCheckin(c *gin.Context) {
// 每次都创建新记录,支持一天多次打卡
result, err := h.db.Exec(`
INSERT INTO exercise_checkins (user_id, checkin_date, image_url, note)
VALUES (?, ?, ?, ?)
`, userID, req.CheckinDate, req.ImageURL, req.Note)
INSERT INTO exercise_checkins (user_id, checkin_date, exercise_type, body_part, duration, note)
VALUES (?, ?, ?, ?, ?, ?)
`, userID, req.CheckinDate, req.ExerciseType, req.BodyPart, req.Duration, req.Note)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "打卡失败", "detail": err.Error()})
return
@@ -50,10 +50,10 @@ func (h *ExerciseHandler) GetCheckins(c *gin.Context) {
userID := c.GetInt64("user_id")
query := `
SELECT id, user_id, checkin_date, image_url, note, created_at
SELECT id, user_id, checkin_date, exercise_type, body_part, duration, note, created_at
FROM exercise_checkins
WHERE user_id = ?
ORDER BY checkin_date DESC
ORDER BY checkin_date DESC, created_at DESC
LIMIT 50
`
@@ -68,7 +68,7 @@ func (h *ExerciseHandler) GetCheckins(c *gin.Context) {
for rows.Next() {
var checkin model.ExerciseCheckin
if err := rows.Scan(&checkin.ID, &checkin.UserID, &checkin.CheckinDate,
&checkin.ImageURL, &checkin.Note, &checkin.CreatedAt); err != nil {
&checkin.ExerciseType, &checkin.BodyPart, &checkin.Duration, &checkin.Note, &checkin.CreatedAt); err != nil {
continue
}
checkins = append(checkins, checkin)

View File

@@ -179,18 +179,22 @@ type RecordWeightRequest struct {
// 运动打卡
type ExerciseCheckin struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
CheckinDate string `json:"checkin_date"`
ImageURL string `json:"image_url"`
Note string `json:"note"`
CreatedAt string `json:"created_at"`
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
CheckinDate string `json:"checkin_date"`
ExerciseType string `json:"exercise_type"` // "aerobic" 或 "anaerobic"
BodyPart string `json:"body_part"` // 无氧运动部位: "leg", "chest", "back", "abs"
Duration int `json:"duration"` // 有氧运动时长(分钟)
Note string `json:"note"`
CreatedAt string `json:"created_at"`
}
type CreateExerciseCheckinRequest struct {
CheckinDate string `json:"checkin_date" binding:"required"`
ImageURL string `json:"image_url"`
Note string `json:"note"`
CheckinDate string `json:"checkin_date" binding:"required"`
ExerciseType string `json:"exercise_type" binding:"required"`
BodyPart string `json:"body_part"`
Duration int `json:"duration"`
Note string `json:"note"`
}
type ExerciseHeatmapData struct {