|
|
@@ -0,0 +1,370 @@
|
|
|
+<template>
|
|
|
+ <view class="page">
|
|
|
+ <view class="nav-bar">
|
|
|
+ <view class="nav-back" @tap="goBack">
|
|
|
+ <text class="back-text">‹ 返回</text>
|
|
|
+ </view>
|
|
|
+ <text class="nav-title">运动打卡</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <scroll-view class="content" scroll-y>
|
|
|
+ <!-- 运动表单 -->
|
|
|
+ <view class="card">
|
|
|
+ <view class="card-accent"></view>
|
|
|
+ <view class="card-body">
|
|
|
+ <text class="card-title">记录运动</text>
|
|
|
+ <view class="form-group">
|
|
|
+ <text class="form-label">运动类型 *</text>
|
|
|
+ <view class="tag-row">
|
|
|
+ <view class="tag-btn" v-for="t in exerciseTypes" :key="t.value"
|
|
|
+ :class="form.exerciseType === t.value ? 'tag-active' : ''"
|
|
|
+ @tap="form.exerciseType = t.value">
|
|
|
+ <text>{{ t.label }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="form-group">
|
|
|
+ <text class="form-label">时长(分钟)*</text>
|
|
|
+ <input class="form-input" type="number" v-model="form.durationMinutes" placeholder="运动了多久?" />
|
|
|
+ </view>
|
|
|
+ <view class="form-group">
|
|
|
+ <text class="form-label">强度</text>
|
|
|
+ <view class="tag-row">
|
|
|
+ <view class="tag-btn" v-for="s in intensityOptions" :key="s.value"
|
|
|
+ :class="form.intensity === s.value ? 'tag-active' : ''"
|
|
|
+ @tap="form.intensity = s.value">
|
|
|
+ <text>{{ s.label }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="form-group">
|
|
|
+ <text class="form-label">消耗卡路里(选填)</text>
|
|
|
+ <input class="form-input" type="number" v-model="form.caloriesBurned" placeholder="估算卡路里" />
|
|
|
+ </view>
|
|
|
+ <view class="form-group">
|
|
|
+ <text class="form-label">备注(选填)</text>
|
|
|
+ <input class="form-input" v-model="form.remark" placeholder="运动感受..." />
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 照片上传 -->
|
|
|
+ <view class="card">
|
|
|
+ <view class="card-accent"></view>
|
|
|
+ <view class="card-body">
|
|
|
+ <text class="card-title">📷 拍照记录</text>
|
|
|
+ <view class="photo-row">
|
|
|
+ <view class="photo-item" v-for="(img, idx) in form.photos" :key="'p' + idx" @tap="removePhoto(idx)">
|
|
|
+ <image :src="img" mode="aspectFill" class="photo-preview" />
|
|
|
+ <text class="photo-del">×</text>
|
|
|
+ </view>
|
|
|
+ <view class="photo-add" @tap="choosePhoto">
|
|
|
+ <text class="photo-add-icon">+</text>
|
|
|
+ <text class="photo-add-text">拍照</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 语音录入 -->
|
|
|
+ <view class="card">
|
|
|
+ <view class="card-accent"></view>
|
|
|
+ <view class="card-body">
|
|
|
+ <text class="card-title">🎙️ 语音打卡</text>
|
|
|
+ <view class="voice-row">
|
|
|
+ <view class="voice-wave" v-if="isRecording">
|
|
|
+ <text class="wave-bar active">◉</text>
|
|
|
+ <text class="wave-bar">○</text>
|
|
|
+ <text class="wave-bar">○</text>
|
|
|
+ <text class="wave-text">录音中 {{ recordSeconds }}s</text>
|
|
|
+ </view>
|
|
|
+ <text class="voice-duration" v-else-if="form.voicePath">{{ recordDurationText }}</text>
|
|
|
+ <view class="voice-btn" :class="{ 'voice-btn-recording': isRecording }" @tap="toggleRecord">
|
|
|
+ <text class="voice-btn-icon">{{ isRecording ? '⏹' : '🎙' }}</text>
|
|
|
+ <text class="voice-btn-text">{{ isRecording ? '结束' : '按住录音' }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <text class="voice-hint">长按录音,松开结束</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 提交按钮 -->
|
|
|
+ <view class="submit-wrap">
|
|
|
+ <view class="submit-btn" :class="{ 'submitting': submitting }" @tap="doCheckin">
|
|
|
+ <text>{{ submitting ? '提交中...' : '✓ 完成打卡' }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 历史记录 -->
|
|
|
+ <view class="card" v-if="history.length > 0">
|
|
|
+ <view class="card-accent"></view>
|
|
|
+ <view class="card-body">
|
|
|
+ <text class="card-title">近期记录</text>
|
|
|
+ <view class="history-list">
|
|
|
+ <view class="history-item" v-for="item in history" :key="item.id">
|
|
|
+ <view class="history-left">
|
|
|
+ <text class="history-type">{{ exerciseTypeLabel(item.exerciseType) }}</text>
|
|
|
+ <text class="history-content">{{ item.durationMinutes }}分钟{{ item.caloriesBurned ? ' · ' + item.caloriesBurned + '千卡' : '' }}</text>
|
|
|
+ </view>
|
|
|
+ <text class="history-date">{{ formatDate(item.createdAt) }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </scroll-view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import config from '../../config'
|
|
|
+import { parseDate } from '../../utils/format.js'
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ memberId: null,
|
|
|
+ submitting: false,
|
|
|
+ isRecording: false,
|
|
|
+ recordSeconds: 0,
|
|
|
+ recordTimer: null,
|
|
|
+ recorderManager: null,
|
|
|
+ history: [],
|
|
|
+ exerciseTypes: [
|
|
|
+ { label: '跑步', value: 'running' },
|
|
|
+ { label: '游泳', value: 'swimming' },
|
|
|
+ { label: '跳绳', value: 'jump_rope' },
|
|
|
+ { label: '球类', value: 'ball' },
|
|
|
+ { label: '骑行', value: 'cycling' },
|
|
|
+ { label: '体操', value: 'gymnastics' },
|
|
|
+ { label: '其他', value: 'other' }
|
|
|
+ ],
|
|
|
+ intensityOptions: [
|
|
|
+ { label: '低', value: 'low' },
|
|
|
+ { label: '中', value: 'medium' },
|
|
|
+ { label: '高', value: 'high' }
|
|
|
+ ],
|
|
|
+ form: {
|
|
|
+ exerciseType: '',
|
|
|
+ durationMinutes: '',
|
|
|
+ intensity: 'medium',
|
|
|
+ caloriesBurned: '',
|
|
|
+ remark: '',
|
|
|
+ photos: [],
|
|
|
+ voicePath: ''
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ recordDurationText: function() {
|
|
|
+ if (!this.form.voicePath) return ''
|
|
|
+ return '录音 ' + this.recordSeconds + '秒'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLoad: function() {
|
|
|
+ var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
|
|
|
+ if (role !== 'child') {
|
|
|
+ this.memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || null
|
|
|
+ }
|
|
|
+ this.recorderManager = uni.getRecorderManager()
|
|
|
+ this.recorderManager.onStart(() => {
|
|
|
+ this.isRecording = true
|
|
|
+ this.recordSeconds = 0
|
|
|
+ this.recordTimer = setInterval(() => { this.recordSeconds++ }, 1000)
|
|
|
+ })
|
|
|
+ this.recorderManager.onStop((res) => {
|
|
|
+ this.isRecording = false
|
|
|
+ if (this.recordTimer) { clearInterval(this.recordTimer); this.recordTimer = null }
|
|
|
+ this.form.voicePath = res.tempFilePath
|
|
|
+ this.form.voiceDuration = res.duration
|
|
|
+ })
|
|
|
+ this.recorderManager.onError(() => {
|
|
|
+ this.isRecording = false
|
|
|
+ if (this.recordTimer) { clearInterval(this.recordTimer); this.recordTimer = null }
|
|
|
+ uni.showToast({ title: '录音失败', icon: 'none' })
|
|
|
+ })
|
|
|
+ this.loadHistory()
|
|
|
+ },
|
|
|
+ onUnload: function() {
|
|
|
+ if (this.recordTimer) clearInterval(this.recordTimer)
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ goBack: function() { uni.navigateBack() },
|
|
|
+ exerciseTypeLabel: function(type) {
|
|
|
+ var m = { running: '跑步', swimming: '游泳', jump_rope: '跳绳', ball: '球类', cycling: '骑行', gymnastics: '体操', other: '其他' }
|
|
|
+ return m[type] || type || '运动'
|
|
|
+ },
|
|
|
+ formatDate: function(dateStr) {
|
|
|
+ if (!dateStr) return ''
|
|
|
+ var d = parseDate(dateStr)
|
|
|
+ if (!d) return dateStr
|
|
|
+ var m2 = (d.getMonth() + 1).toString().padStart(2, '0')
|
|
|
+ var day = d.getDate().toString().padStart(2, '0')
|
|
|
+ return m2 + '-' + day
|
|
|
+ },
|
|
|
+ loadHistory: function() {
|
|
|
+ var self = this
|
|
|
+ var token = uni.getStorageSync('token')
|
|
|
+ if (!token || !self.memberId) return
|
|
|
+ uni.request({
|
|
|
+ url: config.API_BASE_URL + '/api/health/exercise/list',
|
|
|
+ method: 'POST',
|
|
|
+ data: { memberId: self.memberId },
|
|
|
+ header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
|
|
+ success: function(res) {
|
|
|
+ if (res.data && res.data.code === 200 && res.data.data) {
|
|
|
+ self.history = res.data.data.slice(0, 5)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+ choosePhoto: function() {
|
|
|
+ var self = this
|
|
|
+ uni.chooseImage({
|
|
|
+ count: 9 - self.form.photos.length,
|
|
|
+ sizeType: ['compressed'],
|
|
|
+ sourceType: ['album', 'camera'],
|
|
|
+ success: function(res) {
|
|
|
+ res.tempFilePaths.forEach(function(path) {
|
|
|
+ if (self.form.photos.length < 9) self.form.photos.push(path)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+ removePhoto: function(idx) {
|
|
|
+ this.form.photos.splice(idx, 1)
|
|
|
+ },
|
|
|
+ toggleRecord: function() {
|
|
|
+ if (this.isRecording) {
|
|
|
+ this.recorderManager.stop()
|
|
|
+ } else {
|
|
|
+ this.recorderManager.start({ duration: 60000, format: 'mp3' })
|
|
|
+ }
|
|
|
+ },
|
|
|
+ uploadFile: function(filePath) {
|
|
|
+ var self = this
|
|
|
+ return new Promise(function(resolve, reject) {
|
|
|
+ uni.uploadFile({
|
|
|
+ url: config.API_BASE_URL + '/api/media/upload',
|
|
|
+ filePath: filePath,
|
|
|
+ name: 'file',
|
|
|
+ header: { 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
|
|
|
+ success: function(res) {
|
|
|
+ try { var data = JSON.parse(res.data); resolve(data.data || data.url || '') }
|
|
|
+ catch (e) { resolve(res.data || '') }
|
|
|
+ },
|
|
|
+ fail: function() { reject() }
|
|
|
+ })
|
|
|
+ })
|
|
|
+ },
|
|
|
+ async doCheckin() {
|
|
|
+ var self = this
|
|
|
+ if (!self.form.exerciseType) { uni.showToast({ title: '请选择运动类型', icon: 'none' }); return }
|
|
|
+ if (!self.form.durationMinutes) { uni.showToast({ title: '请填写运动时长', icon: 'none' }); return }
|
|
|
+ if (!self.memberId) { uni.showToast({ title: '孩子ID无效', icon: 'none' }); return }
|
|
|
+ self.submitting = true
|
|
|
+ try {
|
|
|
+ var photoUrls = []
|
|
|
+ if (self.form.photos && self.form.photos.length > 0) {
|
|
|
+ for (var i = 0; i < self.form.photos.length; i++) {
|
|
|
+ try { var url = await self.uploadFile(self.form.photos[i]) } catch(e) { url = '' }
|
|
|
+ if (url) photoUrls.push(url)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ var voiceUrl = ''
|
|
|
+ if (self.form.voicePath) {
|
|
|
+ try { voiceUrl = await self.uploadFile(self.form.voicePath) } catch(e) { voiceUrl = '' }
|
|
|
+ }
|
|
|
+ var token = uni.getStorageSync('token')
|
|
|
+ uni.request({
|
|
|
+ url: config.API_BASE_URL + '/api/health/exercise/create',
|
|
|
+ method: 'POST',
|
|
|
+ data: {
|
|
|
+ memberId: self.memberId,
|
|
|
+ exerciseType: self.form.exerciseType,
|
|
|
+ durationMinutes: parseInt(self.form.durationMinutes),
|
|
|
+ intensity: self.form.intensity,
|
|
|
+ caloriesBurned: self.form.caloriesBurned ? parseInt(self.form.caloriesBurned) : null,
|
|
|
+ remark: self.form.remark || '',
|
|
|
+ photoUrls: photoUrls.join(','),
|
|
|
+ voiceUrl: voiceUrl
|
|
|
+ },
|
|
|
+ header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
|
|
+ success: function(res) {
|
|
|
+ self.submitting = false
|
|
|
+ if (res.data && res.data.code === 200) {
|
|
|
+ uni.showToast({ title: '打卡成功', icon: 'success' })
|
|
|
+ self.form.exerciseType = ''
|
|
|
+ self.form.durationMinutes = ''
|
|
|
+ self.form.intensity = 'medium'
|
|
|
+ self.form.caloriesBurned = ''
|
|
|
+ self.form.remark = ''
|
|
|
+ self.form.photos = []
|
|
|
+ self.form.voicePath = ''
|
|
|
+ self.recordSeconds = 0
|
|
|
+ self.loadHistory()
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: (res.data && res.data.message) || '打卡失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ },
|
|
|
+ fail: function() {
|
|
|
+ self.submitting = false
|
|
|
+ uni.showToast({ title: '网络错误', icon: 'none' })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } catch (e) {
|
|
|
+ self.submitting = false
|
|
|
+ uni.showToast({ title: '提交失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.page { min-height: 100vh; background: #FFF7ED; }
|
|
|
+.nav-bar { display: flex; align-items: center; height: 88rpx; padding-top: env(safe-area-inset-top); background: #fff; border-bottom: 1rpx solid #f0f0f0; position: relative; }
|
|
|
+.nav-back { position: absolute; left: 24rpx; top: 0; bottom: 0; display: flex; align-items: center; }
|
|
|
+.back-text { font-size: 28rpx; color: #F97316; }
|
|
|
+.nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: bold; color: #333; }
|
|
|
+.content { height: calc(100vh - 88rpx); }
|
|
|
+.card { margin: 16rpx 24rpx; background: #fff; border-radius: 20rpx; overflow: hidden; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
|
|
|
+.card-accent { height: 6rpx; background: linear-gradient(90deg, #FF8C42, #F97316); }
|
|
|
+.card-body { padding: 24rpx; }
|
|
|
+.card-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; display: block; }
|
|
|
+.form-group { margin-bottom: 20rpx; }
|
|
|
+.form-label { font-size: 26rpx; color: #333; font-weight: 500; margin-bottom: 12rpx; display: block; }
|
|
|
+.tag-row { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
|
|
+.tag-btn { padding: 10rpx 24rpx; border-radius: 30rpx; border: 2rpx solid #e0e0e0; background: #fafafa; }
|
|
|
+.tag-active { border-color: #F97316; background: #FFF3E0; }
|
|
|
+.tag-btn text { font-size: 24rpx; color: #666; }
|
|
|
+.tag-active text { font-size: 24rpx; color: #F97316; font-weight: bold; }
|
|
|
+.form-input { width: 100%; border: 1rpx solid #e0e0e0; border-radius: 12rpx; padding: 16rpx; font-size: 26rpx; background: #FAFAFA; box-sizing: border-box; }
|
|
|
+.photo-row { display: flex; flex-wrap: wrap; gap: 16rpx; }
|
|
|
+.photo-item { position: relative; width: 160rpx; height: 160rpx; }
|
|
|
+.photo-preview { width: 100%; height: 100%; border-radius: 12rpx; object-fit: cover; }
|
|
|
+.photo-del { position: absolute; top: -8rpx; right: -8rpx; width: 36rpx; height: 36rpx; background: rgba(0,0,0,0.5); color: #fff; border-radius: 50%; font-size: 24rpx; text-align: center; line-height: 36rpx; }
|
|
|
+.photo-add { width: 160rpx; height: 160rpx; border: 2rpx dashed #ccc; border-radius: 12rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
|
|
+.photo-add-icon { font-size: 48rpx; color: #ccc; }
|
|
|
+.photo-add-text { font-size: 22rpx; color: #999; margin-top: 8rpx; }
|
|
|
+.voice-row { display: flex; flex-direction: column; align-items: center; gap: 12rpx; padding: 16rpx 0; }
|
|
|
+.voice-wave { display: flex; align-items: center; gap: 8rpx; font-size: 24rpx; color: #F97316; }
|
|
|
+.wave-bar { font-size: 28rpx; color: #ccc; }
|
|
|
+.wave-bar.active { animation: wave 0.6s infinite; }
|
|
|
+@keyframes wave { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
|
|
|
+.voice-duration { font-size: 24rpx; color: #666; }
|
|
|
+.voice-btn { display: flex; flex-direction: column; align-items: center; gap: 8rpx; padding: 20rpx 48rpx; border-radius: 40rpx; background: #FFF3E0; border: 2rpx solid #FFB74D; }
|
|
|
+.voice-btn-recording { background: #FEE2E2; border-color: #EF4444; }
|
|
|
+.voice-btn-icon { font-size: 40rpx; }
|
|
|
+.voice-btn-text { font-size: 24rpx; color: #F97316; }
|
|
|
+.voice-btn-recording .voice-btn-text { color: #EF4444; }
|
|
|
+.voice-hint { font-size: 22rpx; color: #999; text-align: center; }
|
|
|
+.submit-wrap { padding: 24rpx 24rpx 40rpx; }
|
|
|
+.submit-btn { background: linear-gradient(135deg, #FF8C42, #F97316); color: #fff; text-align: center; padding: 24rpx 0; border-radius: 16rpx; font-size: 30rpx; font-weight: bold; }
|
|
|
+.submitting { opacity: 0.6; }
|
|
|
+.history-list { display: flex; flex-direction: column; gap: 16rpx; }
|
|
|
+.history-item { display: flex; justify-content: space-between; align-items: center; padding: 12rpx 0; border-bottom: 1rpx solid #f5f5f5; }
|
|
|
+.history-left { flex: 1; min-width: 0; }
|
|
|
+.history-type { font-size: 26rpx; font-weight: 500; color: #333; display: block; }
|
|
|
+.history-content { font-size: 22rpx; color: #888; display: block; margin-top: 4rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
|
+.history-date { font-size: 22rpx; color: #999; flex-shrink: 0; margin-left: 16rpx; }
|
|
|
+</style>
|