feat(database): 迁移用户关联字段并优化打卡系统

- 修改数据库配置将MYSQL_DB从mys_info_v3改为mys_info_test
- 新增auth_users表wechat_openid字段并建立唯一索引
- 为plan_checkins表添加计划性能索引(idx_plan_period_student)
- 将checkin_records_old表关联方式从student_id改为user_id
- 将plan_checkins表关联方式从student_id改为user_id并更新附件路径
- 移除routes/auth.py中的uuid导入使用安全token生成函数
- 重构routes/checkin.py打卡逻辑去除course_type字段并优化返回结构
- 添加打卡时间限制验证功能防止过期打卡操作
- 实现小组统计功能支持老师查看各组打卡情况
- 更新主页index.html界面适配新的打卡和计划功能
- 调整计划提交时间规则为周五至周四周期
- 添加月度和年度计划提交状态验证机制
This commit is contained in:
王宏豪
2026-09-10 14:11:58 +08:00
parent 1dd4fa3f41
commit cd04c1b5ef
19 changed files with 1457 additions and 407 deletions

36
app.js
View File

@@ -1,11 +1,43 @@
const auth = require('./utils/auth');
const api = require('./utils/api');
App({
onLaunch() {
auth.ensureLogin().catch(() => {});
// 尝试登录并缓存用户信息
auth.ensureLogin()
.then((token) => {
if (token) {
// 登录成功后预取 profile 缓存到 globalData
api.request({ url: '/api/v1/profile' })
.then((res) => {
this.globalData.user = res;
})
.catch(() => {
// 预取失败不影响主流程
});
}
})
.catch(() => {});
},
globalData: {
token: '',
user: null
}
},
// 全局获取用户信息(带缓存)
getUserInfo(force) {
if (this.globalData.user && !force) {
return Promise.resolve(this.globalData.user);
}
return auth.ensureLogin()
.then((token) => {
if (!token) return null;
return api.request({ url: '/api/v1/profile' });
})
.then((user) => {
if (user) {
this.globalData.user = user;
}
return user;
});
},
});

View File

@@ -43,7 +43,6 @@ function fmtTime(ts) {
Page({
data: {
statuses: STATUSES,
statusItems: STATUS_ITEMS,
statusIndex: -1,
loading: true,
@@ -53,16 +52,34 @@ Page({
checked: false,
record: null,
statusColor: '',
statusBg: '',
history: [],
group: [],
groupName: '',
// 视图模式:overview 总览 / detail 小组详情(仅老师)
view: 'overview',
// 当前选中的小组(详情页用)
selectedGroupName: '',
// 小组统计列表(老师总览用)
groups: [],
// 小组成员列表(详情页 & 学员小组 tab 用)
members: [],
checkedCount: 0,
total: 0,
// 用户角色
isTeacher: false,
myGroup: '',
unassignedCount: 0,
// 学员视图 tab:group / personal
tab: 'group',
// 个人历史
history: [],
historyPage: 1,
historyTotal: 0,
historyFinished: false,
historyLoading: false,
submitting: false,
refreshing: false,
loadError: '',
// 周选择相关
weekList: [],
showWeekPicker: false,
},
onShow() {
@@ -70,7 +87,7 @@ Page({
},
onPullDownRefresh() {
this.loadPage().then(() => wx.stopPullDownRefresh());
this.loadPage().finally(() => wx.stopPullDownRefresh());
},
loadPage() {
@@ -81,6 +98,10 @@ Page({
wx.switchTab({ url: '/pages/profile/profile' });
return;
}
// 先加载周列表,再加载当前周数据
return this.loadWeeks();
})
.then(() => {
return this.loadWeek();
})
.catch((err) => {
@@ -94,36 +115,120 @@ Page({
this.loadPage();
},
/** 加载可用周列表 */
loadWeeks() {
return api.request({ url: '/api/v1/checkin/weeks' }).then((res) => {
const weeks = res.weeks || [];
const current = res.current_week || (weeks[0] || '');
this.setData({
weekList: weeks,
classDate: this.data.classDate || current,
});
}).catch(() => {
// 获取周列表失败不影响主流程
console.warn('获取上课周列表失败');
});
},
/** 打开周选择器 */
openWeekPicker() {
if (!this.data.weekList.length) {
return;
}
this.setData({ showWeekPicker: true });
},
/** 关闭周选择器 */
closeWeekPicker() {
this.setData({ showWeekPicker: false });
},
/** 选择某一周 */
onWeekSelect(e) {
const classDate = e.currentTarget.dataset.date;
if (!classDate || classDate === this.data.classDate) {
this.setData({ showWeekPicker: false });
return;
}
this.setData({
classDate,
showWeekPicker: false,
});
this.loadWeek();
},
/**
* 加载当前周数据:
* - 老师:加载小组统计总览
* - 学员:加载小组统计 + 本组成员列表 + 个人历史(个人 tab 延迟加载)
*/
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 classDate = this.data.classDate;
const params = classDate ? { class_date: classDate } : {};
return api.request({ url: '/api/v1/checkin/groups', data: params }).then((res) => {
const date = res.class_date || '';
const weekday = WEEKDAYS[new Date(date + 'T00:00:00').getDay()] || '';
const dayNum = date.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,
const isTeacher = !!res.is_teacher;
// 计算每个小组的百分比
const groups = (res.groups || []).map((g) => ({
...g,
percent: g.total ? Math.round(((g.checked_count || 0) / g.total) * 100) : 0
}));
const baseData = {
classDate: date,
weekday,
dayNum,
checked: !!res.checked,
record: res.record || null,
statusIndex,
statusColor,
statusBg: statusColor ? statusColor + '1A' : ''
});
return this.loadGroup();
}).then(() => {
isTeacher,
myGroup: res.my_group || '',
unassignedCount: res.unassigned_count || 0,
groups,
};
// 如果当前在详情页(老师),重新加载该组详情
if (isTeacher && this.data.view === 'detail' && this.data.selectedGroupName) {
this.setData({ ...baseData, members: [], loading: true });
return this.loadGroup(this.data.selectedGroupName).then(() => {
this.setData({ loading: false });
});
}
this.setData(baseData);
if (!isTeacher) {
// 学员:先设置兜底的小组名(避免/group失败时显示"暂无小组")
if (res.my_group) {
this.setData({ groupName: res.my_group });
}
// 学员:加载本组成员列表
return this.loadGroup(res.my_group || '').then(() => {
this.setData({ loading: false });
});
}
this.setData({ loading: false });
}).catch(() => {
this.setData({ loading: false });
});
},
loadGroup() {
const data = this.data.classDate ? { class_date: this.data.classDate } : {};
/** 加载指定小组的成员列表,不传则看自己小组 */
loadGroup(groupName) {
const data = { class_date: this.data.classDate };
if (groupName) {
data.group_name = groupName;
}
return api.request({ url: '/api/v1/checkin/group', data }).then((res) => {
const members = (res.members || []).map((m) => ({
...m,
@@ -132,17 +237,57 @@ Page({
}));
const checkedCount = members.filter((m) => m.checked).length;
this.setData({
group: members,
groupName: res.group_name || '',
members,
groupName: res.group_name || groupName || '',
checkedCount,
total: members.length
});
}).catch(() => {});
}).catch((err) => {
console.warn('加载小组成员失败:', err);
wx.showToast({ title: '加载小组成员失败', icon: 'none' });
});
},
/** 老师:打开小组详情 */
openGroup(e) {
const groupName = e.currentTarget.dataset.group;
if (!groupName) return;
this.setData({ selectedGroupName: groupName, view: 'detail', members: [], loading: true });
this.loadGroup(groupName).then(() => {
this.setData({ loading: false });
});
},
/** 老师:返回总览 */
backToOverview() {
this.setData({ view: 'overview', selectedGroupName: '', members: [] });
},
/** 加载个人历史(第一页) */
loadHistory() {
return api.request({ url: '/api/v1/checkin/list', data: { page: 1, size: 20 } }).then((res) => {
const history = (res.list || []).map((item) => {
this.setData({ historyPage: 1, historyFinished: false, historyLoading: true });
return this._loadHistoryPage(1, true).then(() => {
this.setData({ historyLoading: false });
}).catch(() => {
this.setData({ historyLoading: false });
});
},
/** 加载更多历史(翻页) */
loadMoreHistory() {
if (this.data.historyLoading || this.data.historyFinished) return;
const nextPage = this.data.historyPage + 1;
this.setData({ historyLoading: true });
return this._loadHistoryPage(nextPage, false).then(() => {
this.setData({ historyLoading: false, historyPage: nextPage });
}).catch(() => {
this.setData({ historyLoading: false });
});
},
_loadHistoryPage(page, replace) {
return api.request({ url: '/api/v1/checkin/list', data: { page, size: 20 } }).then((res) => {
const list = (res.list || []).map((item) => {
const color = STATUS_META[item.status] || '#C67B30';
return {
...item,
@@ -151,10 +296,14 @@ Page({
bg: color + '1A'
};
});
this.setData({ history });
}).catch(() => {});
const total = res.total || 0;
const history = replace ? list : this.data.history.concat(list);
const finished = history.length >= total;
this.setData({ history, historyTotal: total, historyFinished: finished });
});
},
onStatusTap(e) {
if (this.data.submitting) {
return;
@@ -172,15 +321,56 @@ Page({
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' : '' });
this.setData({ submitting: false, checked: true, record, statusColor });
wx.showToast({ title: '打卡成功', icon: 'success' });
this.loadGroup();
if (this.data.tab === 'personal') {
// 学员:本地更新 members 中自己的记录,避免全量 reload
if (!this.data.isTeacher && this.data.members.length > 0) {
const myUserId = record && record.user_id;
if (myUserId) {
const members = this.data.members.map((m) => {
if (m.user_id === myUserId) {
return {
...m,
checked: true,
record: { status: record.status, checked_at: record.checked_at, note: record.note || '' },
timeText: fmtTime(record.checked_at),
};
}
return m;
});
const checkedCount = members.filter((m) => m.checked).length;
this.setData({ members, checkedCount });
}
}
// 老师视图:刷新小组统计(需要更新各小组计数)
if (this.data.isTeacher) {
this.loadWeek();
}
// 个人历史 tab:本地插入最新记录,避免全量 reload
if (!this.data.isTeacher && this.data.tab === 'personal' && record && this.data.history.length > 0) {
const color = STATUS_META[record.status] || '#C67B30';
const newItem = {
...record,
timeText: fmtTime(record.checked_at),
color,
bg: color + '1A',
};
// 检查是否已有同一天的记录(更新而非插入)
const existingIdx = this.data.history.findIndex((h) => h.class_date === record.class_date);
let history;
if (existingIdx >= 0) {
history = this.data.history.slice();
history[existingIdx] = newItem;
} else {
history = [newItem].concat(this.data.history);
}
const historyTotal = Math.max(this.data.historyTotal, this.data.historyTotal + (existingIdx >= 0 ? 0 : 1));
this.setData({ history, historyTotal });
} else if (!this.data.isTeacher && this.data.tab === 'personal') {
this.loadHistory();
}
}).catch((err) => {
@@ -189,6 +379,7 @@ Page({
});
},
/** 学员 tab 切换 */
switchTab(e) {
const tab = e.currentTarget.dataset.tab;
if (tab !== 'group' && tab !== 'personal') {
@@ -196,16 +387,50 @@ Page({
}
this.setData({ tab });
if (tab === 'personal') {
// 每次切到个人 tab 都刷新历史数据
this.loadHistory();
} else if (tab === 'group' && this.data.myGroup) {
// 每次切回小组 tab 都刷新小组成员数据
this.loadGroup(this.data.myGroup);
}
},
onRefresh() {
this.setData({ refreshing: true });
const task = this.data.tab === 'group' ? this.loadWeek() : this.loadHistory();
Promise.resolve(task).then(() => {
this.loadWeek().then(() => {
// 学员个人 tab 也刷新一下
if (!this.data.isTeacher && this.data.tab === 'personal') {
return this.loadHistory();
}
}).then(() => {
this.setData({ refreshing: false });
});
}
});
},
// ---- 分享 ----
onShareAppMessage() {
const checked = this.data.checked;
const status = this.data.record && this.data.record.status;
const title = checked
? '我今天' + status + '啦,一起在明缘云道场打卡吧'
: '明缘云道场 · 每日打卡,共同成长';
return {
title,
path: '/pages/checkin/checkin'
};
},
onShareTimeline() {
return {
title: '明缘云道场 · 每周打卡,共同进步',
query: 'from=timeline'
};
},
/** 上拉加载更多(个人历史 tab) */
onReachBottom() {
if (!this.data.isTeacher && this.data.tab === 'personal') {
this.loadMoreHistory();
}
},
});

View File

@@ -1,3 +1,50 @@
<!-- 顶部打卡卡模板 -->
<template name="top-card">
<view class="card top-card {{checked ? 'done-card' : ''}}">
<view class="status-row">
<view class="date-chip" bindtap="{{weekList.length > 0 ? 'openWeekPicker' : ''}}" hover-class="{{weekList.length > 0 ? 'date-chip-hover' : ''}}">
<view class="date-week">{{weekday}}</view>
<view class="date-num">{{dayNum}}<text class="date-arrow" wx:if="{{weekList.length > 0}}"> ▾</text></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>
</template>
<!-- 成员列表项模板 -->
<template name="member-item">
<view 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>
</template>
<!-- 加载失败 -->
<block wx:if="{{loadError}}">
<view class="page">
@@ -10,7 +57,7 @@
</block>
<!-- 首次加载骨架屏 -->
<block wx:if="{{loading}}">
<block wx:if="{{loading && view === 'overview'}}">
<view class="page">
<view class="skeleton-card">
<view class="sk sk-title"></view>
@@ -23,86 +70,141 @@
</view>
</block>
<block wx:else>
<block wx:elif="{{!loading}}">
<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>
<!-- 老师总览视图 -->
<block wx:if="{{isTeacher && view === 'overview'}}">
<!-- 顶部打卡卡 -->
<template is="top-card" data="{{checked,weekList,weekday,dayNum,statusColor,record,statusItems,statusIndex,submitting}}"></template>
<!-- 小组统计列表 -->
<view class="record-panel">
<view class="section-title">小组统计</view>
<scroll-view class="record-scroll" scroll-y refresher-enabled refresher-triggered="{{refreshing}}" bindrefresherrefresh="onRefresh">
<view class="groups-list">
<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="{{groups.length === 0}}">
<view class="empty-icon">👥</view>
<view class="empty-text">暂无小组</view>
</view>
</view>
<view class="bottom-space"></view>
</scroll-view>
</view>
</block>
<!-- 老师详情视图 -->
<block wx:elif="{{isTeacher && view === 'detail'}}">
<view class="detail-head">
<view class="back" hover-class="back-hover" bindtap="backToOverview">‹ 返回</view>
<view class="detail-title-wrap">
<view class="detail-title">{{selectedGroupName}}</view>
<view class="detail-sub" bindtap="{{weekList.length > 0 ? 'openWeekPicker' : ''}}" hover-class="{{weekList.length > 0 ? 'head-hover' : ''}}">
{{classDate}} <text class="head-arrow" wx:if="{{weekList.length > 0}}"> ▾</text>
</view>
</view>
<view class="detail-progress">{{checkedCount}}/{{total}}</view>
</view>
<view class="pills-grid {{submitting ? 'disabled' : ''}}">
<scroll-view class="detail-scroll" scroll-y refresher-enabled refresher-triggered="{{refreshing}}" bindrefresherrefresh="onRefresh">
<view class="state-block" wx:if="{{loading}}">
<view class="state-icon">◌</view>
<view>加载中...</view>
</view>
<view class="member-list" wx:else>
<block wx:for="{{members}}" wx:key="user_id" wx:for-item="item">
<template is="member-item" data="{{item: item}}"></template>
</block>
</view>
<view class="empty" wx:if="{{!loading && members.length === 0}}">暂无成员</view>
<view class="bottom-space"></view>
</scroll-view>
</block>
<!-- 学员视图 -->
<block wx:else>
<!-- 顶部打卡卡 -->
<template is="top-card" data="{{checked,weekList,weekday,dayNum,statusColor,record,statusItems,statusIndex,submitting}}"></template>
<!-- 记录区:小组/个人 tab -->
<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="{{members.length === 0}}">
<view class="empty-icon">📭</view>
<view class="empty-text">本周暂无打卡记录</view>
</view>
<view class="member-list" wx:else>
<block wx:for="{{members}}" wx:key="student_id" wx:for-item="item">
<template is="member-item" data="{{item: item}}"></template>
</block>
</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 class="history-footer" wx:if="{{history.length > 0}}">
<text wx:if="{{historyLoading}}">加载中…</text>
<text wx:elif="{{historyFinished}}">— 已加载全部 —</text>
</view>
</view>
</scroll-view>
</view>
</block>
<!-- 周选择弹层 -->
<view class="picker-mask" wx:if="{{showWeekPicker}}" bindtap="closeWeekPicker"></view>
<view class="picker-panel {{showWeekPicker ? 'show' : ''}}" wx:if="{{showWeekPicker}}">
<view class="picker-title">选择上课周</view>
<scroll-view class="picker-list" scroll-y>
<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"
class="picker-item {{item === classDate ? 'active' : ''}}"
wx:for="{{weekList}}"
wx:key="*this"
data-date="{{item}}"
bindtap="onWeekSelect"
>
<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>
<text>{{item}}</text>
<text class="picker-check" wx:if="{{item === classDate}}">✓</text>
</view>
</scroll-view>
</view>
</view>
</block>
</block>

View File

@@ -313,6 +313,16 @@
font-size: 22rpx;
color: #999;
}
.history-note {
margin-top: 12rpx;
padding: 16rpx 20rpx;
background: #faf6f0;
border-radius: 8rpx;
font-size: 24rpx;
color: #5A4A3A;
line-height: 1.5;
word-break: break-all;
}
/* 空态 */
.empty {
@@ -390,5 +400,168 @@
font-weight: 700;
}
/* 日期 chip 可点击 */
.date-chip-hover {
opacity: 0.8;
}
.date-arrow {
font-size: 24rpx;
color: #C67B30;
margin-left: 6rpx;
}
/* ---- 老师视图:小组统计卡片 ---- */
.groups-list {
padding: 4rpx 4rpx 24rpx;
}
.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; }
.section-title {
padding: 16rpx 4rpx 12rpx;
font-size: 28rpx;
font-weight: 700;
color: #6B5D50;
}
/* ---- 老师详情视图 ---- */
.detail-head {
display: flex; align-items: center; gap: 16rpx;
padding: 8rpx 4rpx 16rpx;
flex-shrink: 0;
}
.detail-title-wrap { flex: 1; 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; }
.detail-title { font-size: 34rpx; font-weight: 800; color: #2C1810; }
.detail-sub { margin-top: 6rpx; font-size: 24rpx; color: #A99A89; }
.head-hover { opacity: .8; }
.head-arrow { color: #C67B30; font-size: 22rpx; }
.detail-progress {
flex-shrink: 0;
padding: 8rpx 20rpx;
border-radius: 999rpx;
font-size: 24rpx;
background: #FFF3E6;
color: #C67B30;
font-weight: 700;
}
.detail-scroll {
flex: 1; min-height: 0;
padding: 0 4rpx;
}
.member-list {
background: #fff;
border-radius: 20rpx;
padding: 8rpx 24rpx;
box-shadow: 0 6rpx 20rpx rgba(44, 24, 16, 0.05);
}
.bottom-space { height: 40rpx; }
.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} }
/* 周选择弹层 */
.picker-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.4);
z-index: 100;
}
.picker-panel {
position: fixed;
left: 0; right: 0; bottom: 0;
background: #fff;
border-radius: 28rpx 28rpx 0 0;
z-index: 101;
max-height: 70vh;
display: flex;
flex-direction: column;
transform: translateY(100%);
transition: transform .28s ease;
}
.picker-panel.show {
transform: translateY(0);
}
.picker-title {
flex-shrink: 0;
padding: 28rpx 32rpx 20rpx;
font-size: 30rpx;
font-weight: 700;
color: #2C1810;
text-align: center;
border-bottom: 1rpx solid #F2EDE7;
}
.picker-list {
flex: 1;
min-height: 0;
padding: 8rpx 0 32rpx;
}
.picker-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx;
font-size: 28rpx;
color: #2C1810;
}
.picker-item:active {
background: #FBF7F1;
}
.picker-item.active {
color: #C67B30;
font-weight: 700;
}
.picker-check {
font-size: 28rpx;
color: #C67B30;
}
/* 历史列表底部 */
.history-footer {
padding: 32rpx 0;
text-align: center;
font-size: 24rpx;
color: #B6A996;
}

View File

@@ -1,78 +1,94 @@
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 '加载失败,请稍后再试';
}
const H5_HOME_URL = api.H5_BASE_URL + '/';
Page({
data: {
// 登录态标记
isLoggedIn: false,
// web-view 模式
webviewUrl: '',
webviewError: false,
errorMsg: '',
webviewKey: 0,
// 游客模式
greeting: '',
displayName: '',
encouragement: '',
modules: [],
introData: null,
loading: true,
error: '',
skeletonItems: [0, 1, 2, 3, 4, 5, 6, 7, 8]
introData: null
},
onShow() { this.loadHome(); },
_webviewLoaded: false,
_lastToken: null,
_webviewKey: 0,
loadHome(silent) {
if (!silent) {
this.setData({ loading: true, error: '' });
onShow() {
const currentToken = auth.getToken();
const isLoggedIn = !!currentToken;
if (isLoggedIn) {
// 已登录:每次进入首页都重新加载 H5 首页(确保从内部页面回到首页)
this._lastToken = currentToken;
this.setData({ isLoggedIn: true });
this.loadWebview();
this._webviewLoaded = true;
} else {
// 游客模式:token 未变化且已有数据时不重新加载
if (currentToken === this._lastToken && this.data.displayName) {
return;
}
this._lastToken = currentToken;
this.setData({ isLoggedIn: false });
this.loadGuestHome();
this._webviewLoaded = false;
}
},
// ---- 已登录:web-view 模式 ----
loadWebview() {
const token = auth.getToken();
if (!token) {
// 游客模式:展示公开内容,不强制登录
this.setData({
loading: false,
error: '',
greeting: '欢迎来到明缘云道场',
displayName: '游客',
encouragement: '登录后即可打卡、提交计划',
modules: this.getGuestModules(),
introData: this.getIntroData()
});
return Promise.resolve();
}
return auth.ensureLogin()
.then((token) => {
if (!token) {
this.setData({
loading: false,
error: '',
greeting: '欢迎来到明缘云道场',
displayName: '游客',
encouragement: '登录后即可打卡、提交计划',
modules: this.getGuestModules(),
introData: this.getIntroData()
});
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),
introData: null
});
})
.catch((err) => {
this.setData({ loading: false, error: errMessage(err) });
});
const url = api.BASE_URL + '/api/v1/webview/redirect'
+ '?token=' + encodeURIComponent(token)
+ '&target=' + encodeURIComponent(H5_HOME_URL)
+ '&_t=' + Date.now();
// key 自增,强制 web-view 组件销毁重建,解决切 tab 回来白屏问题
// 时间戳参数确保 url 每次不同,强制重新加载并跳转到 H5 首页
this._webviewKey += 1;
this.setData({
webviewUrl: url,
webviewError: false,
errorMsg: '',
webviewKey: this._webviewKey
});
},
onWebviewError(e) {
this.setData({
webviewError: true,
errorMsg: (e && e.detail && e.detail.errMsg) || '加载失败,请检查网络后重试'
});
},
onRetry() {
this.setData({ webviewUrl: '', webviewError: false, errorMsg: '' });
setTimeout(() => {
this.loadWebview();
}, 50);
},
// ---- 未登录:游客原生首页 ----
loadGuestHome() {
this.setData({
greeting: '欢迎来到明缘云道场',
displayName: '游客',
encouragement: '登录后即可打卡、提交计划',
modules: this.getGuestModules(),
introData: this.getIntroData()
});
},
getGuestModules() {
@@ -111,47 +127,36 @@ Page({
};
},
onPullDownRefresh() {
this.loadHome(true).then(() => {
wx.stopPullDownRefresh();
});
},
onRetry() {
this.loadHome();
},
goLogin() {
wx.switchTab({ url: '/pages/profile/profile' });
},
onModuleTap(e) {
const url = e.currentTarget.dataset.url;
const token = auth.getToken();
// 游客模式:引导登录
if (!token) {
wx.showModal({
title: '登录提示',
content: '登录后使用此功能,是否前往登录?',
confirmText: '去登录',
confirmColor: '#C67B30',
success: (res) => {
if (res.confirm) {
wx.switchTab({ url: '/pages/profile/profile' });
}
wx.showModal({
title: '登录提示',
content: '登录后使用此功能,是否前往登录?',
confirmText: '去登录',
confirmColor: '#C67B30',
success: (res) => {
if (res.confirm) {
wx.switchTab({ url: '/pages/profile/profile' });
}
});
return;
}
if (!url) return;
}
});
},
// ---- 分享 ----
onShareAppMessage() {
return {
title: '明缘云道场 · 学习成长,每日精进',
path: '/pages/home/home'
};
},
// 通过 webview redirect 接口:后端验证 token 后设置 HttpOnly cookie,再 302 到目标页
// H5 页面内的同域请求会自动携带 cookie,H5 端零改动
const redirectUrl = api.BASE_URL + '/api/v1/webview/redirect'
+ '?token=' + encodeURIComponent(token)
+ '&target=' + encodeURIComponent(url);
wx.navigateTo({ url: '/pages/webview/webview?url=' + encodeURIComponent(redirectUrl) });
onShareTimeline() {
return {
title: '明缘云道场 · 打卡学习好工具',
query: 'from=timeline'
};
}
});

View File

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

View File

@@ -1,15 +1,16 @@
<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>
<!-- 已登录:web-view 模式 -->
<block wx:if="{{isLoggedIn}}">
<web-view src="{{webviewUrl}}" wx:if="{{webviewUrl && !webviewError}}" key="{{webviewKey}}" binderror="onWebviewError"></web-view>
<view class="error-page" wx:if="{{webviewError}}">
<view class="error-icon">!</view>
<view class="error-title">页面加载失败</view>
<view class="error-desc">{{errorMsg}}</view>
<view class="error-retry" hover-class="btn-hover" bindtap="onRetry">重新加载</view>
</view>
</block>
<!-- 未登录:原生游客首页 -->
<block wx:else>
<view class="hero">
<view class="hero-chip" wx:if="{{greeting}}">{{greeting}}</view>
@@ -20,11 +21,6 @@
</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"
@@ -40,7 +36,6 @@
</view>
</view>
<!-- 游客模式:明缘介绍 -->
<view class="intro-section" wx:if="{{introData && introData.sections}}">
<view class="intro-title">{{introData.title}}</view>
<view class="intro-card" wx:for="{{introData.sections}}" wx:key="heading">
@@ -51,7 +46,5 @@
</view>
</view>
</view>
<view class="empty" wx:if="{{modules.length === 0 && !error}}">暂无功能入口</view>
</block>
</view>

View File

@@ -36,12 +36,17 @@
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;
.hero-login-tip {
margin-top: 24rpx;
display: inline-block;
padding: 12rpx 28rpx;
background: rgba(198, 123, 48, 0.25);
border: 1rpx solid rgba(227, 179, 124, 0.4);
border-radius: 999rpx;
color: #E3B37C;
font-size: 24rpx;
}
.btn-hover { opacity: 0.7; }
/* ---- 模块网格 ---- */
.grid {
@@ -98,72 +103,6 @@
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; }
}
.hero-login-tip {
margin-top: 24rpx;
display: inline-block;
padding: 12rpx 28rpx;
background: rgba(198, 123, 48, 0.25);
border: 1rpx solid rgba(227, 179, 124, 0.4);
border-radius: 999rpx;
color: #E3B37C;
font-size: 24rpx;
}
/* ---- intro section ---- */
.intro-section {
margin-top: 40rpx;
@@ -205,4 +144,48 @@
font-size: 25rpx;
color: #5A4A3A;
line-height: 1.8;
}
}
/* ---- web-view 错误页 ---- */
.error-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 60rpx;
height: 100vh;
box-sizing: border-box;
}
.error-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: #FDEAEA;
color: #C0392B;
font-size: 64rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
}
.error-title {
font-size: 32rpx;
font-weight: 700;
color: #2C1810;
margin-bottom: 16rpx;
}
.error-desc {
font-size: 26rpx;
color: #8B7A6A;
text-align: center;
line-height: 1.6;
margin-bottom: 40rpx;
}
.error-retry {
padding: 20rpx 60rpx;
background: #C67B30;
color: #fff;
font-size: 28rpx;
border-radius: 999rpx;
}

View File

@@ -111,7 +111,7 @@ Page({
record,
content: record ? (record.content || '') : '',
attachments,
attachmentThumbs: attachments.map((p) => this.planFileUrl(p))
attachmentThumbs: attachments.map((p) => api.planFileUrl(p))
});
},
@@ -120,7 +120,7 @@ Page({
.then((res) => {
const history = (res.list || []).map((item) => ({
...item,
thumbSrcs: (item.attachments || []).map((p) => this.planFileUrl(p))
thumbSrcs: (item.attachments || []).map((p) => api.planFileUrl(p))
}));
this.setData({ history });
}).catch(() => {});
@@ -130,16 +130,6 @@ Page({
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 '';
}
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) {
@@ -173,7 +163,7 @@ Page({
if (index >= paths.length) {
this.setData({
uploading: false,
attachmentThumbs: attachments.map((p) => this.planFileUrl(p))
attachmentThumbs: attachments.map((p) => api.planFileUrl(p))
});
return;
}
@@ -196,7 +186,7 @@ Page({
attachments = attachments.concat(data.attachments).slice(0, MAX_IMAGES);
this.setData({
attachments,
attachmentThumbs: attachments.map((p) => this.planFileUrl(p))
attachmentThumbs: attachments.map((p) => api.planFileUrl(p))
});
} else {
wx.showToast({ title: data.error || '上传失败', icon: 'none' });
@@ -252,7 +242,7 @@ Page({
attachments.splice(index, 1);
this.setData({
attachments,
attachmentThumbs: attachments.map((p) => this.planFileUrl(p))
attachmentThumbs: attachments.map((p) => api.planFileUrl(p))
});
};
if (parts.length < 4) {
@@ -346,5 +336,20 @@ Page({
} else {
doPost();
}
},
// ---- 分享 ----
onShareAppMessage() {
const typeLabel = this.data.typeLabel;
return {
title: '明缘云道场 · ' + typeLabel + '提交',
path: '/pages/plan/plan'
};
},
onShareTimeline() {
return {
title: '明缘云道场 · 周/月/年计划,让成长有迹可循',
query: 'from=timeline'
};
}
});
});

View File

@@ -1,27 +1,48 @@
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
// 视图模式:overview 总览 / detail 小组详情(仅老师)
view: 'overview',
// 加载状态
loading: false,
refreshing: false,
// 当前周期
periodKey: '',
// 提交相关
canSubmit: false,
message: '',
// 我的计划状态
myChecked: false,
myStatus: '',
myRecord: null,
myThumbs: [],
// 未分组人数(仅老师可见)
unassignedCount: 0,
// 小组列表
groups: [],
// 当前选中的小组
selectedGroupName: '',
members: []
// 小组成员列表(老师详情 & 学员小组 tab)
members: [],
// 用户角色
isTeacher: false,
myGroup: '',
// 学员视图 tab:group / personal
studentTab: 'group',
// 个人历史列表
history: [],
// 周期切换相关
periodList: [], // 可用周期列表
showPeriodPicker: false,
},
onShow() {
@@ -33,53 +54,141 @@ Page({
}
wx.removeStorageSync('plan_overview_dirty');
this.setData({ view: 'overview' });
this.loadOverview();
}).catch(() => {});
// 先加载周期列表,再加载总览(总览接口会返回 is_teacher)
this.loadPeriods().then(() => {
this.loadOverview();
});
}).catch((err) => {
console.error('plan page init error:', err);
});
},
/** 切换计划类型(周/月/年) */
onTypeChange(e) {
const typeIndex = Number(e.currentTarget.dataset.index);
if (typeIndex === this.data.typeIndex) {
return;
}
this.setData({ typeIndex, view: 'overview', selectedGroupName: '', members: [] });
this.loadOverview();
this.setData({ typeIndex, view: 'overview', selectedGroupName: '', members: [], history: [], studentTab: 'group' });
// 切换类型后重新加载周期列表和总览
this.loadPeriods().then(() => {
this.loadOverview();
});
},
/** 下拉刷新 */
onRefresh() {
this.setData({ refreshing: true });
this.loadOverview();
this.loadPeriods().then(() => {
return this.loadOverview();
}).then(() => {
// 学员个人 tab 也刷一下历史
if (!this.data.isTeacher && this.data.studentTab === 'personal') {
return this.loadHistory();
}
}).then(() => {
this.setData({ refreshing: false });
});
},
/** 加载可用周期列表 */
loadPeriods() {
const type = TYPES[this.data.typeIndex];
return api.request({ url: '/api/v1/plan/periods', data: { type } })
.then((res) => {
const periods = res.periods || [];
// 默认选第一个(最新)周期,或者后端返回的当前周期
let periodKey = periods[0] || res.current_period || '';
this.setData({
periodList: periods,
periodKey: periodKey || this.data.periodKey,
});
})
.catch(() => {
// 获取失败不影响主流程
console.warn('获取周期列表失败');
});
},
/** 打开周期选择器 */
openPeriodPicker() {
if (!this.data.periodList.length) {
return;
}
this.setData({ showPeriodPicker: true });
},
/** 关闭周期选择器 */
closePeriodPicker() {
this.setData({ showPeriodPicker: false });
},
/** 选择某个周期 */
onPeriodSelect(e) {
const period = e.currentTarget.dataset.period;
if (!period || period === this.data.periodKey) {
this.setData({ showPeriodPicker: false });
return;
}
this.setData({
periodKey: period,
showPeriodPicker: false,
});
if (this.data.view === 'detail') {
// 详情页:重新加载当前小组的该周期数据
this.loadDetail();
} else if (!this.data.isTeacher && this.data.studentTab === 'group' && this.data.myGroup) {
// 学员总览 + 小组 tab:刷新本组成员
this.loadOverview();
} else {
// 老师总览 / 学员个人 tab:重新加载总览
this.loadOverview();
}
},
/** 加载总览数据 */
loadOverview() {
const type = TYPES[this.data.typeIndex];
const periodKey = this.data.periodKey;
this.setData({ loading: true });
api.request({ url: '/api/v1/plan/groups', data: { type } })
return api.request({ url: '/api/v1/plan/groups', data: { type, period_key: periodKey } })
.then((res) => {
const groups = (res.groups || []).map((g) => ({
...g,
percent: g.total ? Math.round(((g.checked_count || 0) / g.total) * 100) : 0
}));
const groups = (res.groups || []).map((g) => {
const percent = g.total ? Math.round(((g.checked_count || 0) / g.total) * 100) : 0;
return {
...g,
percent,
progressStyle: 'width:' + percent + '%;'
};
});
const isTeacher = !!res.is_teacher;
this.setData({
loading: false,
refreshing: false,
periodKey: res.period_key || '',
periodKey: res.period_key || periodKey,
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)),
myThumbs: ((res.my_record && res.my_record.attachments) || []).map((p) => api.planFileUrl(p)),
unassignedCount: res.unassigned_count || 0,
groups
isTeacher,
myGroup: res.my_group || '',
groups,
});
// 学员:如果在总览 + 小组 tab,加载本组成员
if (!isTeacher && this.data.view === 'overview' && this.data.studentTab === 'group' && res.my_group) {
return this.loadDetailForGroup(res.my_group);
}
})
.catch(() => {
this.setData({ loading: false, refreshing: false });
wx.showToast({ title: '加载小组失败', icon: 'none' });
wx.showToast({ title: '加载失败', icon: 'none' });
});
},
/** 打开小组详情 */
openGroup(e) {
const groupName = e.currentTarget.dataset.group;
if (!groupName) {
@@ -89,12 +198,29 @@ Page({
this.loadDetail();
},
/** 学员直接查看自己小组(只有一个小组时点击卡片直接进) */
openMyGroup() {
const groupName = this.data.myGroup;
if (!groupName) {
return;
}
this.setData({ selectedGroupName: groupName, view: 'detail', members: [] });
this.loadDetail();
},
/** 加载小组详情 */
loadDetail() {
const type = TYPES[this.data.typeIndex];
return this.loadDetailForGroup(this.data.selectedGroupName, type);
},
/** 加载指定小组的成员数据(不改变视图状态) */
loadDetailForGroup(groupName, type) {
type = type || TYPES[this.data.typeIndex];
this.setData({ loading: true });
api.request({
return api.request({
url: '/api/v1/plan/group',
data: { type, group_name: this.data.selectedGroupName, period_key: this.data.periodKey }
data: { type, group_name: groupName, period_key: this.data.periodKey }
}).then((res) => {
this.setData({
loading: false,
@@ -109,19 +235,44 @@ Page({
});
},
/** 学员切换 tab */
switchStudentTab(e) {
const tab = e.currentTarget.dataset.tab;
if (tab !== 'group' && tab !== 'personal') return;
this.setData({ studentTab: tab });
if (tab === 'personal') {
// 每次切到个人 tab 都刷新历史数据
this.loadHistory();
} else if (tab === 'group' && this.data.myGroup) {
// 每次切回小组 tab 都刷新小组成员数据
this.loadDetailForGroup(this.data.myGroup);
}
},
/** 加载个人计划历史 */
loadHistory() {
const type = TYPES[this.data.typeIndex];
return api.request({
url: '/api/v1/plan/history',
data: { type, page: 1, size: 20 }
}).then((res) => {
const history = (res.list || []).map((item) => ({
...item,
thumbs: (item.attachments || []).map((p) => api.planFileUrl(p)).slice(0, 3),
statusColor: item.status === '未完成' ? '#C67B30' : '#2E7D32',
statusBg: item.status === '未完成' ? '#FFF3E6' : '#E8F4EB',
}));
this.setData({ history });
}).catch(() => {});
},
/** 返回总览 */
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;
@@ -130,10 +281,11 @@ Page({
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' });
if (!this.data.canSubmit) {
wx.showToast({ title: this.data.message || '当前不可提交', icon: 'none' });
return;
}
const params = [
@@ -142,5 +294,26 @@ Page({
'group_name='
].join('&');
wx.navigateTo({ url: '/pages/plan-submit/plan-submit?' + params });
},
// ---- 分享 ----
onShareAppMessage() {
const type = this.data.types[this.data.typeIndex];
const checked = this.data.myChecked;
const title = checked
? '我的' + type + '已提交,来明缘云道场一起成长吧'
: '明缘云道场 · ' + type + '管理工具';
return {
title,
path: '/pages/plan/plan'
};
},
onShareTimeline() {
return {
title: '明缘云道场 · 周/月/年计划,让成长有迹可循',
query: 'from=timeline'
};
}
});
});

View File

@@ -1,6 +1,7 @@
<view class="page">
<block wx:if="{{view === 'overview'}}">
<view class="view-root">
<!-- 顶部类型切换 -->
<view class="type-tabs">
<view
class="type-tab {{typeIndex === index ? 'active' : ''}}"
@@ -19,14 +20,20 @@
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 class="head-sub" wx:if="{{isTeacher}}" bindtap="{{periodList.length > 0 ? 'openPeriodPicker' : ''}}" hover-class="{{periodList.length > 0 ? 'head-hover' : ''}}">
{{periodKey}}
<text class="head-arrow" wx:if="{{periodList.length > 0}}"> ▾</text>
</view>
<view class="head-sub" wx:else>{{periodKey}}</view>
</view>
<view class="head-pill">{{myChecked ? '已提交' : '待提交'}}</view>
<view class="head-pill {{myChecked ? 'checked' : ''}}">{{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">
@@ -37,59 +44,141 @@
class="btn-mystatus {{myChecked ? 'outline' : ''}}"
bindtap="goSubmit"
hover-class="btn-hover"
disabled="{{typeIndex === 0 && !canSubmit}}"
>{{myChecked ? '修改' : '新增打卡'}}</button>
disabled="{{!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="window-msg" wx:if="{{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>
<!-- 老师:显示所有小组列表 -->
<block wx:if="{{isTeacher}}">
<view class="section-title">小组统计</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="{{group.progressStyle}}"></view>
</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>
<view class="unassigned" wx:if="{{unassignedCount > 0}}">另有未分组 {{unassignedCount}} 人</view>
<view class="empty" wx:if="{{!loading && groups.length === 0}}">暂无小组</view>
</block>
<!-- 学员:tab 切换(小组/个人) -->
<block wx:else>
<view class="student-tabs">
<view class="student-tab {{studentTab === 'group' ? 'active' : ''}}" data-tab="group" hover-class="tab-hover" bindtap="switchStudentTab">小组</view>
<view class="student-tab {{studentTab === 'personal' ? 'active' : ''}}" data-tab="personal" hover-class="tab-hover" bindtap="switchStudentTab">个人</view>
</view>
<!-- 小组 tab:本组成员列表 -->
<view wx:if="{{studentTab === 'group'}}" class="student-group-panel">
<view
class="group-period-row"
wx:if="{{myGroup}}"
bindtap="{{periodList.length > 0 ? 'openPeriodPicker' : ''}}"
hover-class="{{periodList.length > 0 ? 'head-hover' : ''}}"
>
<text class="group-period-text">{{myGroup}} · {{periodKey}}</text>
<text class="head-arrow" wx:if="{{periodList.length > 0}}"> ▾</text>
</view>
<view class="state-block" wx:if="{{loading && members.length === 0}}">
<view class="state-icon">◌</view>
<view>加载中...</view>
</view>
<view class="member-list" wx:elif="{{members.length > 0}}">
<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:elif="{{!loading && !myGroup}}">
<view class="empty-icon">👥</view>
<view class="empty-text">暂无小组,请联系管理员分配</view>
</view>
<view class="empty" wx:elif="{{!loading && members.length === 0}}">
<view class="empty-icon">📭</view>
<view class="empty-text">暂无成员数据</view>
</view>
</view>
<!-- 个人 tab:历史记录 -->
<view wx:else class="student-history-panel">
<view class="empty" wx:if="{{history.length === 0}}">
<view class="empty-icon">📒</view>
<view class="empty-text">暂无{{types[typeIndex]}}记录</view>
</view>
<view class="history-card" wx:for="{{history}}" wx:key="id">
<view class="history-head">
<view class="history-period">{{item.period_key}}</view>
<view class="history-status" style="color:{{item.statusColor}};background:{{item.statusBg}}">{{item.status}}</view>
</view>
<view class="history-meta" wx:if="{{item.checked_at}}">{{item.checked_at}} 提交</view>
<view class="history-content" wx:if="{{item.content}}">{{item.content}}</view>
<view class="history-thumbs" wx:if="{{item.thumbs.length > 0}}">
<image class="history-thumb" wx:for="{{item.thumbs}}" wx:for-item="thumb" wx:key="*this" src="{{thumb}}" mode="aspectFill"></image>
</view>
</view>
</view>
</block>
<!-- 底部留白 -->
<view class="bottom-space"></view>
</scroll-view>
<!-- 周期选择弹层 -->
<view class="picker-mask" wx:if="{{showPeriodPicker}}" bindtap="closePeriodPicker"></view>
<view class="picker-panel {{showPeriodPicker ? 'show' : ''}}" wx:if="{{showPeriodPicker}}">
<view class="picker-title">选择周期</view>
<scroll-view class="picker-list" scroll-y>
<view
class="picker-item {{item === periodKey ? 'active' : ''}}"
wx:for="{{periodList}}"
wx:key="*this"
data-period="{{item}}"
bindtap="onPeriodSelect"
>
<text>{{item}}</text>
<text class="picker-check" wx:if="{{item === periodKey}}">✓</text>
</view>
</scroll-view>
</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 class="head-sub" bindtap="{{periodList.length > 0 ? 'openPeriodPicker' : ''}}" hover-class="{{periodList.length > 0 ? 'head-hover' : ''}}">
{{periodKey}} <text class="head-arrow" wx:if="{{periodList.length > 0}}"> ▾</text>
</view>
</view>
</view>
<scroll-view class="view-scroll" scroll-y>
@@ -111,6 +200,24 @@
</view>
<view class="empty" wx:if="{{!loading && members.length === 0}}">暂无成员</view>
</scroll-view>
<!-- 周期选择弹层(详情页也可用) -->
<view class="picker-mask" wx:if="{{showPeriodPicker}}" bindtap="closePeriodPicker"></view>
<view class="picker-panel {{showPeriodPicker ? 'show' : ''}}" wx:if="{{showPeriodPicker}}">
<view class="picker-title">选择周期</view>
<scroll-view class="picker-list" scroll-y>
<view
class="picker-item {{item === periodKey ? 'active' : ''}}"
wx:for="{{periodList}}"
wx:key="*this"
data-period="{{item}}"
bindtap="onPeriodSelect"
>
<text>{{item}}</text>
<text class="picker-check" wx:if="{{item === periodKey}}">✓</text>
</view>
</scroll-view>
</view>
</view>
</block>
</view>
</view>

View File

@@ -77,4 +77,173 @@
.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; }
.empty { padding: 64rpx 0; text-align: center; color: #A99A89; font-size: 26rpx; }
/* 小组标题 */
.section-title { padding: 8rpx 4rpx 16rpx; font-size: 28rpx; font-weight: 700; color: #6B5D50; }
/* 学员小组 tab:周期选择行 */
.group-period-row {
display: flex;
align-items: center;
padding: 12rpx 8rpx;
font-size: 28rpx;
font-weight: 700;
color: #C67B30;
}
.group-period-text {
flex: 1;
}
/* 学员单小组卡片 */
.student-group { margin-bottom: 20rpx; }
/* 学员 tab 切换 */
.student-tabs {
display: flex;
border-bottom: 1rpx solid #EEE;
margin: 8rpx 0 8rpx;
}
.student-tab {
flex: 1;
text-align: center;
padding: 22rpx 0;
color: #7A6A5C;
transition: color .15s ease;
font-size: 28rpx;
}
.student-tab.active {
color: #C67B30;
font-weight: 700;
border-bottom: 4rpx solid #C67B30;
transform: translateY(-2rpx);
}
.tab-hover {
background: #f6efe6;
}
.student-group-panel {
padding: 4rpx 0 24rpx;
}
.student-history-panel {
padding: 16rpx 0 24rpx;
}
/* 历史记录卡片 */
.history-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 18rpx;
box-shadow: 0 6rpx 20rpx rgba(44, 24, 16, 0.05);
}
.history-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.history-period {
font-size: 30rpx;
font-weight: 700;
color: #2C1810;
}
.history-status {
padding: 6rpx 16rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
}
.history-meta {
margin-top: 8rpx;
font-size: 22rpx;
color: #A99A89;
}
.history-content {
margin-top: 14rpx;
padding: 16rpx 20rpx;
background: #FBF7F1;
border-radius: 14rpx;
font-size: 26rpx;
line-height: 1.7;
color: #2C1810;
white-space: pre-wrap;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
.history-thumbs {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 14rpx;
}
.history-thumb {
width: 120rpx;
height: 120rpx;
border-radius: 12rpx;
background: #F2EDE7;
}
/* 周期选择相关 */
.head-hover { opacity: .8; }
.head-arrow { color: #C67B30; font-size: 22rpx; }
.picker-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.4);
z-index: 100;
}
.picker-panel {
position: fixed;
left: 0; right: 0; bottom: 0;
background: #fff;
border-radius: 28rpx 28rpx 0 0;
z-index: 101;
max-height: 70vh;
display: flex;
flex-direction: column;
transform: translateY(100%);
transition: transform .28s ease;
}
.picker-panel.show {
transform: translateY(0);
}
.picker-title {
flex-shrink: 0;
padding: 28rpx 32rpx 20rpx;
font-size: 30rpx;
font-weight: 700;
color: #2C1810;
text-align: center;
border-bottom: 1rpx solid #F2EDE7;
}
.picker-list {
flex: 1;
min-height: 0;
padding: 8rpx 0 32rpx;
}
.picker-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx;
font-size: 28rpx;
color: #2C1810;
}
.picker-item:active {
background: #FBF7F1;
}
.picker-item.active {
color: #C67B30;
font-weight: 700;
}
.picker-check {
font-size: 28rpx;
color: #C67B30;
}
/* 底部留白 */
.bottom-space { height: 40rpx; }

View File

@@ -14,5 +14,19 @@ Page({
const title = TITLES[type] || '隐私政策';
this.setData({ type, title });
wx.setNavigationBarTitle({ title });
},
// ---- 分享 ----
onShareAppMessage() {
const title = this.data.title || '明缘云道场';
return {
title,
path: '/pages/privacy/privacy?type=' + this.data.type
};
},
onShareTimeline() {
return {
title: this.data.title || '明缘云道场',
query: 'type=' + this.data.type
};
}
});

View File

@@ -40,7 +40,7 @@ Page({
},
onPullDownRefresh() {
this.refresh().then(() => wx.stopPullDownRefresh());
this.refresh().finally(() => wx.stopPullDownRefresh());
},
refresh() {
@@ -225,34 +225,42 @@ Page({
wx.navigateTo({ url: '/pages/privacy/privacy?type=' + type });
},
deleteAccount() {
logoutAccount() {
wx.showModal({
title: '注销账号',
content: '确定要注销账号吗?注销后将清除本机登录信息,需要重新登录。',
confirmText: '注销',
title: '退出登录',
content: '确定要退出登录吗?退出后将清除本机登录信息,需要重新登录。',
confirmText: '退出',
confirmColor: '#c0392b',
success: (res) => {
if (!res.confirm) return;
wx.showModal({
title: '再次确认',
content: '注销后本机登录状态将被清除,你可以使用手机号重新登录。确定继续吗?',
confirmText: '确认注销',
confirmColor: '#c0392b',
success: (res2) => {
if (!res2.confirm) return;
// 仅清除前端登录信息
try {
api.request({ url: '/api/v1/auth/logout', method: 'POST' }).catch(() => {});
} catch (e) {}
auth.clearToken();
wx.removeStorageSync('privacy_agreed');
wx.showToast({ title: '已注销', icon: 'success' });
setTimeout(() => {
wx.reLaunch({ url: '/pages/home/home' });
}, 1000);
}
});
try {
api.request({ url: '/api/v1/auth/logout', method: 'POST' }).catch(() => {});
} catch (e) {}
auth.clearToken();
wx.removeStorageSync('privacy_agreed');
wx.showToast({ title: '已退出', icon: 'success' });
setTimeout(() => {
wx.reLaunch({ url: '/pages/home/home' });
}, 1000);
}
});
},
// ---- 分享 ----
onShareAppMessage() {
const name = (this.data.profile && (this.data.profile.display_name || this.data.profile.name)) || '';
const title = name
? name + ' 推荐你用明缘云道场,打卡学习超好用'
: '推荐你用明缘云道场,打卡学习超好用';
return {
title,
path: '/pages/home/home?from=profile_share'
};
},
onShareTimeline() {
return {
title: '明缘云道场 · 学习成长,每日精进',
query: 'from=timeline'
};
}
});

View File

@@ -110,7 +110,7 @@
</view>
<view class="bottom-actions">
<button class="btn-delete" hover-class="btn-hover" bindtap="deleteAccount">注销账号</button>
<button class="btn-logout" hover-class="btn-hover" bindtap="logoutAccount">退出登录</button>
</view>
</view>

View File

@@ -227,11 +227,10 @@ button::after { border: none; }
}
/* ---- delete account ---- */
.btn-delete {
.btn-logout {
background: #fff;
color: #c0392b;
border: 1rpx solid #f0b4ae;
border-radius: 12rpx;
margin-bottom: 20rpx;
}
/* ---- profile name edit ---- */
}

View File

@@ -43,5 +43,19 @@ Page({
this.setData({ url: this.data._url || '' });
}, 50);
}
},
// ---- 分享 ----
onShareAppMessage() {
return {
title: '明缘云道场',
path: '/pages/home/home'
};
},
onShareTimeline() {
return {
title: '明缘云道场 · 学习成长,每日精进',
query: 'from=timeline'
};
}
});

View File

@@ -42,5 +42,6 @@
},
"setting": {
"urlCheck": false
}
},
"libVersion": "3.16.2"
}

View File

@@ -1,7 +1,43 @@
//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';
// 根据小程序版本自动选择后端地址
function _getBaseUrls() {
let envType = 'release';
try {
const accountInfo = wx.getAccountInfoSync();
envType = accountInfo.miniProgram.envVersion || 'release';
} catch (e) {
// 低版本基础库不支持时默认线上环境
envType = 'release';
}
switch (envType) {
case 'develop':
// 开发版:本地服务
return {
baseUrl: 'https://test.muguaclub.cn/clock',
staticUrl: 'https://test.muguaclub.cn',
h5Url: 'https://test.muguaclub.cn'
};
case 'trial':
// 体验版:测试环境
return {
baseUrl: 'https://test.muguaclub.cn/clock',
staticUrl: 'https://test.muguaclub.cn',
h5Url: 'https://test.muguaclub.cn'
};
case 'release':
default:
// 线上版:正式环境
return {
baseUrl: 'https://m.muguaclub.cn/clock',
staticUrl: 'https://m.muguaclub.cn',
h5Url: 'https://m.muguaclub.cn'
};
}
}
const _urls = _getBaseUrls();
const BASE_URL = _urls.baseUrl;
const STATIC_BASE_URL = _urls.staticUrl;
const H5_BASE_URL = _urls.h5Url;
let redirectingProfile = false;
@@ -73,4 +109,16 @@ function request(options) {
});
}
module.exports = { request, BASE_URL, STATIC_BASE_URL };
/**
* 把计划附件的相对路径转为完整的静态资源 URL。
* 路径格式:attachments/{user_id}/{plan_type}/{period_key}/{filename}
*/
function 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 STATIC_BASE_URL + '/clock/static/plan/' + parts.slice(0, 4).map(encodeURIComponent).join('/');
}
module.exports = { request, planFileUrl, BASE_URL, STATIC_BASE_URL, H5_BASE_URL };