From 0ca2e87bf26f798ad6b8b46c56d0e714d7e8eeb7 Mon Sep 17 00:00:00 2001 From: Michelle Zan Date: Thu, 8 Oct 2026 13:06:43 +0800 Subject: [PATCH] Ship signup sheet, subscribe reminders, cover crop, and theme updates. Wire real WeChat app config, prefer activity cover over posters, and add admin cover cropping for the home banner. Co-authored-by: Cursor --- .gitignore | 2 + README.md | 2 + miniprogram/app.json | 4 +- .../components/activity-board/index.js | 2 +- miniprogram/components/signup-sheet/index.js | 158 +++++++++++ .../components/signup-sheet/index.json | 3 + .../components/signup-sheet/index.wxml | 77 ++++++ .../components/signup-sheet/index.wxss | 144 ++++++++++ miniprogram/config.js | 6 +- miniprogram/pages/detail/detail.js | 12 +- miniprogram/pages/detail/detail.json | 3 +- miniprogram/pages/detail/detail.wxml | 20 +- miniprogram/pages/index/index.js | 13 +- miniprogram/pages/index/index.json | 3 +- miniprogram/pages/index/index.wxml | 7 +- miniprogram/pages/past-edit/past-edit.js | 18 ++ miniprogram/pages/past/past.js | 4 +- miniprogram/pages/past/past.wxml | 5 + miniprogram/pages/past/past.wxss | 25 +- miniprogram/pages/signup/signup.js | 66 +---- miniprogram/pages/signup/signup.json | 5 +- miniprogram/pages/signup/signup.wxml | 18 +- miniprogram/pages/signup/signup.wxss | 53 +--- miniprogram/project.config.json | 5 +- miniprogram/utils/activity.js | 2 +- miniprogram/utils/api.js | 3 +- server/.env.example | 4 + server/lib/db.js | 16 +- server/lib/serialize.js | 3 +- server/lib/wechat.js | 78 +++++- server/public/admin.html | 261 ++++++++++++++++-- server/routes/admin.js | 68 ++++- server/routes/public.js | 15 + server/server.js | 30 ++ server/test/api.test.js | 17 +- 35 files changed, 968 insertions(+), 184 deletions(-) create mode 100644 miniprogram/components/signup-sheet/index.js create mode 100644 miniprogram/components/signup-sheet/index.json create mode 100644 miniprogram/components/signup-sheet/index.wxml create mode 100644 miniprogram/components/signup-sheet/index.wxss create mode 100644 server/.env.example diff --git a/.gitignore b/.gitignore index a4002c1..34e961f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ server/public/uploads/ server/data/db.json server/data/*.tmp .env +server/.env .DS_Store + diff --git a/README.md b/README.md index 0da53c5..e75952d 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ npm test # 接口冒烟测试(使用临时数据库,不影响真 | --- | --- | | `PORT` | 端口,默认 3000 | | `WX_APPID` / `WX_SECRET` | 配置后走真实 `code2session` 登录;不配置则为演示登录 | +| `WX_SUBSCRIBE_TMPL_ID` | 可选,订阅消息模板 ID(也可在管理后台「提醒设置」里填) | +| `WX_MINI_STATE` | 订阅消息跳转版本:`formal` / `trial` / `developer`,默认 formal | | `DB_FILE` | 数据文件路径,默认 `server/data/db.json` | | `UPLOAD_DIR` | 上传目录,默认 `server/public/uploads` | diff --git a/miniprogram/app.json b/miniprogram/app.json index 180669b..a976613 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -9,9 +9,9 @@ "pages/detail/detail" ], "window": { - "navigationBarBackgroundColor": "#FFFFFF", + "navigationBarBackgroundColor": "#0D8B87", "navigationBarTitleText": "卓米会", - "navigationBarTextStyle": "black", + "navigationBarTextStyle": "white", "backgroundColor": "#F3F5F7", "backgroundTextStyle": "dark" }, diff --git a/miniprogram/components/activity-board/index.js b/miniprogram/components/activity-board/index.js index 938b096..d2ee5cd 100644 --- a/miniprogram/components/activity-board/index.js +++ b/miniprogram/components/activity-board/index.js @@ -20,7 +20,7 @@ Component({ activity(activity) { const item = activity || {} this.setData({ - poster: media.absUrl(item.poster || item.cover), + poster: media.absUrl(item.cover || item.poster), video: media.absUrl(item.video), ended: item.status === 'past' }) diff --git a/miniprogram/components/signup-sheet/index.js b/miniprogram/components/signup-sheet/index.js new file mode 100644 index 0000000..f63e3df --- /dev/null +++ b/miniprogram/components/signup-sheet/index.js @@ -0,0 +1,158 @@ +const api = require('../../utils/api') +const authUtil = require('../../utils/auth') +const activityUtil = require('../../utils/activity') + +function requestRemind(tmplIds) { + const ids = (tmplIds || []).filter(Boolean).slice(0, 3) + if (!ids.length) { + return Promise.resolve({ status: 'none', enabled: false }) + } + return new Promise((resolve) => { + wx.requestSubscribeMessage({ + tmplIds: ids, + success(res) { + // res[tmplId] === 'accept' | 'reject' | 'ban' | 'filter' + let status = 'reject' + for (let i = 0; i < ids.length; i += 1) { + if (res[ids[i]] === 'accept') { + status = 'accept' + break + } + if (res[ids[i]]) status = res[ids[i]] + } + resolve({ status: status, enabled: true }) + }, + fail() { + resolve({ status: 'reject', enabled: true }) + } + }) + }) +} + +Component({ + properties: { + show: { + type: Boolean, + value: false + }, + activityId: { + type: String, + value: '' + } + }, + data: { + name: '', + phone: '', + company: '', + position: '', + submitting: false, + tmplIds: [], + remindReady: false + }, + observers: { + show(visible) { + if (visible) { + wx.hideTabBar({ animation: false }) + this.prefetch() + } else { + wx.showTabBar({ animation: false }) + } + } + }, + lifetimes: { + detached() { + wx.showTabBar({ animation: false }) + } + }, + methods: { + prefetch() { + api + .subscribeConfig() + .then((config) => { + this.setData({ + tmplIds: (config && config.tmplIds) || [], + remindReady: !!(config && config.enabled) + }) + }) + .catch(() => this.setData({ tmplIds: [], remindReady: false })) + + 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 || '', + position: this.data.position || profile.position || '' + }) + }) + .catch(() => {}) + }, + + noop() {}, + + onInput(event) { + const field = event.currentTarget.dataset.field + const next = {} + next[field] = event.detail.value + this.setData(next) + }, + + onCancel() { + if (this.data.submitting) return + wx.showTabBar({ animation: false }) + this.triggerEvent('close') + }, + + onSubmit() { + if (this.data.submitting) return + const name = (this.data.name || '').trim() + const phone = (this.data.phone || '').trim() + const company = (this.data.company || '').trim() + const position = (this.data.position || '').trim() + if (!name || !phone) { + wx.showToast({ title: '请填写姓名和手机号', icon: 'none' }) + return + } + if (!/^1\d{10}$/.test(phone)) { + wx.showToast({ title: '请填写正确的手机号', icon: 'none' }) + return + } + if (!this.data.activityId) { + wx.showToast({ title: '活动不存在', icon: 'none' }) + return + } + + this.setData({ submitting: true }) + // Must run inside the tap gesture so WeChat can show the subscribe dialog. + requestRemind(this.data.tmplIds) + .then((remind) => + api.signup({ + activityId: this.data.activityId, + name: name, + phone: phone, + company: company, + position: position, + sourceSharer: authUtil.sourceSharer(), + remindStatus: remind.status + }).then((signup) => ({ signup: signup, remind: remind })) + ) + .then((result) => { + const accepted = result.remind && result.remind.status === 'accept' + wx.showToast({ + title: accepted ? '报名成功,已开启提醒' : '报名成功', + icon: 'success' + }) + this.setData({ submitting: false }) + wx.showTabBar({ animation: false }) + this.triggerEvent('success') + this.triggerEvent('close') + }) + .catch((error) => { + this.setData({ submitting: false }) + activityUtil.toast(error) + }) + } + } +}) diff --git a/miniprogram/components/signup-sheet/index.json b/miniprogram/components/signup-sheet/index.json new file mode 100644 index 0000000..467ce29 --- /dev/null +++ b/miniprogram/components/signup-sheet/index.json @@ -0,0 +1,3 @@ +{ + "component": true +} diff --git a/miniprogram/components/signup-sheet/index.wxml b/miniprogram/components/signup-sheet/index.wxml new file mode 100644 index 0000000..f704b08 --- /dev/null +++ b/miniprogram/components/signup-sheet/index.wxml @@ -0,0 +1,77 @@ + + + + + 活动报名 + 填写信息提交后,将推送微信服务通知 + + + 📣 + {{remindReady ? '提交后将申请活动提醒授权,同意即可在活动开始前收到微信通知' : '提交即视为同意接收活动提醒(管理员需先在后台配置订阅消息模板)'}} + + + + + 姓名 + * + + + + + + + 手机号码 + * + + + + + + 公司名称 + + + + + 职位 + + + + + {{submitting ? '提交中…' : '提交报名'}} + + 取消 + + diff --git a/miniprogram/components/signup-sheet/index.wxss b/miniprogram/components/signup-sheet/index.wxss new file mode 100644 index 0000000..9dac793 --- /dev/null +++ b/miniprogram/components/signup-sheet/index.wxss @@ -0,0 +1,144 @@ +.root { + position: fixed; + left: 0; + right: 0; + top: 0; + bottom: 0; + z-index: 1000; +} + +.mask { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.45); +} + +.sheet { + position: absolute; + left: 0; + right: 0; + bottom: 0; + max-height: 92vh; + padding: 16rpx 40rpx calc(40rpx + env(safe-area-inset-bottom)); + box-sizing: border-box; + background: #ffffff; + border-radius: 32rpx 32rpx 0 0; + overflow-y: auto; +} + +.handle { + width: 64rpx; + height: 8rpx; + margin: 0 auto 28rpx; + border-radius: 4rpx; + background: #e5e7eb; +} + +.title { + display: block; + color: #111827; + font-size: 40rpx; + font-weight: 700; + line-height: 1.3; +} + +.subtitle { + display: block; + margin-top: 12rpx; + color: #9ca3af; + font-size: 26rpx; + line-height: 1.5; +} + +.notice { + display: flex; + align-items: flex-start; + gap: 12rpx; + margin-top: 28rpx; + padding: 20rpx 24rpx; + border-radius: 16rpx; + background: #e8f7f4; +} + +.notice-icon { + flex-shrink: 0; + font-size: 28rpx; + line-height: 1.45; +} + +.notice-text { + flex: 1; + color: #0d8b87; + font-size: 24rpx; + line-height: 1.55; +} + +.field { + margin-top: 28rpx; +} + +.label-row { + display: flex; + align-items: center; + gap: 4rpx; +} + +.label { + display: block; + color: #374151; + font-size: 28rpx; + font-weight: 500; +} + +.required { + color: #ef4444; + font-size: 28rpx; + line-height: 1; +} + +.input { + height: 88rpx; + margin-top: 14rpx; + padding: 0 28rpx; + border: 2rpx solid #e5e7eb; + border-radius: 16rpx; + box-sizing: border-box; + font-size: 30rpx; + color: #111827; + background: #ffffff; +} + +.placeholder { + color: #c4c9d1; +} + +.btn { + height: 96rpx; + margin-top: 24rpx; + border-radius: 20rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 32rpx; + font-weight: 600; + box-sizing: border-box; +} + +.btn.primary { + margin-top: 40rpx; + background: #0d8b87; + color: #ffffff; +} + +.btn.primary.busy { + opacity: 0.7; +} + +.btn.ghost { + background: #ffffff; + color: #0d8b87; + border: 2rpx solid #0d8b87; +} diff --git a/miniprogram/config.js b/miniprogram/config.js index 35c15b6..2fe7fa0 100644 --- a/miniprogram/config.js +++ b/miniprogram/config.js @@ -1,4 +1,4 @@ -/** 后端地址。上线改为 HTTPS 域名,并加入小程序 request / downloadFile 合法域名。 */ -const BASE = 'http://127.0.0.1:3000' +/** 后端地址。上线域名需加入小程序 request / uploadFile / downloadFile 合法域名。 */ +const BASE = 'https://dev.zmh.zm.luckypay.cc' -module.exports = { BASE } +module.exports = { BASE: BASE } diff --git a/miniprogram/pages/detail/detail.js b/miniprogram/pages/detail/detail.js index 15ded1f..b0f844e 100644 --- a/miniprogram/pages/detail/detail.js +++ b/miniprogram/pages/detail/detail.js @@ -5,7 +5,8 @@ const activityUtil = require('../../utils/activity') Page({ data: { activity: null, - failed: false + failed: false, + signupOpen: false }, onLoad(query) { @@ -36,8 +37,13 @@ Page({ activityUtil.openMap(this.data.activity) }, - goSignup() { - activityUtil.goSignup(this.data.activity) + openSignup() { + if (!this.data.activity || this.data.activity.status === 'past') return + this.setData({ signupOpen: true }) + }, + + closeSignup() { + this.setData({ signupOpen: false }) }, goHome() { diff --git a/miniprogram/pages/detail/detail.json b/miniprogram/pages/detail/detail.json index 265e0d3..62b3e51 100644 --- a/miniprogram/pages/detail/detail.json +++ b/miniprogram/pages/detail/detail.json @@ -1,6 +1,7 @@ { "navigationBarTitleText": "卓米会", "usingComponents": { - "activity-board": "/components/activity-board/index" + "activity-board": "/components/activity-board/index", + "signup-sheet": "/components/signup-sheet/index" } } diff --git a/miniprogram/pages/detail/detail.wxml b/miniprogram/pages/detail/detail.wxml index d317b1f..645ceec 100644 --- a/miniprogram/pages/detail/detail.wxml +++ b/miniprogram/pages/detail/detail.wxml @@ -1,10 +1,16 @@ - + + + + 活动不存在或已下线 回到首页 diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index a6883c9..8ac7fae 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -5,7 +5,8 @@ const fallback = require('../../utils/fallback') Page({ data: { - activity: fallback.CURRENT + activity: fallback.CURRENT, + signupOpen: false }, onLoad(query) { @@ -34,8 +35,14 @@ Page({ activityUtil.openMap(this.data.activity) }, - goSignup() { - activityUtil.goSignup(this.data.activity) + openSignup() { + if (!this.data.activity || !this.data.activity.id) return + if (this.data.activity.status === 'past') return + this.setData({ signupOpen: true }) + }, + + closeSignup() { + this.setData({ signupOpen: false }) }, goPast() { diff --git a/miniprogram/pages/index/index.json b/miniprogram/pages/index/index.json index fe6bbba..7bf7a15 100644 --- a/miniprogram/pages/index/index.json +++ b/miniprogram/pages/index/index.json @@ -2,6 +2,7 @@ "navigationBarTitleText": "卓米会", "enablePullDownRefresh": true, "usingComponents": { - "activity-board": "/components/activity-board/index" + "activity-board": "/components/activity-board/index", + "signup-sheet": "/components/signup-sheet/index" } } diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml index 8a5f313..6dc2e70 100644 --- a/miniprogram/pages/index/index.wxml +++ b/miniprogram/pages/index/index.wxml @@ -2,6 +2,11 @@ activity="{{activity}}" show-past="{{true}}" bind:map="openMap" - bind:signup="goSignup" + bind:signup="openSignup" bind:past="goPast" /> + diff --git a/miniprogram/pages/past-edit/past-edit.js b/miniprogram/pages/past-edit/past-edit.js index 9f74eb5..43a2a55 100644 --- a/miniprogram/pages/past-edit/past-edit.js +++ b/miniprogram/pages/past-edit/past-edit.js @@ -12,12 +12,29 @@ function chooseOne(mediaType) { success: (res) => { const file = res.tempFiles && res.tempFiles[0] if (file && file.tempFilePath) resolve(file.tempFilePath) + else reject(new Error('未选择文件')) }, fail: reject }) }) } +// Crop to a wide banner ratio before upload (closest built-in scale is 16:9). +function cropCover(filePath) { + return new Promise((resolve, reject) => { + if (!wx.cropImage) { + resolve(filePath) + return + } + wx.cropImage({ + src: filePath, + cropScale: '16:9', + success: (res) => resolve(res.tempFilePath || filePath), + fail: () => resolve(filePath) + }) + }) +} + function uploadWithLoading(filePath) { wx.showLoading({ title: '上传中', mask: true }) return api.uploadMedia(filePath).then( @@ -71,6 +88,7 @@ Page({ pickCover() { chooseOne('image') + .then(cropCover) .then(uploadWithLoading) .then((data) => this.setData({ cover: data.url, coverPreview: media.absUrl(data.url) })) .catch((error) => { diff --git a/miniprogram/pages/past/past.js b/miniprogram/pages/past/past.js index 647beb3..c9bb07c 100644 --- a/miniprogram/pages/past/past.js +++ b/miniprogram/pages/past/past.js @@ -4,7 +4,9 @@ const activityUtil = require('../../utils/activity') function decorate(item) { const cover = item.cover || item.poster || '' - return Object.assign({}, item, { coverUrl: cover ? media.absUrl(cover) : '' }) + return Object.assign({}, item, { + coverUrl: cover ? media.absUrl(cover) : '' + }) } Page({ diff --git a/miniprogram/pages/past/past.wxml b/miniprogram/pages/past/past.wxml index 3eb2e57..0a2e8c4 100644 --- a/miniprogram/pages/past/past.wxml +++ b/miniprogram/pages/past/past.wxml @@ -4,6 +4,11 @@ {{deleting ? '完成' : '删除'}} + + + 往期活动 + + 还没有往期活动 { - if (!profile) return - this.setData({ - name: this.data.name || profile.name || '', - phone: this.data.phone || profile.phone || '', - company: this.data.company || profile.company || '' - }) - }) - .catch(() => {}) + this.setData({ activityId: (query && query.id) || '' }) }, - onName(event) { - this.setData({ name: event.detail.value }) + onClose() { + wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/index/index' }) }) }, - 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) - }) - } + onSuccess() {} }) diff --git a/miniprogram/pages/signup/signup.json b/miniprogram/pages/signup/signup.json index 79115fd..fdee5b1 100644 --- a/miniprogram/pages/signup/signup.json +++ b/miniprogram/pages/signup/signup.json @@ -1,3 +1,6 @@ { - "navigationBarTitleText": "立即报名" + "navigationBarTitleText": "活动报名", + "usingComponents": { + "signup-sheet": "/components/signup-sheet/index" + } } diff --git a/miniprogram/pages/signup/signup.wxml b/miniprogram/pages/signup/signup.wxml index 622227a..6c9f0be 100644 --- a/miniprogram/pages/signup/signup.wxml +++ b/miniprogram/pages/signup/signup.wxml @@ -1,12 +1,6 @@ - - - 姓名 - - 手机号 - - 公司 / 机构 - - - {{submitting ? '提交中…' : '提交报名'}} - 手机号仅用于本次活动通知。 - + diff --git a/miniprogram/pages/signup/signup.wxss b/miniprogram/pages/signup/signup.wxss index 73073bc..0047815 100644 --- a/miniprogram/pages/signup/signup.wxss +++ b/miniprogram/pages/signup/signup.wxss @@ -1,52 +1,3 @@ -.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; +page { + background: transparent; } diff --git a/miniprogram/project.config.json b/miniprogram/project.config.json index 60479af..74655f6 100644 --- a/miniprogram/project.config.json +++ b/miniprogram/project.config.json @@ -45,12 +45,13 @@ }, "compileType": "miniprogram", "libVersion": "3.8.10", - "appid": "wx5f75e1013b7b5a31", + "appid": "wxf84a8a74ac7c1a7d", "projectname": "zhuomi-mp", "condition": {}, "editorSetting": { "tabIndent": "insertSpaces", "tabSize": 2 }, - "simulatorPluginLibVersion": {} + "simulatorPluginLibVersion": {}, + "isGameTourist": false } \ No newline at end of file diff --git a/miniprogram/utils/activity.js b/miniprogram/utils/activity.js index f5e4328..435f59b 100644 --- a/miniprogram/utils/activity.js +++ b/miniprogram/utils/activity.js @@ -23,7 +23,7 @@ function goSignup(activity) { function shareMessage(activity, fallbackId) { const item = activity || {} const openid = wx.getStorageSync('openid') || '' - const cover = item.poster || item.cover + const cover = item.cover || item.poster const message = { title: item.title || '卓米会', path: '/pages/detail/detail?id=' + (item.id || fallbackId || '') + '&sharer=' + openid diff --git a/miniprogram/utils/api.js b/miniprogram/utils/api.js index 2c81a7c..c1ee34b 100644 --- a/miniprogram/utils/api.js +++ b/miniprogram/utils/api.js @@ -38,5 +38,6 @@ module.exports = { signup: (data) => authed('/api/signup', 'POST', data), mySignups: () => authed('/api/signup/mine'), - checkin: (data) => authed('/api/checkin/scan', 'POST', data) + checkin: (data) => authed('/api/checkin/scan', 'POST', data), + subscribeConfig: () => requestUtil.request({ url: '/api/subscribe/config' }) } diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..9b1a18b --- /dev/null +++ b/server/.env.example @@ -0,0 +1,4 @@ +WX_APPID=你的小程序AppID +WX_SECRET=你的小程序AppSecret +# WX_SUBSCRIBE_TMPL_ID=订阅消息模板ID +# PORT=3000 diff --git a/server/lib/db.js b/server/lib/db.js index 17a8d42..fe4402c 100644 --- a/server/lib/db.js +++ b/server/lib/db.js @@ -33,7 +33,11 @@ const SEED = { ], posters: [], signups: [], - checkinCodes: [] + checkinCodes: [], + settings: { + // 微信公众平台 → 订阅消息 → 选用「活动提醒」类模板后填入模板 ID + subscribeTmplId: process.env.WX_SUBSCRIBE_TMPL_ID || '' + } } const COLLECTIONS = ['users', 'admins', 'adminAllowlist', 'activities', 'posters', 'signups', 'checkinCodes'] @@ -49,6 +53,16 @@ function normalize(data) { dirty = true } }) + if (!data.settings || typeof data.settings !== 'object') { + data.settings = { subscribeTmplId: process.env.WX_SUBSCRIBE_TMPL_ID || '' } + dirty = true + } else if (typeof data.settings.subscribeTmplId !== 'string') { + data.settings.subscribeTmplId = process.env.WX_SUBSCRIBE_TMPL_ID || '' + dirty = true + } else if (!data.settings.subscribeTmplId && process.env.WX_SUBSCRIBE_TMPL_ID) { + data.settings.subscribeTmplId = process.env.WX_SUBSCRIBE_TMPL_ID + dirty = true + } // The allowlist is the only source of truth for mini-program admins. data.users.forEach((user) => { diff --git a/server/lib/serialize.js b/server/lib/serialize.js index 2e08a20..a4503d8 100644 --- a/server/lib/serialize.js +++ b/server/lib/serialize.js @@ -24,7 +24,8 @@ function publicActivity(activity, posters) { theme: activity.theme || 0, cover: activity.cover || '', video: activity.video || '', - poster: (poster && poster.image) || activity.cover || '', + // Activity cover wins over linked posters so admin “封面图片” updates show immediately. + poster: activity.cover || (poster && poster.image) || '', createdAt: activity.createdAt } } diff --git a/server/lib/wechat.js b/server/lib/wechat.js index 8827ff2..c9d82a2 100644 --- a/server/lib/wechat.js +++ b/server/lib/wechat.js @@ -1,10 +1,14 @@ const crypto = require('crypto') const { HttpError } = require('./http') +let tokenCache = null + +function wxConfigured() { + return !!(process.env.WX_APPID && process.env.WX_SECRET) +} + async function code2session(code, deviceId) { - const appid = process.env.WX_APPID - const secret = process.env.WX_SECRET - if (!appid || !secret) { + if (!wxConfigured()) { const seed = deviceId || 'guest' return { openid: `demo_${crypto.createHash('sha1').update(seed).digest('hex').slice(0, 16)}`, @@ -14,8 +18,8 @@ async function code2session(code, deviceId) { const url = new URL('https://api.weixin.qq.com/sns/jscode2session') url.search = new URLSearchParams({ - appid, - secret, + appid: process.env.WX_APPID, + secret: process.env.WX_SECRET, js_code: code, grant_type: 'authorization_code' }).toString() @@ -31,4 +35,66 @@ async function code2session(code, deviceId) { return { openid: session.openid, demo: false } } -module.exports = { code2session } +async function getAccessToken() { + if (!wxConfigured()) throw new HttpError(400, '未配置 WX_APPID / WX_SECRET,无法发送订阅消息') + if (tokenCache && tokenCache.expiresAt > Date.now() + 60_000) return tokenCache.token + + const url = new URL('https://api.weixin.qq.com/cgi-bin/token') + url.search = new URLSearchParams({ + grant_type: 'client_credential', + appid: process.env.WX_APPID, + secret: process.env.WX_SECRET + }).toString() + + const res = await fetch(url, { signal: AbortSignal.timeout(8000) }) + const body = await res.json() + if (!body.access_token) throw new HttpError(502, body.errmsg || '获取微信 access_token 失败') + tokenCache = { + token: body.access_token, + expiresAt: Date.now() + (Number(body.expires_in) || 7200) * 1000 + } + return tokenCache.token +} + +function clip(value, max) { + const text = value == null ? '' : String(value) + return text.length > max ? text.slice(0, max) : text +} + +// Default field mapping for a typical “活动提醒” template (thing / time / thing). +function buildRemindData(activity) { + return { + thing1: { value: clip(activity.title || '卓米会活动', 20) }, + time2: { value: clip(activity.startAt || '待定', 20) }, + thing3: { value: clip(activity.address || '详见活动页', 20) } + } +} + +async function sendSubscribeMessage({ openid, templateId, page, data }) { + const token = await getAccessToken() + const res = await fetch( + `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${encodeURIComponent(token)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + touser: openid, + template_id: templateId, + page: page || '', + data, + miniprogram_state: process.env.WX_MINI_STATE || 'formal' + }), + signal: AbortSignal.timeout(8000) + } + ) + const body = await res.json() + return { ok: body.errcode === 0, errcode: body.errcode, errmsg: body.errmsg || '' } +} + +module.exports = { + wxConfigured, + code2session, + getAccessToken, + buildRemindData, + sendSubscribeMessage +} diff --git a/server/public/admin.html b/server/public/admin.html index 3e240e5..452a4fd 100644 --- a/server/public/admin.html +++ b/server/public/admin.html @@ -35,10 +35,51 @@ .split { display: grid; grid-template-columns: 1.1fr .9fr; gap: 16px; } .error { color: #dc2626; min-height: 1.2em; font-size: 13px; } img.qr { width: 140px; height: 140px; } + .crop-modal { + position: fixed; inset: 0; z-index: 50; display: none; + align-items: center; justify-content: center; + background: rgba(15, 23, 42, 0.55); padding: 16px; + } + .crop-modal.on { display: flex; } + .crop-panel { + width: min(720px, 100%); background: #fff; border-radius: 16px; + padding: 16px; max-height: 92vh; overflow: auto; + } + .crop-stage { + position: relative; width: 100%; height: min(52vh, 420px); + background: #111827; border-radius: 12px; overflow: hidden; + touch-action: none; user-select: none; + } + .crop-stage img { + position: absolute; left: 0; top: 0; + max-width: none; cursor: grab; + } + .crop-mask { + position: absolute; inset: 0; pointer-events: none; + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45); + border: 2px solid #fff; border-radius: 4px; + } @media (max-width: 800px) { .grid, .split { grid-template-columns: 1fr; } } +
+
+ 裁剪封面 +

拖动图片选择区域,比例已锁定为小程序首页封面(约 2.3:1)。裁剪后再上传,预览就不会被裁掉重要内容。

+
+ 裁剪预览 +
+
+
+ + + + +
+
+
+