init
项目初始化
This commit is contained in:
351
pages/plan-submit/plan-submit.js
Normal file
351
pages/plan-submit/plan-submit.js
Normal file
@@ -0,0 +1,351 @@
|
||||
const api = require('../../utils/api');
|
||||
const auth = require('../../utils/auth');
|
||||
|
||||
const MAX_IMAGES = 4;
|
||||
const MAX_IMAGE_SIZE = 1024 * 1024; // 超过 1MB 才压缩
|
||||
const COMPRESS_LEVELS = [80, 60, 45]; // 逐级降低质量,直到满足大小限制
|
||||
const ALLOWED_IMAGE_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'];
|
||||
const IMAGE_PLACEHOLDER_URL = 'https://picsum.photos/200/200';
|
||||
const TYPE_LABELS = { weekly: '周计划', monthly: '月计划', yearly: '年计划' };
|
||||
|
||||
function errDetail(err) {
|
||||
if (err && err.detail) return String(err.detail);
|
||||
if (err && err.error) return String(err.error);
|
||||
if (err && err.message) return String(err.message);
|
||||
return '';
|
||||
}
|
||||
|
||||
function getFileSize(filePath) {
|
||||
return new Promise((resolve) => {
|
||||
wx.getFileInfo({
|
||||
filePath,
|
||||
success: (res) => resolve(Number(res.size) || 0),
|
||||
fail: () => resolve(0)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function hasImageExt(path) {
|
||||
if (!path) return false;
|
||||
const ext = (String(path).split('.').pop() || '').toLowerCase();
|
||||
return !!ext && ALLOWED_IMAGE_EXT.indexOf(ext) !== -1;
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
planType: 'weekly',
|
||||
typeLabel: '周计划',
|
||||
periodKey: '',
|
||||
groupName: '',
|
||||
loading: true,
|
||||
loadError: '',
|
||||
canSubmit: true,
|
||||
message: '',
|
||||
content: '',
|
||||
checked: false,
|
||||
record: null,
|
||||
attachments: [],
|
||||
attachmentThumbs: [],
|
||||
uploading: false,
|
||||
submitting: false,
|
||||
history: []
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const planType = options.plan_type || 'weekly';
|
||||
this.setData({
|
||||
planType,
|
||||
typeLabel: TYPE_LABELS[planType] || '计划',
|
||||
periodKey: options.period_key ? decodeURIComponent(options.period_key) : '',
|
||||
groupName: options.group_name ? decodeURIComponent(options.group_name) : ''
|
||||
});
|
||||
auth.ensureLogin().then((token) => {
|
||||
if (!token) {
|
||||
this.setData({ loading: false, loadError: '请先在「我的」页面绑定手机号' });
|
||||
setTimeout(() => wx.switchTab({ url: '/pages/profile/profile' }), 600);
|
||||
return;
|
||||
}
|
||||
this.loadStatus();
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false, loadError: '登录失败,请重试' });
|
||||
});
|
||||
},
|
||||
|
||||
onRetry() {
|
||||
this.setData({ loading: true, loadError: '' });
|
||||
auth.ensureLogin().then((token) => {
|
||||
if (!token) {
|
||||
this.setData({ loading: false, loadError: '请先在「我的」页面绑定手机号' });
|
||||
setTimeout(() => wx.switchTab({ url: '/pages/profile/profile' }), 600);
|
||||
return;
|
||||
}
|
||||
this.loadStatus();
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false, loadError: '登录失败,请重试' });
|
||||
});
|
||||
},
|
||||
|
||||
loadStatus() {
|
||||
api.request({
|
||||
url: '/api/v1/plan/status',
|
||||
data: { type: this.data.planType, period_key: this.data.periodKey }
|
||||
}).then((res) => {
|
||||
this.applyRecord(res);
|
||||
if (this.data.planType !== 'weekly') {
|
||||
this.loadHistory();
|
||||
}
|
||||
}).catch((err) => {
|
||||
this.setData({ loading: false, loadError: errDetail(err) || '加载计划失败' });
|
||||
});
|
||||
},
|
||||
|
||||
applyRecord(res) {
|
||||
const record = res.record;
|
||||
const attachments = record ? (record.attachments || []) : [];
|
||||
this.setData({
|
||||
loading: false,
|
||||
loadError: '',
|
||||
periodKey: res.period_key || this.data.periodKey,
|
||||
canSubmit: res.can_submit !== false,
|
||||
message: res.message || '',
|
||||
checked: !!res.checked,
|
||||
record,
|
||||
content: record ? (record.content || '') : '',
|
||||
attachments,
|
||||
attachmentThumbs: attachments.map((p) => this.planFileUrl(p))
|
||||
});
|
||||
},
|
||||
|
||||
loadHistory() {
|
||||
api.request({ url: '/api/v1/plan/history', data: { type: this.data.planType, page: 1, size: 20 } })
|
||||
.then((res) => {
|
||||
const history = (res.list || []).map((item) => ({
|
||||
...item,
|
||||
thumbSrcs: (item.attachments || []).map((p) => this.planFileUrl(p))
|
||||
}));
|
||||
this.setData({ history });
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
onContentInput(e) {
|
||||
this.setData({ content: e.detail.value });
|
||||
},
|
||||
|
||||
planFileUrl(path) {
|
||||
if (!path) return '';
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
const parts = String(path).replace(/^attachments\//, '').split('/');
|
||||
if (parts.length < 4) {
|
||||
return IMAGE_PLACEHOLDER_URL;
|
||||
}
|
||||
return api.STATIC_BASE_URL + '/clock/static/plan/' + parts.slice(0, 4).map(encodeURIComponent).join('/');
|
||||
},
|
||||
|
||||
chooseImages() {
|
||||
const remain = MAX_IMAGES - this.data.attachments.length;
|
||||
if (remain <= 0) {
|
||||
wx.showToast({ title: '最多上传4张图片', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!this.data.canSubmit || !this.data.periodKey) {
|
||||
wx.showToast({ title: '当前不可上传', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
wx.chooseMedia({
|
||||
count: remain,
|
||||
mediaType: ['image'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const paths = (res.tempFiles || []).map((f) => f.tempFilePath);
|
||||
this.uploadImages(paths);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
uploadImages(paths) {
|
||||
if (this.data.uploading || !paths.length) {
|
||||
return;
|
||||
}
|
||||
const planType = this.data.planType;
|
||||
const periodKey = this.data.periodKey;
|
||||
let attachments = this.data.attachments.slice();
|
||||
this.setData({ uploading: true });
|
||||
const run = (index) => {
|
||||
if (index >= paths.length) {
|
||||
this.setData({
|
||||
uploading: false,
|
||||
attachmentThumbs: attachments.map((p) => this.planFileUrl(p))
|
||||
});
|
||||
return;
|
||||
}
|
||||
const rawPath = paths[index];
|
||||
const doUpload = (src) => {
|
||||
wx.uploadFile({
|
||||
url: api.BASE_URL + '/api/v1/plan/upload',
|
||||
filePath: src,
|
||||
name: 'files',
|
||||
formData: { plan_type: planType, period_key: periodKey },
|
||||
header: { Authorization: 'Bearer ' + auth.getToken() },
|
||||
success: (res) => {
|
||||
let data = {};
|
||||
try {
|
||||
data = JSON.parse(res.data);
|
||||
} catch (e) {
|
||||
data = {};
|
||||
}
|
||||
if (data.success && Array.isArray(data.attachments)) {
|
||||
attachments = attachments.concat(data.attachments).slice(0, MAX_IMAGES);
|
||||
this.setData({
|
||||
attachments,
|
||||
attachmentThumbs: attachments.map((p) => this.planFileUrl(p))
|
||||
});
|
||||
} else {
|
||||
wx.showToast({ title: data.error || '上传失败', icon: 'none' });
|
||||
}
|
||||
run(index + 1);
|
||||
},
|
||||
fail: () => {
|
||||
wx.showToast({ title: '上传失败', icon: 'none' });
|
||||
run(index + 1);
|
||||
}
|
||||
});
|
||||
};
|
||||
// 按大小限制压缩:小图直接上传,大图逐级降质量直到 ≤ MAX_IMAGE_SIZE
|
||||
const tryCompress = (src, level) => {
|
||||
getFileSize(src).then((size) => {
|
||||
if (!size || size <= MAX_IMAGE_SIZE || level >= COMPRESS_LEVELS.length) {
|
||||
doUpload(src);
|
||||
return;
|
||||
}
|
||||
wx.compressImage({
|
||||
src,
|
||||
quality: COMPRESS_LEVELS[level],
|
||||
success: (res) => {
|
||||
const compressed = res.tempFilePath || src;
|
||||
if (!hasImageExt(compressed)) {
|
||||
// 压缩产物扩展名丢失时回退当前图,避免后端按扩展名校验失败
|
||||
doUpload(src);
|
||||
return;
|
||||
}
|
||||
tryCompress(compressed, level + 1);
|
||||
},
|
||||
fail: () => doUpload(src)
|
||||
});
|
||||
});
|
||||
};
|
||||
tryCompress(rawPath, 0);
|
||||
};
|
||||
run(0);
|
||||
},
|
||||
|
||||
removeImage(e) {
|
||||
if (this.data.uploading) {
|
||||
return;
|
||||
}
|
||||
const index = Number(e.currentTarget.dataset.index);
|
||||
const path = this.data.attachments[index];
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
const parts = path.replace(/^attachments\//, '').split('/');
|
||||
const dropLocal = () => {
|
||||
const attachments = this.data.attachments.slice();
|
||||
attachments.splice(index, 1);
|
||||
this.setData({
|
||||
attachments,
|
||||
attachmentThumbs: attachments.map((p) => this.planFileUrl(p))
|
||||
});
|
||||
};
|
||||
if (parts.length < 4) {
|
||||
dropLocal();
|
||||
return;
|
||||
}
|
||||
const sid = parts[0];
|
||||
const planType = parts[1];
|
||||
const period = parts[2];
|
||||
const filename = parts[3];
|
||||
api.request({
|
||||
url: `/api/v1/plan/file/${sid}/${planType}/${period}/${encodeURIComponent(filename)}`,
|
||||
method: 'DELETE'
|
||||
}).then(dropLocal).catch(() => {
|
||||
wx.showToast({ title: '删除失败', icon: 'none' });
|
||||
});
|
||||
},
|
||||
|
||||
previewImage(e) {
|
||||
const index = Number(e.currentTarget.dataset.index);
|
||||
const urls = this.data.attachmentThumbs;
|
||||
const src = urls[index];
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
wx.previewImage({ current: src, urls });
|
||||
},
|
||||
|
||||
previewHistoryImage(e) {
|
||||
const historyId = e.currentTarget.dataset.id;
|
||||
const index = Number(e.currentTarget.dataset.index);
|
||||
const item = this.data.history.find((h) => String(h.id) === String(historyId));
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const urls = item.thumbSrcs || [];
|
||||
const src = urls[index];
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
wx.previewImage({ current: src, urls });
|
||||
},
|
||||
|
||||
submit() {
|
||||
if (this.data.submitting) {
|
||||
return;
|
||||
}
|
||||
if (!this.data.canSubmit) {
|
||||
wx.showToast({ title: this.data.message || '当前不可提交', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const hasText = !!(this.data.content || '').trim();
|
||||
const hasImages = !!this.data.attachments.length;
|
||||
if (!hasText && !hasImages) {
|
||||
wx.showToast({ title: '请填写内容或上传图片', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const doPost = () => {
|
||||
this.setData({ submitting: true });
|
||||
api.request({
|
||||
url: '/api/v1/plan/checkin',
|
||||
method: 'POST',
|
||||
data: {
|
||||
plan_type: this.data.planType,
|
||||
period_key: this.data.periodKey,
|
||||
status: '已完成',
|
||||
content: this.data.content,
|
||||
attachments: this.data.attachments
|
||||
}
|
||||
}).then(() => {
|
||||
this.setData({ submitting: false });
|
||||
wx.showToast({ title: '已保存', icon: 'success' });
|
||||
wx.setStorageSync('plan_overview_dirty', true);
|
||||
setTimeout(() => wx.navigateBack(), 600);
|
||||
}).catch((err) => {
|
||||
this.setData({ submitting: false });
|
||||
const message = errDetail(err) || '保存失败';
|
||||
wx.showToast({ title: message, icon: 'none' });
|
||||
});
|
||||
};
|
||||
if (this.data.checked || this.data.record) {
|
||||
wx.showModal({
|
||||
title: '覆盖确认',
|
||||
content: '将覆盖本周期已提交的计划,确定保存?',
|
||||
success: (r) => {
|
||||
if (r.confirm) {
|
||||
doPost();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
doPost();
|
||||
}
|
||||
}
|
||||
});
|
||||
3
pages/plan-submit/plan-submit.json
Normal file
3
pages/plan-submit/plan-submit.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"usingComponents": {}
|
||||
}
|
||||
95
pages/plan-submit/plan-submit.wxml
Normal file
95
pages/plan-submit/plan-submit.wxml
Normal file
@@ -0,0 +1,95 @@
|
||||
<view class="page">
|
||||
|
||||
|
||||
<view class="head">
|
||||
<view class="head-title">{{typeLabel}}<text wx:if="{{groupName}}"> · {{groupName}}</text> · {{periodKey}}</view>
|
||||
<view class="head-sub" wx:if="{{message}}">{{message}}</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="form-scroll" scroll-y>
|
||||
<block wx:if="{{loading}}">
|
||||
<view class="card skeleton-card">
|
||||
<view class="sk sk-line w-90"></view>
|
||||
<view class="sk sk-line w-80"></view>
|
||||
<view class="sk sk-line w-100"></view>
|
||||
<view class="sk-block"></view>
|
||||
<view class="sk-block"></view>
|
||||
</view>
|
||||
<view class="card skeleton-card">
|
||||
<view class="sk sk-line w-60"></view>
|
||||
<view class="sk-block"></view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{loadError}}">
|
||||
<view class="error-card">
|
||||
<view class="error-icon">!</view>
|
||||
<view class="error-text">{{loadError}}</view>
|
||||
<button class="btn-retry btn-primary" hover-class="btn-hover" bindtap="onRetry">重新加载</button>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<view class="readonly-tip" wx:if="{{!canSubmit}}">{{message || '当前周期不可提交,仅可查看'}}</view>
|
||||
|
||||
<view class="card">
|
||||
<textarea
|
||||
class="content {{canSubmit ? '' : 'readonly'}}"
|
||||
placeholder="写下计划内容"
|
||||
value="{{content}}"
|
||||
bindinput="onContentInput"
|
||||
maxlength="1000"
|
||||
disabled="{{!canSubmit}}"
|
||||
></textarea>
|
||||
<view class="char-count">{{content.length}}/1000</view>
|
||||
|
||||
<view class="upload-section">
|
||||
<button
|
||||
class="upload-btn"
|
||||
bindtap="chooseImages"
|
||||
hover-class="btn-hover"
|
||||
disabled="{{canSubmit === false || uploading || attachments.length >= 4}}"
|
||||
>+ {{uploading ? '上传中...' : '添加图片(' + attachments.length + '/4)'}}</button>
|
||||
<view class="thumb-grid" wx:if="{{attachments.length}}">
|
||||
<view class="thumb-item" wx:for="{{attachments}}" wx:key="*this">
|
||||
<image class="thumb" src="{{attachmentThumbs[index]}}" mode="aspectFill" data-index="{{index}}" bindtap="previewImage"></image>
|
||||
<view class="thumb-del" data-index="{{index}}" catchtap="removeImage">×</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button
|
||||
class="btn-primary btn-save"
|
||||
bindtap="submit"
|
||||
hover-class="btn-hover"
|
||||
loading="{{submitting}}"
|
||||
disabled="{{submitting || !canSubmit}}"
|
||||
>{{!canSubmit ? '不可提交' : (checked ? '保存修改' : '保存计划')}}</button>
|
||||
<view class="save-hint" wx:if="{{canSubmit && checked}}">保存将覆盖本周期已提交的计划</view>
|
||||
</view>
|
||||
|
||||
<block wx:if="{{planType !== 'weekly'}}">
|
||||
<view class="section-title">历史计划</view>
|
||||
<view class="card history-card" wx:for="{{history}}" wx:key="id" wx:for-item="his">
|
||||
<view class="history-head">{{his.period_key}} · <text class="{{his.status === '未完成' ? 'pending' : ''}}">{{his.status}}</text></view>
|
||||
<view class="content-text" wx:if="{{his.content}}">{{his.content}}</view>
|
||||
<view class="history-thumbs" wx:if="{{his.thumbSrcs.length}}">
|
||||
<image
|
||||
wx:for="{{his.thumbSrcs}}"
|
||||
wx:for-item="thumbSrc"
|
||||
wx:for-index="imgIndex"
|
||||
wx:key="*this"
|
||||
class="history-thumb"
|
||||
src="{{thumbSrc}}"
|
||||
mode="aspectFill"
|
||||
data-id="{{his.id}}"
|
||||
data-index="{{imgIndex}}"
|
||||
bindtap="previewHistoryImage"
|
||||
></image>
|
||||
</view>
|
||||
</view>
|
||||
<view class="empty" wx:if="{{history.length === 0}}">暂无历史计划</view>
|
||||
</block>
|
||||
</block>
|
||||
</scroll-view>
|
||||
</view>
|
||||
56
pages/plan-submit/plan-submit.wxss
Normal file
56
pages/plan-submit/plan-submit.wxss
Normal file
@@ -0,0 +1,56 @@
|
||||
.page { height: 100vh; display: flex; flex-direction: column; box-sizing: border-box; overflow: hidden; background: #FDFBF7; }
|
||||
.head { flex-shrink: 0; padding: 24rpx 32rpx 8rpx; }
|
||||
.head-title { font-size: 34rpx; font-weight: 700; color: #2C1810; }
|
||||
.head-sub { margin-top: 6rpx; font-size: 24rpx; color: #c0392b; }
|
||||
|
||||
.form-scroll { flex: 1; min-height: 0; padding: 8rpx 32rpx 24rpx; box-sizing: border-box; }
|
||||
.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; box-shadow: 0 4rpx 16rpx rgba(44, 24, 16, 0.04); }
|
||||
|
||||
/* shared feedback */
|
||||
.btn-hover { opacity: .85; transform: scale(.98); }
|
||||
|
||||
/* skeleton */
|
||||
.skeleton-card { padding: 32rpx; }
|
||||
.sk { background: #EDE3D6; border-radius: 10rpx; }
|
||||
.sk-line { height: 28rpx; margin-bottom: 20rpx; }
|
||||
.sk-block { height: 120rpx; background: #F2EAE0; border-radius: 12rpx; margin-top: 10rpx; }
|
||||
.w-90 { width: 90%; }
|
||||
.w-80 { width: 80%; }
|
||||
.w-100 { width: 100%; }
|
||||
.w-60 { width: 60%; }
|
||||
|
||||
/* error state */
|
||||
.error-card { padding: 120rpx 40rpx; text-align: center; }
|
||||
.error-icon { width: 96rpx; height: 96rpx; line-height: 96rpx; margin: 0 auto 24rpx; border-radius: 50%; background: #fdecea; color: #c0392b; font-size: 48rpx; font-weight: 700; }
|
||||
.error-text { color: #5A4A3A; font-size: 28rpx; margin-bottom: 48rpx; }
|
||||
.btn-retry { display: inline-block; min-width: 240rpx; background: #C67B30; color: #fff; border-radius: 12rpx; }
|
||||
|
||||
.readonly-tip { margin: 8rpx 0 16rpx; padding: 14rpx 20rpx; background: #fff3e6; border-radius: 10rpx; color: #c0392b; font-size: 26rpx; text-align: center; }
|
||||
|
||||
.content { width: 100%; min-height: 200rpx; box-sizing: border-box; padding: 16rpx; border: 1rpx solid #e4d9cb; border-radius: 12rpx; background: #FDFBF7; color: #2C1810; font-size: 28rpx; }
|
||||
.content[disabled], .content.readonly { background: #f6f1ea; }
|
||||
.char-count { margin-top: 8rpx; text-align: right; font-size: 22rpx; color: #B6A996; }
|
||||
|
||||
.upload-section { margin-top: 20rpx; }
|
||||
.upload-btn { background: #FDFBF7; color: #C67B30; border: 2rpx dashed #C67B30; border-radius: 12rpx; font-size: 26rpx; }
|
||||
.upload-btn[disabled] { color: #d8c7b4; border-color: #d8c7b4; background: #FDFBF7; }
|
||||
.thumb-grid { display: flex; flex-wrap: wrap; gap: 14rpx; margin-top: 18rpx; }
|
||||
.thumb-item { position: relative; width: 156rpx; height: 156rpx; }
|
||||
.thumb { width: 100%; height: 100%; border-radius: 12rpx; background: #f6efe6; }
|
||||
.thumb-del { position: absolute; top: -12rpx; right: -12rpx; width: 44rpx; height: 44rpx; line-height: 40rpx; text-align: center; border-radius: 50%; background: #c0392b; color: #fff; font-size: 30rpx; }
|
||||
|
||||
.btn-primary { background: #C67B30; color: #fff; border-radius: 12rpx; }
|
||||
.btn-save { margin-top: 24rpx; }
|
||||
.btn-primary[disabled] { background: #d8c7b4; color: #fff; }
|
||||
.save-hint { margin-top: 12rpx; text-align: center; font-size: 22rpx; color: #B6A996; }
|
||||
|
||||
.section-title { margin: 4rpx 8rpx 14rpx; font-size: 28rpx; font-weight: 700; color: #2C1810; }
|
||||
.history-card { display: flex; flex-direction: column; gap: 10rpx; }
|
||||
.history-head { font-size: 28rpx; font-weight: 700; color: #2C1810; }
|
||||
.history-head .pending { color: #c0392b; }
|
||||
.content-text { color: #5A4A3A; font-size: 26rpx; }
|
||||
.history-thumbs { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 8rpx; }
|
||||
.history-thumb { width: 148rpx; height: 148rpx; border-radius: 12rpx; background: #f6efe6; }
|
||||
|
||||
.state-block { padding: 48rpx 0; text-align: center; color: #B6A996; font-size: 26rpx; }
|
||||
.empty { padding: 48rpx 0; text-align: center; color: #B6A996; font-size: 26rpx; }
|
||||
Reference in New Issue
Block a user