Add mini-program points task center

This commit is contained in:
王鹏
2026-09-16 16:49:25 +08:00
parent c591354ed1
commit 2c46356ec0
9 changed files with 464 additions and 6 deletions

158
pages/task/detail.vue Normal file
View File

@@ -0,0 +1,158 @@
<template>
<view class="page" v-if="task.id">
<view class="summary-card">
<image v-if="task.coverUrl" class="cover" :src="resolveUrl(task.coverUrl)" mode="aspectFill"></image>
<view class="summary-main">
<view class="title">{{ task.taskName }}</view>
<view class="tags"><text class="tag">{{ task.cycleType === 'DAILY' ? '每日任务' : '单次任务' }}</text><text class="points">+{{ task.rewardPoints }}积分</text></view>
</view>
</view>
<view class="section">
<view class="section-title">任务说明</view>
<view class="paragraph">{{ task.description || '完成任务后上传相关截图,平台审核通过后积分自动到账。' }}</view>
</view>
<view v-if="task.operationGuide" class="section">
<view class="section-title">操作步骤</view>
<view class="paragraph guide">{{ task.operationGuide }}</view>
</view>
<view v-if="exampleImages.length" class="section">
<view class="section-title">正确截图示例</view>
<view class="image-grid"><image v-for="(url,index) in exampleImages" :key="url" class="example-image" :src="resolveUrl(url)" mode="aspectFill" @click="previewExamples(index)"></image></view>
</view>
<view v-if="submission" class="section status-section">
<view class="section-title">当前状态</view>
<view class="status-line"><text :class="['status-badge', submissionStatusClass]">{{ submissionStatusText }}</text><text class="submit-time">{{ submission.lastSubmitTime }}</text></view>
<view v-if="submission.rejectReason" class="reject-reason">{{ submission.status === 3 ? '撤销原因:' : '未通过原因:' }}{{ submission.rejectReason }}</view>
<view v-if="currentAttachments.length" class="old-images">
<image v-for="(item,index) in currentAttachments" :key="item.id" class="old-image" :src="item.previewUrl" mode="aspectFill" @click="previewOld(index)"></image>
</view>
</view>
<view v-if="task.canSubmit" class="section">
<view class="section-title">上传完成截图</view>
<view class="upload-tip">最多3张,单张不超过5MB;截图将保存到七牛云用于人工审核</view>
<view class="image-grid upload-grid">
<view v-for="(item,index) in uploads" :key="item.objectKey" class="upload-item">
<image class="upload-image" :src="item.localPath" mode="aspectFill" @click="previewUploads(index)"></image>
<text class="remove cuIcon-close" @click.stop="removeUpload(index)"></text>
</view>
<view v-if="uploads.length < 3" class="choose-image" @click="chooseImages"><text class="cuIcon-cameraadd"></text><view>上传截图</view></view>
</view>
<textarea v-model="remark" class="remark" maxlength="300" placeholder="可补充说明微信昵称、群名称等信息(选填)"></textarea>
</view>
<view class="bottom-space"></view>
<view class="bottom-bar">
<button v-if="task.canSubmit" class="submit-btn" :disabled="submitting || uploads.length === 0" @click="submit">{{ submitting ? '正在提交...' : (task.currentStatus === 2 ? '重新提交审核' : '提交审核') }}</button>
<button v-else class="submit-btn disabled" disabled>{{ buttonText }}</button>
</view>
</view>
</template>
<script>
import config from '@/config'
import { getPointTask, getMyPointTaskSubmission, submitPointTask, resubmitPointTask } from '@/api/app/index.js'
import { uploadPointTaskImage } from '@/utils/qiniu.js'
export default {
data() { return { id: null, selectedSubmissionId: null, task: {}, submission: null, uploads: [], remark: '', submitting: false } },
computed: {
exampleImages() { return String(this.task.exampleImages || '').split(',').map(v => v.trim()).filter(Boolean) },
currentAttachments() { return this.submission && this.submission.attachments ? this.submission.attachments.filter(v => v.isCurrent === 1) : [] },
submissionStatusText() { return this.submission ? ({ 0: '审核中', 1: '审核通过', 2: '审核未通过', 3: '奖励已撤销' }[this.submission.status] || '未知状态') : '' },
submissionStatusClass() { return this.submission ? ({ 0: 'pending', 1: 'approved', 2: 'rejected', 3: 'revoked' }[this.submission.status] || '') : '' },
buttonText() {
if (this.task.currentStatus === 0) return '审核中'
if (this.task.currentStatus === 1) return this.task.cycleType === 'DAILY' ? '今日已完成' : '任务已完成'
if (this.task.currentStatus === 3) return '奖励已撤销'
return '暂不可提交'
}
},
onLoad(options) { this.id = Number(options.id); this.selectedSubmissionId = options.submissionId ? Number(options.submissionId) : null; this.load() },
onPullDownRefresh() { this.load() },
methods: {
load() {
getPointTask(this.id).then(res => {
this.task = res.data || {}
this.submission = null
const submissionId = this.selectedSubmissionId || this.task.currentSubmissionId
if (submissionId) return getMyPointTaskSubmission(submissionId)
}).then(res => { if (res && res.data) { this.submission = res.data; this.remark = this.submission.remark || '' } }).finally(() => uni.stopPullDownRefresh())
},
resolveUrl(url) {
if (!url || /^(https?:)?\/\//i.test(url)) return url
return config.baseUrl + (url.charAt(0) === '/' ? url : '/' + url)
},
chooseImages() {
uni.chooseImage({ count: 3 - this.uploads.length, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: async res => {
const files = res.tempFiles || (res.tempFilePaths || []).map(path => ({ path: path, size: 0 }))
for (let i = 0; i < files.length; i++) {
if (Number(files[i].size || 0) > 5 * 1024 * 1024) { uni.showToast({ title: '单张截图不能超过5MB', icon: 'none' }); continue }
try {
uni.showLoading({ title: '上传第' + (i + 1) + '张' })
const uploaded = await uploadPointTaskImage(files[i])
this.uploads.push(uploaded)
} catch (e) {
uni.showToast({ title: (e && e.message) || '截图上传失败', icon: 'none' })
} finally { uni.hideLoading() }
}
} })
},
removeUpload(index) { this.uploads.splice(index, 1) },
previewExamples(index) { uni.previewImage({ current: this.resolveUrl(this.exampleImages[index]), urls: this.exampleImages.map(this.resolveUrl) }) },
previewUploads(index) { uni.previewImage({ current: this.uploads[index].localPath, urls: this.uploads.map(v => v.localPath) }) },
previewOld(index) { uni.previewImage({ current: this.currentAttachments[index].previewUrl, urls: this.currentAttachments.map(v => v.previewUrl) }) },
submit() {
if (!this.uploads.length || this.submitting) return
this.submitting = true
const data = { attachmentKeys: this.uploads.map(v => v.objectKey), remark: String(this.remark || '').trim() }
const action = this.task.currentStatus === 2 && this.task.currentSubmissionId ? resubmitPointTask(this.task.currentSubmissionId, data) : submitPointTask(this.task.id, data)
action.then(() => {
uni.showModal({ title: '提交成功', content: '截图已提交,请耐心等待后台审核。', showCancel: false })
this.uploads = []
this.load()
}).finally(() => { this.submitting = false })
}
}
}
</script>
<style scoped>
.page { min-height: 100vh; padding: 26rpx; box-sizing: border-box; background: #f5f7fb; }
.summary-card, .section { margin-bottom: 22rpx; padding: 28rpx; border-radius: 22rpx; background: #fff; box-shadow: 0 8rpx 24rpx rgba(34, 48, 79, .05); }
.summary-card { display: flex; align-items: center; }
.cover { width: 132rpx; height: 132rpx; flex-shrink: 0; border-radius: 18rpx; background: #eef2fa; }
.summary-main { flex: 1; min-width: 0; margin-left: 24rpx; }
.title { font-size: 35rpx; font-weight: 700; color: #242933; }
.tags { display: flex; align-items: center; margin-top: 20rpx; }
.tag { padding: 6rpx 15rpx; border-radius: 16rpx; color: #5576e9; background: #edf1ff; font-size: 22rpx; }
.points { margin-left: 16rpx; color: #ff8721; font-size: 29rpx; font-weight: 700; }
.section-title { margin-bottom: 20rpx; color: #282d36; font-size: 30rpx; font-weight: 650; }
.paragraph { color: #626a76; font-size: 26rpx; line-height: 42rpx; }
.guide { white-space: pre-wrap; }
.image-grid { display: flex; flex-wrap: wrap; gap: 16rpx; }
.example-image, .upload-item, .choose-image { width: 190rpx; height: 190rpx; border-radius: 14rpx; }
.example-image, .upload-image { background: #edf0f5; }
.upload-item { position: relative; overflow: hidden; }
.upload-image { width: 100%; height: 100%; }
.remove { position: absolute; top: 8rpx; right: 8rpx; width: 38rpx; height: 38rpx; border-radius: 50%; color: #fff; background: rgba(0,0,0,.58); text-align: center; line-height: 38rpx; font-size: 24rpx; }
.choose-image { display: flex; flex-direction: column; align-items: center; justify-content: center; border: 2rpx dashed #cbd2df; color: #8790a0; font-size: 23rpx; }
.choose-image .cuIcon-cameraadd { margin-bottom: 10rpx; font-size: 52rpx; color: #667ff1; }
.upload-tip { margin: -5rpx 0 22rpx; color: #9ba2ae; font-size: 22rpx; line-height: 34rpx; }
.remark { width: 100%; height: 150rpx; margin-top: 24rpx; padding: 20rpx; box-sizing: border-box; border-radius: 14rpx; background: #f7f8fb; font-size: 25rpx; }
.status-line { display: flex; align-items: center; justify-content: space-between; }
.status-badge { padding: 8rpx 18rpx; border-radius: 18rpx; font-size: 24rpx; }
.status-badge.pending { color: #df8b16; background: #fff3df; }
.status-badge.approved { color: #24925d; background: #e8f7ef; }
.status-badge.rejected { color: #dc5151; background: #ffeded; }
.status-badge.revoked { color: #777f8c; background: #eff0f3; }
.submit-time { color: #9da4af; font-size: 22rpx; }
.reject-reason { margin-top: 20rpx; padding: 18rpx; border-radius: 12rpx; color: #d65050; background: #fff5f5; font-size: 24rpx; line-height: 36rpx; }
.old-images { display: flex; flex-wrap: wrap; gap: 15rpx; margin-top: 22rpx; }
.old-image { width: 150rpx; height: 150rpx; border-radius: 12rpx; }
.bottom-space { height: 130rpx; }
.bottom-bar { position: fixed; z-index: 10; left: 0; right: 0; bottom: 0; padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom)); background: #fff; box-shadow: 0 -7rpx 25rpx rgba(20, 35, 66, .08); }
.submit-btn { height: 84rpx; border: 0; border-radius: 42rpx; color: #fff; background: linear-gradient(90deg, #567cf4, #735ff1); font-size: 29rpx; line-height: 84rpx; }
.submit-btn.disabled { color: #969da8; background: #e7e9ee; }
</style>

96
pages/task/index.vue Normal file
View File

@@ -0,0 +1,96 @@
<template>
<view class="page">
<view class="hero">
<view class="hero-title">积分任务中心</view>
<view class="hero-desc">完成任务上传截图,审核通过后积分自动到账</view>
</view>
<view v-if="tasks.length" class="task-list">
<view v-for="item in tasks" :key="item.id" class="task-card" @click="openTask(item)">
<image v-if="item.coverUrl" class="task-cover" :src="resolveUrl(item.coverUrl)" mode="aspectFill"></image>
<view v-else class="task-cover task-cover-default"><text class="cuIcon-formfill"></text></view>
<view class="task-main">
<view class="task-head">
<text class="task-name">{{ item.taskName }}</text>
<text class="cycle-tag" :class="item.cycleType === 'DAILY' ? 'daily' : 'once'">{{ item.cycleType === 'DAILY' ? '每日任务' : '单次任务' }}</text>
</view>
<view class="task-desc">{{ item.description || '完成任务并上传截图,等待平台审核' }}</view>
<view class="task-foot">
<view class="reward">+{{ item.rewardPoints }}<text> 积分</text></view>
<view class="task-status" :class="item.statusClassName">{{ item.statusLabel }} <text class="cuIcon-right"></text></view>
</view>
</view>
</view>
</view>
<view v-else-if="!loading" class="empty">
<text class="cuIcon-form empty-icon"></text>
<view>暂无进行中的积分任务</view>
</view>
</view>
</template>
<script>
import config from '@/config'
import { listPointTasks } from '@/api/app/index.js'
export default {
data() { return { tasks: [], loading: false } },
onShow() { this.loadTasks() },
onPullDownRefresh() { this.loadTasks() },
methods: {
loadTasks() {
this.loading = true
listPointTasks().then(res => {
this.tasks = (res.data || []).map(item => Object.assign({}, item, {
statusLabel: this.getStatusText(item),
statusClassName: this.getStatusClass(item)
}))
}).finally(() => { this.loading = false; uni.stopPullDownRefresh() })
},
openTask(item) { this.$tab.navigateTo('/pages/task/detail?id=' + item.id) },
resolveUrl(url) {
if (!url || /^(https?:)?\/\//i.test(url)) return url
return config.baseUrl + (url.charAt(0) === '/' ? url : '/' + url)
},
getStatusText(item) {
if (item.currentStatus === 0) return '审核中'
if (item.currentStatus === 1) return item.cycleType === 'DAILY' ? '今日已完成' : '已完成'
if (item.currentStatus === 2) return item.canSubmit ? '重新提交' : '未通过'
if (item.currentStatus === 3) return '奖励已撤销'
return '去完成'
},
getStatusClass(item) {
if (item.currentStatus === 1) return 'done'
if (item.currentStatus === 0) return 'pending'
if (item.currentStatus === 2 || item.currentStatus === 3) return 'rejected'
return 'available'
}
}
}
</script>
<style scoped>
.page { min-height: 100vh; background: #f5f7fb; padding: 28rpx; box-sizing: border-box; }
.hero { padding: 38rpx; border-radius: 24rpx; color: #fff; background: linear-gradient(135deg, #4c8dff, #725cff); box-shadow: 0 16rpx 36rpx rgba(76, 110, 255, .18); }
.hero-title { font-size: 40rpx; font-weight: 700; }
.hero-desc { margin-top: 14rpx; font-size: 25rpx; opacity: .9; }
.task-list { margin-top: 26rpx; }
.task-card { display: flex; padding: 24rpx; margin-bottom: 20rpx; background: #fff; border-radius: 22rpx; box-shadow: 0 8rpx 24rpx rgba(32, 47, 85, .06); }
.task-cover { width: 150rpx; height: 150rpx; flex-shrink: 0; border-radius: 18rpx; background: #eef3ff; }
.task-cover-default { display: flex; align-items: center; justify-content: center; color: #5f79f5; font-size: 60rpx; }
.task-main { flex: 1; min-width: 0; margin-left: 22rpx; }
.task-head { display: flex; align-items: center; }
.task-name { flex: 1; min-width: 0; font-size: 31rpx; font-weight: 600; color: #252a34; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cycle-tag { margin-left: 12rpx; padding: 5rpx 12rpx; border-radius: 15rpx; font-size: 20rpx; }
.cycle-tag.once { color: #2c9f6a; background: #eaf8f1; }
.cycle-tag.daily { color: #ed8a18; background: #fff4e5; }
.task-desc { height: 62rpx; margin-top: 13rpx; color: #8b929e; font-size: 24rpx; line-height: 31rpx; overflow: hidden; }
.task-foot { display: flex; align-items: center; margin-top: 10rpx; }
.reward { flex: 1; color: #ff8a22; font-size: 31rpx; font-weight: 700; }
.reward text { font-size: 23rpx; font-weight: 400; }
.task-status { font-size: 24rpx; }
.task-status.available { color: #4d72ee; }
.task-status.pending { color: #e69a24; }
.task-status.done { color: #36a66f; }
.task-status.rejected { color: #e45b5b; }
.empty { padding-top: 220rpx; color: #a9b0bc; text-align: center; font-size: 27rpx; }
.empty-icon { display: block; margin-bottom: 18rpx; font-size: 90rpx; color: #d7dce6; }
</style>

61
pages/task/records.vue Normal file
View File

@@ -0,0 +1,61 @@
<template>
<view class="page">
<view v-if="records.length" class="record-list">
<view v-for="item in records" :key="item.id" class="record-card" @click="openDetail(item)">
<view class="record-head"><text class="record-name">{{ item.taskName }}</text><text class="status" :class="item.statusClassName">{{ item.statusLabel }}</text></view>
<view class="record-row"><text>{{ item.cycleType === 'DAILY' ? '每日任务 · ' + item.cycleKey : '单次任务' }}</text><text class="reward">+{{ item.rewardPoints }}积分</text></view>
<view class="record-row muted"><text>提交时间 {{ item.lastSubmitTime }}</text><text>第{{ item.attemptCount }}次提交</text></view>
<view v-if="item.rejectReason" class="reason">{{ item.status === 3 ? '撤销原因:' : '未通过原因:' }}{{ item.rejectReason }}</view>
</view>
</view>
<view v-else-if="!loading" class="empty"><text class="cuIcon-form empty-icon"></text><view>还没有任务记录</view><button class="go-btn" @click="goTasks">去做任务</button></view>
<view v-if="loadingMore" class="loading-more">加载中...</view>
</view>
</template>
<script>
import { listMyPointTaskSubmissions } from '@/api/app/index.js'
export default {
data() { return { records: [], total: 0, loading: false, loadingMore: false, query: { pageNum: 1, pageSize: 10 } } },
onShow() { this.query.pageNum = 1; this.load(true) },
onPullDownRefresh() { this.query.pageNum = 1; this.load(true) },
onReachBottom() { if (this.records.length < this.total && !this.loadingMore) { this.query.pageNum++; this.load(false) } },
methods: {
load(reset) {
if (reset) this.loading = true; else this.loadingMore = true
listMyPointTaskSubmissions(this.query).then(res => {
const rows = (res.rows || []).map(item => Object.assign({}, item, {
statusLabel: this.getStatusText(item.status),
statusClassName: this.getStatusClass(item.status)
}))
this.records = reset ? rows : this.records.concat(rows)
this.total = res.total || 0
}).finally(() => { this.loading = false; this.loadingMore = false; uni.stopPullDownRefresh() })
},
openDetail(item) { this.$tab.navigateTo('/pages/task/detail?id=' + item.taskId + '&submissionId=' + item.id) },
goTasks() { this.$tab.navigateTo('/pages/task/index') },
getStatusText(status) { return { 0: '审核中', 1: '已通过', 2: '未通过', 3: '已撤销' }[status] || '未知' },
getStatusClass(status) { return { 0: 'pending', 1: 'approved', 2: 'rejected', 3: 'revoked' }[status] || '' }
}
}
</script>
<style scoped>
.page { min-height: 100vh; padding: 28rpx; box-sizing: border-box; background: #f5f7fb; }
.record-card { margin-bottom: 20rpx; padding: 28rpx; border-radius: 20rpx; background: #fff; box-shadow: 0 7rpx 22rpx rgba(30, 48, 82, .05); }
.record-head, .record-row { display: flex; align-items: center; justify-content: space-between; }
.record-name { flex: 1; font-size: 31rpx; font-weight: 600; color: #262b35; }
.status { padding: 6rpx 14rpx; border-radius: 16rpx; font-size: 22rpx; }
.status.pending { color: #e49320; background: #fff5e5; }
.status.approved { color: #269761; background: #e9f8f0; }
.status.rejected { color: #de5555; background: #fff0f0; }
.status.revoked { color: #7d8490; background: #f0f1f4; }
.record-row { margin-top: 20rpx; color: #646b76; font-size: 25rpx; }
.record-row.muted { color: #a0a6b0; font-size: 22rpx; }
.reward { color: #ff8821; font-weight: 600; }
.reason { margin-top: 20rpx; padding: 18rpx; border-radius: 12rpx; color: #d65353; background: #fff6f6; font-size: 24rpx; line-height: 35rpx; }
.empty { padding-top: 220rpx; text-align: center; color: #a7aeba; font-size: 27rpx; }
.empty-icon { display: block; margin-bottom: 18rpx; font-size: 88rpx; color: #d7dce5; }
.go-btn { width: 230rpx; margin-top: 30rpx; border: 0; border-radius: 40rpx; color: #fff; background: #5578f4; font-size: 27rpx; }
.loading-more { padding: 20rpx; text-align: center; color: #aaa; font-size: 24rpx; }
</style>