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 || '加载失败,请稍后再试'}}
+
+
+
+
+
+
+
+ 👤
+ 微信手机号一键登录
+ 未找到你的账号,请使用微信手机号登录。手机号仅用于账号绑定,不会公开展示。
+
+ {{phoneError}}
+
+
+
+
+
+
+
+
+
+
+ 📷
+
+
+
+ {{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