commit d7fc8c8982592dad67067404333473b454d2a016 Author: 王宏豪 <2443753184@qq.com> Date: Thu Sep 3 16:49:59 2026 +0800 init 项目初始化 diff --git a/app.js b/app.js new file mode 100644 index 0000000..d89fd2e --- /dev/null +++ b/app.js @@ -0,0 +1,11 @@ +const auth = require('./utils/auth'); + +App({ + onLaunch() { + auth.ensureLogin().catch(() => {}); + }, + globalData: { + token: '', + user: null + } +}); \ No newline at end of file diff --git a/app.json b/app.json new file mode 100644 index 0000000..c95aaf4 --- /dev/null +++ b/app.json @@ -0,0 +1,30 @@ +{ + "pages": [ + "pages/home/home", + "pages/checkin/checkin", + "pages/plan/plan", + "pages/plan-submit/plan-submit", + "pages/profile/profile", + "pages/webview/webview" + ], + "window": { + "navigationBarBackgroundColor": "#2C1810", + "navigationBarTextStyle": "white", + "navigationBarTitleText": "明缘进阶场", + "backgroundColor": "#FDFBF7", + "backgroundTextStyle": "dark" + }, + "tabBar": { + "color": "#5A4A3A", + "selectedColor": "#C67B30", + "backgroundColor": "#ffffff", + "borderStyle": "black", + "list": [ + { "pagePath": "pages/home/home", "text": "首页", "iconPath": "assets/tabbar/home.png", "selectedIconPath": "assets/tabbar/home-active.png" }, + { "pagePath": "pages/checkin/checkin", "text": "打卡", "iconPath": "assets/tabbar/checkin.png", "selectedIconPath": "assets/tabbar/checkin-active.png" }, + { "pagePath": "pages/plan/plan", "text": "计划", "iconPath": "assets/tabbar/plan.png", "selectedIconPath": "assets/tabbar/plan-active.png" }, + { "pagePath": "pages/profile/profile", "text": "我的", "iconPath": "assets/tabbar/profile.png", "selectedIconPath": "assets/tabbar/profile-active.png" } + ] + }, + "sitemapLocation": "sitemap.json" +} \ No newline at end of file diff --git a/app.wxss b/app.wxss new file mode 100644 index 0000000..b401459 --- /dev/null +++ b/app.wxss @@ -0,0 +1,16 @@ +page { + background: #FDFBF7; + color: #2C1810; + font-family: -apple-system, "PingFang SC", "Noto Sans SC", sans-serif; +} +.btn-primary { + background: #C67B30; + color: #fff; + border-radius: 12rpx; +} +.card { + background: #fff; + border-radius: 16rpx; + padding: 24rpx; + box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04); +} \ No newline at end of file diff --git a/assets/tabbar/checkin-active.png b/assets/tabbar/checkin-active.png new file mode 100644 index 0000000..2c7bc70 Binary files /dev/null and b/assets/tabbar/checkin-active.png differ diff --git a/assets/tabbar/checkin.png b/assets/tabbar/checkin.png new file mode 100644 index 0000000..0189648 Binary files /dev/null and b/assets/tabbar/checkin.png differ diff --git a/assets/tabbar/home-active.png b/assets/tabbar/home-active.png new file mode 100644 index 0000000..972688c Binary files /dev/null and b/assets/tabbar/home-active.png differ diff --git a/assets/tabbar/home.png b/assets/tabbar/home.png new file mode 100644 index 0000000..11cba6e Binary files /dev/null and b/assets/tabbar/home.png differ diff --git a/assets/tabbar/plan-active.png b/assets/tabbar/plan-active.png new file mode 100644 index 0000000..0a5173e Binary files /dev/null and b/assets/tabbar/plan-active.png differ diff --git a/assets/tabbar/plan.png b/assets/tabbar/plan.png new file mode 100644 index 0000000..e92068d Binary files /dev/null and b/assets/tabbar/plan.png differ diff --git a/assets/tabbar/profile-active.png b/assets/tabbar/profile-active.png new file mode 100644 index 0000000..8f36b26 Binary files /dev/null and b/assets/tabbar/profile-active.png differ diff --git a/assets/tabbar/profile.png b/assets/tabbar/profile.png new file mode 100644 index 0000000..bcd171b Binary files /dev/null and b/assets/tabbar/profile.png differ diff --git a/pages/checkin/checkin.js b/pages/checkin/checkin.js new file mode 100644 index 0000000..9645cb7 --- /dev/null +++ b/pages/checkin/checkin.js @@ -0,0 +1,211 @@ +const api = require('../../utils/api'); +const auth = require('../../utils/auth'); + +const STATUSES = ['正常', '迟到', '请假', '不参加讨论', '早退']; +const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; +const STATUS_META = { + 正常: '#2E7D32', + 迟到: '#C67B30', + 请假: '#3A7BD5', + 早退: '#8B7A6A', + '不参加讨论': '#C0392B' +}; +const STATUS_ITEMS = STATUSES.map((label) => ({ + label, + color: STATUS_META[label] || '#C67B30' +})); + +function errMessage(err) { + if (err && typeof err === 'object') { + if (err.detail) return String(err.detail); + if (err.error) return String(err.error); + if (err.message) return err.message; + } + return ''; +} + +function pad2(n) { + return n < 10 ? '0' + n : String(n); +} + +function fmtTime(ts) { + if (!ts) return ''; + const d = new Date(String(ts).replace(' ', 'T')); + if (isNaN(d.getTime())) return String(ts); + const now = new Date(); + const startOfDay = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); + const diff = Math.round((startOfDay(now) - startOfDay(d)) / 86400000); + const hm = pad2(d.getHours()) + ':' + pad2(d.getMinutes()); + if (diff === 0) return '今天 ' + hm; + if (diff === 1) return '昨天 ' + hm; + return pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + ' ' + hm; +} + +Page({ + data: { + statuses: STATUSES, + statusItems: STATUS_ITEMS, + statusIndex: -1, + loading: true, + classDate: '', + weekday: '', + dayNum: '', + checked: false, + record: null, + statusColor: '', + statusBg: '', + history: [], + group: [], + groupName: '', + checkedCount: 0, + total: 0, + tab: 'group', + submitting: false, + refreshing: false, + loadError: '', + }, + + onShow() { + this.loadPage(); + }, + + onPullDownRefresh() { + this.loadPage().then(() => wx.stopPullDownRefresh()); + }, + + loadPage() { + this.setData({ loadError: '' }); + return auth.ensureLogin() + .then((token) => { + if (!token) { + wx.switchTab({ url: '/pages/profile/profile' }); + return; + } + return this.loadWeek(); + }) + .catch((err) => { + const msg = (err && (err.error || err.detail || err.message)) || '加载失败,请重试'; + this.setData({ loading: false, loadError: msg }); + wx.showToast({ title: msg, icon: 'none' }); + }); + }, + + onRetry() { + this.loadPage(); + }, + + loadWeek() { + this.setData({ loading: true }); + return api.request({ url: '/api/v1/checkin/week' }).then((res) => { + const classDate = res.class_date || ''; + const weekday = WEEKDAYS[new Date(classDate + 'T00:00:00').getDay()] || ''; + const dayNum = classDate.split('-')[2] || ''; + const statusIndex = res.record && res.record.status + ? Math.max(0, STATUSES.indexOf(res.record.status)) + : -1; + const statusColor = res.record ? (STATUS_META[res.record.status] || '#C67B30') : ''; + this.setData({ + classDate, + weekday, + dayNum, + checked: !!res.checked, + record: res.record || null, + statusIndex, + statusColor, + statusBg: statusColor ? statusColor + '1A' : '' + }); + return this.loadGroup(); + }).then(() => { + this.setData({ loading: false }); + }).catch(() => { + this.setData({ loading: false }); + }); + }, + + loadGroup() { + const data = this.data.classDate ? { class_date: this.data.classDate } : {}; + return api.request({ url: '/api/v1/checkin/group', data }).then((res) => { + const members = (res.members || []).map((m) => ({ + ...m, + shortName: (m.name || '?').charAt(0), + timeText: m.checked && m.record ? fmtTime(m.record.checked_at) : '' + })); + const checkedCount = members.filter((m) => m.checked).length; + this.setData({ + group: members, + groupName: res.group_name || '', + checkedCount, + total: members.length + }); + }).catch(() => {}); + }, + + loadHistory() { + return api.request({ url: '/api/v1/checkin/list', data: { page: 1, size: 20 } }).then((res) => { + const history = (res.list || []).map((item) => { + const color = STATUS_META[item.status] || '#C67B30'; + return { + ...item, + timeText: fmtTime(item.checked_at), + color, + bg: color + '1A' + }; + }); + this.setData({ history }); + }).catch(() => {}); + }, + + onStatusTap(e) { + if (this.data.submitting) { + return; + } + const statusIndex = Number(e.currentTarget.dataset.index); + const status = STATUSES[statusIndex]; + if (!status) { + return; + } + const prevIndex = this.data.statusIndex; + this.setData({ statusIndex, submitting: true }); + api.request({ + url: '/api/v1/checkin', + method: 'POST', + data: { + status, + class_date: this.data.classDate, + course_type: '会员课程' + } + }).then((res) => { + const record = res.record || null; + const statusColor = record ? (STATUS_META[record.status] || '#C67B30') : ''; + this.setData({ submitting: false, checked: true, record, statusColor, statusBg: statusColor ? statusColor + '1A' : '' }); + wx.showToast({ title: '打卡成功', icon: 'success' }); + this.loadGroup(); + if (this.data.tab === 'personal') { + this.loadHistory(); + } + }).catch((err) => { + this.setData({ submitting: false, statusIndex: prevIndex }); + wx.showToast({ title: errMessage(err) || '打卡失败', icon: 'none' }); + }); + }, + + switchTab(e) { + const tab = e.currentTarget.dataset.tab; + if (tab !== 'group' && tab !== 'personal') { + return; + } + this.setData({ tab }); + if (tab === 'personal') { + this.loadHistory(); + } + }, + + + onRefresh() { + this.setData({ refreshing: true }); + const task = this.data.tab === 'group' ? this.loadWeek() : this.loadHistory(); + Promise.resolve(task).then(() => { + this.setData({ refreshing: false }); + }); + } +}); diff --git a/pages/checkin/checkin.json b/pages/checkin/checkin.json new file mode 100644 index 0000000..0f41438 --- /dev/null +++ b/pages/checkin/checkin.json @@ -0,0 +1 @@ +{ "usingComponents": {} } \ No newline at end of file diff --git a/pages/checkin/checkin.wxml b/pages/checkin/checkin.wxml new file mode 100644 index 0000000..a40fa6d --- /dev/null +++ b/pages/checkin/checkin.wxml @@ -0,0 +1,108 @@ + + + + + ! + {{loadError}} + + + + + + + + + + + + + + + + + + + + + + + + + {{weekday}} + {{dayNum}} + + + {{checked ? '已打卡' : '未打卡'}} + {{checked ? '已完成今日打卡' : '请选择状态完成打卡'}} + + + {{record.status}} + + + + + + + + {{item.label}} + + 提交中… + + + + + + + 小组 + 个人 + + + + + {{groupName}} · {{classDate}} + {{checkedCount}}/{{total}} 已打卡 + + + 👥 + 暂无小组,请联系老师分配 + + + 📭 + 本周暂无打卡记录 + + + {{item.shortName}} + + {{item.name}} + {{item.checked ? ('打卡 ' + item.timeText) : ''}} + + {{item.checked ? item.record.status : '未打卡'}} + + + + + 📒 + 暂无个人打卡记录 + + + + {{item.class_date}} + {{item.status}} + + {{item.timeText}} + + + + + + + + diff --git a/pages/checkin/checkin.wxss b/pages/checkin/checkin.wxss new file mode 100644 index 0000000..649a943 --- /dev/null +++ b/pages/checkin/checkin.wxss @@ -0,0 +1,394 @@ +/* ---- error state ---- */ +.error-card { + margin-top: 160rpx; + text-align: center; + padding: 0 40rpx; +} +.error-icon { + width: 96rpx; + height: 96rpx; + line-height: 96rpx; + margin: 0 auto 24rpx; + border-radius: 50%; + background: #fdecea; + color: #c0392b; + font-size: 48rpx; + font-weight: 700; + text-align: center; +} +.error-title { + color: #5A4A3A; + font-size: 28rpx; + margin-bottom: 48rpx; + line-height: 1.6; +} +.btn-retry { + display: inline-block; + min-width: 240rpx; + background: #C67B30; + color: #fff; + border-radius: 12rpx; +} +.btn-hover { opacity: .85; transform: scale(.98); } + +.page { + padding: 32rpx; + padding-bottom: calc(32rpx + env(safe-area-inset-bottom)); + height: 100vh; + display: flex; + flex-direction: column; + box-sizing: border-box; + overflow: hidden; + background: #FDFBF7; +} + +.fade-in { + animation: fadeIn .3s ease; +} +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8rpx); } + to { opacity: 1; transform: none; } +} + +.card { + background: #fff; + border-radius: 16rpx; + padding: 24rpx; + box-shadow: 0 4rpx 16rpx rgba(44, 24, 16, 0.05); +} +.top-card { + flex-shrink: 0; + border: 1rpx solid transparent; + box-shadow: 0 8rpx 24rpx rgba(44, 24, 16, 0.08); +} +.top-card.done-card { + border-color: rgba(46, 125, 50, 0.25); +} + +/* 打卡状态卡 */ +.status-row { + display: flex; + align-items: center; + gap: 20rpx; +} +.date-chip { + width: 124rpx; + flex-shrink: 0; + border-radius: 14rpx; + padding: 16rpx 8rpx; + text-align: center; + background: #2C1810; + color: #FDFBF7; +} +.date-week { + font-size: 22rpx; + opacity: 0.75; +} +.date-num { + font-size: 40rpx; + font-weight: 800; + margin-top: 6rpx; + line-height: 1; +} +.status-main { + flex: 1; + min-width: 0; +} +.status-title { + font-size: 40rpx; + font-weight: 800; +} +.status-title.done { + color: #2E7D32; +} +.status-title.undone { + color: #C67B30; +} +.status-sub { + margin-top: 10rpx; + font-size: 24rpx; + color: #7A6A5C; +} + +/* 状态按钮:两行布局 */ +.pills-grid { + position: relative; + display: flex; + flex-wrap: wrap; + gap: 16rpx; + margin-top: 28rpx; + padding-top: 24rpx; + border-top: 1rpx solid #F2EDE7; +} +.pills-grid.disabled { + opacity: 0.7; +} +.pill { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 1 1 30%; + min-width: 180rpx; + box-sizing: border-box; + margin: 0; + padding: 18rpx 20rpx; + border-radius: 999rpx; + border: 1rpx solid #D8C7B4; + background: #FFF; + color: #5A4A3A; + font-size: 26rpx; + line-height: 1; +} +.pill.active { + background: #C67B30; + border-color: #C67B30; + color: #fff; + box-shadow: 0 6rpx 14rpx rgba(198, 123, 48, 0.22); +} +.pill-hover { + opacity: 0.85; + transform: scale(0.97); +} +.dot { + width: 14rpx; + height: 14rpx; + border-radius: 50%; + margin-right: 12rpx; + flex-shrink: 0; +} +.submitting { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: rgba(253, 251, 247, 0.75); + display: flex; + align-items: center; + justify-content: center; + color: #C67B30; + font-weight: 700; + border-radius: 12rpx; + z-index: 2; +} + +/* 记录区 */ +.record-panel { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + margin-top: 24rpx; +} +.record-scroll { + flex: 1; + min-height: 0; +} +.record-inner { + padding: 4rpx 4rpx 24rpx; +} +.tabs { + display: flex; + border-bottom: 1rpx solid #eee; + margin-bottom: 8rpx; +} +.tab { + flex: 1; + text-align: center; + padding: 22rpx 0; + color: #7A6A5C; + transition: color .15s ease; +} +.tab.active { + color: #C67B30; + font-weight: 700; + border-bottom: 4rpx solid #C67B30; + transform: translateY(-2rpx); +} +.tab-hover { + background: #f6efe6; +} + +/* 小组头部 + 进度 */ +.group-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12rpx 8rpx; +} +.group-title { + color: #C67B30; + font-weight: 700; + font-size: 26rpx; +} +.group-progress { + padding: 6rpx 14rpx; + border-radius: 999rpx; + background: #f6efe6; + color: #C67B30; + font-size: 22rpx; +} + +/* 成员列表 */ +.member { + display: flex; + align-items: center; + padding: 18rpx 8rpx; + border-bottom: 1rpx solid #f2ede7; +} +.member-avatar { + width: 60rpx; + height: 60rpx; + border-radius: 50%; + background: #f6efe6; + color: #C67B30; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.member-avatar.ok { + background: #e8f4eb; + color: #2E7D32; +} +.member-info { + flex: 1; + min-width: 0; + margin-left: 16rpx; +} +.member-name { + font-size: 28rpx; + color: #2C1810; +} +.member.ok .member-name { + color: #2E7D32; +} +.member-meta { + margin-top: 6rpx; + font-size: 22rpx; + color: #8B7A6A; +} +.member-badge { + flex-shrink: 0; + margin-left: 12rpx; + padding: 8rpx 16rpx; + border-radius: 999rpx; + font-size: 22rpx; +} +.member-badge.ok { + background: #e8f4eb; + color: #2E7D32; +} +.member-badge.idle { + background: #f2ede7; + color: #8B7A6A; +} + +/* 历史 */ +.history-item { + background: #fff; + border-radius: 12rpx; + padding: 20rpx; + margin-bottom: 16rpx; + box-shadow: 0 4rpx 16rpx rgba(44, 24, 16, 0.04); +} +.history-head { + display: flex; + align-items: center; + justify-content: space-between; +} +.history-date { + font-size: 28rpx; + font-weight: 700; + color: #2C1810; +} +.history-badge { + padding: 6rpx 14rpx; + border-radius: 999rpx; + font-size: 22rpx; +} +.history-time { + margin-top: 8rpx; + font-size: 22rpx; + color: #999; +} + +/* 空态 */ +.empty { + padding: 64rpx 24rpx; + text-align: center; + color: #B6A996; + font-size: 26rpx; +} +.empty-icon { + width: 96rpx; + height: 96rpx; + line-height: 96rpx; + margin: 0 auto 20rpx; + border-radius: 50%; + background: #f6efe6; + font-size: 48rpx; + text-align: center; +} +.empty-text { + color: #B6A996; +} + +/* 骨架屏 */ +.skeleton-card { + background: #fff; + border-radius: 16rpx; + padding: 28rpx 24rpx; + box-shadow: 0 4rpx 16rpx rgba(44, 24, 16, 0.04); +} +.sk { + background: #F0EBE3; + border-radius: 12rpx; +} +.sk-title { + width: 60%; + height: 40rpx; + margin-bottom: 24rpx; +} +.sk-line { + height: 24rpx; + margin-top: 16rpx; +} +.w60 { + width: 60%; +} +.w90 { + width: 90%; +} +.skeleton-list { + margin-top: 24rpx; +} +.sk-row { + height: 96rpx; + margin-bottom: 16rpx; +} + +/* 顶部当前状态徽标 */ +.hero-status { + display: inline-flex; + align-items: center; + margin-top: 14rpx; + padding: 8rpx 16rpx; + border-radius: 999rpx; + background: #F6F0E7; + font-size: 24rpx; +} +.hero-status-dot { + width: 12rpx; + height: 12rpx; + border-radius: 50%; + margin-right: 10rpx; + flex-shrink: 0; +} +.hero-status-label { + font-weight: 700; +} + + + diff --git a/pages/home/home.js b/pages/home/home.js new file mode 100644 index 0000000..7dc3dc0 --- /dev/null +++ b/pages/home/home.js @@ -0,0 +1,74 @@ +const api = require('../../utils/api'); +const auth = require('../../utils/auth'); + +function errMessage(err) { + if (err && typeof err === 'object') { + if (err.detail) return String(err.detail); + if (err.error) return String(err.error); + if (err.message) return String(err.message); + } + return '加载失败,请稍后再试'; +} + +Page({ + data: { + greeting: '', + displayName: '', + encouragement: '', + modules: [], + loading: true, + error: '', + skeletonItems: [0, 1, 2, 3, 4, 5, 6, 7, 8] + }, + + onShow() { this.loadHome(); }, + + loadHome(silent) { + if (!silent) { + this.setData({ loading: true, error: '' }); + } + return auth.ensureLogin() + .then((token) => { + if (!token) { + // 新用户未绑定手机号,引导去「我的」完成绑定,避免无 token 请求受保护接口导致 401 + this.setData({ loading: false, error: '请先完成微信绑定后再进入' }); + setTimeout(() => wx.switchTab({ url: '/pages/profile/profile' }), 500); + return; + } + return api.request({ url: '/api/v1/pages/home' }); + }) + .then((res) => { + if (!res) return; + this.setData({ + loading: false, + error: '', + greeting: res.greeting || '', + displayName: res.display_name || '', + encouragement: res.encouragement || '', + modules: (res.modules || []).filter((m) => m.visible) + }); + }) + .catch((err) => { + this.setData({ loading: false, error: errMessage(err) }); + }); + }, + + onPullDownRefresh() { + this.loadHome(true).then(() => { + wx.stopPullDownRefresh(); + }); + }, + + onRetry() { + this.loadHome(); + }, + + onModuleTap(e) { + const url = e.currentTarget.dataset.url; + if (!url) return; + const token = auth.getToken(); + const sep = url.indexOf('?') >= 0 ? '&' : '?'; + const target = url + sep + 'token=' + encodeURIComponent(token); + wx.navigateTo({ url: '/pages/webview/webview?url=' + encodeURIComponent(target) }); + } +}); diff --git a/pages/home/home.json b/pages/home/home.json new file mode 100644 index 0000000..09e623a --- /dev/null +++ b/pages/home/home.json @@ -0,0 +1,5 @@ +{ + "usingComponents": {}, + "enablePullDownRefresh": true, + "backgroundTextStyle": "dark" +} \ No newline at end of file diff --git a/pages/home/home.wxml b/pages/home/home.wxml new file mode 100644 index 0000000..15b9a71 --- /dev/null +++ b/pages/home/home.wxml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + {{greeting}} + {{displayName || '欢迎回来'}} + {{encouragement}} + + + + {{error}} + 重试 + + + + + {{item.icon}} + {{item.label}} + {{item.desc}} + + + + 暂无功能入口 + + \ No newline at end of file diff --git a/pages/home/home.wxss b/pages/home/home.wxss new file mode 100644 index 0000000..df47d66 --- /dev/null +++ b/pages/home/home.wxss @@ -0,0 +1,156 @@ +.page { + padding: 32rpx; + min-height: 100vh; + box-sizing: border-box; + background: #FDFBF7; +} + +/* ---- Hero 品牌头图 ---- */ +.hero { + position: relative; + overflow: hidden; + padding: 48rpx 40rpx 56rpx; + border-radius: 24rpx; + background: linear-gradient(135deg, #2C1810 0%, #4A2B1D 55%, #6B3F24 100%); + box-shadow: 0 12rpx 32rpx rgba(44, 24, 16, 0.18); + color: #FDFBF7; +} +.hero-chip { + display: inline-block; + padding: 10rpx 22rpx; + border-radius: 999rpx; + background: rgba(198, 123, 48, 0.18); + color: #E3B37C; + font-size: 22rpx; + margin-bottom: 22rpx; +} +.hero-name { + font-size: 48rpx; + font-weight: 700; + color: #FFFFFF; + letter-spacing: 2rpx; +} +.hero-enc { + margin-top: 14rpx; + font-size: 26rpx; + color: #E8DCCB; + line-height: 1.6; +} +.hero-skeleton { + min-height: 240rpx; + background: linear-gradient(90deg, #EFE6D9 25%, #FAF4EC 50%, #EFE6D9 75%); + background-size: 200% 100%; + animation: shimmer 1.4s ease-in-out infinite; +} + +/* ---- 模块网格 ---- */ +.grid { + display: flex; + flex-wrap: wrap; + gap: 20rpx; + margin-top: 32rpx; +} +.module { + width: 31%; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + background: #FFF; + border-radius: 22rpx; + padding: 30rpx 12rpx 26rpx; + box-shadow: 0 6rpx 24rpx rgba(44, 24, 16, 0.06); + transition: transform 0.18s ease, opacity 0.18s ease; +} +.module-hover { + transform: scale(1.03); + opacity: 0.9; +} +.module-icon { + width: 84rpx; + height: 84rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: #F6EFE6; + font-size: 40rpx; + flex-shrink: 0; +} +.module-label { + margin-top: 16rpx; + max-width: 100%; + font-size: 28rpx; + font-weight: 700; + color: #2C1810; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.module-desc { + margin-top: 8rpx; + max-width: 100%; + font-size: 22rpx; + color: #8B7A6A; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ---- 骨架屏 ---- */ +.module-skeleton { + box-shadow: none; +} +.sk { + background: #EDE3D6; + border-radius: 10rpx; +} +.sk-circle { + width: 84rpx; + height: 84rpx; + border-radius: 50%; + margin-bottom: 16rpx; +} +.sk-line { + height: 24rpx; + margin-top: 12rpx; +} +.w-70 { width: 70%; } +.w-40 { width: 40%; } + +/* ---- 错误 / 空状态 ---- */ +.error-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20rpx; + margin-top: 32rpx; + padding: 22rpx 24rpx; + border-radius: 14rpx; + background: #FDEAEA; + color: #C0392B; + font-size: 24rpx; +} +.error-text { + flex: 1; + min-width: 0; +} +.error-retry { + flex-shrink: 0; + color: #C67B30; + font-weight: 700; + padding: 4rpx 0 4rpx 16rpx; +} +.link-hover { opacity: 0.7; } +.empty { + padding: 80rpx 0; + text-align: center; + color: #B6A996; + font-size: 26rpx; +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} \ No newline at end of file diff --git a/pages/plan-submit/plan-submit.js b/pages/plan-submit/plan-submit.js new file mode 100644 index 0000000..7880aa8 --- /dev/null +++ b/pages/plan-submit/plan-submit.js @@ -0,0 +1,351 @@ +const api = require('../../utils/api'); +const auth = require('../../utils/auth'); + +const MAX_IMAGES = 4; +const MAX_IMAGE_SIZE = 1024 * 1024; // 超过 1MB 才压缩 +const COMPRESS_LEVELS = [80, 60, 45]; // 逐级降低质量,直到满足大小限制 +const ALLOWED_IMAGE_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']; +const IMAGE_PLACEHOLDER_URL = 'https://picsum.photos/200/200'; +const TYPE_LABELS = { weekly: '周计划', monthly: '月计划', yearly: '年计划' }; + +function errDetail(err) { + if (err && err.detail) return String(err.detail); + if (err && err.error) return String(err.error); + if (err && err.message) return String(err.message); + return ''; +} + +function getFileSize(filePath) { + return new Promise((resolve) => { + wx.getFileInfo({ + filePath, + success: (res) => resolve(Number(res.size) || 0), + fail: () => resolve(0) + }); + }); +} + +function hasImageExt(path) { + if (!path) return false; + const ext = (String(path).split('.').pop() || '').toLowerCase(); + return !!ext && ALLOWED_IMAGE_EXT.indexOf(ext) !== -1; +} + +Page({ + data: { + planType: 'weekly', + typeLabel: '周计划', + periodKey: '', + groupName: '', + loading: true, + loadError: '', + canSubmit: true, + message: '', + content: '', + checked: false, + record: null, + attachments: [], + attachmentThumbs: [], + uploading: false, + submitting: false, + history: [] + }, + + onLoad(options) { + const planType = options.plan_type || 'weekly'; + this.setData({ + planType, + typeLabel: TYPE_LABELS[planType] || '计划', + periodKey: options.period_key ? decodeURIComponent(options.period_key) : '', + groupName: options.group_name ? decodeURIComponent(options.group_name) : '' + }); + auth.ensureLogin().then((token) => { + if (!token) { + this.setData({ loading: false, loadError: '请先在「我的」页面绑定手机号' }); + setTimeout(() => wx.switchTab({ url: '/pages/profile/profile' }), 600); + return; + } + this.loadStatus(); + }).catch(() => { + this.setData({ loading: false, loadError: '登录失败,请重试' }); + }); + }, + + onRetry() { + this.setData({ loading: true, loadError: '' }); + auth.ensureLogin().then((token) => { + if (!token) { + this.setData({ loading: false, loadError: '请先在「我的」页面绑定手机号' }); + setTimeout(() => wx.switchTab({ url: '/pages/profile/profile' }), 600); + return; + } + this.loadStatus(); + }).catch(() => { + this.setData({ loading: false, loadError: '登录失败,请重试' }); + }); + }, + + loadStatus() { + api.request({ + url: '/api/v1/plan/status', + data: { type: this.data.planType, period_key: this.data.periodKey } + }).then((res) => { + this.applyRecord(res); + if (this.data.planType !== 'weekly') { + this.loadHistory(); + } + }).catch((err) => { + this.setData({ loading: false, loadError: errDetail(err) || '加载计划失败' }); + }); + }, + + applyRecord(res) { + const record = res.record; + const attachments = record ? (record.attachments || []) : []; + this.setData({ + loading: false, + loadError: '', + periodKey: res.period_key || this.data.periodKey, + canSubmit: res.can_submit !== false, + message: res.message || '', + checked: !!res.checked, + record, + content: record ? (record.content || '') : '', + attachments, + attachmentThumbs: attachments.map((p) => this.planFileUrl(p)) + }); + }, + + loadHistory() { + api.request({ url: '/api/v1/plan/history', data: { type: this.data.planType, page: 1, size: 20 } }) + .then((res) => { + const history = (res.list || []).map((item) => ({ + ...item, + thumbSrcs: (item.attachments || []).map((p) => this.planFileUrl(p)) + })); + this.setData({ history }); + }).catch(() => {}); + }, + + onContentInput(e) { + this.setData({ content: e.detail.value }); + }, + + planFileUrl(path) { + if (!path) return ''; + if (/^https?:\/\//i.test(path)) return path; + const parts = String(path).replace(/^attachments\//, '').split('/'); + if (parts.length < 4) { + return IMAGE_PLACEHOLDER_URL; + } + return api.STATIC_BASE_URL + '/clock/static/plan/' + parts.slice(0, 4).map(encodeURIComponent).join('/'); + }, + + chooseImages() { + const remain = MAX_IMAGES - this.data.attachments.length; + if (remain <= 0) { + wx.showToast({ title: '最多上传4张图片', icon: 'none' }); + return; + } + if (!this.data.canSubmit || !this.data.periodKey) { + wx.showToast({ title: '当前不可上传', icon: 'none' }); + return; + } + wx.chooseMedia({ + count: remain, + mediaType: ['image'], + sourceType: ['album', 'camera'], + success: (res) => { + const paths = (res.tempFiles || []).map((f) => f.tempFilePath); + this.uploadImages(paths); + } + }); + }, + + uploadImages(paths) { + if (this.data.uploading || !paths.length) { + return; + } + const planType = this.data.planType; + const periodKey = this.data.periodKey; + let attachments = this.data.attachments.slice(); + this.setData({ uploading: true }); + const run = (index) => { + if (index >= paths.length) { + this.setData({ + uploading: false, + attachmentThumbs: attachments.map((p) => this.planFileUrl(p)) + }); + return; + } + const rawPath = paths[index]; + const doUpload = (src) => { + wx.uploadFile({ + url: api.BASE_URL + '/api/v1/plan/upload', + filePath: src, + name: 'files', + formData: { plan_type: planType, period_key: periodKey }, + header: { Authorization: 'Bearer ' + auth.getToken() }, + success: (res) => { + let data = {}; + try { + data = JSON.parse(res.data); + } catch (e) { + data = {}; + } + if (data.success && Array.isArray(data.attachments)) { + attachments = attachments.concat(data.attachments).slice(0, MAX_IMAGES); + this.setData({ + attachments, + attachmentThumbs: attachments.map((p) => this.planFileUrl(p)) + }); + } else { + wx.showToast({ title: data.error || '上传失败', icon: 'none' }); + } + run(index + 1); + }, + fail: () => { + wx.showToast({ title: '上传失败', icon: 'none' }); + run(index + 1); + } + }); + }; + // 按大小限制压缩:小图直接上传,大图逐级降质量直到 ≤ MAX_IMAGE_SIZE + const tryCompress = (src, level) => { + getFileSize(src).then((size) => { + if (!size || size <= MAX_IMAGE_SIZE || level >= COMPRESS_LEVELS.length) { + doUpload(src); + return; + } + wx.compressImage({ + src, + quality: COMPRESS_LEVELS[level], + success: (res) => { + const compressed = res.tempFilePath || src; + if (!hasImageExt(compressed)) { + // 压缩产物扩展名丢失时回退当前图,避免后端按扩展名校验失败 + doUpload(src); + return; + } + tryCompress(compressed, level + 1); + }, + fail: () => doUpload(src) + }); + }); + }; + tryCompress(rawPath, 0); + }; + run(0); + }, + + removeImage(e) { + if (this.data.uploading) { + return; + } + const index = Number(e.currentTarget.dataset.index); + const path = this.data.attachments[index]; + if (!path) { + return; + } + const parts = path.replace(/^attachments\//, '').split('/'); + const dropLocal = () => { + const attachments = this.data.attachments.slice(); + attachments.splice(index, 1); + this.setData({ + attachments, + attachmentThumbs: attachments.map((p) => this.planFileUrl(p)) + }); + }; + if (parts.length < 4) { + dropLocal(); + return; + } + const sid = parts[0]; + const planType = parts[1]; + const period = parts[2]; + const filename = parts[3]; + api.request({ + url: `/api/v1/plan/file/${sid}/${planType}/${period}/${encodeURIComponent(filename)}`, + method: 'DELETE' + }).then(dropLocal).catch(() => { + wx.showToast({ title: '删除失败', icon: 'none' }); + }); + }, + + previewImage(e) { + const index = Number(e.currentTarget.dataset.index); + const urls = this.data.attachmentThumbs; + const src = urls[index]; + if (!src) { + return; + } + wx.previewImage({ current: src, urls }); + }, + + previewHistoryImage(e) { + const historyId = e.currentTarget.dataset.id; + const index = Number(e.currentTarget.dataset.index); + const item = this.data.history.find((h) => String(h.id) === String(historyId)); + if (!item) { + return; + } + const urls = item.thumbSrcs || []; + const src = urls[index]; + if (!src) { + return; + } + wx.previewImage({ current: src, urls }); + }, + + submit() { + if (this.data.submitting) { + return; + } + if (!this.data.canSubmit) { + wx.showToast({ title: this.data.message || '当前不可提交', icon: 'none' }); + return; + } + const hasText = !!(this.data.content || '').trim(); + const hasImages = !!this.data.attachments.length; + if (!hasText && !hasImages) { + wx.showToast({ title: '请填写内容或上传图片', icon: 'none' }); + return; + } + const doPost = () => { + this.setData({ submitting: true }); + api.request({ + url: '/api/v1/plan/checkin', + method: 'POST', + data: { + plan_type: this.data.planType, + period_key: this.data.periodKey, + status: '已完成', + content: this.data.content, + attachments: this.data.attachments + } + }).then(() => { + this.setData({ submitting: false }); + wx.showToast({ title: '已保存', icon: 'success' }); + wx.setStorageSync('plan_overview_dirty', true); + setTimeout(() => wx.navigateBack(), 600); + }).catch((err) => { + this.setData({ submitting: false }); + const message = errDetail(err) || '保存失败'; + wx.showToast({ title: message, icon: 'none' }); + }); + }; + if (this.data.checked || this.data.record) { + wx.showModal({ + title: '覆盖确认', + content: '将覆盖本周期已提交的计划,确定保存?', + success: (r) => { + if (r.confirm) { + doPost(); + } + } + }); + } else { + doPost(); + } + } +}); diff --git a/pages/plan-submit/plan-submit.json b/pages/plan-submit/plan-submit.json new file mode 100644 index 0000000..a97367d --- /dev/null +++ b/pages/plan-submit/plan-submit.json @@ -0,0 +1,3 @@ +{ + "usingComponents": {} +} diff --git a/pages/plan-submit/plan-submit.wxml b/pages/plan-submit/plan-submit.wxml new file mode 100644 index 0000000..049b029 --- /dev/null +++ b/pages/plan-submit/plan-submit.wxml @@ -0,0 +1,95 @@ + + + + + {{typeLabel}} · {{groupName}} · {{periodKey}} + {{message}} + + + + + + + + + + + + + + + + + + + + ! + {{loadError}} + + + + + + {{message || '当前周期不可提交,仅可查看'}} + + + + {{content.length}}/1000 + + + + + + + × + + + + + + 保存将覆盖本周期已提交的计划 + + + + 历史计划 + + {{his.period_key}} · {{his.status}} + {{his.content}} + + + + + 暂无历史计划 + + + + diff --git a/pages/plan-submit/plan-submit.wxss b/pages/plan-submit/plan-submit.wxss new file mode 100644 index 0000000..74dccd9 --- /dev/null +++ b/pages/plan-submit/plan-submit.wxss @@ -0,0 +1,56 @@ +.page { height: 100vh; display: flex; flex-direction: column; box-sizing: border-box; overflow: hidden; background: #FDFBF7; } +.head { flex-shrink: 0; padding: 24rpx 32rpx 8rpx; } +.head-title { font-size: 34rpx; font-weight: 700; color: #2C1810; } +.head-sub { margin-top: 6rpx; font-size: 24rpx; color: #c0392b; } + +.form-scroll { flex: 1; min-height: 0; padding: 8rpx 32rpx 24rpx; box-sizing: border-box; } +.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; box-shadow: 0 4rpx 16rpx rgba(44, 24, 16, 0.04); } + +/* shared feedback */ +.btn-hover { opacity: .85; transform: scale(.98); } + +/* skeleton */ +.skeleton-card { padding: 32rpx; } +.sk { background: #EDE3D6; border-radius: 10rpx; } +.sk-line { height: 28rpx; margin-bottom: 20rpx; } +.sk-block { height: 120rpx; background: #F2EAE0; border-radius: 12rpx; margin-top: 10rpx; } +.w-90 { width: 90%; } +.w-80 { width: 80%; } +.w-100 { width: 100%; } +.w-60 { width: 60%; } + +/* error state */ +.error-card { padding: 120rpx 40rpx; text-align: center; } +.error-icon { width: 96rpx; height: 96rpx; line-height: 96rpx; margin: 0 auto 24rpx; border-radius: 50%; background: #fdecea; color: #c0392b; font-size: 48rpx; font-weight: 700; } +.error-text { color: #5A4A3A; font-size: 28rpx; margin-bottom: 48rpx; } +.btn-retry { display: inline-block; min-width: 240rpx; background: #C67B30; color: #fff; border-radius: 12rpx; } + +.readonly-tip { margin: 8rpx 0 16rpx; padding: 14rpx 20rpx; background: #fff3e6; border-radius: 10rpx; color: #c0392b; font-size: 26rpx; text-align: center; } + +.content { width: 100%; min-height: 200rpx; box-sizing: border-box; padding: 16rpx; border: 1rpx solid #e4d9cb; border-radius: 12rpx; background: #FDFBF7; color: #2C1810; font-size: 28rpx; } +.content[disabled], .content.readonly { background: #f6f1ea; } +.char-count { margin-top: 8rpx; text-align: right; font-size: 22rpx; color: #B6A996; } + +.upload-section { margin-top: 20rpx; } +.upload-btn { background: #FDFBF7; color: #C67B30; border: 2rpx dashed #C67B30; border-radius: 12rpx; font-size: 26rpx; } +.upload-btn[disabled] { color: #d8c7b4; border-color: #d8c7b4; background: #FDFBF7; } +.thumb-grid { display: flex; flex-wrap: wrap; gap: 14rpx; margin-top: 18rpx; } +.thumb-item { position: relative; width: 156rpx; height: 156rpx; } +.thumb { width: 100%; height: 100%; border-radius: 12rpx; background: #f6efe6; } +.thumb-del { position: absolute; top: -12rpx; right: -12rpx; width: 44rpx; height: 44rpx; line-height: 40rpx; text-align: center; border-radius: 50%; background: #c0392b; color: #fff; font-size: 30rpx; } + +.btn-primary { background: #C67B30; color: #fff; border-radius: 12rpx; } +.btn-save { margin-top: 24rpx; } +.btn-primary[disabled] { background: #d8c7b4; color: #fff; } +.save-hint { margin-top: 12rpx; text-align: center; font-size: 22rpx; color: #B6A996; } + +.section-title { margin: 4rpx 8rpx 14rpx; font-size: 28rpx; font-weight: 700; color: #2C1810; } +.history-card { display: flex; flex-direction: column; gap: 10rpx; } +.history-head { font-size: 28rpx; font-weight: 700; color: #2C1810; } +.history-head .pending { color: #c0392b; } +.content-text { color: #5A4A3A; font-size: 26rpx; } +.history-thumbs { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 8rpx; } +.history-thumb { width: 148rpx; height: 148rpx; border-radius: 12rpx; background: #f6efe6; } + +.state-block { padding: 48rpx 0; text-align: center; color: #B6A996; font-size: 26rpx; } +.empty { padding: 48rpx 0; text-align: center; color: #B6A996; font-size: 26rpx; } diff --git a/pages/plan/plan.js b/pages/plan/plan.js new file mode 100644 index 0000000..606b28a --- /dev/null +++ b/pages/plan/plan.js @@ -0,0 +1,145 @@ +const api = require('../../utils/api'); +const auth = require('../../utils/auth'); + +const TYPES = ['weekly', 'monthly', 'yearly']; +const TYPE_LABELS = ['周计划', '月计划', '年计划']; + +Page({ + data: { + types: TYPE_LABELS, + typeIndex: 0, + view: 'overview', // overview | detail + loading: false, + refreshing: false, + periodKey: '', + canSubmit: false, + message: '', + myChecked: false, + myStatus: '', + myRecord: null, + myThumbs: [], + unassignedCount: 0, + groups: [], + selectedGroupName: '', + members: [] + }, + + onShow() { + auth.ensureLogin().then((token) => { + if (!token) { + wx.switchTab({ url: '/pages/profile/profile' }); + return; + } + wx.removeStorageSync('plan_overview_dirty'); + this.setData({ view: 'overview' }); + this.loadOverview(); + }).catch(() => {}); + }, + + onTypeChange(e) { + const typeIndex = Number(e.currentTarget.dataset.index); + if (typeIndex === this.data.typeIndex) { + return; + } + this.setData({ typeIndex, view: 'overview', selectedGroupName: '', members: [] }); + this.loadOverview(); + }, + + onRefresh() { + this.setData({ refreshing: true }); + this.loadOverview(); + }, + + loadOverview() { + const type = TYPES[this.data.typeIndex]; + this.setData({ loading: true }); + api.request({ url: '/api/v1/plan/groups', data: { type } }) + .then((res) => { + const groups = (res.groups || []).map((g) => ({ + ...g, + percent: g.total ? Math.round(((g.checked_count || 0) / g.total) * 100) : 0 + })); + this.setData({ + loading: false, + refreshing: false, + periodKey: res.period_key || '', + canSubmit: !!res.can_submit, + message: res.message || '', + myChecked: !!res.my_checked, + myStatus: res.my_status || '', + myRecord: res.my_record || null, + myThumbs: ((res.my_record && res.my_record.attachments) || []).map((p) => this.planFileUrl(p)), + unassignedCount: res.unassigned_count || 0, + groups + }); + }) + .catch(() => { + this.setData({ loading: false, refreshing: false }); + wx.showToast({ title: '加载小组失败', icon: 'none' }); + }); + }, + + openGroup(e) { + const groupName = e.currentTarget.dataset.group; + if (!groupName) { + return; + } + this.setData({ selectedGroupName: groupName, view: 'detail', members: [] }); + this.loadDetail(); + }, + + loadDetail() { + const type = TYPES[this.data.typeIndex]; + this.setData({ loading: true }); + api.request({ + url: '/api/v1/plan/group', + data: { type, group_name: this.data.selectedGroupName, period_key: this.data.periodKey } + }).then((res) => { + this.setData({ + loading: false, + periodKey: res.period_key || this.data.periodKey, + canSubmit: !!res.can_submit, + message: res.message || '', + members: res.members || [] + }); + }).catch(() => { + this.setData({ loading: false }); + wx.showToast({ title: '加载成员失败', icon: 'none' }); + }); + }, + + backToOverview() { + this.setData({ view: 'overview', selectedGroupName: '', members: [] }); + this.loadOverview(); + }, + + planFileUrl(path) { + if (!path) return ''; + if (/^https?:\/\//i.test(path)) return path; + const parts = String(path).replace(/^attachments\//, '').split('/'); + if (parts.length < 4) return ''; + return api.STATIC_BASE_URL + '/clock/static/plan/' + parts.slice(0, 4).map(encodeURIComponent).join('/'); + }, + + previewMyImage(e) { + const index = Number(e.currentTarget.dataset.index); + const urls = this.data.myThumbs; + const src = urls[index]; + if (!src) return; + wx.previewImage({ current: src, urls }); + }, + + goSubmit() { + const type = TYPES[this.data.typeIndex]; + if (type === 'weekly' && !this.data.canSubmit) { + wx.showToast({ title: this.data.message || '当前不可打卡', icon: 'none' }); + return; + } + const params = [ + 'plan_type=' + type, + 'period_key=' + encodeURIComponent(this.data.periodKey), + 'group_name=' + ].join('&'); + wx.navigateTo({ url: '/pages/plan-submit/plan-submit?' + params }); + } +}); diff --git a/pages/plan/plan.json b/pages/plan/plan.json new file mode 100644 index 0000000..0f41438 --- /dev/null +++ b/pages/plan/plan.json @@ -0,0 +1 @@ +{ "usingComponents": {} } \ No newline at end of file diff --git a/pages/plan/plan.wxml b/pages/plan/plan.wxml new file mode 100644 index 0000000..694a1f9 --- /dev/null +++ b/pages/plan/plan.wxml @@ -0,0 +1,116 @@ + + + + + {{item}} + + + + + + {{types[typeIndex]}} + {{periodKey}} + + {{myChecked ? '已提交' : '待提交'}} + + + + {{myChecked ? '✓' : '●'}} + + {{myChecked ? '已完成' : '未完成'}} + 我的{{types[typeIndex]}} + + + + + + + 我上传的计划 + {{myRecord.status}} + + {{myRecord.checked_at}} 提交 + {{myRecord.content}} + + + + + + {{message}} + + + ◌ + 加载中... + + + + + + {{index + 1}} + {{group.group_name}} + + 已打卡 {{group.checked_count}} + {{group.total}}人 + + › + + + + + + + + 另有未分组 {{unassignedCount}} 人 + 暂无小组 + + + + + + + + ‹ 返回 + + {{selectedGroupName}} + {{periodKey}} + + + + + ◌ + 加载中... + + + + + {{index + 1}} + + {{member.name}} + {{member.record.checked_at}} 提交 + + + {{member.filled ? '已完成' : '未完成'}} + + + 暂无成员 + + + + \ No newline at end of file diff --git a/pages/plan/plan.wxss b/pages/plan/plan.wxss new file mode 100644 index 0000000..0bebaf0 --- /dev/null +++ b/pages/plan/plan.wxss @@ -0,0 +1,80 @@ +.page { height: 100vh; display: flex; flex-direction: column; box-sizing: border-box; overflow: hidden; background: #F6F2EC; } +.view-root { flex: 1; min-height: 0; display: flex; flex-direction: column; } + +.type-tabs { flex-shrink: 0; display: flex; align-items: center; gap: 8rpx; margin: 24rpx 32rpx 0; padding: 8rpx; background: #EFE7DC; border-radius: 999rpx; } +.type-tab { flex: 1; text-align: center; padding: 16rpx 0; color: #8B7A6A; background: transparent; border-radius: 999rpx; font-size: 28rpx; font-weight: 600; transition: all .18s ease; } +.type-tab.active { background: #fff; color: #C67B30; font-weight: 700; box-shadow: 0 4rpx 12rpx rgba(198,123,48,0.18); } +.type-tab-hover { opacity: .85; } + +.view-scroll { flex: 1; min-height: 0; padding: 8rpx 32rpx 32rpx; box-sizing: border-box; } +.overview-head { display: flex; align-items: flex-end; justify-content: space-between; padding: 24rpx 4rpx 16rpx; } +.head-title-wrap { min-width: 0; } +.head-title { font-size: 38rpx; font-weight: 800; color: #2C1810; letter-spacing: 1rpx; } +.head-sub { margin-top: 6rpx; font-size: 24rpx; color: #A99A89; } +.head-pill { flex-shrink: 0; padding: 8rpx 20rpx; border-radius: 999rpx; font-size: 22rpx; background: #fff; color: #C67B30; box-shadow: 0 2rpx 8rpx rgba(44,24,16,0.06); } + +.detail-head { display: flex; align-items: center; gap: 16rpx; padding: 28rpx 4rpx 8rpx; } +.detail-title-wrap { min-width: 0; } +.back { color: #C67B30; font-size: 30rpx; flex-shrink: 0; padding: 10rpx 16rpx 10rpx 0; border-radius: 12rpx; font-weight: 600; } +.back-hover { opacity: .7; } + +.my-status { display: flex; align-items: center; gap: 18rpx; padding: 24rpx; margin-bottom: 20rpx; border-radius: 20rpx; background: #fff; box-shadow: 0 6rpx 20rpx rgba(44,24,16,0.05); } +.my-status.checked { border-left: 8rpx solid #2E7D32; } +.my-status.unchecked { border-left: 8rpx solid #C0392B; } +.my-state-icon { width: 64rpx; height: 64rpx; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 30rpx; font-weight: 800; flex-shrink: 0; } +.my-state-icon.done { background: linear-gradient(135deg, #2E7D32, #43A047); color: #fff; box-shadow: 0 4rpx 12rpx rgba(46,125,50,0.25); } +.my-state-icon.todo { background: linear-gradient(135deg, #C0392B, #E5736C); color: #fff; box-shadow: 0 4rpx 12rpx rgba(192,57,43,0.22); } +.my-status-left { flex: 1; min-width: 0; } +.my-status-label { font-size: 24rpx; color: #8B7A6A; } +.my-status-text { font-size: 32rpx; font-weight: 800; color: #2C1810; margin-top: 6rpx; } +.btn-mystatus { width: 208rpx; margin: 0; flex-shrink: 0; background: linear-gradient(135deg, #C67B30, #E4A05E); color: #fff; border-radius: 999rpx; font-size: 28rpx; font-weight: 700; box-shadow: 0 6rpx 14rpx rgba(198,123,48,0.3); } +.btn-mystatus.outline { background: #fff; color: #C67B30; border: 2rpx solid #C67B30; box-shadow: none; } +.btn-mystatus[disabled] { background: #D8C7B4; color: #fff; border: none; box-shadow: none; } +.btn-hover { opacity: .9; transform: scale(.98); } + +.window-msg { margin: 0 8rpx 20rpx; padding: 14rpx 22rpx; background: #FFF3E6; border-radius: 14rpx; text-align: center; color: #C0392B; font-size: 24rpx; border: 1rpx solid #F7D9B9; } + +.my-plan { margin-bottom: 20rpx; padding: 24rpx; border-radius: 20rpx; background: #fff; box-shadow: 0 6rpx 20rpx rgba(44,24,16,0.05); } +.my-plan-head { display: flex; align-items: center; justify-content: space-between; } +.my-plan-title { font-size: 26rpx; font-weight: 700; color: #6B5D50; } +.my-plan-status { padding: 6rpx 16rpx; border-radius: 999rpx; font-size: 22rpx; font-weight: 700; background: #E8F4EB; color: #2E7D32; } +.my-plan-status.pending { background: #FFF3E6; color: #C67B30; } +.my-plan-meta { margin-top: 8rpx; font-size: 22rpx; color: #A99A89; } +.my-plan-content { margin-top: 14rpx; padding: 16rpx 20rpx; background: #FBF7F1; border-radius: 14rpx; font-size: 28rpx; line-height: 1.7; color: #2C1810; white-space: pre-wrap; word-break: break-all; } +.my-plan-thumbs { display: flex; flex-wrap: wrap; gap: 14rpx; margin-top: 16rpx; } +.my-plan-thumb { width: 148rpx; height: 148rpx; border-radius: 14rpx; background: #F2EDE7; box-shadow: 0 2rpx 6rpx rgba(44,24,16,0.08); } + +.group-card { background: #fff; border-radius: 20rpx; padding: 22rpx 24rpx; margin-bottom: 18rpx; box-shadow: 0 6rpx 20rpx rgba(44,24,16,0.05); } +.group-card-hover { transform: scale(.99); box-shadow: 0 10rpx 24rpx rgba(44,24,16,0.10); } +.group-row { display: flex; align-items: center; gap: 16rpx; } +.group-avatar { width: 52rpx; height: 52rpx; border-radius: 14rpx; background: linear-gradient(135deg, #F3E3CF, #FBE7D1); color: #C67B30; font-size: 26rpx; font-weight: 800; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } +.group-name { font-size: 30rpx; font-weight: 800; color: #2C1810; flex-shrink: 0; } +.chevron { color: #CDBFAE; font-size: 32rpx; line-height: 1; flex-shrink: 0; transition: transform .15s ease; } +.group-card-hover .chevron { transform: translateX(4rpx); } +.group-counts { display: flex; align-items: center; justify-content: flex-end; gap: 10rpx; flex: 1; min-width: 0; } +.count { font-size: 22rpx; white-space: nowrap; } +.count.done { color: #2E7D32; background: #E8F4EB; padding: 6rpx 12rpx; border-radius: 999rpx; font-weight: 700; } +.count.total { color: #C67B30; background: #FFF3E6; padding: 6rpx 12rpx; border-radius: 999rpx; font-weight: 700; } +.progress-track { height: 10rpx; border-radius: 999rpx; background: #F2EDE7; margin-top: 18rpx; overflow: hidden; } +.progress-bar { height: 100%; border-radius: 999rpx; background: linear-gradient(90deg, #C67B30, #EFA45D); transition: width .35s ease; box-shadow: 0 2rpx 6rpx rgba(198,123,48,0.35); } + +.unassigned { margin: 4rpx 8rpx 20rpx; font-size: 24rpx; color: #8B7A6A; } + +.member-list { background: #fff; border-radius: 20rpx; padding: 8rpx 24rpx; box-shadow: 0 6rpx 20rpx rgba(44,24,16,0.05); } +.member-left { display: flex; align-items: center; min-width: 0; } +.member { display: flex; align-items: center; justify-content: space-between; padding: 22rpx 0; border-bottom: 1rpx solid #F2EDE7; } +.member:last-child { border-bottom: none; } +.member-avatar { width: 64rpx; height: 64rpx; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 26rpx; font-weight: 800; flex-shrink: 0; } +.member-avatar.done { background: linear-gradient(135deg, #E8F4EB, #CFEAD5); color: #2E7D32; } +.member-avatar.unchecked { background: linear-gradient(135deg, #FDECEA, #F9D9D5); color: #C0392B; } +.member-info { margin-left: 18rpx; min-width: 0; } +.member-name { font-size: 28rpx; font-weight: 700; color: #2C1810; } +.member-meta { margin-top: 6rpx; font-size: 22rpx; color: #A99A89; } +.badge { flex-shrink: 0; padding: 8rpx 18rpx; border-radius: 999rpx; font-size: 22rpx; font-weight: 700; } +.badge.done { background: #E8F4EB; color: #2E7D32; } +.badge.unchecked { background: #FDECEA; color: #C0392B; } + +.state-block { padding: 64rpx 0; text-align: center; color: #A99A89; font-size: 26rpx; } +.state-icon { font-size: 40rpx; margin-bottom: 12rpx; color: #D8C7B4; animation: pulse 1.2s ease-in-out infinite; } +@keyframes pulse { 0%,100%{opacity:.4} 50%{opacity:1} } +.empty { padding: 64rpx 0; text-align: center; color: #A99A89; font-size: 26rpx; } \ No newline at end of file diff --git a/pages/profile/profile.js b/pages/profile/profile.js new file mode 100644 index 0000000..83b9d5c --- /dev/null +++ b/pages/profile/profile.js @@ -0,0 +1,224 @@ +const api = require('../../utils/api'); +const auth = require('../../utils/auth'); + +const ROLE_LABELS = { + student: '学员', + member: '成员', + teacher: '老师', + leader: '组长', + 'group_leader': '组长', + admin: '管理员' +}; +const LOGIN_TYPE_LABELS = { + wechat: '微信', + phone: '手机号', + admin: '管理员' +}; + +Page({ + data: { + view: 'loading', + profile: null, + phoneLoading: false, + phoneError: '', + loadError: '', + avatarSrc: '', + avatarFallback: '明', + roleLabel: '', + loginTypeLabel: '', + classGroups: [], + avatarUploading: false + }, + + onShow() { + this.setData({ phoneError: '' }); + this.loadPage(); + }, + + onPullDownRefresh() { + this.refresh().then(() => wx.stopPullDownRefresh()); + }, + + refresh() { + if (this.data.view !== 'profile') { + return this.loadPage(); + } + return new Promise((resolve) => { + api.request({ url: '/api/v1/profile' }) + .then((res) => { + this.applyProfile(res); + resolve(); + }) + .catch(() => { + wx.showToast({ title: '刷新失败', icon: 'none' }); + resolve(); + }); + }); + }, + + loadPage() { + this.setData({ loadError: '' }); + return auth.ensureLogin() + .then((token) => { + if (!token) { + this.showPhoneLogin(); + return null; + } + this.setData({ view: 'loading' }); + return this.loadProfile(); + }) + .catch(() => { + this.showPhoneLogin(); + }); + }, + + loadProfile() { + return api.request({ url: '/api/v1/profile' }) + .then((res) => { + this.applyProfile(res); + }) + .catch((err) => { + if (!auth.getToken()) { + this.showPhoneLogin(); + } else { + this.setData({ + view: 'loadError', + loadError: (err && err.error) || '加载失败' + }); + } + }); + }, + + applyProfile(res) { + const displayName = res.display_name || res.name || ''; + const classGroups = (res.class_groups || []).map((g) => ({ + class_id: g.class_id, + class_code: g.class_code, + period: g.period, + group: g.group, + role: this.roleLabel(g.role) + })); + this.setData({ + view: 'profile', + profile: res, + phoneError: '', + loadError: '', + avatarSrc: this.fullUrl(res.wechat_avatar), + avatarFallback: this.firstChar(displayName), + roleLabel: this.roleLabel(res.role), + loginTypeLabel: this.loginTypeLabel(res.login_type), + classGroups + }); + }, + + showPhoneLogin() { + this.setData({ + view: 'phoneLogin', + phoneLoading: false + }); + }, + + roleLabel(role) { + return ROLE_LABELS[role] || role || '学员'; + }, + + loginTypeLabel(type) { + return LOGIN_TYPE_LABELS[type] || type || '微信'; + }, + + fullUrl(rel) { + if (!rel) return ''; + return /^https?:\/\//i.test(rel) ? rel : api.STATIC_BASE_URL + rel; + }, + + firstChar(str) { + const t = (str && typeof str === 'string') ? str.trim() : ''; + return t ? t.charAt(0) : '明'; + }, + + onChooseAvatar(e) { + const path = e.detail && e.detail.avatarUrl; + console.log('[avatar] chooseAvatar ->', path); + if (!path) { + wx.showToast({ title: '未选择头像', icon: 'none' }); + return; + } + this.setData({ avatarUploading: true }); + wx.uploadFile({ + url: api.BASE_URL + '/api/v1/profile/avatar', + filePath: path, + name: 'file', + header: { Authorization: 'Bearer ' + auth.getToken() }, + success: (res) => { + console.log('[avatar] upload success', res.statusCode, res.data); + let data = {}; + try { + data = JSON.parse(res.data); + } catch (e) { + data = {}; + } + if (data.success && data.wechat_avatar) { + const newAvatar = data.wechat_avatar; + const newUrl = this.fullUrl(newAvatar); + this.setData({ + 'profile.wechat_avatar': newAvatar, + avatarSrc: newUrl + }); + wx.showToast({ title: '头像已更新', icon: 'success' }); + } else { + wx.showToast({ title: data.error || '上传失败', icon: 'none' }); + } + }, + fail: (err) => { + console.error('[avatar] upload fail', err); + console.error('[avatar] filePath', path); + wx.showToast({ title: '上传失败', icon: 'none' }); + }, + complete: () => { + this.setData({ avatarUploading: false }); + } + }); + }, + + onGetPhoneNumber(e) { + if (this.data.phoneLoading) { + return; + } + const phoneCode = e.detail && e.detail.code; + if (!phoneCode) { + const message = '未授权手机号,请点击上方按钮重试'; + this.setData({ phoneError: message }); + wx.showToast({ title: message, icon: 'none' }); + return; + } + this.setData({ phoneLoading: true, phoneError: '' }); + auth.loginWithPhone(phoneCode) + .then(() => this.loadProfile()) + .catch((err) => { + const message = (err && err.error) || '手机号登录失败'; + this.setData({ phoneLoading: false, phoneError: message }); + wx.showToast({ title: message, icon: 'none', duration: 2500 }); + }); + }, + + onRetry() { + this.loadPage(); + }, + + logout() { + wx.showModal({ + title: '退出登录', + content: '确定要退出当前账号吗?', + confirmText: '退出', + confirmColor: '#c0392b', + success: (res) => { + if (!res.confirm) { + return; + } + api.request({ url: '/api/v1/auth/logout', method: 'POST' }).catch(() => {}); + auth.clearToken(); + wx.reLaunch({ url: '/pages/home/home' }); + } + }); + } +}); \ No newline at end of file diff --git a/pages/profile/profile.json b/pages/profile/profile.json new file mode 100644 index 0000000..287657f --- /dev/null +++ b/pages/profile/profile.json @@ -0,0 +1,5 @@ +{ + "usingComponents": {}, + "enablePullDownRefresh": true, + "backgroundTextStyle": "dark" +} diff --git a/pages/profile/profile.wxml b/pages/profile/profile.wxml new file mode 100644 index 0000000..e2c0798 --- /dev/null +++ b/pages/profile/profile.wxml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + ! + {{loadError || '加载失败,请稍后再试'}} + + + + + + + + + + + + + + + + + 📷 + + + + {{profile.display_name}} + {{roleLabel}} + + {{profile.student_id}} + + + + + + 个人信息 + + 姓名 + {{profile.name}} + + + 学号 + {{profile.student_id}} + + + 手机号 + {{profile.phone || '未绑定'}} + + + 角色 + {{roleLabel}} + + + 小组 + {{profile.group || '未分组'}} + + + 登录方式 + {{loginTypeLabel}} + + + + + 班级分组 + + {{item.period || item.class_code}} + {{item.group}} · {{item.role}} + + + + + + + + \ No newline at end of file diff --git a/pages/profile/profile.wxss b/pages/profile/profile.wxss new file mode 100644 index 0000000..d7ccdfa --- /dev/null +++ b/pages/profile/profile.wxss @@ -0,0 +1,194 @@ +button::after { border: none; } + +.page { + padding: 32rpx; + min-height: 100vh; + box-sizing: border-box; + background: #FDFBF7; +} + +.page.has-bottom { + padding-bottom: calc(32rpx + env(safe-area-inset-bottom)); +} + +/* ---- shared feedback ---- */ +.btn-hover { opacity: .85; transform: scale(.98); } +.link-hover { opacity: .7; } + +/* ---- skeleton ---- */ +.skeleton-card { padding: 40rpx 32rpx; } +.skeleton { + background: #eee; + border-radius: 8rpx; +} +.skeleton-avatar { + width: 112rpx; + height: 112rpx; + border-radius: 50%; + margin-bottom: 28rpx; +} +.skeleton-line { + height: 28rpx; + border-radius: 8rpx; + background: #eee; + margin-top: 20rpx; +} +.w-60 { width: 60%; } +.w-40 { width: 40%; } +.w-80 { width: 80%; } +.w-70 { width: 70%; } + +/* ---- error state ---- */ +.error-card { + margin-top: 120rpx; + text-align: center; +} +.error-icon { + width: 96rpx; + height: 96rpx; + line-height: 96rpx; + margin: 0 auto 24rpx; + border-radius: 50%; + background: #fdecea; + color: #c0392b; + font-size: 48rpx; + font-weight: 700; + text-align: center; +} +.error-title { + color: #5A4A3A; + font-size: 28rpx; + margin-bottom: 48rpx; +} +.btn-retry { + display: inline-block; + min-width: 240rpx; + background: #C67B30; + color: #fff; + border-radius: 12rpx; +} + +/* ---- phone login ---- */ +.login-wrap { + min-height: calc(100vh - 200rpx); + display: flex; + align-items: center; + justify-content: center; +} +.login-card { width: 100%; text-align: center; } +.login-icon { font-size: 96rpx; line-height: 1; margin-bottom: 24rpx; } +.login-title { font-size: 40rpx; font-weight: 700; color: #C67B30; margin-bottom: 20rpx; } +.login-desc { font-size: 26rpx; color: #8B7A6A; line-height: 1.6; margin-bottom: 48rpx; } +.btn-phone { width: 100%; background: #C67B30; color: #fff; border-radius: 12rpx; } +.btn-phone[disabled] { background: #d8c7b4; color: #fff; } +.error { margin-top: 24rpx; font-size: 26rpx; color: #c0392b; } + +/* ---- profile header ---- */ +.profile-card { padding: 32rpx; } +.profile-head { display: flex; align-items: center; } +.avatar, +.avatar-fallback { + width: 112rpx; + height: 112rpx; + border-radius: 50%; + overflow: hidden; +} +.avatar { background: #f6efe6; } +.avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #C67B30, #e3b37c); + color: #fff; + font-size: 48rpx; + font-weight: 700; +} +.avatar-click { + position: relative; + flex-shrink: 0; +} +.avatar-btn { + width: 112rpx; + height: 112rpx; + margin: 0; + padding: 0; + line-height: 1; + border: none; + background: transparent; + border-radius: 50%; + overflow: hidden; + display: block; +} +.avatar-btn .avatar { display: block; } +.avatar-btn[disabled] { opacity: .7; } +.avatar-camera { + position: absolute; + right: -2rpx; + bottom: -2rpx; + width: 40rpx; + height: 40rpx; + line-height: 36rpx; + text-align: center; + font-size: 20rpx; + color: #fff; + background: #C67B30; + border-radius: 50%; + border: 3rpx solid #FDFBF7; +} +.avatar-hover { opacity: .85; transform: scale(.98); } +.profile-meta { flex: 1; min-width: 0; margin-left: 24rpx; } +.profile-row { display: flex; align-items: center; gap: 12rpx; } +.profile-name { + font-size: 36rpx; + font-weight: 700; + color: #2C1810; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.profile-sub { margin-top: 8rpx; font-size: 24rpx; color: #8B7A6A; } +.badge { + padding: 4rpx 14rpx; + border-radius: 999rpx; + font-size: 20rpx; + flex-shrink: 0; +} +.badge.role { background: #f6efe6; color: #C67B30; } + +/* ---- info / class list ---- */ +.info-card, +.classlist-card { padding: 28rpx; } +.info-title { font-size: 30rpx; font-weight: 700; color: #2C1810; margin-bottom: 8rpx; } +.info-row, +.class-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18rpx 0; + border-bottom: 1rpx solid #f2ede7; +} +.info-row:last-child, +.class-row:last-child { border-bottom: none; } +.info-label { color: #8B7A6A; font-size: 26rpx; flex-shrink: 0; } +.info-value { + color: #2C1810; + font-size: 28rpx; + max-width: 60%; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; +} +.classlist-card { margin-top: 24rpx; } +.classlist-card .info-title { margin-bottom: 8rpx; } + +/* ---- bottom ---- */ +.bottom-actions { + margin-top: 48rpx; + padding-top: 24rpx; +} +.btn-logout { + background: #fff; + color: #c0392b; + border: 1rpx solid #f0b4ae; + border-radius: 12rpx; +} \ No newline at end of file diff --git a/pages/webview/webview.js b/pages/webview/webview.js new file mode 100644 index 0000000..2cc3ba1 --- /dev/null +++ b/pages/webview/webview.js @@ -0,0 +1,8 @@ +Page({ + data: { url: '' }, + onLoad(options) { + if (options.url) { + this.setData({ url: decodeURIComponent(options.url) }); + } + } +}); \ No newline at end of file diff --git a/pages/webview/webview.json b/pages/webview/webview.json new file mode 100644 index 0000000..0f41438 --- /dev/null +++ b/pages/webview/webview.json @@ -0,0 +1 @@ +{ "usingComponents": {} } \ No newline at end of file diff --git a/pages/webview/webview.wxml b/pages/webview/webview.wxml new file mode 100644 index 0000000..4839609 --- /dev/null +++ b/pages/webview/webview.wxml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/webview/webview.wxss b/pages/webview/webview.wxss new file mode 100644 index 0000000..e69de29 diff --git a/project.config.json b/project.config.json new file mode 100644 index 0000000..ac3e7f3 --- /dev/null +++ b/project.config.json @@ -0,0 +1,18 @@ +{ + "description": "明缘进阶场", + "packOptions": { + "ignore": [] + }, + "setting": { + "urlCheck": true, + "es6": true, + "enhance": true, + "postcss": true, + "minified": true + }, + "compileType": "miniprogram", + "libVersion": "3.0.0", + "appid": "wx0f576e94afa52e1a", + "projectname": "mingyuan-clock", + "simulatorPluginLibVersion": {} +} \ No newline at end of file diff --git a/project.private.config.json b/project.private.config.json new file mode 100644 index 0000000..0c24ae1 --- /dev/null +++ b/project.private.config.json @@ -0,0 +1,46 @@ +{ + "condition": { + "miniprogram": { + "list": [ + { + "name": "pages/webview/webview", + "pathName": "pages/webview/webview", + "query": "", + "scene": null, + "launchMode": "default" + }, + { + "name": "pages/profile/profile", + "pathName": "pages/profile/profile", + "query": "", + "launchMode": "default", + "scene": null + }, + { + "name": "pages/plan/plan", + "pathName": "pages/plan/plan", + "query": "", + "launchMode": "default", + "scene": null + }, + { + "name": "pages/checkin/checkin", + "pathName": "pages/checkin/checkin", + "query": "", + "launchMode": "default", + "scene": null + }, + { + "name": "pages/home/home", + "pathName": "pages/home/home", + "query": "", + "launchMode": "default", + "scene": null + } + ] + } + }, + "setting": { + "urlCheck": false + } +} \ No newline at end of file diff --git a/sitemap.json b/sitemap.json new file mode 100644 index 0000000..726488b --- /dev/null +++ b/sitemap.json @@ -0,0 +1,6 @@ +{ + "desc": "明缘进阶场小程序", + "rules": [ + { "action": "allow", "page": "*" } + ] +} \ No newline at end of file diff --git a/utils/api.js b/utils/api.js new file mode 100644 index 0000000..af6b66c --- /dev/null +++ b/utils/api.js @@ -0,0 +1,76 @@ +//const BASE_URL = 'https://muguaclub.cn'; +//const BASE_URL = "http://127.0.0.1:8000"; +const BASE_URL = 'https://m.muguaclub.cn/clock'; +const STATIC_BASE_URL = 'https://m.muguaclub.cn'; + +let redirectingProfile = false; + +function goProfile() { + if (redirectingProfile) { + return; + } + const pages = getCurrentPages(); + const current = pages.length ? pages[pages.length - 1] : null; + const route = current && current.route; + if (route === 'pages/profile/profile') { + return; + } + redirectingProfile = true; + wx.switchTab({ + url: '/pages/profile/profile', + complete() { + setTimeout(() => { + redirectingProfile = false; + }, 1200); + } + }); +} + +function request(options) { + const { url, method = 'GET', data = {}, auth = true } = options; + const isIdempotent = method === 'GET' || method === 'DELETE'; + let retried = false; + + return new Promise((resolve, reject) => { + const doRequest = () => { + const header = { 'Content-Type': 'application/json' }; + const token = wx.getStorageSync('token'); + if (auth && token) { + header['Authorization'] = 'Bearer ' + token; + } + wx.request({ + url: BASE_URL + url, + method, + data, + header, + success(res) { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(res.data); + } else if (res.statusCode === 401) { + wx.removeStorageSync('token'); + if (auth) { + goProfile(); + } + reject(res.data || { error: '未登录' }); + } else { + reject(res.data || { error: '请求失败' }); + } + }, + fail(err) { + // 冷启动时首个请求可能因网络栈未就绪而失败,空闲时对幂等请求自动重试一次 + const msg = (err && err.errMsg) || ''; + const domainError = /url not in domain list|domain not/i.test(msg); + if (!retried && isIdempotent && !domainError) { + retried = true; + setTimeout(doRequest, 800); + return; + } + reject(err); + } + }); + }; + doRequest(); + }); +} + +module.exports = { request, BASE_URL, STATIC_BASE_URL }; diff --git a/utils/auth.js b/utils/auth.js new file mode 100644 index 0000000..ff2c615 --- /dev/null +++ b/utils/auth.js @@ -0,0 +1,109 @@ +const api = require('./api'); + +let loginPromise = null; + +function getToken() { + return wx.getStorageSync('token') || ''; +} + +function setToken(token) { + wx.setStorageSync('token', token); +} + +function clearToken() { + wx.removeStorageSync('token'); +} + +function wxLogin(retries) { + const remain = typeof retries === 'number' ? retries : 3; + return new Promise((resolve, reject) => { + wx.login({ + success(res) { + if (res && res.code) { + resolve(res.code); + } else { + reject(new Error((res && res.errMsg) || '微信登录失败')); + } + }, + fail: reject + }); + }).catch((err) => { + // 冷启动时微信登录服务可能尚未就绪,指数退避重试 + if (remain > 0) { + const delay = 400 + (4 - remain) * 200; // 首次 600ms,依次 800ms、1000ms + return new Promise((resolve) => setTimeout(resolve, delay)) + .then(() => wxLogin(remain - 1)); + } + throw err; + }); +} + +// 整个微信登录流程失败时重新取 code 再试一次,覆盖冷启动首个网络请求偶发失败 +function wechatLogin(retries) { + const remain = typeof retries === 'number' ? retries : 1; + return wxLogin() + .then((code) => api.request({ + url: '/api/v1/auth/wechat-login', + method: 'POST', + data: { code }, + auth: false + })) + .catch((err) => { + if (remain > 0) { + return new Promise((resolve) => setTimeout(resolve, 500)).then(() => wechatLogin(remain - 1)); + } + throw err; + }); +} + +// 单例登录:App.onLaunch 与首屏页面 onShow 可能几乎同时调用, +// 共享同一个进行中的登录 Promise,避免重复 wx.login / wechat-login 造成竞态。 +// 关键:登录失败时必须清空单例,让下一次调用能重新发起, +// 否则冷启动 wx.login 偶发失败后,后续页面永远拿不到 token。 +function ensureLogin(refresh) { + const existing = getToken(); + if (existing && !refresh) { + return Promise.resolve(existing); + } + if (loginPromise) { + return loginPromise; + } + const p = wechatLogin() + .then((res) => { + if (res && res.success) { + setToken(res.token); + return res.token; + } + // need_phone:未绑定手机号时 resolve(null),由页面引导去「我的」绑定 + return null; + }) + .finally(() => { + // 无论成功失败,完成后都清掉单例,让下一次调用可以重新发起 + if (loginPromise === p) { + loginPromise = null; + } + }); + loginPromise = p; + return p; +} + +function loginWithPhone(phoneCode) { + return wxLogin() + .then((code) => api.request({ + url: '/api/v1/auth/bind-phone', + method: 'POST', + data: { code, phone_code: phoneCode }, + auth: false + })) + .then((res) => { + if (res && res.success) { + setToken(res.token); + return res; + } + const error = new Error((res && res.error) || '手机号登录失败'); + error.error = (res && res.error) || '手机号登录失败'; + throw error; + }); +} + +module.exports = { getToken, setToken, clearToken, wxLogin, ensureLogin, loginWithPhone }; \ No newline at end of file