const app = getApp() function staticIcon(path) { const base = app.globalData.staticBaseUrl || '' const cleanBase = base.replace(/\/$/, '') const cleanPath = path.replace(/^\/+/, '') return `${cleanBase}/${cleanPath}` } const HERO_BADGE_ICON = staticIcon('/icons/achievements/state/achievement-unlocked.svg') const ACHIEVEMENT_ICON_MAP = { MASTERED_100: staticIcon('/icons/achievements/core/100.svg'), MASTERED_1000: staticIcon('/icons/achievements/core/1000.svg'), MASTERED_3000: staticIcon('/icons/achievements/core/3000.svg'), MASTERED_5000: staticIcon('/icons/achievements/core/5000.svg'), MASTERED_7000: staticIcon('/icons/achievements/core/7000.svg'), MASTERED_10000: staticIcon('/icons/achievements/core/10000.svg'), TOTAL_100: staticIcon('/icons/achievements/core/total-100.svg'), CHECKIN_7: staticIcon('/icons/achievements/core/checkin-30.svg'), CHECKIN_30: staticIcon('/icons/achievements/core/checkin-30.svg'), CHECKIN_100: staticIcon('/icons/achievements/core/streak.svg'), CHECKIN_180: staticIcon('/icons/achievements/core/streak.svg'), CHECKIN_360: staticIcon('/icons/achievements/core/streak.svg'), CHECKIN_STREAK_7: staticIcon('/icons/achievements/core/streak.svg'), CHECKIN_STREAK_30: staticIcon('/icons/achievements/core/streak.svg'), CHECKIN_STREAK_100: staticIcon('/icons/achievements/core/streak.svg'), CHECKIN_STREAK_180: staticIcon('/icons/achievements/core/streak.svg'), CHECKIN_STREAK_360: staticIcon('/icons/achievements/core/streak.svg'), ACCURACY_80: staticIcon('/icons/achievements/core/accuracy.svg'), ACCURACY_90: staticIcon('/icons/achievements/core/accuracy.svg'), DAILY_REVIEW_20: staticIcon('/icons/achievements/core/review-volume.svg'), DAILY_REVIEW_50: staticIcon('/icons/achievements/core/review-volume.svg'), } const STATUS_ICON_MAP = { 1: staticIcon('/icons/achievements/state/achievement-unlocked.svg'), 0: staticIcon('/icons/achievements/state/achievement-locked.svg'), } const GROUP_ORDER = { mastery: 1, checkin: 2, streak: 3, accuracy: 4, review: 5, other: 9, } const GROUP_META = { mastery: { title: '掌握进阶', subtitle: '从入门到精通', icon: staticIcon('/icons/achievements/core/mastery-advanced.svg') }, checkin: { title: '打卡里程', subtitle: '记录坚持的脚步', icon: staticIcon('/icons/achievements/core/checkin-30.svg') }, streak: { title: '连续坚持', subtitle: '不断线的努力', icon: staticIcon('/icons/achievements/core/streak.svg') }, accuracy: { title: '准确率', subtitle: '复习质量提升', icon: staticIcon('/icons/achievements/core/accuracy.svg') }, review: { title: '复习数量', subtitle: '积累复习量', icon: staticIcon('/icons/achievements/core/review-volume.svg') }, other: { title: '其他', subtitle: '更多成就', icon: staticIcon('/icons/achievements/core/total-100.svg') }, } const GROUP_PROGRESS = { mastery: [100, 1000, 3000, 5000, 7000, 10000], checkin: [7, 30, 100, 180, 360], streak: [7, 30, 100, 180, 360], accuracy: [80, 90], review: [20, 50], } function getNextTarget(items, values) { const unlockedMax = items.filter((item) => item.is_unlocked).reduce((max, item) => Math.max(max, item.target_value || 0), 0) const nextTarget = values.find((value) => value > unlockedMax) return nextTarget || null } function formatProgressText(key, items) { const values = GROUP_PROGRESS[key] || [] const nextTarget = getNextTarget(items, values) const best = items.reduce((max, item) => Math.max(max, item.progress_value || 0), 0) if (nextTarget) { return `距离下一档还差 ${Math.max(0, nextTarget - best)} 个` } if (items.some((item) => item.is_unlocked)) { return '该分组已全部解锁' } return '继续坚持,解锁第一档' } Page({ data: { total: 0, unlocked: 0, sections: [], loading: false, error: '', heroBadgeIcon: HERO_BADGE_ICON, }, onShow() { this.loadData() }, getGroup(code) { if (code.startsWith('MASTERED_')) return 'mastery' if (code.startsWith('CHECKIN_STREAK_')) return 'streak' if (code.startsWith('CHECKIN_')) return 'checkin' if (code.startsWith('ACCURACY_')) return 'accuracy' if (code.startsWith('DAILY_REVIEW_')) return 'review' return 'other' }, decorateList(list) { return (list || []).map((item) => ({ ...item, group: this.getGroup(item.achievement_code), icon: item.icon_url || ACHIEVEMENT_ICON_MAP[item.achievement_code] || GROUP_META[this.getGroup(item.achievement_code)].icon, stateIcon: item.state_icon_url || STATUS_ICON_MAP[item.is_unlocked] || STATUS_ICON_MAP[0], })) }, buildSections(list) { const buckets = { unlocked: {}, locked: {} } list.forEach((item) => { const state = item.is_unlocked ? 'unlocked' : 'locked' const key = item.group || 'other' if (!buckets[state][key]) buckets[state][key] = [] buckets[state][key].push(item) }) const buildStateSections = (state) => Object.keys(buckets[state]) .sort((a, b) => (GROUP_ORDER[a] || 99) - (GROUP_ORDER[b] || 99)) .map((key) => { const items = buckets[state][key].sort((a, b) => a.target_value - b.target_value) return { key: `${state}-${key}`, state, meta: GROUP_META[key] || GROUP_META.other, progressText: formatProgressText(key, list.filter((item) => item.group === key)), items, } }) return [ { key: 'unlocked', title: '已解锁', sections: buildStateSections('unlocked') }, { key: 'locked', title: '未解锁', sections: buildStateSections('locked') }, ].filter((group) => group.sections.length) }, loadData() { this.setData({ loading: true, error: '' }) wx.request({ url: `${app.globalData.baseUrl}/api/v1/achievements`, header: { 'X-Auth-Token': app.globalData.authToken || '' }, success: (res) => { const list = this.decorateList(res.data?.data || []) const sections = this.buildSections(list) this.setData({ sections, total: list.length, unlocked: list.filter((item) => item.is_unlocked).length, }) }, fail: () => this.setData({ error: '成就数据加载失败' }), complete: () => this.setData({ loading: false }), }) }, refreshAchievements() { this.setData({ loading: true, error: '' }) wx.request({ url: `${app.globalData.baseUrl}/api/v1/achievements/refresh`, method: 'POST', header: { 'X-Auth-Token': app.globalData.authToken || '' }, success: (res) => { const payload = res.data?.data || {} const list = this.decorateList(payload.achievements || []) const sections = this.buildSections(list) this.setData({ sections, total: list.length, unlocked: list.filter((item) => item.is_unlocked).length, }) if ((payload.just_unlocked_count || 0) > 0) { wx.showToast({ title: `新解锁 ${payload.just_unlocked_count} 项成就`, icon: 'success' }) } }, fail: () => this.setData({ error: '成就刷新失败' }), complete: () => this.setData({ loading: false }), }) }, })