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:
Michelle Zan
2026-09-29 19:01:36 +08:00
co-authored by Cursor
commit cd05113755
67 changed files with 4845 additions and 0 deletions
+121
View File
@@ -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>
+119
View File
@@ -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;
}