feat(app): 添加课程页面并优化打卡和计划功能
- 新增 pages/course/course 页面,集成 web-view 显示线上课程列表 - 在 app.json 中注册课程页面并添加底部 tabbar 导航 - 优化打卡页面,添加学号显示和改进成员列表布局 - 重构计划页面,添加内容详情弹层和优化加载逻辑 - 添加阿里云 OSS 上传工具模块用于文件上传功能 - 统一姓名和学号在同一行显示,改善 UI 体验 - 修复多个页面中的绑定事件和样式问题
This commit is contained in:
7
app.json
7
app.json
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/home/home",
|
||||
"pages/course/course",
|
||||
"pages/checkin/checkin",
|
||||
"pages/plan/plan",
|
||||
"pages/plan-submit/plan-submit",
|
||||
@@ -27,6 +28,12 @@
|
||||
"iconPath": "assets/tabbar/home.png",
|
||||
"selectedIconPath": "assets/tabbar/home-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/course/course",
|
||||
"text": "课程",
|
||||
"iconPath": "assets/tabbar/course.png",
|
||||
"selectedIconPath": "assets/tabbar/course-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/checkin/checkin",
|
||||
"text": "打卡",
|
||||
|
||||
BIN
assets/tabbar/course-active.png
Normal file
BIN
assets/tabbar/course-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 963 B |
BIN
assets/tabbar/course.png
Normal file
BIN
assets/tabbar/course.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 914 B |
@@ -2,7 +2,7 @@
|
||||
<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-chip" bindtap="openWeekPicker" hover-class="{{weekList.length > 0 ? 'date-chip-hover' : 'none'}}">
|
||||
<view class="date-week">{{weekday}}</view>
|
||||
<view class="date-num">{{dayNum}}<text class="date-arrow" wx:if="{{weekList.length > 0}}"> ▾</text></view>
|
||||
</view>
|
||||
@@ -38,7 +38,10 @@
|
||||
<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-line">
|
||||
<text class="member-name">{{item.name}}</text>
|
||||
<text class="member-sid" wx:if="{{item.student_id}}">学号 {{item.student_id}}</text>
|
||||
</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>
|
||||
@@ -115,7 +118,7 @@
|
||||
<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' : ''}}">
|
||||
<view class="detail-sub" bindtap="openWeekPicker" hover-class="{{weekList.length > 0 ? 'head-hover' : 'none'}}">
|
||||
{{classDate}} <text class="head-arrow" wx:if="{{weekList.length > 0}}"> ▾</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -163,7 +166,7 @@
|
||||
<view class="empty-text">本周暂无打卡记录</view>
|
||||
</view>
|
||||
<view class="member-list" wx:else>
|
||||
<block wx:for="{{members}}" wx:key="student_id" wx:for-item="item">
|
||||
<block wx:for="{{members}}" wx:key="user_id" wx:for-item="item">
|
||||
<template is="member-item" data="{{item: item}}"></template>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
@@ -257,9 +257,24 @@
|
||||
min-width: 0;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
/* 姓名与学号同一行;姓名过长时省略,学号始终完整 */
|
||||
.member-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.member-name {
|
||||
font-size: 28rpx;
|
||||
color: #2C1810;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.member-sid {
|
||||
font-size: 22rpx;
|
||||
color: #8B7A6A;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.member.ok .member-name {
|
||||
color: #2E7D32;
|
||||
|
||||
103
pages/course/course.js
Normal file
103
pages/course/course.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// 课程中心:全屏 web-view 加载线上课程列表
|
||||
//
|
||||
// 注意:课程页部署在 www.muguaclub.cn,与首页 H5 的 m.muguaclub.cn 是不同主机名,
|
||||
// www 必须单独加入微信公众平台「开发设置 → 业务域名」并在该站点根目录放校验文件,
|
||||
// 否则真机打开会白屏。
|
||||
// 登录态:课程页本体是公开的,但产品要求「未登录不进课程页」——未登录时切到「我的」引导登录,
|
||||
// 因此也不需要走 webview/redirect 换 cookie。
|
||||
const auth = require('../../utils/auth');
|
||||
|
||||
const COURSE_URL = 'https://www.muguaclub.cn/courses/index.html';
|
||||
|
||||
Page({
|
||||
data: {
|
||||
webviewUrl: '',
|
||||
webviewError: false,
|
||||
errorMsg: '',
|
||||
webviewKey: 0
|
||||
},
|
||||
|
||||
_webviewKey: 0,
|
||||
_redirecting: false,
|
||||
|
||||
onShow() {
|
||||
this.ensureAccess();
|
||||
},
|
||||
|
||||
// 未登录不进课程页,直接切到「我的」引导登录
|
||||
ensureAccess() {
|
||||
if (auth.getToken()) {
|
||||
this.loadWebview();
|
||||
return;
|
||||
}
|
||||
// token 可能还在 App.onLaunch 发起的登录流程里,复用同一个登录单例再判一次,避免冷启动误跳
|
||||
return auth.ensureLogin()
|
||||
.then((token) => {
|
||||
if (token) {
|
||||
this.loadWebview();
|
||||
return;
|
||||
}
|
||||
this.denyAccess();
|
||||
})
|
||||
.catch(() => {
|
||||
this.denyAccess();
|
||||
});
|
||||
},
|
||||
|
||||
// 清掉可能残留的 web-view(如退出登录后切回本 tab),再切到「我的」
|
||||
denyAccess() {
|
||||
if (this.data.webviewUrl) {
|
||||
this.setData({ webviewUrl: '', webviewError: false, errorMsg: '' });
|
||||
}
|
||||
if (this._redirecting) {
|
||||
return;
|
||||
}
|
||||
this._redirecting = true;
|
||||
wx.switchTab({
|
||||
url: '/pages/profile/profile',
|
||||
complete: () => {
|
||||
this._redirecting = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
loadWebview() {
|
||||
// key 自增 + 时间戳:强制 web-view 销毁重建,避免切 tab 回来白屏或读到上一次的缓存
|
||||
this._webviewKey += 1;
|
||||
this.setData({
|
||||
webviewUrl: COURSE_URL + '?_t=' + Date.now(),
|
||||
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();
|
||||
}, 60);
|
||||
},
|
||||
|
||||
// ---- 分享 ----
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: '明缘云道场 · 全部课程',
|
||||
path: '/pages/course/course'
|
||||
};
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: '明缘云道场 · 全部课程',
|
||||
query: 'from=timeline'
|
||||
};
|
||||
}
|
||||
});
|
||||
4
pages/course/course.json
Normal file
4
pages/course/course.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"usingComponents": {},
|
||||
"navigationBarTitleText": "全部课程"
|
||||
}
|
||||
9
pages/course/course.wxml
Normal file
9
pages/course/course.wxml
Normal file
@@ -0,0 +1,9 @@
|
||||
<view class="page">
|
||||
<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>
|
||||
</view>
|
||||
51
pages/course/course.wxss
Normal file
51
pages/course/course.wxss
Normal file
@@ -0,0 +1,51 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #FDFBF7;
|
||||
}
|
||||
|
||||
/* ---- 加载失败兜底(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;
|
||||
}
|
||||
.btn-hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -5,6 +5,16 @@ const auth = require('../../utils/auth');
|
||||
const TYPES = ['weekly', 'monthly', 'yearly'];
|
||||
const TYPE_LABELS = ['周计划', '月计划', '年计划'];
|
||||
|
||||
// 个人历史卡片里最多展示的缩略图数量,其余图片在「查看详情」弹层里看全部
|
||||
const HISTORY_THUMB_LIMIT = 2;
|
||||
|
||||
// 说明:本页所有「切换状态 + 紧跟一次加载」的流程,都要求把 loading 合并进
|
||||
// 调用方那一次 setData(加载函数传 silent=true),不要在同一个 tick 里连发两次
|
||||
// setData —— 否则会触发 "recursive update detected: a data update is applying
|
||||
// during another data update" 警告(数据应用到 WXML 是同步过程,见官方文档
|
||||
// 「高级数据更新方法 / 递归更新问题」)。
|
||||
// 同理,refreshing 只由 onRefresh 写:它绑定的是 scroll-view 的
|
||||
// refresher-triggered(受控属性),别的流程顺手写它容易与组件自身状态互相回写。
|
||||
Page({
|
||||
data: {
|
||||
// 类型切换相关
|
||||
@@ -43,6 +53,8 @@ Page({
|
||||
// 周期切换相关
|
||||
periodList: [], // 可用周期列表
|
||||
showPeriodPicker: false,
|
||||
// 内容详情弹层(点击文章 / 图片后展示全文与全部图片)
|
||||
contentDetail: null,
|
||||
},
|
||||
|
||||
onShow() {
|
||||
@@ -87,6 +99,7 @@ Page({
|
||||
return this.loadHistory();
|
||||
}
|
||||
}).then(() => {
|
||||
// refreshing 只在这里复位:它是 scroll-view refresher-triggered 的受控值
|
||||
this.setData({ refreshing: false });
|
||||
});
|
||||
},
|
||||
@@ -130,27 +143,25 @@ Page({
|
||||
this.setData({ showPeriodPicker: false });
|
||||
return;
|
||||
}
|
||||
this.setData({
|
||||
periodKey: period,
|
||||
showPeriodPicker: false,
|
||||
});
|
||||
if (this.data.view === 'detail') {
|
||||
// 详情判断要在 setData 之前取,视图切换与 loading 合并成同一次更新
|
||||
const inDetail = this.data.view === 'detail';
|
||||
this.setData({ periodKey: period, showPeriodPicker: false, loading: true });
|
||||
if (inDetail) {
|
||||
// 详情页:重新加载当前小组的该周期数据
|
||||
this.loadDetail();
|
||||
} else if (!this.data.isTeacher && this.data.studentTab === 'group' && this.data.myGroup) {
|
||||
// 学员总览 + 小组 tab:刷新本组成员
|
||||
this.loadOverview();
|
||||
this.loadDetail(true);
|
||||
} else {
|
||||
// 老师总览 / 学员个人 tab:重新加载总览
|
||||
this.loadOverview();
|
||||
// 总览:老师看小组统计,学员看本组成员(loadOverview 内部会接着拉成员)
|
||||
this.loadOverview(true);
|
||||
}
|
||||
},
|
||||
|
||||
/** 加载总览数据 */
|
||||
loadOverview() {
|
||||
/** 加载总览数据(silent=true 时不写 loading,由调用方合并到同一次 setData) */
|
||||
loadOverview(silent) {
|
||||
const type = TYPES[this.data.typeIndex];
|
||||
const periodKey = this.data.periodKey;
|
||||
if (!silent) {
|
||||
this.setData({ loading: true });
|
||||
}
|
||||
return api.request({ url: '/api/v1/plan/groups', data: { type, period_key: periodKey } })
|
||||
.then((res) => {
|
||||
const groups = (res.groups || []).map((g) => {
|
||||
@@ -162,9 +173,12 @@ Page({
|
||||
};
|
||||
});
|
||||
const isTeacher = !!res.is_teacher;
|
||||
// 学员总览的「小组」tab 紧接着还要拉一次本组成员:把 loading 直接定成
|
||||
// 最终值,避免「先置 false 再置 true」的连续更新,也避免中间态闪出空列表
|
||||
const willLoadMembers = !isTeacher && this.data.view === 'overview'
|
||||
&& this.data.studentTab === 'group' && !!res.my_group;
|
||||
this.setData({
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
loading: willLoadMembers,
|
||||
periodKey: res.period_key || periodKey,
|
||||
canSubmit: !!res.can_submit,
|
||||
message: res.message || '',
|
||||
@@ -177,13 +191,13 @@ Page({
|
||||
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);
|
||||
// 学员:如果在总览 + 小组 tab,加载本组成员(loading 已在上面一并写入)
|
||||
if (willLoadMembers) {
|
||||
return this.loadDetailForGroup(res.my_group, undefined, true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.setData({ loading: false, refreshing: false });
|
||||
this.setData({ loading: false });
|
||||
wx.showToast({ title: '加载失败', icon: 'none' });
|
||||
});
|
||||
},
|
||||
@@ -194,8 +208,9 @@ Page({
|
||||
if (!groupName) {
|
||||
return;
|
||||
}
|
||||
this.setData({ selectedGroupName: groupName, view: 'detail', members: [] });
|
||||
this.loadDetail();
|
||||
// 视图切换与 loading 合并成同一次 setData,避免同一 tick 连发两次更新
|
||||
this.setData({ selectedGroupName: groupName, view: 'detail', members: [], loading: true });
|
||||
this.loadDetail(true);
|
||||
},
|
||||
|
||||
/** 学员直接查看自己小组(只有一个小组时点击卡片直接进) */
|
||||
@@ -204,30 +219,37 @@ Page({
|
||||
if (!groupName) {
|
||||
return;
|
||||
}
|
||||
this.setData({ selectedGroupName: groupName, view: 'detail', members: [] });
|
||||
this.loadDetail();
|
||||
this.setData({ selectedGroupName: groupName, view: 'detail', members: [], loading: true });
|
||||
this.loadDetail(true);
|
||||
},
|
||||
|
||||
/** 加载小组详情 */
|
||||
loadDetail() {
|
||||
/** 加载小组详情(silent=true 表示 loading 已由调用方写入) */
|
||||
loadDetail(silent) {
|
||||
const type = TYPES[this.data.typeIndex];
|
||||
return this.loadDetailForGroup(this.data.selectedGroupName, type);
|
||||
return this.loadDetailForGroup(this.data.selectedGroupName, type, silent);
|
||||
},
|
||||
|
||||
/** 加载指定小组的成员数据(不改变视图状态) */
|
||||
loadDetailForGroup(groupName, type) {
|
||||
loadDetailForGroup(groupName, type, silent) {
|
||||
type = type || TYPES[this.data.typeIndex];
|
||||
if (!silent) {
|
||||
this.setData({ loading: true });
|
||||
}
|
||||
return api.request({
|
||||
url: '/api/v1/plan/group',
|
||||
data: { type, group_name: groupName, period_key: this.data.periodKey }
|
||||
}).then((res) => {
|
||||
// 附件相对路径转完整 URL(列表已不渲染缩略图,allThumbs 全量留给详情弹层)
|
||||
const members = (res.members || []).map((m) => {
|
||||
const all = (((m.record || {}).attachments) || []).map((p) => api.planFileUrl(p));
|
||||
return { ...m, allThumbs: all };
|
||||
});
|
||||
this.setData({
|
||||
loading: false,
|
||||
periodKey: res.period_key || this.data.periodKey,
|
||||
canSubmit: !!res.can_submit,
|
||||
message: res.message || '',
|
||||
members: res.members || []
|
||||
members
|
||||
});
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
@@ -239,13 +261,18 @@ Page({
|
||||
switchStudentTab(e) {
|
||||
const tab = e.currentTarget.dataset.tab;
|
||||
if (tab !== 'group' && tab !== 'personal') return;
|
||||
this.setData({ studentTab: tab });
|
||||
if (tab === 'personal') {
|
||||
// 每次切到个人 tab 都刷新历史数据
|
||||
this.setData({ studentTab: tab });
|
||||
this.loadHistory();
|
||||
} else if (tab === 'group' && this.data.myGroup) {
|
||||
// 每次切回小组 tab 都刷新小组成员数据
|
||||
this.loadDetailForGroup(this.data.myGroup);
|
||||
return;
|
||||
}
|
||||
if (this.data.myGroup) {
|
||||
// 每次切回小组 tab 都刷新小组成员数据;loading 合并进这次 setData
|
||||
this.setData({ studentTab: tab, loading: true });
|
||||
this.loadDetailForGroup(this.data.myGroup, undefined, true);
|
||||
} else {
|
||||
this.setData({ studentTab: tab });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -256,30 +283,75 @@ Page({
|
||||
url: '/api/v1/plan/history',
|
||||
data: { type, page: 1, size: 20 }
|
||||
}).then((res) => {
|
||||
const history = (res.list || []).map((item) => ({
|
||||
const history = (res.list || []).map((item) => {
|
||||
const all = (item.attachments || []).map((p) => api.planFileUrl(p));
|
||||
return {
|
||||
...item,
|
||||
thumbs: (item.attachments || []).map((p) => api.planFileUrl(p)).slice(0, 3),
|
||||
// 卡片里只铺前 2 张缩略图,弹层用 allThumbs 看全部
|
||||
thumbs: all.slice(0, HISTORY_THUMB_LIMIT),
|
||||
allThumbs: all,
|
||||
statusColor: item.status === '未完成' ? '#C67B30' : '#2E7D32',
|
||||
statusBg: item.status === '未完成' ? '#FFF3E6' : '#E8F4EB',
|
||||
}));
|
||||
};
|
||||
});
|
||||
this.setData({ history });
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
/** 返回总览 */
|
||||
backToOverview() {
|
||||
this.setData({ view: 'overview', selectedGroupName: '', members: [] });
|
||||
this.loadOverview();
|
||||
this.setData({ view: 'overview', selectedGroupName: '', members: [], loading: true });
|
||||
this.loadOverview(true);
|
||||
},
|
||||
|
||||
/** 预览我的图片 */
|
||||
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 });
|
||||
/** 点击某个成员的信息行 → 打开详情弹层 */
|
||||
openMemberDetail(e) {
|
||||
const mi = Number(e.currentTarget.dataset.mi);
|
||||
const m = this.data.members[mi];
|
||||
if (!m || !m.record) return;
|
||||
const rec = m.record || {};
|
||||
this.setData({
|
||||
contentDetail: {
|
||||
title: m.name || '',
|
||||
sub: m.student_id ? '学号 ' + m.student_id : '',
|
||||
meta: [this.data.periodKey, rec.checked_at ? rec.checked_at + ' 提交' : ''].filter(Boolean).join(' · '),
|
||||
content: rec.content || '',
|
||||
thumbs: m.allThumbs || [],
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 点击我的一条历史记录 → 打开详情弹层 */
|
||||
openHistoryDetail(e) {
|
||||
const hi = Number(e.currentTarget.dataset.hi);
|
||||
const item = this.data.history[hi];
|
||||
if (!item) return;
|
||||
this.setData({
|
||||
contentDetail: {
|
||||
title: item.period_key || '',
|
||||
sub: item.status || '',
|
||||
meta: item.checked_at ? item.checked_at + ' 提交' : '',
|
||||
content: item.content || '',
|
||||
thumbs: item.allThumbs || [],
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 关闭内容详情弹层 */
|
||||
closeContentDetail() {
|
||||
this.setData({ contentDetail: null });
|
||||
},
|
||||
|
||||
/** 在详情弹层内放大某张图片 */
|
||||
previewDetailImage(e) {
|
||||
const ci = Number(e.currentTarget.dataset.ci) || 0;
|
||||
const urls = (this.data.contentDetail && this.data.contentDetail.thumbs) || [];
|
||||
if (!urls.length) return;
|
||||
wx.previewImage({ current: urls[ci] || urls[0], urls });
|
||||
},
|
||||
|
||||
/** 占位:遮罩层用 catchtouchmove 阻止滚动穿透 */
|
||||
noop() {},
|
||||
|
||||
/** 去提交计划 */
|
||||
goSubmit() {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<view
|
||||
class="type-tab {{typeIndex === index ? 'active' : ''}}"
|
||||
wx:for="{{types}}"
|
||||
wx:key="index"
|
||||
wx:key="*this"
|
||||
data-index="{{index}}"
|
||||
hover-class="type-tab-hover"
|
||||
bindtap="onTypeChange"
|
||||
@@ -24,7 +24,7 @@
|
||||
<view class="overview-head">
|
||||
<view class="head-title-wrap">
|
||||
<view class="head-title">{{types[typeIndex]}}</view>
|
||||
<view class="head-sub" wx:if="{{isTeacher}}" bindtap="{{periodList.length > 0 ? 'openPeriodPicker' : ''}}" hover-class="{{periodList.length > 0 ? 'head-hover' : ''}}">
|
||||
<view class="head-sub" wx:if="{{isTeacher}}" bindtap="openPeriodPicker" hover-class="{{periodList.length > 0 ? 'head-hover' : 'none'}}">
|
||||
{{periodKey}}
|
||||
<text class="head-arrow" wx:if="{{periodList.length > 0}}"> ▾</text>
|
||||
</view>
|
||||
@@ -93,8 +93,8 @@
|
||||
<view
|
||||
class="group-period-row"
|
||||
wx:if="{{myGroup}}"
|
||||
bindtap="{{periodList.length > 0 ? 'openPeriodPicker' : ''}}"
|
||||
hover-class="{{periodList.length > 0 ? 'head-hover' : ''}}"
|
||||
bindtap="openPeriodPicker"
|
||||
hover-class="{{periodList.length > 0 ? 'head-hover' : 'none'}}"
|
||||
>
|
||||
<text class="group-period-text">{{myGroup}} · {{periodKey}}</text>
|
||||
<text class="head-arrow" wx:if="{{periodList.length > 0}}"> ▾</text>
|
||||
@@ -103,18 +103,24 @@
|
||||
<view class="state-icon">◌</view>
|
||||
<view>加载中...</view>
|
||||
</view>
|
||||
<!-- 学员小组 tab:只展示成员信息,不展示任何人的打卡内容,也不提供详情入口 -->
|
||||
<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" wx:for="{{members}}" wx:key="user_id" wx:for-item="member" wx:for-index="mi">
|
||||
<view class="member-row">
|
||||
<view class="member-left">
|
||||
<view class="member-avatar {{member.filled ? 'done' : 'unchecked'}}">{{index + 1}}</view>
|
||||
<view class="member-avatar {{member.filled ? 'done' : 'unchecked'}}">{{mi + 1}}</view>
|
||||
<view class="member-info">
|
||||
<view class="member-name">{{member.name}}</view>
|
||||
<view class="member-line">
|
||||
<text class="member-name">{{member.name}}</text>
|
||||
<text class="member-sid" wx:if="{{member.student_id}}">学号 {{member.student_id}}</text>
|
||||
</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>
|
||||
<view class="empty" wx:elif="{{!loading && !myGroup}}">
|
||||
<view class="empty-icon">👥</view>
|
||||
<view class="empty-text">暂无小组,请联系管理员分配</view>
|
||||
@@ -131,7 +137,13 @@
|
||||
<view class="empty-icon">📒</view>
|
||||
<view class="empty-text">暂无{{types[typeIndex]}}记录</view>
|
||||
</view>
|
||||
<view class="history-card" wx:for="{{history}}" wx:key="id">
|
||||
<!-- 卡片直接铺内容:正文 3 行截断、缩略图最多 2 张;全部文字与图片在「查看详情」弹层里看 -->
|
||||
<view
|
||||
class="history-card"
|
||||
wx:for="{{history}}"
|
||||
wx:key="id"
|
||||
wx:for-index="hi"
|
||||
>
|
||||
<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>
|
||||
@@ -139,8 +151,17 @@
|
||||
<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>
|
||||
<image
|
||||
class="history-thumb"
|
||||
wx:for="{{item.thumbs}}"
|
||||
wx:for-item="thumb"
|
||||
wx:for-index="ti"
|
||||
wx:key="*this"
|
||||
src="{{thumb}}"
|
||||
mode="aspectFill"
|
||||
></image>
|
||||
</view>
|
||||
<view class="tap-hint" data-hi="{{hi}}" hover-class="tap-hint-hover" bindtap="openHistoryDetail">查看详情 ›</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
@@ -176,7 +197,7 @@
|
||||
<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" bindtap="{{periodList.length > 0 ? 'openPeriodPicker' : ''}}" hover-class="{{periodList.length > 0 ? 'head-hover' : ''}}">
|
||||
<view class="head-sub" bindtap="openPeriodPicker" hover-class="{{periodList.length > 0 ? 'head-hover' : 'none'}}">
|
||||
{{periodKey}} <text class="head-arrow" wx:if="{{periodList.length > 0}}"> ▾</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -187,16 +208,37 @@
|
||||
<view>加载中...</view>
|
||||
</view>
|
||||
<view class="member-list">
|
||||
<view class="member" wx:for="{{members}}" wx:key="user_id" wx:for-item="member">
|
||||
<view class="member" wx:for="{{members}}" wx:key="user_id" wx:for-item="member" wx:for-index="mi">
|
||||
<!-- 已提交者整行可点,全文与全部图片在详情弹层里看(未提交者 handler 内部会直接返回) -->
|
||||
<view
|
||||
class="member-row"
|
||||
data-mi="{{mi}}"
|
||||
hover-class="{{member.filled ? 'member-row-hover' : 'none'}}"
|
||||
bindtap="openMemberDetail"
|
||||
>
|
||||
<view class="member-left">
|
||||
<view class="member-avatar {{member.filled ? 'done' : 'unchecked'}}">{{index + 1}}</view>
|
||||
<view class="member-avatar {{member.filled ? 'done' : 'unchecked'}}">{{mi + 1}}</view>
|
||||
<view class="member-info">
|
||||
<view class="member-name">{{member.name}}</view>
|
||||
<view class="member-line">
|
||||
<text class="member-name">{{member.name}}</text>
|
||||
<text class="member-sid" wx:if="{{member.student_id}}">学号 {{member.student_id}}</text>
|
||||
</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>
|
||||
<!-- 提示文字是 .member-row 的兄弟节点,必须自己绑事件,否则点它没有任何节点接收
|
||||
(不要挪进 .member-row 里 —— tap 会冒泡到父行导致 openMemberDetail 执行两次,
|
||||
同 tick 连发 setData 会触发 recursive update detected) -->
|
||||
<view
|
||||
class="tap-hint"
|
||||
wx:if="{{member.filled}}"
|
||||
data-mi="{{mi}}"
|
||||
hover-class="tap-hint-hover"
|
||||
bindtap="openMemberDetail"
|
||||
>点击查看详情 ›</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="empty" wx:if="{{!loading && members.length === 0}}">暂无成员</view>
|
||||
</scroll-view>
|
||||
@@ -220,4 +262,34 @@
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 内容详情弹层:完整文章 + 全部图片 -->
|
||||
<view class="cd-mask" wx:if="{{contentDetail}}" bindtap="closeContentDetail" catchtouchmove="noop"></view>
|
||||
<view class="cd-panel" wx:if="{{contentDetail}}">
|
||||
<view class="cd-head">
|
||||
<view class="cd-head-text">
|
||||
<view class="cd-title">{{contentDetail.title}}</view>
|
||||
<view class="cd-sub" wx:if="{{contentDetail.sub}}">{{contentDetail.sub}}</view>
|
||||
</view>
|
||||
<view class="cd-close" hover-class="cd-close-hover" bindtap="closeContentDetail">✕</view>
|
||||
</view>
|
||||
<view class="cd-meta" wx:if="{{contentDetail.meta}}">{{contentDetail.meta}}</view>
|
||||
<scroll-view class="cd-body" scroll-y>
|
||||
<view class="cd-content" wx:if="{{contentDetail.content}}">{{contentDetail.content}}</view>
|
||||
<view class="cd-no-content" wx:if="{{!contentDetail.content}}">未填写文字内容</view>
|
||||
<view class="cd-thumbs" wx:if="{{contentDetail.thumbs.length > 0}}">
|
||||
<image
|
||||
class="cd-thumb"
|
||||
wx:for="{{contentDetail.thumbs}}"
|
||||
wx:for-item="pic"
|
||||
wx:for-index="ci"
|
||||
wx:key="*this"
|
||||
src="{{pic}}"
|
||||
mode="widthFix"
|
||||
data-ci="{{ci}}"
|
||||
bindtap="previewDetailImage"
|
||||
></image>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -62,13 +62,17 @@
|
||||
|
||||
.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 { display: flex; flex-direction: column; align-items: stretch; padding: 22rpx 0; border-bottom: 1rpx solid #F2EDE7; }
|
||||
.member:last-child { border-bottom: none; }
|
||||
.member-row { display: flex; align-items: center; justify-content: space-between; }
|
||||
.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-line { display: flex; align-items: baseline; gap: 12rpx; }
|
||||
.member-name { font-size: 28rpx; font-weight: 700; color: #2C1810; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.member-sid { font-size: 22rpx; color: #A99A89; flex-shrink: 0; }
|
||||
.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; }
|
||||
@@ -158,6 +162,7 @@
|
||||
font-size: 22rpx;
|
||||
color: #A99A89;
|
||||
}
|
||||
/* 历史卡片内的正文预览:最多 3 行,超出截断 */
|
||||
.history-content {
|
||||
margin-top: 14rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
@@ -173,6 +178,7 @@
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 历史卡片内的缩略图:最多 2 张,其余进详情弹层 */
|
||||
.history-thumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -180,8 +186,8 @@
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
.history-thumb {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
width: 148rpx;
|
||||
height: 148rpx;
|
||||
border-radius: 12rpx;
|
||||
background: #F2EDE7;
|
||||
}
|
||||
@@ -205,6 +211,7 @@
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transform: translateY(100%);
|
||||
transition: transform .28s ease;
|
||||
}
|
||||
@@ -220,9 +227,11 @@
|
||||
text-align: center;
|
||||
border-bottom: 1rpx solid #F2EDE7;
|
||||
}
|
||||
/* flex:1 撑满剩余高度,max-height 兜底(同 .cd-body,防 flex 高度解析失败后滚不动) */
|
||||
.picker-list {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: 64vh;
|
||||
padding: 8rpx 0 32rpx;
|
||||
}
|
||||
.picker-item {
|
||||
@@ -247,3 +256,84 @@
|
||||
|
||||
/* 底部留白 */
|
||||
.bottom-space { height: 40rpx; }
|
||||
|
||||
/* 信息行的点击反馈;正文与图片统一在详情弹层里看 */
|
||||
.member-row-hover { opacity: .7; }
|
||||
.tap-hint { margin-top: 10rpx; text-align: right; font-size: 22rpx; color: #C67B30; }
|
||||
.tap-hint-hover { opacity: .7; }
|
||||
|
||||
/* 内容详情弹层 */
|
||||
.cd-mask {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
z-index: 200;
|
||||
}
|
||||
.cd-panel {
|
||||
position: fixed;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
z-index: 201;
|
||||
max-height: 86vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border-radius: 28rpx 28rpx 0 0;
|
||||
animation: cdIn .22s ease;
|
||||
}
|
||||
@keyframes cdIn {
|
||||
from { transform: translateY(40rpx); opacity: .5; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
.cd-head { flex-shrink: 0; display: flex; align-items: flex-start; gap: 16rpx; padding: 28rpx 32rpx 16rpx; }
|
||||
.cd-head-text { flex: 1; min-width: 0; }
|
||||
.cd-title { font-size: 34rpx; font-weight: 800; color: #2C1810; }
|
||||
.cd-sub { margin-top: 8rpx; font-size: 24rpx; color: #A99A89; }
|
||||
.cd-close {
|
||||
flex-shrink: 0;
|
||||
width: 56rpx; height: 56rpx; line-height: 56rpx;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: #F6F2EC;
|
||||
color: #8B7A6A;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
.cd-close-hover { opacity: .7; }
|
||||
.cd-meta {
|
||||
flex-shrink: 0;
|
||||
padding: 0 32rpx 16rpx;
|
||||
font-size: 22rpx;
|
||||
color: #A99A89;
|
||||
border-bottom: 1rpx solid #F2EDE7;
|
||||
}
|
||||
/*
|
||||
* 滚动区:flex:1 吃掉剩余高度;max-height 兜底 —— 部分基础库下
|
||||
* max-height 父容器里的 flex 子项算不出高度,会导致内容被截断且滚不动。
|
||||
* 双保险下无论哪种情况都有明确高度上限,内容超出即可上下滑动。
|
||||
*/
|
||||
.cd-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: 64vh;
|
||||
padding: 20rpx 32rpx calc(60rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.cd-content {
|
||||
padding: 20rpx 24rpx;
|
||||
background: #FBF7F1;
|
||||
border-radius: 14rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.8;
|
||||
color: #2C1810;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.cd-no-content { padding: 12rpx 4rpx; font-size: 26rpx; color: #A99A89; }
|
||||
.cd-thumbs { margin-top: 20rpx; }
|
||||
.cd-thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 16rpx;
|
||||
border-radius: 14rpx;
|
||||
background: #F2EDE7;
|
||||
}
|
||||
194
utils/oss-upload.js
Normal file
194
utils/oss-upload.js
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 阿里云 OSS 上传封装(demo)
|
||||
*
|
||||
* 提供两种模式,二选一即可:
|
||||
* · uploadDirect() 模式B:向后端取签名 → 小程序直接传 OSS(省服务器带宽,推荐)
|
||||
* · uploadViaServer() 模式A:文件先传后端,由后端转存 OSS(改动最小,兼容现有流程)
|
||||
*
|
||||
* 依赖:utils/api.js 里的 request / BASE_URL
|
||||
*
|
||||
* ⚠️ 用模式B之前,必须先把 OSS 域名加进微信后台「上传域名(uploadFile 合法域名)」,
|
||||
* 否则真机上会直接 fail(errMsg: url not in domain list)。
|
||||
*/
|
||||
|
||||
const api = require('./api');
|
||||
|
||||
const SIGN_API = '/api/v1/oss-demo/signature';
|
||||
const CONFIRM_API = '/api/v1/oss-demo/confirm';
|
||||
const SERVER_UPLOAD_API = '/api/v1/oss-demo/upload';
|
||||
|
||||
/** 支持的扩展名(与后端白名单保持一致) */
|
||||
const ALLOWED_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'];
|
||||
/** 单文件上限,需与后端 OSS_MAX_SIZE 一致,前端只是提前拦截省流量 */
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
function extOf(path) {
|
||||
const m = /\.([a-zA-Z0-9]+)(?:\?.*)?$/.exec(path || '');
|
||||
return m ? m[1].toLowerCase() : '';
|
||||
}
|
||||
|
||||
function baseNameOf(path) {
|
||||
const noQuery = String(path || '').split('?')[0];
|
||||
const parts = noQuery.split('/');
|
||||
return parts[parts.length - 1] || 'upload.jpg';
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端先做一次类型/大小拦截,避免无谓的上传流量。
|
||||
* @param {string} filePath
|
||||
* @param {number} [size] wx.chooseMedia 返回的 size(字节),不传则跳过体积校验
|
||||
*/
|
||||
function checkFile(filePath, size) {
|
||||
const ext = extOf(filePath);
|
||||
if (ALLOWED_EXT.indexOf(ext) === -1) {
|
||||
return { ok: false, msg: '仅支持 jpg/png/gif/webp/bmp 图片' };
|
||||
}
|
||||
if (size && size > MAX_FILE_SIZE) {
|
||||
return { ok: false, msg: '单张图片不能超过 ' + Math.round(MAX_FILE_SIZE / 1024 / 1024) + 'MB' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 模式B:取签名 → 直传 OSS → 回执后端
|
||||
* @param {string} filePath wx.chooseMedia 返回的 tempFilePath
|
||||
* @param {object} options { planType, periodKey, filename, onProgress }
|
||||
* @returns {Promise<{key:string,url:string,signedUrl:string}>}
|
||||
*/
|
||||
function uploadDirect(filePath, options) {
|
||||
const opts = options || {};
|
||||
const filename = opts.filename || baseNameOf(filePath);
|
||||
|
||||
const guard = checkFile(filePath);
|
||||
if (!guard.ok) return Promise.reject({ error: guard.msg });
|
||||
|
||||
const query = [
|
||||
'plan_type=' + encodeURIComponent(opts.planType || ''),
|
||||
'period_key=' + encodeURIComponent(opts.periodKey || ''),
|
||||
'filename=' + encodeURIComponent(filename)
|
||||
].join('&');
|
||||
|
||||
return api.request({ url: SIGN_API + '?' + query }).then((sign) => {
|
||||
if (!sign || !sign.success) {
|
||||
return Promise.reject({ error: (sign && sign.error) || '获取上传签名失败' });
|
||||
}
|
||||
|
||||
// 注意:formData 的字段名由 OSS 约定,不能改名;
|
||||
// `key` 决定对象落到哪个路径;`file` 必须是最后一个表单域(原生实现已保证)。
|
||||
const formData = Object.assign({}, sign.form_data, {
|
||||
key: sign.key,
|
||||
'x-oss-content-type': sign.content_type || 'image/jpeg'
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const task = wx.uploadFile({
|
||||
// 直传的地址是 OSS Bucket 域名,不是自家后端
|
||||
url: sign.host,
|
||||
filePath,
|
||||
name: 'file',
|
||||
formData,
|
||||
timeout: 60000,
|
||||
success(res) {
|
||||
// success_action_status=200 时成功即 200,返回体为空
|
||||
if (res.statusCode === 200 || res.statusCode === 204) {
|
||||
resolve({ key: sign.key, host: sign.host });
|
||||
} else {
|
||||
reject({ error: '上传 OSS 失败(' + res.statusCode + ')', raw: res.data });
|
||||
}
|
||||
},
|
||||
fail(err) {
|
||||
const msg = (err && err.errMsg) || '';
|
||||
if (/url not in domain list|domain not/i.test(msg)) {
|
||||
reject({ error: 'OSS 域名未加入小程序上传域名白名单' });
|
||||
return;
|
||||
}
|
||||
reject({ error: msg || '上传失败' });
|
||||
}
|
||||
});
|
||||
|
||||
if (opts.onProgress && task && task.onProgressUpdate) {
|
||||
task.onProgressUpdate((p) => opts.onProgress(p.progress));
|
||||
}
|
||||
});
|
||||
}).then((uploaded) => {
|
||||
// 直传时后端不知情,必须回执一次:后端会校验对象确实存在,并写业务表
|
||||
return api.request({
|
||||
url: CONFIRM_API,
|
||||
method: 'POST',
|
||||
data: { key: uploaded.key, size: 0 }
|
||||
}).then((res) => ({
|
||||
key: uploaded.key,
|
||||
url: (res && res.url) || '',
|
||||
signedUrl: (res && res.signed_url) || ''
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 模式B-批量:顺序上传(串行避免并发把弱网打爆)
|
||||
* @returns {Promise<Array<{key:string,url:string}>>}
|
||||
*/
|
||||
function uploadDirectBatch(filePaths, options) {
|
||||
const results = [];
|
||||
return filePaths.reduce((chain, path) => {
|
||||
return chain.then(() => uploadDirect(path, options)).then((r) => {
|
||||
results.push(r);
|
||||
if (options && options.onItemDone) options.onItemDone(r, results.length);
|
||||
});
|
||||
}, Promise.resolve()).then(() => results);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模式A:走自家后端中转(与现有 plan-submit.js 的写法一致)
|
||||
* 好处是不用配 OSS 域名白名单,改造时把 url 从 /plan/upload 换成本接口即可。
|
||||
*/
|
||||
function uploadViaServer(filePath, options) {
|
||||
const opts = options || {};
|
||||
const token = wx.getStorageSync('token');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.uploadFile({
|
||||
url: api.BASE_URL + SERVER_UPLOAD_API,
|
||||
filePath,
|
||||
name: 'files',
|
||||
header: token ? { Authorization: 'Bearer ' + token } : {},
|
||||
formData: {
|
||||
plan_type: opts.planType || 'weekly',
|
||||
period_key: opts.periodKey || ''
|
||||
},
|
||||
success(res) {
|
||||
let data = {};
|
||||
try {
|
||||
data = JSON.parse(res.data);
|
||||
} catch (e) {
|
||||
reject({ error: '返回格式异常' });
|
||||
return;
|
||||
}
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && data.success) {
|
||||
resolve({ key: (data.attachments || [])[0] || '', items: data.items || [] });
|
||||
} else {
|
||||
reject({ error: data.detail || data.error || '上传失败' });
|
||||
}
|
||||
},
|
||||
fail: reject
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 私有 Bucket 取临时可读 URL(给 <image src> 用)
|
||||
* key 必须是 attachments/{userId}/... 形式
|
||||
*/
|
||||
function getFileUrl(key) {
|
||||
return api.request({
|
||||
url: '/api/v1/oss-demo/file-url?key=' + encodeURIComponent(key)
|
||||
}).then((res) => (res && res.url) || '');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
uploadDirect,
|
||||
uploadDirectBatch,
|
||||
uploadViaServer,
|
||||
getFileUrl,
|
||||
ALLOWED_EXT
|
||||
};
|
||||
Reference in New Issue
Block a user