diff --git a/docs/api.md b/docs/api.md index 3afe6d8..a4802aa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -136,10 +136,9 @@ curl -X POST 'http://127.0.0.1:8080/api/v1/smoke/logs' \ "next_suggested_clock": "10:30", "suggestion_source": "default" }, - "summary": { + "summary": { "today_count": 3, "daily_target": 10, - "resisted_count": 1, "reduced_from_yesterday": 2, "exceeded_yesterday": false }, @@ -158,7 +157,6 @@ curl -X POST 'http://127.0.0.1:8080/api/v1/smoke/logs' \ - `timer.suggestion_source`:建议来源(`default`/`ai`)。 - `summary.today_count`:今日吸烟支数累加。 - `summary.daily_target`:每日目标。 -- `summary.resisted_count`:今日忍住次数。 - `summary.reduced_from_yesterday`:与昨日的绝对差值(非负)。 - `summary.exceeded_yesterday`:是否比昨天多。 - `daily_summary`:当天已缓存的 AI 日总结;无缓存时为 `null`。 @@ -372,3 +370,59 @@ AI 生成说明: - `money.expected_total`:按“统计周期内有记录的天数”×`baseline_cigs_per_day` 计算;不统计无日志的天数。 - `money.saved_cent`:按 `max(expected_total - actual_total, 0)` 计算,避免出现负值。 - `health.available=false`:表示无历史记录。 + +## 15) 成就主题与当前称号 + +`GET /api/v1/smoke/achievement/themes` + +返回 onboarding 可选择的称号主题。每个主题包含 `icon/name/key/levels`,前端在“重填问卷”中展示。 + +`GET /api/v1/smoke/achievement` + +返回当前用户所选主题下的当前等级、下一等级和进度。记录抽烟模式使用“少抽积分”进阶;戒烟打卡模式仍使用连续记录天数进阶。 + +记录模式少抽积分算法: +- 统计窗口:从 `onboarding_completed_at` 到今天,最多回看最近 90 天。 +- 只统计有记录的日期,避免无记录日期被误判为 0 根。 +- 每日基础分:`max(0, baseline_cigs_per_day - 当日抽烟支数)`。 +- 当日少抽比例 `>=30%` 额外 `+1` 分。 +- 当日少抽比例 `>=60%` 额外 `+2` 分。 +- 当日抽烟支数为 `0` 额外 `+3` 分。 +- 总积分用于匹配所选主题的等级阈值;`fa_achievement_level.required_days` 在记录模式下作为 `required_score` 使用。 + +成功响应示例: +```json +{ + "code": 200, + "message": "success", + "data": { + "achievement": { + "theme_id": 1, + "theme_name": "意志骑士", + "theme_key": "knight", + "theme_icon": "🛡️", + "metric_type": "score", + "score": 48, + "metrics": { + "baseline_cigs_per_day": 10, + "scored_days": 7, + "total_reduced_cigs": 36, + "today_reduced_cigs": 4, + "today_reduce_percent": 40, + "stable_days": 6 + }, + "current": { + "name": "见习骑士", + "icon": "🛡️", + "required_score": 30 + }, + "next": { + "name": "白银骑士", + "icon": "⚔️", + "required_score": 80 + }, + "progress": 0.36 + } + } +} +``` diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index ca31e43..711faa2 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -129,17 +129,28 @@ - {{ recordAvatarText }} + + + {{ recordTitle.icon }} + - 节奏观察者 {{ recordLevelText }} - {{ recordStatusLabel }} · 今日 {{ todayCount }}/{{ dailyTarget || '-' }} 根 + {{ recordTitle.name }} {{ recordTitle.level }} + {{ recordTitle.description }} - {{ recordControlScore }}% + 少 {{ recordReducedCigs }} 根 + + + REDUCTION TITLE + {{ recordTitle.badge }} + + {{ recordNextTitleHint }} + + @@ -208,23 +219,6 @@ - - - - {{ achievementData.theme_icon }} {{ achievementCardTitle }} - {{ achievementData.theme_name }} · 第 {{ achievementData.days }} 天 - - {{ achievementData.current?.name || '--' }} - - - - - - 距下一级「{{ achievementData.next.name }}」还需 {{ achievementData.next.required_days - achievementData.days }} 天 - - 已达最高等级 - - {{ recordHealthTip }} @@ -321,12 +315,12 @@ const packPriceYuan = computed(() => (profileStore.profile?.pack_price_cent || 2 // ========== 记录模式 ========== const todayCount = computed(() => homeSummary.value.today_count ?? 0) -const resistedCount = computed(() => homeSummary.value.resisted_count ?? 0) const dailyTarget = computed(() => { const target = homeSummary.value.daily_target if (target != null) return target return profileStore.profile?.baseline_cigs_per_day || 0 }) +const recordReducedCigs = computed(() => Math.max(0, (dailyTarget.value || 0) - todayCount.value)) const changeText = computed(() => { const reduced = homeSummary.value.reduced_from_yesterday if (reduced == null) return '较昨日暂无对比' @@ -382,11 +376,9 @@ const recordRhythmText = computed(() => { return changeText.value }) -const achievementCardTitle = computed(() => isQuitMode.value ? '无烟等级' : '长期记录等级') - const reducePercent = computed(() => { if (dailyTarget.value <= 0) return 0 - return Math.round(Math.max(0, dailyTarget.value - todayCount.value) / dailyTarget.value * 100) + return Math.round(recordReducedCigs.value / dailyTarget.value * 100) }) const recordHealthTip = computed(() => { @@ -411,8 +403,6 @@ const recordStatusLabel = computed(() => { return '需要降速' }) -const recordAvatarText = computed(() => achievementData.value?.theme_icon || '控') -const recordLevelText = computed(() => `Lv.${Math.max(1, Math.floor((achievementData.value?.days || 0) / 7) + 1)}`) const recordBioBuffText = computed(() => nextSmokeTimeText.value ? `下一次建议 ${nextSmokeTimeText.value}` : '正在建立控烟节奏') const recordControlRingStyle = computed(() => { const angle = Math.round(recordControlScore.value * 3.6) @@ -429,11 +419,32 @@ const recordYesterdayText = computed(() => { const recordBioHealthItems = computed(() => [ { name: '今日目标', value: `${todayCount.value}/${dailyTarget.value || '-'} 根` }, - { name: '今日忍住', value: `${resistedCount.value} 次` }, { name: '较昨日', value: recordYesterdayText.value }, { name: '建议时间', value: nextSmokeTimeText.value || '--:--' } ]) +const recordTitle = computed(() => { + const current = achievementData.value?.current + const metrics = achievementData.value?.metrics || {} + return { + icon: current?.icon || achievementData.value?.theme_icon || '◎', + name: current?.name || '节奏观察者', + level: achievementData.value?.score !== undefined ? `积分 ${achievementData.value.score}` : '积分 0', + badge: achievementData.value?.theme_name || '成就称号', + description: metrics.scored_days > 0 + ? `累计少抽 ${metrics.total_reduced_cigs || 0} 根,稳定记录 ${metrics.stable_days || 0} 天。` + : '完成记录后,会按少抽数量解锁称号。' + } +}) + +const recordNextTitleHint = computed(() => { + const next = achievementData.value?.next + if (!next) return achievementData.value?.current ? '已达当前最高称号' : '记录后开始进阶' + const score = Number(achievementData.value?.score) || 0 + const required = Number(next.required_score ?? next.required_days) || 0 + return `距「${next.name}」还差 ${Math.max(0, required - score)} 分` +}) + // ========== 戒烟模式 ========== // 服务端连续无烟天数优先,失败时从本地缓存推算 @@ -2757,7 +2768,6 @@ onShareAppMessage(() => ({ .record-bio-status-card, .record-bio-console-card, .record-bio-health-card, -.record-bio-achievement-card, .record-bio-tip-card { position: relative; overflow: hidden; @@ -2791,16 +2801,28 @@ onShareAppMessage(() => ({ gap: 18rpx; } -.record-bio-avatar { - width: 76rpx; - height: 76rpx; - border-radius: 28rpx; +.record-bio-avatar-medal { + position: relative; + width: 96rpx; + height: 96rpx; + flex-shrink: 0; @include flex-center; - background: linear-gradient(135deg, #FBBF24, #67E8F9); - box-shadow: 0 12rpx 28rpx rgba(217, 119, 6, 0.14); - color: #ffffff; - font-size: 34rpx; - font-weight: 900; +} + +.record-bio-avatar-bg { + position: absolute; + inset: 0; + width: 96rpx; + height: 96rpx; + border-radius: 30rpx; + box-shadow: 0 12rpx 28rpx rgba(15, 118, 110, 0.12); +} + +.record-bio-avatar-icon { + position: relative; + z-index: 1; + font-size: 44rpx; + line-height: 1; } .record-bio-profile-main { @@ -2847,6 +2869,50 @@ onShareAppMessage(() => ({ color: #0F766E; } +.record-bio-title-panel { + margin-top: 24rpx; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16rpx; + padding: 18rpx 20rpx; + border-radius: 26rpx; + background: + radial-gradient(circle at 12% 30%, rgba(103, 232, 249, 0.2), transparent 34%), + linear-gradient(135deg, rgba(255, 251, 235, 0.82), rgba(236, 253, 245, 0.72)); + border: 1rpx solid rgba(251, 191, 36, 0.18); +} + +.record-bio-title-copy { + min-width: 0; + @include flex-col; + gap: 4rpx; +} + +.record-bio-title-kicker { + font-size: 18rpx; + font-weight: 900; + letter-spacing: 1.2rpx; + color: #0891B2; +} + +.record-bio-title-name { + font-size: 27rpx; + line-height: 1.25; + font-weight: 900; + color: #0F766E; +} + +.record-bio-title-hint { + flex-shrink: 0; + max-width: 280rpx; + font-size: 20rpx; + line-height: 1.4; + font-weight: 800; + text-align: right; + color: #B45309; +} + .record-bio-hp-box { margin-top: 30rpx; padding: 18rpx; @@ -3022,8 +3088,7 @@ onShareAppMessage(() => ({ font-weight: 900; } -.record-bio-health-card, -.record-bio-achievement-card { +.record-bio-health-card { padding: 28rpx; border-radius: 34rpx; } @@ -3132,35 +3197,6 @@ onShareAppMessage(() => ({ color: #0F766E; } -.record-bio-achievement-card { - background: linear-gradient(135deg, rgba(255, 255, 255, 0.78), rgba(250, 245, 255, 0.5)); -} - -.record-bio-ach-progress { - margin-top: 22rpx; -} - -.record-bio-ach-bar { - height: 14rpx; - border-radius: 999rpx; - overflow: hidden; - background: rgba(226, 232, 240, 0.8); -} - -.record-bio-ach-fill { - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, var(--record-lavender), var(--record-clear)); -} - -.record-bio-ach-hint { - display: block; - margin-top: 12rpx; - font-size: 21rpx; - line-height: 1.5; - color: #64748B; -} - .record-bio-tip-card { display: flex; align-items: flex-start; diff --git a/src/static/achievements/theme-medallion.png b/src/static/achievements/theme-medallion.png new file mode 100644 index 0000000..535428c Binary files /dev/null and b/src/static/achievements/theme-medallion.png differ