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
+11
View File
@@ -0,0 +1,11 @@
const authUtil = require('./utils/auth')
App({
onLaunch(options) {
authUtil.captureSharer(options && options.query)
authUtil.ensureLogin().catch(() => {})
},
onShow(options) {
authUtil.captureSharer(options && options.query)
}
})
+47
View File
@@ -0,0 +1,47 @@
{
"pages": [
"pages/index/index",
"pages/past/past",
"pages/mine/mine",
"pages/signup/signup",
"pages/profile/profile",
"pages/past-edit/past-edit",
"pages/detail/detail"
],
"window": {
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTitleText": "卓米会",
"navigationBarTextStyle": "black",
"backgroundColor": "#F3F5F7",
"backgroundTextStyle": "dark"
},
"tabBar": {
"color": "#9CA3AF",
"selectedColor": "#128A80",
"backgroundColor": "#FFFFFF",
"borderStyle": "white",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "assets/tab-home.png",
"selectedIconPath": "assets/tab-home-on.png"
},
{
"pagePath": "pages/past/past",
"text": "往期",
"iconPath": "assets/tab-past.png",
"selectedIconPath": "assets/tab-past-on.png"
},
{
"pagePath": "pages/mine/mine",
"text": "我的",
"iconPath": "assets/tab-mine.png",
"selectedIconPath": "assets/tab-mine-on.png"
}
]
},
"style": "v2",
"sitemapLocation": "sitemap.json",
"lazyCodeLoading": "requiredComponents"
}
+11
View File
@@ -0,0 +1,11 @@
page {
background: #f3f5f7;
color: #1f2937;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
font-size: 28rpx;
line-height: 1.5;
}
button::after {
border: none;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

@@ -0,0 +1,41 @@
const media = require('../../utils/media')
Component({
properties: {
activity: {
type: Object,
value: null
},
showPast: {
type: Boolean,
value: false
}
},
data: {
poster: '',
video: '',
ended: false
},
observers: {
activity(activity) {
const item = activity || {}
this.setData({
poster: media.absUrl(item.poster || item.cover),
video: media.absUrl(item.video),
ended: item.status === 'past'
})
}
},
methods: {
onMap() {
this.triggerEvent('map')
},
onSignup() {
if (!this.data.activity || this.data.ended) return
this.triggerEvent('signup')
},
onPast() {
this.triggerEvent('past')
}
}
})
@@ -0,0 +1,3 @@
{
"component": true
}
@@ -0,0 +1,48 @@
<view class="board" wx:if="{{activity}}">
<view class="banner">
<video
wx:if="{{video}}"
class="banner-media"
src="{{video}}"
controls
show-center-play-btn
object-fit="cover"
poster="{{poster}}"
/>
<block wx:else>
<image wx:if="{{poster}}" class="banner-media" src="{{poster}}" mode="aspectFill" />
<view class="banner-shade"></view>
<text class="banner-title">{{activity.title}}</text>
</block>
<view class="badge" wx:if="{{activity.isCurrent}}">当前活动</view>
</view>
<view class="card">
<text class="brand">{{activity.brandName}}</text>
<text wx:if="{{activity.brandDesc}}" class="desc">{{activity.brandDesc}}</text>
</view>
<view class="card info">
<view class="row">
<text class="label">活动名称</text>
<text class="value">{{activity.title}}</text>
</view>
<view class="row">
<text class="label">活动时间</text>
<text class="value">{{activity.startAt || '待定'}}</text>
</view>
<view class="row last">
<text class="label">会场地址</text>
<text class="value">{{activity.address || '待定'}}</text>
</view>
</view>
<view class="btn green" hover-class="btn-pressed" bindtap="onMap">📍 一键导航</view>
<view
class="btn teal {{ended ? 'disabled' : ''}}"
hover-class="{{ended ? 'none' : 'btn-pressed'}}"
bindtap="onSignup"
>{{ended ? '活动已结束' : '立即报名'}}</view>
<view wx:if="{{showPast}}" class="more" bindtap="onPast">查看往期活动 ›</view>
</view>
@@ -0,0 +1,139 @@
.board {
padding: 28rpx 32rpx 48rpx;
}
.banner {
position: relative;
height: 320rpx;
border-radius: 28rpx;
overflow: hidden;
background: linear-gradient(180deg, #d5dbe3 0%, #8e98a6 100%);
}
.banner-media,
.banner-shade {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.banner-shade {
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 45%, rgba(0, 0, 0, 0.28) 100%);
}
.badge {
position: absolute;
left: 24rpx;
top: 24rpx;
z-index: 2;
height: 48rpx;
padding: 0 20rpx;
border-radius: 24rpx;
background: rgba(55, 65, 81, 0.45);
color: #ffffff;
font-size: 24rpx;
line-height: 48rpx;
}
.banner-title {
position: absolute;
left: 32rpx;
right: 32rpx;
bottom: 32rpx;
z-index: 1;
color: #ffffff;
font-size: 40rpx;
font-weight: 700;
line-height: 1.35;
}
.card {
margin-top: 24rpx;
padding: 32rpx;
background: #ffffff;
border-radius: 28rpx;
}
.brand {
display: block;
color: #128a80;
font-size: 36rpx;
font-weight: 700;
}
.desc {
display: block;
margin-top: 16rpx;
color: #6b7280;
font-size: 28rpx;
line-height: 1.65;
}
.info {
padding: 8rpx 32rpx;
}
.row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24rpx;
padding: 28rpx 0;
border-bottom: 1rpx dashed #e5e7eb;
}
.row.last {
border-bottom: none;
}
.label {
flex-shrink: 0;
color: #9ca3af;
font-size: 28rpx;
}
.value {
flex: 1;
color: #111827;
font-size: 28rpx;
text-align: right;
line-height: 1.5;
}
.btn {
height: 96rpx;
margin-top: 24rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
}
.btn-pressed {
opacity: 0.85;
}
.green {
background: #07c160;
}
.teal {
background: #0d8b87;
}
.disabled {
background: #cbd5e1;
}
.more {
margin-top: 36rpx;
padding: 8rpx 0;
text-align: center;
color: #9ca3af;
font-size: 28rpx;
}
+4
View File
@@ -0,0 +1,4 @@
/** 后端地址。上线改为 HTTPS 域名,并加入小程序 request / downloadFile 合法域名。 */
const BASE = 'http://127.0.0.1:3000'
module.exports = { BASE }
+50
View File
@@ -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)
}
})
+6
View File
@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "卓米会",
"usingComponents": {
"activity-board": "/components/activity-board/index"
}
}
+12
View File
@@ -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>
+17
View File
@@ -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;
}
+48
View File
@@ -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)
}
})
+7
View File
@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "卓米会",
"enablePullDownRefresh": true,
"usingComponents": {
"activity-board": "/components/activity-board/index"
}
}
+7
View File
@@ -0,0 +1,7 @@
<activity-board
activity="{{activity}}"
show-past="{{true}}"
bind:map="openMap"
bind:signup="goSignup"
bind:past="goPast"
/>
+3
View File
@@ -0,0 +1,3 @@
page {
background: #f3f5f7;
}
+56
View File
@@ -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 }))
}
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "我的"
}
+35
View File
@@ -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>
+131
View File
@@ -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;
}
+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;
}
+91
View File
@@ -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)
}
})
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "往期活动",
"enablePullDownRefresh": true
}
+46
View File
@@ -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>
+163
View File
@@ -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;
}
+97
View File
@@ -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)
})
}
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "编辑资料"
}
+24
View File
@@ -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>
+94
View File
@@ -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;
}
+71
View File
@@ -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)
})
}
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "立即报名"
}
+12
View File
@@ -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>
+52
View File
@@ -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;
}
+56
View File
@@ -0,0 +1,56 @@
{
"description": "卓米会小程序",
"packOptions": {
"ignore": [],
"include": []
},
"setting": {
"es6": true,
"enhance": false,
"postcss": true,
"minified": true,
"newFeature": false,
"coverView": true,
"nodeModules": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"scopeDataCheck": false,
"uglifyFileName": false,
"checkInvalidKey": true,
"checkSiteMap": true,
"uploadWithSourceMap": true,
"compileHotReLoad": true,
"lazyloadPlaceholderEnable": false,
"useMultiFrameRuntime": true,
"useApiHook": true,
"useApiHostProcess": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"useIsolateContext": true,
"userConfirmedBundleSwitch": false,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true,
"compileWorklet": false
},
"compileType": "miniprogram",
"libVersion": "3.8.10",
"appid": "wx5f75e1013b7b5a31",
"projectname": "zhuomi-mp",
"condition": {},
"editorSetting": {
"tabIndent": "insertSpaces",
"tabSize": 2
},
"simulatorPluginLibVersion": {}
}
+4
View File
@@ -0,0 +1,4 @@
{
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
"rules": [{ "action": "allow", "page": "*" }]
}
+44
View File
@@ -0,0 +1,44 @@
const media = require('./media')
function openMap(activity) {
if (!activity || activity.latitude == null || activity.longitude == null) {
wx.showToast({ title: '暂无会场坐标', icon: 'none' })
return
}
wx.openLocation({
latitude: Number(activity.latitude),
longitude: Number(activity.longitude),
name: activity.title,
address: activity.address,
scale: 16
})
}
function goSignup(activity) {
if (!activity || !activity.id) return
wx.navigateTo({ url: '/pages/signup/signup?id=' + activity.id })
}
// Shared links always land on the detail page and carry the sharer's openid for attribution.
function shareMessage(activity, fallbackId) {
const item = activity || {}
const openid = wx.getStorageSync('openid') || ''
const cover = item.poster || item.cover
const message = {
title: item.title || '卓米会',
path: '/pages/detail/detail?id=' + (item.id || fallbackId || '') + '&sharer=' + openid
}
if (cover) message.imageUrl = media.absUrl(cover)
return message
}
function toast(error) {
wx.showToast({ title: (error && error.message) || '操作失败', icon: 'none' })
}
module.exports = {
openMap: openMap,
goSignup: goSignup,
shareMessage: shareMessage,
toast: toast
}
+42
View File
@@ -0,0 +1,42 @@
const requestUtil = require('./request')
const authUtil = require('./auth')
// Runs a logged-in call; if the stored token has expired, logs in again and retries once.
function withLogin(run) {
return authUtil
.ensureLogin()
.then(run)
.catch((error) => {
if (error.statusCode !== 401) throw error
return authUtil.ensureLogin().then(run)
})
}
function authed(url, method, data) {
return withLogin(() =>
requestUtil.request({ url: url, method: method || 'GET', data: data, auth: true })
)
}
function uploadTo(url, filePath) {
return withLogin(() => requestUtil.upload({ url: url, filePath: filePath }))
}
module.exports = {
currentActivity: () => requestUtil.request({ url: '/api/activity/current' }),
pastActivities: () => requestUtil.request({ url: '/api/activity/past' }),
activity: (id) => requestUtil.request({ url: '/api/activity/' + id }),
profile: () => authed('/api/user/profile'),
saveProfile: (data) => authed('/api/user/profile', 'POST', data),
uploadAvatar: (filePath) => uploadTo('/api/user/avatar', filePath),
createPast: (data) => authed('/api/activity/past', 'POST', data),
pinPast: (id, pinned) => authed('/api/activity/past/' + id + '/pin', 'POST', { pinned: pinned }),
removePast: (id) => authed('/api/activity/past/' + id, 'DELETE'),
uploadMedia: (filePath) => uploadTo('/api/upload', filePath),
signup: (data) => authed('/api/signup', 'POST', data),
mySignups: () => authed('/api/signup/mine'),
checkin: (data) => authed('/api/checkin/scan', 'POST', data)
}
+75
View File
@@ -0,0 +1,75 @@
const requestUtil = require('./request')
let pending = null
function deviceId() {
let id = wx.getStorageSync('deviceId')
if (!id) {
id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
wx.setStorageSync('deviceId', id)
}
return id
}
function captureSharer(query) {
if (!query || !query.sharer) return
const selfId = wx.getStorageSync('openid')
if (query.sharer !== selfId) wx.setStorageSync('sharer', query.sharer)
}
function login() {
return new Promise((resolve, reject) => {
wx.login({
success(res) {
if (!res.code) {
reject(new Error('微信登录未返回 code'))
return
}
requestUtil
.request({
url: '/api/login',
method: 'POST',
data: { code: res.code, deviceId: deviceId() }
})
.then((data) => {
wx.setStorageSync('token', data.token)
wx.setStorageSync('openid', data.openid)
wx.setStorageSync('loginDemo', !!data.demo)
resolve(data)
})
.catch(reject)
},
fail() {
reject(new Error('微信登录失败'))
}
})
})
}
function ensureLogin() {
if (wx.getStorageSync('token')) {
return Promise.resolve({
token: wx.getStorageSync('token'),
openid: wx.getStorageSync('openid')
})
}
if (!pending) {
pending = login().finally(() => {
pending = null
})
}
return pending
}
function sourceSharer() {
const sharer = wx.getStorageSync('sharer') || ''
const openid = wx.getStorageSync('openid') || ''
if (!sharer || sharer === openid) return ''
return sharer
}
module.exports = {
captureSharer: captureSharer,
ensureLogin: ensureLogin,
sourceSharer: sourceSharer
}
+15
View File
@@ -0,0 +1,15 @@
const CURRENT = {
id: '93',
title: '2026年卓米第93期卓米会',
brandName: '卓米会 ZHUOMI',
brandDesc: '专注于品牌增长与企业服务的共创平台,连接资源、方法与同行者。',
startAt: '2026-06-18 14:00',
address: '上海市浦东新区世纪大道 100 号 卓米中心 3F',
latitude: 31.235929,
longitude: 121.506321,
status: 'current',
isCurrent: true,
poster: ''
}
module.exports = { CURRENT }
+9
View File
@@ -0,0 +1,9 @@
const config = require('../config')
function absUrl(url) {
if (!url) return ''
if (/^https?:\/\//.test(url)) return url
return config.BASE + url
}
module.exports = { absUrl: absUrl }
+75
View File
@@ -0,0 +1,75 @@
const config = require('../config')
function networkError(err) {
const msg = (err && err.errMsg) || ''
if (msg.indexOf('url not in domain list') >= 0 || msg.indexOf('合法域名') >= 0) {
return new Error('请勾选:不校验合法域名')
}
return new Error('网络异常,请确认后端已启动')
}
function apiError(statusCode, body) {
const error = new Error((body && body.message) || '请求失败')
error.statusCode = statusCode
return error
}
function authHeader(header) {
const token = wx.getStorageSync('token')
if (token) header.Authorization = token
return header
}
function handle(statusCode, body, auth, resolve, reject) {
if (statusCode === 401 && auth) wx.removeStorageSync('token')
if (statusCode >= 200 && statusCode < 300 && body && body.ok !== false) {
resolve(body.data)
return
}
reject(apiError(statusCode, body))
}
function request(options) {
const auth = !!options.auth
const header = { 'content-type': 'application/json' }
return new Promise((resolve, reject) => {
wx.request({
url: config.BASE + options.url,
method: options.method || 'GET',
data: options.data,
header: auth ? authHeader(header) : header,
success(res) {
handle(res.statusCode, res.data || {}, auth, resolve, reject)
},
fail(err) {
reject(networkError(err))
}
})
})
}
function upload(options) {
return new Promise((resolve, reject) => {
wx.uploadFile({
url: config.BASE + options.url,
filePath: options.filePath,
name: 'file',
header: authHeader({}),
success(res) {
let body = null
try {
body = JSON.parse(res.data || '{}')
} catch (error) {
reject(new Error('上传返回异常'))
return
}
handle(res.statusCode, body, true, resolve, reject)
},
fail(err) {
reject(networkError(err))
}
})
})
}
module.exports = { request: request, upload: upload }