From 4066bd31fae77f945e6b079ddbcd0a334939ad98 Mon Sep 17 00:00:00 2001 From: nepiedg Date: Sat, 4 Apr 2026 14:22:22 +0800 Subject: [PATCH] feat: enhance user profile and achievement features - Add functionality for users to update their profile picture and nickname - Implement achievement theme selection in onboarding - Update API integration for profile updates and achievement themes - Refine UI elements for better user interaction and experience --- src/api/auth.js | 56 +++ src/api/request.js | 4 +- src/api/smoke.js | 39 ++ src/config/index.js | 3 +- src/pages.json | 6 + src/pages/dream-goals/index.vue | 737 +++++++++++++++++++++++++++++ src/pages/index/index.vue | 611 +++++++++++++++++++----- src/pages/logs/index.vue | 173 +++---- src/pages/onboarding/index.vue | 262 +++++++--- src/pages/profile/index.vue | 106 ++++- src/pages/stats-calendar/index.vue | 204 +++----- src/stores/user.js | 5 + vite.config.js | 3 +- 13 files changed, 1773 insertions(+), 436 deletions(-) create mode 100644 src/pages/dream-goals/index.vue diff --git a/src/api/auth.js b/src/api/auth.js index 325fa99..e4849d9 100644 --- a/src/api/auth.js +++ b/src/api/auth.js @@ -62,3 +62,59 @@ export function logout() { storage.remove(SESSION_KEY) storage.remove(USER_KEY) } + +export async function updateProfile(data) { + const res = await request.put('/auth/profile', data) + const user = storage.get(USER_KEY) + if (user && res.data) { + if (res.data.nickname) user.nickname = res.data.nickname + if (res.data.avatar_url) user.avatar_url = res.data.avatar_url + storage.set(USER_KEY, user) + } + return res +} + +export function getUploadToken(filename) { + return request.post('/common/upload/oss/token', { filename }) +} + +export async function uploadFile(filePath) { + const ext = filePath.split('.').pop() || 'jpg' + const tokenRes = await getUploadToken(`avatar.${ext}`) + const data = tokenRes.data + + let uploadUrl = (data.upload_url || '').replace(/\/$/, '') + if (!uploadUrl || uploadUrl.indexOf('aliyuncs.com') === -1) { + uploadUrl = (data.cdn_domain || '').replace(/\/$/, '') + if (uploadUrl && uploadUrl.indexOf('http') !== 0) { + uploadUrl = 'https://' + uploadUrl + } + } + + return new Promise((resolve, reject) => { + uni.uploadFile({ + url: uploadUrl, + filePath, + name: 'file', + formData: { + key: data.key, + OSSAccessKeyId: data.oss_access_key_id, + policy: data.oss_policy, + Signature: data.oss_signature + }, + success: (res) => { + const code = res.statusCode || 0 + if (code === 200 || code === 204 || (code >= 200 && code < 300)) { + const cdnDomain = (data.cdn_domain || '').replace(/\/$/, '') + const fileUrl = cdnDomain.startsWith('http') + ? `${cdnDomain}/${data.key}` + : `https://${cdnDomain}/${data.key}` + resolve({ url: fileUrl, key: data.key }) + } else { + reject(new Error('上传失败: ' + (res.data || code))) + } + }, + fail: (err) => reject(new Error(err.errMsg || '上传失败')) + }) + }) +} diff --git a/src/api/request.js b/src/api/request.js index 23898ca..7d46a21 100644 --- a/src/api/request.js +++ b/src/api/request.js @@ -13,12 +13,12 @@ function isInvalidToken(res) { export const request = { async request(options) { const sessionKey = storage.get(SESSION_KEY) - // 测试 const isRetryAfter401 = options._retryAfter401 === true + const baseUrl = options.baseUrl || BASE_URL return new Promise((resolve, reject) => { uni.request({ - url: BASE_URL + options.url, + url: baseUrl + options.url, method: options.method || 'GET', data: options.data, header: { diff --git a/src/api/smoke.js b/src/api/smoke.js index 62ab0bc..2bd63b8 100644 --- a/src/api/smoke.js +++ b/src/api/smoke.js @@ -1,4 +1,7 @@ import { request } from './request' +import { BASE_URL } from '@/config' + +const BASE_URL_V2 = BASE_URL.replace('/v1', '/v2') export function getDashboard(params = {}) { return request.get('/smoke/dashboard', params) @@ -88,3 +91,39 @@ export function getQuitPlanDays(planId) { export function resetQuitPlan() { return request.post('/smoke/quit-plan/reset') } + +// 成就系统 API +export function getAchievementThemes() { + return request.get('/smoke/achievement/themes') +} + +export function getAchievement() { + return request.get('/smoke/achievement') +} + +// V2 戒烟打卡 API +export function getQuitCheckinHome() { + return request.request({ url: '/checkin/home', method: 'GET', baseUrl: BASE_URL_V2 }) +} + +export function quitCheckin(data = {}) { + return request.request({ url: '/checkin/check', method: 'POST', data, baseUrl: BASE_URL_V2 }) +} + +export function upsertQuitCheckinProfile(data) { + return request.request({ url: '/profile', method: 'POST', data, baseUrl: BASE_URL_V2 }) +} + +// 梦想目标 API +export function listRewardGoals(status = 'all') { + return request.request({ url: '/reward-goals', method: 'GET', data: { status }, baseUrl: BASE_URL_V2 }) +} + +export function createRewardGoal(data) { + return request.request({ url: '/reward-goals', method: 'POST', data, baseUrl: BASE_URL_V2 }) +} + +export function updateRewardGoal(id, data) { + return request.request({ url: `/reward-goals/${id}`, method: 'PUT', data, baseUrl: BASE_URL_V2 }) +} + diff --git a/src/config/index.js b/src/config/index.js index 406c6f1..451f3b3 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -4,7 +4,8 @@ const ENV = { MINI_PROGRAM_ID: 2 }, production: { - BASE_URL: 'https://wx.nepiedg.top/api/v1', + // BASE_URL: 'https://wx.nepiedg.top/api/v1', + BASE_URL: 'http://192.168.31.73:8080/api/v1', MINI_PROGRAM_ID: 2 } } diff --git a/src/pages.json b/src/pages.json index f2a4d62..ed28603 100644 --- a/src/pages.json +++ b/src/pages.json @@ -71,6 +71,12 @@ "style": { "navigationStyle": "custom" } + }, + { + "path": "pages/dream-goals/index", + "style": { + "navigationBarTitleText": "梦想清单" + } } ], "globalStyle": { diff --git a/src/pages/dream-goals/index.vue b/src/pages/dream-goals/index.vue new file mode 100644 index 0000000..f2f5a84 --- /dev/null +++ b/src/pages/dream-goals/index.vue @@ -0,0 +1,737 @@ + + + + + diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 719005c..3d687d2 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -21,8 +21,7 @@ 无烟旅程 - 今天也在慢慢变好 - {{ quitEncouragement }} + {{ quitEncouragement }} {{ todayChecked ? '今日已完成' : '等待打卡' }} @@ -53,6 +52,25 @@ + + + + {{ achievementData.theme_icon }} + + {{ achievementData.current?.name || '--' }} + {{ achievementData.theme_name }} + + + + + + + + 距「{{ achievementData.next.name }}」还需 {{ achievementData.next.required_days - achievementData.days }} 天 + + 已达最高等级 + + - 健康恢复 - 身体正在按自己的节奏修复 + 健康恢复里程碑 + + + {{ healthProgress >= 100 ? '已达成' : `${healthProgress}%` }} - {{ healthProgress }}% - - - - + - - {{ milestone.label }} + + {{ ms.name }} + {{ ms.percent }}% + + + + - - 今日提醒 - {{ healthTip }} + + + 🎯 + + 梦想清单 + {{ activeGoalText }} + + + + + + + 💡 + {{ healthTip }} @@ -212,6 +247,28 @@ 💡 {{ recordHealthTip }} + + + + {{ achievementData.theme_icon }} 成就称号 + {{ achievementData.theme_name }} + + + + {{ achievementData.current?.name || '--' }} + 第 {{ achievementData.days }} 天 + + + + + + 距下一级「{{ achievementData.next.name }}」还需 {{ achievementData.next.required_days - achievementData.days }} 天 + + + 已达最高等级 + + + @@ -244,6 +301,8 @@ const showDialog = ref(false) const dialogType = ref('smoke') const homeData = ref(null) const pageReady = ref(false) +const achievementData = ref(null) +const quitHomeData = ref(null) const quitState = ref(defaultQuitState()) let timerInterval = null @@ -288,35 +347,59 @@ const nextSmokeTimeText = computed(() => { return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` }) -// 戒烟模式计算 +// 戒烟模式计算 — 优先使用 V2 API 数据 +const quitSummary = computed(() => quitHomeData.value?.summary || {}) +const quitDailyStatus = computed(() => quitHomeData.value?.daily_status || {}) + const baselineCigsPerDay = computed(() => profileStore.profile?.baseline_cigs_per_day || 10) const packPriceYuan = computed(() => (profileStore.profile?.pack_price_cent || 2500) / 100) const quitDays = computed(() => { + if (quitSummary.value.current_streak_days !== undefined) { + return quitSummary.value.current_streak_days + } if (!quitState.value.lastCheckinDate) return 0 const gap = diffDays(quitState.value.lastCheckinDate, formatDate(new Date())) if (gap > 1) return 0 return Number(quitState.value.streakDays || 0) }) -const todayChecked = computed(() => quitState.value.lastCheckinDate === formatDate(new Date())) -const todayCheckinTime = computed(() => formatClock(quitState.value.lastCheckinAt)) +const todayChecked = computed(() => { + if (quitDailyStatus.value.status) { + return quitDailyStatus.value.status === 'checked_in' + } + return quitState.value.lastCheckinDate === formatDate(new Date()) +}) + +const todayCheckinTime = computed(() => { + if (quitDailyStatus.value.checkin_at) { + return formatClock(quitDailyStatus.value.checkin_at) + } + return formatClock(quitState.value.lastCheckinAt) +}) const savedMoney = computed(() => { - const total = (quitDays.value * baselineCigsPerDay.value / 20) * packPriceYuan.value - return Math.round(total) + if (quitSummary.value.saved_money_cent !== undefined) { + return Math.round(quitSummary.value.saved_money_cent / 100) + } + return Math.round((quitDays.value * baselineCigsPerDay.value / 20) * packPriceYuan.value) }) const avoidedCigs = computed(() => { + if (quitSummary.value.avoided_cigs !== undefined) { + return quitSummary.value.avoided_cigs + } return quitDays.value * baselineCigsPerDay.value }) const lifeSaved = computed(() => { - // 每支烟减少约11分钟生命,换算成小时 - return Math.round(quitDays.value * baselineCigsPerDay.value * 11 / 60) + return Math.round(avoidedCigs.value * 11 / 60) }) const healthProgress = computed(() => { + if (quitSummary.value.health_recovery_percent !== undefined) { + return quitSummary.value.health_recovery_percent + } if (quitDays.value >= 365) return 100 if (quitDays.value >= 180) return 85 if (quitDays.value >= 90) return 70 @@ -361,6 +444,30 @@ const healthMilestones = computed(() => [ { days: 365, label: '1年' } ]) +const healthMilestoneItems = computed(() => { + const milestones = [ + { name: '血压心率恢复', minutes: 20 }, + { name: '一氧化碳排出', minutes: 480 }, + { name: '尼古丁代谢完', minutes: 4320 }, + { name: '味觉嗅觉恢复', minutes: 43200 }, + { name: '血液循环改善', minutes: 129600 }, + { name: '肺功能提升', minutes: 525600 }, + ] + const minutes = quitDays.value * 1440 + return milestones.map(m => ({ + name: m.name, + percent: Math.min(Math.round((minutes / m.minutes) * 100), 100) + })) +}) + +const activeGoalText = computed(() => { + const goal = quitHomeData.value?.goal + if (!goal) return '设定一个小目标,攒钱实现它' + const remaining = goal.target_amount_cent - (goal.current_amount_cent || 0) + if (remaining <= 0) return `「${goal.title}」已攒够!` + return `「${goal.title}」还差 ¥${Math.round(remaining / 100)}` +}) + const todayCountPercent = computed(() => { if (!dailyTarget.value || dailyTarget.value <= 0) return todayCount.value > 0 ? 100 : 0 const percent = Math.round((todayCount.value / dailyTarget.value) * 100) @@ -518,21 +625,73 @@ async function handleSubmit(submitData) { } } -function handleQuitCheckin() { +async function handleQuitCheckin() { if (todayChecked.value) { uni.showToast({ title: '今天已经打过卡', icon: 'none' }) return } - const today = formatDate(new Date()) - const previousDate = quitState.value.lastCheckinDate - let streakDays = 1 - if (previousDate) { - const gap = diffDays(previousDate, today) - if (gap === 1) streakDays = Number(quitState.value.streakDays || 0) + 1 - else if (gap === 0) streakDays = Number(quitState.value.streakDays || 0) + try { + const today = formatDate(new Date()) + const res = await api.quitCheckin({ date: today }) + applyQuitHomeData(res.data) + saveQuitState({ + lastCheckinDate: today, + lastCheckinAt: new Date().toISOString(), + streakDays: res.data?.summary?.current_streak_days || 0 + }) + uni.showToast({ title: '打卡成功', icon: 'success' }) + } catch (e) { + console.error('handleQuitCheckin error:', e) + uni.showToast({ title: '打卡失败,请重试', icon: 'none' }) } - saveQuitState({ lastCheckinDate: today, lastCheckinAt: new Date().toISOString(), streakDays }) - uni.showToast({ title: '打卡成功', icon: 'success' }) +} + +function gotoDreamGoals() { + uni.navigateTo({ url: '/pages/dream-goals/index' }) +} + +function applyQuitHomeData(data) { + if (!data) return + quitHomeData.value = data + if (data.daily_status?.status === 'checked_in' && data.daily_status?.checkin_at) { + saveQuitState({ + lastCheckinDate: data.daily_status.date, + lastCheckinAt: data.daily_status.checkin_at, + streakDays: data.summary?.current_streak_days || 0 + }) + } +} + +async function fetchQuitHomeData() { + try { + const res = await api.getQuitCheckinHome() + applyQuitHomeData(res.data) + } catch (e) { + const msg = e?.message || '' + if (msg.includes('基础资料')) { + await ensureQuitProfile() + try { + const res = await api.getQuitCheckinHome() + applyQuitHomeData(res.data) + } catch (retryErr) { + console.error('fetchQuitHomeData retry error:', retryErr) + loadQuitState() + } + } else { + console.error('fetchQuitHomeData error:', e) + loadQuitState() + } + } +} + +async function ensureQuitProfile() { + const profile = profileStore.profile + if (!profile) return + await api.upsertQuitCheckinProfile({ + quit_start_date: formatDate(new Date()), + pack_price_cent: profile.pack_price_cent || 2500, + baseline_cigs_per_day: profile.baseline_cigs_per_day || 10 + }) } async function ensureProfileReady() { @@ -548,13 +707,23 @@ async function ensureProfileReady() { return true } +async function fetchAchievement() { + try { + const res = await api.getAchievement() + achievementData.value = res.data?.achievement || null + } catch (e) { + console.error('fetchAchievement error:', e) + } +} + async function refreshCurrentMode() { if (!userStore.mode) return const profileReady = await ensureProfileReady() if (!profileReady) return + fetchAchievement() if (isQuitMode.value) { stopTimer() - loadQuitState() + await fetchQuitHomeData() return } await fetchRecordHomeData() @@ -698,8 +867,7 @@ onShareAppMessage(() => ({ .quit-hero-card, .quit-checkin-card, -.quit-health-card, -.quit-motivation-card { +.quit-health-card { position: relative; overflow: hidden; background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(251, 253, 252, 0.94) 100%); @@ -714,8 +882,7 @@ onShareAppMessage(() => ({ } .quit-hero-card::before, -.quit-health-card::before, -.quit-motivation-card::before { +.quit-health-card::before { content: ''; position: absolute; width: 240rpx; @@ -744,7 +911,7 @@ onShareAppMessage(() => ({ align-items: flex-start; justify-content: space-between; gap: 16rpx; - margin-bottom: 26rpx; + margin-bottom: 20rpx; } .quit-hero-copy { @@ -765,20 +932,12 @@ onShareAppMessage(() => ({ } .quit-hero-title { - display: block; - margin-top: 16rpx; - font-size: 42rpx; - line-height: 1.2; - font-weight: 800; - color: #111827; -} - -.quit-hero-subtitle { display: block; margin-top: 10rpx; - font-size: 24rpx; - line-height: 1.6; - color: #6b7280; + font-size: 28rpx; + line-height: 1.5; + font-weight: 700; + color: #14936d; } .quit-hero-chip { @@ -966,10 +1125,10 @@ onShareAppMessage(() => ({ .quit-health-header { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 16rpx; - margin-bottom: 18rpx; + margin-bottom: 20rpx; } .quit-health-title { @@ -979,101 +1138,238 @@ onShareAppMessage(() => ({ color: #111827; } -.quit-health-subtitle { - display: block; - margin-top: 8rpx; - font-size: 22rpx; - line-height: 1.5; - color: #6b7280; +.quit-health-badge { + font-size: 21rpx; + padding: 8rpx 16rpx; + border-radius: 999rpx; + background: rgba(240, 252, 248, 0.94); + border: 1rpx solid rgba(15, 23, 42, 0.05); + color: #14936d; + font-weight: 600; } -.quit-health-value { - font-size: 34rpx; - font-weight: 800; +.quit-health-badge-done { + background: linear-gradient(135deg, #10B981, #14936d); + color: #fff; + border-color: transparent; +} + +.quit-milestone-list { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.quit-ms-item { + position: relative; +} + +.quit-ms-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8rpx; +} + +.quit-ms-name { + font-size: 24rpx; + color: #374151; + font-weight: 500; +} + +.quit-ms-pct { + font-size: 22rpx; + color: #9ca3af; + font-weight: 600; +} + +.quit-ms-pct-done { color: #14936d; } -.quit-health-bar { - position: relative; - z-index: 1; - height: 14rpx; +.quit-ms-bar { + height: 10rpx; background: rgba(15, 23, 42, 0.06); border-radius: 999rpx; overflow: hidden; } -.quit-health-bar-fill { +.quit-ms-fill { height: 100%; - background: linear-gradient(90deg, #31c18b 0%, #14936d 100%); border-radius: 999rpx; transition: width 0.5s ease; } -.quit-health-milestones { - display: flex; - justify-content: space-between; - gap: 8rpx; - margin-top: 22rpx; +.quit-ms-fill-done { + background: linear-gradient(90deg, #31c18b, #14936d); } -.quit-milestone-item { +.quit-ms-fill-pending { + background: rgba(52, 200, 160, 0.3); +} + +.quit-ach-inline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16rpx; + margin-top: 20rpx; + padding: 18rpx 20rpx; + border-radius: 22rpx; + background: linear-gradient(180deg, #f5fbf9 0%, #eef6f2 100%); + border: 1rpx solid rgba(15, 23, 42, 0.05); + position: relative; + z-index: 1; +} + +.quit-ach-left { + display: flex; + align-items: center; + gap: 12rpx; + flex-shrink: 0; +} + +.quit-ach-icon { + font-size: 36rpx; + line-height: 1; +} + +.quit-ach-info { display: flex; flex-direction: column; - align-items: center; - gap: 8rpx; - flex: 1; } -.quit-milestone-dot { - width: 16rpx; - height: 16rpx; - border-radius: 50%; - background: rgba(15, 23, 42, 0.08); - border: 2rpx solid rgba(15, 23, 42, 0.1); +.quit-ach-rank { + font-size: 28rpx; + font-weight: 800; + color: #14936d; + line-height: 1.2; } -.quit-milestone-done .quit-milestone-dot { - background: #31c18b; - border-color: #14936d; -} - -.quit-milestone-text { +.quit-ach-theme { font-size: 20rpx; color: #9ca3af; + margin-top: 2rpx; } -.quit-milestone-done .quit-milestone-text { +.quit-ach-right { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; +} + +.quit-ach-progress-area { + width: 100%; + max-width: 280rpx; +} + +.quit-ach-bar { + height: 8rpx; + background: rgba(16, 185, 129, 0.12); + border-radius: 999rpx; + overflow: hidden; +} + +.quit-ach-fill { + height: 100%; + background: linear-gradient(90deg, #10B981, #34D399); + border-radius: 999rpx; + transition: width 0.5s ease; +} + +.quit-ach-hint { + display: block; + margin-top: 6rpx; + font-size: 20rpx; + color: #9ca3af; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.quit-ach-max { + font-size: 20rpx; color: #14936d; font-weight: 600; } -.quit-motivation-card { - padding: 24rpx; -} - -.quit-motivation-label { - position: relative; - z-index: 1; - display: inline-flex; +.quit-dream-entry { + display: flex; align-items: center; - padding: 8rpx 18rpx; - border-radius: 999rpx; - background: rgba(240, 252, 248, 0.94); - border: 1rpx solid rgba(15, 23, 42, 0.05); - font-size: 20rpx; - font-weight: 600; - color: #6b7280; + justify-content: space-between; + padding: 22rpx 24rpx; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(251, 253, 252, 0.94) 100%); + border-radius: 24rpx; + border: 1rpx solid rgba(15, 23, 42, 0.06); + box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.04); } -.quit-motivation-text { - position: relative; - z-index: 1; +.quit-dream-left { + display: flex; + align-items: center; + gap: 16rpx; + flex: 1; + min-width: 0; +} + +.quit-dream-icon { + font-size: 36rpx; + flex-shrink: 0; +} + +.quit-dream-info { + flex: 1; + min-width: 0; +} + +.quit-dream-title { display: block; - margin-top: 16rpx; font-size: 28rpx; - line-height: 1.7; - font-weight: 600; - color: #374151; + font-weight: 700; + color: #111827; +} + +.quit-dream-desc { + display: block; + margin-top: 4rpx; + font-size: 22rpx; + color: #9ca3af; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quit-dream-arrow { + font-size: 36rpx; + color: #d1d5db; + flex-shrink: 0; + font-weight: 300; +} + +.quit-tip-bar { + display: flex; + align-items: center; + gap: 8rpx; + padding: 14rpx 18rpx; + border-radius: 16rpx; + background: rgba(240, 252, 248, 0.6); + border: 1rpx solid rgba(15, 23, 42, 0.03); +} + +.quit-tip-icon { + font-size: 22rpx; + flex-shrink: 0; +} + +.quit-tip-text { + font-size: 20rpx; + line-height: 1.5; + color: #9ca3af; } /* ===== 记录模式 ===== */ @@ -1497,4 +1793,93 @@ onShareAppMessage(() => ({ color: #4b5563; line-height: 1.5; } + +.record-achievement-card { + margin-top: 20rpx; + padding: 24rpx; + border-radius: 24rpx; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 251, 249, 0.94) 100%); + border: 1rpx solid rgba(15, 23, 42, 0.06); + box-shadow: 0 6rpx 14rpx rgba(15, 23, 42, 0.03); +} + +.ach-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16rpx; +} + +.ach-title { + font-size: 26rpx; + font-weight: 700; + color: #111827; +} + +.ach-theme-name { + font-size: 22rpx; + color: #6b7280; + padding: 6rpx 14rpx; + background: rgba(240, 252, 248, 0.94); + border-radius: 999rpx; + border: 1rpx solid rgba(15, 23, 42, 0.05); +} + +.ach-body { + position: relative; + z-index: 1; +} + +.ach-current { + display: flex; + align-items: baseline; + gap: 12rpx; + margin-bottom: 16rpx; +} + +.ach-rank { + font-size: 40rpx; + font-weight: 800; + color: #14936d; + font-family: 'DIN Alternate', -apple-system, sans-serif; +} + +.ach-days { + font-size: 24rpx; + color: #6b7280; +} + +.ach-progress-wrap { + margin-bottom: 4rpx; +} + +.ach-progress-bar { + height: 12rpx; + background: rgba(16, 185, 129, 0.12); + border-radius: 6rpx; + overflow: hidden; + margin-bottom: 10rpx; +} + +.ach-progress-fill { + height: 100%; + background: linear-gradient(90deg, #10B981, #34D399); + border-radius: 6rpx; + transition: width 0.5s ease; +} + +.ach-next-hint { + font-size: 22rpx; + color: #6b7280; +} + +.ach-max { + margin-top: 4rpx; +} + +.ach-max-text { + font-size: 22rpx; + color: #14936d; + font-weight: 600; +} diff --git a/src/pages/logs/index.vue b/src/pages/logs/index.vue index f248302..25960cb 100644 --- a/src/pages/logs/index.vue +++ b/src/pages/logs/index.vue @@ -297,88 +297,55 @@ onShareAppMessage(() => { min-height: 100vh; display: flex; flex-direction: column; - background: linear-gradient(180deg, #E6F7F2 0%, #F0FBF7 40%, #FAFFFE 100%); - padding: 0 28rpx; + background-color: #F5F7F6; + padding: 0 32rpx; box-sizing: border-box; } -.filters, -.fab { - position: relative; - z-index: 1; -} - -.card { - background: rgba(255, 255, 255, 0.88); - border-radius: 24rpx; - border: 1.5rpx solid rgba(52, 200, 160, 0.14); - box-shadow: 0 4rpx 18rpx rgba(52, 200, 160, 0.07); - padding: 24rpx; -} - -.section-label { - display: block; - margin: 0 0 18rpx 6rpx; - font-size: 26rpx; - font-weight: 600; - color: #1a5c45; -} - .filters-sticky { position: relative; - height: 148rpx; + height: 120rpx; flex-shrink: 0; z-index: 20; } .filters { position: fixed; - left: 28rpx; - right: 28rpx; + left: 32rpx; + right: 32rpx; z-index: 50; - margin: 8rpx 0 0; - padding: 12rpx; - border-radius: 28rpx; - background: rgba(255, 255, 255, 0.72); - border: 1.5rpx solid rgba(255, 255, 255, 0.75); - box-shadow: 0 10rpx 28rpx rgba(52, 200, 160, 0.1); - -webkit-backdrop-filter: blur(12px); - backdrop-filter: blur(12px); -} - -.filters::before { - content: ''; - position: absolute; - inset: -12rpx -8rpx -10rpx; - border-radius: 34rpx; - background: linear-gradient(180deg, rgba(230, 247, 242, 0.96) 0%, rgba(240, 251, 247, 0.88) 72%, rgba(240, 251, 247, 0) 100%); - z-index: -1; - pointer-events: none; + margin: 12rpx 0 0; } .tabs { display: flex; - background: rgba(248, 252, 250, 0.92); - border-radius: 22rpx; + background: #FFFFFF; + border-radius: 24rpx; padding: 6rpx; - border: 1.5rpx solid rgba(52, 200, 160, 0.12); - box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.9); + box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.03); } .tab { flex: 1; text-align: center; padding: 16rpx 0; - border-radius: 16rpx; - font-size: 24rpx; + border-radius: 20rpx; + font-size: 26rpx; font-weight: 600; - color: #7aA898; + color: #999999; } .tab-active { - background: #FFFFFF; - color: #0D3D2E; - box-shadow: 0 8rpx 18rpx rgba(52, 200, 160, 0.12); + background: #10B981; + color: #FFFFFF; +} + +.section-label { + display: block; + margin: 0 0 18rpx 6rpx; + font-size: 28rpx; + font-weight: 600; + color: #666666; } .scroll-container { @@ -405,7 +372,7 @@ onShareAppMessage(() => { width: 64rpx; height: 64rpx; border-radius: 18rpx; - background: linear-gradient(90deg, rgba(52, 200, 160, 0.08) 25%, rgba(52, 200, 160, 0.18) 50%, rgba(52, 200, 160, 0.08) 75%); + background: linear-gradient(90deg, #EEEEEE 25%, #F5F5F5 50%, #EEEEEE 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; flex-shrink: 0; @@ -413,16 +380,15 @@ onShareAppMessage(() => { .skeleton-card { flex: 1; - background: rgba(255, 255, 255, 0.88); + background: #FFFFFF; border-radius: 24rpx; padding: 24rpx; - border: 1.5rpx solid rgba(52, 200, 160, 0.14); - box-shadow: 0 4rpx 18rpx rgba(52, 200, 160, 0.07); + box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.03); } .skeleton-line { height: 24rpx; - background: linear-gradient(90deg, rgba(52, 200, 160, 0.08) 25%, rgba(52, 200, 160, 0.18) 50%, rgba(52, 200, 160, 0.08) 75%); + background: linear-gradient(90deg, #EEEEEE 25%, #F5F5F5 50%, #EEEEEE 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 8rpx; @@ -461,42 +427,40 @@ onShareAppMessage(() => { .group-title { font-size: 28rpx; font-weight: 700; - color: #0D3D2E; + color: #1A1A1A; } .group-count { font-size: 22rpx; - color: #7aA898; - background-color: rgba(52, 200, 160, 0.06); + color: #999999; + background-color: #F0F0F0; padding: 6rpx 16rpx; border-radius: 999rpx; - border: 1.5rpx solid rgba(52, 200, 160, 0.14); } .group-items { display: flex; flex-direction: column; - gap: 20rpx; + gap: 16rpx; } .log-card { position: relative; - background: rgba(255, 255, 255, 0.88); + background: #FFFFFF; border-radius: 24rpx; padding: 24rpx 24rpx 20rpx 24rpx; - box-shadow: 0 4rpx 18rpx rgba(52, 200, 160, 0.07); + box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.03); display: flex; gap: 20rpx; overflow: hidden; - border: 1.5rpx solid rgba(52, 200, 160, 0.14); } .log-card-resisted { - background: linear-gradient(135deg, rgba(245, 255, 251, 0.96), rgba(255, 255, 255, 0.88)); + background: #FFFFFF; } .log-card-smoke { - background: linear-gradient(135deg, rgba(255, 250, 244, 0.96), rgba(255, 255, 255, 0.88)); + background: #FFFFFF; } .log-bar { @@ -505,11 +469,11 @@ onShareAppMessage(() => { top: 0; bottom: 0; width: 8rpx; - background-color: #34C8A0; + background-color: #10B981; } .log-card-smoke .log-bar { - background-color: #B45309; + background-color: #F59E0B; } .log-icon { @@ -525,13 +489,13 @@ onShareAppMessage(() => { } .icon-resisted { - background-color: rgba(52, 200, 160, 0.12); - color: #1a8c62; + background-color: #E8F5F0; + color: #10B981; } .icon-smoke { - background-color: rgba(251, 191, 36, 0.14); - color: #B45309; + background-color: #FEF3C7; + color: #D97706; } .log-main { @@ -556,7 +520,7 @@ onShareAppMessage(() => { .log-time { font-size: 30rpx; font-weight: 700; - color: #0D3D2E; + color: #1A1A1A; } .log-tag { @@ -567,13 +531,13 @@ onShareAppMessage(() => { } .tag-smoke { - background-color: rgba(251, 191, 36, 0.14); - color: #B45309; + background-color: #FEF3C7; + color: #D97706; } .tag-resisted { - background-color: rgba(52, 200, 160, 0.12); - color: #1a8c62; + background-color: #E8F5F0; + color: #10B981; } .log-right { @@ -585,8 +549,8 @@ onShareAppMessage(() => { .count-pill { font-size: 22rpx; - color: #B45309; - background-color: rgba(251, 191, 36, 0.14); + color: #D97706; + background-color: #FEF3C7; padding: 8rpx 16rpx; border-radius: 999rpx; font-weight: 600; @@ -594,15 +558,15 @@ onShareAppMessage(() => { .thumb-pill { font-size: 24rpx; - background-color: rgba(52, 200, 160, 0.12); - color: #1a8c62; + background-color: #E8F5F0; + color: #10B981; padding: 8rpx 14rpx; border-radius: 999rpx; } .log-desc { font-size: 25rpx; - color: #52806E; + color: #666666; line-height: 1.5; margin-bottom: 10rpx; } @@ -622,11 +586,10 @@ onShareAppMessage(() => { .log-interval { font-size: 22rpx; - color: #7aA898; - background-color: rgba(52, 200, 160, 0.06); + color: #999999; + background-color: #F5F5F5; padding: 6rpx 14rpx; border-radius: 999rpx; - border: 1.5rpx solid rgba(52, 200, 160, 0.12); } .log-actions { @@ -640,29 +603,24 @@ onShareAppMessage(() => { font-size: 22rpx; padding: 8rpx 16rpx; border-radius: 999rpx; - background-color: rgba(255, 255, 255, 0.9); - border: 1.5rpx solid rgba(52, 200, 160, 0.18); - color: #7aA898; } .edit-btn { color: #2563EB; - border-color: rgba(37, 99, 235, 0.18); background-color: rgba(37, 99, 235, 0.08); } .delete-btn { color: #DC2626; - border-color: rgba(220, 38, 38, 0.16); - background-color: rgba(220, 38, 38, 0.08); + background-color: rgba(220, 38, 38, 0.06); } .level-unknown { - color: #7aA898; + color: #999999; } .level-1 { - color: #1a8c62; + color: #10B981; } .level-2 { @@ -687,36 +645,35 @@ onShareAppMessage(() => { align-items: center; justify-content: center; padding: 120rpx 32rpx; - border-radius: 24rpx; - border: 2rpx dashed rgba(52, 200, 160, 0.2); - background: transparent; + border-radius: 32rpx; + background: #FFFFFF; + box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.03); } .empty-icon { width: 112rpx; height: 112rpx; border-radius: 36rpx; - background: rgba(52, 200, 160, 0.06); - border: 1.5rpx solid rgba(52, 200, 160, 0.14); + background: #E8F5F0; display: flex; align-items: center; justify-content: center; font-size: 40rpx; font-weight: 700; - color: #7aA898; + color: #10B981; margin-bottom: 24rpx; } .empty-text { font-size: 32rpx; - color: #0D3D2E; + color: #1A1A1A; font-weight: 600; margin-bottom: 12rpx; } .empty-hint { font-size: 24rpx; - color: #7aA898; + color: #999999; } .loading-more, .no-more { @@ -726,7 +683,7 @@ onShareAppMessage(() => { .loading-text, .no-more-text { font-size: 24rpx; - color: #7aA898; + color: #999999; } .fab { @@ -735,12 +692,12 @@ onShareAppMessage(() => { bottom: 140rpx; width: 96rpx; height: 96rpx; - background: linear-gradient(180deg, #3DD9AE 0%, #34C8A0 100%); + background: #10B981; border-radius: 50%; display: flex; align-items: center; justify-content: center; - box-shadow: 0 12rpx 28rpx rgba(52, 200, 160, 0.28); + box-shadow: 0 8rpx 24rpx rgba(16, 185, 129, 0.25); transition: all 0.3s; z-index: 100; } diff --git a/src/pages/onboarding/index.vue b/src/pages/onboarding/index.vue index 7f3c78b..40a5cbf 100644 --- a/src/pages/onboarding/index.vue +++ b/src/pages/onboarding/index.vue @@ -17,13 +17,20 @@ :class="{ 'mode-switch-item-active': formData.mode === item.value }" @tap="selectMode(item.value)" > - {{ item.icon }} {{ item.label }} {{ item.desc }} + + + + + {{ formData.quit_date || '请选择日期' }} + + + @@ -98,14 +105,41 @@ type="digit" v-model="priceYuan" class="inline-field" - placeholder="25" - placeholder-style="color: #9CC5B5" + placeholder="25" + placeholder-style="color: #CCCCCC" /> 元/包 + + + + 打卡越久,称号越高 + + + + {{ theme.icon }} + {{ theme.name }} + + + {{ level.name }} + + + + + @@ -123,6 +157,7 @@ import { onShareAppMessage } from '@dcloudio/uni-app' import { useProfileStore } from '@/stores/profile' import { useUserStore } from '@/stores/user' import { useLogin } from '@/hooks/useLogin' +import * as api from '@/api' const profileStore = useProfileStore() const userStore = useUserStore() @@ -130,12 +165,18 @@ const { waitForLogin } = useLogin() const navBarHeight = ref(0) const modeSaving = ref(false) +const achievementThemes = ref([]) const modeOptions = [ - { value: 'quit', label: '戒烟打卡', desc: '按天打卡,坚持戒烟', icon: '🌿' }, - { value: 'record', label: '记录抽烟', desc: '按支数记录变化趋势', icon: '📊' } + { value: 'quit', label: '戒烟打卡', desc: '按天打卡,坚持戒烟' }, + { value: 'record', label: '记录抽烟', desc: '按支数记录变化趋势' } ] +function todayStr() { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + const formData = ref({ mode: 'record', baseline_cigs_per_day: 10, @@ -144,7 +185,9 @@ const formData = ref({ smoke_motivations: [], wake_up_time: '07:30', sleep_time: '23:00', - pack_price_cent: 2500 + pack_price_cent: 2500, + quit_date: todayStr(), + achievement_theme_id: null }) const priceYuan = ref('25') @@ -201,6 +244,10 @@ async function selectMode(mode) { } } +function onQuitDateChange(e) { + formData.value.quit_date = e.detail.value +} + function onWakeTimeChange(e) { formData.value.wake_up_time = e.detail.value } @@ -238,6 +285,17 @@ onMounted(async () => { } await waitForLogin() + + try { + const res = await api.getAchievementThemes() + achievementThemes.value = res.data?.themes || [] + if (achievementThemes.value.length > 0 && !formData.value.achievement_theme_id) { + formData.value.achievement_theme_id = achievementThemes.value[0].id + } + } catch (e) { + console.error('loadAchievementThemes error:', e) + } + try { const profileData = await profileStore.fetchProfile() if (profileData?.profile) { @@ -253,7 +311,9 @@ onMounted(async () => { smoke_motivations: Array.isArray(profile.smoke_motivations) ? profile.smoke_motivations : formData.value.smoke_motivations, wake_up_time: profile.wake_up_time || formData.value.wake_up_time, sleep_time: profile.sleep_time || formData.value.sleep_time, - pack_price_cent: profile.pack_price_cent || formData.value.pack_price_cent + pack_price_cent: profile.pack_price_cent || formData.value.pack_price_cent, + quit_date: profile.quit_date ? profile.quit_date.split('T')[0] : formData.value.quit_date, + achievement_theme_id: profile.achievement_theme_id || formData.value.achievement_theme_id } if (profile.pack_price_cent) { priceYuan.value = String((profile.pack_price_cent / 100).toFixed(2)).replace(/\.00$/, '') @@ -275,56 +335,58 @@ onShareAppMessage(() => {