Add 卓米会 mini program and admin server.
Initial commit of the WeChat mini program, Express backend, and admin console. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
const api = require('../../utils/api')
|
||||
const authUtil = require('../../utils/auth')
|
||||
const activityUtil = require('../../utils/activity')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
activity: null,
|
||||
failed: false
|
||||
},
|
||||
|
||||
onLoad(query) {
|
||||
authUtil.captureSharer(query)
|
||||
this.activityId = (query && query.id) || ''
|
||||
wx.showShareMenu({ menus: ['shareAppMessage'] })
|
||||
this.loadActivity()
|
||||
},
|
||||
|
||||
loadActivity() {
|
||||
if (!this.activityId) {
|
||||
this.setData({ failed: true })
|
||||
return
|
||||
}
|
||||
api
|
||||
.activity(this.activityId)
|
||||
.then((activity) => {
|
||||
this.setData({ activity: activity, failed: false })
|
||||
if (activity && activity.title) wx.setNavigationBarTitle({ title: activity.title })
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setData({ failed: true })
|
||||
activityUtil.toast(error)
|
||||
})
|
||||
},
|
||||
|
||||
openMap() {
|
||||
activityUtil.openMap(this.data.activity)
|
||||
},
|
||||
|
||||
goSignup() {
|
||||
activityUtil.goSignup(this.data.activity)
|
||||
},
|
||||
|
||||
goHome() {
|
||||
wx.switchTab({ url: '/pages/index/index' })
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return activityUtil.shareMessage(this.data.activity, this.activityId)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"navigationBarTitleText": "卓米会",
|
||||
"usingComponents": {
|
||||
"activity-board": "/components/activity-board/index"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<activity-board
|
||||
wx:if="{{activity}}"
|
||||
activity="{{activity}}"
|
||||
show-past="{{false}}"
|
||||
bind:map="openMap"
|
||||
bind:signup="goSignup"
|
||||
/>
|
||||
<view wx:elif="{{failed}}" class="empty">
|
||||
<text>活动不存在或已下线</text>
|
||||
<view class="home" bindtap="goHome">回到首页</view>
|
||||
</view>
|
||||
<view wx:else class="empty">加载中</view>
|
||||
@@ -0,0 +1,17 @@
|
||||
.empty {
|
||||
padding: 160rpx 32rpx;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.home {
|
||||
display: inline-block;
|
||||
margin-top: 32rpx;
|
||||
padding: 0 40rpx;
|
||||
height: 76rpx;
|
||||
line-height: 76rpx;
|
||||
border-radius: 38rpx;
|
||||
border: 2rpx solid #128a80;
|
||||
color: #128a80;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
const api = require('../../utils/api')
|
||||
const authUtil = require('../../utils/auth')
|
||||
const activityUtil = require('../../utils/activity')
|
||||
const fallback = require('../../utils/fallback')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
activity: fallback.CURRENT
|
||||
},
|
||||
|
||||
onLoad(query) {
|
||||
authUtil.captureSharer(query)
|
||||
wx.showShareMenu({ menus: ['shareAppMessage'] })
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadActivity()
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
this.loadActivity().then(() => wx.stopPullDownRefresh())
|
||||
},
|
||||
|
||||
loadActivity() {
|
||||
return api
|
||||
.currentActivity()
|
||||
.then((activity) => {
|
||||
if (activity) this.setData({ activity: activity })
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
openMap() {
|
||||
activityUtil.openMap(this.data.activity)
|
||||
},
|
||||
|
||||
goSignup() {
|
||||
activityUtil.goSignup(this.data.activity)
|
||||
},
|
||||
|
||||
goPast() {
|
||||
wx.switchTab({ url: '/pages/past/past' })
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return activityUtil.shareMessage(this.data.activity)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "卓米会",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {
|
||||
"activity-board": "/components/activity-board/index"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<activity-board
|
||||
activity="{{activity}}"
|
||||
show-past="{{true}}"
|
||||
bind:map="openMap"
|
||||
bind:signup="goSignup"
|
||||
bind:past="goPast"
|
||||
/>
|
||||
@@ -0,0 +1,3 @@
|
||||
page {
|
||||
background: #f3f5f7;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
const api = require('../../utils/api')
|
||||
const media = require('../../utils/media')
|
||||
const activityUtil = require('../../utils/activity')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
nickName: '',
|
||||
avatarUrl: '',
|
||||
letter: '微',
|
||||
signups: [],
|
||||
checkins: [],
|
||||
checking: false
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadAll()
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
this.loadAll().then(() => wx.stopPullDownRefresh())
|
||||
},
|
||||
|
||||
loadAll() {
|
||||
return Promise.all([api.profile(), api.mySignups()])
|
||||
.then((results) => {
|
||||
const profile = results[0] || {}
|
||||
const mine = results[1] || {}
|
||||
const nickName = profile.nickName || ''
|
||||
this.setData({
|
||||
nickName: nickName,
|
||||
avatarUrl: media.absUrl(profile.avatarUrl),
|
||||
letter: (nickName || '微').slice(0, 1),
|
||||
signups: mine.signups || [],
|
||||
checkins: mine.checkins || []
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
goProfile() {
|
||||
wx.navigateTo({ url: '/pages/profile/profile' })
|
||||
},
|
||||
|
||||
checkin() {
|
||||
if (this.data.checking) return
|
||||
this.setData({ checking: true })
|
||||
api
|
||||
.checkin({ simulate: true })
|
||||
.then(() => {
|
||||
wx.showToast({ title: '签到成功', icon: 'success' })
|
||||
return this.loadAll()
|
||||
})
|
||||
.catch(activityUtil.toast)
|
||||
.then(() => this.setData({ checking: false }))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "我的"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<view class="page">
|
||||
<view class="card profile" hover-class="pressed" bindtap="goProfile">
|
||||
<image wx:if="{{avatarUrl}}" class="avatar" src="{{avatarUrl}}" mode="aspectFill" />
|
||||
<view wx:else class="avatar letter">{{letter}}</view>
|
||||
<view class="who">
|
||||
<text class="name">{{nickName || '微信用户'}}</text>
|
||||
<text class="edit">点击编辑资料</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card block">
|
||||
<text class="heading">我的报名</text>
|
||||
<view wx:if="{{!signups.length}}" class="empty">暂无报名记录,去首页报名吧</view>
|
||||
<view wx:for="{{signups}}" wx:key="id" class="item">
|
||||
<view class="item-main">
|
||||
<text class="item-title">{{item.activityTitle}}</text>
|
||||
<text class="item-sub">{{nickName || item.name}} · {{item.phone}}</text>
|
||||
</view>
|
||||
<text class="tag {{item.checkedIn ? 'on' : ''}}">{{item.checkedIn ? '已签到' : '未签到'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card block">
|
||||
<text class="heading">签到记录</text>
|
||||
<view wx:if="{{!checkins.length}}" class="empty">暂无签到记录</view>
|
||||
<view wx:for="{{checkins}}" wx:key="id" class="item">
|
||||
<view class="item-main">
|
||||
<text class="item-title">{{item.activityTitle}}</text>
|
||||
<text class="item-sub">已签到</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="scan" hover-class="scan-pressed" bindtap="checkin">模拟现场扫码签到</view>
|
||||
</view>
|
||||
@@ -0,0 +1,131 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 28rpx 32rpx 48rpx;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28rpx;
|
||||
padding: 36rpx 32rpx;
|
||||
}
|
||||
|
||||
.pressed {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
border-radius: 56rpx;
|
||||
}
|
||||
|
||||
.letter {
|
||||
background: #128a80;
|
||||
color: #ffffff;
|
||||
font-size: 42rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.who {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
display: block;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.edit {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: #9ca3af;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.block {
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 56rpx 0 28rpx;
|
||||
text-align: center;
|
||||
color: #c5cdd6;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding-top: 28rpx;
|
||||
}
|
||||
|
||||
.item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.item-sub {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
color: #9ca3af;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.tag {
|
||||
flex-shrink: 0;
|
||||
color: #9ca3af;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.tag.on {
|
||||
color: #128a80;
|
||||
}
|
||||
|
||||
.scan {
|
||||
height: 96rpx;
|
||||
margin-top: 8rpx;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #128a80;
|
||||
color: #128a80;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.scan-pressed {
|
||||
background: #f0faf9;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
const api = require('../../utils/api')
|
||||
const media = require('../../utils/media')
|
||||
const activityUtil = require('../../utils/activity')
|
||||
|
||||
function chooseOne(mediaType) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.chooseMedia({
|
||||
count: 1,
|
||||
mediaType: [mediaType],
|
||||
sourceType: ['album', 'camera'],
|
||||
maxDuration: 60,
|
||||
success: (res) => {
|
||||
const file = res.tempFiles && res.tempFiles[0]
|
||||
if (file && file.tempFilePath) resolve(file.tempFilePath)
|
||||
},
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function uploadWithLoading(filePath) {
|
||||
wx.showLoading({ title: '上传中', mask: true })
|
||||
return api.uploadMedia(filePath).then(
|
||||
(data) => {
|
||||
wx.hideLoading()
|
||||
return data
|
||||
},
|
||||
(error) => {
|
||||
wx.hideLoading()
|
||||
throw error
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
title: '',
|
||||
startAt: '',
|
||||
pinned: false,
|
||||
cover: '',
|
||||
coverPreview: '',
|
||||
video: '',
|
||||
saving: false
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
api
|
||||
.profile()
|
||||
.then((profile) => {
|
||||
if (profile && profile.isAdmin) return
|
||||
wx.showToast({ title: '仅管理员可操作', icon: 'none' })
|
||||
setTimeout(() => wx.navigateBack(), 400)
|
||||
})
|
||||
.catch((error) => {
|
||||
activityUtil.toast(error)
|
||||
setTimeout(() => wx.navigateBack(), 400)
|
||||
})
|
||||
},
|
||||
|
||||
onTitle(event) {
|
||||
this.setData({ title: event.detail.value })
|
||||
},
|
||||
|
||||
onTime(event) {
|
||||
this.setData({ startAt: event.detail.value })
|
||||
},
|
||||
|
||||
togglePinned() {
|
||||
this.setData({ pinned: !this.data.pinned })
|
||||
},
|
||||
|
||||
pickCover() {
|
||||
chooseOne('image')
|
||||
.then(uploadWithLoading)
|
||||
.then((data) => this.setData({ cover: data.url, coverPreview: media.absUrl(data.url) }))
|
||||
.catch((error) => {
|
||||
if (error && error.message) activityUtil.toast(error)
|
||||
})
|
||||
},
|
||||
|
||||
clearCover() {
|
||||
this.setData({ cover: '', coverPreview: '' })
|
||||
},
|
||||
|
||||
pickVideo() {
|
||||
chooseOne('video')
|
||||
.then(uploadWithLoading)
|
||||
.then((data) => this.setData({ video: data.url }))
|
||||
.catch((error) => {
|
||||
if (error && error.message) activityUtil.toast(error)
|
||||
})
|
||||
},
|
||||
|
||||
clearVideo() {
|
||||
this.setData({ video: '' })
|
||||
},
|
||||
|
||||
save() {
|
||||
if (this.data.saving) return
|
||||
const title = this.data.title.trim()
|
||||
const startAt = this.data.startAt.trim()
|
||||
if (!title || !startAt) {
|
||||
wx.showToast({ title: '请填写名称和时间', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.setData({ saving: true })
|
||||
api
|
||||
.createPast({
|
||||
title: title,
|
||||
startAt: startAt,
|
||||
pinned: this.data.pinned,
|
||||
cover: this.data.cover,
|
||||
video: this.data.video
|
||||
})
|
||||
.then(() => wx.navigateBack())
|
||||
.catch((error) => {
|
||||
this.setData({ saving: false })
|
||||
activityUtil.toast(error)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "新增往期"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<view class="page">
|
||||
<view class="card">
|
||||
<text class="label">活动名称</text>
|
||||
<input
|
||||
class="input"
|
||||
placeholder="例如:卓米会城市沙龙"
|
||||
placeholder-class="placeholder"
|
||||
value="{{title}}"
|
||||
maxlength="40"
|
||||
bindinput="onTitle"
|
||||
/>
|
||||
<text class="label">活动时间</text>
|
||||
<input
|
||||
class="input"
|
||||
placeholder="例如:2026-05-22 19:00"
|
||||
placeholder-class="placeholder"
|
||||
value="{{startAt}}"
|
||||
maxlength="32"
|
||||
bindinput="onTime"
|
||||
/>
|
||||
|
||||
<text class="label">封面图片</text>
|
||||
<view class="media-row">
|
||||
<image wx:if="{{cover}}" class="preview" src="{{coverPreview}}" mode="aspectFill" />
|
||||
<view class="media-btn" bindtap="pickCover">{{cover ? '更换图片' : '选择图片'}}</view>
|
||||
<view wx:if="{{cover}}" class="media-btn ghost" bindtap="clearCover">清除</view>
|
||||
</view>
|
||||
|
||||
<text class="label">活动视频</text>
|
||||
<view class="media-row">
|
||||
<text class="media-name">{{video ? '已选择视频' : '未选择'}}</text>
|
||||
<view class="media-btn" bindtap="pickVideo">{{video ? '更换视频' : '选择视频'}}</view>
|
||||
<view wx:if="{{video}}" class="media-btn ghost" bindtap="clearVideo">清除</view>
|
||||
</view>
|
||||
|
||||
<view class="pin-row" bindtap="togglePinned">
|
||||
<view class="check {{pinned ? 'on' : ''}}"></view>
|
||||
<text>创建后置顶(可同时置顶多个)</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="save {{saving ? 'busy' : ''}}" bindtap="save">{{saving ? '保存中…' : '保存'}}</view>
|
||||
</view>
|
||||
@@ -0,0 +1,119 @@
|
||||
.page {
|
||||
padding: 28rpx 32rpx 48rpx;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 12rpx 32rpx 28rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 28rpx;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-top: 28rpx;
|
||||
color: #6b7280;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 88rpx;
|
||||
margin-top: 8rpx;
|
||||
border-bottom: 1rpx solid #eef0f3;
|
||||
font-size: 32rpx;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: #c4c9d1;
|
||||
}
|
||||
|
||||
.pin-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-top: 32rpx;
|
||||
padding-bottom: 8rpx;
|
||||
color: #374151;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.media-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.preview {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #eef0f3;
|
||||
}
|
||||
|
||||
.media-name {
|
||||
color: #6b7280;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.media-btn {
|
||||
height: 64rpx;
|
||||
padding: 0 24rpx;
|
||||
border-radius: 32rpx;
|
||||
background: #128a80;
|
||||
color: #ffffff;
|
||||
font-size: 26rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.media-btn.ghost {
|
||||
background: #ffffff;
|
||||
color: #6b7280;
|
||||
border: 2rpx solid #e5e7eb;
|
||||
}
|
||||
|
||||
.check {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 8rpx;
|
||||
border: 2rpx solid #cbd5e1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.check.on {
|
||||
background: #128a80;
|
||||
border-color: #128a80;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.check.on::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10rpx;
|
||||
top: 4rpx;
|
||||
width: 10rpx;
|
||||
height: 18rpx;
|
||||
border: 4rpx solid #ffffff;
|
||||
border-top: 0;
|
||||
border-left: 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.save {
|
||||
height: 96rpx;
|
||||
margin-top: 36rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #128a80;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.save.busy {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
const api = require('../../utils/api')
|
||||
const media = require('../../utils/media')
|
||||
const activityUtil = require('../../utils/activity')
|
||||
|
||||
function decorate(item) {
|
||||
const cover = item.cover || item.poster || ''
|
||||
return Object.assign({}, item, { coverUrl: cover ? media.absUrl(cover) : '' })
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
list: [],
|
||||
loaded: false,
|
||||
deleting: false,
|
||||
isAdmin: false
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadPage()
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
this.loadPage().then(() => wx.stopPullDownRefresh())
|
||||
},
|
||||
|
||||
loadPage() {
|
||||
const profile = api.profile().catch(() => null)
|
||||
return Promise.all([api.pastActivities(), profile])
|
||||
.then((results) => {
|
||||
const isAdmin = !!(results[1] && results[1].isAdmin)
|
||||
this.setData({
|
||||
list: (results[0] || []).map(decorate),
|
||||
loaded: true,
|
||||
isAdmin: isAdmin,
|
||||
deleting: isAdmin && this.data.deleting
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setData({ loaded: true })
|
||||
activityUtil.toast(error)
|
||||
})
|
||||
},
|
||||
|
||||
openDetail(event) {
|
||||
if (this.data.deleting) return
|
||||
wx.navigateTo({ url: '/pages/detail/detail?id=' + event.currentTarget.dataset.id })
|
||||
},
|
||||
|
||||
onAdd() {
|
||||
if (!this.data.isAdmin) return
|
||||
wx.navigateTo({ url: '/pages/past-edit/past-edit' })
|
||||
},
|
||||
|
||||
onDelete() {
|
||||
if (!this.data.isAdmin) return
|
||||
if (!this.data.list.length) {
|
||||
wx.showToast({ title: '还没有可以删除的活动', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.setData({ deleting: !this.data.deleting })
|
||||
},
|
||||
|
||||
remove(event) {
|
||||
if (!this.data.isAdmin) return
|
||||
const id = event.currentTarget.dataset.id
|
||||
wx.showModal({
|
||||
title: '删除这场活动?',
|
||||
content: event.currentTarget.dataset.title,
|
||||
confirmColor: '#DC2626',
|
||||
success: (res) => {
|
||||
if (!res.confirm) return
|
||||
api
|
||||
.removePast(id)
|
||||
.then(() => {
|
||||
const list = this.data.list.filter((item) => item.id !== id)
|
||||
this.setData({ list: list, deleting: list.length > 0 && this.data.deleting })
|
||||
})
|
||||
.catch(activityUtil.toast)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
togglePin(event) {
|
||||
if (!this.data.isAdmin) return
|
||||
const dataset = event.currentTarget.dataset
|
||||
api
|
||||
.pinPast(dataset.id, String(dataset.pinned) !== '1')
|
||||
.then(() => this.loadPage())
|
||||
.catch(activityUtil.toast)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "往期活动",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<view class="page">
|
||||
<view wx:if="{{isAdmin}}" class="actions">
|
||||
<view class="btn add" bindtap="onAdd">增加</view>
|
||||
<view class="btn del {{deleting ? 'active' : ''}}" bindtap="onDelete">{{deleting ? '完成' : '删除'}}</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{loaded && !list.length}}" class="empty">还没有往期活动</view>
|
||||
|
||||
<view
|
||||
class="card {{item.pinned ? 'pinned' : ''}}"
|
||||
wx:for="{{list}}"
|
||||
wx:key="id"
|
||||
data-id="{{item.id}}"
|
||||
bindtap="openDetail"
|
||||
>
|
||||
<view class="cover theme-{{item.theme}}">
|
||||
<image wx:if="{{item.coverUrl}}" class="cover-image" src="{{item.coverUrl}}" mode="aspectFill" lazy-load />
|
||||
<view wx:if="{{item.coverUrl}}" class="cover-shade"></view>
|
||||
<text wx:if="{{item.pinned}}" class="pin-badge">置顶</text>
|
||||
<text wx:if="{{item.video}}" class="video-badge">▶ 视频</text>
|
||||
<text class="cover-title">{{item.title}}</text>
|
||||
</view>
|
||||
<view class="body">
|
||||
<view class="title-row">
|
||||
<text class="name">{{item.title}}</text>
|
||||
<view wx:if="{{isAdmin}}" class="ops">
|
||||
<text
|
||||
wx:if="{{!deleting}}"
|
||||
class="pin-btn"
|
||||
data-id="{{item.id}}"
|
||||
data-pinned="{{item.pinned ? 1 : 0}}"
|
||||
catchtap="togglePin"
|
||||
>{{item.pinned ? '取消置顶' : '置顶'}}</text>
|
||||
<text
|
||||
wx:else
|
||||
class="remove"
|
||||
data-id="{{item.id}}"
|
||||
data-title="{{item.title}}"
|
||||
catchtap="remove"
|
||||
>删除</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="time">{{item.startAt}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,163 @@
|
||||
.page {
|
||||
padding: 28rpx 32rpx 48rpx;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
height: 84rpx;
|
||||
border-radius: 18rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add {
|
||||
background: #128a80;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.del {
|
||||
background: #ffffff;
|
||||
color: #dc2626;
|
||||
border: 2rpx solid #fecaca;
|
||||
}
|
||||
|
||||
.del.active {
|
||||
background: #fff1f2;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin-top: 160rpx;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-top: 28rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.card:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.card.pinned {
|
||||
box-shadow: 0 8rpx 24rpx rgba(18, 138, 128, 0.14);
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: relative;
|
||||
height: 272rpx;
|
||||
padding: 32rpx;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-image,
|
||||
.cover-shade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cover-shade {
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.45) 100%);
|
||||
}
|
||||
|
||||
.cover-title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: #ffffff;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.pin-badge,
|
||||
.video-badge {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
height: 44rpx;
|
||||
padding: 0 16rpx;
|
||||
border-radius: 22rpx;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: #ffffff;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
line-height: 44rpx;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pin-badge {
|
||||
left: 20rpx;
|
||||
}
|
||||
|
||||
.video-badge {
|
||||
right: 20rpx;
|
||||
}
|
||||
|
||||
.theme-0 {
|
||||
background: linear-gradient(135deg, #0b6b66 0%, #13857c 100%);
|
||||
}
|
||||
|
||||
.theme-1 {
|
||||
background: linear-gradient(135deg, #1aa39a 0%, #4fd1c5 100%);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 28rpx 32rpx 32rpx;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.ops {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.time {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: #9ca3af;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.pin-btn {
|
||||
color: #128a80;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.remove {
|
||||
color: #dc2626;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
const api = require('../../utils/api')
|
||||
const media = require('../../utils/media')
|
||||
const activityUtil = require('../../utils/activity')
|
||||
|
||||
const FIELDS = ['nickName', 'name', 'phone', 'company', 'position']
|
||||
|
||||
Page({
|
||||
data: {
|
||||
avatarUrl: '',
|
||||
avatarPreview: '',
|
||||
nickName: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
company: '',
|
||||
position: '',
|
||||
letter: '微',
|
||||
uploading: false,
|
||||
saving: false
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
api
|
||||
.profile()
|
||||
.then((profile) => {
|
||||
const next = {
|
||||
avatarUrl: profile.avatarUrl || '',
|
||||
avatarPreview: media.absUrl(profile.avatarUrl),
|
||||
letter: (profile.nickName || profile.name || '微').slice(0, 1)
|
||||
}
|
||||
FIELDS.forEach((key) => {
|
||||
next[key] = profile[key] || ''
|
||||
})
|
||||
this.setData(next)
|
||||
})
|
||||
.catch(activityUtil.toast)
|
||||
},
|
||||
|
||||
pickAvatar() {
|
||||
if (this.data.uploading) return
|
||||
wx.chooseMedia({
|
||||
count: 1,
|
||||
mediaType: ['image'],
|
||||
sourceType: ['album', 'camera'],
|
||||
sizeType: ['compressed'],
|
||||
success: (res) => {
|
||||
const file = res.tempFiles && res.tempFiles[0]
|
||||
if (!file || !file.tempFilePath) return
|
||||
this.setData({ uploading: true, avatarPreview: file.tempFilePath })
|
||||
api
|
||||
.uploadAvatar(file.tempFilePath)
|
||||
.then((data) => this.setData({ avatarUrl: data.url, uploading: false }))
|
||||
.catch((error) => {
|
||||
this.setData({ uploading: false, avatarPreview: media.absUrl(this.data.avatarUrl) })
|
||||
activityUtil.toast(error)
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onInput(event) {
|
||||
const next = {}
|
||||
next[event.currentTarget.dataset.field] = event.detail.value
|
||||
this.setData(next)
|
||||
},
|
||||
|
||||
save() {
|
||||
if (this.data.saving) return
|
||||
if (this.data.uploading) {
|
||||
wx.showToast({ title: '头像上传中,请稍候', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const payload = { avatarUrl: this.data.avatarUrl }
|
||||
FIELDS.forEach((key) => {
|
||||
payload[key] = (this.data[key] || '').trim()
|
||||
})
|
||||
if (!payload.name && !payload.nickName) {
|
||||
wx.showToast({ title: '请至少填写昵称或姓名', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (payload.phone && !/^1\d{10}$/.test(payload.phone)) {
|
||||
wx.showToast({ title: '请填写正确的手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({ saving: true })
|
||||
api
|
||||
.saveProfile(payload)
|
||||
.then(() => {
|
||||
wx.showToast({ title: '已保存', icon: 'success' })
|
||||
setTimeout(() => wx.navigateBack(), 500)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setData({ saving: false })
|
||||
activityUtil.toast(error)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "编辑资料"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<view class="page">
|
||||
<view class="card avatar-card">
|
||||
<view class="avatar-btn" bindtap="pickAvatar">
|
||||
<image wx:if="{{avatarPreview}}" class="avatar" src="{{avatarPreview}}" mode="aspectFill" />
|
||||
<view wx:else class="avatar letter">{{letter}}</view>
|
||||
</view>
|
||||
<text class="hint">{{uploading ? '头像上传中…' : '点击更换头像'}}</text>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<text class="label">昵称</text>
|
||||
<input class="input" placeholder="填写昵称" placeholder-class="placeholder" value="{{nickName}}" maxlength="32" data-field="nickName" bindinput="onInput" />
|
||||
<text class="label">姓名</text>
|
||||
<input class="input" placeholder="真实姓名" placeholder-class="placeholder" value="{{name}}" maxlength="20" data-field="name" bindinput="onInput" />
|
||||
<text class="label">手机号</text>
|
||||
<input class="input" type="number" placeholder="11 位手机号" placeholder-class="placeholder" value="{{phone}}" maxlength="11" data-field="phone" bindinput="onInput" />
|
||||
<text class="label">公司</text>
|
||||
<input class="input" placeholder="公司 / 机构" placeholder-class="placeholder" value="{{company}}" maxlength="40" data-field="company" bindinput="onInput" />
|
||||
<text class="label">职位</text>
|
||||
<input class="input" placeholder="职位" placeholder-class="placeholder" value="{{position}}" maxlength="40" data-field="position" bindinput="onInput" />
|
||||
</view>
|
||||
|
||||
<button class="save" loading="{{saving}}" disabled="{{saving}}" bindtap="save">保存资料</button>
|
||||
</view>
|
||||
@@ -0,0 +1,94 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 28rpx 32rpx 48rpx;
|
||||
background: #f3f5f7;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 28rpx;
|
||||
padding: 12rpx 32rpx 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.avatar-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 36rpx 32rpx;
|
||||
}
|
||||
|
||||
.avatar-btn {
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
border-radius: 64rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar,
|
||||
.letter {
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
border-radius: 64rpx;
|
||||
}
|
||||
|
||||
.letter {
|
||||
background: #128a80;
|
||||
color: #ffffff;
|
||||
font-size: 44rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 16rpx;
|
||||
color: #9ca3af;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-top: 28rpx;
|
||||
color: #6b7280;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 88rpx;
|
||||
margin-top: 8rpx;
|
||||
border-bottom: 1rpx solid #eef0f3;
|
||||
font-size: 32rpx;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: #c4c9d1;
|
||||
}
|
||||
|
||||
.save {
|
||||
width: 100% !important;
|
||||
height: 96rpx;
|
||||
margin: 12rpx 0 0;
|
||||
padding: 0;
|
||||
border-radius: 20rpx;
|
||||
background: #128a80;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
line-height: 96rpx;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.save::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.save[disabled] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
const api = require('../../utils/api')
|
||||
const authUtil = require('../../utils/auth')
|
||||
const activityUtil = require('../../utils/activity')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
name: '',
|
||||
phone: '',
|
||||
company: '',
|
||||
submitting: false
|
||||
},
|
||||
|
||||
onLoad(query) {
|
||||
this.activityId = (query && query.id) || ''
|
||||
api
|
||||
.profile()
|
||||
.then((profile) => {
|
||||
if (!profile) return
|
||||
this.setData({
|
||||
name: this.data.name || profile.name || '',
|
||||
phone: this.data.phone || profile.phone || '',
|
||||
company: this.data.company || profile.company || ''
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
onName(event) {
|
||||
this.setData({ name: event.detail.value })
|
||||
},
|
||||
|
||||
onPhone(event) {
|
||||
this.setData({ phone: event.detail.value })
|
||||
},
|
||||
|
||||
onCompany(event) {
|
||||
this.setData({ company: event.detail.value })
|
||||
},
|
||||
|
||||
submit() {
|
||||
if (this.data.submitting) return
|
||||
const name = this.data.name.trim()
|
||||
const phone = this.data.phone.trim()
|
||||
const company = this.data.company.trim()
|
||||
if (!name || !phone) {
|
||||
wx.showToast({ title: '请填写姓名和手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!/^1\d{10}$/.test(phone)) {
|
||||
wx.showToast({ title: '请填写正确的手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.setData({ submitting: true })
|
||||
api
|
||||
.signup({
|
||||
activityId: this.activityId,
|
||||
name: name,
|
||||
phone: phone,
|
||||
company: company,
|
||||
sourceSharer: authUtil.sourceSharer()
|
||||
})
|
||||
.then(() => {
|
||||
wx.showToast({ title: '报名成功', icon: 'success' })
|
||||
setTimeout(() => wx.navigateBack(), 600)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setData({ submitting: false })
|
||||
activityUtil.toast(error)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "立即报名"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<view class="page">
|
||||
<view class="card">
|
||||
<text class="label">姓名</text>
|
||||
<input class="input" placeholder="怎么称呼你" placeholder-class="placeholder" value="{{name}}" maxlength="20" bindinput="onName" />
|
||||
<text class="label">手机号</text>
|
||||
<input class="input" type="number" placeholder="用于活动通知" placeholder-class="placeholder" value="{{phone}}" maxlength="11" bindinput="onPhone" />
|
||||
<text class="label">公司 / 机构</text>
|
||||
<input class="input" placeholder="选填" placeholder-class="placeholder" value="{{company}}" maxlength="40" bindinput="onCompany" />
|
||||
</view>
|
||||
<view class="save {{submitting ? 'busy' : ''}}" bindtap="submit">{{submitting ? '提交中…' : '提交报名'}}</view>
|
||||
<text class="hint">手机号仅用于本次活动通知。</text>
|
||||
</view>
|
||||
@@ -0,0 +1,52 @@
|
||||
.page {
|
||||
padding: 28rpx 32rpx 48rpx;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 12rpx 32rpx 28rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 28rpx;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-top: 28rpx;
|
||||
color: #6b7280;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 88rpx;
|
||||
margin-top: 8rpx;
|
||||
border-bottom: 1rpx solid #eef0f3;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: #c4c9d1;
|
||||
}
|
||||
|
||||
.save {
|
||||
height: 96rpx;
|
||||
margin-top: 36rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #128a80;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.save.busy {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 20rpx;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
Reference in New Issue
Block a user