项目初始化
This commit is contained in:
王宏豪
2026-09-03 16:49:59 +08:00
commit d7fc8c8982
40 changed files with 2688 additions and 0 deletions

11
app.js Normal file
View File

@@ -0,0 +1,11 @@
const auth = require('./utils/auth');
App({
onLaunch() {
auth.ensureLogin().catch(() => {});
},
globalData: {
token: '',
user: null
}
});

30
app.json Normal file
View File

@@ -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"
}

16
app.wxss Normal file
View File

@@ -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);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
assets/tabbar/checkin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
assets/tabbar/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
assets/tabbar/plan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
assets/tabbar/profile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

211
pages/checkin/checkin.js Normal file
View File

@@ -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 });
});
}
});

View File

@@ -0,0 +1 @@
{ "usingComponents": {} }

108
pages/checkin/checkin.wxml Normal file
View File

@@ -0,0 +1,108 @@
<!-- 加载失败 -->
<block wx:if="{{loadError}}">
<view class="page">
<view class="error-card">
<view class="error-icon">!</view>
<view class="error-title">{{loadError}}</view>
<button class="btn-retry" hover-class="btn-hover" bindtap="onRetry">重新加载</button>
</view>
</view>
</block>
<!-- 首次加载骨架屏 -->
<block wx:if="{{loading}}">
<view class="page">
<view class="skeleton-card">
<view class="sk sk-title"></view>
<view class="sk sk-line w60"></view>
<view class="sk sk-line w90"></view>
</view>
<view class="skeleton-list">
<view class="sk sk-row" wx:for="{{[1, 2, 3, 4, 5]}}" wx:key="*this"></view>
</view>
</view>
</block>
<block wx:else>
<view class="page fade-in">
<view class="card top-card {{checked ? 'done-card' : ''}}">
<view class="status-row">
<view class="date-chip">
<view class="date-week">{{weekday}}</view>
<view class="date-num">{{dayNum}}</view>
</view>
<view class="status-main">
<view class="status-title {{checked ? 'done' : 'undone'}}">{{checked ? '已打卡' : '未打卡'}}</view>
<view class="status-sub">{{checked ? '已完成今日打卡' : '请选择状态完成打卡'}}</view>
<view class="hero-status" wx:if="{{checked}}">
<view class="hero-status-dot" style="background-color:{{statusColor}}"></view>
<text class="hero-status-label" style="color:{{statusColor}}">{{record.status}}</text>
</view>
</view>
</view>
<view class="pills-grid {{submitting ? 'disabled' : ''}}">
<view
wx:for="{{statusItems}}"
wx:key="label"
class="pill {{statusIndex === index ? 'active' : ''}}"
style="background-color:{{statusIndex === index ? item.color : '#ffffff'}};border-color:{{statusIndex === index ? item.color : '#D8C7B4'}};color:{{statusIndex === index ? '#ffffff' : '#5A4A3A'}}"
data-index="{{index}}"
hover-class="pill-hover"
bindtap="onStatusTap"
>
<view class="dot" style="background-color:{{statusIndex === index ? '#ffffff' : item.color}}"></view>
<text>{{item.label}}</text>
</view>
<view class="submitting" wx:if="{{submitting}}">提交中…</view>
</view>
</view>
<view class="record-panel">
<view class="tabs">
<view class="tab {{tab === 'group' ? 'active' : ''}}" data-tab="group" hover-class="tab-hover" bindtap="switchTab">小组</view>
<view class="tab {{tab === 'personal' ? 'active' : ''}}" data-tab="personal" hover-class="tab-hover" bindtap="switchTab">个人</view>
</view>
<scroll-view class="record-scroll" scroll-y refresher-enabled refresher-triggered="{{refreshing}}" bindrefresherrefresh="onRefresh">
<view class="record-inner" wx:if="{{tab === 'group'}}">
<view class="group-head" wx:if="{{groupName}}">
<view class="group-title">{{groupName}} · {{classDate}}</view>
<view class="group-progress">{{checkedCount}}/{{total}} 已打卡</view>
</view>
<view class="empty" wx:if="{{!groupName}}">
<view class="empty-icon">👥</view>
<view class="empty-text">暂无小组,请联系老师分配</view>
</view>
<view class="empty" wx:elif="{{group.length === 0}}">
<view class="empty-icon">📭</view>
<view class="empty-text">本周暂无打卡记录</view>
</view>
<view wx:for="{{group}}" wx:key="student_id" class="member {{item.checked ? 'ok' : 'idle'}}">
<view class="member-avatar {{item.checked ? 'ok' : ''}}">{{item.shortName}}</view>
<view class="member-info">
<view class="member-name">{{item.name}}</view>
<view class="member-meta">{{item.checked ? ('打卡 ' + item.timeText) : ''}}</view>
</view>
<view class="member-badge {{item.checked ? 'ok' : 'idle'}}">{{item.checked ? item.record.status : '未打卡'}}</view>
</view>
</view>
<view wx:else class="record-inner">
<view class="empty" wx:if="{{history.length === 0}}">
<view class="empty-icon">📒</view>
<view class="empty-text">暂无个人打卡记录</view>
</view>
<view class="history-item" wx:for="{{history}}" wx:key="id">
<view class="history-head">
<view class="history-date">{{item.class_date}}</view>
<view class="history-badge" style="color:{{item.color}};background:{{item.bg}}">{{item.status}}</view>
</view>
<view class="history-time">{{item.timeText}}</view>
</view>
</view>
</scroll-view>
</view>
</view>
</block>

394
pages/checkin/checkin.wxss Normal file
View File

@@ -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;
}

74
pages/home/home.js Normal file
View File

@@ -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) });
}
});

5
pages/home/home.json Normal file
View File

@@ -0,0 +1,5 @@
{
"usingComponents": {},
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}

42
pages/home/home.wxml Normal file
View File

@@ -0,0 +1,42 @@
<view class="page">
<block wx:if="{{loading}}">
<view class="hero hero-skeleton"></view>
<view class="grid">
<view class="module module-skeleton" wx:for="{{skeletonItems}}" wx:key="*this">
<view class="sk sk-circle"></view>
<view class="sk sk-line w-70"></view>
<view class="sk sk-line w-40"></view>
</view>
</view>
</block>
<block wx:else>
<view class="hero">
<view class="hero-chip" wx:if="{{greeting}}">{{greeting}}</view>
<view class="hero-name">{{displayName || '欢迎回来'}}</view>
<view class="hero-enc" wx:if="{{encouragement}}">{{encouragement}}</view>
</view>
<view class="error-bar" wx:if="{{error}}">
<text class="error-text">{{error}}</text>
<view class="error-retry" hover-class="link-hover" bindtap="onRetry">重试</view>
</view>
<view class="grid">
<view
class="module"
wx:for="{{modules}}"
wx:key="id"
hover-class="module-hover"
data-url="{{item.url}}"
bindtap="onModuleTap"
>
<view class="module-icon">{{item.icon}}</view>
<view class="module-label">{{item.label}}</view>
<view class="module-desc">{{item.desc}}</view>
</view>
</view>
<view class="empty" wx:if="{{modules.length === 0 && !error}}">暂无功能入口</view>
</block>
</view>

156
pages/home/home.wxss Normal file
View File

@@ -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; }
}

View File

@@ -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();
}
}
});

View File

@@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@@ -0,0 +1,95 @@
<view class="page">
<view class="head">
<view class="head-title">{{typeLabel}}<text wx:if="{{groupName}}"> · {{groupName}}</text> · {{periodKey}}</view>
<view class="head-sub" wx:if="{{message}}">{{message}}</view>
</view>
<scroll-view class="form-scroll" scroll-y>
<block wx:if="{{loading}}">
<view class="card skeleton-card">
<view class="sk sk-line w-90"></view>
<view class="sk sk-line w-80"></view>
<view class="sk sk-line w-100"></view>
<view class="sk-block"></view>
<view class="sk-block"></view>
</view>
<view class="card skeleton-card">
<view class="sk sk-line w-60"></view>
<view class="sk-block"></view>
</view>
</block>
<block wx:elif="{{loadError}}">
<view class="error-card">
<view class="error-icon">!</view>
<view class="error-text">{{loadError}}</view>
<button class="btn-retry btn-primary" hover-class="btn-hover" bindtap="onRetry">重新加载</button>
</view>
</block>
<block wx:else>
<view class="readonly-tip" wx:if="{{!canSubmit}}">{{message || '当前周期不可提交,仅可查看'}}</view>
<view class="card">
<textarea
class="content {{canSubmit ? '' : 'readonly'}}"
placeholder="写下计划内容"
value="{{content}}"
bindinput="onContentInput"
maxlength="1000"
disabled="{{!canSubmit}}"
></textarea>
<view class="char-count">{{content.length}}/1000</view>
<view class="upload-section">
<button
class="upload-btn"
bindtap="chooseImages"
hover-class="btn-hover"
disabled="{{canSubmit === false || uploading || attachments.length >= 4}}"
>+ {{uploading ? '上传中...' : '添加图片(' + attachments.length + '/4)'}}</button>
<view class="thumb-grid" wx:if="{{attachments.length}}">
<view class="thumb-item" wx:for="{{attachments}}" wx:key="*this">
<image class="thumb" src="{{attachmentThumbs[index]}}" mode="aspectFill" data-index="{{index}}" bindtap="previewImage"></image>
<view class="thumb-del" data-index="{{index}}" catchtap="removeImage">×</view>
</view>
</view>
</view>
<button
class="btn-primary btn-save"
bindtap="submit"
hover-class="btn-hover"
loading="{{submitting}}"
disabled="{{submitting || !canSubmit}}"
>{{!canSubmit ? '不可提交' : (checked ? '保存修改' : '保存计划')}}</button>
<view class="save-hint" wx:if="{{canSubmit && checked}}">保存将覆盖本周期已提交的计划</view>
</view>
<block wx:if="{{planType !== 'weekly'}}">
<view class="section-title">历史计划</view>
<view class="card history-card" wx:for="{{history}}" wx:key="id" wx:for-item="his">
<view class="history-head">{{his.period_key}} · <text class="{{his.status === '未完成' ? 'pending' : ''}}">{{his.status}}</text></view>
<view class="content-text" wx:if="{{his.content}}">{{his.content}}</view>
<view class="history-thumbs" wx:if="{{his.thumbSrcs.length}}">
<image
wx:for="{{his.thumbSrcs}}"
wx:for-item="thumbSrc"
wx:for-index="imgIndex"
wx:key="*this"
class="history-thumb"
src="{{thumbSrc}}"
mode="aspectFill"
data-id="{{his.id}}"
data-index="{{imgIndex}}"
bindtap="previewHistoryImage"
></image>
</view>
</view>
<view class="empty" wx:if="{{history.length === 0}}">暂无历史计划</view>
</block>
</block>
</scroll-view>
</view>

View File

@@ -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; }

145
pages/plan/plan.js Normal file
View File

@@ -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 });
}
});

1
pages/plan/plan.json Normal file
View File

@@ -0,0 +1 @@
{ "usingComponents": {} }

116
pages/plan/plan.wxml Normal file
View File

@@ -0,0 +1,116 @@
<view class="page">
<block wx:if="{{view === 'overview'}}">
<view class="view-root">
<view class="type-tabs">
<view
class="type-tab {{typeIndex === index ? 'active' : ''}}"
wx:for="{{types}}"
wx:key="index"
data-index="{{index}}"
hover-class="type-tab-hover"
bindtap="onTypeChange"
>{{item}}</view>
</view>
<scroll-view
class="view-scroll"
scroll-y
refresher-enabled="{{true}}"
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onRefresh"
>
<view class="overview-head">
<view class="head-title-wrap">
<view class="head-title">{{types[typeIndex]}}</view>
<view class="head-sub">{{periodKey}}</view>
</view>
<view class="head-pill">{{myChecked ? '已提交' : '待提交'}}</view>
</view>
<view class="card my-status {{myChecked ? 'checked' : 'unchecked'}}">
<view class="my-state-icon {{myChecked ? 'done' : 'todo'}}">{{myChecked ? '✓' : '●'}}</view>
<view class="my-status-left">
<view class="my-status-label">{{myChecked ? '已完成' : '未完成'}}</view>
<view class="my-status-text">我的{{types[typeIndex]}}</view>
</view>
<button
class="btn-mystatus {{myChecked ? 'outline' : ''}}"
bindtap="goSubmit"
hover-class="btn-hover"
disabled="{{typeIndex === 0 && !canSubmit}}"
>{{myChecked ? '修改' : '新增打卡'}}</button>
</view>
<view class="card my-plan" wx:if="{{myChecked && myRecord}}">
<view class="my-plan-head">
<text class="my-plan-title">我上传的计划</text>
<text class="my-plan-status {{myRecord.status === '未完成' ? 'pending' : ''}}">{{myRecord.status}}</text>
</view>
<view class="my-plan-meta" wx:if="{{myRecord.checked_at}}">{{myRecord.checked_at}} 提交</view>
<view class="my-plan-content" wx:if="{{myRecord.content}}">{{myRecord.content}}</view>
<view class="my-plan-thumbs" wx:if="{{myThumbs.length}}">
<image class="my-plan-thumb" wx:for="{{myThumbs}}" wx:key="*this" src="{{item}}" mode="aspectFill" data-index="{{index}}" bindtap="previewMyImage"></image>
</view>
</view>
<view class="window-msg" wx:if="{{typeIndex === 0 && message}}">{{message}}</view>
<view class="state-block" wx:if="{{loading && groups.length === 0}}">
<view class="state-icon">◌</view>
<view>加载中...</view>
</view>
<view class="group-card" hover-class="group-card-hover" wx:for="{{groups}}" wx:key="group_name" wx:for-item="group">
<view class="group-main" data-group="{{group.group_name}}" bindtap="openGroup">
<view class="group-row">
<view class="group-avatar">{{index + 1}}</view>
<view class="group-name">{{group.group_name}}</view>
<view class="group-counts">
<text class="count done">已打卡 {{group.checked_count}}</text>
<text class="count total">{{group.total}}人</text>
</view>
<view class="chevron">›</view>
</view>
<view class="progress-track">
<view class="progress-bar" style="width: {{group.percent}}%;"></view>
</view>
</view>
</view>
<view class="unassigned" wx:if="{{unassignedCount > 0}}">另有未分组 {{unassignedCount}} 人</view>
<view class="empty" wx:if="{{!loading && groups.length === 0}}">暂无小组</view>
</scroll-view>
</view>
</block>
<block wx:else>
<view class="view-root">
<view class="detail-head">
<view class="back" hover-class="back-hover" bindtap="backToOverview">‹ 返回</view>
<view class="detail-title-wrap">
<view class="head-title">{{selectedGroupName}}</view>
<view class="head-sub">{{periodKey}}</view>
</view>
</view>
<scroll-view class="view-scroll" scroll-y>
<view class="state-block" wx:if="{{loading && members.length === 0}}">
<view class="state-icon">◌</view>
<view>加载中...</view>
</view>
<view class="member-list">
<view class="member" wx:for="{{members}}" wx:key="user_id" wx:for-item="member">
<view class="member-left">
<view class="member-avatar {{member.filled ? 'done' : 'unchecked'}}">{{index + 1}}</view>
<view class="member-info">
<view class="member-name">{{member.name}}</view>
<view class="member-meta" wx:if="{{member.filled && member.record}}">{{member.record.checked_at}} 提交</view>
</view>
</view>
<view class="badge {{member.filled ? 'done' : 'unchecked'}}">{{member.filled ? '已完成' : '未完成'}}</view>
</view>
</view>
<view class="empty" wx:if="{{!loading && members.length === 0}}">暂无成员</view>
</scroll-view>
</view>
</block>
</view>

80
pages/plan/plan.wxss Normal file
View File

@@ -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; }

224
pages/profile/profile.js Normal file
View File

@@ -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' });
}
});
}
});

View File

@@ -0,0 +1,5 @@
{
"usingComponents": {},
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}

105
pages/profile/profile.wxml Normal file
View File

@@ -0,0 +1,105 @@
<view class="page" wx:if="{{view === 'loading'}}">
<view class="card skeleton-card">
<view class="skeleton skeleton-avatar"></view>
<view class="skeleton-line w-60"></view>
<view class="skeleton-line w-40"></view>
<view class="skeleton-line w-80"></view>
<view class="skeleton-line w-70"></view>
</view>
</view>
<view class="page" wx:elif="{{view === 'loadError'}}">
<view class="error-card">
<view class="error-icon">!</view>
<view class="error-title">{{loadError || '加载失败,请稍后再试'}}</view>
<button class="btn-retry btn-primary" hover-class="btn-hover" bindtap="onRetry">重新加载</button>
</view>
</view>
<view class="page" wx:elif="{{view === 'phoneLogin'}}">
<view class="login-wrap">
<view class="login-card">
<view class="login-icon">👤</view>
<view class="login-title">微信手机号一键登录</view>
<view class="login-desc">未找到你的账号,请使用微信手机号登录。手机号仅用于账号绑定,不会公开展示。</view>
<button
class="btn-phone btn-primary"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumber"
loading="{{phoneLoading}}"
disabled="{{phoneLoading}}"
hover-class="btn-hover"
>{{phoneLoading ? '登录中...' : '微信手机号一键登录'}}</button>
<view class="error" wx:if="{{phoneError}}">{{phoneError}}</view>
</view>
</view>
</view>
<block wx:elif="{{profile}}">
<view class="page has-bottom">
<view class="card profile-card">
<view class="profile-head">
<view class="avatar-click">
<button
class="avatar-btn"
open-type="chooseAvatar"
bindchooseavatar="onChooseAvatar"
disabled="{{avatarUploading}}"
hover-class="avatar-hover"
>
<image wx:if="{{avatarSrc}}" class="avatar" src="{{avatarSrc}}" mode="aspectFill"></image>
<text wx:else class="avatar avatar-fallback">{{avatarFallback}}</text>
</button>
<view class="avatar-camera" wx:if="{{!avatarUploading}}">📷</view>
</view>
<view class="profile-meta">
<view class="profile-row">
<text class="profile-name">{{profile.display_name}}</text>
<text class="badge role" wx:if="{{roleLabel}}">{{roleLabel}}</text>
</view>
<view class="profile-sub">{{profile.student_id}}</view>
</view>
</view>
</view>
<view class="card info-card">
<view class="info-title">个人信息</view>
<view class="info-row">
<text class="info-label">姓名</text>
<text class="info-value">{{profile.name}}</text>
</view>
<view class="info-row">
<text class="info-label">学号</text>
<text class="info-value">{{profile.student_id}}</text>
</view>
<view class="info-row">
<text class="info-label">手机号</text>
<text class="info-value">{{profile.phone || '未绑定'}}</text>
</view>
<view class="info-row">
<text class="info-label">角色</text>
<text class="info-value">{{roleLabel}}</text>
</view>
<view class="info-row">
<text class="info-label">小组</text>
<text class="info-value">{{profile.group || '未分组'}}</text>
</view>
<view class="info-row">
<text class="info-label">登录方式</text>
<text class="info-value">{{loginTypeLabel}}</text>
</view>
</view>
<view class="card classlist-card" wx:if="{{classGroups.length}}">
<view class="info-title">班级分组</view>
<view class="class-row" wx:for="{{classGroups}}" wx:key="class_id">
<text class="info-label">{{item.period || item.class_code}}</text>
<text class="info-value">{{item.group}} · {{item.role}}</text>
</view>
</view>
<view class="bottom-actions">
<button class="btn-logout" hover-class="btn-hover" bindtap="logout">退出登录</button>
</view>
</view>
</block>

194
pages/profile/profile.wxss Normal file
View File

@@ -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;
}

8
pages/webview/webview.js Normal file
View File

@@ -0,0 +1,8 @@
Page({
data: { url: '' },
onLoad(options) {
if (options.url) {
this.setData({ url: decodeURIComponent(options.url) });
}
}
});

View File

@@ -0,0 +1 @@
{ "usingComponents": {} }

View File

@@ -0,0 +1 @@
<web-view src="{{url}}" wx:if="{{url}}"></web-view>

View File

18
project.config.json Normal file
View File

@@ -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": {}
}

View File

@@ -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
}
}

6
sitemap.json Normal file
View File

@@ -0,0 +1,6 @@
{
"desc": "明缘进阶场小程序",
"rules": [
{ "action": "allow", "page": "*" }
]
}

76
utils/api.js Normal file
View File

@@ -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 };

109
utils/auth.js Normal file
View File

@@ -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 };