init
项目初始化
11
app.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const auth = require('./utils/auth');
|
||||
|
||||
App({
|
||||
onLaunch() {
|
||||
auth.ensureLogin().catch(() => {});
|
||||
},
|
||||
globalData: {
|
||||
token: '',
|
||||
user: null
|
||||
}
|
||||
});
|
||||
30
app.json
Normal 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
@@ -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);
|
||||
}
|
||||
BIN
assets/tabbar/checkin-active.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
assets/tabbar/checkin.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
assets/tabbar/home-active.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/tabbar/home.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/tabbar/plan-active.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
assets/tabbar/plan.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/tabbar/profile-active.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
assets/tabbar/profile.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
211
pages/checkin/checkin.js
Normal 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 });
|
||||
});
|
||||
}
|
||||
});
|
||||
1
pages/checkin/checkin.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "usingComponents": {} }
|
||||
108
pages/checkin/checkin.wxml
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"usingComponents": {},
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
42
pages/home/home.wxml
Normal 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
@@ -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; }
|
||||
}
|
||||
351
pages/plan-submit/plan-submit.js
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
3
pages/plan-submit/plan-submit.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"usingComponents": {}
|
||||
}
|
||||
95
pages/plan-submit/plan-submit.wxml
Normal 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>
|
||||
56
pages/plan-submit/plan-submit.wxss
Normal 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
@@ -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
@@ -0,0 +1 @@
|
||||
{ "usingComponents": {} }
|
||||
116
pages/plan/plan.wxml
Normal 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
@@ -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
@@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
5
pages/profile/profile.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"usingComponents": {},
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
105
pages/profile/profile.wxml
Normal 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
@@ -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
@@ -0,0 +1,8 @@
|
||||
Page({
|
||||
data: { url: '' },
|
||||
onLoad(options) {
|
||||
if (options.url) {
|
||||
this.setData({ url: decodeURIComponent(options.url) });
|
||||
}
|
||||
}
|
||||
});
|
||||
1
pages/webview/webview.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "usingComponents": {} }
|
||||
1
pages/webview/webview.wxml
Normal file
@@ -0,0 +1 @@
|
||||
<web-view src="{{url}}" wx:if="{{url}}"></web-view>
|
||||
0
pages/webview/webview.wxss
Normal file
18
project.config.json
Normal 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": {}
|
||||
}
|
||||
46
project.private.config.json
Normal 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
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"desc": "明缘进阶场小程序",
|
||||
"rules": [
|
||||
{ "action": "allow", "page": "*" }
|
||||
]
|
||||
}
|
||||
76
utils/api.js
Normal 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
@@ -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 };
|
||||